Merge commit '7621e2f8dec938cf48181c8b10afc9b01f444e68' into beta

This commit is contained in:
Ilya Laktyushin
2025-12-06 02:17:48 +04:00
commit 8344b97e03
28070 changed files with 7995182 additions and 0 deletions
@@ -0,0 +1,313 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import TelegramCore
import TelegramPresentationData
import TelegramUIPreferences
import AccountContext
import ComponentFlow
import MultilineTextComponent
import BundleIconComponent
import ButtonComponent
import GiftItemComponent
import AnimatedTextComponent
private let titleFont = Font.semibold(15.0)
private let subtitleFont = Font.regular(14.0)
final class GiftAuctionAccessoryPanel: ASDisplayNode {
private let context: AccountContext
private var theme: PresentationTheme
private var strings: PresentationStrings
private let tapAction: () -> Void
private let contentNode: ASDisplayNode
private let title = ComponentView<Empty>()
private let subtitle = ComponentView<Empty>()
private let button = ComponentView<Empty>()
private let separatorNode: ASDisplayNode
private var validLayout: (CGSize, CGFloat, CGFloat, Bool)?
private var states: [GiftAuctionContext.State] = []
private var giftAuctionTimer: SwiftSignalKit.Timer?
init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, tapAction: @escaping () -> Void) {
self.context = context
self.theme = theme
self.strings = strings
self.tapAction = tapAction
self.contentNode = ASDisplayNode()
self.separatorNode = ASDisplayNode()
self.separatorNode.isLayerBacked = true
self.separatorNode.backgroundColor = theme.rootController.navigationBar.separatorColor
super.init()
self.clipsToBounds = true
self.addSubnode(self.contentNode)
self.contentNode.addSubnode(self.separatorNode)
self.giftAuctionTimer = SwiftSignalKit.Timer(timeout: 0.5, repeat: true, completion: { [weak self] in
if let self, let (size, leftInset, rightInset, isHidden) = self.validLayout {
self.updateLayout(size: size, leftInset: leftInset, rightInset: rightInset, isHidden: isHidden, transition: .immediate)
}
}, queue: Queue.mainQueue())
self.giftAuctionTimer?.start()
}
deinit {
self.giftAuctionTimer?.invalidate()
}
override func didLoad() {
super.didLoad()
let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))
self.view.addGestureRecognizer(tapRecognizer)
}
func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, isHidden: Bool, transition: ContainedViewLayoutTransition) {
self.validLayout = (size, leftInset, rightInset, isHidden)
transition.updateFrame(node: self.contentNode, frame: CGRect(origin: CGPoint(x: 0.0, y: isHidden ? -size.height : 0.0), size: size))
transition.updateAlpha(node: self.contentNode, alpha: isHidden ? 0.0 : 1.0)
guard self.states.count > 0 else {
return
}
var titleItems: [AnyComponentWithIdentity<Empty>] = []
for auctionState in self.states {
if case let .generic(gift) = auctionState.gift {
titleItems.append(AnyComponentWithIdentity(id: "icon-\(gift.id)", component: AnyComponent(
GiftItemComponent(
context: self.context,
theme: self.theme,
strings: self.strings,
peer: nil,
subject: .starGift(gift: gift, price: ""),
mode: .tableIcon
)
)))
}
}
let currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970)
var titleText: String = self.strings.ChatList_Auctions_ActiveAuction(Int32(self.states.count))
let subtitleText: String
var subtitleTextColor = self.theme.rootController.navigationBar.secondaryTextColor
var isOutbid = false
var buttonAnimatedTitleItems: [AnimatedTextComponent.Item] = []
if self.states.count == 1, let auctionState = self.states.first {
var isUpcoming = false
var startTime = currentTime
var endTime = currentTime
if case let .ongoing(_, startDate, _, _, _, _, nextRoundDate, _, _, _, _, _) = auctionState.auctionState {
startTime = startDate
endTime = nextRoundDate
if currentTime < startDate {
isUpcoming = true
}
}
let place = auctionState.place ?? 1
if isUpcoming {
subtitleText = self.strings.ChatList_Auctions_Status_UpcomingBid
} else if case let .generic(gift) = auctionState.gift, let auctionGiftsPerRound = gift.auctionGiftsPerRound, place > auctionGiftsPerRound {
subtitleText = self.strings.ChatList_Auctions_Status_Single_Outbid
subtitleTextColor = self.theme.list.itemDestructiveColor
isOutbid = true
} else {
let placeText: String
let lastDigit = place % 10
switch lastDigit {
case 1:
placeText = self.strings.ChatList_Auctions_Status_Single_PlaceFirst("\(place)").string
case 2:
placeText = self.strings.ChatList_Auctions_Status_Single_PlaceSecond("\(place)").string
case 3:
placeText = self.strings.ChatList_Auctions_Status_Single_PlaceThird("\(place)").string
default:
placeText = self.strings.ChatList_Auctions_Status_Single_PlaceNTh("\(place)").string
}
subtitleText = self.strings.ChatList_Auctions_Status_Single_Winning(placeText).string
}
let endTimeout: Int32
if currentTime < startTime {
endTimeout = max(0, startTime - currentTime)
titleText = self.strings.ChatList_Auctions_UpcomingAuction
} else {
endTimeout = max(0, endTime - currentTime)
}
let hours = Int(endTimeout / 3600)
let minutes = Int((endTimeout % 3600) / 60)
let seconds = Int(endTimeout % 60)
if hours > 0 {
buttonAnimatedTitleItems.append(AnimatedTextComponent.Item(id: "h", content: .number(hours, minDigits: 1)))
buttonAnimatedTitleItems.append(AnimatedTextComponent.Item(id: "colon1", content: .text(":")))
buttonAnimatedTitleItems.append(AnimatedTextComponent.Item(id: "m", content: .number(minutes, minDigits: 2)))
buttonAnimatedTitleItems.append(AnimatedTextComponent.Item(id: "colon2", content: .text(":")))
buttonAnimatedTitleItems.append(AnimatedTextComponent.Item(id: "s", content: .number(seconds, minDigits: 2)))
} else {
buttonAnimatedTitleItems.append(AnimatedTextComponent.Item(id: "m", content: .number(minutes, minDigits: 2)))
buttonAnimatedTitleItems.append(AnimatedTextComponent.Item(id: "colon2", content: .text(":")))
buttonAnimatedTitleItems.append(AnimatedTextComponent.Item(id: "s", content: .number(seconds, minDigits: 2)))
}
} else {
var outbidCount = 0
for auctionState in self.states {
let place = auctionState.place ?? 1
if case let .generic(gift) = auctionState.gift, let auctionGiftsPerRound = gift.auctionGiftsPerRound, place > auctionGiftsPerRound {
outbidCount += 1
}
}
if outbidCount > 0 {
if outbidCount == self.states.count {
subtitleText = self.strings.ChatList_Auctions_Status_Many_OutbidAll
} else {
subtitleText = self.strings.ChatList_Auctions_Status_Many_Outbid(Int32(outbidCount))
}
subtitleTextColor = self.theme.list.itemDestructiveColor
isOutbid = true
} else {
subtitleText = self.strings.ChatList_Auctions_Status_Many_WinningAll
}
buttonAnimatedTitleItems.append(AnimatedTextComponent.Item(id: "view", content: .text(self.strings.ChatList_Auctions_View)))
}
titleItems.append(AnyComponentWithIdentity(id: "label", component: AnyComponent(MultilineTextComponent(text: .plain(NSAttributedString(string: titleText, font: titleFont, textColor: self.theme.rootController.navigationBar.primaryTextColor))))))
let buttonSize = self.button.update(
transition: .spring(duration: 0.2),
component: AnyComponent(
ButtonComponent(
background: ButtonComponent.Background(
color: self.theme.list.itemCheckColors.fillColor,
foreground: self.theme.list.itemCheckColors.foregroundColor,
pressedColor: self.theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.9),
cornerRadius: 14.0,
isShimmering: isOutbid
),
content: AnyComponentWithIdentity(
id: "content",
component: AnyComponent(HStack([
AnyComponentWithIdentity(id: "icon", component: AnyComponent(BundleIconComponent(name: "Premium/Auction/BidSmall", tintColor: self.theme.list.itemCheckColors.foregroundColor))),
AnyComponentWithIdentity(id: "timer", component: AnyComponent(
AnimatedTextComponent(
font: Font.with(size: 15.0, weight: .semibold, traits: .monospacedNumbers),
color: self.theme.list.itemCheckColors.foregroundColor,
items: buttonAnimatedTitleItems,
noDelay: true
)
))
], spacing: 3.0))
),
fitToContentWidth: true,
action: { [weak self] in
guard let self else {
return
}
self.tapAction()
}
)
),
environment: {},
containerSize: CGSize(width: size.width, height: 28.0)
)
let buttonFrame = CGRect(origin: CGPoint(x: size.width - rightInset - buttonSize.width - 16.0, y: 14.0), size: buttonSize)
if let buttonView = self.button.view {
if buttonView.superview == nil {
self.contentNode.view.addSubview(buttonView)
}
buttonView.frame = buttonFrame
}
let titleSize = self.title.update(
transition: .immediate,
component: AnyComponent(
HStack(titleItems, spacing: 3.0, alignment: .left)
),
environment: {},
containerSize: CGSize(width: size.width - buttonSize.width - 48.0, height: size.height)
)
let titleFrame = CGRect(origin: CGPoint(x: 16.0, y: 9.0), size: titleSize)
if let titleView = self.title.view {
if titleView.superview == nil {
self.contentNode.view.addSubview(titleView)
}
titleView.frame = titleFrame
}
let subtitleSize = self.subtitle.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(text: .plain(NSAttributedString(string: subtitleText, font: subtitleFont, textColor: subtitleTextColor)))),
environment: {},
containerSize: CGSize(width: size.width - buttonSize.width - 40.0, height: size.height)
)
let subtitleFrame = CGRect(origin: CGPoint(x: 16.0, y: 29.0), size: subtitleSize)
if let subtitleView = self.subtitle.view {
if subtitleView.superview == nil {
self.contentNode.view.addSubview(subtitleView)
}
subtitleView.frame = subtitleFrame
}
transition.updateFrame(node: self.separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: UIScreenPixel)))
}
func update(states: [GiftAuctionContext.State]) {
self.states = states
if let (size, leftInset, rightInset, isHidden) = self.validLayout {
self.updateLayout(size: size, leftInset: leftInset, rightInset: rightInset, isHidden: isHidden, transition: .immediate)
}
}
func animateIn(_ transition: ContainedViewLayoutTransition) {
let contentPosition = self.contentNode.layer.position
transition.animatePosition(node: self.contentNode, from: CGPoint(x: contentPosition.x, y: contentPosition.y - 56.0))
guard let (size, _, _, _) = self.validLayout else {
return
}
transition.animatePositionAdditive(node: self.separatorNode, offset: CGPoint(x: 0.0, y: size.height))
}
func animateOut(_ transition: ContainedViewLayoutTransition, completion: @escaping () -> Void) {
let contentPosition = self.contentNode.layer.position
transition.animatePosition(node: self.contentNode, to: CGPoint(x: contentPosition.x, y: contentPosition.y - 56.0), removeOnCompletion: false, completion: { _ in
completion()
})
guard let (size, _, _, _) = self.validLayout else {
return
}
transition.updatePosition(node: self.separatorNode, position: self.separatorNode.position.offsetBy(dx: 0.0, dy: size.height))
}
@objc func tapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
self.tapAction()
}
}
}
@@ -0,0 +1,139 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramCore
import Postbox
import TelegramPresentationData
import AccountContext
import LiveLocationTimerNode
import AvatarNode
public class LocationBroadcastActionSheetItem: ActionSheetItem {
public let context: AccountContext
public let peer: Peer
public let title: String
public let beginTimestamp: Double
public let timeout: Double
public let strings: PresentationStrings
public let action: () -> Void
public init(context: AccountContext, peer: Peer, title: String, beginTimestamp: Double, timeout: Double, strings: PresentationStrings, action: @escaping () -> Void) {
self.context = context
self.peer = peer
self.title = title
self.beginTimestamp = beginTimestamp
self.timeout = timeout
self.strings = strings
self.action = action
}
public func node(theme: ActionSheetControllerTheme) -> ActionSheetItemNode {
let node = LocationBroadcastActionSheetItemNode(theme: theme)
node.setItem(self)
return node
}
public func updateNode(_ node: ActionSheetItemNode) {
guard let node = node as? LocationBroadcastActionSheetItemNode else {
assertionFailure()
return
}
node.setItem(self)
node.requestLayoutUpdate()
}
}
private let avatarFont = avatarPlaceholderFont(size: 15.0)
public class LocationBroadcastActionSheetItemNode: ActionSheetItemNode {
private let theme: ActionSheetControllerTheme
private let defaultFont: UIFont
private var item: LocationBroadcastActionSheetItem?
private let button: HighlightTrackingButton
private let avatarNode: AvatarNode
private let label: ImmediateTextNode
private let timerNode: ChatMessageLiveLocationTimerNode
override public init(theme: ActionSheetControllerTheme) {
self.theme = theme
self.defaultFont = Font.regular(floor(theme.baseFontSize * 20.0 / 17.0))
self.button = HighlightTrackingButton()
self.avatarNode = AvatarNode(font: avatarFont)
self.avatarNode.isLayerBacked = !smartInvertColorsEnabled()
self.label = ImmediateTextNode()
self.label.isUserInteractionEnabled = false
self.label.displaysAsynchronously = false
self.label.maximumNumberOfLines = 1
self.timerNode = ChatMessageLiveLocationTimerNode()
super.init(theme: theme)
self.view.addSubview(self.button)
self.addSubnode(self.avatarNode)
self.addSubnode(self.label)
self.addSubnode(self.timerNode)
self.button.highligthedChanged = { [weak self] highlighted in
if let strongSelf = self {
if highlighted {
strongSelf.backgroundNode.backgroundColor = strongSelf.theme.itemHighlightedBackgroundColor
} else {
UIView.animate(withDuration: 0.3, animations: {
strongSelf.backgroundNode.backgroundColor = strongSelf.theme.itemBackgroundColor
})
}
}
}
self.button.addTarget(self, action: #selector(self.buttonPressed), for: .touchUpInside)
}
func setItem(_ item: LocationBroadcastActionSheetItem) {
self.item = item
let defaultFont = Font.regular(floor(theme.baseFontSize * 20.0 / 17.0))
let textColor: UIColor = self.theme.primaryTextColor
self.label.attributedText = NSAttributedString(string: item.title, font: defaultFont, textColor: textColor)
self.avatarNode.setPeer(context: item.context, theme: (item.context.sharedContext.currentPresentationData.with { $0 }).theme, peer: EnginePeer(item.peer))
self.timerNode.update(backgroundColor: self.theme.controlAccentColor.withAlphaComponent(0.4), foregroundColor: self.theme.controlAccentColor, textColor: self.theme.controlAccentColor, beginTimestamp: item.beginTimestamp, timeout: Int32(item.timeout) == liveLocationIndefinitePeriod ? -1.0 : item.timeout, strings: item.strings)
}
public override func updateLayout(constrainedSize: CGSize, transition: ContainedViewLayoutTransition) -> CGSize {
let size = CGSize(width: constrainedSize.width, height: 57.0)
self.button.frame = CGRect(origin: CGPoint(), size: size)
let avatarInset: CGFloat = 42.0
let avatarSize: CGFloat = 32.0
self.avatarNode.frame = CGRect(origin: CGPoint(x: 16.0, y: floor((size.height - avatarSize) / 2.0)), size: CGSize(width: avatarSize, height: avatarSize))
let labelSize = self.label.updateLayout(CGSize(width: max(1.0, size.width - avatarInset - 16.0 - 16.0 - 30.0), height: size.height))
self.label.frame = CGRect(origin: CGPoint(x: 16.0 + avatarInset, y: floorToScreenPixels((size.height - labelSize.height) / 2.0)), size: labelSize)
let timerSize = CGSize(width: 28.0, height: 28.0)
self.timerNode.frame = CGRect(origin: CGPoint(x: size.width - 16.0 - timerSize.width, y: floorToScreenPixels((size.height - timerSize.height) / 2.0)), size: timerSize)
self.updateInternalLayout(size, constrainedSize: constrainedSize)
return size
}
@objc func buttonPressed() {
if let item = self.item {
item.action()
}
}
}
@@ -0,0 +1,224 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramCore
import TelegramPresentationData
import TelegramUIPreferences
import TextFormat
import Markdown
import LocalizedPeerData
import LiveLocationTimerNode
private let titleFont = Font.regular(12.0)
private let subtitleFont = Font.regular(10.0)
enum LocationBroadcastNavigationAccessoryPanelMode {
case summary
case peer
}
final class LocationBroadcastNavigationAccessoryPanel: ASDisplayNode {
private let accountPeerId: EnginePeer.Id
private var theme: PresentationTheme
private var strings: PresentationStrings
private var nameDisplayOrder: PresentationPersonNameOrder
private let tapAction: () -> Void
private let close: () -> Void
private let contentNode: ASDisplayNode
private let iconNode: ASImageNode
private let wavesNode: LiveLocationWavesNode
private let titleNode: TextNode
private let subtitleNode: TextNode
private let closeButton: HighlightableButtonNode
private let separatorNode: ASDisplayNode
private var validLayout: (CGSize, CGFloat, CGFloat, Bool)?
private var peersAndMode: ([EnginePeer], LocationBroadcastNavigationAccessoryPanelMode, Bool)?
init(accountPeerId: EnginePeer.Id, theme: PresentationTheme, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, tapAction: @escaping () -> Void, close: @escaping () -> Void) {
self.accountPeerId = accountPeerId
self.theme = theme
self.strings = strings
self.nameDisplayOrder = nameDisplayOrder
self.tapAction = tapAction
self.close = close
self.contentNode = ASDisplayNode()
self.iconNode = ASImageNode()
self.iconNode.isLayerBacked = true
self.iconNode.displayWithoutProcessing = true
self.iconNode.displaysAsynchronously = false
self.iconNode.image = PresentationResourcesRootController.navigationLiveLocationIcon(self.theme)
self.wavesNode = LiveLocationWavesNode(color: self.theme.rootController.navigationBar.accentTextColor)
self.titleNode = TextNode()
self.titleNode.isUserInteractionEnabled = false
self.subtitleNode = TextNode()
self.subtitleNode.isUserInteractionEnabled = false
self.closeButton = HighlightableButtonNode()
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.displaysAsynchronously = false
self.separatorNode = ASDisplayNode()
self.separatorNode.isLayerBacked = true
self.separatorNode.backgroundColor = theme.rootController.navigationBar.separatorColor
super.init()
self.clipsToBounds = true
self.addSubnode(self.contentNode)
self.contentNode.addSubnode(self.iconNode)
self.contentNode.addSubnode(self.wavesNode)
self.contentNode.addSubnode(self.titleNode)
self.contentNode.addSubnode(self.subtitleNode)
self.contentNode.addSubnode(self.closeButton)
self.contentNode.addSubnode(self.separatorNode)
self.closeButton.addTarget(self, action: #selector(self.closePressed), forControlEvents: .touchUpInside)
}
override func didLoad() {
super.didLoad()
let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))
self.view.addGestureRecognizer(tapRecognizer)
}
func updatePresentationData(_ presentationData: PresentationData) {
self.theme = presentationData.theme
self.strings = presentationData.strings
self.iconNode.image = PresentationResourcesRootController.navigationLiveLocationIcon(self.theme)
self.wavesNode.color = self.theme.rootController.navigationBar.accentTextColor
self.closeButton.setImage(PresentationResourcesRootController.navigationPlayerCloseButton(self.theme), for: [])
self.separatorNode.backgroundColor = theme.rootController.navigationBar.separatorColor
}
func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, isHidden: Bool, transition: ContainedViewLayoutTransition) {
self.validLayout = (size, leftInset, rightInset, isHidden)
transition.updateFrame(node: self.contentNode, frame: CGRect(origin: CGPoint(x: 0.0, y: isHidden ? -size.height : 0.0), size: size))
transition.updateAlpha(node: self.contentNode, alpha: isHidden ? 0.0 : 1.0)
let titleString = NSAttributedString(string: self.strings.Conversation_LiveLocation, font: titleFont, textColor: self.theme.rootController.navigationBar.primaryTextColor)
var subtitleString: NSAttributedString?
if let (peers, mode, canClose) = self.peersAndMode {
switch mode {
case .summary:
let text: String
if peers.count == 1 {
text = self.strings.DialogList_LiveLocationSharingTo(peers[0].displayTitle(strings: self.strings, displayOrder: self.nameDisplayOrder)).string
} else {
text = self.strings.DialogList_LiveLocationChatsCount(Int32(peers.count))
}
subtitleString = NSAttributedString(string: text, font: subtitleFont, textColor: self.theme.rootController.navigationBar.secondaryTextColor)
case .peer:
self.closeButton.isHidden = !canClose
let filteredPeers = peers.filter {
$0.id != self.accountPeerId
}
if filteredPeers.count == 0 {
subtitleString = NSAttributedString(string: self.strings.Conversation_LiveLocationYou, font: subtitleFont, textColor: self.theme.rootController.navigationBar.accentTextColor)
} else {
let otherString: String
if filteredPeers.count == 1 {
otherString = peers[0].compactDisplayTitle.replacingOccurrences(of: "*", with: "")
} else {
otherString = self.strings.Conversation_LiveLocationMembersCount(Int32(peers.count))
}
let rawText: String
if filteredPeers.count != peers.count {
rawText = self.strings.Conversation_LiveLocationYouAndOther(otherString).string
} else {
rawText = otherString
}
let body = MarkdownAttributeSet(font: subtitleFont, textColor: self.theme.rootController.navigationBar.secondaryTextColor)
let accent = MarkdownAttributeSet(font: subtitleFont, textColor: self.theme.rootController.navigationBar.accentTextColor)
subtitleString = parseMarkdownIntoAttributedString(rawText, attributes: MarkdownAttributes(body: body, bold: accent, link: body, linkAttribute: { _ in nil }))
}
}
}
let makeTitleLayout = TextNode.asyncLayout(self.titleNode)
let makeSubtitleLayout = TextNode.asyncLayout(self.subtitleNode)
let (titleLayout, titleApply) = makeTitleLayout(TextNodeLayoutArguments(attributedString: titleString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .middle, constrainedSize: CGSize(width: size.width - 80.0, 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 - 80.0, 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)
if let image = self.iconNode.image {
transition.updateFrame(node: self.iconNode, frame: CGRect(origin: CGPoint(x: 7.0 + leftInset, y: 8.0), size: image.size))
transition.updateFrame(node: self.wavesNode, frame: CGRect(origin: CGPoint(x: -2.0 + leftInset, y: -4.0), size: CGSize(width: 48.0, height: 48.0)))
}
transition.updateFrame(node: self.titleNode, frame: minimizedTitleFrame)
transition.updateFrame(node: self.subtitleNode, frame: minimizedSubtitleFrame)
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 - 18.0 - closeButtonSize.width - rightInset, y: minimizedTitleFrame.minY + 8.0), size: closeButtonSize))
transition.updateFrame(node: self.separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: UIScreenPixel)))
}
func update(peers: [EnginePeer], mode: LocationBroadcastNavigationAccessoryPanelMode, canClose: Bool) {
self.peersAndMode = (peers, mode, canClose)
if let (size, leftInset, rightInset, isHidden) = self.validLayout {
self.updateLayout(size: size, leftInset: leftInset, rightInset: rightInset, isHidden: isHidden, transition: .immediate)
}
}
func animateIn(_ transition: ContainedViewLayoutTransition) {
let contentPosition = self.contentNode.layer.position
transition.animatePosition(node: self.contentNode, from: CGPoint(x: contentPosition.x, y: contentPosition.y - 37.0))
guard let (size, _, _, _) = self.validLayout else {
return
}
transition.animatePositionAdditive(node: self.separatorNode, offset: CGPoint(x: 0.0, y: size.height))
}
func animateOut(_ transition: ContainedViewLayoutTransition, completion: @escaping () -> Void) {
let contentPosition = self.contentNode.layer.position
transition.animatePosition(node: self.contentNode, to: CGPoint(x: contentPosition.x, y: contentPosition.y - 37.0), removeOnCompletion: false, completion: { _ in
completion()
})
guard let (size, _, _, _) = self.validLayout else {
return
}
transition.updatePosition(node: self.separatorNode, position: self.separatorNode.position.offsetBy(dx: 0.0, dy: size.height))
}
@objc func closePressed() {
self.close()
}
@objc func tapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
self.tapAction()
}
}
}
@@ -0,0 +1,75 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramCore
import TelegramPresentationData
import AccountContext
public final class MediaNavigationAccessoryContainerNode: ASDisplayNode, ASGestureRecognizerDelegate {
private let displayBackground: Bool
public let backgroundNode: ASDisplayNode
public let separatorNode: ASDisplayNode
public let headerNode: MediaNavigationAccessoryHeaderNode
private let currentHeaderHeight: CGFloat = MediaNavigationAccessoryHeaderNode.minimizedHeight
private var presentationData: PresentationData
init(context: AccountContext, presentationData: PresentationData, displayBackground: Bool) {
self.displayBackground = displayBackground
self.presentationData = presentationData
self.backgroundNode = ASDisplayNode()
self.separatorNode = ASDisplayNode()
self.headerNode = MediaNavigationAccessoryHeaderNode(context: context, presentationData: presentationData)
super.init()
if self.displayBackground {
self.backgroundNode.backgroundColor = self.presentationData.theme.rootController.navigationBar.opaqueBackgroundColor
self.separatorNode.backgroundColor = self.presentationData.theme.rootController.navigationBar.separatorColor
}
self.addSubnode(self.backgroundNode)
self.addSubnode(self.separatorNode)
self.addSubnode(self.headerNode)
}
func updatePresentationData(_ presentationData: PresentationData) {
self.presentationData = presentationData
if self.displayBackground {
self.backgroundNode.backgroundColor = self.presentationData.theme.rootController.navigationBar.opaqueBackgroundColor
self.separatorNode.backgroundColor = self.presentationData.theme.rootController.navigationBar.separatorColor
}
self.headerNode.updatePresentationData(presentationData)
}
func animateIn(transition: ContainedViewLayoutTransition) {
self.headerNode.animateIn(transition: transition)
}
func animateOut(transition: ContainedViewLayoutTransition) {
self.headerNode.animateOut(transition: transition)
}
func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, transition: ContainedViewLayoutTransition) {
transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: self.currentHeaderHeight)))
transition.updateFrame(node: self.separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: self.currentHeaderHeight - UIScreenPixel), size: CGSize(width: size.width, height: UIScreenPixel)))
let headerHeight = self.currentHeaderHeight
transition.updateFrame(node: self.headerNode, frame: CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: headerHeight)))
self.headerNode.updateLayout(size: CGSize(width: size.width, height: headerHeight), leftInset: leftInset, rightInset: rightInset, transition: transition)
}
override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if !self.headerNode.frame.contains(point) {
return nil
}
return super.hitTest(point, with: event)
}
}
@@ -0,0 +1,827 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import TelegramCore
import TelegramPresentationData
import TelegramUIPreferences
import UniversalMediaPlayer
import AccountContext
import TelegramStringFormatting
import ManagedAnimationNode
import ContextUI
import TelegramNotices
import TooltipUI
import SliderContextItem
private let titleFont = Font.regular(12.0)
private let subtitleFont = Font.regular(10.0)
private func normalizeValue(_ value: CGFloat) -> CGFloat {
return round(value * 10.0) / 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 case let .channel(peer) = peer, case .broadcast = peer.info {
subtitleText = strings.MusicPlayer_VoiceNote
} else if case .legacyGroup = peer {
subtitleText = peer.displayTitle(strings: strings, displayOrder: nameDisplayOrder)
} else if case .channel = peer {
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 {
switch peer {
case .legacyGroup, .channel:
subtitleText = peer.displayTitle(strings: strings, displayOrder: nameDisplayOrder)
default:
subtitleText = strings.Message_VideoMessage
}
} else {
subtitleText = strings.Message_VideoMessage
}
if titleText == subtitleText {
subtitleText = humanReadableStringForTimestamp(strings: strings, dateTimeFormat: dateTimeFormat, timestamp: timestamp).string
}
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, ASScrollViewDelegate {
public static let minimizedHeight: CGFloat = 37.0
private let context: AccountContext
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: AudioRateButton
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 setRate: ((AudioPlaybackRate, MediaNavigationAccessoryPanel.ChangeType) -> Void)?
public var togglePlayPause: (() -> Void)?
public var playPrevious: (() -> Void)?
public var playNext: (() -> Void)?
public var getController: (() -> ViewController?)?
public var presentInGlobalOverlay: ((ViewController) -> Void)?
public var playbackBaseRate: AudioPlaybackRate? = nil {
didSet {
guard self.playbackBaseRate != oldValue, let playbackBaseRate = self.playbackBaseRate else {
return
}
self.rateButton.accessibilityLabel = self.strings.VoiceOver_Media_PlaybackRate
self.rateButton.accessibilityHint = self.strings.VoiceOver_Media_PlaybackRateChange
self.rateButton.accessibilityValue = playbackBaseRate.stringValue
self.rateButton.setContent(.image(optionsRateImage(rate: playbackBaseRate.stringValue.uppercased(), color: self.theme.rootController.navigationBar.controlColor)))
}
}
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)
}
}
}
private weak var tooltipController: TooltipScreen?
private let dismissedPromise = ValuePromise<Bool>(false)
public init(context: AccountContext, presentationData: PresentationData) {
self.context = context
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.opaqueBackgroundColor)
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 = AudioRateButton()
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.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.actionButton.addTarget(self, action: #selector(self.actionButtonPressed), forControlEvents: .touchUpInside)
self.rateButton.addTarget(self, action: #selector(self.rateButtonPressed), forControlEvents: .touchUpInside)
self.rateButton.contextAction = { [weak self] sourceNode, gesture in
self?.openRateMenu(sourceNode: sourceNode, gesture: gesture)
}
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 {
strongSelf.playbackBaseRate = AudioPlaybackRate(status.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.wrappedScrollViewDelegate
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.opaqueBackgroundColor)
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 {
self.rateButton.setContent(.image(optionsRateImage(rate: playbackBaseRate.stringValue.uppercased(), color: self.theme.rootController.navigationBar.controlColor)))
}
if let (size, leftInset, rightInset) = self.validLayout {
self.updateLayout(size: size, leftInset: leftInset, rightInset: rightInset, transition: .immediate)
}
}
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?()
}
}
func animateIn(transition: ContainedViewLayoutTransition) {
guard let (size, _, _) = self.validLayout else {
return
}
transition.animatePositionAdditive(node: self.separatorNode, offset: CGPoint(x: 0.0, y: size.height))
}
func animateOut(transition: ContainedViewLayoutTransition) {
guard let (size, _, _) = self.validLayout else {
return
}
self.tooltipController?.dismiss()
self.dismissedPromise.set(true)
transition.updatePosition(node: self.separatorNode, position: self.separatorNode.position.offsetBy(dx: 0.0, dy: size.height))
}
public func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, transition: ContainedViewLayoutTransition) {
self.validLayout = (size, leftInset, rightInset)
let minHeight = MediaNavigationAccessoryHeaderNode.minimizedHeight
let inset: CGFloat = 45.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: 30.0, height: minHeight)
transition.updateFrame(node: self.rateButton, frame: CGRect(origin: CGPoint(x: bounds.size.width - 27.0 - closeButtonSize.width - rateButtonSize.width - rightInset, y: -4.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: 0.0), 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() {
var changeType: MediaNavigationAccessoryPanel.ChangeType = .preset
let nextRate: AudioPlaybackRate
if let rate = self.playbackBaseRate {
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
}
changeType = .sliderCommit(rate.doubleValue, nextRate.doubleValue)
}
} else {
nextRate = .x1_5
}
self.setRate?(nextRate, changeType)
let frame = self.rateButton.view.convert(self.rateButton.bounds, to: nil)
let _ = (ApplicationSpecificNotice.incrementAudioRateOptionsTip(accountManager: self.context.sharedContext.accountManager)
|> deliverOnMainQueue).start(next: { [weak self] value in
if let strongSelf = self, let controller = strongSelf.getController?(), value == 2 {
let tooltipController = TooltipScreen(account: strongSelf.context.account, sharedContext: strongSelf.context.sharedContext, text: .plain(text: strongSelf.strings.Conversation_AudioRateOptionsTooltip), style: .default, icon: nil, location: .point(frame.offsetBy(dx: 0.0, dy: 4.0), .bottom), displayDuration: .custom(3.0), inset: 3.0, shouldDismissOnTouch: { _, _ in
return .dismiss(consume: false)
})
controller.present(tooltipController, in: .window(.root))
strongSelf.tooltipController = tooltipController
}
})
}
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<ContextController.Items, NoError> {
var presetItems: [ContextMenuItem] = []
let previousRate = self.playbackBaseRate
let previousValue = self.playbackBaseRate?.doubleValue ?? 1.0
let sliderValuePromise = ValuePromise<Double?>(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?.setRate?(AudioPlaybackRate(newValue), .sliderChange)
sliderValuePromise.set(newValue)
if finished {
scheduleTooltip(.sliderCommit(previousValue, newValue))
}
}), true)
let theme = self.context.sharedContext.currentPresentationData.with { $0 }.theme
for (text, _, rate) in self.speedList(strings: self.strings) {
let isSelected = self.playbackBaseRate == 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)
if let previousRate, previousRate.isPreset {
self?.setRate?(rate, .preset)
} else {
self?.setRate?(rate, .sliderCommit(previousValue, rate.doubleValue))
}
})))
}
return .single(ContextController.Items(content: .twoLists(presetItems, [sliderItem])))
}
private func openRateMenu(sourceNode: ASDisplayNode, gesture: ContextGesture?) {
guard let controller = self.getController?() else {
return
}
var scheduledTooltip: MediaNavigationAccessoryPanel.ChangeType?
let items = self.contextMenuSpeedItems(scheduleTooltip: { change in
scheduledTooltip = change
})
let contextController = ContextController(presentationData: self.context.sharedContext.currentPresentationData.with { $0 }, source: .reference(HeaderContextReferenceContentSource(controller: controller, sourceNode: self.rateButton.referenceNode, shouldBeDismissed: self.dismissedPromise.get())), items: items, gesture: gesture)
contextController.dismissed = { [weak self] in
if let scheduledTooltip, let self, let rate = self.playbackBaseRate {
self.setRate?(rate, scheduledTooltip)
}
}
self.presentInGlobalOverlay?(contextController)
}
@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
}
}
}
}
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()
})
}
public final class AudioRateButton: HighlightableButtonNode {
public enum Content {
case image(UIImage?)
}
public let referenceNode: ContextReferenceContentNode
let containerNode: ContextControllerSourceNode
private let iconNode: ASImageNode
public var contextAction: ((ASDisplayNode, ContextGesture?) -> Void)?
private let wide: Bool
public init(wide: Bool = false) {
self.wide = wide
self.referenceNode = ContextReferenceContentNode()
self.containerNode = ContextControllerSourceNode()
self.containerNode.animateScale = false
self.iconNode = ASImageNode()
self.iconNode.displaysAsynchronously = false
self.iconNode.displayWithoutProcessing = true
self.iconNode.contentMode = .scaleToFill
super.init()
self.containerNode.addSubnode(self.referenceNode)
self.referenceNode.addSubnode(self.iconNode)
self.addSubnode(self.containerNode)
self.containerNode.shouldBegin = { [weak self] location in
guard let strongSelf = self, let _ = strongSelf.contextAction else {
return false
}
return true
}
self.containerNode.activated = { [weak self] gesture, _ in
guard let strongSelf = self else {
return
}
strongSelf.contextAction?(strongSelf.containerNode, gesture)
}
self.containerNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: 26.0, height: 44.0))
self.referenceNode.frame = self.containerNode.bounds
if let image = self.iconNode.image {
self.iconNode.frame = CGRect(origin: CGPoint(x: floor((self.containerNode.bounds.width - image.size.width) / 2.0), y: floor((self.containerNode.bounds.height - image.size.height) / 2.0)), size: image.size)
}
self.hitTestSlop = UIEdgeInsets(top: 0.0, left: -4.0, bottom: 0.0, right: -4.0)
}
private var content: Content?
public func setContent(_ content: Content, animated: Bool = false) {
if animated {
if let snapshotView = self.referenceNode.view.snapshotContentTree() {
snapshotView.frame = self.referenceNode.frame
self.view.addSubview(snapshotView)
snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { [weak snapshotView] _ in
snapshotView?.removeFromSuperview()
})
snapshotView.layer.animateScale(from: 1.0, to: 0.1, duration: 0.3, removeOnCompletion: false)
self.iconNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
self.iconNode.layer.animateScale(from: 0.1, to: 1.0, duration: 0.3)
}
switch content {
case let .image(image):
if let image = image {
self.iconNode.frame = CGRect(origin: CGPoint(x: floor((self.containerNode.bounds.width - image.size.width) / 2.0), y: floor((self.containerNode.bounds.height - image.size.height) / 2.0)), size: image.size)
}
self.iconNode.image = image
self.iconNode.isHidden = false
}
} else {
self.content = content
switch content {
case let .image(image):
if let image = image {
self.iconNode.frame = CGRect(origin: CGPoint(x: floor((self.containerNode.bounds.width - image.size.width) / 2.0), y: floor((self.containerNode.bounds.height - image.size.height) / 2.0)), size: image.size)
}
self.iconNode.image = image
self.iconNode.isHidden = false
}
}
}
public override func didLoad() {
super.didLoad()
self.view.isOpaque = false
}
public override func calculateSizeThatFits(_ constrainedSize: CGSize) -> CGSize {
return CGSize(width: wide ? 32.0 : 22.0, height: 44.0)
}
func onLayout() {
}
}
private final class HeaderContextReferenceContentSource: ContextReferenceContentSource {
private let controller: ViewController
private let sourceNode: ContextReferenceContentNode
var shouldBeDismissed: Signal<Bool, NoError>
init(controller: ViewController, sourceNode: ContextReferenceContentNode, shouldBeDismissed: Signal<Bool, NoError>) {
self.controller = controller
self.sourceNode = sourceNode
self.shouldBeDismissed = shouldBeDismissed
}
func transitionInfo() -> ContextControllerReferenceViewInfo? {
return ContextControllerReferenceViewInfo(referenceView: self.sourceNode.view, contentAreaInScreenSpace: UIScreen.main.bounds)
}
}
@@ -0,0 +1,109 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import TelegramCore
import AccountContext
import TelegramUIPreferences
import TelegramPresentationData
public final class MediaNavigationAccessoryPanel: ASDisplayNode {
public let containerNode: MediaNavigationAccessoryContainerNode
public enum ChangeType {
case preset
case sliderChange
case sliderCommit(CGFloat, CGFloat)
}
public var close: (() -> Void)?
public var setRate: ((AudioPlaybackRate, ChangeType) -> Void)?
public var togglePlayPause: (() -> Void)?
public var tapAction: (() -> Void)?
public var playPrevious: (() -> Void)?
public var playNext: (() -> Void)?
public var getController: (() -> ViewController?)?
public var presentInGlobalOverlay: ((ViewController) -> Void)?
public init(context: AccountContext, presentationData: PresentationData, displayBackground: Bool = false) {
self.containerNode = MediaNavigationAccessoryContainerNode(context: context, presentationData: presentationData, displayBackground: displayBackground)
super.init()
self.clipsToBounds = true
self.addSubnode(self.containerNode)
self.containerNode.headerNode.close = { [weak self] in
if let strongSelf = self, let close = strongSelf.close {
close()
}
}
self.containerNode.headerNode.setRate = { [weak self] rate, type in
self?.setRate?(rate, type)
}
self.containerNode.headerNode.togglePlayPause = { [weak self] in
if let strongSelf = self, let togglePlayPause = strongSelf.togglePlayPause {
togglePlayPause()
}
}
self.containerNode.headerNode.tapAction = { [weak self] in
if let strongSelf = self, let tapAction = strongSelf.tapAction {
tapAction()
}
}
self.containerNode.headerNode.playPrevious = { [weak self] in
if let strongSelf = self, let playPrevious = strongSelf.playPrevious {
playPrevious()
}
}
self.containerNode.headerNode.playNext = { [weak self] in
if let strongSelf = self, let playNext = strongSelf.playNext {
playNext()
}
}
self.containerNode.headerNode.getController = { [weak self] in
if let strongSelf = self, let getController = strongSelf.getController {
return getController()
} else {
return nil
}
}
self.containerNode.headerNode.presentInGlobalOverlay = { [weak self] c in
if let strongSelf = self, let presentInGlobalOverlay = strongSelf.presentInGlobalOverlay {
presentInGlobalOverlay(c)
}
}
}
public func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, isHidden: Bool, transition: ContainedViewLayoutTransition) {
transition.updateFrame(node: self.containerNode, frame: CGRect(origin: CGPoint(x: 0.0, y: isHidden ? -size.height : 0.0), size: size))
transition.updateAlpha(node: self.containerNode, alpha: isHidden ? 0.0 : 1.0)
self.containerNode.updateLayout(size: size, leftInset: leftInset, rightInset: rightInset, transition: transition)
}
public func animateIn(transition: ContainedViewLayoutTransition) {
let contentPosition = self.containerNode.layer.position
self.containerNode.animateIn(transition: transition)
transition.animatePosition(node: self.containerNode, from: CGPoint(x: contentPosition.x, y: contentPosition.y - 37.0))
}
public func animateOut(transition: ContainedViewLayoutTransition, completion: @escaping () -> Void) {
let contentPosition = self.containerNode.layer.position
self.containerNode.animateOut(transition: transition)
transition.animatePosition(node: self.containerNode, to: CGPoint(x: contentPosition.x, y: contentPosition.y - 37.0), removeOnCompletion: false, completion: { _ in
completion()
})
}
override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
return self.containerNode.hitTest(point, with: event)
}
}
File diff suppressed because it is too large Load Diff