From 466084b5a3a732f8789d5fbd1a375c96fdb7cf9c Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Fri, 19 Jun 2026 16:21:45 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20Phase=2017.3=20=E2=80=94=20Fixed=20?= =?UTF-8?q?embed=20queue=20panel=20with=20collapse/expand=20iframe=20resiz?= =?UTF-8?q?e=20(OQ1=20Option=20A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read-only inline queue panel below the release embed's player bar; row-jump reuses PlayRelease. ForRelease mints a taller iframe plus a postMessage resize listener for the collapse toggle; ForTrack unchanged. --- .../AudioPlayerBar/AudioPlayerBar.razor | 23 +++- .../AudioPlayerBar/AudioPlayerBar.razor.cs | 110 ++++++++++++++++-- .../Helpers/EmbedSnippetBuilder.cs | 43 ++++++- DeepDrftPublic/Interop/embed/embed-frame.ts | 31 +++++ .../wwwroot/styles/deepdrft-styles.css | 12 ++ DeepDrftTests/EmbedSnippetBuilderTests.cs | 59 +++++++++- 6 files changed, 257 insertions(+), 21 deletions(-) create mode 100644 DeepDrftPublic/Interop/embed/embed-frame.ts diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor b/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor index 54cc9c5..98b1e75 100644 --- a/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor +++ b/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor @@ -26,7 +26,7 @@ else SkipNext="@SkipNext" SkipPrevious="@SkipPrevious" ShowQueueButton="ShowQueueButton" - QueueOpen="_queueOpen" + QueueOpen="QueueButtonOpen" QueueToggle="@ToggleQueue" Class="transport-zone"/> @@ -42,6 +42,23 @@ else Class="seek-zone"/> + @* Fixed (embed) queue panel (§4 / AC5). A release embed shows the up-next inline below the + controls as a read-only list (Editable=false → no drag handles, no remove buttons; C3). + Jump-to-track is still allowed (OQ2) — routed through the same OnQueueJump as the docked + overlay, which calls PlayRelease (clearing IsArmed if the embed was armed-but-not-started). + Gated on ShowFixedPanel so a single-track embed (empty queue) stays panel-free (UC6). The + Queue button collapses/expands this panel (OQ1 Option A); collapse hides it and posts the + shrunken height to the host iframe. *@ + @if (ShowFixedPanel && _fixedPanelOpen) + { +
+ +
+ } + @* Minimize / close — positioned absolutely top-right *@ @if (!Fixed) { @@ -62,8 +79,8 @@ else @* Docked queue overlay (Phase 17 §3.2). MudOverlay portals to the body, so its position here in the dock subtree does not affect its screen-centered rendering. Only mounted in docked mode — - the Fixed embed gets its own inline panel in a later wave. *@ - @if (ShowQueueButton) + the Fixed embed renders its own inline panel inside the surface above. *@ + @if (ShowDockedOverlay) { PlayerService?.IsLoaded ?? false; private bool IsLoading => PlayerService?.IsLoading ?? false; @@ -64,13 +71,31 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable private bool HasNext => QueueService?.HasNext ?? false; private bool HasPrevious => QueueService?.HasPrevious ?? false; - // Queue overlay state. The button (and overlay) appear only in docked mode with a non-empty queue, - // mirroring the skip-affordance gating (AC1): with no queue the bar is byte-for-byte its pre-queue - // self. The Fixed embed gets an inline panel in a later wave, so the docked overlay is !Fixed-only. - private bool ShowQueueButton => !Fixed && (QueueService?.Items.Count ?? 0) > 0; + // Queue button gating. The button appears in BOTH modes when a queue is loaded, mirroring the + // skip-affordance gating (AC1): with no queue the bar is byte-for-byte its pre-queue self, so a + // single-track embed (empty queue) shows no button and no panel (UC6). In docked mode it toggles + // the overlay; in Fixed mode it collapses/expands the inline panel (OQ1 Option A). + private bool HasQueue => (QueueService?.Items.Count ?? 0) > 0; + private bool ShowQueueButton => HasQueue; + + // The docked overlay mounts only in docked mode; the Fixed embed renders its inline panel instead. + private bool ShowDockedOverlay => !Fixed && HasQueue; + + // The Fixed-mode inline panel: always shown (read-only, C3) when a release embed has a queue. + // Gated on Fixed + non-empty so single-track embeds keep their compact, panel-free bar (UC6). + private bool ShowFixedPanel => Fixed && HasQueue; + private IReadOnlyList QueueItems => QueueService?.Items ?? []; private int QueueCurrentIndex => QueueService?.CurrentIndex ?? -1; + // Fixed-mode panel collapse state (OQ1 Option A). Default expanded so a release embed shows the + // up-next out of the box; the Queue button collapses it to let the viewer reclaim iframe space. + private bool _fixedPanelOpen = true; + + // The Queue button's "open" state differs by mode: docked tracks the overlay, Fixed tracks the + // inline panel's expanded state. One button, mode-appropriate meaning. + private bool QueueButtonOpen => Fixed ? _fixedPanelOpen : _queueOpen; + /// /// Display time - shows seek position while dragging, otherwise current playback time. /// @@ -137,7 +162,21 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable await QueueService.Previous(); } - private void ToggleQueue() => _queueOpen = !_queueOpen; + // Docked: toggle the overlay. Fixed: collapse/expand the inline panel and flag a height re-post so + // the host iframe resizes to match the new panel state (OQ1 Option A). The post happens in + // OnAfterRenderAsync (below) once the DOM reflects the new state, then degrades safely — the host + // listener may simply not be present (Option B's behaviour). + private void ToggleQueue() + { + if (Fixed) + { + _fixedPanelOpen = !_fixedPanelOpen; + _embedHeightDirty = true; + return; + } + + _queueOpen = !_queueOpen; + } private void CloseQueue() => _queueOpen = false; @@ -160,7 +199,21 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable protected override async Task OnAfterRenderAsync(bool firstRender) { - // The Fixed embed is already in normal flow — no spacer/clip needed. + // Fixed embed: post the live player height to the host so the iframe sizes to the panel. We + // post on the first render (so the host snaps to the expanded panel rather than the snippet's + // initial guess) and whenever the panel is collapsed/expanded (_embedHeightDirty). No spacer/ + // clip here — the embed is in normal flow. + if (Fixed) + { + if (ShowFixedPanel && (!_embedHeightPosted || _embedHeightDirty)) + { + _embedHeightDirty = false; + _embedHeightPosted = true; + await PostEmbedHeight(); + } + return; + } + // For the docked player: we observe in BOTH expanded and minimized states // so --player-height always reflects the live height of whichever element // is visible. This keeps the WaveformVisualizer clipped to the top of @@ -169,7 +222,6 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable // minimized → observe _miniDock (floating FAB container, ~56–60px) // The player-spacer's .minimized class uses a hardcoded height and ignores // the var, so publishing the FAB height here does not regress the spacer. - if (Fixed) return; var elementToObserve = _isMinimized ? _miniDock : _playerRoot; var alreadyOnThisElement = _spacerObserved && elementToObserve.Id == _lastObservedElement.Id; @@ -198,6 +250,37 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable } } + // Measure the player root's live height and post it to the host page (OQ1 Option A). Best-effort: + // a missing module or a host that ignores the message just means no outer resize (Option B value). + private async Task PostEmbedHeight() + { + var module = await GetEmbedModuleAsync(); + if (module is null) return; + + try + { + await module.InvokeVoidAsync("postHeight", _playerRoot); + } + catch (JSException) + { + // Runtime gone or element detached mid-teardown — nothing actionable. + } + } + + private async Task GetEmbedModuleAsync() + { + try + { + return _embedModule ??= await JsRuntime.InvokeAsync( + "import", "./js/embed/embed-frame.js"); + } + catch (JSException) + { + // Module failed to load — the panel still renders and toggles; only the outer resize is lost. + return null; + } + } + private async Task Expand() => await SetMinimized(false); /// @@ -318,5 +401,18 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable } _spacerModule = null; } + + if (_embedModule is not null) + { + try + { + await _embedModule.DisposeAsync(); + } + catch (JSException) + { + // Runtime already gone (navigation/teardown) — nothing to clean up. + } + _embedModule = null; + } } } \ No newline at end of file diff --git a/DeepDrftPublic.Client/Helpers/EmbedSnippetBuilder.cs b/DeepDrftPublic.Client/Helpers/EmbedSnippetBuilder.cs index 178395c..0585d7d 100644 --- a/DeepDrftPublic.Client/Helpers/EmbedSnippetBuilder.cs +++ b/DeepDrftPublic.Client/Helpers/EmbedSnippetBuilder.cs @@ -3,19 +3,50 @@ namespace DeepDrftPublic.Client.Helpers; /// /// Builds the iframe embed snippet the share popover copies. Two targets: a single track /// (FramePlayer?TrackEntryKey=...) and a whole release -/// (FramePlayer?ReleaseEntryKey=...). The iframe chrome -/// (dimensions, border radius, autoplay permission) is identical across both, defined once here. +/// (FramePlayer?ReleaseEntryKey=...). +/// +/// +/// The two snippets diverge in height by design (Phase 17 §4.1, OQ6): a single-track embed has no +/// queue, so stays at the compact player height; a release embed renders the +/// always-shown queue panel below the controls, so is taller to show it +/// without clipping. Other iframe chrome (width, border radius, autoplay permission) is identical and +/// defined once in . +/// +/// +/// +/// OQ1 Option A — collapse/expand resize handshake. The release snippet carries a tiny host-side +/// listener: the embedded player posts its desired height when the viewer collapses/expands the +/// queue panel, and the listener sizes this specific iframe to match. It is scoped to the snippet's +/// own iframe (matched by id) and ignores any message whose shape does not match, so it cannot be +/// driven by foreign frames. It degrades to Option B's behaviour if the host strips the script: the +/// panel still renders and toggles inside the iframe at its default (expanded) height — only the +/// outer resize is lost. The track snippet needs no script (no panel, no toggle). +/// +/// /// Pure string composition so the snippet shape is unit-testable without rendering the component. /// public static class EmbedSnippetBuilder { + // Compact single-track height (the historical embed height — must not change: UC6/AC6). + private const int TrackHeight = 196; + + // Release height: the compact player plus the queue panel (fixed, internally scrollable past N + // rows per OQ6). The panel collapses to the track height via the resize handshake below. + private const int ReleaseHeight = 384; + // baseUri carries a trailing slash (NavigationManager.BaseUri), so "FramePlayer" appends cleanly. public static string ForTrack(string baseUri, string trackEntryKey) - => Frame($"{baseUri}FramePlayer?TrackEntryKey={trackEntryKey}"); + => Frame($"{baseUri}FramePlayer?TrackEntryKey={trackEntryKey}", TrackHeight); public static string ForRelease(string baseUri, string releaseEntryKey) - => Frame($"{baseUri}FramePlayer?ReleaseEntryKey={releaseEntryKey}"); + => Frame($"{baseUri}FramePlayer?ReleaseEntryKey={releaseEntryKey}", ReleaseHeight, ResizeScript); - private static string Frame(string src) - => $""""""; + private static string Frame(string src, int height, string trailingScript = "") + => $"""{trailingScript}"""; + + // Host-side listener: resize #deepdrft-embed when the embedded player posts its panel height. The + // shape/origin-agnostic guard (ignores non-matching messages) keeps a foreign frame from driving + // it; the height is clamped to a sane floor so a bad payload can't collapse the player away. + private const string ResizeScript = + """"""; } diff --git a/DeepDrftPublic/Interop/embed/embed-frame.ts b/DeepDrftPublic/Interop/embed/embed-frame.ts new file mode 100644 index 0000000..3f0f98f --- /dev/null +++ b/DeepDrftPublic/Interop/embed/embed-frame.ts @@ -0,0 +1,31 @@ +// Embed (iframe) → host resize handshake (Phase 17 wave 17.3, OQ1 Option A). +// +// The Fixed-mode player renders an always-shown queue panel below the controls. A collapse/expand +// toggle lets the embedder's viewer reclaim the panel's vertical space — but collapsing inside the +// iframe only reclaims space if the *outer* iframe element also shrinks. The iframe cannot resize +// itself, so it posts its desired pixel height to the host page; the embed snippet (minted by +// EmbedSnippetBuilder.ForRelease) carries a tiny listener that sets iframe.style.height. +// +// Degrades safely: if the host page ignores or strips the snippet's listener (Option B's value), the +// panel still renders and toggles inside the iframe — only the outer resize is lost. We post nothing +// when not framed (window === parent), so the docked player is unaffected. + +const MESSAGE_TYPE = "deepdrft-embed-resize"; + +/** + * Measure the live rendered height of the player element and post it to the host page so it can size + * the iframe to match. No-op when not embedded in a frame, or when the element is unmeasurable. + * + * targetOrigin is "*" deliberately: the embedder's origin is unknown (any blog can embed us) and the + * payload carries no secrets — just a height the host is free to ignore. + */ +export function postHeight(element: HTMLElement): void { + if (window.parent === window) return; // Not framed — nothing to resize. + if (!element) return; + + // ceil + a hairline guard against sub-pixel rounding that would otherwise clip the bottom edge. + const height = Math.ceil(element.getBoundingClientRect().height) + 2; + if (!Number.isFinite(height) || height <= 0) return; + + window.parent.postMessage({ type: MESSAGE_TYPE, height }, "*"); +} diff --git a/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css b/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css index 3bb29ba..2e35c18 100644 --- a/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css +++ b/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css @@ -905,3 +905,15 @@ body:has(.deepdrft-queue-overlay) { background: color-mix(in srgb, var(--deepdrft-green-accent) 22%, transparent); border-radius: 6px; } + +/* ── Fixed (embed) inline queue panel (Phase 17 §4, OQ6). ── + Rendered below the player controls inside the embed surface. A fixed sensible height with internal + scroll past N rows (NOT grow-to-cap): ~4.5 rows are visible, the rest scroll. A top hairline + separates it from the controls. The list rows reuse the shared .deepdrft-queue-* styles above. */ +.deepdrft-queue-embed-panel { + margin-top: 0.5rem; + padding-top: 0.5rem; + border-top: 1px solid var(--deepdrft-border-light); + max-height: 184px; + overflow-y: auto; +} diff --git a/DeepDrftTests/EmbedSnippetBuilderTests.cs b/DeepDrftTests/EmbedSnippetBuilderTests.cs index 81fbc60..c55e126 100644 --- a/DeepDrftTests/EmbedSnippetBuilderTests.cs +++ b/DeepDrftTests/EmbedSnippetBuilderTests.cs @@ -1,3 +1,4 @@ +using System.Text.RegularExpressions; using DeepDrftPublic.Client.Helpers; namespace DeepDrftTests; @@ -5,8 +6,9 @@ namespace DeepDrftTests; /// /// Unit tests for the share-popover embed snippet (). The builder is /// the mode-aware half of SharePopover: track mode targets FramePlayer's TrackEntryKey param, release -/// mode targets its ReleaseEntryKey param. The iframe chrome (dimensions, autoplay) must be identical -/// across both. Pure string composition, tested directly without rendering the component. +/// mode targets its ReleaseEntryKey param. The two snippets share width/border/autoplay chrome but +/// diverge in height by design (Phase 17 §4.1, OQ6): the release embed is taller to show its queue +/// panel; the track embed stays compact. Pure string composition, tested without rendering. /// [TestFixture] public class EmbedSnippetBuilderTests @@ -32,7 +34,7 @@ public class EmbedSnippetBuilderTests } [Test] - public void BothModes_ShareIdenticalIframeChrome() + public void BothModes_ShareIdenticalNonHeightChrome() { var track = EmbedSnippetBuilder.ForTrack(BaseUri, "k"); var release = EmbedSnippetBuilder.ForRelease(BaseUri, "k"); @@ -43,12 +45,59 @@ public class EmbedSnippetBuilderTests { Assert.That(snippet, Does.StartWith("")); + Assert.That(snippet, Does.Contain("")); } }); } + + // T14 (Phase 17 §9): the release embed is taller than the track embed (it shows a queue panel), + // and the track embed's height is unchanged from its historical value (UC6/AC6). + [Test] + public void ForTrack_KeepsHistoricalCompactHeight() + { + var track = EmbedSnippetBuilder.ForTrack(BaseUri, "k"); + + Assert.That(track, Does.Contain(@"height=""196""")); + } + + [Test] + public void ForRelease_IsTallerThanForTrack_ToShowQueuePanel() + { + var trackHeight = HeightOf(EmbedSnippetBuilder.ForTrack(BaseUri, "k")); + var releaseHeight = HeightOf(EmbedSnippetBuilder.ForRelease(BaseUri, "k")); + + Assert.That(releaseHeight, Is.GreaterThan(trackHeight)); + } + + // The release snippet carries the host-side resize listener (OQ1 Option A); the track snippet, + // having no panel to collapse, does not. + [Test] + public void ForRelease_IncludesResizeListenerScript() + { + var release = EmbedSnippetBuilder.ForRelease(BaseUri, "k"); + + Assert.Multiple(() => + { + Assert.That(release, Does.Contain(""""; + // Host-side listener: resize the matching iframe when the embedded player posts its panel height. + // The embedId field in the payload is matched against the snippet's own token so only this + // iframe is driven — foreign frames or other release embeds on the same page cannot interfere. + // The height is clamped to a sane floor so a bad payload can't collapse the player away. + // Messages without embedId (older snippets) are silently ignored. + private static string ResizeScript(string iframeId, string token) => + ""; } diff --git a/DeepDrftPublic/Interop/embed/embed-frame.ts b/DeepDrftPublic/Interop/embed/embed-frame.ts index 3f0f98f..03493fd 100644 --- a/DeepDrftPublic/Interop/embed/embed-frame.ts +++ b/DeepDrftPublic/Interop/embed/embed-frame.ts @@ -9,15 +9,37 @@ // Degrades safely: if the host page ignores or strips the snippet's listener (Option B's value), the // panel still renders and toggles inside the iframe — only the outer resize is lost. We post nothing // when not framed (window === parent), so the docked player is unaffected. +// +// Multi-embed isolation: EmbedSnippetBuilder.ForRelease mints a per-snippet random token and passes +// it as ?EmbedId= in the iframe src. We read it here from window.location.search and include +// it in the postMessage payload as `embedId`. The host-side resize script matches on this token so +// only the correct iframe is resized when multiple embeds share a host page. If EmbedId is absent +// (older already-pasted snippets), embedId is omitted from the payload — those snippets' scripts +// ignore messages without a matching embedId, so there is no cross-talk either way. const MESSAGE_TYPE = "deepdrft-embed-resize"; +/** Read the EmbedId query param from the iframe's own URL, if present. */ +function readEmbedId(): string | null { + try { + return new URLSearchParams(window.location.search).get("EmbedId"); + } catch { + return null; + } +} + +// Resolved once at module load — the URL does not change while the iframe is alive. +const embedId: string | null = readEmbedId(); + /** * Measure the live rendered height of the player element and post it to the host page so it can size * the iframe to match. No-op when not embedded in a frame, or when the element is unmeasurable. * * targetOrigin is "*" deliberately: the embedder's origin is unknown (any blog can embed us) and the * payload carries no secrets — just a height the host is free to ignore. + * + * The payload includes `embedId` when the iframe src carried an EmbedId query param. The host-side + * resize script matches on this field to isolate multiple embeds on the same page. */ export function postHeight(element: HTMLElement): void { if (window.parent === window) return; // Not framed — nothing to resize. @@ -27,5 +49,7 @@ export function postHeight(element: HTMLElement): void { const height = Math.ceil(element.getBoundingClientRect().height) + 2; if (!Number.isFinite(height) || height <= 0) return; - window.parent.postMessage({ type: MESSAGE_TYPE, height }, "*"); + const payload: Record = { type: MESSAGE_TYPE, height }; + if (embedId !== null) payload.embedId = embedId; + window.parent.postMessage(payload, "*"); } diff --git a/DeepDrftTests/EmbedSnippetBuilderTests.cs b/DeepDrftTests/EmbedSnippetBuilderTests.cs index c55e126..e455bd9 100644 --- a/DeepDrftTests/EmbedSnippetBuilderTests.cs +++ b/DeepDrftTests/EmbedSnippetBuilderTests.cs @@ -29,7 +29,8 @@ public class EmbedSnippetBuilderTests { var snippet = EmbedSnippetBuilder.ForRelease(BaseUri, "rel-xyz"); - Assert.That(snippet, Does.Contain(@"src=""https://deepdrft.example/FramePlayer?ReleaseEntryKey=rel-xyz""")); + // src contains ReleaseEntryKey; may also carry additional query params (e.g. EmbedId). + Assert.That(snippet, Does.Contain("ReleaseEntryKey=rel-xyz")); Assert.That(snippet, Does.Not.Contain("TrackEntryKey")); } @@ -94,10 +95,66 @@ public class EmbedSnippetBuilderTests Assert.That(track, Does.Not.Contain("