Swiftgram/submodules/TelegramBaseController/Sources/MediaNavigationAccessoryHeaderNode.swift
2021-03-20 16:13:57 +05:00

542 lines
27 KiB
Swift

import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import TelegramCore
import SyncCore
import TelegramPresentationData
import TelegramUIPreferences
import UniversalMediaPlayer
import AccountContext
import TelegramStringFormatting
import ManagedAnimationNode
private let titleFont = Font.regular(12.0)
private let subtitleFont = Font.regular(10.0)
private class MediaHeaderItemNode: ASDisplayNode {
private let titleNode: TextNode
private let subtitleNode: TextNode
override init() {
self.titleNode = TextNode()
self.titleNode.isUserInteractionEnabled = false
self.titleNode.displaysAsynchronously = false
self.subtitleNode = TextNode()
self.subtitleNode.isUserInteractionEnabled = false
self.subtitleNode.displaysAsynchronously = false
super.init()
self.isUserInteractionEnabled = false
self.addSubnode(self.titleNode)
self.addSubnode(self.subtitleNode)
}
func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, theme: PresentationTheme, strings: PresentationStrings, dateTimeFormat: PresentationDateTimeFormat, nameDisplayOrder: PresentationPersonNameOrder, playbackItem: SharedMediaPlaylistItem?, transition: ContainedViewLayoutTransition) -> (NSAttributedString?, NSAttributedString?, Bool) {
var rateButtonHidden = false
var titleString: NSAttributedString?
var subtitleString: NSAttributedString?
if let playbackItem = playbackItem, let displayData = playbackItem.displayData {
switch displayData {
case let .music(title, performer, _, long):
rateButtonHidden = !long
let titleText: String = title ?? strings.MediaPlayer_UnknownTrack
let subtitleText: String = performer ?? strings.MediaPlayer_UnknownArtist
titleString = NSAttributedString(string: titleText, font: titleFont, textColor: theme.rootController.navigationBar.primaryTextColor)
subtitleString = NSAttributedString(string: subtitleText, font: subtitleFont, textColor: theme.rootController.navigationBar.secondaryTextColor)
case let .voice(author, peer):
rateButtonHidden = false
let titleText: String = author?.displayTitle(strings: strings, displayOrder: nameDisplayOrder) ?? ""
let subtitleText: String
if let peer = peer {
if peer is TelegramGroup || peer is TelegramChannel {
subtitleText = peer.displayTitle(strings: strings, displayOrder: nameDisplayOrder)
} else {
subtitleText = strings.MusicPlayer_VoiceNote
}
} else {
subtitleText = strings.MusicPlayer_VoiceNote
}
titleString = NSAttributedString(string: titleText, font: titleFont, textColor: theme.rootController.navigationBar.primaryTextColor)
subtitleString = NSAttributedString(string: subtitleText, font: subtitleFont, textColor: theme.rootController.navigationBar.secondaryTextColor)
case let .instantVideo(author, peer, timestamp):
rateButtonHidden = false
let titleText: String = author?.displayTitle(strings: strings, displayOrder: nameDisplayOrder) ?? ""
var subtitleText: String
if let peer = peer {
if peer is TelegramGroup || peer is TelegramChannel {
subtitleText = peer.displayTitle(strings: strings, displayOrder: nameDisplayOrder)
} else {
subtitleText = strings.Message_VideoMessage
}
} else {
subtitleText = strings.Message_VideoMessage
}
if titleText == subtitleText {
subtitleText = humanReadableStringForTimestamp(strings: strings, dateTimeFormat: dateTimeFormat, timestamp: timestamp)
}
titleString = NSAttributedString(string: titleText, font: titleFont, textColor: theme.rootController.navigationBar.primaryTextColor)
subtitleString = NSAttributedString(string: subtitleText, font: subtitleFont, textColor: theme.rootController.navigationBar.secondaryTextColor)
}
}
let makeTitleLayout = TextNode.asyncLayout(self.titleNode)
let makeSubtitleLayout = TextNode.asyncLayout(self.subtitleNode)
var titleSideInset: CGFloat = 12.0
if !rateButtonHidden {
titleSideInset += 52.0
}
let (titleLayout, titleApply) = makeTitleLayout(TextNodeLayoutArguments(attributedString: titleString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .middle, constrainedSize: CGSize(width: size.width - titleSideInset, height: 100.0), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
let (subtitleLayout, subtitleApply) = makeSubtitleLayout(TextNodeLayoutArguments(attributedString: subtitleString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .middle, constrainedSize: CGSize(width: size.width - titleSideInset, height: 100.0), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
let _ = titleApply()
let _ = subtitleApply()
let minimizedTitleOffset: CGFloat = subtitleString == nil ? 6.0 : 0.0
let minimizedTitleFrame = CGRect(origin: CGPoint(x: floor((size.width - titleLayout.size.width) / 2.0), y: 4.0 + minimizedTitleOffset), size: titleLayout.size)
let minimizedSubtitleFrame = CGRect(origin: CGPoint(x: floor((size.width - subtitleLayout.size.width) / 2.0), y: 20.0), size: subtitleLayout.size)
transition.updateFrame(node: self.titleNode, frame: minimizedTitleFrame)
transition.updateFrame(node: self.subtitleNode, frame: minimizedSubtitleFrame)
return (titleString, subtitleString, rateButtonHidden)
}
}
private func generateMaskImage(color: UIColor) -> UIImage? {
return generateImage(CGSize(width: 12.0, height: 2.0), opaque: false, rotatedContext: { size, context in
let bounds = CGRect(origin: CGPoint(), size: size)
context.clear(bounds)
let gradientColors = [color.cgColor, color.withAlphaComponent(0.0).cgColor] as CFArray
var locations: [CGFloat] = [0.0, 1.0]
let colorSpace = CGColorSpaceCreateDeviceRGB()
let gradient = CGGradient(colorsSpace: colorSpace, colors: gradientColors, locations: &locations)!
context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: 12.0, y: 0.0), options: CGGradientDrawingOptions())
})
}
public final class MediaNavigationAccessoryHeaderNode: ASDisplayNode, UIScrollViewDelegate {
public static let minimizedHeight: CGFloat = 37.0
private var theme: PresentationTheme
private var strings: PresentationStrings
private var dateTimeFormat: PresentationDateTimeFormat
private var nameDisplayOrder: PresentationPersonNameOrder
private let scrollNode: ASScrollNode
private var initialContentOffset: CGFloat?
private let leftMaskNode: ASImageNode
private let rightMaskNode: ASImageNode
private let currentItemNode: MediaHeaderItemNode
private let previousItemNode: MediaHeaderItemNode
private let nextItemNode: MediaHeaderItemNode
private let closeButton: HighlightableButtonNode
private let actionButton: HighlightTrackingButtonNode
private let playPauseIconNode: PlayPauseIconNode
private let rateButton: HighlightableButtonNode
private let accessibilityAreaNode: AccessibilityAreaNode
private let scrubbingNode: MediaPlayerScrubbingNode
private var validLayout: (CGSize, CGFloat, CGFloat)?
public var displayScrubber: Bool = true {
didSet {
self.scrubbingNode.isHidden = !self.displayScrubber
}
}
private let separatorNode: ASDisplayNode
private var tapRecognizer: UITapGestureRecognizer?
public var tapAction: (() -> Void)?
public var close: (() -> Void)?
public var toggleRate: (() -> Void)?
public var togglePlayPause: (() -> Void)?
public var playPrevious: (() -> Void)?
public var playNext: (() -> Void)?
public var playbackBaseRate: AudioPlaybackRate? = nil {
didSet {
guard self.playbackBaseRate != oldValue, let playbackBaseRate = self.playbackBaseRate else {
return
}
switch playbackBaseRate {
case .x1:
self.rateButton.setImage(PresentationResourcesRootController.navigationPlayerRateInactiveIcon(self.theme), for: [])
self.rateButton.accessibilityLabel = self.strings.VoiceOver_Media_PlaybackRate
self.rateButton.accessibilityValue = self.strings.VoiceOver_Media_PlaybackRateNormal
self.rateButton.accessibilityHint = self.strings.VoiceOver_Media_PlaybackRateChange
case .x2:
self.rateButton.setImage(PresentationResourcesRootController.navigationPlayerRateActiveIcon(self.theme), for: [])
self.rateButton.accessibilityLabel = self.strings.VoiceOver_Media_PlaybackRate
self.rateButton.accessibilityValue = self.strings.VoiceOver_Media_PlaybackRateFast
self.rateButton.accessibilityHint = self.strings.VoiceOver_Media_PlaybackRateChange
default:
break
}
}
}
public var playbackStatus: Signal<MediaPlayerStatus, NoError>? {
didSet {
self.scrubbingNode.status = self.playbackStatus
}
}
public var playbackItems: (SharedMediaPlaylistItem?, SharedMediaPlaylistItem?, SharedMediaPlaylistItem?)? {
didSet {
if !arePlaylistItemsEqual(self.playbackItems?.0, oldValue?.0) || !arePlaylistItemsEqual(self.playbackItems?.1, oldValue?.1) || !arePlaylistItemsEqual(self.playbackItems?.2, oldValue?.2), let layout = validLayout {
self.updateLayout(size: layout.0, leftInset: layout.1, rightInset: layout.2, transition: .immediate)
}
}
}
public init(presentationData: PresentationData) {
self.theme = presentationData.theme
self.strings = presentationData.strings
self.dateTimeFormat = presentationData.dateTimeFormat
self.nameDisplayOrder = presentationData.nameDisplayOrder
self.scrollNode = ASScrollNode()
self.currentItemNode = MediaHeaderItemNode()
self.previousItemNode = MediaHeaderItemNode()
self.nextItemNode = MediaHeaderItemNode()
self.leftMaskNode = ASImageNode()
self.leftMaskNode.contentMode = .scaleToFill
self.rightMaskNode = ASImageNode()
self.rightMaskNode.contentMode = .scaleToFill
let maskImage = generateMaskImage(color: self.theme.rootController.navigationBar.backgroundColor)
self.leftMaskNode.image = maskImage
self.rightMaskNode.image = maskImage
self.closeButton = HighlightableButtonNode()
self.closeButton.accessibilityLabel = presentationData.strings.VoiceOver_Media_PlaybackStop
self.closeButton.setImage(PresentationResourcesRootController.navigationPlayerCloseButton(self.theme), for: [])
self.closeButton.hitTestSlop = UIEdgeInsets(top: -8.0, left: -8.0, bottom: -8.0, right: -8.0)
self.closeButton.contentEdgeInsets = UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 2.0)
self.closeButton.displaysAsynchronously = false
self.rateButton = HighlightableButtonNode()
self.rateButton.hitTestSlop = UIEdgeInsets(top: -8.0, left: -4.0, bottom: -8.0, right: -4.0)
self.rateButton.displaysAsynchronously = false
self.accessibilityAreaNode = AccessibilityAreaNode()
self.actionButton = HighlightTrackingButtonNode()
self.actionButton.hitTestSlop = UIEdgeInsets(top: -8.0, left: -8.0, bottom: -8.0, right: -8.0)
self.actionButton.displaysAsynchronously = false
self.playPauseIconNode = PlayPauseIconNode()
self.playPauseIconNode.customColor = self.theme.rootController.navigationBar.accentTextColor
self.scrubbingNode = MediaPlayerScrubbingNode(content: .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: []))
self.separatorNode = ASDisplayNode()
self.separatorNode.isLayerBacked = true
self.separatorNode.backgroundColor = theme.rootController.navigationBar.separatorColor
super.init()
self.clipsToBounds = true
self.addSubnode(self.scrollNode)
self.scrollNode.addSubnode(self.currentItemNode)
self.scrollNode.addSubnode(self.previousItemNode)
self.scrollNode.addSubnode(self.nextItemNode)
self.addSubnode(self.leftMaskNode)
self.addSubnode(self.rightMaskNode)
self.addSubnode(self.closeButton)
self.addSubnode(self.rateButton)
self.addSubnode(self.accessibilityAreaNode)
self.actionButton.addSubnode(self.playPauseIconNode)
self.addSubnode(self.actionButton)
self.closeButton.addTarget(self, action: #selector(self.closeButtonPressed), forControlEvents: .touchUpInside)
self.rateButton.addTarget(self, action: #selector(self.rateButtonPressed), forControlEvents: .touchUpInside)
self.actionButton.addTarget(self, action: #selector(self.actionButtonPressed), forControlEvents: .touchUpInside)
self.addSubnode(self.scrubbingNode)
self.addSubnode(self.separatorNode)
self.actionButton.highligthedChanged = { [weak self] highlighted in
if let strongSelf = self {
if highlighted {
strongSelf.actionButton.layer.removeAnimation(forKey: "opacity")
strongSelf.actionButton.alpha = 0.4
} else {
strongSelf.actionButton.alpha = 1.0
strongSelf.actionButton.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
}
}
}
self.scrubbingNode.playerStatusUpdated = { [weak self] status in
guard let strongSelf = self else {
return
}
if let status = status {
let baseRate: AudioPlaybackRate
if status.baseRate.isEqual(to: 1.0) {
baseRate = .x1
} else {
baseRate = .x2
}
strongSelf.playbackBaseRate = baseRate
} else {
strongSelf.playbackBaseRate = .x1
}
}
self.scrubbingNode.playbackStatusUpdated = { [weak self] status in
if let strongSelf = self {
let paused: Bool
if let status = status {
switch status {
case .paused:
paused = true
case let .buffering(_, whilePlaying, _, _):
paused = !whilePlaying
case .playing:
paused = false
}
} else {
paused = true
}
strongSelf.playPauseIconNode.enqueueState(paused ? .play : .pause, animated: true)
strongSelf.actionButton.accessibilityLabel = paused ? strongSelf.strings.VoiceOver_Media_PlaybackPlay : strongSelf.strings.VoiceOver_Media_PlaybackPause
}
}
}
override public func didLoad() {
super.didLoad()
self.view.disablesInteractiveTransitionGestureRecognizer = true
self.scrollNode.view.alwaysBounceHorizontal = true
self.scrollNode.view.delegate = self
self.scrollNode.view.isPagingEnabled = true
self.scrollNode.view.showsHorizontalScrollIndicator = false
self.scrollNode.view.showsVerticalScrollIndicator = false
let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))
self.tapRecognizer = tapRecognizer
self.view.addGestureRecognizer(tapRecognizer)
}
public func updatePresentationData(_ presentationData: PresentationData) {
self.theme = presentationData.theme
self.strings = presentationData.strings
self.nameDisplayOrder = presentationData.nameDisplayOrder
self.dateTimeFormat = presentationData.dateTimeFormat
let maskImage = generateMaskImage(color: self.theme.rootController.navigationBar.backgroundColor)
self.leftMaskNode.image = maskImage
self.rightMaskNode.image = maskImage
self.closeButton.setImage(PresentationResourcesRootController.navigationPlayerCloseButton(self.theme), for: [])
self.playPauseIconNode.customColor = self.theme.rootController.navigationBar.accentTextColor
self.separatorNode.backgroundColor = self.theme.rootController.navigationBar.separatorColor
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 .x1:
self.rateButton.setImage(PresentationResourcesRootController.navigationPlayerRateInactiveIcon(self.theme), for: [])
case .x2:
self.rateButton.setImage(PresentationResourcesRootController.navigationPlayerRateActiveIcon(self.theme), for: [])
default:
break
}
}
if let (size, leftInset, rightInset) = self.validLayout {
self.updateLayout(size: size, leftInset: leftInset, rightInset: rightInset, transition: .immediate)
}
}
public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
if scrollView.isDecelerating {
self.changeTrack()
}
self.rateButton.alpha = 0.0
self.rateButton.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2)
}
public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
self.changeTrack()
self.rateButton.alpha = 1.0
self.rateButton.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
guard !decelerate else {
return
}
self.changeTrack()
self.rateButton.alpha = 1.0
self.rateButton.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
private func changeTrack() {
guard let initialContentOffset = self.initialContentOffset, abs(initialContentOffset - self.scrollNode.view.contentOffset.x) > self.bounds.width / 2.0 else {
return
}
if self.scrollNode.view.contentOffset.x < initialContentOffset {
self.playPrevious?()
} else if self.scrollNode.view.contentOffset.x > initialContentOffset {
self.playNext?()
}
}
public func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, transition: ContainedViewLayoutTransition) {
self.validLayout = (size, leftInset, rightInset)
let minHeight = MediaNavigationAccessoryHeaderNode.minimizedHeight
let inset: CGFloat = 40.0 + leftInset
let constrainedSize = CGSize(width: size.width - inset * 2.0, height: size.height)
let (titleString, subtitleString, rateButtonHidden) = self.currentItemNode.updateLayout(size: constrainedSize, leftInset: leftInset, rightInset: rightInset, theme: self.theme, strings: self.strings, dateTimeFormat: self.dateTimeFormat, nameDisplayOrder: self.nameDisplayOrder, playbackItem: self.playbackItems?.0, transition: transition)
self.accessibilityAreaNode.accessibilityLabel = "\(titleString?.string ?? ""). \(subtitleString?.string ?? "")"
self.rateButton.isHidden = rateButtonHidden
let _ = self.previousItemNode.updateLayout(size: constrainedSize, leftInset: 0.0, rightInset: 0.0, theme: self.theme, strings: self.strings, dateTimeFormat: self.dateTimeFormat, nameDisplayOrder: self.nameDisplayOrder, playbackItem: self.playbackItems?.1, transition: transition)
let _ = self.nextItemNode.updateLayout(size: constrainedSize, leftInset: 0.0, rightInset: 0.0, theme: self.theme, strings: self.strings, dateTimeFormat: self.dateTimeFormat, nameDisplayOrder: self.nameDisplayOrder, playbackItem: self.playbackItems?.2, transition: transition)
let constrainedBounds = CGRect(origin: CGPoint(), size: constrainedSize)
transition.updateFrame(node: self.scrollNode, frame: constrainedBounds.offsetBy(dx: inset, dy: 0.0))
var contentSize = constrainedSize
var contentOffset: CGFloat = 0.0
if self.playbackItems?.1 != nil {
contentSize.width += constrainedSize.width
contentOffset = constrainedSize.width
}
if self.playbackItems?.2 != nil {
contentSize.width += constrainedSize.width
}
self.previousItemNode.frame = constrainedBounds.offsetBy(dx: contentOffset - constrainedSize.width, dy: 0.0)
self.currentItemNode.frame = constrainedBounds.offsetBy(dx: contentOffset, dy: 0.0)
self.nextItemNode.frame = constrainedBounds.offsetBy(dx: contentOffset + constrainedSize.width, dy: 0.0)
self.leftMaskNode.frame = CGRect(x: inset, y: 0.0, width: 12.0, height: minHeight)
self.rightMaskNode.transform = CATransform3DMakeScale(-1.0, 1.0, 1.0)
self.rightMaskNode.frame = CGRect(x: size.width - inset - 12.0, y: 0.0, width: 12.0, height: minHeight)
if !self.scrollNode.view.isTracking && !self.scrollNode.view.isTracking {
self.scrollNode.view.contentSize = contentSize
self.scrollNode.view.contentOffset = CGPoint(x: contentOffset, y: 0.0)
self.initialContentOffset = contentOffset
}
let bounds = CGRect(origin: CGPoint(), size: size)
let closeButtonSize = self.closeButton.measure(CGSize(width: 100.0, height: 100.0))
transition.updateFrame(node: self.closeButton, frame: CGRect(origin: CGPoint(x: bounds.size.width - 44.0 - rightInset, y: 0.0), size: CGSize(width: 44.0, height: minHeight)))
let rateButtonSize = CGSize(width: 24.0, height: minHeight)
transition.updateFrame(node: self.rateButton, frame: CGRect(origin: CGPoint(x: bounds.size.width - 18.0 - closeButtonSize.width - 17.0 - rateButtonSize.width - rightInset, y: 0.0), size: rateButtonSize))
transition.updateFrame(node: self.playPauseIconNode, frame: CGRect(origin: CGPoint(x: 6.0, y: 4.0 + UIScreenPixel), size: CGSize(width: 28.0, height: 28.0)))
transition.updateFrame(node: self.actionButton, frame: CGRect(origin: CGPoint(x: leftInset, y: 0.0), size: CGSize(width: 40.0, height: 37.0)))
transition.updateFrame(node: self.scrubbingNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 37.0 - 2.0), size: CGSize(width: size.width, height: 2.0)))
transition.updateFrame(node: self.separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: minHeight - UIScreenPixel), size: CGSize(width: size.width, height: UIScreenPixel)))
self.accessibilityAreaNode.frame = CGRect(origin: CGPoint(x: self.actionButton.frame.maxX, y: 0.0), size: CGSize(width: self.rateButton.frame.minX - self.actionButton.frame.maxX, height: minHeight))
}
@objc public func closeButtonPressed() {
self.close?()
}
@objc public func rateButtonPressed() {
self.toggleRate?()
}
@objc public func actionButtonPressed() {
self.togglePlayPause?()
}
@objc public func tapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
self.tapAction?()
}
}
}
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: 28.0, height: 28.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
}
}
}
}