WinterGram/submodules/InstantPageUI/Sources/InstantPageMediaPlaylist.swift
isaac f6da30da70 InstantPage V2: audio/music rendering, playback, and file-bubble styling
Render and play InstantPageBlock.audio in the InstantPage V2 renderer
(rich-data message bubbles + the rich send preview), where audio was
previously an inert grey placeholder.

- New InstantPageV2AudioContentNode replicates the standard music message
  bubble (ChatMessageInteractiveFileNode's music layout): a SemanticStatusNode
  control (album art via playerAlbumArt + play/pause) with a download/progress
  overlay, title + "duration · performer" lines, exact fonts/colors from
  theme.chat.message.{incoming|outgoing}. Tap is driven by a
  UITapGestureRecognizer (ASControl .touchUpInside is cancelled by the chat
  ListView's gesture system). V1's InstantPageAudioNode is unchanged.
- Playback runs on InstantPageMediaPlaylist with a discriminated, message-scoped
  InstantPageMediaPlaylistId (.instantPage / .richMessage) so concurrent
  rich-message audio bubbles don't collide; the big control's play/pause comes
  from filteredPlaylistState, the overlay's download/progress from
  messageMediaFileStatus, and fetch goes through the fetch manager.
- Rich-message audio fetches via a MessageReference (threaded through the V2
  render context) instead of the synthesized webpage; FetchedMediaResource's
  .message revalidation arm now also searches RichTextMessageAttribute instant
  pages, so a stale instant-page audio/image reference can recover. Corrected a
  dormant inverted InstantPagePlaylistLocation.isEqual.
- New .mediaAudio laid-out item + layout/reveal-cost arms; the audio block lays
  out full-width at the file node's music normHeight.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:44:45 +02:00

264 lines
11 KiB
Swift

import Foundation
import UIKit
import SwiftSignalKit
import TelegramCore
import TelegramUIPreferences
import AccountContext
import MusicAlbumArtResources
struct InstantPageMediaPlaylistItemId: SharedMediaPlaylistItemId {
let index: Int
func isEqual(to: SharedMediaPlaylistItemId) -> Bool {
if let to = to as? InstantPageMediaPlaylistItemId {
if self.index != to.index {
return false
}
return true
}
return false
}
}
private func extractFileMedia(_ item: InstantPageMedia) -> TelegramMediaFile? {
if case let .file(file) = item.media {
return file
} else {
return nil
}
}
final class InstantPageMediaPlaylistItem: SharedMediaPlaylistItem {
let webPage: TelegramMediaWebpage
let messageReference: MessageReference?
let id: SharedMediaPlaylistItemId
let item: InstantPageMedia
init(webPage: TelegramMediaWebpage, messageReference: MessageReference?, item: InstantPageMedia) {
self.webPage = webPage
self.messageReference = messageReference
self.id = InstantPageMediaPlaylistItemId(index: item.index)
self.item = item
}
private func fileReference(_ file: TelegramMediaFile) -> FileMediaReference {
// Require a resolvable message id (mirrors the playlist-key fallback in
// InstantPageV2MediaAudioView): a `.none`-content reference can't revalidate, so fall
// back to the webpage reference in that case.
if let messageReference = self.messageReference, messageReference.id != nil {
return .message(message: messageReference, media: file)
} else {
return .webPage(webPage: WebpageReference(self.webPage), media: file)
}
}
var stableId: AnyHashable {
return self.item.index
}
var playbackData: SharedMediaPlaybackData? {
if let file = extractFileMedia(self.item) {
for attribute in file.attributes {
switch attribute {
case let .Audio(isVoice, _, _, _, _):
if isVoice {
return SharedMediaPlaybackData(type: .voice, source: .telegramFile(reference: self.fileReference(file), isCopyProtected: false, isViewOnce: false))
} else {
return SharedMediaPlaybackData(type: .music, source: .telegramFile(reference: self.fileReference(file), isCopyProtected: false, isViewOnce: false))
}
case let .Video(_, _, flags, _, _, _):
if flags.contains(.instantRoundVideo) {
return SharedMediaPlaybackData(type: .instantVideo, source: .telegramFile(reference: self.fileReference(file), isCopyProtected: false, isViewOnce: false))
} else {
return nil
}
default:
break
}
}
if file.mimeType.hasPrefix("audio/") {
return SharedMediaPlaybackData(type: .music, source: .telegramFile(reference: self.fileReference(file), isCopyProtected: false, isViewOnce: false))
}
if let fileName = file.fileName {
let ext = (fileName as NSString).pathExtension.lowercased()
if ext == "wav" || ext == "opus" {
return SharedMediaPlaybackData(type: .music, source: .telegramFile(reference: self.fileReference(file), isCopyProtected: false, isViewOnce: false))
}
}
}
return nil
}
var displayData: SharedMediaPlaybackDisplayData? {
if let file = extractFileMedia(self.item) {
for attribute in file.attributes {
switch attribute {
case let .Audio(isVoice, _, title, performer, _):
if isVoice {
return SharedMediaPlaybackDisplayData.voice(author: nil, peer: nil)
} else {
var updatedTitle = title
let updatedPerformer = performer
if (title ?? "").isEmpty && (performer ?? "").isEmpty {
updatedTitle = file.fileName ?? ""
}
let albumArt: SharedMediaPlaybackAlbumArt?
if file.fileName?.lowercased().hasSuffix(".ogg") == true {
albumArt = nil
} else {
albumArt = SharedMediaPlaybackAlbumArt(thumbnailResource: ExternalMusicAlbumArtResource(file: .standalone(media: file), title: updatedTitle ?? "", performer: updatedPerformer ?? "", isThumbnail: true), fullSizeResource: ExternalMusicAlbumArtResource(file: .standalone(media: file), title: updatedTitle ?? "", performer: updatedPerformer ?? "", isThumbnail: false))
}
return SharedMediaPlaybackDisplayData.music(title: updatedTitle, performer: updatedPerformer, albumArt: albumArt, long: false, caption: nil)
}
case let .Video(_, _, flags, _, _, _):
if flags.contains(.instantRoundVideo) {
return SharedMediaPlaybackDisplayData.instantVideo(author: nil, peer: nil, timestamp: 0)
} else {
return nil
}
default:
break
}
}
return SharedMediaPlaybackDisplayData.music(title: file.fileName ?? "", performer: "", albumArt: nil, long: false, caption: nil)
}
return nil
}
}
public enum InstantPageMediaPlaylistId: Equatable, SharedMediaPlaylistId {
case instantPage(webpageId: EngineMedia.Id)
case richMessage(messageId: EngineMessage.Id)
public func isEqual(to: SharedMediaPlaylistId) -> Bool {
guard let to = to as? InstantPageMediaPlaylistId else {
return false
}
return self == to
}
}
struct InstantPagePlaylistLocation: Equatable, SharedMediaPlaylistLocation {
let webpageId: EngineMedia.Id
func isEqual(to: SharedMediaPlaylistLocation) -> Bool {
guard let to = to as? InstantPagePlaylistLocation else {
return false
}
return self.webpageId == to.webpageId
}
}
public final class InstantPageMediaPlaylist: SharedMediaPlaylist {
private let webPage: TelegramMediaWebpage
private let messageReference: MessageReference?
private let items: [InstantPageMedia]
private let initialItemIndex: Int
public var location: SharedMediaPlaylistLocation {
return InstantPagePlaylistLocation(webpageId: self.webPage.webpageId)
}
public var currentItemDisappeared: (() -> Void)?
private var currentItem: InstantPageMedia?
private var playedToEnd: Bool = false
private var order: MusicPlaybackSettingsOrder = .regular
public private(set) var looping: MusicPlaybackSettingsLooping = .none
public let id: SharedMediaPlaylistId
private let stateValue = Promise<SharedMediaPlaylistState>()
public var state: Signal<SharedMediaPlaylistState, NoError> {
return self.stateValue.get()
}
public init(playlistId: InstantPageMediaPlaylistId, webPage: TelegramMediaWebpage, messageReference: MessageReference?, items: [InstantPageMedia], initialItemIndex: Int) {
assert(Queue.mainQueue().isCurrent())
self.id = playlistId
self.webPage = webPage
self.messageReference = messageReference
self.items = items
self.initialItemIndex = initialItemIndex
self.control(.next)
}
public func control(_ action: SharedMediaPlaylistControlAction) {
assert(Queue.mainQueue().isCurrent())
switch action {
case .next, .previous:
if let currentItem = self.currentItem, let currentIndex = self.items.firstIndex(where: { $0.index == currentItem.index }) {
let selectedIndex: Int?
switch self.order {
case .regular:
if case .next = action {
selectedIndex = max(0, currentIndex - 1)
} else {
if currentIndex == self.items.count - 1 {
selectedIndex = nil
} else {
selectedIndex = currentIndex + 1
}
}
case .reversed:
if case .next = action {
if currentIndex == self.items.count - 1 {
selectedIndex = nil
} else {
selectedIndex = currentIndex + 1
}
} else {
selectedIndex = max(0, currentIndex - 1)
}
case .random:
selectedIndex = Int(arc4random_uniform(UInt32(self.items.count)))
}
if let selectedIndex = selectedIndex {
self.currentItem = self.items[selectedIndex]
self.playedToEnd = false
} else {
self.currentItem = nil
self.playedToEnd = true
}
self.updateState()
} else {
if self.initialItemIndex < self.items.count {
self.currentItem = self.items[self.initialItemIndex]
} else {
self.currentItem = nil
}
self.playedToEnd = false
self.updateState()
}
}
}
public func setOrder(_ order: MusicPlaybackSettingsOrder) {
if self.order != order {
self.order = order
self.updateState()
}
}
public func setLooping(_ looping: MusicPlaybackSettingsLooping) {
if self.looping != looping {
self.looping = looping
self.updateState()
}
}
private func updateState() {
self.stateValue.set(.single(SharedMediaPlaylistState(loading: false, playedToEnd: self.playedToEnd, item: self.currentItem.flatMap({ InstantPageMediaPlaylistItem(webPage: self.webPage, messageReference: self.messageReference, item: $0) }), nextItem: nil, previousItem: nil, order: self.order, looping: self.looping)))
}
public func onItemPlaybackStarted(_ item: SharedMediaPlaylistItem) {
}
}