Telegram-iOS/submodules/GalleryUI/Sources/ChatVideoGalleryItemScrubberView.swift
2026-04-03 22:15:42 +08:00

503 lines
28 KiB
Swift

import Foundation
import UIKit
import AsyncDisplayKit
import SwiftSignalKit
import TelegramCore
import Postbox
import Display
import UniversalMediaPlayer
import TelegramPresentationData
import RangeSet
import ShimmerEffect
import TelegramUniversalVideoContent
import ComponentFlow
import ComponentDisplayAdapters
import GlassBackgroundComponent
private let textFont = Font.with(size: 13.0, design: .regular, weight: .medium, traits: [.monospacedNumbers])
private let scrubberBackgroundColor = UIColor(white: 1.0, alpha: 0.42)
private let scrubberForegroundColor = UIColor.white
private let scrubberBufferingColor = UIColor(rgb: 0xffffff, alpha: 0.5)
final class ChatVideoGalleryItemScrubberView: UIView {
private var containerLayout: (size: CGSize, leftInset: CGFloat, rightInset: CGFloat, isCollapsed: Bool)?
private let backgroundContainer: GlassBackgroundContainerView
private let backgroundView: GlassBackgroundView
private let leftTimestampNode: MediaPlayerTimeTextNode
private let rightTimestampNode: MediaPlayerTimeTextNode
private let infoNode: ASTextNode
private let scrubberNode: MediaPlayerScrubbingNode
private let shimmerEffectNode: ShimmerEffectForegroundNode
private let hapticFeedback = HapticFeedback()
private var playbackStatus: MediaPlayerStatus?
private var chapters: [MediaPlayerScrubbingChapter] = []
private var fetchStatusDisposable = MetaDisposable()
private var chapterDisposable = MetaDisposable()
private var loadingDisposable = MetaDisposable()
private var leftTimestampNodePushed = false
private var rightTimestampNodePushed = false
private var currentChapter: MediaPlayerScrubbingChapter?
private var isAnimatedOut: Bool = false
private var currentLeftString: String?
private var currentRightString: String?
var hasVisibleInfo: Bool {
if let attributedText = self.infoNode.attributedText, !attributedText.string.isEmpty {
return true
} else {
return false
}
}
var hideWhenDurationIsUnknown = false {
didSet {
if self.hideWhenDurationIsUnknown {
if let playbackStatus = self.playbackStatus, !playbackStatus.duration.isZero {
self.backgroundContainer.isHidden = false
} else {
self.backgroundContainer.isHidden = true
}
} else {
self.backgroundContainer.isHidden = false
}
}
}
var updateScrubbing: (Double?) -> Void = { _ in }
var updateScrubbingVisual: (Double?) -> Void = { _ in }
var updateScrubbingHandlePosition: (CGFloat) -> Void = { _ in }
var seek: (Double) -> Void = { _ in }
var onRequestLayout: ((ContainedViewLayoutTransition) -> Void)?
init(chapters: [MediaPlayerScrubbingChapter]) {
self.backgroundContainer = GlassBackgroundContainerView()
self.backgroundView = GlassBackgroundView()
self.backgroundContainer.contentView.addSubview(self.backgroundView)
self.backgroundView.contentView.layer.allowsGroupOpacity = true
self.chapters = chapters
self.scrubberNode = MediaPlayerScrubbingNode(content: .standard(lineHeight: 8.0, lineCap: .round, scrubberHandle: .none, backgroundColor: scrubberBackgroundColor, foregroundColor: scrubberForegroundColor, bufferingColor: scrubberBufferingColor, chapters: chapters))
self.scrubberNode.layer.allowsGroupOpacity = true
self.shimmerEffectNode = ShimmerEffectForegroundNode()
self.leftTimestampNode = MediaPlayerTimeTextNode(textColor: .white)
self.rightTimestampNode = MediaPlayerTimeTextNode(textColor: .white)
self.rightTimestampNode.alignment = .right
self.rightTimestampNode.mode = .reversed
self.infoNode = ASTextNode()
self.infoNode.maximumNumberOfLines = 1
self.infoNode.isUserInteractionEnabled = false
self.infoNode.displaysAsynchronously = false
super.init(frame: CGRect())
self.addSubview(self.backgroundContainer)
self.scrubberNode.seek = { [weak self] timestamp in
self?.seek(timestamp)
}
self.scrubberNode.update = { [weak self] timestamp, position in
self?.updateScrubbing(timestamp)
self?.updateScrubbingVisual(timestamp)
self?.updateScrubbingHandlePosition(position)
}
self.scrubberNode.playerStatusUpdated = { [weak self] status in
if let strongSelf = self {
strongSelf.playbackStatus = status
if strongSelf.hideWhenDurationIsUnknown {
if let playbackStatus = status, !playbackStatus.duration.isZero {
strongSelf.scrubberNode.isHidden = false
strongSelf.leftTimestampNode.isHidden = false
strongSelf.rightTimestampNode.isHidden = false
} else {
strongSelf.scrubberNode.isHidden = true
strongSelf.leftTimestampNode.isHidden = true
strongSelf.rightTimestampNode.isHidden = true
}
} else {
strongSelf.scrubberNode.isHidden = false
strongSelf.leftTimestampNode.isHidden = false
strongSelf.rightTimestampNode.isHidden = false
}
}
}
self.backgroundView.contentView.addSubview(self.leftTimestampNode.view)
self.backgroundView.contentView.addSubview(self.rightTimestampNode.view)
self.addSubview(self.scrubberNode.view)
self.backgroundView.contentView.addSubview(self.infoNode.view)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.fetchStatusDisposable.dispose()
self.chapterDisposable.dispose()
self.loadingDisposable.dispose()
}
var isLoading = false
var isCollapsed: Bool?
private func requestLayout(transition: ContainedViewLayoutTransition) {
if let onRequestLayout = self.onRequestLayout {
onRequestLayout(transition)
} else {
if let (size, leftInset, rightInset, isCollapsed) = self.containerLayout {
self.updateLayout(size: size, leftInset: leftInset, rightInset: rightInset, isCollapsed: isCollapsed, transition: .immediate)
}
}
}
func updateTimestampsVisibility(animated: Bool) {
if self.isAnimatedOut {
return
}
let transition: ContainedViewLayoutTransition = animated ? .animated(duration: 0.2, curve: .easeInOut) : .immediate
let alpha: CGFloat = self.isCollapsed == true || self.isLoading ? 0.0 : 1.0
transition.updateAlpha(node: self.leftTimestampNode, alpha: alpha)
transition.updateAlpha(node: self.rightTimestampNode, alpha: alpha)
}
private func updateScrubberVisibility(animated: Bool) {
var collapsed = self.isCollapsed
var alpha: CGFloat = 1.0
var controlAlpha: CGFloat = 1.0
if let playbackStatus = self.playbackStatus, playbackStatus.duration <= 30.0 {
alpha = self.isCollapsed == true ? 0.0 : 1.0
controlAlpha = 1.0
} else {
alpha = self.isCollapsed == true ? 0.0 : 1.0
controlAlpha = alpha
collapsed = false
}
self.scrubberNode.setCollapsed(collapsed == true, animated: animated)
let transition: ContainedViewLayoutTransition = animated ? .animated(duration: 0.3, curve: .linear) : .immediate
ComponentTransition(transition).setAlpha(view: self.backgroundContainer, alpha: alpha)
ComponentTransition(transition).setAlpha(view: self.scrubberNode.view, alpha: controlAlpha)
}
func animateTo(_ timestamp: Double) {
self.scrubberNode.animateTo(timestamp)
}
func setStatusSignal(_ status: Signal<MediaPlayerStatus, NoError>?) {
let mappedStatus: Signal<MediaPlayerStatus, NoError>?
if let status = status {
mappedStatus = combineLatest(status, self.scrubberNode.scrubbingTimestamp) |> map { status, scrubbingTimestamp -> MediaPlayerStatus in
return MediaPlayerStatus(generationTimestamp: scrubbingTimestamp != nil ? 0 : status.generationTimestamp, duration: status.duration, dimensions: status.dimensions, timestamp: scrubbingTimestamp ?? status.timestamp, baseRate: status.baseRate, seekId: status.seekId, status: status.status, soundEnabled: status.soundEnabled)
}
} else {
mappedStatus = nil
}
self.scrubberNode.status = mappedStatus
self.leftTimestampNode.status = mappedStatus
self.rightTimestampNode.status = mappedStatus
if let mappedStatus = mappedStatus {
self.loadingDisposable.set((mappedStatus
|> deliverOnMainQueue).start(next: { [weak self] status in
if let strongSelf = self {
if status.duration < 1.0 {
strongSelf.isLoading = true
strongSelf.updateTimestampsVisibility(animated: true)
if strongSelf.shimmerEffectNode.supernode == nil {
strongSelf.scrubberNode.containerNode.addSubnode(strongSelf.shimmerEffectNode)
}
} else {
strongSelf.isLoading = false
strongSelf.updateTimestampsVisibility(animated: true)
if strongSelf.shimmerEffectNode.supernode != nil {
strongSelf.shimmerEffectNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak self] _ in
if let strongSelf = self {
strongSelf.shimmerEffectNode.removeFromSupernode()
}
})
}
}
let leftString = MediaPlayerTimeTextNode.timestampString(for: status, mode: .normal)
let rightString = MediaPlayerTimeTextNode.timestampString(for: status, mode: .reversed)
if strongSelf.currentLeftString?.count != leftString?.count || strongSelf.currentRightString?.count != rightString?.count {
strongSelf.currentLeftString = leftString
strongSelf.currentRightString = rightString
if let (size, leftInset, rightInset, isCollapsed) = strongSelf.containerLayout {
strongSelf.updateLayout(size: size, leftInset: leftInset, rightInset: rightInset, isCollapsed: isCollapsed, transition: .immediate)
}
}
}
}))
self.chapterDisposable.set((mappedStatus
|> deliverOnMainQueue).start(next: { [weak self] status in
if let strongSelf = self, status.duration > 1.0, strongSelf.chapters.count > 0 {
let previousChapter = strongSelf.currentChapter
var currentChapter: MediaPlayerScrubbingChapter?
for chapter in strongSelf.chapters {
if chapter.start > status.timestamp {
break
} else {
currentChapter = chapter
}
}
if let chapter = currentChapter, chapter != previousChapter {
strongSelf.currentChapter = chapter
if strongSelf.scrubberNode.isScrubbing {
strongSelf.hapticFeedback.impact(.light)
}
if let previousChapter = previousChapter, !strongSelf.infoNode.alpha.isZero {
if let snapshotView = strongSelf.infoNode.view.snapshotView(afterScreenUpdates: false) {
snapshotView.frame = strongSelf.infoNode.frame
strongSelf.infoNode.view.superview?.addSubview(snapshotView)
let offset: CGFloat = 30.0
let snapshotTargetPosition: CGPoint
let nodeStartPosition: CGPoint
if previousChapter.start < chapter.start {
snapshotTargetPosition = CGPoint(x: -offset, y: 0.0)
nodeStartPosition = CGPoint(x: offset, y: 0.0)
} else {
snapshotTargetPosition = CGPoint(x: offset, y: 0.0)
nodeStartPosition = CGPoint(x: -offset, y: 0.0)
}
snapshotView.layer.animatePosition(from: CGPoint(), to: snapshotTargetPosition, duration: 0.2, removeOnCompletion: false, additive: true)
snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak snapshotView] _ in
snapshotView?.removeFromSuperview()
})
strongSelf.infoNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
strongSelf.infoNode.layer.animatePosition(from: nodeStartPosition, to: CGPoint(), duration: 0.2, additive: true)
}
}
strongSelf.infoNode.attributedText = NSAttributedString(string: chapter.title, font: textFont, textColor: .white)
strongSelf.requestLayout(transition: .immediate)
}
}
}))
}
}
func setBufferingStatusSignal(_ status: Signal<(RangeSet<Int64>, Int64)?, NoError>?) {
self.scrubberNode.bufferingStatus = status
}
func setFetchStatusSignal(_ fetchStatus: Signal<MediaResourceStatus, NoError>?, strings: PresentationStrings, decimalSeparator: String, fileSize: Int64?) {
let formatting = DataSizeStringFormatting(strings: strings, decimalSeparator: decimalSeparator)
if let fileSize = fileSize {
if let fetchStatus = fetchStatus {
self.fetchStatusDisposable.set((fetchStatus
|> deliverOnMainQueue).start(next: { [weak self] status in
if let strongSelf = self, strongSelf.chapters.isEmpty {
var text: String
switch status {
case .Remote:
text = dataSizeString(fileSize, forceDecimal: true, formatting: formatting)
case let .Fetching(_, progress):
text = strings.DownloadingStatus(dataSizeString(Int64(Float(fileSize) * progress), forceDecimal: true, formatting: formatting), dataSizeString(fileSize, forceDecimal: true, formatting: formatting)).string
default:
text = ""
}
strongSelf.infoNode.attributedText = NSAttributedString(string: text, font: textFont, textColor: .white)
strongSelf.requestLayout(transition: .animated(duration: 0.4, curve: .spring))
}
}))
} else if self.chapters.isEmpty {
self.infoNode.attributedText = NSAttributedString(string: dataSizeString(fileSize, forceDecimal: true, formatting: formatting), font: textFont, textColor: .white)
self.requestLayout(transition: .animated(duration: 0.4, curve: .spring))
}
} else if self.chapters.isEmpty {
self.infoNode.attributedText = nil
self.requestLayout(transition: .animated(duration: 0.4, curve: .spring))
}
}
func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, isCollapsed: Bool, transition: ContainedViewLayoutTransition) {
self.containerLayout = (size, leftInset, rightInset, isCollapsed)
self.isCollapsed = isCollapsed
let transition = ComponentTransition(transition)
let scrubberHeight: CGFloat = 14.0
let leftTimestampOffset: CGFloat
let rightTimestampOffset: CGFloat
let infoOffset: CGFloat
var scrubberLeftInset: CGFloat = 58.0
var scrubberRightInset: CGFloat = 58.0
if let leftString = self.currentLeftString, let rightString = self.currentRightString {
if leftString.count > 4 {
let string = NSAttributedString(string: leftString, font: Font.regular(13.0), textColor: .black)
let size = string.boundingRect(with: CGSize(width: 200.0, height: 100.0), options: NSStringDrawingOptions.usesLineFragmentOrigin, context: nil).size
scrubberLeftInset = min(100.0, 16.0 + size.width + 8.0)
}
if rightString.count > 4 {
let string = NSAttributedString(string: rightString, font: Font.regular(13.0), textColor: .black)
let size = string.boundingRect(with: CGSize(width: 200.0, height: 100.0), options: NSStringDrawingOptions.usesLineFragmentOrigin, context: nil).size
scrubberRightInset = min(100.0, 16.0 + size.width + 8.0)
}
}
var yOffset: CGFloat = 0.0
if !isCollapsed {
yOffset = size.height - 44.0
}
leftTimestampOffset = 14.0
rightTimestampOffset = 14.0
infoOffset = -8.0
if isCollapsed {
scrubberLeftInset = 0.0
scrubberRightInset = 0.0
}
transition.setFrame(view: self.leftTimestampNode.view, frame: CGRect(origin: CGPoint(x: 16.0, y: yOffset + leftTimestampOffset), size: CGSize(width: 60.0, height: 20.0)))
transition.setFrame(view: self.rightTimestampNode.view, frame: CGRect(origin: CGPoint(x: size.width - leftInset - rightInset - 60.0 - 16.0, y: yOffset + rightTimestampOffset), size: CGSize(width: 60.0, height: 20.0)))
var infoConstrainedSize = size
infoConstrainedSize.width = size.width - 20.0 * 2.0
let infoSize = self.infoNode.measure(infoConstrainedSize)
self.infoNode.bounds = CGRect(origin: CGPoint(), size: infoSize)
transition.setPosition(view: self.infoNode.view, position: CGPoint(x: size.width / 2.0, y: yOffset + infoOffset + infoSize.height / 2.0))
self.infoNode.alpha = self.isCollapsed == false ? 1.0 : 0.0
var scrubberFrame = CGRect(origin: CGPoint(x: scrubberLeftInset, y: yOffset + 15.0), size: CGSize(width: size.width - leftInset - rightInset - scrubberLeftInset - scrubberRightInset, height: scrubberHeight))
if isCollapsed {
scrubberFrame.origin.y = size.height - scrubberHeight
}
transition.setFrame(view: self.scrubberNode.view, frame: scrubberFrame)
let scrubberTransition: ControlledTransition
switch transition.animation {
case let .curve(duration, curve):
scrubberTransition = ControlledTransition(duration: duration, curve: curve.containedViewLayoutTransitionCurve, interactive: false)
default:
scrubberTransition = ControlledTransition(duration: 0.0, curve: .linear, interactive: false)
}
self.scrubberNode.update(size: scrubberFrame.size, animator: scrubberTransition.legacyAnimator)
self.shimmerEffectNode.updateAbsoluteRect(CGRect(origin: .zero, size: scrubberFrame.size), within: scrubberFrame.size)
self.shimmerEffectNode.update(backgroundColor: .clear, foregroundColor: UIColor(rgb: 0xffffff, alpha: 0.75), horizontal: true, effectSize: nil, globalTimeOffset: false, duration: nil)
self.shimmerEffectNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 4.0), size: CGSize(width: scrubberFrame.size.width, height: 5.0))
self.shimmerEffectNode.cornerRadius = 2.5
transition.setFrame(view: self.backgroundContainer, frame: CGRect(origin: CGPoint(), size: size))
self.backgroundContainer.update(size: size, isDark: true, transition: transition)
transition.setFrame(view: self.backgroundView, frame: CGRect(origin: CGPoint(), size: size))
self.backgroundView.update(size: size, cornerRadius: min(44.0 * 0.5, size.height * 0.5), isDark: true, tintColor: .init(kind: .panel), transition: transition)
self.updateTimestampsVisibility(animated: !transition.animation.isImmediate)
self.updateScrubberVisibility(animated: !transition.animation.isImmediate)
}
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
var hitTestRect = self.bounds
let minHeightDiff = 44.0 - hitTestRect.height
if (minHeightDiff > 0) {
hitTestRect = bounds.insetBy(dx: 0, dy: -minHeightDiff / 2.0)
}
return hitTestRect.contains(point)
}
func animateIn(from scrubberTransition: GalleryItemScrubberTransition?, transition: ContainedViewLayoutTransition) {
if let scrubberTransition = scrubberTransition?.scrubber {
let fromRect = scrubberTransition.view.convert(scrubberTransition.view.bounds, to: self)
let targetCloneView = scrubberTransition.makeView()
self.backgroundView.contentView.addSubview(targetCloneView)
targetCloneView.frame = fromRect
scrubberTransition.updateView(targetCloneView, GalleryItemScrubberTransition.Scrubber.TransitionState(sourceSize: fromRect.size, destinationSize: CGSize(width: self.scrubberNode.bounds.width, height: fromRect.height), progress: 0.0, direction: .in), .immediate)
targetCloneView.alpha = 1.0
transition.updateFrame(view: targetCloneView, frame: CGRect(origin: CGPoint(x: self.scrubberNode.frame.minX, y: self.scrubberNode.frame.maxY - fromRect.height - 3.0), size: CGSize(width: self.scrubberNode.bounds.width, height: fromRect.height)))
scrubberTransition.updateView(targetCloneView, GalleryItemScrubberTransition.Scrubber.TransitionState(sourceSize: fromRect.size, destinationSize: CGSize(width: self.scrubberNode.bounds.width, height: fromRect.height), progress: 1.0, direction: .in), transition)
let scrubberTransitionView = scrubberTransition.view
scrubberTransitionView.isHidden = true
ContainedViewLayoutTransition.animated(duration: 0.08, curve: .easeInOut).updateAlpha(layer: targetCloneView.layer, alpha: 0.0, completion: { [weak targetCloneView] _ in
targetCloneView?.removeFromSuperview()
})
let scrubberSourceRect = CGRect(origin: CGPoint(x: fromRect.minX, y: fromRect.maxY - 3.0), size: CGSize(width: fromRect.width, height: 3.0))
let leftTimestampOffset = CGPoint(x: self.leftTimestampNode.position.x - self.scrubberNode.frame.minX, y: self.leftTimestampNode.position.y - self.scrubberNode.frame.maxY)
let rightTimestampOffset = CGPoint(x: self.rightTimestampNode.position.x - self.scrubberNode.frame.maxX, y: self.rightTimestampNode.position.y - self.scrubberNode.frame.maxY)
transition.animatePosition(node: self.scrubberNode, from: scrubberSourceRect.center)
self.scrubberNode.animateWidth(from: scrubberSourceRect.width, transition: transition)
transition.animatePosition(node: self.leftTimestampNode, from: CGPoint(x: leftTimestampOffset.x + scrubberSourceRect.minX, y: leftTimestampOffset.y + scrubberSourceRect.maxY))
transition.animatePosition(node: self.rightTimestampNode, from: CGPoint(x: rightTimestampOffset.x + scrubberSourceRect.maxX, y: rightTimestampOffset.y + scrubberSourceRect.maxY))
}
self.backgroundContainer.alpha = 0.0
ComponentTransition.easeInOut(duration: 0.2).setAlpha(view: self.backgroundContainer, alpha: 1.0)
self.scrubberNode.alpha = 0.0
ComponentTransition.easeInOut(duration: 0.2).setAlpha(view: self.scrubberNode.view, alpha: 1.0)
}
func animateOut(to scrubberTransition: GalleryItemScrubberTransition?, transition: ContainedViewLayoutTransition) {
self.isAnimatedOut = true
if let scrubberTransition = scrubberTransition?.scrubber {
let toRect = scrubberTransition.view.convert(scrubberTransition.view.bounds, to: self)
let scrubberDestinationRect = CGRect(origin: CGPoint(x: toRect.minX, y: toRect.maxY - 3.0), size: CGSize(width: toRect.width, height: 3.0))
let targetCloneView = scrubberTransition.makeView()
self.backgroundView.contentView.addSubview(targetCloneView)
targetCloneView.frame = CGRect(origin: CGPoint(x: self.scrubberNode.frame.minX, y: self.scrubberNode.frame.maxY - toRect.height), size: CGSize(width: self.scrubberNode.bounds.width, height: toRect.height))
scrubberTransition.updateView(targetCloneView, GalleryItemScrubberTransition.Scrubber.TransitionState(sourceSize: CGSize(width: self.scrubberNode.bounds.width, height: toRect.height), destinationSize: toRect.size, progress: 0.0, direction: .out), .immediate)
targetCloneView.alpha = 0.0
transition.updateFrame(view: targetCloneView, frame: toRect)
scrubberTransition.updateView(targetCloneView, GalleryItemScrubberTransition.Scrubber.TransitionState(sourceSize: CGSize(width: self.scrubberNode.bounds.width, height: toRect.height), destinationSize: toRect.size, progress: 1.0, direction: .out), transition)
let scrubberTransitionView = scrubberTransition.view
scrubberTransitionView.isHidden = true
transition.updateAlpha(layer: targetCloneView.layer, alpha: 1.0, completion: { [weak scrubberTransitionView] _ in
scrubberTransitionView?.isHidden = false
})
let leftTimestampOffset = CGPoint(x: self.leftTimestampNode.position.x - self.scrubberNode.frame.minX, y: self.leftTimestampNode.position.y - self.scrubberNode.frame.maxY)
let rightTimestampOffset = CGPoint(x: self.rightTimestampNode.position.x - self.scrubberNode.frame.maxX, y: self.rightTimestampNode.position.y - self.scrubberNode.frame.maxY)
transition.animatePositionAdditive(layer: self.scrubberNode.layer, offset: CGPoint(), to: CGPoint(x: scrubberDestinationRect.midX - self.scrubberNode.position.x, y: scrubberDestinationRect.midY - self.scrubberNode.position.y), removeOnCompletion: false)
self.scrubberNode.animateWidth(to: scrubberDestinationRect.width, transition: transition)
transition.animatePositionAdditive(layer: self.leftTimestampNode.layer, offset: CGPoint(), to: CGPoint(x: -self.leftTimestampNode.position.x + (leftTimestampOffset.x + scrubberDestinationRect.minX), y: -self.leftTimestampNode.position.y + (leftTimestampOffset.y + scrubberDestinationRect.maxY)), removeOnCompletion: false)
transition.animatePositionAdditive(layer: self.rightTimestampNode.layer, offset: CGPoint(), to: CGPoint(x: -self.rightTimestampNode.position.x + (rightTimestampOffset.x + scrubberDestinationRect.maxX), y: -self.rightTimestampNode.position.y + (rightTimestampOffset.y + scrubberDestinationRect.maxY)), removeOnCompletion: false)
}
ComponentTransition.easeInOut(duration: 0.2).setAlpha(view: self.backgroundContainer, alpha: 0.0)
ComponentTransition.easeInOut(duration: 0.2).setAlpha(view: self.scrubberNode.view, alpha: 0.0)
}
}