diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.cs b/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.cs
index d21f610..f69963a 100644
--- a/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.cs
+++ b/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.cs
@@ -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;
}
diff --git a/DeepDrftPublic.Client/Controls/SharePopover.razor b/DeepDrftPublic.Client/Controls/SharePopover.razor
index aee3780..2a7429c 100644
--- a/DeepDrftPublic.Client/Controls/SharePopover.razor
+++ b/DeepDrftPublic.Client/Controls/SharePopover.razor
@@ -42,35 +42,33 @@
}
- @* 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. *@
+
+
+
+
+ @if (_embed)
{
-
-
-
-
- @if (_embed)
- {
-
-
-
-
- @if (_embedCopied)
- {
- Copied!
- }
-
+
+
+
+
+ @if (_embedCopied)
+ {
+ Copied!
+ }
- }
+
}
diff --git a/DeepDrftPublic.Client/Controls/SharePopover.razor.cs b/DeepDrftPublic.Client/Controls/SharePopover.razor.cs
index 84f1912..85b24f3 100644
--- a/DeepDrftPublic.Client/Controls/SharePopover.razor.cs
+++ b/DeepDrftPublic.Client/Controls/SharePopover.razor.cs
@@ -1,5 +1,6 @@
using DeepDrftModels.Enums;
using DeepDrftPublic.Client.Common;
+using DeepDrftPublic.Client.Helpers;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
@@ -7,19 +8,19 @@ namespace DeepDrftPublic.Client.Controls;
///
/// Share affordance with two modes from one source of clipboard/popover-chrome logic
-/// (Phase 11 §3b). Track mode ( set) offers a canonical-link copy plus an
-/// optional iframe embed snippet. Release mode ( 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 ( set) embeds a single track (FramePlayer?TrackEntryKey=...);
+/// release mode ( 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.
///
public partial class SharePopover : ComponentBase, IDisposable
{
/// Track mode: the vault entry key of the track to share. Mutually exclusive with the release target.
[Parameter] public string? EntryKey { get; set; }
- /// Release mode: the release's opaque public EntryKey to share. When set (with ), the popover shares the release detail URL and omits the embed option.
+ /// Release mode: the release's opaque public EntryKey to share. When set (with ), the popover shares the release detail URL and embeds the whole release.
[Parameter] public string? ReleaseEntryKey { get; set; }
/// Release mode: the medium of the release, used to resolve its canonical detail route.
@@ -56,8 +57,11 @@ public partial class SharePopover : ComponentBase, IDisposable
private string TrackUrl => $"{Navigation.BaseUri}tracks/{EntryKey}";
- private string EmbedSnippet =>
- $"""""";
+ // 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;
diff --git a/DeepDrftPublic.Client/Helpers/EmbedSnippetBuilder.cs b/DeepDrftPublic.Client/Helpers/EmbedSnippetBuilder.cs
new file mode 100644
index 0000000..178395c
--- /dev/null
+++ b/DeepDrftPublic.Client/Helpers/EmbedSnippetBuilder.cs
@@ -0,0 +1,21 @@
+namespace DeepDrftPublic.Client.Helpers;
+
+///
+/// Builds the iframe embed snippet the share popover copies. Two targets: a single track
+/// ( → FramePlayer?TrackEntryKey=...) and a whole release
+/// ( → FramePlayer?ReleaseEntryKey=...). The iframe chrome
+/// (dimensions, border radius, autoplay permission) is identical across both, defined once here.
+/// Pure string composition so the snippet shape is unit-testable without rendering the component.
+///
+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)
+ => $"""""";
+}
diff --git a/DeepDrftPublic.Client/Pages/CutDetail.razor b/DeepDrftPublic.Client/Pages/CutDetail.razor
index 7460da1..54f9af6 100644
--- a/DeepDrftPublic.Client/Pages/CutDetail.razor
+++ b/DeepDrftPublic.Client/Pages/CutDetail.razor
@@ -138,6 +138,7 @@ else
OnToggle="@(() => PlayTrack(track, index))" />
@track.TrackName
+
}
diff --git a/DeepDrftPublic.Client/Pages/FramePlayer.razor b/DeepDrftPublic.Client/Pages/FramePlayer.razor
index 177c406..8fe32cc 100644
--- a/DeepDrftPublic.Client/Pages/FramePlayer.razor
+++ b/DeepDrftPublic.Client/Pages/FramePlayer.razor
@@ -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.
}
-}
\ No newline at end of file
+}
diff --git a/DeepDrftPublic.Client/Services/IQueueService.cs b/DeepDrftPublic.Client/Services/IQueueService.cs
index b3cea03..2bd0f9e 100644
--- a/DeepDrftPublic.Client/Services/IQueueService.cs
+++ b/DeepDrftPublic.Client/Services/IQueueService.cs
@@ -38,6 +38,15 @@ public interface IQueueService
/// The current track, or null when the queue is empty.
TrackDto? Current { get; }
+ ///
+ /// True when the queue has been loaded via but no track has streamed yet —
+ /// the embed's pre-gesture state. Set by ; cleared the moment playback actually
+ /// starts (///)
+ /// or on . The player bar reads this to route the first play gesture through
+ /// (which begins the armed release) rather than streaming the staged track alone.
+ ///
+ bool IsArmed { get; }
+
/// True when there is a track after to advance to.
bool HasNext { get; }
@@ -59,6 +68,25 @@ public interface IQueueService
///
Task PlayRelease(IEnumerable tracks, int startIndex = 0);
+ ///
+ /// Loads 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 ) then starts playback via , which
+ /// keeps the loaded release queued so it advances through its tracks. No-op when
+ /// is empty (the queue stays empty and disarmed).
+ ///
+ void Arm(IEnumerable tracks);
+
+ ///
+ /// Begins playback of an armed queue (see ): streams the current track and clears
+ /// , 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.
+ ///
+ Task Start();
+
/// Appends a track to the end of the queue without changing what is currently playing.
void Enqueue(TrackDto track);
diff --git a/DeepDrftPublic.Client/Services/QueueService.cs b/DeepDrftPublic.Client/Services/QueueService.cs
index 2138c37..bdebd4c 100644
--- a/DeepDrftPublic.Client/Services/QueueService.cs
+++ b/DeepDrftPublic.Client/Services/QueueService.cs
@@ -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 tracks)
+ {
+ var list = tracks as IReadOnlyList ?? 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();
}
diff --git a/DeepDrftPublic.Client/Startup.cs b/DeepDrftPublic.Client/Startup.cs
index 5ba3473..c5c6b67 100644
--- a/DeepDrftPublic.Client/Startup.cs
+++ b/DeepDrftPublic.Client/Startup.cs
@@ -25,6 +25,7 @@ public static class Startup
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
// Home hero stats read surface — same HTTP posture as the track/release clients.
services.AddScoped();
diff --git a/DeepDrftPublic.Client/ViewModels/FramePlayerViewModel.cs b/DeepDrftPublic.Client/ViewModels/FramePlayerViewModel.cs
new file mode 100644
index 0000000..09521ca
--- /dev/null
+++ b/DeepDrftPublic.Client/ViewModels/FramePlayerViewModel.cs
@@ -0,0 +1,52 @@
+using DeepDrftModels.DTOs;
+using DeepDrftPublic.Client.Services;
+
+namespace DeepDrftPublic.Client.ViewModels;
+
+///
+/// Resolves the ordered track list for a release embed (FramePlayer?ReleaseEntryKey=...). Mirrors
+/// '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; is reset per
+/// so a reused instance never bleeds across embeds.
+///
+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 Tracks { get; private set; } = [];
+
+ public FramePlayerViewModel(IReleaseDataService releaseData, ITrackDataService trackData)
+ {
+ _releaseData = releaseData;
+ _trackData = trackData;
+ }
+
+ ///
+ /// Resolves to its ordered tracks. Leaves
+ /// empty when the release is not found or has no streamable tracks — the caller leaves the bar idle.
+ ///
+ 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();
+ }
+}
diff --git a/DeepDrftTests/EmbedSnippetBuilderTests.cs b/DeepDrftTests/EmbedSnippetBuilderTests.cs
new file mode 100644
index 0000000..81fbc60
--- /dev/null
+++ b/DeepDrftTests/EmbedSnippetBuilderTests.cs
@@ -0,0 +1,54 @@
+using DeepDrftPublic.Client.Helpers;
+
+namespace DeepDrftTests;
+
+///
+/// Unit tests for the share-popover embed snippet (). The builder is
+/// the mode-aware half of SharePopover: track mode targets FramePlayer's TrackEntryKey param, release
+/// mode targets its ReleaseEntryKey param. The iframe chrome (dimensions, autoplay) must be identical
+/// across both. Pure string composition, tested directly without rendering the component.
+///
+[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(""));
+ }
+ });
+ }
+}
diff --git a/DeepDrftTests/FramePlayerReleaseResolutionTests.cs b/DeepDrftTests/FramePlayerReleaseResolutionTests.cs
new file mode 100644
index 0000000..d7997cf
--- /dev/null
+++ b/DeepDrftTests/FramePlayerReleaseResolutionTests.cs
@@ -0,0 +1,159 @@
+using DeepDrftModels.DTOs;
+using DeepDrftPublic.Client.Services;
+using DeepDrftPublic.Client.ViewModels;
+using Models.Common;
+using NetBlocks.Models;
+
+namespace DeepDrftTests;
+
+///
+/// Unit tests for the release-embed track resolution (). 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).
+///
+[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 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(); // 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> GetByEntryKey(string entryKey)
+ => Task.FromResult(Release is null
+ ? ApiResult.CreateFailResult("not found")
+ : ApiResult.CreatePassResult(Release));
+
+ public Task>> 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 Page { get; set; } = new();
+ public bool WasQueried { get; private set; }
+ public long? LastReleaseId { get; private set; }
+ public string? LastSortColumn { get; private set; }
+
+ public Task>> 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
+ {
+ Items = Page,
+ TotalCount = Page.Count,
+ Page = pageNumber,
+ PageSize = pageSize,
+ };
+ return Task.FromResult(ApiResult>.CreatePassResult(paged));
+ }
+
+ // Inert remainder — FramePlayerViewModel only calls GetPage.
+ public Task>> GetAlbums() => throw new NotSupportedException();
+ public Task>> GetGenres() => throw new NotSupportedException();
+ public Task> GetTrack(string trackId) => throw new NotSupportedException();
+ public Task> GetTrackWaveform(string trackEntryKey) => throw new NotSupportedException();
+ public Task> GetRandomTrack() => throw new NotSupportedException();
+ }
+}
diff --git a/DeepDrftTests/QueueServiceTests.cs b/DeepDrftTests/QueueServiceTests.cs
index 0321ccd..dcd5e1b 100644
--- a/DeepDrftTests/QueueServiceTests.cs
+++ b/DeepDrftTests/QueueServiceTests.cs
@@ -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());
+
+ 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]