diff --git a/docs/instantpage-richtext.md b/docs/instantpage-richtext.md index 31aceb8acc..75fbe45d99 100644 --- a/docs/instantpage-richtext.md +++ b/docs/instantpage-richtext.md @@ -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` ≈ 15–16pt) 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. diff --git a/submodules/BrowserUI/Sources/BrowserInstantPageContent.swift b/submodules/BrowserUI/Sources/BrowserInstantPageContent.swift index 9cdb96f6be..f075b42965 100644 --- a/submodules/BrowserUI/Sources/BrowserInstantPageContent.swift +++ b/submodules/BrowserUI/Sources/BrowserInstantPageContent.swift @@ -1590,7 +1590,7 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg } } } - self.context.sharedContext.mediaManager.setPlaylist((self.context, InstantPageMediaPlaylist(webPage: webPage, items: medias, initialItemIndex: initialIndex)), type: file.isVoice ? .voice : .music, control: .playback(.play)) + 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)) return } diff --git a/submodules/InstantPageUI/BUILD b/submodules/InstantPageUI/BUILD index 186ab72b1c..feb1d67ef6 100644 --- a/submodules/InstantPageUI/BUILD +++ b/submodules/InstantPageUI/BUILD @@ -25,6 +25,7 @@ swift_library( "//submodules/TelegramUI/Components/MultiAnimationRenderer:MultiAnimationRenderer", "//submodules/GalleryUI:GalleryUI", "//submodules/MusicAlbumArtResources:MusicAlbumArtResources", + "//submodules/SemanticStatusNode:SemanticStatusNode", "//submodules/LiveLocationPositionNode:LiveLocationPositionNode", "//submodules/MosaicLayout:MosaicLayout", "//submodules/LocationUI:LocationUI", diff --git a/submodules/InstantPageUI/Sources/InstantPageAudioItem.swift b/submodules/InstantPageUI/Sources/InstantPageAudioItem.swift index f007db8530..1085daf8e4 100644 --- a/submodules/InstantPageUI/Sources/InstantPageAudioItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPageAudioItem.swift @@ -24,7 +24,7 @@ public final class InstantPageAudioItem: InstantPageItem { } 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? { - return InstantPageAudioNode(context: context, strings: strings, theme: theme, webPage: self.webpage, media: self.media, openMedia: openMedia) + return InstantPageAudioNode(context: context, strings: strings, theme: theme, webPage: self.webpage, media: self.media, playlistId: .instantPage(webpageId: self.webpage.webpageId), openMedia: openMedia) } public func matchesAnchor(_ anchor: String) -> Bool { diff --git a/submodules/InstantPageUI/Sources/InstantPageAudioNode.swift b/submodules/InstantPageUI/Sources/InstantPageAudioNode.swift index 4da50a07c0..573281233d 100644 --- a/submodules/InstantPageUI/Sources/InstantPageAudioNode.swift +++ b/submodules/InstantPageUI/Sources/InstantPageAudioNode.swift @@ -66,7 +66,7 @@ final class InstantPageAudioNode: ASDisplayNode, InstantPageNode { private var playImage: UIImage private var pauseImage: UIImage - private let buttonNode: HighlightableButtonNode + private let buttonView: UIView private let statusNode: RadialStatusNode private let titleNode: ASTextNode private let scrubbingNode: MediaPlayerScrubbingNode @@ -76,7 +76,7 @@ final class InstantPageAudioNode: ASDisplayNode, InstantPageNode { private var isPlaying: Bool = false private var playbackState: SharedMediaPlayerItemPlaybackState? - init(context: AccountContext, strings: PresentationStrings, theme: InstantPageTheme, webPage: TelegramMediaWebpage, media: InstantPageMedia, openMedia: @escaping (InstantPageMedia) -> Void) { + init(context: AccountContext, strings: PresentationStrings, theme: InstantPageTheme, webPage: TelegramMediaWebpage, media: InstantPageMedia, playlistId: InstantPageMediaPlaylistId, openMedia: @escaping (InstantPageMedia) -> Void) { self.context = context self.strings = strings self.theme = theme @@ -86,7 +86,7 @@ final class InstantPageAudioNode: ASDisplayNode, InstantPageNode { self.playImage = generatePlayButton(color: theme.textCategories.paragraph.color)! self.pauseImage = generatePauseButton(color: theme.textCategories.paragraph.color)! - self.buttonNode = HighlightableButtonNode() + self.buttonView = UIView() self.statusNode = RadialStatusNode(backgroundNodeColor: .clear) self.titleNode = ASTextNode() self.titleNode.maximumNumberOfLines = 1 @@ -112,25 +112,11 @@ final class InstantPageAudioNode: ASDisplayNode, InstantPageNode { self.titleNode.attributedText = titleString(media: media, theme: theme, strings: strings) self.addSubnode(self.statusNode) - self.addSubnode(self.buttonNode) self.addSubnode(self.titleNode) self.addSubnode(self.scrubbingNode) - + self.statusNode.transitionToState(RadialStatusNodeState.customIcon(self.playImage), animated: false, completion: {}) - self.buttonNode.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: .touchUpInside) - self.buttonNode.highligthedChanged = { [weak self] highlighted in - if let strongSelf = self { - if highlighted { - strongSelf.statusNode.layer.removeAnimation(forKey: "opacity") - strongSelf.statusNode.alpha = 0.4 - } else { - strongSelf.statusNode.alpha = 1.0 - strongSelf.statusNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) - } - } - } - self.scrubbingNode.seek = { [weak self] timestamp in if let strongSelf = self { if let _ = strongSelf.playbackState { @@ -178,12 +164,12 @@ final class InstantPageAudioNode: ASDisplayNode, InstantPageNode { } })*/ - 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) + self.scrubbingNode.status = context.sharedContext.mediaManager.filteredPlaylistState(accountId: context.account.id, playlistId: playlistId, itemId: InstantPageMediaPlaylistItemId(index: self.media.index), type: self.playlistType) |> map { playbackState -> MediaPlayerStatus in return playbackState?.status ?? MediaPlayerStatus(generationTimestamp: 0.0, duration: 0.0, dimensions: CGSize(), timestamp: 0.0, baseRate: 1.0, seekId: 0, status: .paused, soundEnabled: true) } - self.playerStatusDisposable = (context.sharedContext.mediaManager.filteredPlaylistState(accountId: context.account.id, playlistId: InstantPageMediaPlaylistId(webpageId: webPage.webpageId), itemId: InstantPageMediaPlaylistItemId(index: self.media.index), type: playlistType) + self.playerStatusDisposable = (context.sharedContext.mediaManager.filteredPlaylistState(accountId: context.account.id, playlistId: playlistId, itemId: InstantPageMediaPlaylistItemId(index: self.media.index), type: playlistType) |> deliverOnMainQueue).start(next: { [weak self] playbackState in guard let strongSelf = self else { return @@ -213,7 +199,21 @@ final class InstantPageAudioNode: ASDisplayNode, InstantPageNode { deinit { self.playerStatusDisposable?.dispose() } - + + override func didLoad() { + super.didLoad() + // The play/pause tap target is a plain view + UITapGestureRecognizer, NOT an ASControl + // button. An ASControl's `.touchUpInside` is cancelled by the chat ListView's gesture + // system (the control highlights on touch-down, but the action never fires), so an + // embedded audio control in a rich-message bubble could never start playback. A gesture + // recognizer coordinates with the list's gestures and fires reliably — matching the V2 + // image node, the details-title hit view, and the regular file/music message. The plain + // view sits above `statusNode` and is positioned over the icon in `layout()`. (Works in + // V1's full-page Instant View too; gesture recognizers fire inside its scroll view.) + self.view.addSubview(self.buttonView) + self.buttonView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.buttonPressed))) + } + func update(strings: PresentationStrings, theme: InstantPageTheme) { if self.strings !== strings || self.theme !== theme { let themeUpdated = self.theme !== theme @@ -268,7 +268,7 @@ final class InstantPageAudioNode: ASDisplayNode, InstantPageNode { let titleSize = self.titleNode.measure(CGSize(width: maxTitleWidth, height: size.height)) self.titleNode.frame = CGRect(origin: CGPoint(x: insets.left + leftInset, y: 2.0), size: titleSize) - self.buttonNode.frame = CGRect(origin: CGPoint(x: insets.left, y: 0.0), size: CGSize(width: 48.0, height: 48.0)) + self.buttonView.frame = CGRect(origin: CGPoint(x: insets.left, y: 0.0), size: CGSize(width: 48.0, height: 48.0)) self.statusNode.frame = CGRect(origin: CGPoint(x: insets.left, y: 0.0), size: CGSize(width: 48.0, height: 48.0)) var topOffset: CGFloat = 0.0 diff --git a/submodules/InstantPageUI/Sources/InstantPageControllerNode.swift b/submodules/InstantPageUI/Sources/InstantPageControllerNode.swift index c003a10967..9236b3aec3 100644 --- a/submodules/InstantPageUI/Sources/InstantPageControllerNode.swift +++ b/submodules/InstantPageUI/Sources/InstantPageControllerNode.swift @@ -1839,7 +1839,7 @@ final class InstantPageControllerNode: ASDisplayNode, ASScrollViewDelegate { } } } - self.context.sharedContext.mediaManager.setPlaylist((self.context, InstantPageMediaPlaylist(webPage: webPage, items: medias, initialItemIndex: initialIndex)), type: file.isVoice ? .voice : .music, control: .playback(.play)) + 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)) return } diff --git a/submodules/InstantPageUI/Sources/InstantPageMediaPlaylist.swift b/submodules/InstantPageUI/Sources/InstantPageMediaPlaylist.swift index 7d939eeb13..c7dc21c521 100644 --- a/submodules/InstantPageUI/Sources/InstantPageMediaPlaylist.swift +++ b/submodules/InstantPageUI/Sources/InstantPageMediaPlaylist.swift @@ -30,15 +30,28 @@ private func extractFileMedia(_ item: InstantPageMedia) -> TelegramMediaFile? { final class InstantPageMediaPlaylistItem: SharedMediaPlaylistItem { let webPage: TelegramMediaWebpage + let messageReference: MessageReference? let id: SharedMediaPlaylistItemId let item: InstantPageMedia - - init(webPage: TelegramMediaWebpage, 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 } @@ -49,13 +62,13 @@ final class InstantPageMediaPlaylistItem: SharedMediaPlaylistItem { switch attribute { case let .Audio(isVoice, _, _, _, _): if isVoice { - return SharedMediaPlaybackData(type: .voice, source: .telegramFile(reference: .webPage(webPage: WebpageReference(self.webPage), media: file), isCopyProtected: false, isViewOnce: false)) + return SharedMediaPlaybackData(type: .voice, source: .telegramFile(reference: self.fileReference(file), isCopyProtected: false, isViewOnce: false)) } else { - return SharedMediaPlaybackData(type: .music, source: .telegramFile(reference: .webPage(webPage: WebpageReference(self.webPage), media: file), isCopyProtected: false, isViewOnce: false)) + 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: .webPage(webPage: WebpageReference(self.webPage), media: file), isCopyProtected: false, isViewOnce: false)) + return SharedMediaPlaybackData(type: .instantVideo, source: .telegramFile(reference: self.fileReference(file), isCopyProtected: false, isViewOnce: false)) } else { return nil } @@ -64,12 +77,12 @@ final class InstantPageMediaPlaylistItem: SharedMediaPlaylistItem { } } if file.mimeType.hasPrefix("audio/") { - return SharedMediaPlaybackData(type: .music, source: .telegramFile(reference: .webPage(webPage: WebpageReference(self.webPage), media: file), isCopyProtected: false, isViewOnce: false)) + 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: .webPage(webPage: WebpageReference(self.webPage), media: file), isCopyProtected: false, isViewOnce: false)) + return SharedMediaPlaybackData(type: .music, source: .telegramFile(reference: self.fileReference(file), isCopyProtected: false, isViewOnce: false)) } } } @@ -116,14 +129,15 @@ final class InstantPageMediaPlaylistItem: SharedMediaPlaylistItem { } } -struct InstantPageMediaPlaylistId: SharedMediaPlaylistId { - let webpageId: EngineMedia.Id - - func isEqual(to: SharedMediaPlaylistId) -> Bool { - if let to = to as? InstantPageMediaPlaylistId { - return self.webpageId == to.webpageId +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 false + return self == to } } @@ -134,15 +148,13 @@ struct InstantPagePlaylistLocation: Equatable, SharedMediaPlaylistLocation { guard let to = to as? InstantPagePlaylistLocation else { return false } - if self.webpageId == to.webpageId { - return false - } - return true + 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 @@ -164,15 +176,16 @@ public final class InstantPageMediaPlaylist: SharedMediaPlaylist { return self.stateValue.get() } - public init(webPage: TelegramMediaWebpage, items: [InstantPageMedia], initialItemIndex: Int) { + public init(playlistId: InstantPageMediaPlaylistId, webPage: TelegramMediaWebpage, messageReference: MessageReference?, items: [InstantPageMedia], initialItemIndex: Int) { assert(Queue.mainQueue().isCurrent()) - - self.id = InstantPageMediaPlaylistId(webpageId: webPage.webpageId) - + + self.id = playlistId + self.webPage = webPage + self.messageReference = messageReference self.items = items self.initialItemIndex = initialItemIndex - + self.control(.next) } @@ -243,7 +256,7 @@ public final class InstantPageMediaPlaylist: SharedMediaPlaylist { } private func updateState() { - 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))) + 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) { diff --git a/submodules/InstantPageUI/Sources/InstantPageRenderer.swift b/submodules/InstantPageUI/Sources/InstantPageRenderer.swift index 1093b02258..c389e0d3cf 100644 --- a/submodules/InstantPageUI/Sources/InstantPageRenderer.swift +++ b/submodules/InstantPageUI/Sources/InstantPageRenderer.swift @@ -59,6 +59,11 @@ public final class InstantPageV2RenderContext { public let push: (ViewController) -> Void public let openUrl: (InstantPageUrlItem) -> Void public let baseNavigationController: () -> NavigationController? + /// A reference to the message hosting this page, when rendered inside a chat bubble. Used to + /// key audio playback per message (`.richMessage(message.id)`) AND to fetch audio files via a + /// message reference (so a stale file reference can revalidate); `nil` in the send preview, + /// which falls back to the webpage-keyed playlist id + webpage file reference. + public let message: MessageReference? public init( context: AccountContext, @@ -69,7 +74,8 @@ public final class InstantPageV2RenderContext { present: @escaping (ViewController, Any?) -> Void, push: @escaping (ViewController) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, - baseNavigationController: @escaping () -> NavigationController? + baseNavigationController: @escaping () -> NavigationController?, + message: MessageReference? ) { self.context = context self.webpage = webpage @@ -80,6 +86,7 @@ public final class InstantPageV2RenderContext { self.push = push self.openUrl = openUrl self.baseNavigationController = baseNavigationController + self.message = message } /// Update the content-bearing fields for a later chunk of the SAME message. Enables the @@ -676,6 +683,10 @@ public final class InstantPageV2View: UIView { guard let v = existingView as? InstantPageV2MediaCoverImageView, let rc = self.renderContext else { return nil } v.update(item: media, theme: theme, renderContext: rc) return v + case let .mediaAudio(media): + guard let v = existingView as? InstantPageV2MediaAudioView, let rc = self.renderContext else { return nil } + v.update(item: media, theme: theme, renderContext: rc) + return v case let .thinking(thinking): guard let v = existingView as? InstantPageV2ThinkingView else { return nil } v.update(item: thinking, theme: theme) @@ -693,6 +704,7 @@ public final class InstantPageV2View: UIView { case let .mediaVideo(m): return .media(m.media.index) case let .mediaMap(m): return .media(m.media.index) case let .mediaCoverImage(m): return .media(m.media.index) + case let .mediaAudio(m): return .media(m.media.index) case let .details(d): return .details(d.index) case .text: return .positional(.text, position) case .codeBlock: return .positional(.codeBlock, position) @@ -794,6 +806,12 @@ public final class InstantPageV2View: UIView { } else { return InstantPageV2MediaPlaceholderView(item: placeholderFallback(for: media), theme: theme) } + case let .mediaAudio(media): + if let renderContext = self.renderContext { + return InstantPageV2MediaAudioView(item: media, renderContext: renderContext, theme: theme) + } else { + return InstantPageV2MediaPlaceholderView(item: InstantPageV2MediaPlaceholderItem(frame: media.frame, kind: .audio, cornerRadius: 0.0), theme: theme) + } case let .formula(formula): return InstantPageV2FormulaView(item: formula, theme: theme) case let .thinking(thinking): diff --git a/submodules/InstantPageUI/Sources/InstantPageV2AudioContentNode.swift b/submodules/InstantPageUI/Sources/InstantPageV2AudioContentNode.swift new file mode 100644 index 0000000000..a3b0dd0db5 --- /dev/null +++ b/submodules/InstantPageUI/Sources/InstantPageV2AudioContentNode.swift @@ -0,0 +1,278 @@ +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) { + 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: " · " 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)) + } +} diff --git a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift index 62b8675dc7..ee2a886d50 100644 --- a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift +++ b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift @@ -50,6 +50,7 @@ public struct InstantPageV2Layout { case let .mediaVideo(m): result.append(m.media) case let .mediaMap(m): result.append(m.media) case let .mediaCoverImage(m): result.append(m.media) + case let .mediaAudio(m): result.append(m.media) case let .slideshow(s): result.append(contentsOf: s.medias) case let .details(d): if let inner = d.innerLayout { @@ -86,6 +87,7 @@ public enum InstantPageV2LaidOutItem { case mediaVideo(InstantPageV2MediaVideoItem) case mediaMap(InstantPageV2MediaMapItem) case mediaCoverImage(InstantPageV2MediaCoverImageItem) + case mediaAudio(InstantPageV2MediaAudioItem) case formula(InstantPageV2FormulaItem) case thinking(InstantPageV2ThinkingItem) case slideshow(InstantPageV2SlideshowItem) @@ -106,6 +108,7 @@ public enum InstantPageV2LaidOutItem { case let .mediaVideo(item): return item.frame case let .mediaMap(item): return item.frame case let .mediaCoverImage(item): return item.frame + case let .mediaAudio(item): return item.frame case let .formula(item): return item.frame case let .thinking(item): return item.frame case let .slideshow(item): return item.frame @@ -131,6 +134,7 @@ public enum InstantPageV2LaidOutItem { case var .mediaVideo(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .mediaVideo(item) case var .mediaMap(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .mediaMap(item) case var .mediaCoverImage(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .mediaCoverImage(item) + case var .mediaAudio(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .mediaAudio(item) case var .formula(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .formula(item) case var .thinking(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .thinking(item) case var .slideshow(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .slideshow(item) @@ -241,6 +245,18 @@ public struct InstantPageV2MediaImageItem { } } +public struct InstantPageV2MediaAudioItem { + public var frame: CGRect + public let media: InstantPageMedia + public let webPage: TelegramMediaWebpage + + public init(frame: CGRect, media: InstantPageMedia, webPage: TelegramMediaWebpage) { + self.frame = frame + self.media = media + self.webPage = webPage + } +} + public struct InstantPageV2MediaVideoItem { public var frame: CGRect public let cornerRadius: CGFloat @@ -754,11 +770,34 @@ private func layoutBlock( return [] } - case let .audio(_, caption): - return layoutMediaWithCaption(kind: .audio, - naturalSize: CGSize(width: boundingWidth, height: 56.0), caption: caption, - isCover: false, cornerRadius: 8.0, flush: false, boundingWidth: boundingWidth, - horizontalInset: horizontalInset, context: &context) + case let .audio(audioId, caption): + guard case let .file(file) = context.media[audioId] else { + return [] + } + let mediaIndex = context.mediaIndexCounter + context.mediaIndexCounter += 1 + let instantPageMedia = InstantPageMedia( + index: mediaIndex, + media: .file(file), + url: nil, + caption: nil, + credit: nil + ) + let audioFrame = CGRect(x: 0.0, y: 0.0, width: boundingWidth, height: 44.0) + var result: [InstantPageV2LaidOutItem] = [.mediaAudio(InstantPageV2MediaAudioItem( + frame: audioFrame, + media: instantPageMedia, + webPage: context.webpage + ))] + let (captionItems, _) = layoutCaptionAndCredit( + caption, + offset: audioFrame.height, + boundingWidth: boundingWidth, + horizontalInset: horizontalInset, + context: &context + ) + result.append(contentsOf: captionItems) + return result case let .webEmbed(url, _, dimensions, caption, _, _, coverId): // V1 (InstantPageLayout.swift:848): if the embed has a URL and a resolvable cover image, @@ -1683,7 +1722,7 @@ private let instantPageV2MediaEdgeBleed: CGFloat = 4.0 // Computes the laid-out frame for a block-media item. // -// `flush == true` (every block media except audio): the media is edge-to-edge (x = 0, full +// `flush == true` (every current caller): the media is edge-to-edge (x = 0, full // `boundingWidth`) with corner radius forced to 0, relying on the bubble's rounded clipping // container to round media that meets the bubble's top/bottom edge. A media item that fills the // full width is widened by `instantPageV2MediaEdgeBleed` on the trailing edge (see the constant). @@ -1692,8 +1731,10 @@ private let instantPageV2MediaEdgeBleed: CGFloat = 4.0 // (The `cornerRadius` argument is ignored when `flush == true` — flush media is always // un-rounded; callers may still pass their legacy radius, it has no effect.) // -// `flush == false` (audio only): legacy behavior — inset by `horizontalInset` on each side with -// the caller-supplied corner radius. +// `flush == false`: DEAD as of the V2 audio port — audio was its last caller and now has its +// own `layoutAudio` arm (in `layoutBlock`), so this branch is currently unreachable (follow-up: +// drop the `flush` parameter and this branch). Legacy behavior was: inset by `horizontalInset` +// on each side with the caller-supplied corner radius. // // Returns the frame, the un-bled scaled content size (the caption is offset by // `scaledSize.height`), and the effective corner radius to stamp on the item. diff --git a/submodules/InstantPageUI/Sources/InstantPageV2MediaViews.swift b/submodules/InstantPageUI/Sources/InstantPageV2MediaViews.swift index 314f78fb7b..89ec1c04f3 100644 --- a/submodules/InstantPageUI/Sources/InstantPageV2MediaViews.swift +++ b/submodules/InstantPageUI/Sources/InstantPageV2MediaViews.swift @@ -7,6 +7,7 @@ import AccountContext import TelegramCore import TelegramPresentationData import GalleryUI +import UniversalMediaPlayer // Mutable weak box: lets a wrapper hand its `openMedia` closure a back-reference to itself, // filled in after `super.init` (when `self` becomes usable). SwiftSignalKit's `Weak` requires @@ -362,3 +363,133 @@ final class InstantPageV2MediaCoverImageView: UIView, InstantPageItemView { self.wrappedNode.updateHiddenMedia(media: media) } } + +// Sets up shared-media playback for an audio tap. Mirrors V1's +// `InstantPageControllerNode.openMedia(_:)` audio branch: collect the page's voice/music +// medias from the root V2View's current layout, build an `InstantPageMediaPlaylist` keyed by +// `playlistId`, and start playback. No-op if the wrapper isn't currently in a V2View tree. +func handleOpenAudioTap( + tapped: InstantPageMedia, + wrapper: UIView, + renderContext: InstantPageV2RenderContext, + playlistId: InstantPageMediaPlaylistId +) { + guard case let .file(tappedFile) = tapped.media, tappedFile.isVoice || tappedFile.isMusic else { return } + guard let v2 = findEnclosingV2View(from: wrapper.superview) else { return } + let root = v2.trueRegistryRoot + guard let layout = root.currentLayout else { return } + + var audioMedias: [InstantPageMedia] = [] + var initialIndex = 0 + for media in layout.allMedias() { + if case let .file(file) = media.media, (file.isVoice || file.isMusic) { + if media.index == tapped.index { + initialIndex = audioMedias.count + } + audioMedias.append(media) + } + } + + let playlist = InstantPageMediaPlaylist( + playlistId: playlistId, + webPage: renderContext.webpage, + messageReference: renderContext.message, + items: audioMedias, + initialItemIndex: initialIndex + ) + renderContext.context.sharedContext.mediaManager.setPlaylist( + (renderContext.context, playlist), + type: tappedFile.isVoice ? .voice : .music, + control: .playback(.play) + ) +} + +final class InstantPageV2MediaAudioView: UIView, InstantPageItemView { + private(set) var item: InstantPageV2MediaAudioItem + var itemFrame: CGRect { return self.item.frame } + private let audioNode: InstantPageV2AudioContentNode + + init(item: InstantPageV2MediaAudioItem, renderContext: InstantPageV2RenderContext, theme: InstantPageTheme) { + self.item = item + + // `.richMessage(messageId)` isolates playback state per chat message; the preview (no + // message) falls back to the webpage-keyed id (only one preview is ever on screen). + let playlistId: InstantPageMediaPlaylistId + if let messageId = renderContext.message?.id { + playlistId = .richMessage(messageId: messageId) + } else { + playlistId = .instantPage(webpageId: renderContext.webpage.webpageId) + } + + let wrapperRef = WrapperRef() + let renderContextRef = renderContext + let itemMedia = item.media + + let presentationData = renderContext.context.sharedContext.currentPresentationData.with { $0 } + let incoming = renderContext.message?.isIncoming == true + let audioFile: TelegramMediaFile + if case let .file(f) = item.media.media { audioFile = f } else { audioFile = TelegramMediaFile(fileId: EngineMedia.Id(namespace: Namespaces.Media.LocalFile, id: 0), partialReference: nil, resource: EmptyMediaResource(), previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "audio/mpeg", size: nil, attributes: [], alternativeRepresentations: []) } + self.audioNode = InstantPageV2AudioContentNode(context: renderContext.context, message: renderContext.message, file: audioFile, incoming: incoming, presentationData: presentationData) + + super.init(frame: item.frame) + self.backgroundColor = .clear // structural + self.addSubview(self.audioNode.view) // structural + wrapperRef.view = self // structural: back-reference for the play closure + + self.audioNode.play = { + guard let wrapper = wrapperRef.view else { return } + handleOpenAudioTap(tapped: itemMedia, wrapper: wrapper, renderContext: renderContextRef, playlistId: playlistId) + } + + let fetchContext = renderContext.context + let fetchMessage = renderContext.message + let fetchMedia = item.media + self.audioNode.fetch = { + guard case let .file(file) = fetchMedia.media, let message = fetchMessage, let messageId = message.id else { return } + // Route through the fetch manager (not freeMediaFileInteractiveFetched) so the + // messageMediaFileStatus signal — which keys progress off the fetch manager's + // `hasEntry` — surfaces .Fetching, letting the overlay show the animated ring. + let _ = messageMediaFileInteractiveFetched(fetchManager: fetchContext.fetchManager, messageId: messageId, messageReference: message, file: file, userInitiated: true, priority: .userInitiated).startStandalone() + } + + let mediaForPlayback = item.media + let playlistTypeForPlayback: MediaManagerPlayerType + if case let .file(f) = mediaForPlayback.media, f.isVoice { playlistTypeForPlayback = .voice } else { playlistTypeForPlayback = .music } + let contextForPlayback = renderContext.context + + self.audioNode.togglePlayPause = { + contextForPlayback.sharedContext.mediaManager.playlistControl(.playback(.togglePlayPause), type: playlistTypeForPlayback) + } + + let stateSignal = contextForPlayback.sharedContext.mediaManager.filteredPlaylistState(accountId: contextForPlayback.account.id, playlistId: playlistId, itemId: InstantPageMediaPlaylistItemId(index: mediaForPlayback.index), type: playlistTypeForPlayback) + self.audioNode.setPlaybackStatusSignal(stateSignal) + + self.update(item: item, theme: theme, renderContext: renderContext) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func layoutSubviews() { + super.layoutSubviews() + self.audioNode.frame = self.bounds + self.audioNode.updateLayout(width: self.bounds.width) + } + + func update(item: InstantPageV2MediaAudioItem, theme: InstantPageTheme, renderContext: InstantPageV2RenderContext) { + self.item = item + let presentationData = renderContext.context.sharedContext.currentPresentationData.with { $0 } + let incoming = renderContext.message?.isIncoming == true + self.audioNode.updatePresentationData(presentationData, incoming: incoming) + self.audioNode.updateLayout(width: self.bounds.width) + } + + // Audio is not a gallery item: explicit nil/no-op witnesses (per the existing pattern of + // explicit per-class witnesses rather than a shared protocol-extension override). + func instantPageTransitionNode(for media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? { + return nil + } + + func instantPageUpdateHiddenMedia(_ media: InstantPageMedia?) { + } +} diff --git a/submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift b/submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift index 82adf21c2b..caae5152be 100644 --- a/submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift +++ b/submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift @@ -334,7 +334,7 @@ private func computeEntries(items: [InstantPageV2LaidOutItem], cursor: inout Int // positions are identical whether or not thinking blocks are present, so adding/ // removing a thinking block never jumps the answer's reveal position. entries.append(.thinking(start: cursor)) - case .formula, .mediaImage, .mediaVideo, .mediaMap, .mediaCoverImage, .mediaPlaceholder, .slideshow, + case .formula, .mediaImage, .mediaVideo, .mediaMap, .mediaCoverImage, .mediaAudio, .mediaPlaceholder, .slideshow, .divider, .listMarker, .blockQuoteBar, .shape, .anchor: let start = cursor cursor += itemWidthCost(item) diff --git a/submodules/TelegramCore/Sources/Network/FetchedMediaResource.swift b/submodules/TelegramCore/Sources/Network/FetchedMediaResource.swift index 494ff54652..ee197a83d8 100644 --- a/submodules/TelegramCore/Sources/Network/FetchedMediaResource.swift +++ b/submodules/TelegramCore/Sources/Network/FetchedMediaResource.swift @@ -901,6 +901,18 @@ func revalidateMediaResourceReference(accountPeerId: PeerId, postbox: Postbox, n return .single(RevalidatedMediaResource(updatedResource: updatedResource, updatedReference: nil)) } } + // Rich-text messages (`RichTextMessageAttribute`) embed their media in the + // attribute's `InstantPage`, not in `message.media` — search there too so a + // stale instant-page audio/image file reference can revalidate. + for attribute in message.attributes { + if let attribute = attribute as? RichTextMessageAttribute { + for (_, pageMedia) in attribute.instantPage.media { + if let updatedResource = findUpdatedMediaResource(media: pageMedia, previousMedia: previousMedia, resource: resource) { + return .single(RevalidatedMediaResource(updatedResource: updatedResource, updatedReference: nil)) + } + } + } + } return .fail(.generic) } case let .stickerPack(stickerPack, media): diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift index 523fb809c6..38c668570c 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift @@ -139,7 +139,8 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode }, baseNavigationController: { [weak self] in self?.item?.controllerInteraction.navigationController() - } + }, + message: messageReference ) let view = InstantPageV2View(renderContext: renderContext) self.pageView = view diff --git a/submodules/TelegramUI/Sources/Chat/ChatSendMessageRichTextPreview.swift b/submodules/TelegramUI/Sources/Chat/ChatSendMessageRichTextPreview.swift index 01fe36a383..4a12867f5a 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatSendMessageRichTextPreview.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatSendMessageRichTextPreview.swift @@ -63,7 +63,8 @@ final class ChatSendMessageRichTextPreview: ChatSendMessageContextScreenRichText present: { _, _ in }, push: { _ in }, openUrl: { _ in }, - baseNavigationController: { return nil } + baseNavigationController: { return nil }, + message: nil ) self.pageView = InstantPageV2View(renderContext: renderContext) self.pageView.isUserInteractionEnabled = false