Merge p17-w3-embed-panel into dev (Phase 17 Wave 17.3: Fixed embed queue panel + collapse/resize handshake)
This commit is contained in:
@@ -26,7 +26,7 @@ else
|
|||||||
SkipNext="@SkipNext"
|
SkipNext="@SkipNext"
|
||||||
SkipPrevious="@SkipPrevious"
|
SkipPrevious="@SkipPrevious"
|
||||||
ShowQueueButton="ShowQueueButton"
|
ShowQueueButton="ShowQueueButton"
|
||||||
QueueOpen="_queueOpen"
|
QueueOpen="QueueButtonOpen"
|
||||||
QueueToggle="@ToggleQueue"
|
QueueToggle="@ToggleQueue"
|
||||||
Class="transport-zone"/>
|
Class="transport-zone"/>
|
||||||
|
|
||||||
@@ -42,6 +42,23 @@ else
|
|||||||
Class="seek-zone"/>
|
Class="seek-zone"/>
|
||||||
</div>
|
</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 *@
|
@* Minimize / close — positioned absolutely top-right *@
|
||||||
@if (!Fixed)
|
@if (!Fixed)
|
||||||
{
|
{
|
||||||
@@ -62,8 +79,8 @@ else
|
|||||||
|
|
||||||
@* Docked queue overlay (Phase 17 §3.2). MudOverlay portals to the body, so its position here in
|
@* 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 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. *@
|
the Fixed embed renders its own inline panel inside the surface above. *@
|
||||||
@if (ShowQueueButton)
|
@if (ShowDockedOverlay)
|
||||||
{
|
{
|
||||||
<QueueOverlay Visible="_queueOpen"
|
<QueueOverlay Visible="_queueOpen"
|
||||||
Items="QueueItems"
|
Items="QueueItems"
|
||||||
|
|||||||
@@ -40,6 +40,13 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
|||||||
private IJSObjectReference? _spacerModule;
|
private IJSObjectReference? _spacerModule;
|
||||||
private bool _spacerObserved;
|
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 IsLoaded => PlayerService?.IsLoaded ?? false;
|
||||||
private bool IsLoading => PlayerService?.IsLoading ?? 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 HasNext => QueueService?.HasNext ?? false;
|
||||||
private bool HasPrevious => QueueService?.HasPrevious ?? false;
|
private bool HasPrevious => QueueService?.HasPrevious ?? false;
|
||||||
|
|
||||||
// Queue overlay state. The button (and overlay) appear only in docked mode with a non-empty queue,
|
// Queue button gating. The button appears in BOTH modes when a queue is loaded, mirroring the
|
||||||
// mirroring the skip-affordance gating (AC1): with no queue the bar is byte-for-byte its pre-queue
|
// skip-affordance gating (AC1): with no queue the bar is byte-for-byte its pre-queue self, so a
|
||||||
// self. The Fixed embed gets an inline panel in a later wave, so the docked overlay is !Fixed-only.
|
// single-track embed (empty queue) shows no button and no panel (UC6). In docked mode it toggles
|
||||||
private bool ShowQueueButton => !Fixed && (QueueService?.Items.Count ?? 0) > 0;
|
// 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 IReadOnlyList<TrackDto> QueueItems => QueueService?.Items ?? [];
|
||||||
private int QueueCurrentIndex => QueueService?.CurrentIndex ?? -1;
|
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>
|
/// <summary>
|
||||||
/// Display time - shows seek position while dragging, otherwise current playback time.
|
/// Display time - shows seek position while dragging, otherwise current playback time.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -137,7 +162,21 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
|||||||
await QueueService.Previous();
|
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;
|
private void CloseQueue() => _queueOpen = false;
|
||||||
|
|
||||||
@@ -160,7 +199,21 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
|||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
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
|
// For the docked player: we observe in BOTH expanded and minimized states
|
||||||
// so --player-height always reflects the live height of whichever element
|
// so --player-height always reflects the live height of whichever element
|
||||||
// is visible. This keeps the WaveformVisualizer clipped to the top of
|
// 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)
|
// minimized → observe _miniDock (floating FAB container, ~56–60px)
|
||||||
// The player-spacer's .minimized class uses a hardcoded height and ignores
|
// 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.
|
// the var, so publishing the FAB height here does not regress the spacer.
|
||||||
if (Fixed) return;
|
|
||||||
|
|
||||||
var elementToObserve = _isMinimized ? _miniDock : _playerRoot;
|
var elementToObserve = _isMinimized ? _miniDock : _playerRoot;
|
||||||
var alreadyOnThisElement = _spacerObserved && elementToObserve.Id == _lastObservedElement.Id;
|
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);
|
private async Task Expand() => await SetMinimized(false);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -318,5 +401,18 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
|||||||
}
|
}
|
||||||
_spacerModule = null;
|
_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,74 @@ namespace DeepDrftPublic.Client.Helpers;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds the iframe embed snippet the share popover copies. Two targets: a single track
|
/// 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="ForTrack"/> → <c>FramePlayer?TrackEntryKey=...</c>) and a whole release
|
||||||
/// (<see cref="ForRelease"/> → <c>FramePlayer?ReleaseEntryKey=...</c>). The iframe chrome
|
/// (<see cref="ForRelease"/> → <c>FramePlayer?ReleaseEntryKey=...</c>).
|
||||||
/// (dimensions, border radius, autoplay permission) is identical across both, defined once here.
|
///
|
||||||
|
/// <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>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Multi-embed isolation: each <see cref="ForRelease"/> call mints a fresh random token (8 hex
|
||||||
|
/// chars). The token is used as the iframe id (<c>deepdrft-embed-{token}</c>) and threaded into
|
||||||
|
/// the iframe src as <c>&EmbedId={token}</c> so the iframe can learn its own id. The host-side
|
||||||
|
/// resize script matches incoming messages on <c>embedId</c> and resizes only the iframe whose id
|
||||||
|
/// matches the token — two releases on one host page resize independently without cross-talk. Two
|
||||||
|
/// calls for the same release still get distinct tokens, ensuring uniqueness even when the same
|
||||||
|
/// release is pasted twice. Older snippets that lack <c>embedId</c> in their postMessage payload are
|
||||||
|
/// silently ignored by the script (backward-compatible degradation).
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
/// Pure string composition so the snippet shape is unit-testable without rendering the component.
|
/// Pure string composition so the snippet shape is unit-testable without rendering the component.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class EmbedSnippetBuilder
|
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.
|
// baseUri carries a trailing slash (NavigationManager.BaseUri), so "FramePlayer" appends cleanly.
|
||||||
public static string ForTrack(string baseUri, string trackEntryKey)
|
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)
|
public static string ForRelease(string baseUri, string releaseEntryKey)
|
||||||
=> Frame($"{baseUri}FramePlayer?ReleaseEntryKey={releaseEntryKey}");
|
{
|
||||||
|
// Mint a fresh random token per call so two embeds on the same host page never share an id,
|
||||||
|
// even when they point at the same release.
|
||||||
|
var token = Guid.NewGuid().ToString("N")[..8];
|
||||||
|
var iframeId = $"deepdrft-embed-{token}";
|
||||||
|
var src = $"{baseUri}FramePlayer?ReleaseEntryKey={releaseEntryKey}&EmbedId={token}";
|
||||||
|
return Frame(src, ReleaseHeight, iframeId, ResizeScript(iframeId, token));
|
||||||
|
}
|
||||||
|
|
||||||
private static string Frame(string src)
|
private static string Frame(string src, int height, string iframeId = "deepdrft-embed", string trailingScript = "")
|
||||||
=> $"""<iframe src="{src}" width="656" height="196" frameborder="0" style="border-radius:8px;" allow="autoplay"></iframe>""";
|
=> $"""<iframe id="{iframeId}" src="{src}" width="656" height="{height}" frameborder="0" style="border-radius:8px;" allow="autoplay"></iframe>{trailingScript}""";
|
||||||
|
|
||||||
|
// 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) =>
|
||||||
|
"<script>(function(){var id=\"" + iframeId + "\",tok=\"" + token + "\";" +
|
||||||
|
"window.addEventListener(\"message\",function(e){var d=e.data;" +
|
||||||
|
"if(!d||d.type!==\"deepdrft-embed-resize\"||d.embedId!==tok)return;" +
|
||||||
|
"var f=document.getElementById(id);var h=Number(d.height);" +
|
||||||
|
"if(f&&h>=150)f.style.height=h+\"px\";});})();</script>";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// Multi-embed isolation: EmbedSnippetBuilder.ForRelease mints a per-snippet random token and passes
|
||||||
|
// it as ?EmbedId=<token> 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.
|
||||||
|
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;
|
||||||
|
|
||||||
|
const payload: Record<string, unknown> = { type: MESSAGE_TYPE, height };
|
||||||
|
if (embedId !== null) payload.embedId = embedId;
|
||||||
|
window.parent.postMessage(payload, "*");
|
||||||
|
}
|
||||||
@@ -905,3 +905,15 @@ body:has(.deepdrft-queue-overlay) {
|
|||||||
background: color-mix(in srgb, var(--deepdrft-green-accent) 22%, transparent);
|
background: color-mix(in srgb, var(--deepdrft-green-accent) 22%, transparent);
|
||||||
border-radius: 6px;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
using DeepDrftPublic.Client.Helpers;
|
using DeepDrftPublic.Client.Helpers;
|
||||||
|
|
||||||
namespace DeepDrftTests;
|
namespace DeepDrftTests;
|
||||||
@@ -5,8 +6,9 @@ namespace DeepDrftTests;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Unit tests for the share-popover embed snippet (<see cref="EmbedSnippetBuilder"/>). The builder is
|
/// Unit tests for the share-popover embed snippet (<see cref="EmbedSnippetBuilder"/>). The builder is
|
||||||
/// the mode-aware half of SharePopover: track mode targets FramePlayer's TrackEntryKey param, release
|
/// 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
|
/// mode targets its ReleaseEntryKey param. The two snippets share width/border/autoplay chrome but
|
||||||
/// across both. Pure string composition, tested directly without rendering the component.
|
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[TestFixture]
|
[TestFixture]
|
||||||
public class EmbedSnippetBuilderTests
|
public class EmbedSnippetBuilderTests
|
||||||
@@ -27,12 +29,13 @@ public class EmbedSnippetBuilderTests
|
|||||||
{
|
{
|
||||||
var snippet = EmbedSnippetBuilder.ForRelease(BaseUri, "rel-xyz");
|
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"));
|
Assert.That(snippet, Does.Not.Contain("TrackEntryKey"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void BothModes_ShareIdenticalIframeChrome()
|
public void BothModes_ShareIdenticalNonHeightChrome()
|
||||||
{
|
{
|
||||||
var track = EmbedSnippetBuilder.ForTrack(BaseUri, "k");
|
var track = EmbedSnippetBuilder.ForTrack(BaseUri, "k");
|
||||||
var release = EmbedSnippetBuilder.ForRelease(BaseUri, "k");
|
var release = EmbedSnippetBuilder.ForRelease(BaseUri, "k");
|
||||||
@@ -43,12 +46,115 @@ public class EmbedSnippetBuilderTests
|
|||||||
{
|
{
|
||||||
Assert.That(snippet, Does.StartWith("<iframe "));
|
Assert.That(snippet, Does.StartWith("<iframe "));
|
||||||
Assert.That(snippet, Does.Contain(@"width=""656"""));
|
Assert.That(snippet, Does.Contain(@"width=""656"""));
|
||||||
Assert.That(snippet, Does.Contain(@"height=""196"""));
|
|
||||||
Assert.That(snippet, Does.Contain(@"frameborder=""0"""));
|
Assert.That(snippet, Does.Contain(@"frameborder=""0"""));
|
||||||
Assert.That(snippet, Does.Contain(@"style=""border-radius:8px;"""));
|
Assert.That(snippet, Does.Contain(@"style=""border-radius:8px;"""));
|
||||||
Assert.That(snippet, Does.Contain(@"allow=""autoplay"""));
|
Assert.That(snippet, Does.Contain(@"allow=""autoplay"""));
|
||||||
Assert.That(snippet, Does.EndWith("></iframe>"));
|
Assert.That(snippet, Does.Contain("</iframe>"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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("<script>"));
|
||||||
|
Assert.That(release, Does.Contain("deepdrft-embed-resize"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ForTrack_HasNoResizeListenerScript()
|
||||||
|
{
|
||||||
|
var track = EmbedSnippetBuilder.ForTrack(BaseUri, "k");
|
||||||
|
|
||||||
|
Assert.That(track, Does.Not.Contain("<script>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Multi-embed isolation (Phase 17 major remediation) ---
|
||||||
|
|
||||||
|
// Two ForRelease calls must produce snippets with different iframe ids so both can coexist on one
|
||||||
|
// host page without the host-side resize script resolving only the first via getElementById.
|
||||||
|
[Test]
|
||||||
|
public void ForRelease_TwoCalls_ProduceDifferentIframeIds()
|
||||||
|
{
|
||||||
|
var a = EmbedSnippetBuilder.ForRelease(BaseUri, "rel-xyz");
|
||||||
|
var b = EmbedSnippetBuilder.ForRelease(BaseUri, "rel-xyz"); // same release, different call
|
||||||
|
|
||||||
|
var idA = IframeId(a);
|
||||||
|
var idB = IframeId(b);
|
||||||
|
|
||||||
|
Assert.That(idA, Is.Not.EqualTo(idB),
|
||||||
|
"each ForRelease call must mint a distinct iframe id to prevent multi-embed cross-talk");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The iframe id and the token embedded in the host-side resize script must be consistent within
|
||||||
|
// a single snippet — the script assigns the id string to a JS variable and calls getElementById
|
||||||
|
// with it, so the id literal must appear in the script's var initializer.
|
||||||
|
[Test]
|
||||||
|
public void ForRelease_IframeIdAndScriptToken_AreConsistentWithinOneSnippet()
|
||||||
|
{
|
||||||
|
var snippet = EmbedSnippetBuilder.ForRelease(BaseUri, "rel-abc");
|
||||||
|
|
||||||
|
var id = IframeId(snippet);
|
||||||
|
Assert.That(id, Does.StartWith("deepdrft-embed-"), "id must carry the expected prefix");
|
||||||
|
|
||||||
|
// The iframe element must declare the minted id.
|
||||||
|
Assert.That(snippet, Does.Contain($@"id=""{id}"""),
|
||||||
|
"iframe element must carry the minted id");
|
||||||
|
|
||||||
|
// The script stores the id in a JS var and calls getElementById(id) — confirm the id literal
|
||||||
|
// appears in the script's var initializer so the right iframe is targeted.
|
||||||
|
Assert.That(snippet, Does.Contain($@"var id=""{id}"""),
|
||||||
|
"resize script must initialise its id variable with the same minted id");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The iframe src must carry EmbedId so the iframe content (embed-frame.ts) can read its own
|
||||||
|
// token and include it in postMessage payloads for the host-side script to match on.
|
||||||
|
[Test]
|
||||||
|
public void ForRelease_SrcCarriesEmbedIdParam()
|
||||||
|
{
|
||||||
|
var snippet = EmbedSnippetBuilder.ForRelease(BaseUri, "rel-def");
|
||||||
|
|
||||||
|
Assert.That(snippet, Does.Contain("EmbedId="),
|
||||||
|
"iframe src must include EmbedId query param so embed-frame.ts can read its own token");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int HeightOf(string snippet)
|
||||||
|
{
|
||||||
|
var match = Regex.Match(snippet, @"height=""(\d+)""");
|
||||||
|
Assert.That(match.Success, Is.True, "snippet must declare an iframe height");
|
||||||
|
return int.Parse(match.Groups[1].Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string IframeId(string snippet)
|
||||||
|
{
|
||||||
|
var match = Regex.Match(snippet, @"id=""([^""]+)""");
|
||||||
|
Assert.That(match.Success, Is.True, "snippet must declare an iframe id");
|
||||||
|
return match.Groups[1].Value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user