Files
Telegram-iOS/submodules/TelegramUI/Components/MediaPlaybackHeaderPanelComponent/Sources/MediaPlaybackHeaderPanelComponent.swift
T
isaacisaacClaude Opus 4.7
86d1456552 Postbox -> TelegramEngine waves 107-137 (squashed)
31 waves of consumer-side migration from `import Postbox` to TelegramEngine
typealiases. Net: 173 import drops + 39 BUILD-dep drops + 1 new typealias
(`EngineStoryId = StoryId`, wave 113).

Wave shapes used:
- Orphan-import sweeps (107, 108, 128): drop `import Postbox` from files
  whose only Postbox-symbol reference was the import line itself, then
  resolve build failures. Methodology requires token-level (`grep -oE`)
  filtering, not line-level, to avoid masking real Postbox usage on lines
  that also contain `Namespaces.X` references.
- Identifier-swap mini-waves (109-127, 129-134, 136-137): rename
  Postbox-typealiased identifiers to engine equivalents
  (PeerId -> EnginePeer.Id, MessageId -> EngineMessage.Id,
  MediaId -> EngineMedia.Id, MessageIndex -> EngineMessage.Index,
  StoryId -> EngineStoryId, ItemCollectionId -> EngineItemCollectionId,
  PreferencesEntry -> EnginePreferencesEntry,
  FetchResourceSourceType/Error -> EngineFetchResourceSourceType/Error,
  MemoryBuffer -> EngineMemoryBuffer, MessageTags -> EngineMessage.Tags,
  MessageAttribute -> EngineMessage.Attribute,
  TempBox -> EngineTempBox).
- Asset-string FP-only orphans (124).
- Typealias addition + drain (113): added `EngineStoryId` typealias to
  TelegramCore, then drained 3+11 consumer sites.

Hard blockers identified during these waves (must restore `import Postbox`
when present): MediaResource[A-Za-z]* (any suffix -- the literal
`MediaResource` matches don't catch MediaResourceData/MediaResourceId/etc.),
Postbox/MediaBox/MediaResource raw types, PostboxCoding/PostboxEncoder/
PostboxDecoder, TempBoxFile, ValueBoxKey, PostboxView, combinedView,
HashFunctions, postboxLog, openPostbox, declareEncodable, PeerView,
MessageHistoryView, MessageHistoryThreadData, CachedPeerData, RenderedPeer,
SelectivePrivacyPeer, SimpleDictionary, ItemCollectionInfosView,
ItemCollectionItem, ItemCollectionItemIndex, ItemCollectionViewEntryIndex,
ChatListIndex, ChatListEntrySummaryComponents, CodableEntry,
MessageHistoryThread, MessageHistoryAnchorIndex,
MessageHistoryEntryLocation, PeerStoryStats, PeerNameIndex,
PeerSummaryCounterTags, ChatListTotalUnreadStateCategory/Stats,
arePeersEqual. Protocol-shape blockers: bare `Peer`/`Message`/`Media`
in function signatures, generic args, enum-case payloads, or dict value
types (e.g., `[PeerId: Peer]`, `case messages([Message])`,
`Signal<(Peer?, ...), NoError>`).

`replace_all PeerId -> EnginePeer.Id` is dangerous: mangles compound
names like `failedPeerId`, `ContactListPeerId`, `nextRemoteMediaId`,
`replyToMessageId`. Pre-flight grep `\b[a-z][a-zA-Z]*PeerId\b` and only
replace_all if 0 matches.

Also removes unneeded design/plan docs from a separate (link-highlighting)
feature branch:
- docs/superpowers/plans/2026-05-02-link-highlighting-modern-path-fixes.md
- docs/superpowers/specs/2026-05-02-link-highlighting-modern-path-fixes-design.md

Squashed commits: 6d82c2980d..e6de5d53a3 (59 commits, including
per-wave content commits and per-wave CLAUDE.md bumps).

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

386 lines
20 KiB
Swift

import Foundation
import UIKit
import Display
import TelegramPresentationData
import ComponentFlow
import ComponentDisplayAdapters
import AccountContext
import TelegramCore
import GlobalControlPanelsContext
import SwiftSignalKit
import UniversalMediaPlayer
import UndoUI
import OverlayStatusController
import TelegramUIPreferences
import PresentationDataUtils
public final class MediaPlaybackHeaderPanelComponent: Component {
public let context: AccountContext
public let theme: PresentationTheme
public let strings: PresentationStrings
public let data: GlobalControlPanelsContext.MediaPlayback
public let controller: () -> ViewController?
public let shouldPerformAction: ((@escaping () -> Void) -> Void)?
public init(
context: AccountContext,
theme: PresentationTheme,
strings: PresentationStrings,
data: GlobalControlPanelsContext.MediaPlayback,
controller: @escaping () -> ViewController?,
shouldPerformAction: ((@escaping () -> Void) -> Void)? = nil
) {
self.context = context
self.theme = theme
self.strings = strings
self.data = data
self.controller = controller
self.shouldPerformAction = shouldPerformAction
}
public static func ==(lhs: MediaPlaybackHeaderPanelComponent, rhs: MediaPlaybackHeaderPanelComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.theme !== rhs.theme {
return false
}
if lhs.strings !== rhs.strings {
return false
}
if lhs.data != rhs.data {
return false
}
return true
}
public final class View: UIView {
private var panel: MediaNavigationAccessoryPanel?
private var component: MediaPlaybackHeaderPanelComponent?
private weak var state: EmptyComponentState?
private var playlistPreloadDisposable: Disposable?
public override init(frame: CGRect) {
super.init(frame: frame)
}
required public init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.playlistPreloadDisposable?.dispose()
}
private func performAction(_ action: @escaping () -> Void) {
guard let component = self.component else {
return
}
if let shouldPerformAction = component.shouldPerformAction {
shouldPerformAction(action)
} else {
action()
}
}
func update(component: MediaPlaybackHeaderPanelComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
let themeUpdated = self.component?.theme !== component.theme
self.component = component
self.state = state
let panel: MediaNavigationAccessoryPanel
if let current = self.panel {
panel = current
} else {
panel = MediaNavigationAccessoryPanel(context: component.context, presentationData: PresentationData(
strings: component.strings,
theme: component.theme,
autoNightModeTriggered: false,
chatWallpaper: .builtin(WallpaperSettings()),
chatFontSize: .regular,
chatBubbleCorners: PresentationChatBubbleCorners(mainRadius: 0.0, auxiliaryRadius: 0.0, mergeBubbleCorners: true),
listsFontSize: .regular,
dateTimeFormat: PresentationDateTimeFormat(),
nameDisplayOrder: .firstLast,
nameSortOrder: .firstLast,
reduceMotion: false,
largeEmoji: false
))
self.panel = panel
self.addSubview(panel.view)
let delayedStatus = component.context.sharedContext.mediaManager.globalMediaPlayerState
|> mapToSignal { value -> Signal<(Account, SharedMediaPlayerItemPlaybackStateOrLoading, MediaManagerPlayerType)?, NoError> in
guard let value = value else {
return .single(nil)
}
switch value.1 {
case .state:
return .single(value)
case .loading:
return .single(value) |> delay(0.1, queue: .mainQueue())
}
}
panel.containerNode.headerNode.playbackStatus = delayedStatus
|> map { state -> MediaPlayerStatus in
if let stateOrLoading = state?.1, case let .state(state) = stateOrLoading {
return state.status
} else {
return MediaPlayerStatus(generationTimestamp: 0.0, duration: 0.0, dimensions: CGSize(), timestamp: 0.0, baseRate: 1.0, seekId: 0, status: .paused, soundEnabled: true)
}
}
panel.containerNode.headerNode.displayScrubber = component.data.item.playbackData?.type != .instantVideo
panel.getController = { [weak self] in
guard let self, let component = self.component else {
return nil
}
return component.controller()
}
panel.presentInGlobalOverlay = { [weak self] c in
guard let self, let component = self.component else {
return
}
component.controller()?.presentInGlobalOverlay(c)
}
panel.close = { [weak self] in
guard let self, let component = self.component else {
return
}
component.context.sharedContext.mediaManager.setPlaylist(nil, type: component.data.kind, control: SharedMediaPlayerControlAction.playback(.pause))
}
panel.setRate = { [weak self] rate, changeType in
guard let self, let component = self.component else {
return
}
let _ = (component.context.sharedContext.accountManager.transaction { transaction -> AudioPlaybackRate in
let settings = transaction.getSharedData(ApplicationSpecificSharedDataKeys.musicPlaybackSettings)?.get(MusicPlaybackSettings.self) ?? MusicPlaybackSettings.defaultSettings
transaction.updateSharedData(ApplicationSpecificSharedDataKeys.musicPlaybackSettings, { _ in
return EnginePreferencesEntry(settings.withUpdatedVoicePlaybackRate(rate))
})
return rate
}
|> deliverOnMainQueue).start(next: { [weak self] baseRate in
guard let self, let component = self.component else {
return
}
component.context.sharedContext.mediaManager.playlistControl(.setBaseRate(baseRate), type: component.data.kind)
var hasTooltip = false
component.controller()?.forEachController({ controller in
if let controller = controller as? UndoOverlayController {
hasTooltip = true
controller.dismissWithCommitAction()
}
return true
})
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
let text: String?
let rate: CGFloat?
if case let .sliderCommit(previousValue, newValue) = changeType {
let value = String(format: "%0.1f", baseRate.doubleValue)
if baseRate == .x1 {
text = presentationData.strings.Conversation_AudioRateTooltipNormal
} else {
text = presentationData.strings.Conversation_AudioRateTooltipCustom(value).string
}
if newValue > previousValue {
rate = .infinity
} else if newValue < previousValue {
rate = -.infinity
} else {
rate = nil
}
} else if baseRate == .x1 {
text = presentationData.strings.Conversation_AudioRateTooltipNormal
rate = 1.0
} else if baseRate == .x1_5 {
text = presentationData.strings.Conversation_AudioRateTooltip15X
rate = 1.5
} else if baseRate == .x2 {
text = presentationData.strings.Conversation_AudioRateTooltipSpeedUp
rate = 2.0
} else {
text = nil
rate = nil
}
var showTooltip = true
if case .sliderChange = changeType {
showTooltip = false
}
if let rate, let text, showTooltip {
let controller = UndoOverlayController(
presentationData: presentationData,
content: .audioRate(
rate: rate,
text: text
),
elevatedLayout: false,
animateInAsReplacement: hasTooltip,
action: { action in
return true
}
)
//strongSelf.audioRateTooltipController = controller
component.controller()?.present(controller, in: .current)
}
})
}
panel.togglePlayPause = { [weak self] in
self?.performAction({ [weak self] in
guard let self, let component = self.component else {
return
}
component.context.sharedContext.mediaManager.playlistControl(.playback(.togglePlayPause), type: component.data.kind)
})
}
panel.playPrevious = { [weak self] in
self?.performAction({ [weak self] in
guard let self, let component = self.component else {
return
}
component.context.sharedContext.mediaManager.playlistControl(.next, type: component.data.kind)
})
}
panel.playNext = { [weak self] in
self?.performAction({ [weak self] in
guard let self, let component = self.component else {
return
}
component.context.sharedContext.mediaManager.playlistControl(.previous, type: component.data.kind)
})
}
panel.tapAction = { [weak self] in
self?.performAction({ [weak self] in
guard let self, let component = self.component, let controller = component.controller(), let navigationController = controller.navigationController as? NavigationController else {
return
}
if let id = component.data.item.id as? PeerMessagesMediaPlaylistItemId, let playlistLocation = component.data.playlistLocation as? PeerMessagesPlaylistLocation {
if case .music = component.data.kind {
switch playlistLocation {
case .custom, .savedMusic:
let controllerContext: AccountContext
if component.data.account.id == component.context.account.id {
controllerContext = component.context
} else {
controllerContext = component.context.sharedContext.makeTempAccountContext(account: component.data.account)
}
let playerController = component.context.sharedContext.makeOverlayAudioPlayerController(context: controllerContext, chatLocation: .peer(id: id.messageId.peerId), type: component.data.kind, initialMessageId: id.messageId, initialOrder: component.data.playbackOrder, playlistLocation: playlistLocation, parentNavigationController: navigationController)
self.window?.endEditing(true)
playerController.navigationPresentation = .flatModal
controller.push(playerController)
case let .messages(chatLocation, _, _):
let signal = component.context.sharedContext.messageFromPreloadedChatHistoryViewForLocation(id: id.messageId, location: ChatHistoryLocationInput(content: .InitialSearch(subject: MessageHistoryInitialSearchSubject(location: .id(id.messageId)), count: 60, highlight: true, setupReply: false), id: 0), context: component.context, chatLocation: chatLocation, subject: nil, chatLocationContextHolder: Atomic<ChatLocationContextHolder?>(value: nil), tag: .tag(EngineMessage.Tags.music))
var cancelImpl: (() -> Void)?
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
let progressSignal = Signal<Never, NoError> { [weak self] subscriber in
let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: {
cancelImpl?()
}))
self?.component?.controller()?.present(controller, in: .window(.root))
return ActionDisposable { [weak controller] in
Queue.mainQueue().async() {
controller?.dismiss()
}
}
}
|> runOn(Queue.mainQueue())
|> delay(0.15, queue: Queue.mainQueue())
let progressDisposable = MetaDisposable()
var progressStarted = false
self.playlistPreloadDisposable?.dispose()
self.playlistPreloadDisposable = (signal
|> afterDisposed {
Queue.mainQueue().async {
progressDisposable.dispose()
}
}
|> deliverOnMainQueue).start(next: { [weak self] index in
guard let self, let component = self.component else {
return
}
if let _ = index.0 {
let controllerContext: AccountContext
if component.data.account.id == component.context.account.id {
controllerContext = component.context
} else {
controllerContext = component.context.sharedContext.makeTempAccountContext(account: component.data.account)
}
let playerController = component.context.sharedContext.makeOverlayAudioPlayerController(context: controllerContext, chatLocation: chatLocation, type: component.data.kind, initialMessageId: id.messageId, initialOrder: component.data.playbackOrder, playlistLocation: nil, parentNavigationController: navigationController)
self.window?.endEditing(true)
playerController.navigationPresentation = .flatModal
controller.push(playerController)
} else if index.1 {
if !progressStarted {
progressStarted = true
progressDisposable.set(progressSignal.start())
}
}
}, completed: {
})
cancelImpl = { [weak self] in
self?.playlistPreloadDisposable?.dispose()
}
default:
break
}
} else {
component.context.sharedContext.navigateToChat(accountId: component.context.account.id, peerId: id.messageId.peerId, messageId: id.messageId)
}
}
})
}
}
let size = CGSize(width: availableSize.width, height: 40.0)
let panelFrame = CGRect(origin: CGPoint(), size: size)
transition.setFrame(view: panel.view, frame: panelFrame)
panel.updateLayout(size: panelFrame.size, leftInset: 0.0, rightInset: 0.0, transition: transition.containedViewLayoutTransition)
switch component.data.playbackOrder {
case .regular:
panel.containerNode.headerNode.playbackItems = (component.data.item, component.data.previousItem, component.data.nextItem)
case .reversed:
panel.containerNode.headerNode.playbackItems = (component.data.item, component.data.nextItem, component.data.previousItem)
case .random:
panel.containerNode.headerNode.playbackItems = (component.data.item, nil, nil)
}
if themeUpdated {
panel.containerNode.updatePresentationData(PresentationData(
strings: component.strings,
theme: component.theme,
autoNightModeTriggered: false,
chatWallpaper: .builtin(WallpaperSettings()),
chatFontSize: .regular,
chatBubbleCorners: PresentationChatBubbleCorners(mainRadius: 0.0, auxiliaryRadius: 0.0, mergeBubbleCorners: true),
listsFontSize: .regular,
dateTimeFormat: PresentationDateTimeFormat(),
nameDisplayOrder: .firstLast,
nameSortOrder: .firstLast,
reduceMotion: false,
largeEmoji: false
))
}
return size
}
}
public func makeView() -> View {
return View(frame: CGRect())
}
public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}