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>
278 lines
10 KiB
Swift
278 lines
10 KiB
Swift
import Foundation
|
|
import UIKit
|
|
import AVFoundation
|
|
import SwiftSignalKit
|
|
import UniversalMediaPlayer
|
|
import TelegramCore
|
|
import AccountContext
|
|
import TelegramAudio
|
|
import Display
|
|
import TelegramVoip
|
|
import RangeSet
|
|
import ManagedFile
|
|
import FFMpegBinding
|
|
import TelegramUniversalVideoContent
|
|
|
|
final class LivestreamVideoViewV1: UIView {
|
|
private final class PartContext {
|
|
let part: DirectMediaStreamingContext.Playlist.Part
|
|
let disposable = MetaDisposable()
|
|
var resolvedTimeOffset: Double?
|
|
var data: EngineTempBoxFile?
|
|
var info: FFMpegMediaInfo?
|
|
|
|
init(part: DirectMediaStreamingContext.Playlist.Part) {
|
|
self.part = part
|
|
}
|
|
|
|
deinit {
|
|
self.disposable.dispose()
|
|
}
|
|
}
|
|
|
|
private let context: AccountContext
|
|
private let audioSessionManager: ManagedAudioSession
|
|
private let call: PresentationGroupCall
|
|
|
|
private let chunkPlayerPartsState = Promise<ChunkMediaPlayerPartsState>(ChunkMediaPlayerPartsState(duration: 10000000.0, content: .parts([])))
|
|
private var parts: [ChunkMediaPlayerPart] = [] {
|
|
didSet {
|
|
self.chunkPlayerPartsState.set(.single(ChunkMediaPlayerPartsState(duration: 10000000.0, content: .parts(self.parts))))
|
|
}
|
|
}
|
|
|
|
private let player: ChunkMediaPlayer
|
|
private let playerNode: MediaPlayerNode
|
|
|
|
private var playerStatus: MediaPlayerStatus?
|
|
private var playerStatusDisposable: Disposable?
|
|
|
|
private var streamingContextDisposable: Disposable?
|
|
private var streamingContext: DirectMediaStreamingContext?
|
|
private var playlistDisposable: Disposable?
|
|
|
|
private var partContexts: [Int: PartContext] = [:]
|
|
|
|
private var requestedSeekTimestamp: Double?
|
|
|
|
init(
|
|
context: AccountContext,
|
|
audioSessionManager: ManagedAudioSession,
|
|
call: PresentationGroupCall
|
|
) {
|
|
self.context = context
|
|
self.audioSessionManager = audioSessionManager
|
|
self.call = call
|
|
|
|
self.playerNode = MediaPlayerNode()
|
|
|
|
var onSeeked: (() -> Void)?
|
|
self.player = ChunkMediaPlayerV2(
|
|
params: ChunkMediaPlayerV2.MediaDataReaderParams(context: context),
|
|
audioSessionManager: audioSessionManager,
|
|
source: .externalParts(self.chunkPlayerPartsState.get()),
|
|
video: true,
|
|
enableSound: true,
|
|
baseRate: 1.0,
|
|
onSeeked: {
|
|
onSeeked?()
|
|
},
|
|
playerNode: self.playerNode
|
|
)
|
|
|
|
super.init(frame: CGRect())
|
|
|
|
self.addSubview(self.playerNode.view)
|
|
|
|
onSeeked = {
|
|
}
|
|
|
|
self.playerStatusDisposable = (self.player.status
|
|
|> deliverOnMainQueue).startStrict(next: { [weak self] status in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.updatePlayerStatus(status: status)
|
|
})
|
|
|
|
var didProcessFramesToDisplay = false
|
|
self.playerNode.isHidden = true
|
|
self.playerNode.hasSentFramesToDisplay = { [weak self] in
|
|
guard let self, !didProcessFramesToDisplay else {
|
|
return
|
|
}
|
|
didProcessFramesToDisplay = true
|
|
self.playerNode.isHidden = false
|
|
}
|
|
|
|
if let call = call as? PresentationGroupCallImpl {
|
|
self.streamingContextDisposable = (call.externalMediaStream.get()
|
|
|> deliverOnMainQueue).startStrict(next: { [weak self] externalMediaStream in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.streamingContext = externalMediaStream
|
|
self.resetPlayback()
|
|
})
|
|
}
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
deinit {
|
|
self.playerStatusDisposable?.dispose()
|
|
self.streamingContextDisposable?.dispose()
|
|
self.playlistDisposable?.dispose()
|
|
}
|
|
|
|
private func updatePlayerStatus(status: MediaPlayerStatus) {
|
|
self.playerStatus = status
|
|
|
|
self.updatePlaybackPositionIfNeeded()
|
|
}
|
|
|
|
private func resetPlayback() {
|
|
self.parts = []
|
|
|
|
self.playlistDisposable?.dispose()
|
|
self.playlistDisposable = nil
|
|
|
|
guard let streamingContext = self.streamingContext else {
|
|
return
|
|
}
|
|
self.playlistDisposable = (streamingContext.playlistData()
|
|
|> deliverOnMainQueue).startStrict(next: { [weak self] playlist in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.updatePlaylist(playlist: playlist)
|
|
})
|
|
}
|
|
|
|
private func updatePlaylist(playlist: DirectMediaStreamingContext.Playlist) {
|
|
var validPartIds: [Int] = []
|
|
for part in playlist.parts.prefix(upTo: 4) {
|
|
validPartIds.append(part.index)
|
|
|
|
if self.partContexts[part.index] == nil {
|
|
let partContext = PartContext(part: part)
|
|
self.partContexts[part.index] = partContext
|
|
|
|
if let streamingContext = self.streamingContext {
|
|
partContext.disposable.set((streamingContext.partData(index: part.index)
|
|
|> deliverOn(Queue.concurrentDefaultQueue())
|
|
|> map { data -> (file: EngineTempBoxFile, info: FFMpegMediaInfo)? in
|
|
guard let data else {
|
|
return nil
|
|
}
|
|
let tempFile = EngineTempBox.shared.tempFile(fileName: "part.mp4")
|
|
if let _ = try? data.write(to: URL(fileURLWithPath: tempFile.path), options: .atomic) {
|
|
if let info = extractFFMpegMediaInfo(path: tempFile.path) {
|
|
return (tempFile, info)
|
|
} else {
|
|
return nil
|
|
}
|
|
} else {
|
|
EngineTempBox.shared.dispose(tempFile)
|
|
return nil
|
|
}
|
|
}
|
|
|> deliverOnMainQueue).startStrict(next: { [weak self, weak partContext] fileAndInfo in
|
|
guard let self, let partContext else {
|
|
return
|
|
}
|
|
if let (file, info) = fileAndInfo {
|
|
partContext.data = file
|
|
partContext.info = info
|
|
} else {
|
|
partContext.data = nil
|
|
}
|
|
self.updatePartContexts()
|
|
}))
|
|
}
|
|
}
|
|
}
|
|
|
|
var removedPartIds: [Int] = []
|
|
for (id, _) in self.partContexts {
|
|
if !validPartIds.contains(id) {
|
|
removedPartIds.append(id)
|
|
}
|
|
}
|
|
for id in removedPartIds {
|
|
self.partContexts.removeValue(forKey: id)
|
|
}
|
|
}
|
|
|
|
private func updatePartContexts() {
|
|
var readyParts: [ChunkMediaPlayerPart] = []
|
|
let sortedContexts = self.partContexts.values.sorted(by: { $0.part.timestamp < $1.part.timestamp })
|
|
outer: for i in 0 ..< sortedContexts.count {
|
|
let partContext = sortedContexts[i]
|
|
|
|
if let data = partContext.data {
|
|
let offsetTime: Double
|
|
if i != 0 {
|
|
var foundOffset: Double?
|
|
inner: for j in 0 ..< i {
|
|
let previousContext = sortedContexts[j]
|
|
if previousContext.part.index == partContext.part.index - 1 {
|
|
if let previousInfo = previousContext.info {
|
|
if let previousResolvedOffset = previousContext.resolvedTimeOffset {
|
|
if let audio = previousInfo.audio {
|
|
foundOffset = previousResolvedOffset + audio.duration.seconds
|
|
} else {
|
|
foundOffset = partContext.part.timestamp
|
|
}
|
|
}
|
|
}
|
|
break inner
|
|
}
|
|
}
|
|
if let foundOffset {
|
|
partContext.resolvedTimeOffset = foundOffset
|
|
offsetTime = foundOffset
|
|
} else {
|
|
continue outer
|
|
}
|
|
} else {
|
|
if let resolvedOffset = partContext.resolvedTimeOffset {
|
|
offsetTime = resolvedOffset
|
|
} else {
|
|
offsetTime = partContext.part.timestamp
|
|
partContext.resolvedTimeOffset = offsetTime
|
|
}
|
|
}
|
|
|
|
readyParts.append(ChunkMediaPlayerPart(
|
|
startTime: partContext.part.timestamp,
|
|
endTime: partContext.part.timestamp + partContext.part.duration,
|
|
content: ChunkMediaPlayerPart.TempFile(file: data),
|
|
codecName: nil,
|
|
offsetTime: offsetTime
|
|
))
|
|
}
|
|
}
|
|
readyParts.sort(by: { $0.startTime < $1.startTime })
|
|
self.parts = readyParts
|
|
self.updatePlaybackPositionIfNeeded()
|
|
}
|
|
|
|
private func updatePlaybackPositionIfNeeded() {
|
|
if let part = self.parts.first {
|
|
if let playerStatus = self.playerStatus, playerStatus.timestamp < part.startTime {
|
|
if self.requestedSeekTimestamp != part.startTime {
|
|
self.requestedSeekTimestamp = part.startTime
|
|
self.player.seek(timestamp: part.startTime, play: true)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public func update(size: CGSize, transition: ContainedViewLayoutTransition) {
|
|
//transition.updateFrame(view: self.playerNode.view, frame: CGRect(origin: CGPoint(), size: size))
|
|
self.playerNode.frame = CGRect(origin: CGPoint(), size: size)
|
|
}
|
|
}
|