WinterGram/submodules/GalleryUI/Sources/ChatVideoGalleryItemScrubberView.swift
isaac 69bfc65da7 Postbox refactor waves 138–276: 168-commit squash
Consolidates 137 wave commits + 31 supporting commits (CLAUDE.md bump,
typealias additions, AnyObject→EngineMedia restoration) into one squashed
commit. Migrates dozens of consumer-side public APIs, struct fields,
protocol methods, and enum payloads from Postbox protocols/structs to
TelegramEngine engine wrappers and typealiases. Drops `import Postbox`
from many files. Adds new TelegramCore typealiases and one
TelegramEngineUnauthorized facade.

Notable changes by category:

**TelegramCore typealias additions** (rule 2 — narrow utility typealiases):
- EngineChatListIndex, EngineTempBoxFile, EngineItemCollectionItemIndex,
  EngineItemCollectionViewEntryIndex, EngineValueBoxEncryptionParameters,
  EngineMessageAndThreadId, EnginePeerStoryStats, EngineMessageHistoryAnchorIndex,
  EngineChatListTotalUnreadStateCategory, EngineChatListTotalUnreadStateStats,
  EnginePeerSummaryCounterTags, EngineChatListTotalUnreadState,
  EngineItemCacheEntryId, EngineHashFunctions,
  EngineCachedMediaResourceRepresentationResult,
  EngineMediaResourceDataFetchResult, EngineMediaResourceDataFetchError,
  EngineMediaResourceStatus, EngineCachedPeerData

**TelegramCore engine extensions/forwarders**:
- EngineMessage.engineMedia, EngineMessage.enginePeers,
  EngineMessage.adAttribute, EngineMessage.effectivelyIncoming
- engineFileSize forwarder
- TelegramEngine.Resources.clearCachedMediaResources(mediaResourceIds: Set<EngineMediaResource.Id>)
- TelegramEngine.Resources.fetchStatus(id:resourceSize:)
- TelegramEngineUnauthorized.UnauthorizedResources facade with storeResourceData

**Public API/struct migrations to engine types**:
- ChatAvailableMessageActions.banAuthor/banAuthors → EnginePeer?/[EnginePeer]
- WebSessionsContextState.peers → [EnginePeer.Id: EnginePeer]
- CacheUsageStats.peers → [EnginePeer.Id: EnginePeer]
- PeerCommand.peer → EnginePeer
- PeerInfoControllerMode.calls(messages:) → [EngineMessage]
- CallControllerNodeProtocol.updatePeer → EnginePeer params
- ChatHistoryListNode.messageInCurrentHistoryView (and 4 variants) → EngineMessage?
- ChatHistorySearchContainerNode.messageForGallery → EngineMessage?
- PeerInfoPaneNode.findLoadedMessage / ensureMessageIsVisible /
  transitionNodeForGallery → engine-typed
- GalleryHiddenMediaTarget.getTransitionInfo /
  GalleryHiddenMediaManager.findTarget → engine-typed
- ChatPanelInterfaceInteraction.presentReactionDeletionOptions /
  presentBan*MessageOptions → EnginePeer
- DrawingMessageRenderer.messages → [EngineMessage]
- ChatVideoGalleryItemScrubberView.setFetchStatusSignal →
  EngineMediaResource.FetchStatus
- ChannelDiscussionGroupActionSheetItem.peer, VoiceChatPeerEntry.peer,
  VoiceChatFullscreenParticipantItem.peer, MediaStreamComponent.chatPeer,
  MediaStreamVideoComponent.callPeer, ChatMessageContactBubbleContentNode.contactPeer,
  ChatMessageForwardInfoNode.peer, ChatMessageCommentFooterContentNode.replyPeers,
  ChatReportPeerTitlePanelNode.peer, ChatMessageActionUrlAuthController.bot,
  PeerMediaCollectionInterfaceState.peer, ChatMessageCallBubbleContentNode.peopleAvatars,
  ChatLoadingNode.renderedPeer (→ EngineRenderedPeer) — all to engine types

**Wave-71-shadow stored-field migrations** (Postbox Peer/Message → Engine wrapper):
- LegacyCallControllerNode.peer
- CallStatusBarNode.currentPeer

**Dead-code / dead-field removals**:
- CallController.peer, CallControllerNodeV2.account,
  ContactMultiselectionController PeerNameIndex fields,
  preparedChatListNodeViewTransition account: Account param,
  FetchResource.swift entirely (unused function)

**Module-level Postbox import drops**: 30+ files including TelegramRootController,
EditStories, GiftViewScreen, AnimatedStickerUtils, FetchPhotoLibraryImageResource,
PeerInfoGiftsPaneNode, PeerInfoPaneContainerNode, PresentAddMembers,
PeerInfoProfileItems, ChatControllerAdminBanUsers, PresentationData typealiases,
DefaultDayPresentationTheme, ChatListViewTransition, GalleryHiddenMediaManager,
RecentSessionsController, GifContext, AuthorizationSequenceController,
PeerInfoHeaderEditingContentNode, PeerInfoHeaderNode,
PeerAllowedReactionListController, CallControllerNodeV2, and 6 PeerInfo pane files.

**AnyObject restoration**: rule 8 added (never substitute Postbox protocols
with Any/AnyObject) — undid previous AnyObject substitutions in waves 141/143
back to EngineMedia.

Doc maintenance: CLAUDE.md updated to reflect new typealiases and forwarders.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 00:44:27 +02:00

502 lines
28 KiB
Swift

import Foundation
import UIKit
import AsyncDisplayKit
import SwiftSignalKit
import TelegramCore
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<EngineMediaResource.FetchStatus, 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)
}
}