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>
This commit is contained in:
isaac 2026-06-02 17:44:45 +02:00
parent 899030184a
commit f6da30da70
15 changed files with 595 additions and 66 deletions

View file

@ -57,25 +57,58 @@ A V2 `.table` block's item frame is **full-width / flush** with the bubble inter
## InstantPage V2 block media — flush (edge-to-edge), un-rounded
Every V2 block-media kind **except `.audio`** lays out **flush** with the bubble interior (0 inset, full bounding width) and **un-rounded** (cornerRadius 0). The bubble's existing rounded clipping container rounds any media that meets the bubble's top/bottom edge. V1 (`InstantPageLayout.swift`) is unchanged. (Audio keeps the legacy inset + 8pt rounding.)
Every V2 block-media kind lays out **flush** with the bubble interior (0 inset, full bounding width) and **un-rounded** (cornerRadius 0). The bubble's existing rounded clipping container rounds any media that meets the bubble's top/bottom edge. V1 (`InstantPageLayout.swift`) is unchanged. (Audio is **also** full-width / x = 0 as of the V2 audio port, but it does not use this helper — it has its own `layoutAudio` arm; the wrapped `InstantPageAudioNode` supplies its own 17pt internal content inset. See the "InstantPage V2 audio/music" section below.)
### Where things live
| File | Responsibility |
|---|---|
| `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift` | `instantPageV2MediaFrame(naturalSize:flush:cornerRadius:boundingWidth:horizontalInset:)` — the shared frame helper; `instantPageV2MediaEdgeBleed` constant; the `flush: Bool` parameter on `layoutTypedMediaWithCaption` (image/video/webEmbed-cover/map) and `layoutMediaWithCaption` (audio/webEmbed-placeholder/postEmbed/channelBanner/relatedArticles). (Collage/slideshow no longer route through `layoutMediaWithCaption` — see the collage & slideshow section.) |
| `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift` | `instantPageV2MediaFrame(naturalSize:flush:cornerRadius:boundingWidth:horizontalInset:)` — the shared frame helper; `instantPageV2MediaEdgeBleed` constant; the `flush: Bool` parameter on `layoutTypedMediaWithCaption` (image/video/webEmbed-cover/map) and `layoutMediaWithCaption` (webEmbed-placeholder/postEmbed/channelBanner/relatedArticles). (Collage/slideshow and **audio** no longer route through these — see their dedicated sections.) |
| `submodules/InstantPageUI/Sources/InstantPageV2MediaViews.swift`, `…/InstantPageRenderer.swift` (`InstantPageV2MediaPlaceholderView`) | Renderer — **no change needed**: every media view + the placeholder view already does `clipsToBounds = item.cornerRadius > 0.0`, so cornerRadius 0 means the view doesn't self-clip; the bubble's `containerNode` clips. |
| `…/Chat/ChatMessageRichDataBubbleContentNode/…` | The clipping container: `containerNode` (`clipsToBounds = true`, `cornerRadius = layoutConstants.image.defaultCornerRadius` ≈ 1516pt) is what rounds flush media at the bubble edge. |
### Non-obvious invariants
- **`flush` is a parameter, not inferred from cornerRadius.** All media call sites pass `flush: true` **except `.audio`** (`flush: false`). On the flush path the helper forces the returned corner radius to `0` regardless of the caller's `cornerRadius` argument (the legacy `8.0`/`0.0` args at the call sites are now inert on the flush path — kept as-is, documented in the helper).
- **`flush` is a parameter, not inferred from cornerRadius.** **Every** remaining media call site now passes `flush: true`. Audio — the former lone `flush: false` caller — was moved to its own `layoutAudio` arm in the V2 audio port, so `instantPageV2MediaFrame`'s `flush == false` branch is now **dead code** (a candidate for a follow-up cleanup: drop the `flush` parameter and the inset branch entirely). On the flush path the helper forces the returned corner radius to `0` regardless of the caller's `cornerRadius` argument (the legacy `8.0`/`0.0` args at the call sites are now inert — kept as-is, documented in the helper).
- **Small images are NOT upscaled.** The `scale = min(availableWidth / naturalSize.width, 1.0)` cap is kept (now against `availableWidth = boundingWidth`). A small image stays at natural size, **flush-left at x = 0** (not stretched to full width). Large images (the common server/AI case) fill the width.
- **Full-width media bleeds `instantPageV2MediaEdgeBleed` (4pt) past the trailing edge.** The pageView sits at `x: -1` inside `containerNode` (a border-hiding hairline), so a frame at `x: 0, width: boundingWidth` falls ~1px short of the container's right rounded-clip edge → a 1px corner notch. A small over-bleed on **full-width** items only (`fillsWidth = scaledSize.width >= availableWidth - 1.0`) closes it; a genuinely small image gets no bleed. **The bleed never widens the bubble** because `layoutInstantPageV2` clamps `contentSize.width = min(maxX, boundingWidth)` (gated by `context.fitToWidth`, which both callers — the rich bubble and the send preview — pass `true`).
- **Captions stay inset.** `layoutCaptionAndCredit` is still called with the page `horizontalInset` and offset by the **un-bled** `scaledSize.height`; the caption/credit text is inset under a full-bleed image. The `isCover && captionHeight > 0` cover-padding block is unchanged.
- **Audio is the lone exception** and routes through the non-flush branch of `instantPageV2MediaFrame` (inset by `horizontalInset`, caller's cornerRadius), reproducing the legacy behavior exactly.
- **Audio is no longer routed through this helper.** As of the V2 audio port it has a dedicated `layoutAudio` arm emitting a typed `.mediaAudio` item at a full-width (x = 0), height-48 frame (matching V1 `InstantPageLayout.swift`); the wrapped `InstantPageAudioNode` self-insets its content by 17pt, and audio does **not** participate in `instantPageV2MediaEdgeBleed` (its node background is transparent). See the dedicated "InstantPage V2 audio/music" section below.
- **`.map` blocks get a 600×300 (2:1) fallback when the sender omits dimensions.** AI/server-sent `.map` blocks can arrive with `dimensions == 0×0` (the wire `w`/`h` are *required* `Int32`, but the sender may put 0; our `pageBlockMap` parse and both serializers — Postbox `sw`/`sh`, FlatBuffers `required dimensions` — preserve whatever arrives, so the zero originates upstream). A zero `naturalSize.height` hits `instantPageV2MediaFrame`'s `else` branch and returns a **height-0** frame: the map collapses to no space, the caption slides up into it, and the V1 node's pin (positioned at `size.height*0.5 10 pinSize/2`) floats over the caption. **The `.map` arm in `InstantPageV2Layout.swift` substitutes `PixelDimensions(600, 300)` whenever `width <= 0 || height <= 0`, and feeds that `effectiveDimensions` to BOTH the layout `naturalSize` AND the `InstantPageMapAttribute`** — the latter is essential because a `MapSnapshotMediaResource(width:0,height:0)` makes `MKMapSnapshotter` render nothing, so fixing only the frame would yield a correctly-sized *blank* box. Real web-article maps (the V1 renderer) always carry real dimensions, so V1 never trips this; the fallback is deliberately scoped to the V2 `.map` arm rather than V1 or the wire/parse layer.
## InstantPage V2 audio/music
`InstantPageBlock.audio` renders in V2 as a control **styled exactly like the standard music message bubble** (`ChatMessageInteractiveFileNode`'s music layout) — a dedicated `InstantPageV2AudioContentNode`, NOT the V1 `InstantPageAudioNode` (which V2 used in the first iteration and which still backs V1's full-page Instant View). It replaces the earlier inert grey `.mediaPlaceholder(kind: .audio)`. Playback stays on `InstantPageMediaPlaylist`, with two deliberate behavior changes for the rich-message context: the shared playlist identity is **message-scoped** so concurrent rich-message audio bubbles don't collide, and rich-message audio files are fetched via a **message reference** (not the synthesized webpage) so a stale file reference can revalidate.
Specs: [`2026-06-02-instantpage-v2-audio-design.md`](docs/superpowers/specs/2026-06-02-instantpage-v2-audio-design.md) (initial port) + [`2026-06-02-instantpage-v2-audio-file-style-design.md`](docs/superpowers/specs/2026-06-02-instantpage-v2-audio-file-style-design.md) (file-bubble styling). Plans: [`2026-06-02-instantpage-v2-audio.md`](docs/superpowers/plans/2026-06-02-instantpage-v2-audio.md) + [`2026-06-02-instantpage-v2-audio-file-style.md`](docs/superpowers/plans/2026-06-02-instantpage-v2-audio-file-style.md).
### Where things live
| File | Responsibility |
|---|---|
| `submodules/InstantPageUI/Sources/InstantPageMediaPlaylist.swift` | `InstantPageMediaPlaylistId` is a **public enum**`.instantPage(webpageId:)` (V1 full-page IV) / `.richMessage(messageId:)` (V2 rich bubble). `InstantPageMediaPlaylist.init` takes an injected `playlistId:` (no longer derived from the webpage) and a `messageReference: MessageReference?` threaded into each `InstantPageMediaPlaylistItem`. The item's `fileReference(_:)` helper builds a `.message(message:media:)` file reference when a (resolvable-id) message reference is present, else the legacy `.webPage(...)`. |
| `submodules/InstantPageUI/Sources/InstantPageV2AudioContentNode.swift` | **The V2 control** — replicates `ChatMessageInteractiveFileNode`'s music layout: a Ø44 `SemanticStatusNode` (album art via `playerAlbumArt` + play/pause) + a small bottom-right `streamingStatusNode` download/progress overlay + title/performer `TextNode`s + a line `MediaPlayerScrubbingNode`. Big control play/pause from **our** `filteredPlaylistState`; small overlay download/progress from `messageMediaFileStatus`; tap via a `UITapGestureRecognizer` (`controlTapped` routes fetch / `play` / `togglePlayPause`); fetch via `messageMediaFileInteractiveFetched(fetchManager:…)`. |
| `submodules/InstantPageUI/Sources/InstantPageAudioNode.swift` | **V1 only** (full-page Instant View) — unchanged except `init` takes an injected `playlistId:`. No longer used by V2. |
| `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift` | `InstantPageV2MediaAudioItem` (frame/media/webPage — no cornerRadius/attributes); the `.mediaAudio` `InstantPageV2LaidOutItem` case + its `frame`/`offsetBy`/`collectMedias` arms; the `.audio` block's `layoutAudio` arm (full-width x = 0, height 44 — the file node's music `normHeight`; the `InstantPageMedia` carries `caption: nil`/`credit: nil`, the visible caption is a separate item via `layoutCaptionAndCredit`). |
| `submodules/InstantPageUI/Sources/InstantPageV2MediaViews.swift` | `InstantPageV2MediaAudioView` (hosts `InstantPageV2AudioContentNode` via the shared `WrapperRef` weak-box pattern; wires its `play`/`togglePlayPause`/`seek`/`fetch` closures + the `filteredPlaylistState` playback signal) + `handleOpenAudioTap` (builds the playlist + `setPlaylist`, mirroring V1's `InstantPageControllerNode.openMedia`). |
| `submodules/InstantPageUI/Sources/InstantPageRenderer.swift` | `InstantPageV2RenderContext.message: MessageReference?` (carries both the playlist-key id via `.id` AND the file-fetch reference); the `.mediaAudio` arms in `stableId`/`reuse`/`makeItemView`. |
| `submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift` | `.mediaAudio` is a non-text reveal entry charging `frame.width` (like other media). |
| `submodules/TelegramCore/Sources/Network/FetchedMediaResource.swift` | The `.message` media-reference revalidation arm also searches `RichTextMessageAttribute.instantPage.media` (not just `message.media`), so a stale instant-page file reference inside a rich message can recover. |
| rich bubble + send preview | `ChatMessageRichDataBubbleContentNode` passes `message: MessageReference(item.message)`; `ChatSendMessageRichTextPreview` passes `message: nil`. |
### Non-obvious invariants
- **The playlist key is message-scoped, NOT webpage-scoped, for rich bubbles.** Every rich message synthesizes its `TelegramMediaWebpage` with the SAME constant id `(namespace: 0, id: 0)` (`ChatMessageRichDataBubbleContentNode`), and `mediaIndex` restarts at 0 per page — so keying playback by `(webpageId, mediaIndex)` (V1's scheme) would make two audio bubbles on screen share/fight playback state (scrubber + play/pause icon). The discriminated `InstantPageMediaPlaylistId.richMessage(messageId)` isolates them. The audio view resolves `renderContext.message?.id``.richMessage(messageId)`, else `.instantPage(webpageId:)`; the send preview (no message) takes the webpage fallback — harmless since only one preview is ever on screen. The V1 full-page IV path is byte-identical (always `.instantPage(...)`).
- **`InstantPageMediaPlaylistId` had to become `public`.** It is exposed through `InstantPageMediaPlaylist`'s `public init`, which BrowserUI constructs cross-module; an internal type in a public initializer is a hard Swift compile error (independent of `-warnings-as-errors`). This surfaced only at full-build time — the per-module reasoning didn't catch it.
- **The big control's play/pause comes from OUR playlist, the small overlay's download/progress from the resource status — two separate signals.** The file node (`ChatMessageInteractiveFileNode`) for music keys its play/pause off the **peer-messages** playback model (`messageFileMediaPlaybackStatus``peerMessagesMediaPlaylistAndItemId`), which our attribute-embedded audio is NOT part of — so `InstantPageV2AudioContentNode` drives the big `statusNode` `.play``.pause` from **our** `filteredPlaylistState` (keyed by the message-scoped `playlistId` + `InstantPageMediaPlaylistItemId(index:)`) and the small `streamingStatusNode` from `messageMediaFileStatus`. This split (rather than reusing the file node) is why the redesign is a replicated layout, not a hosted `ChatMessageInteractiveFileNode`.
- **Fetch MUST go through the fetch manager, not `freeMediaFileInteractiveFetched`.** `messageMediaFileStatus`'s progress (`.Fetching`) is derived from the fetch manager's `hasEntry` flag; `freeMediaFileInteractiveFetched` bypasses the manager (`hasEntry` stays false), so the overlay would stick on the static download icon and never show the animated ring. The control fetches via `messageMediaFileInteractiveFetched(fetchManager:messageId:messageReference:file:…)`.
- **Tap is a `UITapGestureRecognizer`, never an ASControl** (same invariant as the V1 `InstantPageAudioNode` play button): ASControl `.touchUpInside` is cancelled by the chat `ListView`'s gesture system. The plain `tapView` covers the whole control → `controlTapped` (fetch-when-remote / `togglePlayPause`-when-playing / `play`-else).
- **`InstantPageV2AudioContentNode.updatePresentationData` must refresh EVERYTHING theme/incoming-dependent.** `TextNode` (unlike `ASTextNode`) has no stored `attributedText` — the strings live in `titleAttributedString`/`descriptionAttributedString` and are fed to `TextNode.asyncLayout`. On an in-place theme/direction change `updatePresentationData` rebuilds those strings AND `statusNode.backgroundNodeColor` + `foregroundNodeColor` + `overlayForegroundNodeColor` + `scrubbingNode.updateColors(…)`; missing any leaves a stale-colored control. Font size is `presentationData.chatFontSize.baseDisplaySize` (plain `PresentationData` has no `.fontSize`).
- **Audio is NOT a gallery item.** `InstantPageV2MediaAudioView` does not register in the root media registry (no `didMoveToWindow`/`registerInRootRegistry`) and returns `nil` from `instantPageTransitionNode` / no-ops `instantPageUpdateHiddenMedia` — explicit per-class witnesses, not the protocol-extension default. Its media IS enrolled in `collectMedias`/`allMedias()` so `handleOpenAudioTap` can gather the page's sibling voice/music files for the playlist (matching V1's `mediasFromItems`). The `WrapperRef` weak box breaks the wrapper → node → closure → wrapper retain cycle (the `play` closure captures only the box + value locals, never `self`).
- **Full-width item frame, file-node internal layout.** The `.audio` arm lays the item at `x = 0, width = boundingWidth, height = 44` (the file node's music `normHeight`), NOT inset by `horizontalInset`. The control's internal geometry is copied from the file node's non-thumbnail music branch (Ø44 control at x = 3, `controlAreaWidth = 55`, title at x = 55). Music-only: any voice file renders music-style (no waveform/transcription). No edge-bleed.
- **Audio files fetch via a message reference (the former recipient-fetch risk is resolved).** `InstantPageMediaPlaylistItem.fileReference(_:)` builds `.message(message: messageReference, media: file)` when the playlist carries a **resolvable-id** `MessageReference` (rich bubbles), else the legacy `.webPage(...)` (V1 full-page IV, whose webpage is real). The fetch-reference fallback uses the same `message?.id != nil` test as the playlist-key fallback, so a `.none`-content reference degrades to the webpage path consistently. Because the rich-message file lives in `RichTextMessageAttribute.instantPage.media` (not `message.media`), `FetchedMediaResource.swift`'s `.message` revalidation arm was taught to search the attribute's instant page too — so a **stale** file reference can re-fetch the message and recover (a synthetic-`(0,0)`-webpage reference never could, because that webpage doesn't exist server-side). This also fixes a latent pre-existing bug: instant-page **image** references in rich messages couldn't revalidate either.
- **Fixed a dormant inverted `InstantPagePlaylistLocation.isEqual`** (it returned `false` for equal locations and `true` for unequal — backwards). `areSharedMediaPlaylistsEqual` ANDs the playlist `id` and `location`; it gates only seek-forwarding inside `setPlaylist`, a path the instant-page audio scrubber doesn't take (it uses `playlistControl(.seek)`), so the bug was inert. The corrected equality is safe even though all rich-message locations share the synthetic `(0,0)` webpageId: the `.richMessage(messageId)` **id** (ANDed in) disambiguates different rich-message playlists.
## InstantPage V2 collage & slideshow blocks
`InstantPageBlock.collage` and `.slideshow` (grouped photos/videos with a caption — only ever produced by **real web Instant View articles**; nothing on the markdown/AI path emits them) render in V2 by porting V1. Collage flattens into the existing media-item machinery; slideshow is a dedicated interactive carousel.