Skip to content

Commit f6da30d

Browse files
isaacclaude
andcommitted
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>
1 parent 8990301 commit f6da30d

15 files changed

Lines changed: 595 additions & 66 deletions

‎docs/instantpage-richtext.md‎

Lines changed: 37 additions & 4 deletions
Large diffs are not rendered by default.

‎submodules/BrowserUI/Sources/BrowserInstantPageContent.swift‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1590,7 +1590,7 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg
15901590
}
15911591
}
15921592
}
1593-
self.context.sharedContext.mediaManager.setPlaylist((self.context, InstantPageMediaPlaylist(webPage: webPage, items: medias, initialItemIndex: initialIndex)), type: file.isVoice ? .voice : .music, control: .playback(.play))
1593+
self.context.sharedContext.mediaManager.setPlaylist((self.context, InstantPageMediaPlaylist(playlistId: .instantPage(webpageId: webPage.webpageId), webPage: webPage, messageReference: nil, items: medias, initialItemIndex: initialIndex)), type: file.isVoice ? .voice : .music, control: .playback(.play))
15941594
return
15951595
}
15961596

‎submodules/InstantPageUI/BUILD‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ swift_library(
2525
"//submodules/TelegramUI/Components/MultiAnimationRenderer:MultiAnimationRenderer",
2626
"//submodules/GalleryUI:GalleryUI",
2727
"//submodules/MusicAlbumArtResources:MusicAlbumArtResources",
28+
"//submodules/SemanticStatusNode:SemanticStatusNode",
2829
"//submodules/LiveLocationPositionNode:LiveLocationPositionNode",
2930
"//submodules/MosaicLayout:MosaicLayout",
3031
"//submodules/LocationUI:LocationUI",

‎submodules/InstantPageUI/Sources/InstantPageAudioItem.swift‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ public final class InstantPageAudioItem: InstantPageItem {
2424
}
2525

2626
public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? {
27-
return InstantPageAudioNode(context: context, strings: strings, theme: theme, webPage: self.webpage, media: self.media, openMedia: openMedia)
27+
return InstantPageAudioNode(context: context, strings: strings, theme: theme, webPage: self.webpage, media: self.media, playlistId: .instantPage(webpageId: self.webpage.webpageId), openMedia: openMedia)
2828
}
2929

3030
public func matchesAnchor(_ anchor: String) -> Bool {

‎submodules/InstantPageUI/Sources/InstantPageAudioNode.swift‎

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ final class InstantPageAudioNode: ASDisplayNode, InstantPageNode {
6666
private var playImage: UIImage
6767
private var pauseImage: UIImage
6868

69-
private let buttonNode: HighlightableButtonNode
69+
private let buttonView: UIView
7070
private let statusNode: RadialStatusNode
7171
private let titleNode: ASTextNode
7272
private let scrubbingNode: MediaPlayerScrubbingNode
@@ -76,7 +76,7 @@ final class InstantPageAudioNode: ASDisplayNode, InstantPageNode {
7676
private var isPlaying: Bool = false
7777
private var playbackState: SharedMediaPlayerItemPlaybackState?
7878

79-
init(context: AccountContext, strings: PresentationStrings, theme: InstantPageTheme, webPage: TelegramMediaWebpage, media: InstantPageMedia, openMedia: @escaping (InstantPageMedia) -> Void) {
79+
init(context: AccountContext, strings: PresentationStrings, theme: InstantPageTheme, webPage: TelegramMediaWebpage, media: InstantPageMedia, playlistId: InstantPageMediaPlaylistId, openMedia: @escaping (InstantPageMedia) -> Void) {
8080
self.context = context
8181
self.strings = strings
8282
self.theme = theme
@@ -86,7 +86,7 @@ final class InstantPageAudioNode: ASDisplayNode, InstantPageNode {
8686
self.playImage = generatePlayButton(color: theme.textCategories.paragraph.color)!
8787
self.pauseImage = generatePauseButton(color: theme.textCategories.paragraph.color)!
8888

89-
self.buttonNode = HighlightableButtonNode()
89+
self.buttonView = UIView()
9090
self.statusNode = RadialStatusNode(backgroundNodeColor: .clear)
9191
self.titleNode = ASTextNode()
9292
self.titleNode.maximumNumberOfLines = 1
@@ -112,25 +112,11 @@ final class InstantPageAudioNode: ASDisplayNode, InstantPageNode {
112112
self.titleNode.attributedText = titleString(media: media, theme: theme, strings: strings)
113113

114114
self.addSubnode(self.statusNode)
115-
self.addSubnode(self.buttonNode)
116115
self.addSubnode(self.titleNode)
117116
self.addSubnode(self.scrubbingNode)
118-
117+
119118
self.statusNode.transitionToState(RadialStatusNodeState.customIcon(self.playImage), animated: false, completion: {})
120119

121-
self.buttonNode.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: .touchUpInside)
122-
self.buttonNode.highligthedChanged = { [weak self] highlighted in
123-
if let strongSelf = self {
124-
if highlighted {
125-
strongSelf.statusNode.layer.removeAnimation(forKey: "opacity")
126-
strongSelf.statusNode.alpha = 0.4
127-
} else {
128-
strongSelf.statusNode.alpha = 1.0
129-
strongSelf.statusNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
130-
}
131-
}
132-
}
133-
134120
self.scrubbingNode.seek = { [weak self] timestamp in
135121
if let strongSelf = self {
136122
if let _ = strongSelf.playbackState {
@@ -178,12 +164,12 @@ final class InstantPageAudioNode: ASDisplayNode, InstantPageNode {
178164
}
179165
})*/
180166

181-
self.scrubbingNode.status = context.sharedContext.mediaManager.filteredPlaylistState(accountId: context.account.id, playlistId: InstantPageMediaPlaylistId(webpageId: webPage.webpageId), itemId: InstantPageMediaPlaylistItemId(index: self.media.index), type: self.playlistType)
167+
self.scrubbingNode.status = context.sharedContext.mediaManager.filteredPlaylistState(accountId: context.account.id, playlistId: playlistId, itemId: InstantPageMediaPlaylistItemId(index: self.media.index), type: self.playlistType)
182168
|> map { playbackState -> MediaPlayerStatus in
183169
return playbackState?.status ?? MediaPlayerStatus(generationTimestamp: 0.0, duration: 0.0, dimensions: CGSize(), timestamp: 0.0, baseRate: 1.0, seekId: 0, status: .paused, soundEnabled: true)
184170
}
185171

186-
self.playerStatusDisposable = (context.sharedContext.mediaManager.filteredPlaylistState(accountId: context.account.id, playlistId: InstantPageMediaPlaylistId(webpageId: webPage.webpageId), itemId: InstantPageMediaPlaylistItemId(index: self.media.index), type: playlistType)
172+
self.playerStatusDisposable = (context.sharedContext.mediaManager.filteredPlaylistState(accountId: context.account.id, playlistId: playlistId, itemId: InstantPageMediaPlaylistItemId(index: self.media.index), type: playlistType)
187173
|> deliverOnMainQueue).start(next: { [weak self] playbackState in
188174
guard let strongSelf = self else {
189175
return
@@ -213,7 +199,21 @@ final class InstantPageAudioNode: ASDisplayNode, InstantPageNode {
213199
deinit {
214200
self.playerStatusDisposable?.dispose()
215201
}
216-
202+
203+
override func didLoad() {
204+
super.didLoad()
205+
// The play/pause tap target is a plain view + UITapGestureRecognizer, NOT an ASControl
206+
// button. An ASControl's `.touchUpInside` is cancelled by the chat ListView's gesture
207+
// system (the control highlights on touch-down, but the action never fires), so an
208+
// embedded audio control in a rich-message bubble could never start playback. A gesture
209+
// recognizer coordinates with the list's gestures and fires reliably — matching the V2
210+
// image node, the details-title hit view, and the regular file/music message. The plain
211+
// view sits above `statusNode` and is positioned over the icon in `layout()`. (Works in
212+
// V1's full-page Instant View too; gesture recognizers fire inside its scroll view.)
213+
self.view.addSubview(self.buttonView)
214+
self.buttonView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.buttonPressed)))
215+
}
216+
217217
func update(strings: PresentationStrings, theme: InstantPageTheme) {
218218
if self.strings !== strings || self.theme !== theme {
219219
let themeUpdated = self.theme !== theme
@@ -268,7 +268,7 @@ final class InstantPageAudioNode: ASDisplayNode, InstantPageNode {
268268
let titleSize = self.titleNode.measure(CGSize(width: maxTitleWidth, height: size.height))
269269
self.titleNode.frame = CGRect(origin: CGPoint(x: insets.left + leftInset, y: 2.0), size: titleSize)
270270

271-
self.buttonNode.frame = CGRect(origin: CGPoint(x: insets.left, y: 0.0), size: CGSize(width: 48.0, height: 48.0))
271+
self.buttonView.frame = CGRect(origin: CGPoint(x: insets.left, y: 0.0), size: CGSize(width: 48.0, height: 48.0))
272272
self.statusNode.frame = CGRect(origin: CGPoint(x: insets.left, y: 0.0), size: CGSize(width: 48.0, height: 48.0))
273273

274274
var topOffset: CGFloat = 0.0

‎submodules/InstantPageUI/Sources/InstantPageControllerNode.swift‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1839,7 +1839,7 @@ final class InstantPageControllerNode: ASDisplayNode, ASScrollViewDelegate {
18391839
}
18401840
}
18411841
}
1842-
self.context.sharedContext.mediaManager.setPlaylist((self.context, InstantPageMediaPlaylist(webPage: webPage, items: medias, initialItemIndex: initialIndex)), type: file.isVoice ? .voice : .music, control: .playback(.play))
1842+
self.context.sharedContext.mediaManager.setPlaylist((self.context, InstantPageMediaPlaylist(playlistId: .instantPage(webpageId: webPage.webpageId), webPage: webPage, messageReference: nil, items: medias, initialItemIndex: initialIndex)), type: file.isVoice ? .voice : .music, control: .playback(.play))
18431843
return
18441844
}
18451845

‎submodules/InstantPageUI/Sources/InstantPageMediaPlaylist.swift‎

Lines changed: 38 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,28 @@ private func extractFileMedia(_ item: InstantPageMedia) -> TelegramMediaFile? {
3030

3131
final class InstantPageMediaPlaylistItem: SharedMediaPlaylistItem {
3232
let webPage: TelegramMediaWebpage
33+
let messageReference: MessageReference?
3334
let id: SharedMediaPlaylistItemId
3435
let item: InstantPageMedia
35-
36-
init(webPage: TelegramMediaWebpage, item: InstantPageMedia) {
36+
37+
init(webPage: TelegramMediaWebpage, messageReference: MessageReference?, item: InstantPageMedia) {
3738
self.webPage = webPage
39+
self.messageReference = messageReference
3840
self.id = InstantPageMediaPlaylistItemId(index: item.index)
3941
self.item = item
4042
}
41-
43+
44+
private func fileReference(_ file: TelegramMediaFile) -> FileMediaReference {
45+
// Require a resolvable message id (mirrors the playlist-key fallback in
46+
// InstantPageV2MediaAudioView): a `.none`-content reference can't revalidate, so fall
47+
// back to the webpage reference in that case.
48+
if let messageReference = self.messageReference, messageReference.id != nil {
49+
return .message(message: messageReference, media: file)
50+
} else {
51+
return .webPage(webPage: WebpageReference(self.webPage), media: file)
52+
}
53+
}
54+
4255
var stableId: AnyHashable {
4356
return self.item.index
4457
}
@@ -49,13 +62,13 @@ final class InstantPageMediaPlaylistItem: SharedMediaPlaylistItem {
4962
switch attribute {
5063
case let .Audio(isVoice, _, _, _, _):
5164
if isVoice {
52-
return SharedMediaPlaybackData(type: .voice, source: .telegramFile(reference: .webPage(webPage: WebpageReference(self.webPage), media: file), isCopyProtected: false, isViewOnce: false))
65+
return SharedMediaPlaybackData(type: .voice, source: .telegramFile(reference: self.fileReference(file), isCopyProtected: false, isViewOnce: false))
5366
} else {
54-
return SharedMediaPlaybackData(type: .music, source: .telegramFile(reference: .webPage(webPage: WebpageReference(self.webPage), media: file), isCopyProtected: false, isViewOnce: false))
67+
return SharedMediaPlaybackData(type: .music, source: .telegramFile(reference: self.fileReference(file), isCopyProtected: false, isViewOnce: false))
5568
}
5669
case let .Video(_, _, flags, _, _, _):
5770
if flags.contains(.instantRoundVideo) {
58-
return SharedMediaPlaybackData(type: .instantVideo, source: .telegramFile(reference: .webPage(webPage: WebpageReference(self.webPage), media: file), isCopyProtected: false, isViewOnce: false))
71+
return SharedMediaPlaybackData(type: .instantVideo, source: .telegramFile(reference: self.fileReference(file), isCopyProtected: false, isViewOnce: false))
5972
} else {
6073
return nil
6174
}
@@ -64,12 +77,12 @@ final class InstantPageMediaPlaylistItem: SharedMediaPlaylistItem {
6477
}
6578
}
6679
if file.mimeType.hasPrefix("audio/") {
67-
return SharedMediaPlaybackData(type: .music, source: .telegramFile(reference: .webPage(webPage: WebpageReference(self.webPage), media: file), isCopyProtected: false, isViewOnce: false))
80+
return SharedMediaPlaybackData(type: .music, source: .telegramFile(reference: self.fileReference(file), isCopyProtected: false, isViewOnce: false))
6881
}
6982
if let fileName = file.fileName {
7083
let ext = (fileName as NSString).pathExtension.lowercased()
7184
if ext == "wav" || ext == "opus" {
72-
return SharedMediaPlaybackData(type: .music, source: .telegramFile(reference: .webPage(webPage: WebpageReference(self.webPage), media: file), isCopyProtected: false, isViewOnce: false))
85+
return SharedMediaPlaybackData(type: .music, source: .telegramFile(reference: self.fileReference(file), isCopyProtected: false, isViewOnce: false))
7386
}
7487
}
7588
}
@@ -116,14 +129,15 @@ final class InstantPageMediaPlaylistItem: SharedMediaPlaylistItem {
116129
}
117130
}
118131

119-
struct InstantPageMediaPlaylistId: SharedMediaPlaylistId {
120-
let webpageId: EngineMedia.Id
121-
122-
func isEqual(to: SharedMediaPlaylistId) -> Bool {
123-
if let to = to as? InstantPageMediaPlaylistId {
124-
return self.webpageId == to.webpageId
132+
public enum InstantPageMediaPlaylistId: Equatable, SharedMediaPlaylistId {
133+
case instantPage(webpageId: EngineMedia.Id)
134+
case richMessage(messageId: EngineMessage.Id)
135+
136+
public func isEqual(to: SharedMediaPlaylistId) -> Bool {
137+
guard let to = to as? InstantPageMediaPlaylistId else {
138+
return false
125139
}
126-
return false
140+
return self == to
127141
}
128142
}
129143

@@ -134,15 +148,13 @@ struct InstantPagePlaylistLocation: Equatable, SharedMediaPlaylistLocation {
134148
guard let to = to as? InstantPagePlaylistLocation else {
135149
return false
136150
}
137-
if self.webpageId == to.webpageId {
138-
return false
139-
}
140-
return true
151+
return self.webpageId == to.webpageId
141152
}
142153
}
143154

144155
public final class InstantPageMediaPlaylist: SharedMediaPlaylist {
145156
private let webPage: TelegramMediaWebpage
157+
private let messageReference: MessageReference?
146158
private let items: [InstantPageMedia]
147159
private let initialItemIndex: Int
148160

@@ -164,15 +176,16 @@ public final class InstantPageMediaPlaylist: SharedMediaPlaylist {
164176
return self.stateValue.get()
165177
}
166178

167-
public init(webPage: TelegramMediaWebpage, items: [InstantPageMedia], initialItemIndex: Int) {
179+
public init(playlistId: InstantPageMediaPlaylistId, webPage: TelegramMediaWebpage, messageReference: MessageReference?, items: [InstantPageMedia], initialItemIndex: Int) {
168180
assert(Queue.mainQueue().isCurrent())
169-
170-
self.id = InstantPageMediaPlaylistId(webpageId: webPage.webpageId)
171-
181+
182+
self.id = playlistId
183+
172184
self.webPage = webPage
185+
self.messageReference = messageReference
173186
self.items = items
174187
self.initialItemIndex = initialItemIndex
175-
188+
176189
self.control(.next)
177190
}
178191

@@ -243,7 +256,7 @@ public final class InstantPageMediaPlaylist: SharedMediaPlaylist {
243256
}
244257

245258
private func updateState() {
246-
self.stateValue.set(.single(SharedMediaPlaylistState(loading: false, playedToEnd: self.playedToEnd, item: self.currentItem.flatMap({ InstantPageMediaPlaylistItem(webPage: self.webPage, item: $0) }), nextItem: nil, previousItem: nil, order: self.order, looping: self.looping)))
259+
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)))
247260
}
248261

249262
public func onItemPlaybackStarted(_ item: SharedMediaPlaylistItem) {

0 commit comments

Comments
 (0)