Telegram-iOS/submodules/InstantPageUI/Sources/InstantPageV2AudioContentNode.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

278 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import TelegramCore
import TelegramPresentationData
import AccountContext
import UniversalMediaPlayer
import SemanticStatusNode
import MusicAlbumArtResources
import PhotoResources
// Renders an InstantPage audio block to match the standard music message bubble
// (ChatMessageInteractiveFileNode, non-thumbnail/non-voice music branch). Visual only differs
// from the file node in that playback is driven by InstantPageMediaPlaylist (our `play` closure)
// rather than the peer-messages model. V1's InstantPageAudioNode is unaffected.
final class InstantPageV2AudioContentNode: ASDisplayNode {
private let context: AccountContext
private let message: MessageReference?
private let file: TelegramMediaFile
private let incoming: Bool
private let statusNode: SemanticStatusNode
private let streamingStatusNode: SemanticStatusNode
private let titleNode: TextNode
private let descriptionNode: TextNode
private let tapView: UIView
// TextNode (unlike ASTextNode) has no stored `attributedText`; the string is an argument to
// `TextNode.asyncLayout`. Keep the current strings here and feed them in `updateLayout`.
private var titleAttributedString: NSAttributedString?
private var descriptionAttributedString: NSAttributedString?
var play: () -> Void = {}
var togglePlayPause: () -> Void = {}
var fetch: () -> Void = {}
private var resourceStatusDisposable: Disposable?
// EngineMediaResourceStatus is the TelegramCore typealias for Postbox's MediaResourceStatus;
// using it keeps this file off `import Postbox` (TelegramCore doesn't re-export Postbox).
private var fetchStatus: EngineMediaResourceStatus?
private var playbackStatusDisposable: Disposable?
private(set) var isPlaying: Bool = false
// Theme-refresh state. `incoming` is already a `let` stored above; `incomingValue` tracks the
// last theme-update's incoming flag so `updatePresentationData` can guard on change.
private var presentationData: PresentationData
private var incomingValue: Bool
private static let progressDiameter: CGFloat = 40.0
// Shifted +9pt right of the original x=3 ( 12); the Ø40 control is vertically centered in the
// 44pt row (y = (44 40)/2 = 2).
private static let progressOrigin = CGPoint(x: 12.0, y: 2.0)
private static let controlAreaWidth: CGFloat = 12.0 + 40.0 + 8.0
private static let normHeight: CGFloat = 44.0
init(context: AccountContext, message: MessageReference?, file: TelegramMediaFile, incoming: Bool, presentationData: PresentationData) {
self.context = context
self.message = message
self.file = file
self.incoming = incoming
self.presentationData = presentationData
self.incomingValue = incoming
let messageTheme = incoming ? presentationData.theme.chat.message.incoming : presentationData.theme.chat.message.outgoing
let backgroundNodeColor = messageTheme.mediaActiveControlColor
let foregroundNodeColor: UIColor = (incoming && messageTheme.mediaActiveControlColor.rgb != 0xffffff) ? .white : .clear
var title: String?
var performer: String?
for attribute in file.attributes {
if case let .Audio(_, _, t, p, _) = attribute { title = t; performer = p; break }
}
let albumArtImage: Signal<(TransformImageArguments) -> DrawingContext?, NoError>?
if file.isMusic, file.fileName?.lowercased().hasSuffix(".ogg") != true, let message = message {
let fileRef: FileMediaReference = .message(message: message, media: file)
albumArtImage = playerAlbumArt(
engine: context.engine,
fileReference: fileRef,
albumArt: SharedMediaPlaybackAlbumArt(
thumbnailResource: ExternalMusicAlbumArtResource(file: fileRef, title: title ?? "", performer: performer ?? "", isThumbnail: true),
fullSizeResource: ExternalMusicAlbumArtResource(file: fileRef, title: title ?? "", performer: performer ?? "", isThumbnail: false)
),
thumbnail: true,
overlayColor: UIColor(white: 0.0, alpha: 0.3),
drawPlaceholderWhenEmpty: false,
attemptSynchronously: false
)
} else {
albumArtImage = nil
}
self.statusNode = SemanticStatusNode(
backgroundNodeColor: backgroundNodeColor,
foregroundNodeColor: foregroundNodeColor,
image: albumArtImage,
overlayForegroundNodeColor: presentationData.theme.chat.message.mediaOverlayControlColors.foregroundColor
)
self.streamingStatusNode = SemanticStatusNode(backgroundNodeColor: .clear, foregroundNodeColor: messageTheme.mediaActiveControlColor)
self.titleNode = TextNode()
self.titleNode.displaysAsynchronously = false
self.titleNode.isUserInteractionEnabled = false
self.descriptionNode = TextNode()
self.descriptionNode.displaysAsynchronously = false
self.descriptionNode.isUserInteractionEnabled = false
self.tapView = UIView()
super.init()
self.titleAttributedString = InstantPageV2AudioContentNode.titleString(file: file, incoming: incoming, presentationData: presentationData)
self.descriptionAttributedString = InstantPageV2AudioContentNode.descriptionString(file: file, incoming: incoming, presentationData: presentationData)
self.addSubnode(self.statusNode)
self.addSubnode(self.streamingStatusNode)
self.addSubnode(self.titleNode)
self.addSubnode(self.descriptionNode)
self.statusNode.transitionToState(.play, animated: false)
self.streamingStatusNode.transitionToState(.none, animated: false)
if let messageId = self.message?.id {
self.resourceStatusDisposable = (messageMediaFileStatus(context: context, messageId: messageId, file: file)
|> deliverOnMainQueue).startStrict(next: { [weak self] status in
self?.fetchStatus = status
self?.updateStreamingState()
})
}
}
override func didLoad() {
super.didLoad()
// Tap target = plain view + UITapGestureRecognizer, NOT an ASControl: ASControl
// .touchUpInside is cancelled by the chat ListView's gesture system (see InstantPageAudioNode
// for the same reason).
self.view.addSubview(self.tapView)
self.tapView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapped)))
}
@objc private func tapped() {
self.controlTapped()
}
func controlTapped() {
switch self.fetchStatus {
case .Remote, .Paused:
self.fetch()
case .none, .Local, .Fetching:
if self.isPlaying {
self.togglePlayPause()
} else {
self.play()
}
}
}
deinit {
self.resourceStatusDisposable?.dispose()
self.playbackStatusDisposable?.dispose()
}
// Drives the big control's play/pause icon from the playlist state filtered to our
// playlistId + itemId. Mirrors InstantPageAudioNode's subscription shape.
func setPlaybackStatusSignal(_ signal: Signal<SharedMediaPlayerItemPlaybackState?, NoError>) {
self.playbackStatusDisposable?.dispose() // defensive: a re-call must not leak the prior subscription
self.playbackStatusDisposable = (signal |> deliverOnMainQueue).startStrict(next: { [weak self] state in
guard let self else { return }
let isPlaying: Bool
if let status = state?.status {
if case .playing = status.status {
isPlaying = true
} else {
isPlaying = false
}
} else {
isPlaying = false
}
self.isPlaying = isPlaying
self.statusNode.transitionToState(isPlaying ? .pause : .play)
})
}
// Refreshes title/description attributed strings and the statusNode tint/foreground/overlay
// colors when the theme or incoming direction changes. Called from the host view's
// update(item:theme:renderContext:).
func updatePresentationData(_ presentationData: PresentationData, incoming: Bool) {
if self.presentationData.theme === presentationData.theme && self.incomingValue == incoming { return }
self.presentationData = presentationData
self.incomingValue = incoming
self.titleAttributedString = InstantPageV2AudioContentNode.titleString(file: self.file, incoming: incoming, presentationData: presentationData)
self.descriptionAttributedString = InstantPageV2AudioContentNode.descriptionString(file: self.file, incoming: incoming, presentationData: presentationData)
let messageTheme = incoming ? presentationData.theme.chat.message.incoming : presentationData.theme.chat.message.outgoing
self.statusNode.backgroundNodeColor = messageTheme.mediaActiveControlColor
// foreground/overlay also depend on incoming + theme (set at construction) refresh them
// too so the play glyph isn't miscolored after an in-place theme/direction change.
self.statusNode.foregroundNodeColor = (incoming && messageTheme.mediaActiveControlColor.rgb != 0xffffff) ? .white : .clear
self.statusNode.overlayForegroundNodeColor = presentationData.theme.chat.message.mediaOverlayControlColors.foregroundColor
// No setNeedsLayout(): this node doesn't override layout(); the host calls updateLayout(width:)
// right after updatePresentationData, which re-runs the text layout with the rebuilt strings.
}
private func updateStreamingState() {
let state: SemanticStatusNodeState
switch self.fetchStatus {
case .none, .Local:
state = .none
case let .Fetching(_, progress):
state = .progress(value: CGFloat(max(progress, 0.027)), cancelEnabled: true, appearance: SemanticStatusNodeState.ProgressAppearance(inset: 1.0, lineWidth: 2.0), animateRotation: true)
case .Remote, .Paused:
state = .download
}
self.streamingStatusNode.transitionToState(state)
}
// Line 1: track title at 17pt (= baseDisplaySize at the default font setting; scales with it).
private static func titleString(file: TelegramMediaFile, incoming: Bool, presentationData: PresentationData) -> NSAttributedString {
let messageTheme = incoming ? presentationData.theme.chat.message.incoming : presentationData.theme.chat.message.outgoing
let titleFont = Font.regular(floor(presentationData.chatFontSize.baseDisplaySize * 17.0 / 17.0))
var title = file.fileName ?? "Unknown Track"
for attribute in file.attributes {
if case let .Audio(false, _, t, _, _) = attribute { title = t ?? title; break }
}
return NSAttributedString(string: title, font: titleFont, textColor: messageTheme.fileTitleColor)
}
// Line 2: "<duration> · <performer>" at 15pt (omits the "· performer" tail when there's no
// performer; omits the duration when it's absent).
private static func descriptionString(file: TelegramMediaFile, incoming: Bool, presentationData: PresentationData) -> NSAttributedString {
let messageTheme = incoming ? presentationData.theme.chat.message.incoming : presentationData.theme.chat.message.outgoing
let descriptionFont = Font.with(size: floor(presentationData.chatFontSize.baseDisplaySize * 15.0 / 17.0), design: .regular, weight: .regular, traits: [.monospacedNumbers])
var performer = ""
var durationSeconds: Int = 0
for attribute in file.attributes {
if case let .Audio(false, duration, _, p, _) = attribute {
performer = (p ?? "").trimmingCharacters(in: .whitespaces)
durationSeconds = duration
break
}
}
var text = ""
if durationSeconds > 0 {
text = String(format: "%d:%02d", Int32(durationSeconds / 60), Int32(durationSeconds % 60))
}
if !performer.isEmpty {
text += text.isEmpty ? performer : " · \(performer)"
}
return NSAttributedString(string: text, font: descriptionFont, textColor: messageTheme.fileDescriptionColor)
}
func updateLayout(width: CGFloat) {
let progressFrame = CGRect(origin: InstantPageV2AudioContentNode.progressOrigin, size: CGSize(width: InstantPageV2AudioContentNode.progressDiameter, height: InstantPageV2AudioContentNode.progressDiameter))
self.statusNode.frame = progressFrame
let streamingDiameter: CGFloat = 24.0
self.streamingStatusNode.frame = CGRect(origin: CGPoint(x: progressFrame.maxX - streamingDiameter + 2.0, y: progressFrame.maxY - streamingDiameter + 2.0), size: CGSize(width: streamingDiameter, height: streamingDiameter))
let controlAreaWidth = InstantPageV2AudioContentNode.controlAreaWidth
let textWidth = max(1.0, width - controlAreaWidth - 8.0)
let (titleLayout, titleApply) = TextNode.asyncLayout(self.titleNode)(TextNodeLayoutArguments(attributedString: self.titleAttributedString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .middle, constrainedSize: CGSize(width: textWidth, height: 100.0), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
let (descLayout, descApply) = TextNode.asyncLayout(self.descriptionNode)(TextNodeLayoutArguments(attributedString: self.descriptionAttributedString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: textWidth, height: 100.0), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
let _ = titleApply()
let _ = descApply()
let titleAndDescriptionHeight = titleLayout.size.height - 1.0 + descLayout.size.height
let normHeight = InstantPageV2AudioContentNode.normHeight
let titleFrame = CGRect(origin: CGPoint(x: controlAreaWidth, y: floor((normHeight - titleAndDescriptionHeight) / 2.0)), size: titleLayout.size)
self.titleNode.frame = titleFrame
self.descriptionNode.frame = CGRect(origin: CGPoint(x: titleFrame.minX, y: titleFrame.maxY - 1.0), size: descLayout.size)
// No scrubber. The tapView covers the full row so a tap anywhere toggles playback (there is
// no scrubber pan to conflict with anymore).
self.tapView.frame = CGRect(origin: .zero, size: CGSize(width: width, height: normHeight))
}
}