import Foundation import UIKit import AsyncDisplayKit import Display import Postbox import TelegramCore import SwiftSignalKit import TelegramPresentationData import UniversalMediaPlayer import TelegramUIPreferences import AccountContext import PhotoResources import AppBundle import ManagedAnimationNode import RangeSet import TelegramBaseController import ContextUI import SliderContextItem import UndoUI private func normalizeValue(_ value: CGFloat) -> CGFloat { return round(value * 10.0) / 10.0 } private func generateBackground(theme: PresentationTheme) -> UIImage? { return generateImage(CGSize(width: 20.0, height: 10.0 + 8.0), rotatedContext: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) context.setShadow(offset: CGSize(width: 0.0, height: -4.0), blur: 20.0, color: UIColor(white: 0.0, alpha: 0.3).cgColor) context.setFillColor(theme.list.plainBackgroundColor.cgColor) context.fillEllipse(in: CGRect(origin: CGPoint(x: 0.0, y: 8.0), size: CGSize(width: 20.0, height: 20.0))) })?.stretchableImage(withLeftCapWidth: 10, topCapHeight: 10 + 8) } private func generateCollapseIcon(theme: PresentationTheme) -> UIImage? { return generateImage(CGSize(width: 38.0, height: 5.0), rotatedContext: { size, context in let bounds = CGRect(origin: CGPoint(), size: size) context.clear(bounds) let path = UIBezierPath(roundedRect: bounds, cornerRadius: 2.5) context.setFillColor(theme.list.controlSecondaryColor.cgColor) context.addPath(path.cgPath) context.fillPath() }) } private func optionsRateImage(rate: String, color: UIColor = .white) -> UIImage? { 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)) 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: isLarge ? 11.0 : 10.0, design: .round, weight: .semibold), textColor: color) var offset = CGPoint(x: 1.0, y: 0.0) 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 } } else { offset.x += -0.3 } 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 + floor((size.height - boundingRect.height) / 2.0))) UIGraphicsPopContext() }) } private let digitsSet = CharacterSet(charactersIn: "0123456789") private func timestampLabelWidthForDuration(_ timestamp: Double) -> CGFloat { let text: String if timestamp > 0 { let timestamp = Int32(timestamp) let hours = timestamp / (60 * 60) let minutes = timestamp % (60 * 60) / 60 let seconds = timestamp % 60 if hours != 0 { text = String(format: "%d:%02d:%02d", hours, minutes, seconds) } else { text = String(format: "%d:%02d", minutes, seconds) } } else { text = "-:--" } let convertedString = text.components(separatedBy: digitsSet).joined(separator: "8") let string = NSAttributedString(string: convertedString, font: Font.regular(13.0), textColor: .black) let size = string.boundingRect(with: CGSize(width: 200.0, height: 100.0), options: NSStringDrawingOptions.usesLineFragmentOrigin, context: nil).size return size.width } private let titleFont = Font.semibold(18.0) private let descriptionFont = Font.regular(18.0) private func stringsForDisplayData(_ data: SharedMediaPlaybackDisplayData?, presentationData: PresentationData) -> (NSAttributedString?, NSAttributedString?, Bool, NSAttributedString?) { var titleString: NSAttributedString? var descriptionString: NSAttributedString? var hasArtist = false var captionString: NSAttributedString? if let data = data { let titleText: String let subtitleText: String switch data { case let .music(title, performer, _, _, caption): titleText = title ?? presentationData.strings.MediaPlayer_UnknownTrack subtitleText = performer ?? presentationData.strings.MediaPlayer_UnknownArtist hasArtist = performer != nil captionString = caption case .voice, .instantVideo: titleText = "" subtitleText = "" } titleString = NSAttributedString(string: titleText, font: titleFont, textColor: presentationData.theme.list.itemPrimaryTextColor) descriptionString = NSAttributedString(string: subtitleText, font: descriptionFont, textColor: hasArtist ? presentationData.theme.list.itemAccentColor : presentationData.theme.list.itemSecondaryTextColor) } return (titleString, descriptionString, hasArtist, captionString) } final class OverlayPlayerControlsNode: ASDisplayNode { private let accountManager: AccountManager private let account: Account private let engine: TelegramEngine private var presentationData: PresentationData private let backgroundNode: ASImageNode private let collapseNode: HighlightableButtonNode private let albumArtNode: TransformImageNode private var largeAlbumArtNode: TransformImageNode? private let titleNode: TextNode private let descriptionNode: TextNode private let shareNode: HighlightableButtonNode private let artistButton: HighlightTrackingButtonNode private let scrubberNode: MediaPlayerScrubbingNode private let leftDurationLabel: MediaPlayerTimeTextNode private let rightDurationLabel: MediaPlayerTimeTextNode private let infoNode: ASTextNode private let backwardButton: IconButtonNode private let forwardButton: IconButtonNode private var seekTimer: SwiftSignalKit.Timer? private var seekRate: AudioPlaybackRate = .x2 private var previousRate: AudioPlaybackRate? private var currentIsPaused: Bool? private let playPauseButton: IconButtonNode private let playPauseIconNode: PlayPauseIconNode private var currentOrder: MusicPlaybackSettingsOrder? private let orderButton: IconButtonNode private var currentLooping: MusicPlaybackSettingsLooping? private let loopingButton: IconButtonNode private var currentRate: AudioPlaybackRate? private let rateButton: AudioRateButton let separatorNode: ASDisplayNode var isExpanded = false var updateIsExpanded: (() -> Void)? var requestCollapse: (() -> Void)? var requestShare: ((MessageId) -> Void)? var requestSearchByArtist: ((String) -> Void)? var updateOrder: ((MusicPlaybackSettingsOrder) -> Void)? var control: ((SharedMediaPlayerControlAction) -> Void)? var getParentController: () -> ViewController? = { return nil } private(set) var currentItemId: SharedMediaPlaylistItemId? private var displayData: SharedMediaPlaybackDisplayData? private var currentAlbumArtInitialized = false private var currentAlbumArt: SharedMediaPlaybackAlbumArt? private var currentFileReference: FileMediaReference? private var statusDisposable: Disposable? private var chapterDisposable: Disposable? private var previousCaption: NSAttributedString? private var chaptersPromise = ValuePromise<[MediaPlayerScrubbingChapter]>([]) private var currentChapter: MediaPlayerScrubbingChapter? private let hapticFeedback = HapticFeedback() private var scrubbingDisposable: Disposable? private var leftDurationLabelPushed = false private var rightDurationLabelPushed = false private var infoNodePushed = false private var currentDuration: Double = 0.0 private var currentPosition: Double = 0.0 private var validLayout: (width: CGFloat, leftInset: CGFloat, rightInset: CGFloat, maxHeight: CGFloat)? init(account: Account, engine: TelegramEngine, accountManager: AccountManager, presentationData: PresentationData, status: Signal<(Account, SharedMediaPlayerItemPlaybackStateOrLoading, MediaManagerPlayerType)?, NoError>) { self.accountManager = accountManager self.account = account self.engine = engine self.presentationData = presentationData self.backgroundNode = ASImageNode() self.backgroundNode.isLayerBacked = true self.backgroundNode.displayWithoutProcessing = true self.backgroundNode.displaysAsynchronously = false self.backgroundNode.image = generateBackground(theme: presentationData.theme) self.collapseNode = HighlightableButtonNode() self.collapseNode.displaysAsynchronously = false self.collapseNode.setImage(generateCollapseIcon(theme: presentationData.theme), for: []) self.albumArtNode = TransformImageNode() self.titleNode = TextNode() self.titleNode.isUserInteractionEnabled = false self.titleNode.displaysAsynchronously = false self.descriptionNode = TextNode() self.descriptionNode.isUserInteractionEnabled = false self.descriptionNode.displaysAsynchronously = false self.artistButton = HighlightTrackingButtonNode() self.shareNode = HighlightableButtonNode() self.shareNode.setImage(generateTintedImage(image: UIImage(bundleImageName: "GlobalMusicPlayer/Share"), color: presentationData.theme.list.itemAccentColor), for: []) self.scrubberNode = MediaPlayerScrubbingNode(content: .standard(lineHeight: 3.0, lineCap: .round, scrubberHandle: .circle, backgroundColor: presentationData.theme.list.controlSecondaryColor, foregroundColor: presentationData.theme.list.itemAccentColor, bufferingColor: presentationData.theme.list.itemAccentColor.withAlphaComponent(0.4), chapters: [])) self.leftDurationLabel = MediaPlayerTimeTextNode(textColor: presentationData.theme.list.itemSecondaryTextColor) self.leftDurationLabel.displaysAsynchronously = false self.leftDurationLabel.keepPreviousValueOnEmptyState = true self.rightDurationLabel = MediaPlayerTimeTextNode(textColor: presentationData.theme.list.itemSecondaryTextColor) self.rightDurationLabel.displaysAsynchronously = false self.rightDurationLabel.mode = .reversed self.rightDurationLabel.alignment = .right self.rightDurationLabel.keepPreviousValueOnEmptyState = true self.infoNode = ASTextNode() self.infoNode.maximumNumberOfLines = 1 self.infoNode.isUserInteractionEnabled = false self.infoNode.displaysAsynchronously = false self.rateButton = AudioRateButton() self.rateButton.hitTestSlop = UIEdgeInsets(top: -8.0, left: -4.0, bottom: -8.0, right: -4.0) self.rateButton.displaysAsynchronously = false self.backwardButton = IconButtonNode() self.backwardButton.displaysAsynchronously = false self.forwardButton = IconButtonNode() self.forwardButton.displaysAsynchronously = false self.orderButton = IconButtonNode() self.orderButton.displaysAsynchronously = false self.loopingButton = IconButtonNode() self.loopingButton.displaysAsynchronously = false self.playPauseButton = IconButtonNode() self.playPauseButton.displaysAsynchronously = false self.playPauseIconNode = PlayPauseIconNode() self.backwardButton.icon = generateTintedImage(image: UIImage(bundleImageName: "GlobalMusicPlayer/Previous"), color: presentationData.theme.list.itemPrimaryTextColor) self.forwardButton.icon = generateTintedImage(image: UIImage(bundleImageName: "GlobalMusicPlayer/Next"), color: presentationData.theme.list.itemPrimaryTextColor) self.separatorNode = ASDisplayNode() self.separatorNode.isLayerBacked = true self.separatorNode.backgroundColor = presentationData.theme.list.itemPlainSeparatorColor super.init() self.addSubnode(self.backgroundNode) self.addSubnode(self.collapseNode) self.addSubnode(self.albumArtNode) self.addSubnode(self.titleNode) self.addSubnode(self.descriptionNode) self.addSubnode(self.artistButton) self.addSubnode(self.shareNode) self.addSubnode(self.leftDurationLabel) self.addSubnode(self.rightDurationLabel) self.addSubnode(self.infoNode) self.addSubnode(self.rateButton) self.addSubnode(self.scrubberNode) self.addSubnode(self.orderButton) self.addSubnode(self.loopingButton) self.addSubnode(self.backwardButton) self.addSubnode(self.forwardButton) self.addSubnode(self.playPauseButton) self.playPauseButton.addSubnode(self.playPauseIconNode) self.addSubnode(self.separatorNode) let accountId = account.id let delayedStatus = status |> mapToSignal { value -> Signal<(Account, SharedMediaPlayerItemPlaybackStateOrLoading, MediaManagerPlayerType)?, NoError> in guard let value = value, value.0.id == accountId else { return .single(nil) } switch value.1 { case .state: return .single(value) case .loading: return .single(value) |> delay(0.1, queue: .mainQueue()) } } let mappedStatus = combineLatest(delayedStatus, self.scrubberNode.scrubbingTimestamp) |> map { value, scrubbingTimestamp -> MediaPlayerStatus in if let (_, valueOrLoading, _) = value, case let .state(value) = valueOrLoading { return MediaPlayerStatus(generationTimestamp: scrubbingTimestamp != nil ? 0 : value.status.generationTimestamp, duration: value.status.duration, dimensions: value.status.dimensions, timestamp: scrubbingTimestamp ?? value.status.timestamp, baseRate: value.status.baseRate, seekId: value.status.seekId, status: value.status.status, soundEnabled: value.status.soundEnabled) } else { return MediaPlayerStatus(generationTimestamp: 0.0, duration: 0.0, dimensions: CGSize(), timestamp: 0.0, baseRate: 1.0, seekId: 0, status: .paused, soundEnabled: true) } } self.scrubberNode.status = mappedStatus self.leftDurationLabel.status = mappedStatus self.rightDurationLabel.status = mappedStatus self.scrubbingDisposable = (self.scrubberNode.scrubbingPosition |> deliverOnMainQueue).startStrict(next: { [weak self] value in guard let strongSelf = self else { return } let leftDurationLabelPushed: Bool let rightDurationLabelPushed: Bool let infoNodePushed: Bool if let value = value { leftDurationLabelPushed = value < 0.16 rightDurationLabelPushed = value > (strongSelf.rateButton.isHidden ? 0.84 : 0.74) infoNodePushed = value >= 0.16 && value <= 0.84 } else { leftDurationLabelPushed = false rightDurationLabelPushed = false infoNodePushed = false } if leftDurationLabelPushed != strongSelf.leftDurationLabelPushed || rightDurationLabelPushed != strongSelf.rightDurationLabelPushed || infoNodePushed != strongSelf.infoNodePushed { strongSelf.leftDurationLabelPushed = leftDurationLabelPushed strongSelf.rightDurationLabelPushed = rightDurationLabelPushed strongSelf.infoNodePushed = infoNodePushed if let layout = strongSelf.validLayout { let _ = strongSelf.updateLayout(width: layout.0, leftInset: layout.1, rightInset: layout.2, maxHeight: layout.3, transition: .animated(duration: 0.35, curve: .spring)) } } }) self.statusDisposable = (delayedStatus |> deliverOnMainQueue).startStrict(next: { [weak self] value in guard let strongSelf = self else { return } var valueItemId: SharedMediaPlaylistItemId? if let (_, value, _) = value, case let .state(state) = value { valueItemId = state.item.id } if !areSharedMediaPlaylistItemIdsEqual(valueItemId, strongSelf.currentItemId) { strongSelf.currentItemId = valueItemId strongSelf.scrubberNode.ignoreSeekId = nil } var rateButtonIsHidden = true var displayData: SharedMediaPlaybackDisplayData? if let (_, valueOrLoading, _) = value, case let .state(value) = valueOrLoading { var isPaused: Bool switch value.status.status { case .playing: isPaused = false case .paused: isPaused = true case let .buffering(_, whilePlaying, _, _): isPaused = !whilePlaying } if strongSelf.wasPlaying { isPaused = false } let isFirstTime = strongSelf.currentIsPaused == nil if strongSelf.currentIsPaused != isPaused { strongSelf.currentIsPaused = isPaused strongSelf.updatePlayPauseButton(paused: isPaused, animated: !isFirstTime) } strongSelf.playPauseButton.isEnabled = true strongSelf.backwardButton.isEnabled = true strongSelf.forwardButton.isEnabled = true displayData = value.item.displayData if value.order != strongSelf.currentOrder { strongSelf.updateOrder?(value.order) strongSelf.currentOrder = value.order strongSelf.updateOrderButton(value.order) } if value.looping != strongSelf.currentLooping { strongSelf.currentLooping = value.looping strongSelf.updateLoopButton(value.looping) } let baseRate = AudioPlaybackRate(value.status.baseRate ) if baseRate != strongSelf.currentRate { strongSelf.currentRate = baseRate strongSelf.updateRateButton(baseRate) } if let displayData = displayData, case let .music(_, _, _, long, _) = displayData, long { strongSelf.scrubberNode.enableFineScrubbing = true rateButtonIsHidden = false } else { strongSelf.scrubberNode.enableFineScrubbing = false rateButtonIsHidden = true } let duration = value.status.duration if duration != strongSelf.currentDuration && !duration.isZero { strongSelf.currentDuration = duration if let layout = strongSelf.validLayout { let _ = strongSelf.updateLayout(width: layout.0, leftInset: layout.1, rightInset: layout.2, maxHeight: layout.3, transition: .immediate) } } strongSelf.rateButton.isHidden = rateButtonIsHidden strongSelf.currentPosition = value.status.timestamp } else { strongSelf.playPauseButton.isEnabled = false strongSelf.backwardButton.isEnabled = false strongSelf.forwardButton.isEnabled = false strongSelf.rateButton.isHidden = true displayData = nil } if strongSelf.displayData != displayData { strongSelf.displayData = displayData var canShare = true if let (_, valueOrLoading, _) = value, case let .state(value) = valueOrLoading, let source = value.item.playbackData?.source { switch source { case let .telegramFile(fileReference, isCopyProtected, _): canShare = !isCopyProtected strongSelf.currentFileReference = fileReference if let size = fileReference.media.size { strongSelf.scrubberNode.bufferingStatus = strongSelf.account.postbox.mediaBox.resourceRangesStatus(fileReference.media.resource) |> map { ranges -> (RangeSet, Int64) in return (ranges, size) } } else { strongSelf.scrubberNode.bufferingStatus = nil } } } else { strongSelf.scrubberNode.bufferingStatus = nil } strongSelf.updateLabels(transition: .immediate) strongSelf.shareNode.isHidden = !canShare } }) self.chapterDisposable = combineLatest(queue: Queue.mainQueue(), mappedStatus, self.chaptersPromise.get()) .startStrict(next: { [weak self] status, chapters in if let strongSelf = self, status.duration > 1.0, chapters.count > 0 { let previousChapter = strongSelf.currentChapter var currentChapter: MediaPlayerScrubbingChapter? for chapter in chapters { if chapter.start > status.timestamp { break } else { currentChapter = chapter } } if let chapter = currentChapter, chapter != previousChapter { strongSelf.currentChapter = chapter if strongSelf.scrubberNode.isScrubbing { strongSelf.hapticFeedback.impact(.light) } if let previousChapter = previousChapter, !strongSelf.infoNode.alpha.isZero { if let snapshotView = strongSelf.infoNode.view.snapshotView(afterScreenUpdates: false) { snapshotView.frame = strongSelf.infoNode.frame strongSelf.infoNode.view.superview?.addSubview(snapshotView) let offset: CGFloat = 30.0 let snapshotTargetPosition: CGPoint let nodeStartPosition: CGPoint if previousChapter.start < chapter.start { snapshotTargetPosition = CGPoint(x: -offset, y: 0.0) nodeStartPosition = CGPoint(x: offset, y: 0.0) } else { snapshotTargetPosition = CGPoint(x: offset, y: 0.0) nodeStartPosition = CGPoint(x: -offset, y: 0.0) } snapshotView.layer.animatePosition(from: CGPoint(), to: snapshotTargetPosition, duration: 0.2, removeOnCompletion: false, additive: true) snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak snapshotView] _ in snapshotView?.removeFromSuperview() }) strongSelf.infoNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) strongSelf.infoNode.layer.animatePosition(from: nodeStartPosition, to: CGPoint(), duration: 0.2, additive: true) } } strongSelf.infoNode.attributedText = NSAttributedString(string: chapter.title, font: Font.regular(13.0), textColor: strongSelf.presentationData.theme.list.itemSecondaryTextColor) if let layout = strongSelf.validLayout { let _ = strongSelf.updateLayout(width: layout.0, leftInset: layout.1, rightInset: layout.2, maxHeight: layout.3, transition: .immediate) } } } }) self.scrubberNode.seek = { [weak self] value in self?.control?(.seek(value)) } self.collapseNode.addTarget(self, action: #selector(self.collapsePressed), forControlEvents: .touchUpInside) self.shareNode.addTarget(self, action: #selector(self.sharePressed), forControlEvents: .touchUpInside) self.orderButton.addTarget(self, action: #selector(self.orderPressed), forControlEvents: .touchUpInside) self.loopingButton.addTarget(self, action: #selector(self.loopingPressed), forControlEvents: .touchUpInside) self.backwardButton.addTarget(self, action: #selector(self.backwardPressed), forControlEvents: .touchUpInside) self.forwardButton.addTarget(self, action: #selector(self.forwardPressed), forControlEvents: .touchUpInside) self.playPauseButton.addTarget(self, action: #selector(self.playPausePressed), forControlEvents: .touchUpInside) self.rateButton.addTarget(self, action: #selector(self.rateButtonPressed), forControlEvents: .touchUpInside) self.artistButton.addTarget(self, action: #selector(self.artistPressed), forControlEvents: .touchUpInside) self.artistButton.highligthedChanged = { [weak self] highlighted in if let strongSelf = self { if highlighted { strongSelf.descriptionNode.layer.removeAnimation(forKey: "opacity") strongSelf.descriptionNode.alpha = 0.4 } else { strongSelf.descriptionNode.alpha = 1.0 strongSelf.descriptionNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) } } } self.rateButton.contextAction = { [weak self] sourceNode, gesture in self?.openRateMenu(sourceNode: sourceNode, gesture: gesture) } self.playPauseButton.circleColor = presentationData.theme.list.controlSecondaryColor.withAlphaComponent(0.35) self.backwardButton.circleColor = presentationData.theme.list.controlSecondaryColor.withAlphaComponent(0.35) self.forwardButton.circleColor = presentationData.theme.list.controlSecondaryColor.withAlphaComponent(0.35) } deinit { self.statusDisposable?.dispose() self.chapterDisposable?.dispose() self.scrubbingDisposable?.dispose() } override func didLoad() { super.didLoad() self.albumArtNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.albumArtTap(_:)))) let backwardLongPressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(self.seekBackwardLongPress(_:))) backwardLongPressGestureRecognizer.minimumPressDuration = 0.3 self.backwardButton.view.addGestureRecognizer(backwardLongPressGestureRecognizer) let forwardLongPressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(self.seekForwardLongPress(_:))) forwardLongPressGestureRecognizer.minimumPressDuration = 0.3 self.forwardButton.view.addGestureRecognizer(forwardLongPressGestureRecognizer) } private var wasPlaying = false @objc private func seekBackwardLongPress(_ gestureRecognizer: UILongPressGestureRecognizer) { switch gestureRecognizer.state { case .began: self.wasPlaying = !(self.currentIsPaused ?? true) self.backwardButton.isPressing = true self.previousRate = self.currentRate self.control?(.playback(.pause)) var time: Double = 0.0 let seekTimer = SwiftSignalKit.Timer(timeout: 0.1, repeat: true, completion: { [weak self] in if let strongSelf = self { var delta: Double = 0.8 if time >= 4.0 { delta = 3.2 } else if time >= 2.0 { delta = 1.6 } time += 0.1 let newPosition = strongSelf.currentPosition - delta strongSelf.currentPosition = newPosition strongSelf.control?(.seek(newPosition)) } }, queue: Queue.mainQueue()) self.seekTimer = seekTimer seekTimer.start() case .ended, .cancelled: self.backwardButton.isPressing = false self.seekTimer?.invalidate() self.seekTimer = nil if self.wasPlaying { self.control?(.playback(.play)) } self.previousRate = nil default: break } } @objc private func seekForwardLongPress(_ gestureRecognizer: UILongPressGestureRecognizer) { switch gestureRecognizer.state { case .began: self.wasPlaying = !(self.currentIsPaused ?? true) self.forwardButton.isPressing = true self.previousRate = self.currentRate self.seekRate = .x4 self.control?(.playback(.play)) self.control?(.setBaseRate(self.seekRate)) let seekTimer = SwiftSignalKit.Timer(timeout: 2.0, repeat: true, completion: { [weak self] in if let strongSelf = self { if strongSelf.seekRate == .x4 { strongSelf.seekRate = .x8 } else if strongSelf.seekRate == .x8 { strongSelf.seekRate = .x16 } strongSelf.control?(.setBaseRate(strongSelf.seekRate)) if strongSelf.seekRate == .x16 { strongSelf.seekTimer?.invalidate() strongSelf.seekTimer = nil } } }, queue: Queue.mainQueue()) self.seekTimer = seekTimer seekTimer.start() case .ended, .cancelled: self.forwardButton.isPressing = false self.control?(.setBaseRate(self.previousRate ?? .x1)) self.seekTimer?.invalidate() self.seekTimer = nil if !self.wasPlaying { self.control?(.playback(.pause)) } self.previousRate = nil default: break } } func updatePresentationData(_ presentationData: PresentationData) { guard self.presentationData.theme !== presentationData.theme else { return } self.presentationData = presentationData self.playPauseButton.circleColor = presentationData.theme.list.controlSecondaryColor.withAlphaComponent(0.35) self.backwardButton.circleColor = presentationData.theme.list.controlSecondaryColor.withAlphaComponent(0.35) self.forwardButton.circleColor = presentationData.theme.list.controlSecondaryColor.withAlphaComponent(0.35) self.backgroundNode.image = generateBackground(theme: presentationData.theme) self.collapseNode.setImage(generateCollapseIcon(theme: presentationData.theme), for: []) self.shareNode.setImage(generateTintedImage(image: UIImage(bundleImageName: "GlobalMusicPlayer/Share"), color: presentationData.theme.list.itemAccentColor), for: []) self.scrubberNode.updateColors(backgroundColor: presentationData.theme.list.controlSecondaryColor, foregroundColor: presentationData.theme.list.itemAccentColor) self.leftDurationLabel.textColor = presentationData.theme.list.itemSecondaryTextColor self.rightDurationLabel.textColor = presentationData.theme.list.itemSecondaryTextColor self.backwardButton.icon = generateTintedImage(image: UIImage(bundleImageName: "GlobalMusicPlayer/Previous"), color: presentationData.theme.list.itemPrimaryTextColor) self.forwardButton.icon = generateTintedImage(image: UIImage(bundleImageName: "GlobalMusicPlayer/Next"), color: presentationData.theme.list.itemPrimaryTextColor) if let isPaused = self.currentIsPaused { self.updatePlayPauseButton(paused: isPaused, animated: false) } if let order = self.currentOrder { self.updateOrderButton(order) } if let looping = self.currentLooping { self.updateLoopButton(looping) } if let rate = self.currentRate { self.updateRateButton(rate) } self.separatorNode.backgroundColor = presentationData.theme.list.itemPlainSeparatorColor } private func updateLabels(transition: ContainedViewLayoutTransition) { guard let (width, leftInset, rightInset, maxHeight) = self.validLayout else { return } let panelHeight = OverlayPlayerControlsNode.heightForLayout(width: width, leftInset: leftInset, rightInset: rightInset, maxHeight: maxHeight, isExpanded: self.isExpanded) let sideInset: CGFloat = 20.0 let infoLabelsLeftInset: CGFloat = 60.0 let infoLabelsRightInset: CGFloat = 32.0 let infoVerticalOrigin: CGFloat = panelHeight - OverlayPlayerControlsNode.basePanelHeight + 36.0 let (titleString, descriptionString, hasArtist, caption) = stringsForDisplayData(self.displayData, presentationData: self.presentationData) if self.previousCaption?.string != caption?.string { self.previousCaption = caption let chapters = caption.flatMap { parseMediaPlayerChapters($0) } ?? [] self.chaptersPromise.set(chapters) self.scrubberNode.updateContent(.standard(lineHeight: 3.0, lineCap: .round, scrubberHandle: .circle, backgroundColor: self.presentationData.theme.list.controlSecondaryColor, foregroundColor: self.presentationData.theme.list.itemAccentColor, bufferingColor: self.presentationData.theme.list.itemAccentColor.withAlphaComponent(0.4), chapters: chapters)) } self.artistButton.isUserInteractionEnabled = hasArtist let makeTitleLayout = TextNode.asyncLayout(self.titleNode) let (titleLayout, titleApply) = makeTitleLayout(TextNodeLayoutArguments(attributedString: titleString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: width - sideInset * 2.0 - leftInset - rightInset - infoLabelsLeftInset - infoLabelsRightInset, height: CGFloat.greatestFiniteMagnitude), alignment: .left, lineSpacing: 0.0, cutout: nil, insets: UIEdgeInsets())) let makeDescriptionLayout = TextNode.asyncLayout(self.descriptionNode) let (descriptionLayout, descriptionApply) = makeDescriptionLayout(TextNodeLayoutArguments(attributedString: descriptionString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: width - sideInset * 2.0 - leftInset - rightInset - infoLabelsLeftInset - infoLabelsRightInset, height: CGFloat.greatestFiniteMagnitude), alignment: .left, lineSpacing: 0.0, cutout: nil, insets: UIEdgeInsets())) transition.updateFrame(node: self.titleNode, frame: CGRect(origin: CGPoint(x: self.isExpanded ? floor((width - titleLayout.size.width) / 2.0) : (leftInset + sideInset + infoLabelsLeftInset), y: infoVerticalOrigin + 1.0), size: titleLayout.size)) let _ = titleApply() let descriptionFrame = CGRect(origin: CGPoint(x: self.isExpanded ? floor((width - descriptionLayout.size.width) / 2.0) : (leftInset + sideInset + infoLabelsLeftInset), y: infoVerticalOrigin + 24.0), size: descriptionLayout.size) transition.updateFrame(node: self.descriptionNode, frame: descriptionFrame) let _ = descriptionApply() self.artistButton.frame = descriptionFrame.insetBy(dx: -8.0, dy: -8.0) var albumArt: SharedMediaPlaybackAlbumArt? if let displayData = self.displayData { switch displayData { case let .music(_, _, value, _, _): albumArt = value default: break } } if self.currentAlbumArt != albumArt || !self.currentAlbumArtInitialized { self.currentAlbumArtInitialized = true self.currentAlbumArt = albumArt self.albumArtNode.setSignal(playerAlbumArt(postbox: self.account.postbox, engine: self.engine, fileReference: self.currentFileReference, albumArt: albumArt, thumbnail: true)) if let largeAlbumArtNode = self.largeAlbumArtNode { largeAlbumArtNode.setSignal(playerAlbumArt(postbox: self.account.postbox, engine: self.engine, fileReference: self.currentFileReference, albumArt: albumArt, thumbnail: false)) } } } private func updatePlayPauseButton(paused: Bool, animated: Bool) { self.playPauseIconNode.customColor = self.presentationData.theme.list.itemPrimaryTextColor if paused { self.playPauseIconNode.enqueueState(.play, animated: animated) } else { self.playPauseIconNode.enqueueState(.pause, animated: animated) } } private func updateOrderButton(_ order: MusicPlaybackSettingsOrder) { switch order { case .regular: self.orderButton.icon = generateTintedImage(image: UIImage(bundleImageName: "GlobalMusicPlayer/OrderReverse"), color: self.presentationData.theme.list.itemSecondaryTextColor) case .reversed: self.orderButton.icon = generateTintedImage(image: UIImage(bundleImageName: "GlobalMusicPlayer/OrderReverse"), color: self.presentationData.theme.list.itemAccentColor) case .random: self.orderButton.icon = generateTintedImage(image: UIImage(bundleImageName: "GlobalMusicPlayer/OrderRandom"), color: self.presentationData.theme.list.itemAccentColor) } } private func updateLoopButton(_ looping: MusicPlaybackSettingsLooping) { let baseColor = self.presentationData.theme.list.itemSecondaryTextColor switch looping { case .none: self.loopingButton.icon = generateTintedImage(image: UIImage(bundleImageName: "GlobalMusicPlayer/Repeat"), color: baseColor) case .item: self.loopingButton.icon = generateTintedImage(image: UIImage(bundleImageName: "GlobalMusicPlayer/RepeatOne"), color: self.presentationData.theme.list.itemAccentColor) case .all: self.loopingButton.icon = generateTintedImage(image: UIImage(bundleImageName: "GlobalMusicPlayer/Repeat"), color: self.presentationData.theme.list.itemAccentColor) } } private func updateRateButton(_ playbackBaseRate: AudioPlaybackRate) { let rate = self.previousRate ?? playbackBaseRate self.rateButton.setContent(.image(optionsRateImage(rate: rate.stringValue.uppercased(), color: self.presentationData.theme.list.itemSecondaryTextColor))) } static let basePanelHeight: CGFloat = 220.0 static func heightForLayout(width: CGFloat, leftInset: CGFloat, rightInset: CGFloat, maxHeight: CGFloat, isExpanded: Bool) -> CGFloat { var panelHeight: CGFloat = OverlayPlayerControlsNode.basePanelHeight if isExpanded { let sideInset: CGFloat = 20.0 panelHeight += width - leftInset - rightInset - sideInset * 2.0 + 24.0 } return min(panelHeight, maxHeight) } func updateLayout(width: CGFloat, leftInset: CGFloat, rightInset: CGFloat, maxHeight: CGFloat, transition: ContainedViewLayoutTransition) -> CGFloat { self.validLayout = (width, leftInset, rightInset, maxHeight) let panelHeight = OverlayPlayerControlsNode.heightForLayout(width: width, leftInset: leftInset, rightInset: rightInset, maxHeight: maxHeight, isExpanded: self.isExpanded) transition.updateFrame(node: self.separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: panelHeight), size: CGSize(width: width, height: UIScreenPixel))) transition.updateFrame(node: self.collapseNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 2.0), size: CGSize(width: width, height: 30.0))) let sideInset: CGFloat = 20.0 let sideButtonsInset: CGFloat = sideInset + 36.0 let infoVerticalOrigin: CGFloat = panelHeight - OverlayPlayerControlsNode.basePanelHeight + 36.0 self.updateLabels(transition: transition) transition.updateFrame(node: self.shareNode, frame: CGRect(origin: CGPoint(x: width - rightInset - sideInset - 32.0, y: infoVerticalOrigin + 2.0), size: CGSize(width: 42.0, height: 42.0))) let albumArtSize = CGSize(width: 48.0, height: 48.0) let makeAlbumArtLayout = self.albumArtNode.asyncLayout() let applyAlbumArt = makeAlbumArtLayout(TransformImageArguments(corners: ImageCorners(radius: 4.0), imageSize: albumArtSize, boundingSize: albumArtSize, intrinsicInsets: UIEdgeInsets())) applyAlbumArt() let albumArtFrame = CGRect(origin: CGPoint(x: leftInset + sideInset, y: infoVerticalOrigin - 1.0), size: albumArtSize) let previousAlbumArtNodeFrame = self.albumArtNode.frame transition.updateFrame(node: self.albumArtNode, frame: albumArtFrame) if self.isExpanded { let largeAlbumArtNode: TransformImageNode var animateIn = false if let current = self.largeAlbumArtNode { largeAlbumArtNode = current } else { animateIn = true largeAlbumArtNode = TransformImageNode() if self.isNodeLoaded { largeAlbumArtNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.albumArtTap(_:)))) } self.largeAlbumArtNode = largeAlbumArtNode self.addSubnode(largeAlbumArtNode) if self.currentAlbumArtInitialized { largeAlbumArtNode.setSignal(playerAlbumArt(postbox: self.account.postbox, engine: self.engine, fileReference: self.currentFileReference, albumArt: self.currentAlbumArt, thumbnail: false)) } } let albumArtHeight = max(1.0, panelHeight - OverlayPlayerControlsNode.basePanelHeight - 24.0) let largeAlbumArtSize = CGSize(width: albumArtHeight, height: albumArtHeight) let makeLargeAlbumArtLayout = largeAlbumArtNode.asyncLayout() let applyLargeAlbumArt = makeLargeAlbumArtLayout(TransformImageArguments(corners: ImageCorners(radius: 4.0), imageSize: largeAlbumArtSize, boundingSize: largeAlbumArtSize, intrinsicInsets: UIEdgeInsets())) applyLargeAlbumArt() let largeAlbumArtFrame = CGRect(origin: CGPoint(x: floor((width - largeAlbumArtSize.width) / 2.0), y: 34.0), size: largeAlbumArtSize) if animateIn && transition.isAnimated { largeAlbumArtNode.frame = largeAlbumArtFrame transition.animatePositionAdditive(node: largeAlbumArtNode, offset: CGPoint(x: previousAlbumArtNodeFrame.center.x - largeAlbumArtFrame.center.x, y: previousAlbumArtNodeFrame.center.y - largeAlbumArtFrame.center.y)) //largeAlbumArtNode.layer.animatePosition(from: CGPoint(x: -50.0, y: 0.0), to: CGPoint(), duration: 0.15, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, additive: true) transition.animateTransformScale(node: largeAlbumArtNode, from: previousAlbumArtNodeFrame.size.height / largeAlbumArtFrame.size.height) largeAlbumArtNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.12) if let copyView = self.albumArtNode.view.snapshotContentTree() { copyView.frame = previousAlbumArtNodeFrame copyView.center = largeAlbumArtFrame.center self.view.insertSubview(copyView, belowSubview: largeAlbumArtNode.view) transition.animatePositionAdditive(layer: copyView.layer, offset: CGPoint(x: previousAlbumArtNodeFrame.center.x - largeAlbumArtFrame.center.x, y: previousAlbumArtNodeFrame.center.y - largeAlbumArtFrame.center.y), completion: { [weak copyView] _ in copyView?.removeFromSuperview() }) //copyView.layer.animatePosition(from: CGPoint(x: -50.0, y: 0.0), to: CGPoint(), duration: 0.15, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, additive: true) copyView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.28, removeOnCompletion: false) transition.updateTransformScale(layer: copyView.layer, scale: largeAlbumArtFrame.size.height / previousAlbumArtNodeFrame.size.height) } } else { transition.updateFrame(node: largeAlbumArtNode, frame: largeAlbumArtFrame) } self.albumArtNode.isHidden = true } else if let largeAlbumArtNode = self.largeAlbumArtNode { self.largeAlbumArtNode = nil self.albumArtNode.isHidden = false if transition.isAnimated { transition.animatePosition(node: self.albumArtNode, from: largeAlbumArtNode.frame.center) transition.animateTransformScale(node: self.albumArtNode, from: largeAlbumArtNode.frame.height / self.albumArtNode.frame.height) self.albumArtNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.12) transition.updatePosition(node: largeAlbumArtNode, position: self.albumArtNode.frame.center, completion: { [weak largeAlbumArtNode] _ in largeAlbumArtNode?.removeFromSupernode() }) largeAlbumArtNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.28, removeOnCompletion: false) transition.updateTransformScale(node: largeAlbumArtNode, scale: self.albumArtNode.frame.height / largeAlbumArtNode.frame.height) } else { largeAlbumArtNode.removeFromSupernode() } } let scrubberVerticalOrigin: CGFloat = infoVerticalOrigin + 64.0 transition.updateFrame(node: self.scrubberNode, frame: CGRect(origin: CGPoint(x: leftInset + sideInset, y: scrubberVerticalOrigin - 8.0), size: CGSize(width: width - sideInset * 2.0 - leftInset - rightInset, height: 10.0 + 8.0 * 2.0))) let leftLabelVerticalOffset: CGFloat = self.leftDurationLabelPushed ? 6.0 : 0.0 transition.updateFrame(node: self.leftDurationLabel, frame: CGRect(origin: CGPoint(x: leftInset + sideInset, y: scrubberVerticalOrigin + 14.0 + leftLabelVerticalOffset), size: CGSize(width: 100.0, height: 20.0))) let rightLabelVerticalOffset: CGFloat = self.rightDurationLabelPushed ? 6.0 : 0.0 transition.updateFrame(node: self.rightDurationLabel, frame: CGRect(origin: CGPoint(x: width - sideInset - rightInset - 100.0, y: scrubberVerticalOrigin + 14.0 + rightLabelVerticalOffset), size: CGSize(width: 100.0, height: 20.0))) let infoLabelVerticalOffset: CGFloat = self.infoNodePushed ? 6.0 : 0.0 let infoSize = self.infoNode.measure(CGSize(width: width - 60.0 * 2.0 - 100.0, height: 100.0)) self.infoNode.bounds = CGRect(origin: CGPoint(), size: infoSize) transition.updatePosition(node: self.infoNode, position: CGPoint(x: width / 2.0, y: scrubberVerticalOrigin + 14.0 + infoLabelVerticalOffset + infoSize.height / 2.0)) let rateRightOffset = timestampLabelWidthForDuration(self.currentDuration) transition.updateFrame(node: self.rateButton, frame: CGRect(origin: CGPoint(x: width - sideInset - rightInset - rateRightOffset - 28.0, y: scrubberVerticalOrigin + 10.0 + rightLabelVerticalOffset - 10.0), size: CGSize(width: 24.0, height: 44.0))) transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: -8.0), size: CGSize(width: width, height: panelHeight + 8.0))) let buttonSize = CGSize(width: 64.0, height: 64.0) let buttonsWidth = min(width - leftInset - rightInset - sideButtonsInset * 2.0, 320.0) let buttonsRect = CGRect(origin: CGPoint(x: floor((width - buttonsWidth) / 2.0), y: scrubberVerticalOrigin + 36.0), size: CGSize(width: buttonsWidth, height: buttonSize.height)) transition.updateFrame(node: self.orderButton, frame: CGRect(origin: CGPoint(x: leftInset + sideInset - 22.0, y: buttonsRect.minY), size: buttonSize)) transition.updateFrame(node: self.loopingButton, frame: CGRect(origin: CGPoint(x: width - rightInset - sideInset - buttonSize.width + 22.0, y: buttonsRect.minY), size: buttonSize)) transition.updateFrame(node: self.backwardButton, frame: CGRect(origin: buttonsRect.origin, size: buttonSize)) transition.updateFrame(node: self.forwardButton, frame: CGRect(origin: CGPoint(x: buttonsRect.maxX - buttonSize.width, y: buttonsRect.minY), size: buttonSize)) let playPauseFrame = CGRect(origin: CGPoint(x: buttonsRect.minX + floor((buttonsRect.width - buttonSize.width) / 2.0), y: buttonsRect.minY), size: buttonSize) transition.updateFrame(node: self.playPauseButton, frame: playPauseFrame) transition.updateFrame(node: self.playPauseIconNode, frame: CGRect(origin: CGPoint(x: -6.0, y: -6.0), size: CGSize(width: 76.0, height: 76.0))) return panelHeight } func collapse() { if self.isExpanded { self.isExpanded = false self.updateIsExpanded?() } } @objc func collapsePressed() { self.requestCollapse?() } @objc func sharePressed() { if let itemId = self.currentItemId as? PeerMessagesMediaPlaylistItemId { self.requestShare?(itemId.messageId) } } @objc func orderPressed() { if let order = self.currentOrder { let nextOrder: MusicPlaybackSettingsOrder switch order { case .regular: nextOrder = .reversed case .reversed: nextOrder = .random case .random: nextOrder = .regular } let _ = updateMusicPlaybackSettingsInteractively(accountManager: self.accountManager, { return $0.withUpdatedOrder(nextOrder) }).startStandalone() self.control?(.setOrder(nextOrder)) } } @objc func loopingPressed() { if let looping = self.currentLooping { let nextLooping: MusicPlaybackSettingsLooping switch looping { case .none: nextLooping = .item case .item: nextLooping = .all case .all: nextLooping = .none } let _ = updateMusicPlaybackSettingsInteractively(accountManager: self.accountManager, { return $0.withUpdatedLooping(nextLooping) }).startStandalone() self.control?(.setLooping(nextLooping)) } } @objc func backwardPressed() { self.control?(.previous) } @objc func forwardPressed() { self.control?(.next) } @objc func playPausePressed() { self.control?(.playback(.togglePlayPause)) } @objc func rateButtonPressed() { var nextRate: AudioPlaybackRate if let rate = self.currentRate { switch rate { case .x0_5, .x2: nextRate = .x1 case .x1: nextRate = .x1_5 case .x1_5: nextRate = .x2 default: if rate.doubleValue < 0.5 { nextRate = .x0_5 } else if rate.doubleValue < 1.0 { nextRate = .x1 } else if rate.doubleValue < 1.5 { nextRate = .x1_5 } else if rate.doubleValue < 2.0 { nextRate = .x2 } else { nextRate = .x1 } } } else { nextRate = .x1_5 } self.control?(.setBaseRate(nextRate)) } 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.5x", "1.5x", .x1_5), ("2x", "2x", .x2) ] return speedList } private func contextMenuSpeedItems(scheduleTooltip: @escaping (MediaNavigationAccessoryPanel.ChangeType?) -> Void) -> Signal { var presetItems: [ContextMenuItem] = [] let previousRate = self.currentRate let previousValue = self.currentRate?.doubleValue ?? 1.0 let sliderValuePromise = ValuePromise(nil) let sliderItem: ContextMenuItem = .custom(SliderContextItem(minValue: 0.2, maxValue: 2.5, value: previousValue, valueChanged: { [weak self] newValue, finished in let newValue = normalizeValue(newValue) self?.control?(.setBaseRate(AudioPlaybackRate(newValue))) sliderValuePromise.set(newValue) if finished { scheduleTooltip(.sliderCommit(previousValue, newValue)) } }), true) let theme = self.presentationData.theme for (text, _, rate) in self.speedList(strings: self.presentationData.strings) { let isSelected = self.currentRate == rate presetItems.append(.action(ContextMenuActionItem(text: text, icon: { _ in return nil }, iconSource: ContextMenuActionItemIconSource(size: CGSize(width: 24.0, height: 24.0), signal: sliderValuePromise.get() |> map { value in if isSelected && value == nil { return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) } else { return nil } }), action: { [weak self] _, f in scheduleTooltip(nil) f(.default) self?.control?(.setBaseRate(rate)) if let previousRate, previousRate.isPreset { self?.presentAudioRateTooltip(baseRate: rate, changeType: .preset) } else { self?.presentAudioRateTooltip(baseRate: rate, changeType: .sliderCommit(previousValue, rate.doubleValue)) } }))) } return .single(ContextController.Items(content: .twoLists(presetItems, [sliderItem]))) } private func openRateMenu(sourceNode: ASDisplayNode, gesture: ContextGesture?) { guard let controller = self.getParentController() else { return } var scheduledTooltip: MediaNavigationAccessoryPanel.ChangeType? let items = self.contextMenuSpeedItems(scheduleTooltip: { change in scheduledTooltip = change }) let contextController = ContextController(presentationData: self.presentationData, source: .reference(HeaderContextReferenceContentSource(controller: controller, sourceNode: self.rateButton.referenceNode, shouldBeDismissed: .single(false))), items: items, gesture: gesture) contextController.dismissed = { [weak self] in if let scheduledTooltip, let self, let rate = self.currentRate { self.presentAudioRateTooltip(baseRate: rate, changeType: scheduledTooltip) } } controller.presentInGlobalOverlay(contextController) } private func presentAudioRateTooltip(baseRate: AudioPlaybackRate, changeType: MediaNavigationAccessoryPanel.ChangeType) { guard let controller = self.getParentController() else { return } let presentationData = self.presentationData let text: String? let rate: CGFloat? if case let .sliderCommit(previousValue, newValue) = changeType { let value = String(format: "%0.1f", baseRate.doubleValue) if baseRate == .x1 { text = presentationData.strings.Conversation_AudioRateTooltipNormal } else { text = presentationData.strings.Conversation_AudioRateTooltipCustom(value).string } if newValue > previousValue { rate = .infinity } else if newValue < previousValue { rate = -.infinity } else { rate = nil } } else if baseRate == .x1 { text = presentationData.strings.Conversation_AudioRateTooltipNormal rate = 1.0 } else if baseRate == .x1_5 { text = presentationData.strings.Conversation_AudioRateTooltip15X rate = 1.5 } else if baseRate == .x2 { text = presentationData.strings.Conversation_AudioRateTooltipSpeedUp rate = 2.0 } else { text = nil rate = nil } var showTooltip = true if case .sliderChange = changeType { showTooltip = false } if let rate, let text, showTooltip { controller.presentInGlobalOverlay( UndoOverlayController( presentationData: presentationData, content: .audioRate( rate: rate, text: text ), elevatedLayout: false, animateInAsReplacement: false, action: { action in return true } ) ) } } @objc func albumArtTap(_ recognizer: UITapGestureRecognizer) { if case .ended = recognizer.state { if let supernode = self.supernode { let bounds = supernode.bounds if bounds.width > bounds.height { return } } self.isExpanded = !self.isExpanded self.updateIsExpanded?() } } @objc func artistPressed() { let (_, descriptionString, _, _) = stringsForDisplayData(self.displayData, presentationData: self.presentationData) if let artist = descriptionString?.string { self.requestSearchByArtist?(artist) } } override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { let result = super.hitTest(point, with: event) if result == self.view { return nil } return result } } private enum PlayPauseIconNodeState: Equatable { case play case pause } private final class PlayPauseIconNode: ManagedAnimationNode { private let duration: Double = 0.35 private var iconState: PlayPauseIconNodeState = .pause init() { super.init(size: CGSize(width: 76.0, height: 76.0)) self.trackTo(item: ManagedAnimationItem(source: .local("anim_playpause"), frames: .range(startFrame: 41, endFrame: 41), duration: 0.01)) } func enqueueState(_ state: PlayPauseIconNodeState, animated: Bool) { guard self.iconState != state else { return } let previousState = self.iconState self.iconState = state switch previousState { case .pause: switch state { case .play: if animated { self.trackTo(item: ManagedAnimationItem(source: .local("anim_playpause"), frames: .range(startFrame: 41, endFrame: 83), duration: self.duration)) } else { self.trackTo(item: ManagedAnimationItem(source: .local("anim_playpause"), frames: .range(startFrame: 0, endFrame: 0), duration: 0.01)) } case .pause: break } case .play: switch state { case .pause: if animated { self.trackTo(item: ManagedAnimationItem(source: .local("anim_playpause"), frames: .range(startFrame: 0, endFrame: 41), duration: self.duration)) } else { self.trackTo(item: ManagedAnimationItem(source: .local("anim_playpause"), frames: .range(startFrame: 41, endFrame: 41), duration: 0.01)) } case .play: break } } } } private final class HeaderContextReferenceContentSource: ContextReferenceContentSource { private let controller: ViewController private let sourceNode: ContextReferenceContentNode var shouldBeDismissed: Signal init(controller: ViewController, sourceNode: ContextReferenceContentNode, shouldBeDismissed: Signal) { self.controller = controller self.sourceNode = sourceNode self.shouldBeDismissed = shouldBeDismissed } func transitionInfo() -> ContextControllerReferenceViewInfo? { return ContextControllerReferenceViewInfo(referenceView: self.sourceNode.view, contentAreaInScreenSpace: UIScreen.main.bounds) } }