mirror of
https://github.com/TelegramMessenger/Telegram-iOS.git
synced 2026-07-05 19:28:46 +02:00
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:
parent
899030184a
commit
f6da30da70
15 changed files with 595 additions and 66 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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<SharedMediaPlayerItemPlaybackState?, NoError>) {
|
||||
self.playbackStatusDisposable?.dispose() // defensive: a re-call must not leak the prior subscription
|
||||
self.playbackStatusDisposable = (signal |> deliverOnMainQueue).startStrict(next: { [weak self] state in
|
||||
guard let self else { return }
|
||||
let isPlaying: Bool
|
||||
if let status = state?.status {
|
||||
if case .playing = status.status {
|
||||
isPlaying = true
|
||||
} else {
|
||||
isPlaying = false
|
||||
}
|
||||
} else {
|
||||
isPlaying = false
|
||||
}
|
||||
self.isPlaying = isPlaying
|
||||
self.statusNode.transitionToState(isPlaying ? .pause : .play)
|
||||
})
|
||||
}
|
||||
|
||||
// Refreshes title/description attributed strings and the statusNode tint/foreground/overlay
|
||||
// colors when the theme or incoming direction changes. Called from the host view's
|
||||
// update(item:theme:renderContext:).
|
||||
func updatePresentationData(_ presentationData: PresentationData, incoming: Bool) {
|
||||
if self.presentationData.theme === presentationData.theme && self.incomingValue == incoming { return }
|
||||
self.presentationData = presentationData
|
||||
self.incomingValue = incoming
|
||||
self.titleAttributedString = InstantPageV2AudioContentNode.titleString(file: self.file, incoming: incoming, presentationData: presentationData)
|
||||
self.descriptionAttributedString = InstantPageV2AudioContentNode.descriptionString(file: self.file, incoming: incoming, presentationData: presentationData)
|
||||
let messageTheme = incoming ? presentationData.theme.chat.message.incoming : presentationData.theme.chat.message.outgoing
|
||||
self.statusNode.backgroundNodeColor = messageTheme.mediaActiveControlColor
|
||||
// foreground/overlay also depend on incoming + theme (set at construction) — refresh them
|
||||
// too so the play glyph isn't miscolored after an in-place theme/direction change.
|
||||
self.statusNode.foregroundNodeColor = (incoming && messageTheme.mediaActiveControlColor.rgb != 0xffffff) ? .white : .clear
|
||||
self.statusNode.overlayForegroundNodeColor = presentationData.theme.chat.message.mediaOverlayControlColors.foregroundColor
|
||||
|
||||
// No setNeedsLayout(): this node doesn't override layout(); the host calls updateLayout(width:)
|
||||
// right after updatePresentationData, which re-runs the text layout with the rebuilt strings.
|
||||
}
|
||||
|
||||
private func updateStreamingState() {
|
||||
let state: SemanticStatusNodeState
|
||||
switch self.fetchStatus {
|
||||
case .none, .Local:
|
||||
state = .none
|
||||
case let .Fetching(_, progress):
|
||||
state = .progress(value: CGFloat(max(progress, 0.027)), cancelEnabled: true, appearance: SemanticStatusNodeState.ProgressAppearance(inset: 1.0, lineWidth: 2.0), animateRotation: true)
|
||||
case .Remote, .Paused:
|
||||
state = .download
|
||||
}
|
||||
self.streamingStatusNode.transitionToState(state)
|
||||
}
|
||||
|
||||
// Line 1: track title at 17pt (= baseDisplaySize at the default font setting; scales with it).
|
||||
private static func titleString(file: TelegramMediaFile, incoming: Bool, presentationData: PresentationData) -> NSAttributedString {
|
||||
let messageTheme = incoming ? presentationData.theme.chat.message.incoming : presentationData.theme.chat.message.outgoing
|
||||
let titleFont = Font.regular(floor(presentationData.chatFontSize.baseDisplaySize * 17.0 / 17.0))
|
||||
var title = file.fileName ?? "Unknown Track"
|
||||
for attribute in file.attributes {
|
||||
if case let .Audio(false, _, t, _, _) = attribute { title = t ?? title; break }
|
||||
}
|
||||
return NSAttributedString(string: title, font: titleFont, textColor: messageTheme.fileTitleColor)
|
||||
}
|
||||
|
||||
// Line 2: "<duration> · <performer>" at 15pt (omits the "· performer" tail when there's no
|
||||
// performer; omits the duration when it's absent).
|
||||
private static func descriptionString(file: TelegramMediaFile, incoming: Bool, presentationData: PresentationData) -> NSAttributedString {
|
||||
let messageTheme = incoming ? presentationData.theme.chat.message.incoming : presentationData.theme.chat.message.outgoing
|
||||
let descriptionFont = Font.with(size: floor(presentationData.chatFontSize.baseDisplaySize * 15.0 / 17.0), design: .regular, weight: .regular, traits: [.monospacedNumbers])
|
||||
var performer = ""
|
||||
var durationSeconds: Int = 0
|
||||
for attribute in file.attributes {
|
||||
if case let .Audio(false, duration, _, p, _) = attribute {
|
||||
performer = (p ?? "").trimmingCharacters(in: .whitespaces)
|
||||
durationSeconds = duration
|
||||
break
|
||||
}
|
||||
}
|
||||
var text = ""
|
||||
if durationSeconds > 0 {
|
||||
text = String(format: "%d:%02d", Int32(durationSeconds / 60), Int32(durationSeconds % 60))
|
||||
}
|
||||
if !performer.isEmpty {
|
||||
text += text.isEmpty ? performer : " · \(performer)"
|
||||
}
|
||||
return NSAttributedString(string: text, font: descriptionFont, textColor: messageTheme.fileDescriptionColor)
|
||||
}
|
||||
|
||||
func updateLayout(width: CGFloat) {
|
||||
let progressFrame = CGRect(origin: InstantPageV2AudioContentNode.progressOrigin, size: CGSize(width: InstantPageV2AudioContentNode.progressDiameter, height: InstantPageV2AudioContentNode.progressDiameter))
|
||||
self.statusNode.frame = progressFrame
|
||||
let streamingDiameter: CGFloat = 24.0
|
||||
self.streamingStatusNode.frame = CGRect(origin: CGPoint(x: progressFrame.maxX - streamingDiameter + 2.0, y: progressFrame.maxY - streamingDiameter + 2.0), size: CGSize(width: streamingDiameter, height: streamingDiameter))
|
||||
|
||||
let controlAreaWidth = InstantPageV2AudioContentNode.controlAreaWidth
|
||||
let textWidth = max(1.0, width - controlAreaWidth - 8.0)
|
||||
let (titleLayout, titleApply) = TextNode.asyncLayout(self.titleNode)(TextNodeLayoutArguments(attributedString: self.titleAttributedString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .middle, constrainedSize: CGSize(width: textWidth, height: 100.0), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
|
||||
let (descLayout, descApply) = TextNode.asyncLayout(self.descriptionNode)(TextNodeLayoutArguments(attributedString: self.descriptionAttributedString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: textWidth, height: 100.0), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
|
||||
let _ = titleApply()
|
||||
let _ = descApply()
|
||||
|
||||
let titleAndDescriptionHeight = titleLayout.size.height - 1.0 + descLayout.size.height
|
||||
let normHeight = InstantPageV2AudioContentNode.normHeight
|
||||
let titleFrame = CGRect(origin: CGPoint(x: controlAreaWidth, y: floor((normHeight - titleAndDescriptionHeight) / 2.0)), size: titleLayout.size)
|
||||
self.titleNode.frame = titleFrame
|
||||
self.descriptionNode.frame = CGRect(origin: CGPoint(x: titleFrame.minX, y: titleFrame.maxY - 1.0), size: descLayout.size)
|
||||
|
||||
// No scrubber. The tapView covers the full row so a tap anywhere toggles playback (there is
|
||||
// no scrubber pan to conflict with anymore).
|
||||
self.tapView.frame = CGRect(origin: .zero, size: CGSize(width: width, height: normHeight))
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<T>` 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?) {
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue