Add whole-release embeds to FramePlayer and un-gate the release embed share affordance

The queue gains an armed-but-idle state (Arm/Start) so a release embed stages track 0 prerender-safe, then queues the full release on first play and auto-advances.
This commit is contained in:
daniel-c-harvey
2026-06-19 12:05:35 -04:00
parent 1931574ad4
commit 912256d99a
12 changed files with 560 additions and 47 deletions
@@ -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;
}
@@ -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 Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
@@ -7,19 +8,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>
@@ -56,8 +57,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>""";
}
+48 -11
View File
@@ -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();
}
+1
View File
@@ -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();
}
}
+54
View File
@@ -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();
}
}
+121
View File
@@ -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]