Telegram-iOS/submodules/InstantPageUI/Sources/InstantPagePlayableVideoNode.swift
isaac d1aa0db537 Postbox -> TelegramEngine waves 46-93 (squashed)
Squash of 63 commits spanning waves 46-93 (plus interspersed docs commits)
of the gradual Postbox->TelegramEngine consumer-side migration.

Scope: 139 files changed, 2123 insertions(+), 452 deletions(-).

## Themes by wave-block

**Waves 46-58 — Peer field migrations + facade additions**
Foundational EnginePeer convenience init additions (PeerReference, RenderedPeer,
SelectivePrivacyPeer). Multiple `peer: Peer` field migrations across PeerInfo,
ChatList, and SettingsUI components.

**Waves 59-73 — peer field cascade + EnginePeer wrap drops**
Series of single- to two-file peer-field migrations; consumer-side wrap
removal (`EnginePeer(peer)` -> direct EnginePeer use); `as? TelegramUser`
cast conversion to `case let .user(...)` enum match. Wave 64: RenderedPeer
convenience init. Wave 68: SelectivePrivacyPeer convenience init.

**Waves 74-83 — controller-Node bridge cleanup + small migrations**
Wave-71 shadow-pattern cleanup at controller->Node bridges. Migrations of
ChatRecentActionsController.peer (74), PeerInfoMember (75), MentionChatInputPanelItem
(76), PassportUI SecureIdAuthController (77), AccountWithInfo + ShareController
(78), peerInputActivitiesPromise (79), InactiveChannel (80), BlockedPeers (81),
openHashtag resolveSignal (82), NotificationExceptionsList (83).

**Waves 84-90 — TelegramEngine.Resources facade migrations**
Per-method Shape-A/B sweeps converting `<ctx>.account.postbox.mediaBox.X(...)`
to `<ctx>.engine.resources.X(...)`. Wave 90 was a single-commit big sweep:
40 fetchedMediaResource sites in 25 files migrated to engine.resources.fetch
facade in one atomic pass with first-pass-clean build.

Methods covered: storeResourceData, completedResourcePath, cancelInteractiveResourceFetch,
resourceRangesStatus, resourceStatus, fetch (fetchedMediaResource).

**Waves 91-92 — additional type migrations**
Wave 91: ItemListWebsiteItem.peer + RecentSessionsController enum-case payload
+ openWebSession callback Peer? -> EnginePeer?.
Wave 92: ChatListController StateHolder.EntryContext status type
MediaResourceStatus -> EngineMediaResource.FetchStatus.

**Wave 93 — speculative `import Postbox` drop sweep**
Drop import from 7 wave-touched files where it became unused; restore in 5
files where bare PeerId/Message/MediaId/StoryId references escaped the
pre-flight regex. Includes one MediaId(...) -> EngineMedia.Id(...) swap in
InAppPurchaseManager to unlock its import drop.

## Build state

Final state at squash: clean Telegram/Telegram build at debug_sim_arm64.

## Persistent-state notes

- Pre-existing WIP unchanged across the squashed range:
  - build-system/bazel-rules/sourcekit-bazel-bsp submodule marker
  - Untracked: build-system/tulsi/, submodules/TgVoip/, third-party/libx264/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:48:15 +04:00

184 lines
7.2 KiB
Swift

import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramCore
import SwiftSignalKit
import TelegramPresentationData
import AccountContext
import RadialStatusNode
import GalleryUI
import TelegramUniversalVideoContent
private struct FetchControls {
let fetch: (Bool) -> Void
let cancel: () -> Void
}
final class InstantPagePlayableVideoNode: ASDisplayNode, InstantPageNode, GalleryItemTransitionNode {
private let context: AccountContext
let media: InstantPageMedia
let userLocation: MediaResourceUserLocation
private let interactive: Bool
private let openMedia: (InstantPageMedia) -> Void
private var fetchControls: FetchControls?
private let videoNode: UniversalVideoNode
private let statusNode: RadialStatusNode
private var currentSize: CGSize?
private var fetchStatus: EngineMediaResource.FetchStatus?
private var fetchedDisposable = MetaDisposable()
private var statusDisposable = MetaDisposable()
private var localIsVisible = false
public var decoration: UniversalVideoDecoration? {
return nil
}
init(context: AccountContext, userLocation: MediaResourceUserLocation, webPage: TelegramMediaWebpage, theme: InstantPageTheme, media: InstantPageMedia, interactive: Bool, openMedia: @escaping (InstantPageMedia) -> Void) {
self.context = context
self.userLocation = userLocation
self.media = media
self.interactive = interactive
self.openMedia = openMedia
var imageReference: ImageMediaReference?
if case let .file(file) = media.media, let presentation = smallestImageRepresentation(file.previewRepresentations) {
let image = TelegramMediaImage(imageId: EngineMedia.Id(namespace: 0, id: 0), representations: [presentation], immediateThumbnailData: file.immediateThumbnailData, reference: nil, partialReference: nil, flags: [])
imageReference = ImageMediaReference.webPage(webPage: WebpageReference(webPage), media: image)
}
var streamVideo = false
var fileValue: TelegramMediaFile?
if case let .file(file) = media.media {
streamVideo = isMediaStreamable(media: file)
fileValue = file
}
self.videoNode = UniversalVideoNode(context: context, postbox: context.account.postbox, audioSession: context.sharedContext.mediaManager.audioSession, manager: context.sharedContext.mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: NativeVideoContent(id: .instantPage(webPage.webpageId, media.media.id!), userLocation: userLocation, fileReference: .webPage(webPage: WebpageReference(webPage), media: fileValue!), imageReference: imageReference, streamVideo: streamVideo ? .conservative : .none, loopVideo: true, enableSound: false, fetchAutomatically: true, placeholderColor: theme.pageBackgroundColor, storeAfterDownload: nil), priority: .embedded, autoplay: true)
self.videoNode.isUserInteractionEnabled = false
self.statusNode = RadialStatusNode(backgroundNodeColor: UIColor(white: 0.0, alpha: 0.6))
super.init()
self.addSubnode(self.videoNode)
if case let .file(file) = media.media {
self.fetchedDisposable.set(context.engine.resources.fetch(reference: AnyMediaReference.webPage(webPage: WebpageReference(webPage), media: file).resourceReference(file.resource), userLocation: userLocation, userContentType: .video).start())
self.statusDisposable.set((context.engine.resources.status(resource: EngineMediaResource(file.resource)) |> deliverOnMainQueue).start(next: { [weak self] status in
displayLinkDispatcher.dispatch {
if let strongSelf = self {
strongSelf.fetchStatus = status
strongSelf.updateFetchStatus()
}
}
}))
}
}
deinit {
self.fetchedDisposable.dispose()
}
func isAvailableForGalleryTransition() -> Bool {
return true
}
func isAvailableForInstantPageTransition() -> Bool {
return true
}
override func didLoad() {
super.didLoad()
if self.interactive {
self.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))))
}
}
func updateIsVisible(_ isVisible: Bool) {
if self.localIsVisible != isVisible {
self.localIsVisible = isVisible
self.videoNode.canAttachContent = isVisible
}
}
func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) {
}
func update(strings: PresentationStrings, theme: InstantPageTheme) {
}
private func updateFetchStatus() {
var state: RadialStatusNodeState = .none
if let fetchStatus = self.fetchStatus {
switch fetchStatus {
case let .Fetching(_, progress):
let adjustedProgress = max(progress, 0.027)
state = .progress(color: .white, lineWidth: nil, value: CGFloat(adjustedProgress), cancelEnabled: true, animateRotation: true)
case .Remote:
state = .download(.white)
default:
break
}
}
self.statusNode.transitionToState(state, completion: { [weak statusNode] in
if state == .none {
statusNode?.removeFromSupernode()
}
})
}
override func layout() {
super.layout()
let size = self.bounds.size
if self.currentSize != size {
self.currentSize = size
self.videoNode.frame = CGRect(origin: CGPoint(), size: size)
self.videoNode.updateLayout(size: size, transition: .immediate)
let radialStatusSize: CGFloat = 50.0
self.statusNode.frame = CGRect(x: floorToScreenPixels((size.width - radialStatusSize) / 2.0), y: floorToScreenPixels((size.height - radialStatusSize) / 2.0), width: radialStatusSize, height: radialStatusSize)
}
}
func transitionNode(media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
if media == self.media {
return (self, self.bounds, { [weak self] in
return (self?.view.snapshotContentTree(unhide: true), nil)
})
} else {
return nil
}
}
func updateHiddenMedia(media: InstantPageMedia?) {
self.videoNode.isHidden = self.media == media
}
@objc func tapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state, let fetchStatus = self.fetchStatus {
switch fetchStatus {
case .Local:
self.openMedia(self.media)
case .Remote, .Paused:
self.fetchControls?.fetch(true)
case .Fetching:
self.fetchControls?.cancel()
}
}
}
func scrubberTransition() -> GalleryItemScrubberTransition? {
return nil
}
}