From 8f181490e2fd4d434e1d4090886792f595036685 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Fri, 10 Feb 2023 19:34:45 +0400 Subject: [PATCH] Improve media playback rate controls --- .../Telegram-iOS/en.lproj/Localizable.strings | 13 +- submodules/GalleryUI/BUILD | 1 + .../Items/UniversalVideoGalleryItem.swift | 106 +++++----- .../ImportStickerPackControllerNode.swift | 28 ++- .../Sources/MediaPlaybackStoredState.swift | 2 +- .../Sources/SemanticStatusNode.swift | 6 + submodules/TelegramBaseController/BUILD | 3 + .../MediaNavigationAccessoryHeaderNode.swift | 115 +++++------ .../Components/SliderContextItem/BUILD | 22 +++ .../Sources/SliderContextItem.swift | 184 ++++++++++++++++++ .../Sources/OverlayPlayerControlsNode.swift | 8 - .../Sources/MusicPlaybackSettings.swift | 73 +++++-- 12 files changed, 416 insertions(+), 145 deletions(-) create mode 100644 submodules/TelegramUI/Components/SliderContextItem/BUILD create mode 100644 submodules/TelegramUI/Components/SliderContextItem/Sources/SliderContextItem.swift diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index e97cea1fcc..afa80d1900 100644 --- a/Telegram/Telegram-iOS/en.lproj/Localizable.strings +++ b/Telegram/Telegram-iOS/en.lproj/Localizable.strings @@ -8911,10 +8911,11 @@ Sorry for the inconvenience."; "ChatList.EmptyChatListWithArchive" = "All of your chats are archived."; -"VoiceOver.Media.PlaybackRate05X" = "0.5X"; -"VoiceOver.Media.PlaybackRate125X" = "1.25X"; -"VoiceOver.Media.PlaybackRate15X" = "1.5X"; -"VoiceOver.Media.PlaybackRate175X" = "1.75X"; -"VoiceOver.Media.PlaybackRate2X" = "2X"; - "Conversation.AudioRateTooltip15X" = "Audio will play at 1.5X speed."; +"Conversation.AudioRateOptionsTooltip" = "Long tap for more speed values."; + +"ImportStickerPack.EmojiCount_1" = "%@ Emoji"; +"ImportStickerPack.EmojiCount_any" = "%@ Emojis"; + +"ImportStickerPack.ImportingEmojis" = "Importing Emojis"; +"ImportStickerPack.CreateNewEmojiPack" = "Create a New Emoji Pack"; diff --git a/submodules/GalleryUI/BUILD b/submodules/GalleryUI/BUILD index ba2b6e0711..82b0612031 100644 --- a/submodules/GalleryUI/BUILD +++ b/submodules/GalleryUI/BUILD @@ -45,6 +45,7 @@ swift_library( "//submodules/TelegramUI/Components/TextNodeWithEntities:TextNodeWithEntities", "//submodules/TelegramUI/Components/AnimationCache:AnimationCache", "//submodules/TelegramUI/Components/MultiAnimationRenderer:MultiAnimationRenderer", + "//submodules/TelegramUI/Components/SliderContextItem:SliderContextItem", ], visibility = [ "//visibility:public", diff --git a/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift b/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift index d8dc50ca3d..e0481a0677 100644 --- a/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift +++ b/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift @@ -22,6 +22,7 @@ import TelegramUIPreferences import OpenInExternalAppUI import AVKit import TextFormat +import SliderContextItem public enum UniversalVideoGalleryItemContentInfo { case message(Message) @@ -1267,17 +1268,11 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { strongSelf.moreBarButtonRateTimestamp = CFAbsoluteTimeGetCurrent() if abs(effectiveBaseRate - 1.0) > 0.01 { - let rateString: String - if abs(effectiveBaseRate - 0.5) < 0.01 { - rateString = "0.5x" - } else if abs(effectiveBaseRate - 1.5) < 0.01 { - rateString = "1.5x" - } else if abs(effectiveBaseRate - 2.0) < 0.01 { - rateString = "2x" - } else { - rateString = "x" + var stringValue = String(format: "%.1fx", effectiveBaseRate) + if stringValue.hasSuffix(".0x") { + stringValue = stringValue.replacingOccurrences(of: ".0x", with: "x") } - strongSelf.moreBarButton.setContent(.image(optionsRateImage(rate: rateString, isLarge: true)), animated: animated) + strongSelf.moreBarButton.setContent(.image(optionsRateImage(rate: stringValue, isLarge: true)), animated: animated) } else { strongSelf.moreBarButton.setContent(.more(optionsCircleImage(dark: false)), animated: animated) } @@ -2428,15 +2423,19 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { } private func openMoreMenu(sourceNode: ASDisplayNode, gesture: ContextGesture?) { - let items: Signal<[ContextMenuItem], NoError> = self.contextMenuMainItems() guard let controller = self.baseNavigationController()?.topViewController as? ViewController else { return } - + var dismissImpl: (() -> Void)? + let items: Signal<[ContextMenuItem], NoError> = self.contextMenuMainItems(dismiss: { + dismissImpl?() + }) let contextController = ContextController(account: self.context.account, presentationData: self.presentationData.withUpdated(theme: defaultDarkColorPresentationTheme), source: .reference(HeaderContextReferenceContentSource(controller: controller, sourceNode: self.moreBarButton.referenceNode)), items: items |> map { ContextController.Items(content: .list($0)) }, gesture: gesture) self.isShowingContextMenuPromise.set(true) controller.presentInGlobalOverlay(contextController) - + dismissImpl = { [weak contextController] in + contextController?.dismiss() + } contextController.dismissed = { [weak self] in Queue.mainQueue().after(0.1, { self?.isShowingContextMenuPromise.set(false) @@ -2455,7 +2454,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { return speedList } - private func contextMenuMainItems() -> Signal<[ContextMenuItem], NoError> { + private func contextMenuMainItems(dismiss: @escaping () -> Void) -> Signal<[ContextMenuItem], NoError> { guard let videoNode = self.videoNode else { return .single([]) } @@ -2470,6 +2469,29 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { var items: [ContextMenuItem] = [] + var speedValue: String = strongSelf.presentationData.strings.PlaybackSpeed_Normal + var speedIconText: String = "1x" + for (text, iconText, speed) in strongSelf.speedList(strings: strongSelf.presentationData.strings) { + if abs(speed - status.baseRate) < 0.01 { + speedValue = text + speedIconText = iconText + break + } + } + + items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.PlaybackSpeed_Title, textLayout: .secondLineWithValue(speedValue), icon: { theme in + return optionsRateImage(rate: speedIconText, isLarge: false, color: theme.contextMenu.primaryColor) + }, action: { c, _ in + guard let strongSelf = self else { + c.dismiss(completion: nil) + return + } + + c.setItems(strongSelf.contextMenuSpeedItems(dismiss: dismiss) |> map { ContextController.Items(content: .list($0)) }, minHeight: nil) + }))) + + items.append(.separator) + if let (message, _, _) = strongSelf.contentInfo() { let context = strongSelf.context items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.SharedMedia_ViewInChat, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/GoToMessage"), color: theme.contextMenu.primaryColor)}, action: { [weak self] _, f in @@ -2493,27 +2515,6 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { }))) } - var speedValue: String = strongSelf.presentationData.strings.PlaybackSpeed_Normal - var speedIconText: String = "1x" - for (text, iconText, speed) in strongSelf.speedList(strings: strongSelf.presentationData.strings) { - if abs(speed - status.baseRate) < 0.01 { - speedValue = text - speedIconText = iconText - break - } - } - - items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.PlaybackSpeed_Title, textLayout: .secondLineWithValue(speedValue), icon: { theme in - return optionsRateImage(rate: speedIconText, isLarge: false, color: theme.contextMenu.primaryColor) - }, action: { c, _ in - guard let strongSelf = self else { - c.dismiss(completion: nil) - return - } - - c.setItems(strongSelf.contextMenuSpeedItems() |> map { ContextController.Items(content: .list($0)) }, minHeight: nil) - }))) - // if #available(iOS 11.0, *) { // items.append(.action(ContextMenuActionItem(text: "AirPlay", textColor: .primary, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Media Gallery/AirPlay"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in // f(.default) @@ -2593,7 +2594,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { } } - private func contextMenuSpeedItems() -> Signal<[ContextMenuItem], NoError> { + private func contextMenuSpeedItems(dismiss: @escaping () -> Void) -> Signal<[ContextMenuItem], NoError> { guard let videoNode = self.videoNode else { return .single([]) } @@ -2607,7 +2608,29 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { } var items: [ContextMenuItem] = [] + + items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.Common_Back, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Back"), color: theme.actionSheet.primaryTextColor) + }, iconPosition: .left, action: { c, _ in + guard let strongSelf = self else { + c.dismiss(completion: nil) + return + } + c.setItems(strongSelf.contextMenuMainItems(dismiss: dismiss) |> map { ContextController.Items(content: .list($0)) }, minHeight: nil) + }))) + items.append(.custom(SliderContextItem(minValue: 0.05, maxValue: 2.5, value: status.baseRate, valueChanged: { [weak self] newValue, finished in + guard let strongSelf = self, let videoNode = strongSelf.videoNode else { + return + } + videoNode.setBaseRate(newValue) + if finished { + dismiss() + } + }), true)) + + items.append(.separator) + for (text, _, rate) in strongSelf.speedList(strings: strongSelf.presentationData.strings) { let isSelected = abs(status.baseRate - rate) < 0.01 items.append(.action(ContextMenuActionItem(text: text, icon: { theme in @@ -2631,17 +2654,6 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { }))) } - items.append(.separator) - items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.Common_Back, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Back"), color: theme.actionSheet.primaryTextColor) - }, iconPosition: .left, action: { c, _ in - guard let strongSelf = self else { - c.dismiss(completion: nil) - return - } - c.setItems(strongSelf.contextMenuMainItems() |> map { ContextController.Items(content: .list($0)) }, minHeight: nil) - }))) - return items } } diff --git a/submodules/ImportStickerPackUI/Sources/ImportStickerPackControllerNode.swift b/submodules/ImportStickerPackUI/Sources/ImportStickerPackControllerNode.swift index e16fa6fa03..7a9600b7a0 100644 --- a/submodules/ImportStickerPackUI/Sources/ImportStickerPackControllerNode.swift +++ b/submodules/ImportStickerPackUI/Sources/ImportStickerPackControllerNode.swift @@ -394,19 +394,39 @@ final class ImportStickerPackControllerNode: ViewControllerTracingNode, UIScroll forceTitleUpdate = true } - if let _ = self.stickerPack, self.currentItems.isEmpty || self.currentItems.count != self.pendingItems.count || self.pendingItems != self.currentItems || forceTitleUpdate { + let itemsPerRow: Int + if let stickerPack = self.stickerPack, case .emoji = stickerPack.type { + itemsPerRow = 8 + } else { + itemsPerRow = 4 + } + if let stickerPack = self.stickerPack, self.currentItems.isEmpty || self.currentItems.count != self.pendingItems.count || self.pendingItems != self.currentItems || forceTitleUpdate { let previousItems = self.currentItems self.currentItems = self.pendingItems let titleFont = Font.medium(20.0) let title: String if let _ = self.progress { - title = self.presentationData.strings.ImportStickerPack_ImportingStickers + if case .emoji = stickerPack.type { + title = self.presentationData.strings.ImportStickerPack_ImportingEmojis + } else { + title = self.presentationData.strings.ImportStickerPack_ImportingStickers + } } else { - title = self.presentationData.strings.ImportStickerPack_StickerCount(Int32(self.currentItems.count)) + if case .emoji = stickerPack.type { + title = self.presentationData.strings.ImportStickerPack_EmojiCount(Int32(self.currentItems.count)) + } else { + title = self.presentationData.strings.ImportStickerPack_StickerCount(Int32(self.currentItems.count)) + } } self.contentTitleNode.attributedText = stringWithAppliedEntities(title, entities: [], baseColor: self.presentationData.theme.actionSheet.primaryTextColor, linkColor: self.presentationData.theme.actionSheet.controlAccentColor, baseFont: titleFont, linkFont: titleFont, boldFont: titleFont, italicFont: titleFont, boldItalicFont: titleFont, fixedFont: titleFont, blockQuoteFont: titleFont, message: nil) + if case .emoji = stickerPack.type { + self.createActionButtonNode.setTitle(self.presentationData.strings.ImportStickerPack_CreateNewEmojiPack, with: Font.regular(20.0), with: self.presentationData.theme.actionSheet.controlAccentColor, for: .normal) + } else { + self.createActionButtonNode.setTitle(self.presentationData.strings.ImportStickerPack_CreateNewStickerSet, with: Font.regular(20.0), with: self.presentationData.theme.actionSheet.controlAccentColor, for: .normal) + } + if !forceTitleUpdate { transaction = StickerPackPreviewGridTransaction(previousList: previousItems, list: self.currentItems, account: self.context.account, interaction: self.interaction, theme: self.presentationData.theme) } @@ -422,7 +442,7 @@ final class ImportStickerPackControllerNode: ViewControllerTracingNode, UIScroll transition.updateFrame(node: self.contentTitleNode, frame: titleFrame) transition.updateFrame(node: self.contentSeparatorNode, frame: CGRect(origin: CGPoint(x: contentContainerFrame.minX, y: self.contentBackgroundNode.frame.minY + titleAreaHeight), size: CGSize(width: contentContainerFrame.size.width, height: UIScreenPixel))) - let itemsPerRow = 4 + let itemWidth = floor(contentFrame.size.width / CGFloat(itemsPerRow)) let rowCount = itemCount / itemsPerRow + (itemCount % itemsPerRow != 0 ? 1 : 0) diff --git a/submodules/MediaResources/Sources/MediaPlaybackStoredState.swift b/submodules/MediaResources/Sources/MediaPlaybackStoredState.swift index d9bef73942..99360ec901 100644 --- a/submodules/MediaResources/Sources/MediaPlaybackStoredState.swift +++ b/submodules/MediaResources/Sources/MediaPlaybackStoredState.swift @@ -18,7 +18,7 @@ public final class MediaPlaybackStoredState: Codable { let container = try decoder.container(keyedBy: StringCodingKey.self) self.timestamp = try container.decode(Double.self, forKey: "timestamp") - self.playbackRate = AudioPlaybackRate(rawValue: try container.decode(Int32.self, forKey: "playbackRate")) ?? .x1 + self.playbackRate = AudioPlaybackRate(rawValue: try container.decode(Int32.self, forKey: "playbackRate")) } public func encode(to encoder: Encoder) throws { diff --git a/submodules/SemanticStatusNode/Sources/SemanticStatusNode.swift b/submodules/SemanticStatusNode/Sources/SemanticStatusNode.swift index ca2d5d5a5d..440082d668 100644 --- a/submodules/SemanticStatusNode/Sources/SemanticStatusNode.swift +++ b/submodules/SemanticStatusNode/Sources/SemanticStatusNode.swift @@ -145,6 +145,9 @@ private final class SemanticStatusNodeIconContext: SemanticStatusNodeStateContex if let iconImage = self.iconImage { context.saveGState() let iconRect = CGRect(origin: CGPoint(), size: iconImage.size) + context.translateBy(x: size.width / 2.0, y: size.height / 2.0) + context.scaleBy(x: 1.0, y: -1.0) + context.translateBy(x: -size.width / 2.0, y: -size.height / 2.0) context.clip(to: iconRect, mask: iconImage.cgImage!) context.fill(iconRect) context.restoreGState() @@ -180,6 +183,9 @@ private final class SemanticStatusNodeIconContext: SemanticStatusNodeStateContex if let iconImage = self.iconImage { context.saveGState() let iconRect = CGRect(origin: CGPoint(), size: iconImage.size) + context.translateBy(x: size.width / 2.0, y: size.height / 2.0) + context.scaleBy(x: 1.0, y: -1.0) + context.translateBy(x: -size.width / 2.0, y: -size.height / 2.0) context.clip(to: iconRect, mask: iconImage.cgImage!) context.fill(iconRect) context.restoreGState() diff --git a/submodules/TelegramBaseController/BUILD b/submodules/TelegramBaseController/BUILD index 1d6b6bf6f8..52f34d888a 100644 --- a/submodules/TelegramBaseController/BUILD +++ b/submodules/TelegramBaseController/BUILD @@ -24,6 +24,9 @@ swift_library( "//submodules/Markdown:Markdown", "//submodules/TelegramCallsUI:TelegramCallsUI", "//submodules/ManagedAnimationNode:ManagedAnimationNode", + "//submodules/TelegramNotices:TelegramNotices", + "//submodules/TooltipUI:TooltipUI", + "//submodules/TelegramUI/Components/SliderContextItem:SliderContextItem", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramBaseController/Sources/MediaNavigationAccessoryHeaderNode.swift b/submodules/TelegramBaseController/Sources/MediaNavigationAccessoryHeaderNode.swift index b7151abe4b..9bf2178231 100644 --- a/submodules/TelegramBaseController/Sources/MediaNavigationAccessoryHeaderNode.swift +++ b/submodules/TelegramBaseController/Sources/MediaNavigationAccessoryHeaderNode.swift @@ -11,6 +11,9 @@ import AccountContext import TelegramStringFormatting import ManagedAnimationNode import ContextUI +import TelegramNotices +import TooltipUI +import SliderContextItem private let titleFont = Font.regular(12.0) private let subtitleFont = Font.regular(10.0) @@ -186,28 +189,9 @@ public final class MediaNavigationAccessoryHeaderNode: ASDisplayNode, UIScrollVi } self.rateButton.accessibilityLabel = self.strings.VoiceOver_Media_PlaybackRate self.rateButton.accessibilityHint = self.strings.VoiceOver_Media_PlaybackRateChange - switch playbackBaseRate { - case .x0_5: - self.rateButton.setContent(.image(optionsRateImage(rate: "0.5X", color: self.theme.rootController.navigationBar.accentTextColor))) - self.rateButton.accessibilityValue = self.strings.VoiceOver_Media_PlaybackRate05X - case .x1: - self.rateButton.setContent(.image(optionsRateImage(rate: "1X", color: self.theme.rootController.navigationBar.controlColor))) - self.rateButton.accessibilityValue = self.strings.VoiceOver_Media_PlaybackRateNormal - case .x1_25: - self.rateButton.setContent(.image(optionsRateImage(rate: "1.25X", color: self.theme.rootController.navigationBar.accentTextColor))) - self.rateButton.accessibilityValue = self.strings.VoiceOver_Media_PlaybackRate125X - case .x1_5: - self.rateButton.setContent(.image(optionsRateImage(rate: "1.5X", color: self.theme.rootController.navigationBar.accentTextColor))) - self.rateButton.accessibilityValue = self.strings.VoiceOver_Media_PlaybackRate15X - case .x1_75: - self.rateButton.setContent(.image(optionsRateImage(rate: "1.75X", color: self.theme.rootController.navigationBar.accentTextColor))) - self.rateButton.accessibilityValue = self.strings.VoiceOver_Media_PlaybackRate175X - case .x2: - self.rateButton.setContent(.image(optionsRateImage(rate: "2X", color: self.theme.rootController.navigationBar.accentTextColor))) - self.rateButton.accessibilityValue = self.strings.VoiceOver_Media_PlaybackRate2X - default: - break - } + self.rateButton.accessibilityValue = playbackBaseRate.stringValue + + self.rateButton.setContent(.image(optionsRateImage(rate: playbackBaseRate.stringValue.uppercased(), color: self.theme.rootController.navigationBar.controlColor))) } } @@ -380,22 +364,7 @@ public final class MediaNavigationAccessoryHeaderNode: ASDisplayNode, UIScrollVi self.scrubbingNode.updateContent(.standard(lineHeight: 2.0, lineCap: .square, scrubberHandle: .none, backgroundColor: .clear, foregroundColor: self.theme.rootController.navigationBar.accentTextColor, bufferingColor: self.theme.rootController.navigationBar.accentTextColor.withAlphaComponent(0.5), chapters: [])) if let playbackBaseRate = self.playbackBaseRate { - switch playbackBaseRate { - case .x0_5: - self.rateButton.setContent(.image(optionsRateImage(rate: "0.5X", color: self.theme.rootController.navigationBar.accentTextColor))) - case .x1: - self.rateButton.setContent(.image(optionsRateImage(rate: "1X", color: self.theme.rootController.navigationBar.controlColor))) - case .x1_25: - self.rateButton.setContent(.image(optionsRateImage(rate: "1.25X", color: self.theme.rootController.navigationBar.controlColor))) - case .x1_5: - self.rateButton.setContent(.image(optionsRateImage(rate: "1.5X", color: self.theme.rootController.navigationBar.accentTextColor))) - case .x1_75: - self.rateButton.setContent(.image(optionsRateImage(rate: "1.75X", color: self.theme.rootController.navigationBar.controlColor))) - case .x2: - self.rateButton.setContent(.image(optionsRateImage(rate: "2X", color: self.theme.rootController.navigationBar.accentTextColor))) - default: - break - } + self.rateButton.setContent(.image(optionsRateImage(rate: playbackBaseRate.stringValue.uppercased(), color: self.theme.rootController.navigationBar.controlColor))) } if let (size, leftInset, rightInset) = self.validLayout { self.updateLayout(size: size, leftInset: leftInset, rightInset: rightInset, transition: .immediate) @@ -532,23 +501,41 @@ public final class MediaNavigationAccessoryHeaderNode: ASDisplayNode, UIScrollVi nextRate = .x2 } self.setRate?(nextRate, false) + + let frame = self.rateButton.view.convert(self.rateButton.bounds, to: nil) + + let _ = (ApplicationSpecificNotice.incrementAudioRateOptionsTip(accountManager: self.context.sharedContext.accountManager) + |> deliverOnMainQueue).start(next: { [weak self] value in + if let strongSelf = self, let controller = strongSelf.getController?(), value == 4 { + controller.present(TooltipScreen(account: strongSelf.context.account, text: strongSelf.strings.Conversation_AudioRateOptionsTooltip, style: .default, icon: nil, location: .point(frame.offsetBy(dx: 0.0, dy: 4.0), .bottom), displayDuration: .custom(3.0), inset: 3.0, shouldDismissOnTouch: { _ in + return .dismiss(consume: false) + }), in: .window(.root)) + } + }) } private func speedList(strings: PresentationStrings) -> [(String, String, AudioPlaybackRate)] { let speedList: [(String, String, AudioPlaybackRate)] = [ ("0.5x", "0.5x", .x0_5), (strings.PlaybackSpeed_Normal, "1x", .x1), - ("1.25x", "1.25x", .x1_25), ("1.5x", "1.5x", .x1_5), - ("1.75x", "1.75x", .x1_75), ("2x", "2x", .x2) ] return speedList } - private func contextMenuSpeedItems() -> Signal<[ContextMenuItem], NoError> { + private func contextMenuSpeedItems(dismiss: @escaping () -> Void) -> Signal<[ContextMenuItem], NoError> { var items: [ContextMenuItem] = [] + items.append(.custom(SliderContextItem(minValue: 0.05, maxValue: 2.5, value: self.playbackBaseRate?.doubleValue ?? 1.0, valueChanged: { [weak self] newValue, finished in + self?.setRate?(AudioPlaybackRate(newValue), true) + if finished { + dismiss() + } + }), true)) + + items.append(.separator) + for (text, _, rate) in self.speedList(strings: self.strings) { let isSelected = self.playbackBaseRate == rate items.append(.action(ContextMenuActionItem(text: text, icon: { theme in @@ -571,9 +558,14 @@ public final class MediaNavigationAccessoryHeaderNode: ASDisplayNode, UIScrollVi guard let controller = self.getController?() else { return } - let items: Signal<[ContextMenuItem], NoError> = self.contextMenuSpeedItems() + var dismissImpl: (() -> Void)? + let items: Signal<[ContextMenuItem], NoError> = self.contextMenuSpeedItems(dismiss: { + dismissImpl?() + }) let contextController = ContextController(account: self.context.account, presentationData: self.context.sharedContext.currentPresentationData.with { $0 }, source: .reference(HeaderContextReferenceContentSource(controller: controller, sourceNode: self.rateButton.referenceNode, shouldBeDismissed: self.dismissedPromise.get())), items: items |> map { ContextController.Items(content: .list($0)) }, gesture: gesture) - + dismissImpl = { [weak contextController] in + contextController?.dismiss() + } self.presentInGlobalOverlay?(contextController) } @@ -639,45 +631,38 @@ private final class PlayPauseIconNode: ManagedAnimationNode { } private func optionsRateImage(rate: String, color: UIColor = .white) -> UIImage? { - return generateImage(CGSize(width: 36.0, height: 16.0), rotatedContext: { size, context in + let isLarge = "".isEmpty + return generateImage(isLarge ? CGSize(width: 30.0, height: 30.0) : CGSize(width: 24.0, height: 24.0), rotatedContext: { size, context in UIGraphicsPushContext(context) context.clear(CGRect(origin: CGPoint(), size: size)) - let lineWidth = 1.0 + UIScreenPixel - context.setLineWidth(lineWidth) - context.setStrokeColor(color.cgColor) - + if let image = generateTintedImage(image: UIImage(bundleImageName: isLarge ? "Chat/Context Menu/Playspeed30" : "Chat/Context Menu/Playspeed24"), color: color) { + image.draw(at: CGPoint(x: 0.0, y: 0.0)) + } - let string = NSMutableAttributedString(string: rate, font: Font.with(size: 11.0, design: .round, weight: .bold), textColor: color) + let string = NSMutableAttributedString(string: rate, font: Font.with(size: isLarge ? 11.0 : 10.0, design: .round, weight: .semibold), textColor: color) var offset = CGPoint(x: 1.0, y: 0.0) - var width: CGFloat - if rate.count >= 5 { - string.addAttribute(.kern, value: -0.8 as NSNumber, range: NSRange(string.string.startIndex ..< string.string.endIndex, in: string.string)) - offset.x += -0.5 - width = 34.0 - } else if rate.count >= 3 { - if rate == "0.5X" { + if rate.count >= 3 { + if rate == "0.5x" { string.addAttribute(.kern, value: -0.8 as NSNumber, range: NSRange(string.string.startIndex ..< string.string.endIndex, in: string.string)) offset.x += -0.5 } else { string.addAttribute(.kern, value: -0.5 as NSNumber, range: NSRange(string.string.startIndex ..< string.string.endIndex, in: string.string)) offset.x += -0.3 } - width = 29.0 } else { - string.addAttribute(.kern, value: -0.5 as NSNumber, range: NSRange(string.string.startIndex ..< string.string.endIndex, in: string.string)) - width = 19.0 offset.x += -0.3 } - - let path = UIBezierPath(roundedRect: CGRect(x: floorToScreenPixels((size.width - width) / 2.0), y: 0.0, width: width, height: 16.0).insetBy(dx: lineWidth / 2.0, dy: lineWidth / 2.0), byRoundingCorners: .allCorners, cornerRadii: CGSize(width: 2.0, height: 2.0)) - context.addPath(path.cgPath) - context.strokePath() - + + if !isLarge { + offset.x *= 0.5 + offset.y *= 0.5 + } + let boundingRect = string.boundingRect(with: size, options: [], context: nil) - string.draw(at: CGPoint(x: offset.x + floor((size.width - boundingRect.width) / 2.0), y: offset.y + UIScreenPixel + floor((size.height - boundingRect.height) / 2.0))) + string.draw(at: CGPoint(x: offset.x + floor((size.width - boundingRect.width) / 2.0), y: offset.y + floor((size.height - boundingRect.height) / 2.0))) UIGraphicsPopContext() }) diff --git a/submodules/TelegramUI/Components/SliderContextItem/BUILD b/submodules/TelegramUI/Components/SliderContextItem/BUILD new file mode 100644 index 0000000000..849f6e3715 --- /dev/null +++ b/submodules/TelegramUI/Components/SliderContextItem/BUILD @@ -0,0 +1,22 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "SliderContextItem", + module_name = "SliderContextItem", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit", + "//submodules/AsyncDisplayKit:AsyncDisplayKit", + "//submodules/Display:Display", + "//submodules/ContextUI:ContextUI", + "//submodules/TelegramPresentationData:TelegramPresentationData", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/SliderContextItem/Sources/SliderContextItem.swift b/submodules/TelegramUI/Components/SliderContextItem/Sources/SliderContextItem.swift new file mode 100644 index 0000000000..197ec449ea --- /dev/null +++ b/submodules/TelegramUI/Components/SliderContextItem/Sources/SliderContextItem.swift @@ -0,0 +1,184 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import ContextUI +import TelegramPresentationData + +public final class SliderContextItem: ContextMenuCustomItem { + private let minValue: CGFloat + private let maxValue: CGFloat + private let value: CGFloat + private let valueChanged: (CGFloat, Bool) -> Void + + public init(minValue: CGFloat, maxValue: CGFloat, value: CGFloat, valueChanged: @escaping (CGFloat, Bool) -> Void) { + self.minValue = minValue + self.maxValue = maxValue + self.value = value + self.valueChanged = valueChanged + } + + public func node(presentationData: PresentationData, getController: @escaping () -> ContextControllerProtocol?, actionSelected: @escaping (ContextMenuActionResult) -> Void) -> ContextMenuCustomNode { + return SliderContextItemNode(presentationData: presentationData, getController: getController, minValue: self.minValue, maxValue: self.maxValue, value: self.value, valueChanged: self.valueChanged) + } +} + +private let textFont = Font.regular(17.0) + +private final class SliderContextItemNode: ASDisplayNode, ContextMenuCustomNode { + private var presentationData: PresentationData + + private let backgroundTextNode: ImmediateTextNode + + private let foregroundNode: ASDisplayNode + private let foregroundTextNode: ImmediateTextNode + + let minValue: CGFloat + let maxValue: CGFloat + var value: CGFloat = 1.0 { + didSet { + self.updateValue() + } + } + + private let valueChanged: (CGFloat, Bool) -> Void + + private let hapticFeedback = HapticFeedback() + + init(presentationData: PresentationData, getController: @escaping () -> ContextControllerProtocol?, minValue: CGFloat, maxValue: CGFloat, value: CGFloat, valueChanged: @escaping (CGFloat, Bool) -> Void) { + self.presentationData = presentationData + self.minValue = minValue + self.maxValue = maxValue + self.value = value + self.valueChanged = valueChanged + + self.backgroundTextNode = ImmediateTextNode() + self.backgroundTextNode.isAccessibilityElement = false + self.backgroundTextNode.isUserInteractionEnabled = false + self.backgroundTextNode.displaysAsynchronously = false + self.backgroundTextNode.textAlignment = .left + + self.foregroundNode = ASDisplayNode() + self.foregroundNode.clipsToBounds = true + self.foregroundNode.isAccessibilityElement = false + self.foregroundNode.backgroundColor = UIColor(rgb: 0xffffff) + self.foregroundNode.isUserInteractionEnabled = false + + self.foregroundTextNode = ImmediateTextNode() + self.foregroundTextNode.isAccessibilityElement = false + self.foregroundTextNode.isUserInteractionEnabled = false + self.foregroundTextNode.displaysAsynchronously = false + self.foregroundTextNode.textAlignment = .left + + super.init() + + self.isUserInteractionEnabled = true + + self.addSubnode(self.backgroundTextNode) + self.addSubnode(self.foregroundNode) + self.foregroundNode.addSubnode(self.foregroundTextNode) + } + + override func didLoad() { + super.didLoad() + + let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(self.panGesture(_:))) + self.view.addGestureRecognizer(panGestureRecognizer) + + let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))) + self.view.addGestureRecognizer(tapGestureRecognizer) + } + + func updateTheme(presentationData: PresentationData) { + self.presentationData = presentationData + self.updateValue() + } + + private func updateValue(transition: ContainedViewLayoutTransition = .immediate) { + let width = self.frame.width + + let range = self.maxValue - self.minValue + let value = (self.value - self.minValue) / range + transition.updateFrameAdditive(node: self.foregroundNode, frame: CGRect(origin: CGPoint(), size: CGSize(width: value * width, height: self.frame.height))) + + var stringValue = String(format: "%.1fx", self.value) + if stringValue.hasSuffix(".0x") { + stringValue = stringValue.replacingOccurrences(of: ".0x", with: "x") + } + self.backgroundTextNode.attributedText = NSAttributedString(string: stringValue, font: textFont, textColor: UIColor(rgb: 0xffffff)) + self.foregroundTextNode.attributedText = NSAttributedString(string: stringValue, font: textFont, textColor: UIColor(rgb: 0x000000)) + + let _ = self.backgroundTextNode.updateLayout(CGSize(width: 70.0, height: .greatestFiniteMagnitude)) + let _ = self.foregroundTextNode.updateLayout(CGSize(width: 70.0, height: .greatestFiniteMagnitude)) + } + + func updateLayout(constrainedWidth: CGFloat, constrainedHeight: CGFloat) -> (CGSize, (CGSize, ContainedViewLayoutTransition) -> Void) { + let valueWidth: CGFloat = 70.0 + let height: CGFloat = 45.0 + + var textSize = self.backgroundTextNode.updateLayout(CGSize(width: valueWidth, height: .greatestFiniteMagnitude)) + textSize.width = valueWidth + + return (CGSize(width: height * 3.0, height: height), { size, transition in + let leftInset: CGFloat = 17.0 + + let textFrame = CGRect(origin: CGPoint(x: leftInset, y: floor((size.height - textSize.height) / 2.0)), size: textSize) + transition.updateFrameAdditive(node: self.backgroundTextNode, frame: textFrame) + transition.updateFrameAdditive(node: self.foregroundTextNode, frame: textFrame) + + self.updateValue(transition: transition) + }) + } + + @objc private func panGesture(_ gestureRecognizer: UIPanGestureRecognizer) { + let range = self.maxValue - self.minValue + switch gestureRecognizer.state { + case .began: + break + case .changed: + let previousValue = self.value + + let translation: CGFloat = gestureRecognizer.translation(in: gestureRecognizer.view).x + let delta = translation / self.bounds.width * range + self.value = max(self.minValue, min(self.maxValue, self.value + delta)) + gestureRecognizer.setTranslation(CGPoint(), in: gestureRecognizer.view) + + if self.value == 2.0 && previousValue != 2.0 { + self.hapticFeedback.impact(.soft) + } else if self.value == 1.0 && previousValue != 1.0 { + self.hapticFeedback.impact(.soft) + } else if self.value == 2.5 && previousValue != 2.5 { + self.hapticFeedback.impact(.soft) + } else if self.value == 0.05 && previousValue != 0.05 { + self.hapticFeedback.impact(.soft) + } + if abs(previousValue - self.value) >= 0.001 { + self.valueChanged(self.value, false) + } + case .ended: + let translation: CGFloat = gestureRecognizer.translation(in: gestureRecognizer.view).x + let delta = translation / self.bounds.width * range + self.value = max(self.minValue, min(self.maxValue, self.value + delta)) + self.valueChanged(self.value, true) + default: + break + } + } + + @objc private func tapGesture(_ gestureRecognizer: UITapGestureRecognizer) { + let range = self.maxValue - self.minValue + let location = gestureRecognizer.location(in: gestureRecognizer.view) + self.value = max(self.minValue, min(self.maxValue, location.x / range)) + self.valueChanged(self.value, true) + } + + func canBeHighlighted() -> Bool { + return false + } + + func updateIsHighlighted(isHighlighted: Bool) { + } + + func performAction() { + } +} diff --git a/submodules/TelegramUI/Sources/OverlayPlayerControlsNode.swift b/submodules/TelegramUI/Sources/OverlayPlayerControlsNode.swift index 7b437db8d5..e450ad44e1 100644 --- a/submodules/TelegramUI/Sources/OverlayPlayerControlsNode.swift +++ b/submodules/TelegramUI/Sources/OverlayPlayerControlsNode.swift @@ -422,11 +422,7 @@ final class OverlayPlayerControlsNode: ASDisplayNode { let baseRate: AudioPlaybackRate if value.status.baseRate.isEqual(to: 2.0) { baseRate = .x2 - } else if value.status.baseRate.isEqual(to: 1.75) { - baseRate = .x1_75 } else if value.status.baseRate.isEqual(to: 1.5) { - baseRate = .x1_25 - } else if value.status.baseRate.isEqual(to: 1.25) { baseRate = .x1_5 } else if value.status.baseRate.isEqual(to: 0.5) { baseRate = .x0_5 @@ -794,12 +790,8 @@ final class OverlayPlayerControlsNode: ASDisplayNode { switch baseRate { case .x2: self.rateButton.setImage(optionsRateImage(rate: "2X", color: self.presentationData.theme.list.itemAccentColor), for: []) - case .x1_75: - self.rateButton.setImage(optionsRateImage(rate: "1.75X", color: self.presentationData.theme.list.itemAccentColor), for: []) case .x1_5: self.rateButton.setImage(optionsRateImage(rate: "1.5X", color: self.presentationData.theme.list.itemAccentColor), for: []) - case .x1_25: - self.rateButton.setImage(optionsRateImage(rate: "1.25X", color: self.presentationData.theme.list.itemAccentColor), for: []) case .x0_5: self.rateButton.setImage(optionsRateImage(rate: "0.5X", color: self.presentationData.theme.list.itemAccentColor), for: []) default: diff --git a/submodules/TelegramUIPreferences/Sources/MusicPlaybackSettings.swift b/submodules/TelegramUIPreferences/Sources/MusicPlaybackSettings.swift index a16e2c01ef..d69d8491e0 100644 --- a/submodules/TelegramUIPreferences/Sources/MusicPlaybackSettings.swift +++ b/submodules/TelegramUIPreferences/Sources/MusicPlaybackSettings.swift @@ -15,28 +15,73 @@ public enum MusicPlaybackSettingsLooping: Int32 { case all = 2 } -public enum AudioPlaybackRate: Int32 { - case x0_5 = 500 - case x1 = 1000 - case x1_25 = 1250 - case x1_5 = 1500 - case x1_75 = 1750 - case x2 = 2000 - case x4 = 4000 - case x8 = 8000 - case x16 = 16000 +public enum AudioPlaybackRate: Equatable { + case x0_5 + case x1 + case x1_5 + case x2 + case x4 + case x8 + case x16 + case custom(Int32) public var doubleValue: Double { return Double(self.rawValue) / 1000.0 } + + public var rawValue: Int32 { + switch self { + case .x0_5: + return 500 + case .x1: + return 1000 + case .x1_5: + return 1500 + case .x2: + return 2000 + case .x4: + return 4000 + case .x8: + return 8000 + case .x16: + return 16000 + case let .custom(value): + return value + } + } public init(_ value: Double) { - if let resolved = AudioPlaybackRate(rawValue: Int32(value * 1000.0)) { - self = resolved - } else { + self.init(rawValue: Int32(value * 1000.0)) + } + + public init(rawValue: Int32) { + switch rawValue { + case 500: + self = .x0_5 + case 1000: self = .x1 + case 1500: + self = .x1_5 + case 2000: + self = .x2 + case 4000: + self = .x4 + case 8000: + self = .x8 + case 16000: + self = .x16 + default: + self = .custom(rawValue) } } + + public var stringValue: String { + var stringValue = String(format: "%.1fx", self.doubleValue) + if stringValue.hasSuffix(".0x") { + stringValue = stringValue.replacingOccurrences(of: ".0x", with: "x") + } + return stringValue + } } public struct MusicPlaybackSettings: Codable, Equatable { @@ -59,7 +104,7 @@ public struct MusicPlaybackSettings: Codable, Equatable { self.order = MusicPlaybackSettingsOrder(rawValue: try container.decode(Int32.self, forKey: "order")) ?? .regular self.looping = MusicPlaybackSettingsLooping(rawValue: try container.decode(Int32.self, forKey: "looping")) ?? .none - self.voicePlaybackRate = AudioPlaybackRate(rawValue: try container.decodeIfPresent(Int32.self, forKey: "voicePlaybackRate") ?? AudioPlaybackRate.x1.rawValue) ?? .x1 + self.voicePlaybackRate = AudioPlaybackRate(rawValue: try container.decodeIfPresent(Int32.self, forKey: "voicePlaybackRate") ?? AudioPlaybackRate.x1.rawValue) } public func encode(to encoder: Encoder) throws {