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:
@@ -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>""";
|
||||
}
|
||||
@@ -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,28 +11,64 @@
|
||||
|
||||
@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