Merge branch 'dev' into p16-w1-foundation
# Conflicts: # DeepDrftPublic.Client/Controls/SharePopover.razor.cs
This commit is contained in:
@@ -10,7 +10,7 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
|
||||
|
||||
## Actual structure
|
||||
|
||||
- `Pages/`: Routable components. `Home.razor` (hero/about), `SessionDetail.razor` (session detail — hero-dominant overlay composition rendered via `<ReleaseHeroOverlay>`: large background hero image with darkening gradient shim, cover thumbnail + title + play button overlaid near the hero's bottom, genre/date/share overlaid at the top; uses `MudContainer MaxWidth="Large"`; **does not compose `ReleaseDetailScaffold`** — `PlayTrack` is wired directly in its own `@code` block; mounts `<WaveformVisualizer>` ambient engine + `<WaveformVisualizerControlPopover>` directly; renders `<ReleaseDescription>` below the hero for the release's description blurb), `MixDetail.razor` (mix detail — composes `ReleaseDetailScaffold` with `TopRightAction` lava-lamp `<WaveformVisualizerControlPopover>`; hero+meta rendered via `<ReleaseHeroOverlay Class="mix-hero">` in the scaffold's `Hero` slot with `ShowHeader="false"` suppressing the duplicate masthead; square ~600px cover-as-background with metadata overlaid; full-bleed `<WaveformVisualizer>` is the mode-A centerpiece mounted by the page directly; renders `<ReleaseDescription>` below the hero for the release's description blurb), `CutDetail.razor` (album detail — composes `ReleaseDetailScaffold` with the `Ambient` slot carrying `<WaveformVisualizer>` + `<WaveformVisualizerControlPopover>` for mode-B ambient layer; renders `<ReleaseDescription>` below the hero for the release's description blurb). **No demo pages** (`Counter.razor`, `Weather.razor` do not exist).
|
||||
- `Pages/`: Routable components. `Home.razor` (hero/about), `SessionDetail.razor` (session detail — hero-dominant overlay composition rendered via `<ReleaseHeroOverlay>`: large background hero image with darkening gradient shim, cover thumbnail + title + play button overlaid near the hero's bottom, genre/date/share overlaid at the top; uses `MudContainer MaxWidth="Large"`; **does not compose `ReleaseDetailScaffold`** — `PlayTrack` is wired directly in its own `@code` block; mounts `<WaveformVisualizer>` ambient engine + `<WaveformVisualizerControlPopover>` directly; renders `<ReleaseDescription>` below the hero for the release's description blurb), `MixDetail.razor` (mix detail — composes `ReleaseDetailScaffold` with `TopRightAction` lava-lamp `<WaveformVisualizerControlPopover>`; hero+meta rendered via `<ReleaseHeroOverlay Class="mix-hero">` in the scaffold's `Hero` slot with `ShowHeader="false"` suppressing the duplicate masthead; square ~600px cover-as-background with metadata overlaid; full-bleed `<WaveformVisualizer>` is the mode-A centerpiece mounted by the page directly; renders `<ReleaseDescription>` below the hero for the release's description blurb), `CutDetail.razor` (album detail — composes `ReleaseDetailScaffold` with the `Ambient` slot carrying `<WaveformVisualizer>` + `<WaveformVisualizerControlPopover>` for mode-B ambient layer; renders `<ReleaseDescription>` below the hero for the release's description blurb; each track row carries a per-track `<SharePopover EntryKey="@track.EntryKey" />` aligned far-right as the last flex child of `.cut-detail-track-row`), `FramePlayer.razor` (embeddable iframe player at `/FramePlayer`, uses `EmbedLayout`; two mutually-exclusive modes via query params: `TrackEntryKey` stages a single track as before; `ReleaseEntryKey` resolves the release's ordered tracks via `FramePlayerViewModel`, stages track 0 via `PlayerService.StageTrack`, and arms the queue via `Queue.Arm` — no JS interop in either path, so both run safely during prerender; the first play gesture in `AudioPlayerBar` routes through `Queue.Start()` which streams the current track and clears the armed state; release embeds expose queue skip-prev/next navigation in the player bar while single-track embeds show none; track-title links open in a new tab so the iframe keeps playing). **No demo pages** (`Counter.razor`, `Weather.razor` do not exist).
|
||||
- `Layout/`: `MainLayout.razor` (root layout, wraps in `AudioPlayerProvider`, hosts theme switcher), `DeepDrftMenu.razor` (branded menu bar), `NavMenu.razor` (nav list), `Pages.cs` (centralised nav index — `MenuPages` for header, `AllPages` for exhaustive list).
|
||||
- `Controls/`: Reusable components.
|
||||
- `TrackCard.razor`: Individual track display (image, name, artist, album, genre, release date). Play/pause icon controlled via `IsPaused` parameter.
|
||||
@@ -19,7 +19,8 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
|
||||
- `AudioPlayerProvider.razor`: Cascading host for `IStreamingPlayerService`. Everything inside it gets the player via `[CascadingParameter]`.
|
||||
- `StreamNowButton.razor`: Reusable streaming-trigger button. Fetches a random track, warms the AudioContext (Safari gesture requirement), and starts streaming via `IStreamingPlayerService`. Accepts `ButtonClass` and `ButtonLabel` for distinct visual presentations; `OnStreamStarted` EventCallback for post-stream side effects (e.g., mobile menu close).
|
||||
- `AudioPlayerBar.razor`: Dock UI at the bottom (play/pause/seek/volume).
|
||||
- `AudioPlayerBar/PlayerControls.razor`: Play/pause/stop buttons in the transport zone. Renders via `<PlayStateIcon>`.
|
||||
- `AudioPlayerBar/PlayerControls.razor`: Play/pause/stop buttons in the transport zone. Renders via `<PlayStateIcon>`. In embedded (`Fixed`) mode, skip-previous and skip-next render when `!Fixed || HasPrevious || HasNext` — so a release embed (which has a queue) shows forward/back navigation while a single-track embed (no queue) hides them; the Stop button is hidden in all embed contexts (`!Fixed` only).
|
||||
- `AudioPlayerBar/TrackMetaLabel.razor`: Now-playing track-title + artist row. Takes `[Parameter] bool Fixed` (passed from `AudioPlayerBar.razor`). When `Fixed` (embedded iframe), the track-title anchor renders with `target="_blank" rel="noopener noreferrer"` so clicking it opens the release detail page in a new tab; the docked (non-embedded) player keeps same-tab nav. When no release is attached the title renders unlinked in both modes.
|
||||
- `AudioPlayerBar/PlayStateIcon.razor`: Icon button encapsulating service subscription + transport-state icon selection. Injects `IPlayerService`, subscribes to `StateChanged`, calls `PlaybackIcons.Resolve()` to determine icon and active state.
|
||||
- `AudioPlayerBar/LevelMeterFab.razor`: Floating-action button replacing the static FAB in the minimized dock. Renders a continuous vertical fill inside the music-note silhouette that tracks live audio level (0–100%), with fixed three-zone gradient (green 0–60%, yellow 60–85%, orange 85–100%). Note silhouette always visible at 25% opacity; idle when paused/stopped. Reuses spectrum-callback infrastructure.
|
||||
- `SpectrumVisualizer.razor`: Bar-graph spectrum display, driven by `getSpectrumData` JS callback.
|
||||
@@ -33,9 +34,11 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
|
||||
- `NowPlayingStats.razor`: Home hero stat row. Three cards: Studio Cuts (total Cut-medium track count + zero-suppressed per-`ReleaseType` Cut release breakdown), Mixes (`MixReleaseCount` labelled "Sets" + `hh:mm` total mix runtime via `RuntimeFormat`), and Plays (static "XXX / Plays (Coming Soon)" odometer placeholder). Fetches `HomeStatsDto` via `IStatsDataService` on init; bridges the prerender fetch across the WASM seam with `PersistentComponentState` (persists only on a successful load, matching the medium-browse bridge pattern). Implements `IDisposable` to release the `PersistingComponentStateSubscription`.
|
||||
- `NowPlaying.razor`: Owns the home hero's right-side panel (`.now-playing-panel` — the outer wrapper formerly called `.hero-right` in `Home.razor`). Mounts `<WaveformVisualizer Fill="true">` as a full-bleed background inside `.np-visualizer-bg`, `<WaveformVisualizerControlPopover>` in `.np-visualizer-controls` (top-right corner), the three pulsing `.circle-deco` rings, and the content layer (hosts `<NowPlayingCard>` + `<NowPlayingStats>`). `Home.razor`'s `MudItem` renders `<NowPlaying />` directly with no wrapper. Subscribes to `IPlayerService.StateChanged` in `OnParametersSet` (reference-guarded, idempotent) and unsubscribes on dispose — needed because the player cascade is `IsFixed` (the provider's own re-render does not reach `NowPlaying`), so the subscription is the only way to re-render and re-propagate `ReleaseEntryKey`/`TrackId`/`TrackEntryKey` into `<WaveformVisualizer>` when the playing track changes.
|
||||
- `ReleaseDetailScaffold.razor`: Shared scaffold for release detail pages. Gained an optional `Ambient` `RenderFragment` slot (Phase 12) — a full-bleed layer rendered behind the main content. Absent slot = no regression. Cut mounts `<WaveformVisualizer>` + `<WaveformVisualizerControlPopover>` here; Mix uses its own full-bleed mount outside the scaffold.
|
||||
- `SharePopover.razor`: Share affordance serving both track and release surfaces from one clipboard/popover-chrome source. **Track mode** (`EntryKey` set): copies the track's canonical URL and offers an iframe embed snippet pointing at `FramePlayer?TrackEntryKey=…`. **Release mode** (`ReleaseEntryKey` + `ReleaseMedium` set): copies the release's canonical detail URL (via `ReleaseRoutes.DetailHref`) and offers an iframe embed snippet pointing at `FramePlayer?ReleaseEntryKey=…`, which queues and auto-advances through the release's tracks on first play. Both modes offer the embed affordance — release mode no longer suppresses it. The iframe snippet is built by `EmbedSnippetBuilder`. A transient "Copied!" confirmation resets after a short delay.
|
||||
- `Helpers/`: Utilities and mapper functions.
|
||||
- `PlaybackIcons.cs`: Static `Resolve(isPlaying, isPaused, trackId, currentTrackId)` method — the sole glyph-mapping source for transport icons across all surfaces. Returns `(Icon, IsActive, IsPaused)` tuple.
|
||||
- `RuntimeFormat.cs`: Static `ToHoursMinutes(double totalSeconds)` helper. Formats a seconds value as `h:mm` (hours not zero-padded, minutes always two digits). Negative / non-finite inputs return `"0:00"`. Used by `NowPlayingStats` for the mix runtime figure.
|
||||
- `EmbedSnippetBuilder.cs`: Static helper that builds the iframe embed snippet the share popover copies. `ForTrack(baseUri, trackEntryKey)` → `<iframe src="…FramePlayer?TrackEntryKey=…">` and `ForRelease(baseUri, releaseEntryKey)` → `<iframe src="…FramePlayer?ReleaseEntryKey=…">`. iframe chrome (dimensions, border-radius, autoplay permission) is identical across both targets and defined once here.
|
||||
- `Services/`: Audio player + dark-mode services.
|
||||
- `IPlayerService` / `IStreamingPlayerService`: Contracts exposed to UI.
|
||||
- `AudioPlayerService`: Abstract base (lifecycle, initialise, select track, play/pause/stop/seek/volume).
|
||||
@@ -43,6 +46,7 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
|
||||
- `AudioInteropService`: JS interop wrapper over `window.DeepDrftAudio`. Manages `DotNetObjectReference` lifetimes for progress, end-of-playback, spectrum callbacks.
|
||||
- Dark-mode services: `DarkModeServiceBase` (cookie name constant), `DarkModeCookieService` (JS cookie read/write).
|
||||
- `WaveformVisualizerControlState`: Scoped session-persistent holder for the visualizer's **eight** continuous control positions plus **two subsystem on/off toggles** (Phase 15): `ScrollSpeed`, `GradientRotationSpeed`, `LavaGravity`, `LavaHeat`, `FluidAmount` (wax count/volume), `FluidViscosity` (cohesion — the second half of the Phase 10 "bubbles" split; `BlobDensity` is gone), `CollisionStrength`, `WaveformWidth`, `LavaEnabled` (bool, default `true`), `WaveformEnabled` (bool, default `true`). Each has a matching `Default*` const. `Changed` event is the decoupling seam — controls mutate state + raise `Changed`; the bridge (`WaveformVisualizer`) subscribes and pushes the affected uniform or subsystem-enable. Scoped DI so state survives SPA nav within a session and resets on fresh page load.
|
||||
- `IQueueService` / `QueueService`: Ordered playback orchestrator above the single-slot player. `PlayRelease(tracks, startIndex)` replaces the queue and starts streaming; `Next`/`Previous` advance or step back; `Enqueue`/`EnqueueRange` append without interrupting the current track; `Clear` empties the queue. **Armed-idle state** added to support prerender-safe release embeds: `Arm(tracks)` loads the track list at index 0 with no JS interop (safe during prerender); `IsArmed` signals the armed-but-not-streaming state; `Start()` begins streaming the current track and clears `IsArmed`, leaving the list and position intact so auto-advance carries on. `AudioPlayerBar` reads `IsArmed` to route the first play gesture through `Start()` instead of streaming the staged track alone. `QueueChanged` event fires on all list/position changes; cascaded via `AudioPlayerProvider`.
|
||||
- `Clients/`: HTTP API clients (both target DeepDrftAPI).
|
||||
- `TrackClient`: SQL metadata API. Uses named `IHttpClientFactory` client `"DeepDrft.API"`. Sends `page` param (not `pageNumber`). Deserializes response as bare `PagedResult<TrackDto>` (not wrapped in ApiResultDto envelope).
|
||||
- `TrackMediaClient`: Content API. Uses named `IHttpClientFactory` client `"DeepDrft.Content"`. Methods like `GetAudioStreamAsync(trackId, byteOffset?)` → `Stream` with optional Range header support for seek-beyond-buffer.
|
||||
@@ -52,6 +56,7 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
|
||||
- `ViewModels/`: Component state.
|
||||
- `TracksViewModel`: Scoped. Holds current page, page size, sort column, descending flag. `SetPage(pageNumber)` calls `TrackClient.GetPageAsync` and updates. Registered in `Startup.ConfigureDomainServices`.
|
||||
- `TrackDetailViewModel`: Scoped. Holds loaded track, loading flag, not-found flag. `Load(entryKey)` fetches via `ITrackDataService` and resets all flags per call (prevents cross-navigation bleed). Registered in `Startup.ConfigureDomainServices`.
|
||||
- `FramePlayerViewModel`: Scoped. Resolves the ordered track list for a release embed (`FramePlayer?ReleaseEntryKey=…`). `Load(releaseEntryKey)` calls `IReleaseDataService.GetByEntryKey` → `release.Id` → `ITrackDataService.GetPage(sortColumn:"TrackNumber", releaseId:…)`, mirroring `CutDetailViewModel.Load` exactly so an embedded release queues the same ordered list the Cut detail page plays. Owns no playback or staging — `FramePlayer.razor` uses the loaded `Tracks` to stage and arm. Registered scoped in `Startup.ConfigureDomainServices`.
|
||||
- `Common/`: Shared utilities.
|
||||
- `DarkModeSettings.cs`: `[PersistentState]`-annotated class (single source of truth for dark mode in the client). Registered scoped.
|
||||
- `ReleaseRoutes.cs`: Static helper. `DetailHref(long id, ReleaseMedium)` returns the canonical public detail route for a release; consumed by Archive, AlbumsView, player bar, and TrackRedirect (11.B).
|
||||
|
||||
@@ -30,7 +30,7 @@ else
|
||||
<VolumeZone Volume="@Volume" VolumeChanged="@OnVolumeChange"/>
|
||||
|
||||
<div class="meta-zone">
|
||||
<TrackMetaLabel Track="CurrentTrack"/>
|
||||
<TrackMetaLabel Track="CurrentTrack" Fixed="Fixed"/>
|
||||
</div>
|
||||
|
||||
<PlayerSeekZone OnSeekStart="@OnSeekStart"
|
||||
|
||||
@@ -186,6 +186,16 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||
// the first play click — the user gesture the browser requires before audio can start.
|
||||
if (IsStaged)
|
||||
{
|
||||
// Release embed: the queue is armed with the whole release. Route the first gesture through
|
||||
// the queue so it takes over (streams track 0 and auto-advances) rather than streaming the
|
||||
// staged track in isolation. Single-track embeds leave the queue disarmed and fall through
|
||||
// to the direct stream below — unchanged.
|
||||
if (QueueService is { IsArmed: true })
|
||||
{
|
||||
await QueueService.Start();
|
||||
return;
|
||||
}
|
||||
|
||||
await PlayerService.SelectTrackStreaming(PlayerService.CurrentTrack!);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
@using DeepDrftPublic.Client.Controls
|
||||
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
@if (!Fixed)
|
||||
@if (!Fixed || HasPrevious || HasNext)
|
||||
{
|
||||
<MudIconButton Icon="@Icons.Material.Filled.SkipPrevious"
|
||||
Color="Color.Primary"
|
||||
@@ -14,13 +14,16 @@
|
||||
Color="Color.Primary"
|
||||
Disabled="!CanPlay"
|
||||
OnToggle="@TogglePlayPause"/>
|
||||
@if (!Fixed)
|
||||
@if (!Fixed || HasPrevious || HasNext)
|
||||
{
|
||||
<MudIconButton Icon="@Icons.Material.Filled.SkipNext"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Large"
|
||||
OnClick="@SkipNext"
|
||||
Disabled="!HasNext"/>
|
||||
}
|
||||
@if (!Fixed)
|
||||
{
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Stop"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Large"
|
||||
|
||||
@@ -8,14 +8,26 @@
|
||||
<div class="track-meta-identity">
|
||||
@* Title links to the release's dedicated detail page via the shared resolver (§2): the
|
||||
TrackDto already carries Release { Id, Medium }, so no round-trip is needed. When no
|
||||
release is attached there is no medium to resolve, so the title renders unlinked. *@
|
||||
release is attached there is no medium to resolve, so the title renders unlinked.
|
||||
When Fixed (embedded iframe), the link opens in a new tab so the iframe keeps playing. *@
|
||||
@if (Track.Release is not null)
|
||||
{
|
||||
<a href="@ReleaseRoutes.DetailHref(Track.Release)" style="text-decoration: none;">
|
||||
<MudText Typo="Typo.subtitle2" Class="track-meta-title text-truncate">
|
||||
@Track.TrackName
|
||||
</MudText>
|
||||
</a>
|
||||
@if (Fixed)
|
||||
{
|
||||
<a href="@ReleaseRoutes.DetailHref(Track.Release)" target="_blank" rel="noopener noreferrer" style="text-decoration: none;">
|
||||
<MudText Typo="Typo.subtitle2" Class="track-meta-title text-truncate">
|
||||
@Track.TrackName
|
||||
</MudText>
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a href="@ReleaseRoutes.DetailHref(Track.Release)" style="text-decoration: none;">
|
||||
<MudText Typo="Typo.subtitle2" Class="track-meta-title text-truncate">
|
||||
@Track.TrackName
|
||||
</MudText>
|
||||
</a>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -11,4 +11,5 @@ namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
|
||||
public partial class TrackMetaLabel : ComponentBase
|
||||
{
|
||||
[Parameter] public TrackDto? Track { get; set; }
|
||||
[Parameter] public bool Fixed { get; set; }
|
||||
}
|
||||
|
||||
@@ -42,35 +42,33 @@
|
||||
}
|
||||
</MudStack>
|
||||
|
||||
@* Embed is a single-track affordance only; a release page is not a single-track embed (§3b.3). *@
|
||||
@if (!IsReleaseMode)
|
||||
@* Embed is offered in both modes: a track snippet (TrackEntryKey) or a whole-release
|
||||
snippet (ReleaseEntryKey) targeting FramePlayer's matching query param. *@
|
||||
<MudDivider />
|
||||
|
||||
<MudCheckBox @bind-Value="Embed" Color="Color.Primary" Label="Embed player" Dense="true" />
|
||||
|
||||
@if (_embed)
|
||||
{
|
||||
<MudDivider />
|
||||
|
||||
<MudCheckBox @bind-Value="Embed" Color="Color.Primary" Label="Embed player" Dense="true" />
|
||||
|
||||
@if (_embed)
|
||||
{
|
||||
<MudStack Row AlignItems="AlignItems.Center" Spacing="1">
|
||||
<MudTextField Value="@EmbedSnippet"
|
||||
T="string"
|
||||
ReadOnly="true"
|
||||
Variant="Variant.Outlined"
|
||||
Lines="3"
|
||||
Margin="Margin.Dense"
|
||||
Class="deepdrft-share-embed-field" />
|
||||
<MudStack AlignItems="AlignItems.Center" Spacing="0">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ContentCopy"
|
||||
Color="Color.Primary"
|
||||
OnClick="@CopyEmbed"
|
||||
aria-label="Copy embed snippet" />
|
||||
@if (_embedCopied)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Success">Copied!</MudText>
|
||||
}
|
||||
</MudStack>
|
||||
<MudStack Row AlignItems="AlignItems.Center" Spacing="1">
|
||||
<MudTextField Value="@EmbedSnippet"
|
||||
T="string"
|
||||
ReadOnly="true"
|
||||
Variant="Variant.Outlined"
|
||||
Lines="3"
|
||||
Margin="Margin.Dense"
|
||||
Class="deepdrft-share-embed-field" />
|
||||
<MudStack AlignItems="AlignItems.Center" Spacing="0">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ContentCopy"
|
||||
Color="Color.Primary"
|
||||
OnClick="@CopyEmbed"
|
||||
aria-label="Copy embed snippet" />
|
||||
@if (_embedCopied)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Success">Copied!</MudText>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
</MudStack>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using DeepDrftModels.Enums;
|
||||
using DeepDrftPublic.Client.Common;
|
||||
using DeepDrftPublic.Client.Helpers;
|
||||
using DeepDrftPublic.Client.Services;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.JSInterop;
|
||||
@@ -8,19 +9,19 @@ namespace DeepDrftPublic.Client.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// Share affordance with two modes from one source of clipboard/popover-chrome logic
|
||||
/// (Phase 11 §3b). Track mode (<see cref="EntryKey"/> set) offers a canonical-link copy plus an
|
||||
/// optional iframe embed snippet. Release mode (<see cref="ReleaseEntryKey"/> set) is copy-link-only —
|
||||
/// it copies the absolute form of the release's canonical detail URL and hides the embed
|
||||
/// affordance, since a release page is not a single-track embed. Clipboard writes go through
|
||||
/// navigator.clipboard; each copy shows a transient "Copied!" confirmation that resets after a
|
||||
/// short delay.
|
||||
/// (Phase 11 §3b). Both modes offer a canonical-link copy plus an optional iframe embed snippet.
|
||||
/// Track mode (<see cref="EntryKey"/> set) embeds a single track (FramePlayer?TrackEntryKey=...);
|
||||
/// release mode (<see cref="ReleaseEntryKey"/> set) copies the release's canonical detail URL and
|
||||
/// embeds the whole release (FramePlayer?ReleaseEntryKey=...), which queues and advances through its
|
||||
/// tracks on first play. Clipboard writes go through navigator.clipboard; each copy shows a transient
|
||||
/// "Copied!" confirmation that resets after a short delay.
|
||||
/// </summary>
|
||||
public partial class SharePopover : ComponentBase, IDisposable
|
||||
{
|
||||
/// <summary>Track mode: the vault entry key of the track to share. Mutually exclusive with the release target.</summary>
|
||||
[Parameter] public string? EntryKey { get; set; }
|
||||
|
||||
/// <summary>Release mode: the release's opaque public EntryKey to share. When set (with <see cref="ReleaseMedium"/>), the popover shares the release detail URL and omits the embed option.</summary>
|
||||
/// <summary>Release mode: the release's opaque public EntryKey to share. When set (with <see cref="ReleaseMedium"/>), the popover shares the release detail URL and embeds the whole release.</summary>
|
||||
[Parameter] public string? ReleaseEntryKey { get; set; }
|
||||
|
||||
/// <summary>Release mode: the medium of the release, used to resolve its canonical detail route.</summary>
|
||||
@@ -58,8 +59,11 @@ public partial class SharePopover : ComponentBase, IDisposable
|
||||
|
||||
private string TrackUrl => $"{Navigation.BaseUri}tracks/{EntryKey}";
|
||||
|
||||
private string EmbedSnippet =>
|
||||
$"""<iframe src="{Navigation.BaseUri}FramePlayer?TrackEntryKey={EntryKey}" width="656" height="196" frameborder="0" style="border-radius:8px;" allow="autoplay"></iframe>""";
|
||||
// FramePlayer's query param selects the embed mode: ReleaseEntryKey queues the whole release,
|
||||
// TrackEntryKey stages a single track. The iframe chrome is identical in both modes.
|
||||
private string EmbedSnippet => IsReleaseMode
|
||||
? EmbedSnippetBuilder.ForRelease(Navigation.BaseUri, ReleaseEntryKey!)
|
||||
: EmbedSnippetBuilder.ForTrack(Navigation.BaseUri, EntryKey!);
|
||||
|
||||
private void Toggle() => _open = !_open;
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
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.
|
||||
/// Pure string composition so the snippet shape is unit-testable without rendering the component.
|
||||
/// </summary>
|
||||
public static class EmbedSnippetBuilder
|
||||
{
|
||||
// baseUri carries a trailing slash (NavigationManager.BaseUri), so "FramePlayer" appends cleanly.
|
||||
public static string ForTrack(string baseUri, string trackEntryKey)
|
||||
=> Frame($"{baseUri}FramePlayer?TrackEntryKey={trackEntryKey}");
|
||||
|
||||
public static string ForRelease(string baseUri, string releaseEntryKey)
|
||||
=> Frame($"{baseUri}FramePlayer?ReleaseEntryKey={releaseEntryKey}");
|
||||
|
||||
private static string Frame(string src)
|
||||
=> $"""<iframe src="{src}" width="656" height="196" frameborder="0" style="border-radius:8px;" allow="autoplay"></iframe>""";
|
||||
}
|
||||
@@ -138,6 +138,7 @@ else
|
||||
OnToggle="@(() => PlayTrack(track, index))" />
|
||||
</div>
|
||||
<span class="cut-detail-track-name text-truncate">@track.TrackName</span>
|
||||
<SharePopover EntryKey="@track.EntryKey" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
@using DeepDrftPublic.Client.Controls.AudioPlayerBar
|
||||
@using DeepDrftPublic.Client.Layout
|
||||
@using DeepDrftPublic.Client.Services
|
||||
@using DeepDrftPublic.Client.ViewModels
|
||||
|
||||
@page "/FramePlayer"
|
||||
@layout EmbedLayout
|
||||
@@ -10,29 +11,65 @@
|
||||
|
||||
@code {
|
||||
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
|
||||
[CascadingParameter] public IQueueService? Queue { get; set; }
|
||||
|
||||
// Two mutually-exclusive embed targets. ReleaseEntryKey wins if both are somehow supplied — a
|
||||
// release embed is the richer surface, and the single-track path would otherwise mask it.
|
||||
[SupplyParameterFromQuery] public string? ReleaseEntryKey { get; set; }
|
||||
[SupplyParameterFromQuery] public string? TrackEntryKey { get; set; }
|
||||
|
||||
[Inject] public required ITrackDataService TrackDataService { get; set; }
|
||||
[Inject] public required FramePlayerViewModel ViewModel { get; set; }
|
||||
|
||||
private string? _stagedKey;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (PlayerService is null || string.IsNullOrWhiteSpace(TrackEntryKey)) return;
|
||||
if (PlayerService is null) return;
|
||||
|
||||
// OnParametersSetAsync can fire repeatedly (and once per render pass); only act when the
|
||||
// key actually changes so we don't re-fetch on every parameter set.
|
||||
if (TrackEntryKey == _stagedKey) return;
|
||||
_stagedKey = TrackEntryKey;
|
||||
if (!string.IsNullOrWhiteSpace(ReleaseEntryKey))
|
||||
{
|
||||
await StageRelease(ReleaseEntryKey);
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(TrackEntryKey))
|
||||
{
|
||||
await StageSingleTrack(TrackEntryKey);
|
||||
}
|
||||
}
|
||||
|
||||
var result = await TrackDataService.GetTrack(TrackEntryKey);
|
||||
// Release embed: resolve the release's ordered tracks, stage the first so the bar shows the release
|
||||
// ready, and arm the queue with the whole list. No JS interop here (StageTrack and Arm are both
|
||||
// interop-free), so this runs identically during prerender and after WASM boot. The first play
|
||||
// click (handled in AudioPlayerBar) routes through Queue.PlayRelease, queueing the full release.
|
||||
private async Task StageRelease(string releaseEntryKey)
|
||||
{
|
||||
// OnParametersSetAsync can fire repeatedly; only act when the key actually changes.
|
||||
if (releaseEntryKey == _stagedKey) return;
|
||||
_stagedKey = releaseEntryKey;
|
||||
|
||||
await ViewModel.Load(releaseEntryKey);
|
||||
if (ViewModel.Tracks.Count == 0) return; // No tracks: leave the bar idle.
|
||||
|
||||
await PlayerService!.StageTrack(ViewModel.Tracks[0]);
|
||||
Queue?.Arm(ViewModel.Tracks);
|
||||
}
|
||||
|
||||
// Single-track embed: unchanged behaviour — stage exactly the requested track. The first play
|
||||
// click streams it directly (the queue stays empty/disarmed).
|
||||
private async Task StageSingleTrack(string trackEntryKey)
|
||||
{
|
||||
if (trackEntryKey == _stagedKey) return;
|
||||
_stagedKey = trackEntryKey;
|
||||
|
||||
var result = await TrackDataService.GetTrack(trackEntryKey);
|
||||
if (result.Success && result.Value is not null)
|
||||
{
|
||||
// Stage only — no audio context, no streaming. The browser blocks audio until a user
|
||||
// gesture, so the embed shows the track ready and the first play click (handled in
|
||||
// AudioPlayerBar) calls SelectTrackStreaming. This also keeps this pass free of JS
|
||||
// interop, so it works whether it runs during prerender or after WASM is interactive.
|
||||
await PlayerService.StageTrack(result.Value);
|
||||
// gesture, so the embed shows the track ready and the first play click calls
|
||||
// SelectTrackStreaming. This keeps this pass free of JS interop, so it works whether it
|
||||
// runs during prerender or after WASM is interactive.
|
||||
await PlayerService!.StageTrack(result.Value);
|
||||
}
|
||||
// On failure, leave the bar idle; a stream-level error surfaces via PlayerService.ErrorMessage.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,15 @@ public interface IQueueService
|
||||
/// <summary>The current track, or null when the queue is empty.</summary>
|
||||
TrackDto? Current { get; }
|
||||
|
||||
/// <summary>
|
||||
/// True when the queue has been loaded via <see cref="Arm"/> but no track has streamed yet —
|
||||
/// the embed's pre-gesture state. Set by <see cref="Arm"/>; cleared the moment playback actually
|
||||
/// starts (<see cref="Start"/>/<see cref="PlayRelease"/>/<see cref="Next"/>/<see cref="Previous"/>)
|
||||
/// or on <see cref="Clear"/>. The player bar reads this to route the first play gesture through
|
||||
/// <see cref="Start"/> (which begins the armed release) rather than streaming the staged track alone.
|
||||
/// </summary>
|
||||
bool IsArmed { get; }
|
||||
|
||||
/// <summary>True when there is a track after <see cref="CurrentIndex"/> to advance to.</summary>
|
||||
bool HasNext { get; }
|
||||
|
||||
@@ -59,6 +68,25 @@ public interface IQueueService
|
||||
/// </summary>
|
||||
Task PlayRelease(IEnumerable<TrackDto> tracks, int startIndex = 0);
|
||||
|
||||
/// <summary>
|
||||
/// Loads <paramref name="tracks"/> as the queue and sets the current position to index 0 WITHOUT
|
||||
/// streaming anything — the queue is "armed". This is the embed's prerender-safe entry point: it
|
||||
/// performs no JS interop, so it runs identically during prerender and after WASM boot. The first
|
||||
/// play gesture (see <see cref="IsArmed"/>) then starts playback via <see cref="Start"/>, which
|
||||
/// keeps the loaded release queued so it advances through its tracks. No-op when
|
||||
/// <paramref name="tracks"/> is empty (the queue stays empty and disarmed).
|
||||
/// </summary>
|
||||
void Arm(IEnumerable<TrackDto> tracks);
|
||||
|
||||
/// <summary>
|
||||
/// Begins playback of an armed queue (see <see cref="Arm"/>): streams the current track and clears
|
||||
/// <see cref="IsArmed"/>, leaving the loaded list and position intact so auto-advance carries on
|
||||
/// through the release. This is the first-gesture entry point the embed bar calls. No-op (and stays
|
||||
/// disarmed) when the queue is not armed or is empty — so it never double-streams or disturbs a
|
||||
/// queue already playing.
|
||||
/// </summary>
|
||||
Task Start();
|
||||
|
||||
/// <summary>Appends a track to the end of the queue without changing what is currently playing.</summary>
|
||||
void Enqueue(TrackDto track);
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ public sealed class QueueService : IQueueService, IDisposable
|
||||
|
||||
public int CurrentIndex { get; private set; } = -1;
|
||||
|
||||
public bool IsArmed { get; private set; }
|
||||
|
||||
public TrackDto? Current =>
|
||||
CurrentIndex >= 0 && CurrentIndex < _items.Count ? _items[CurrentIndex] : null;
|
||||
|
||||
@@ -62,11 +64,34 @@ public sealed class QueueService : IQueueService, IDisposable
|
||||
_items.Clear();
|
||||
_items.AddRange(list);
|
||||
CurrentIndex = start;
|
||||
// Playback is now starting for real, so the queue is no longer merely armed.
|
||||
IsArmed = false;
|
||||
QueueChanged?.Invoke();
|
||||
|
||||
await PlayCurrent();
|
||||
}
|
||||
|
||||
public void Arm(IEnumerable<TrackDto> tracks)
|
||||
{
|
||||
var list = tracks as IReadOnlyList<TrackDto> ?? tracks.ToList();
|
||||
if (list.Count == 0) return;
|
||||
|
||||
_items.Clear();
|
||||
_items.AddRange(list);
|
||||
CurrentIndex = 0;
|
||||
IsArmed = true;
|
||||
// No PlayCurrent: arming is interop-free state only. The first play gesture drives Start().
|
||||
QueueChanged?.Invoke();
|
||||
}
|
||||
|
||||
public async Task Start()
|
||||
{
|
||||
if (!IsArmed) return;
|
||||
IsArmed = false;
|
||||
QueueChanged?.Invoke();
|
||||
await PlayCurrent();
|
||||
}
|
||||
|
||||
public void Enqueue(TrackDto track)
|
||||
{
|
||||
_items.Add(track);
|
||||
@@ -85,6 +110,7 @@ public sealed class QueueService : IQueueService, IDisposable
|
||||
{
|
||||
if (!HasNext) return;
|
||||
CurrentIndex++;
|
||||
IsArmed = false;
|
||||
QueueChanged?.Invoke();
|
||||
await PlayCurrent();
|
||||
}
|
||||
@@ -93,6 +119,7 @@ public sealed class QueueService : IQueueService, IDisposable
|
||||
{
|
||||
if (!HasPrevious) return;
|
||||
CurrentIndex--;
|
||||
IsArmed = false;
|
||||
QueueChanged?.Invoke();
|
||||
await PlayCurrent();
|
||||
}
|
||||
@@ -102,6 +129,7 @@ public sealed class QueueService : IQueueService, IDisposable
|
||||
if (_items.Count == 0 && CurrentIndex == -1) return;
|
||||
_items.Clear();
|
||||
CurrentIndex = -1;
|
||||
IsArmed = false;
|
||||
QueueChanged?.Invoke();
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ public static class Startup
|
||||
services.AddScoped<IReleaseDataService, ReleaseClientDataService>();
|
||||
services.AddScoped<ReleaseDetailViewModel>();
|
||||
services.AddScoped<CutDetailViewModel>();
|
||||
services.AddScoped<FramePlayerViewModel>();
|
||||
|
||||
// Home hero stats read surface — same HTTP posture as the track/release clients.
|
||||
services.AddScoped<StatsClient>();
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftPublic.Client.Services;
|
||||
|
||||
namespace DeepDrftPublic.Client.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the ordered track list for a release embed (<c>FramePlayer?ReleaseEntryKey=...</c>). Mirrors
|
||||
/// <see cref="CutDetailViewModel"/>'s release-to-tracks resolution exactly (GetByEntryKey -> release.Id
|
||||
/// -> releaseId-filtered track page sorted by TrackNumber) so an embedded release queues the same
|
||||
/// ordered list the Cut detail page plays. Owns no playback or staging — the page stages the first
|
||||
/// track and arms the queue; this VM only fetches. Scoped; <see cref="Tracks"/> is reset per
|
||||
/// <see cref="Load"/> so a reused instance never bleeds across embeds.
|
||||
/// </summary>
|
||||
public class FramePlayerViewModel
|
||||
{
|
||||
private readonly IReleaseDataService _releaseData;
|
||||
private readonly ITrackDataService _trackData;
|
||||
|
||||
// One page covers the whole release; the API caps PageSize at 100 regardless. Matches CutDetailViewModel.
|
||||
private const int ReleasePageSize = 100;
|
||||
|
||||
public IReadOnlyList<TrackDto> Tracks { get; private set; } = [];
|
||||
|
||||
public FramePlayerViewModel(IReleaseDataService releaseData, ITrackDataService trackData)
|
||||
{
|
||||
_releaseData = releaseData;
|
||||
_trackData = trackData;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves <paramref name="releaseEntryKey"/> to its ordered tracks. Leaves <see cref="Tracks"/>
|
||||
/// empty when the release is not found or has no streamable tracks — the caller leaves the bar idle.
|
||||
/// </summary>
|
||||
public async Task Load(string releaseEntryKey)
|
||||
{
|
||||
Tracks = [];
|
||||
|
||||
var releaseResult = await _releaseData.GetByEntryKey(releaseEntryKey);
|
||||
if (releaseResult is not { Success: true, Value: { } release }) return;
|
||||
|
||||
// The release's tracks via the releaseId-filtered page — the exact join the Cut page uses
|
||||
// (internal int FK, not a title string), sorted by the explicit TrackNumber ordinal so the
|
||||
// queue advances in saved order.
|
||||
var trackResult = await _trackData.GetPage(
|
||||
pageNumber: 1,
|
||||
pageSize: ReleasePageSize,
|
||||
sortColumn: "TrackNumber",
|
||||
releaseId: release.Id);
|
||||
if (trackResult is { Success: true, Value: { Items: { } items } })
|
||||
Tracks = items.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using DeepDrftPublic.Client.Helpers;
|
||||
|
||||
namespace DeepDrftTests;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// mode targets its ReleaseEntryKey param. The iframe chrome (dimensions, autoplay) must be identical
|
||||
/// across both. Pure string composition, tested directly without rendering the component.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class EmbedSnippetBuilderTests
|
||||
{
|
||||
private const string BaseUri = "https://deepdrft.example/";
|
||||
|
||||
[Test]
|
||||
public void ForTrack_EmitsTrackEntryKeySrc()
|
||||
{
|
||||
var snippet = EmbedSnippetBuilder.ForTrack(BaseUri, "abc123");
|
||||
|
||||
Assert.That(snippet, Does.Contain(@"src=""https://deepdrft.example/FramePlayer?TrackEntryKey=abc123"""));
|
||||
Assert.That(snippet, Does.Not.Contain("ReleaseEntryKey"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ForRelease_EmitsReleaseEntryKeySrc()
|
||||
{
|
||||
var snippet = EmbedSnippetBuilder.ForRelease(BaseUri, "rel-xyz");
|
||||
|
||||
Assert.That(snippet, Does.Contain(@"src=""https://deepdrft.example/FramePlayer?ReleaseEntryKey=rel-xyz"""));
|
||||
Assert.That(snippet, Does.Not.Contain("TrackEntryKey"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BothModes_ShareIdenticalIframeChrome()
|
||||
{
|
||||
var track = EmbedSnippetBuilder.ForTrack(BaseUri, "k");
|
||||
var release = EmbedSnippetBuilder.ForRelease(BaseUri, "k");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
foreach (var snippet in new[] { track, release })
|
||||
{
|
||||
Assert.That(snippet, Does.StartWith("<iframe "));
|
||||
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(@"style=""border-radius:8px;"""));
|
||||
Assert.That(snippet, Does.Contain(@"allow=""autoplay"""));
|
||||
Assert.That(snippet, Does.EndWith("></iframe>"));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftPublic.Client.Services;
|
||||
using DeepDrftPublic.Client.ViewModels;
|
||||
using Models.Common;
|
||||
using NetBlocks.Models;
|
||||
|
||||
namespace DeepDrftTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the release-embed track resolution (<see cref="FramePlayerViewModel"/>). The VM is
|
||||
/// the data half of FramePlayer's release path: resolve a release EntryKey to its ordered track list,
|
||||
/// which the page then stages (track 0) and arms into the queue. It is pure async logic over the two
|
||||
/// data-service seams, so it is exercised here against recording fakes — no browser, no JS, no HTTP.
|
||||
/// Coverage: ordered non-empty resolution, single-track release, release-not-found, and a release
|
||||
/// that resolves to no tracks (the "leave the bar idle, don't throw" path).
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class FramePlayerReleaseResolutionTests
|
||||
{
|
||||
private FakeReleaseData _releaseData = null!;
|
||||
private FakeTrackData _trackData = null!;
|
||||
private FramePlayerViewModel _vm = null!;
|
||||
|
||||
private const string ReleaseKey = "release-key-1";
|
||||
private const long ReleaseId = 42;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_releaseData = new FakeReleaseData();
|
||||
_trackData = new FakeTrackData();
|
||||
_vm = new FramePlayerViewModel(_releaseData, _trackData);
|
||||
}
|
||||
|
||||
private static ReleaseDto Release() => new() { Id = ReleaseId, EntryKey = ReleaseKey, Title = "Album" };
|
||||
|
||||
private static List<TrackDto> Tracks(int count) =>
|
||||
Enumerable.Range(1, count)
|
||||
.Select(i => new TrackDto { EntryKey = $"track-{i}", TrackName = $"Track {i}", TrackNumber = i })
|
||||
.ToList();
|
||||
|
||||
[Test]
|
||||
public async Task Load_ResolvesOrderedNonEmptyTrackList_AndQueriesByResolvedReleaseId()
|
||||
{
|
||||
_releaseData.Release = Release();
|
||||
_trackData.Page = Tracks(3);
|
||||
|
||||
await _vm.Load(ReleaseKey);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_vm.Tracks.Select(t => t.EntryKey),
|
||||
Is.EqualTo(new[] { "track-1", "track-2", "track-3" }));
|
||||
// The track page must be narrowed by the resolved release.Id (the int FK join), sorted by
|
||||
// the explicit TrackNumber ordinal — the same resolution the Cut detail page uses.
|
||||
Assert.That(_trackData.LastReleaseId, Is.EqualTo(ReleaseId));
|
||||
Assert.That(_trackData.LastSortColumn, Is.EqualTo("TrackNumber"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Load_WithSingleTrackRelease_ResolvesAOneItemList()
|
||||
{
|
||||
_releaseData.Release = Release();
|
||||
_trackData.Page = Tracks(1);
|
||||
|
||||
await _vm.Load(ReleaseKey);
|
||||
|
||||
Assert.That(_vm.Tracks, Has.Count.EqualTo(1));
|
||||
Assert.That(_vm.Tracks[0].EntryKey, Is.EqualTo("track-1"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Load_WhenReleaseNotFound_LeavesTracksEmptyAndDoesNotQueryTracks()
|
||||
{
|
||||
_releaseData.Release = null; // GetByEntryKey returns a fail result.
|
||||
|
||||
await _vm.Load(ReleaseKey);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_vm.Tracks, Is.Empty);
|
||||
Assert.That(_trackData.WasQueried, Is.False);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Load_WhenReleaseResolvesToNoTracks_LeavesTracksEmptyWithoutThrowing()
|
||||
{
|
||||
_releaseData.Release = Release();
|
||||
_trackData.Page = new List<TrackDto>(); // empty page
|
||||
|
||||
await _vm.Load(ReleaseKey);
|
||||
|
||||
Assert.That(_vm.Tracks, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Load_ResetsTracksFromAPriorLoad()
|
||||
{
|
||||
_releaseData.Release = Release();
|
||||
_trackData.Page = Tracks(3);
|
||||
await _vm.Load(ReleaseKey);
|
||||
Assert.That(_vm.Tracks, Has.Count.EqualTo(3));
|
||||
|
||||
// A second load whose release is not found must not retain the prior album's tracks.
|
||||
_releaseData.Release = null;
|
||||
await _vm.Load("another-key");
|
||||
|
||||
Assert.That(_vm.Tracks, Is.Empty);
|
||||
}
|
||||
|
||||
private sealed class FakeReleaseData : IReleaseDataService
|
||||
{
|
||||
public ReleaseDto? Release { get; set; }
|
||||
|
||||
public Task<ApiResult<ReleaseDto>> GetByEntryKey(string entryKey)
|
||||
=> Task.FromResult(Release is null
|
||||
? ApiResult<ReleaseDto>.CreateFailResult("not found")
|
||||
: ApiResult<ReleaseDto>.CreatePassResult(Release));
|
||||
|
||||
public Task<ApiResult<PagedResult<ReleaseDto>>> GetPaged(
|
||||
string? medium, int page, int pageSize, string? sortColumn = null,
|
||||
bool sortDescending = false, string? search = null, string? genre = null)
|
||||
=> throw new NotSupportedException("FramePlayerViewModel does not page releases.");
|
||||
}
|
||||
|
||||
private sealed class FakeTrackData : ITrackDataService
|
||||
{
|
||||
public List<TrackDto> Page { get; set; } = new();
|
||||
public bool WasQueried { get; private set; }
|
||||
public long? LastReleaseId { get; private set; }
|
||||
public string? LastSortColumn { get; private set; }
|
||||
|
||||
public Task<ApiResult<PagedResult<TrackDto>>> GetPage(
|
||||
int pageNumber, int pageSize, string? sortColumn = null, bool sortDescending = false,
|
||||
string? searchText = null, string? album = null, string? genre = null, long? releaseId = null)
|
||||
{
|
||||
WasQueried = true;
|
||||
LastReleaseId = releaseId;
|
||||
LastSortColumn = sortColumn;
|
||||
var paged = new PagedResult<TrackDto>
|
||||
{
|
||||
Items = Page,
|
||||
TotalCount = Page.Count,
|
||||
Page = pageNumber,
|
||||
PageSize = pageSize,
|
||||
};
|
||||
return Task.FromResult(ApiResult<PagedResult<TrackDto>>.CreatePassResult(paged));
|
||||
}
|
||||
|
||||
// Inert remainder — FramePlayerViewModel only calls GetPage.
|
||||
public Task<ApiResult<List<ReleaseDto>>> GetAlbums() => throw new NotSupportedException();
|
||||
public Task<ApiResult<List<GenreSummaryDto>>> GetGenres() => throw new NotSupportedException();
|
||||
public Task<ApiResult<TrackDto>> GetTrack(string trackId) => throw new NotSupportedException();
|
||||
public Task<ApiResult<WaveformProfileDto?>> GetTrackWaveform(string trackEntryKey) => throw new NotSupportedException();
|
||||
public Task<ApiResult<TrackDto?>> GetRandomTrack() => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -144,6 +144,127 @@ public class QueueServiceTests
|
||||
});
|
||||
}
|
||||
|
||||
// --- Arm: prerender-safe load without streaming (release embed) ---
|
||||
|
||||
[Test]
|
||||
public void Arm_LoadsTracksAtIndexZero_WithoutStreaming()
|
||||
{
|
||||
var tracks = Tracks(3);
|
||||
|
||||
_queue.Arm(tracks);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.Items.Select(t => t.EntryKey),
|
||||
Is.EqualTo(new[] { "track-1", "track-2", "track-3" }));
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
|
||||
Assert.That(_queue.IsArmed, Is.True);
|
||||
// Arming is interop-free state only: nothing must have been streamed yet.
|
||||
Assert.That(_player.SelectedTracks, Is.Empty);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Arm_WithSingleTrackRelease_ArmsAOneItemQueueWithoutError()
|
||||
{
|
||||
_queue.Arm(Tracks(1));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.Items, Has.Count.EqualTo(1));
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
|
||||
Assert.That(_queue.IsArmed, Is.True);
|
||||
Assert.That(_queue.HasNext, Is.False);
|
||||
Assert.That(_player.SelectedTracks, Is.Empty);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Arm_WithEmptyTracks_IsNoOpAndLeavesQueueDisarmed()
|
||||
{
|
||||
_queue.Arm(Enumerable.Empty<TrackDto>());
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.Items, Is.Empty);
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(-1));
|
||||
Assert.That(_queue.IsArmed, Is.False);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Start_OnArmedQueue_StreamsTrackZeroAndDisarms()
|
||||
{
|
||||
// Models the embed's first-gesture path: FramePlayer arms the queue (no stream), then the
|
||||
// play click routes through Start().
|
||||
_queue.Arm(Tracks(3));
|
||||
|
||||
await _queue.Start();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
|
||||
Assert.That(_queue.IsArmed, Is.False);
|
||||
Assert.That(_player.SelectedTracks.Single().EntryKey, Is.EqualTo("track-1"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Start_OnUnarmedQueue_IsNoOp()
|
||||
{
|
||||
// A non-embed flow (PlayRelease already streaming) must not be disturbed by a stray Start.
|
||||
await _queue.PlayRelease(Tracks(3));
|
||||
var streamedBefore = _player.SelectedTracks.Count;
|
||||
|
||||
await _queue.Start();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.IsArmed, Is.False);
|
||||
Assert.That(_player.SelectedTracks, Has.Count.EqualTo(streamedBefore),
|
||||
"Start on an unarmed queue must not re-stream");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ArmedQueue_StartedThenAdvancesThroughWholeReleaseOnTrackEnded()
|
||||
{
|
||||
_queue.Arm(Tracks(3));
|
||||
await _queue.Start();
|
||||
|
||||
_player.RaiseTrackEnded(); // → track-2
|
||||
_player.RaiseTrackEnded(); // → track-3
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(2));
|
||||
Assert.That(_player.SelectedTracks.Select(t => t.EntryKey),
|
||||
Is.EqualTo(new[] { "track-1", "track-2", "track-3" }));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Arm_RaisesQueueChanged()
|
||||
{
|
||||
var raised = false;
|
||||
_queue.QueueChanged += () => raised = true;
|
||||
|
||||
_queue.Arm(Tracks(2));
|
||||
|
||||
Assert.That(raised, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Clear_DisarmsAnArmedQueue()
|
||||
{
|
||||
_queue.Arm(Tracks(2));
|
||||
|
||||
_queue.Clear();
|
||||
|
||||
Assert.That(_queue.IsArmed, Is.False);
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
// --- Next / Previous mechanics and bounds ---
|
||||
|
||||
[Test]
|
||||
|
||||
Reference in New Issue
Block a user