Improve media playback rate controls

This commit is contained in:
Ilya Laktyushin 2023-02-10 19:34:45 +04:00
parent a235246c49
commit 8f181490e2
12 changed files with 416 additions and 145 deletions

View File

@ -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";

View File

@ -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",

View File

@ -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([])
}
@ -2608,6 +2609,28 @@ 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
}
}

View File

@ -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 {
if case .emoji = stickerPack.type {
title = self.presentationData.strings.ImportStickerPack_ImportingEmojis
} else {
title = self.presentationData.strings.ImportStickerPack_ImportingStickers
}
} else {
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)

View File

@ -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 {

View File

@ -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()

View File

@ -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",

View File

@ -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()
})

View File

@ -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",
],
)

View File

@ -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() {
}
}

View File

@ -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:

View File

@ -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 init(_ value: Double) {
if let resolved = AudioPlaybackRate(rawValue: Int32(value * 1000.0)) {
self = resolved
} else {
self = .x1
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) {
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 {