Swiftgram/submodules/TelegramBaseController/Sources/MediaNavigationAccessoryHeaderNode.swift
Ilya Laktyushin 32effd4291 Various fixes
2020-10-28 04:46:09 +04:00

493 lines
25 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
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 actionPauseNode: ASImageNode
private let actionPlayNode: ASImageNode
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
}
}
}
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.actionPauseNode = ASImageNode()
self.actionPauseNode.contentMode = .center
self.actionPauseNode.isLayerBacked = true
self.actionPauseNode.displaysAsynchronously = false
self.actionPauseNode.displayWithoutProcessing = true
self.actionPauseNode.image = PresentationResourcesRootController.navigationPlayerPauseIcon(self.theme)
self.actionPlayNode = ASImageNode()
self.actionPlayNode.contentMode = .center
self.actionPlayNode.isLayerBacked = true
self.actionPlayNode.displaysAsynchronously = false
self.actionPlayNode.displayWithoutProcessing = true
self.actionPlayNode.image = PresentationResourcesRootController.navigationPlayerPlayIcon(self.theme)
self.actionPlayNode.isHidden = true
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.actionPauseNode)
self.actionButton.addSubnode(self.actionPlayNode)
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.actionPlayNode.isHidden = !paused
strongSelf.actionPauseNode.isHidden = paused
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.actionPlayNode.image = PresentationResourcesRootController.navigationPlayerPlayIcon(self.theme)
self.actionPauseNode.image = PresentationResourcesRootController.navigationPlayerPauseIcon(self.theme)
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: [])
}
}
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()
}
}
public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
self.changeTrack()
}
public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
guard !decelerate else {
return
}
self.changeTrack()
}
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)
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.actionPlayNode, frame: CGRect(origin: CGPoint(), size: CGSize(width: 40.0, height: 37.0)))
transition.updateFrame(node: self.actionPauseNode, frame: CGRect(origin: CGPoint(), size: CGSize(width: 40.0, height: 37.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?()
}
}
}