From 912256d99ab5df1dcc5d972a3cc0e65f63b49fda Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Fri, 19 Jun 2026 12:05:35 -0400 Subject: [PATCH 1/2] 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. --- .../AudioPlayerBar/AudioPlayerBar.razor.cs | 10 ++ .../Controls/SharePopover.razor | 52 +++--- .../Controls/SharePopover.razor.cs | 22 ++- .../Helpers/EmbedSnippetBuilder.cs | 21 +++ DeepDrftPublic.Client/Pages/FramePlayer.razor | 59 +++++-- .../Services/IQueueService.cs | 28 +++ .../Services/QueueService.cs | 28 +++ DeepDrftPublic.Client/Startup.cs | 1 + .../ViewModels/FramePlayerViewModel.cs | 52 ++++++ DeepDrftTests/EmbedSnippetBuilderTests.cs | 54 ++++++ .../FramePlayerReleaseResolutionTests.cs | 159 ++++++++++++++++++ DeepDrftTests/QueueServiceTests.cs | 121 +++++++++++++ 12 files changed, 560 insertions(+), 47 deletions(-) create mode 100644 DeepDrftPublic.Client/Helpers/EmbedSnippetBuilder.cs create mode 100644 DeepDrftPublic.Client/ViewModels/FramePlayerViewModel.cs create mode 100644 DeepDrftTests/EmbedSnippetBuilderTests.cs create mode 100644 DeepDrftTests/FramePlayerReleaseResolutionTests.cs 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/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] From 098020db32565a39eadb635ebc248aac35a9b695 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Fri, 19 Jun 2026 12:08:27 -0400 Subject: [PATCH 2/2] feat: add per-track SharePopover to Cut detail track rows --- DeepDrftPublic.Client/Pages/CutDetail.razor | 1 + 1 file changed, 1 insertion(+) 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 + }