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
@@ -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();
}