feat: Phase 17.3 — Fixed embed queue panel with collapse/expand iframe resize (OQ1 Option A)
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.
This commit is contained in:
@@ -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"/>
|
||||
</div>
|
||||
|
||||
@* 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)
|
||||
{
|
||||
<div class="deepdrft-queue-embed-panel">
|
||||
<QueueList Items="QueueItems"
|
||||
CurrentIndex="QueueCurrentIndex"
|
||||
Editable="false"
|
||||
OnJump="@OnQueueJump"/>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* 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)
|
||||
{
|
||||
<QueueOverlay Visible="_queueOpen"
|
||||
Items="QueueItems"
|
||||
|
||||
@@ -40,6 +40,13 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||
private IJSObjectReference? _spacerModule;
|
||||
private bool _spacerObserved;
|
||||
|
||||
// Fixed-embed → host resize handshake (OQ1 Option A). When the inline panel collapses/expands we
|
||||
// measure the player's live height and post it to the host so the iframe resizes to match. The
|
||||
// dirty flag defers the post to OnAfterRenderAsync so the DOM reflects the new panel state first.
|
||||
private IJSObjectReference? _embedModule;
|
||||
private bool _embedHeightDirty;
|
||||
private bool _embedHeightPosted;
|
||||
|
||||
private bool IsLoaded => 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<TrackDto> 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;
|
||||
|
||||
/// <summary>
|
||||
/// Display time - shows seek position while dragging, otherwise current playback time.
|
||||
/// </summary>
|
||||
@@ -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<IJSObjectReference?> GetEmbedModuleAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
return _embedModule ??= await JsRuntime.InvokeAsync<IJSObjectReference>(
|
||||
"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);
|
||||
|
||||
/// <summary>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,19 +3,50 @@ namespace DeepDrftPublic.Client.Helpers;
|
||||
/// <summary>
|
||||
/// Builds the iframe embed snippet the share popover copies. Two targets: a single track
|
||||
/// (<see cref="ForTrack"/> → <c>FramePlayer?TrackEntryKey=...</c>) and a whole release
|
||||
/// (<see cref="ForRelease"/> → <c>FramePlayer?ReleaseEntryKey=...</c>). The iframe chrome
|
||||
/// (dimensions, border radius, autoplay permission) is identical across both, defined once here.
|
||||
/// (<see cref="ForRelease"/> → <c>FramePlayer?ReleaseEntryKey=...</c>).
|
||||
///
|
||||
/// <para>
|
||||
/// The two snippets diverge in height by design (Phase 17 §4.1, OQ6): a single-track embed has no
|
||||
/// queue, so <see cref="ForTrack"/> stays at the compact player height; a release embed renders the
|
||||
/// always-shown queue panel below the controls, so <see cref="ForRelease"/> is taller to show it
|
||||
/// without clipping. Other iframe chrome (width, border radius, autoplay permission) is identical and
|
||||
/// defined once in <see cref="Frame"/>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// 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).
|
||||
/// </para>
|
||||
///
|
||||
/// Pure string composition so the snippet shape is unit-testable without rendering the component.
|
||||
/// </summary>
|
||||
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)
|
||||
=> $"""<iframe src="{src}" width="656" height="196" frameborder="0" style="border-radius:8px;" allow="autoplay"></iframe>""";
|
||||
private static string Frame(string src, int height, string trailingScript = "")
|
||||
=> $"""<iframe id="deepdrft-embed" src="{src}" width="656" height="{height}" frameborder="0" style="border-radius:8px;" allow="autoplay"></iframe>{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 =
|
||||
"""<script>window.addEventListener("message",function(e){var d=e.data;if(!d||d.type!=="deepdrft-embed-resize")return;var f=document.getElementById("deepdrft-embed");var h=Number(d.height);if(f&&h>=150)f.style.height=h+"px";});</script>""";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user