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("