Merge dev into p10-w4-popover-knobs (integrate concurrent Phase 11 scaffold changes)
# Conflicts: # DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor.cs
This commit is contained in:
@@ -21,6 +21,10 @@ else
|
||||
Fixed="Fixed"
|
||||
TogglePlayPause="@TogglePlayPause"
|
||||
Stop="@Stop"
|
||||
HasNext="HasNext"
|
||||
HasPrevious="HasPrevious"
|
||||
SkipNext="@SkipNext"
|
||||
SkipPrevious="@SkipPrevious"
|
||||
Class="transport-zone"/>
|
||||
|
||||
<VolumeZone Volume="@Volume" VolumeChanged="@OnVolumeChange"/>
|
||||
|
||||
@@ -9,6 +9,7 @@ namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
|
||||
public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||
{
|
||||
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
|
||||
[CascadingParameter] public IQueueService? QueueService { get; set; }
|
||||
[Parameter] public bool Fixed { get; set; } = false;
|
||||
|
||||
[Parameter] public EventCallback<bool> OnMinimized { get; set; }
|
||||
@@ -19,6 +20,7 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||
private bool _isSeeking = false;
|
||||
private double _seekPosition = 0;
|
||||
private IStreamingPlayerService? _subscribedService;
|
||||
private IQueueService? _subscribedQueue;
|
||||
|
||||
// Spacer-height bridge: the expanded dock is position:fixed, so MainLayout's
|
||||
// spacer reserves its space. We mirror this element's live height into a CSS
|
||||
@@ -48,6 +50,11 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||
private double LoadProgress => PlayerService?.LoadProgress ?? 0;
|
||||
private string? ErrorMessage => PlayerService?.ErrorMessage;
|
||||
|
||||
// Skip affordances reflect live queue state. With no queue (null) or an empty queue both are
|
||||
// false, so the buttons sit disabled and the bar behaves exactly as it did before the queue.
|
||||
private bool HasNext => QueueService?.HasNext ?? false;
|
||||
private bool HasPrevious => QueueService?.HasPrevious ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// Display time - shows seek position while dragging, otherwise current playback time.
|
||||
/// </summary>
|
||||
@@ -76,10 +83,35 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||
PlayerService.StateChanged += OnPlayerStateChanged;
|
||||
_subscribedService = PlayerService;
|
||||
}
|
||||
|
||||
// The queue cascade is also IsFixed, so re-render the skip affordances off its own
|
||||
// change signal — same posture as the player StateChanged subscription above.
|
||||
if (QueueService != null && !ReferenceEquals(QueueService, _subscribedQueue))
|
||||
{
|
||||
if (_subscribedQueue != null)
|
||||
_subscribedQueue.QueueChanged -= OnQueueChanged;
|
||||
|
||||
QueueService.QueueChanged += OnQueueChanged;
|
||||
_subscribedQueue = QueueService;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPlayerStateChanged() => InvokeAsync(StateHasChanged);
|
||||
|
||||
private void OnQueueChanged() => InvokeAsync(StateHasChanged);
|
||||
|
||||
private async Task SkipNext()
|
||||
{
|
||||
if (QueueService == null) return;
|
||||
await QueueService.Next();
|
||||
}
|
||||
|
||||
private async Task SkipPrevious()
|
||||
{
|
||||
if (QueueService == null) return;
|
||||
await QueueService.Previous();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
// Only the docked, expanded shape needs a spacer: the Fixed embed is
|
||||
|
||||
@@ -2,12 +2,25 @@
|
||||
@using DeepDrftPublic.Client.Controls
|
||||
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
@if (!Fixed)
|
||||
{
|
||||
<MudIconButton Icon="@Icons.Material.Filled.SkipPrevious"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Large"
|
||||
OnClick="@SkipPrevious"
|
||||
Disabled="!HasPrevious"/>
|
||||
}
|
||||
<PlayStateIcon Size="Size.Large"
|
||||
Color="Color.Primary"
|
||||
Disabled="!CanPlay"
|
||||
OnToggle="@TogglePlayPause"/>
|
||||
@if (!Fixed)
|
||||
{
|
||||
<MudIconButton Icon="@Icons.Material.Filled.SkipNext"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Large"
|
||||
OnClick="@SkipNext"
|
||||
Disabled="!HasNext"/>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Stop"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Large"
|
||||
|
||||
@@ -16,4 +16,13 @@ public partial class PlayerControls : ComponentBase
|
||||
[Parameter] public bool Fixed { get; set; } = false;
|
||||
[Parameter] public required EventCallback TogglePlayPause { get; set; }
|
||||
[Parameter] public required EventCallback Stop { get; set; }
|
||||
|
||||
/// <summary>Whether the queue has a track to skip forward to. Drives the skip-next affordance.</summary>
|
||||
[Parameter] public bool HasNext { get; set; }
|
||||
|
||||
/// <summary>Whether the queue has a track to step back to. Drives the skip-previous affordance.</summary>
|
||||
[Parameter] public bool HasPrevious { get; set; }
|
||||
|
||||
[Parameter] public EventCallback SkipNext { get; set; }
|
||||
[Parameter] public EventCallback SkipPrevious { get; set; }
|
||||
}
|
||||
|
||||
@@ -6,7 +6,11 @@
|
||||
CanPlay="CanPlay"
|
||||
Fixed="Fixed"
|
||||
TogglePlayPause="TogglePlayPause"
|
||||
Stop="Stop"/>
|
||||
Stop="Stop"
|
||||
HasNext="HasNext"
|
||||
HasPrevious="HasPrevious"
|
||||
SkipNext="SkipNext"
|
||||
SkipPrevious="SkipPrevious"/>
|
||||
@if (IsLoading && !IsStreaming)
|
||||
{
|
||||
<MudProgressCircular Color="Color.Tertiary"
|
||||
|
||||
@@ -14,5 +14,9 @@ public partial class PlayerTransportZone : ComponentBase
|
||||
[Parameter] public bool Fixed { get; set; } = false;
|
||||
[Parameter] public EventCallback TogglePlayPause { get; set; }
|
||||
[Parameter] public EventCallback Stop { get; set; }
|
||||
[Parameter] public bool HasNext { get; set; }
|
||||
[Parameter] public bool HasPrevious { get; set; }
|
||||
[Parameter] public EventCallback SkipNext { get; set; }
|
||||
[Parameter] public EventCallback SkipPrevious { get; set; }
|
||||
[Parameter] public string? Class { get; set; }
|
||||
}
|
||||
|
||||
@@ -3,5 +3,7 @@
|
||||
If instance swapping at runtime is ever needed, change IsFixed to false (adds subscription
|
||||
overhead on every parent re-render, but allows children to see the new reference). *@
|
||||
<CascadingValue Value="@(_audioPlayerService)" IsFixed="true">
|
||||
@ChildContent
|
||||
<CascadingValue Value="@(_queueService)" IsFixed="true">
|
||||
@ChildContent
|
||||
</CascadingValue>
|
||||
</CascadingValue>
|
||||
@@ -12,6 +12,7 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
|
||||
[Inject] public required ILogger<StreamingAudioPlayerService> Logger { get; set; }
|
||||
|
||||
private IStreamingPlayerService? _audioPlayerService;
|
||||
private QueueService? _queueService;
|
||||
|
||||
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
@@ -29,6 +30,13 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
|
||||
// Children must not wrap or replace this callback.
|
||||
_audioPlayerService.OnStateChanged = new EventCallback(this, () => InvokeAsync(StateHasChanged));
|
||||
// OnTrackSelected will be set by individual child components that need it
|
||||
|
||||
// The queue orchestrates above the single-slot player. The player is not DI-registered
|
||||
// (constructed here), so the queue binds to it via Attach rather than constructor injection —
|
||||
// no construction cycle, no IServiceProvider. Cascaded alongside the player so the bar and a
|
||||
// future up-next panel both read it.
|
||||
_queueService = new QueueService();
|
||||
_queueService.Attach(_audioPlayerService);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -38,6 +46,11 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
|
||||
/// </summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
// Dispose the queue first so it unsubscribes from the player's TrackEnded before the
|
||||
// player tears down.
|
||||
_queueService?.Dispose();
|
||||
_queueService = null;
|
||||
|
||||
if (_audioPlayerService is IAsyncDisposable disposable)
|
||||
{
|
||||
await disposable.DisposeAsync();
|
||||
|
||||
@@ -19,20 +19,30 @@
|
||||
|
||||
@TopContent
|
||||
|
||||
<MudStack Row AlignItems="AlignItems.Start" Justify="Justify.SpaceBetween" Style="margin: 2rem 0 1.5rem;">
|
||||
<div class="deepdrft-track-detail-masthead">
|
||||
<MudText Typo="Typo.h3">@Title</MudText>
|
||||
<MudText Typo="Typo.h6" Color="Color.Primary">@Artist</MudText>
|
||||
</div>
|
||||
@* The header region. A composer that wants the default masthead+play row supplies nothing; one
|
||||
that needs a different arrangement (e.g. the Cut album's left-meta / right-cover split) supplies
|
||||
its own Header fragment. Layout variance rides this slot, never a boolean flag (Phase 9 §5.3). *@
|
||||
@if (Header is not null)
|
||||
{
|
||||
@Header
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudStack Row AlignItems="AlignItems.Start" Justify="Justify.SpaceBetween" Style="margin: 2rem 0 1.5rem;">
|
||||
<div class="deepdrft-track-detail-masthead">
|
||||
<MudText Typo="Typo.h3">@Title</MudText>
|
||||
<MudText Typo="Typo.h6" Color="Color.Primary">@Artist</MudText>
|
||||
</div>
|
||||
|
||||
@* Play only makes sense once a playable track is resolved. *@
|
||||
@if (Track is not null)
|
||||
{
|
||||
<MudStack Row AlignItems="AlignItems.Center" Spacing="1">
|
||||
<PlayStateIcon Track="@Track" Size="Size.Large" Color="Color.Secondary" OnToggle="@PlayTrack" />
|
||||
</MudStack>
|
||||
}
|
||||
</MudStack>
|
||||
@* Play only makes sense once a playable track is resolved. *@
|
||||
@if (Track is not null)
|
||||
{
|
||||
<MudStack Row AlignItems="AlignItems.Center" Spacing="1">
|
||||
<PlayStateIcon Track="@Track" Size="Size.Large" Color="Color.Secondary" OnToggle="@PlayTrack" />
|
||||
</MudStack>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@Hero
|
||||
|
||||
@@ -44,7 +54,12 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Track is not null)
|
||||
@* Multi-track body region (the Cut album's track list). Single-track media leave it null. *@
|
||||
@BodyContent
|
||||
|
||||
@* The default share row is bound to the single resolved track. A composer that owns its own share
|
||||
affordance (the Cut header carries Play + Share inline) suppresses it via ShowShareRow. *@
|
||||
@if (Track is not null && ShowShareRow)
|
||||
{
|
||||
<div class="deepdrft-share-row">
|
||||
<SharePopover EntryKey="@Track.EntryKey" />
|
||||
|
||||
@@ -38,9 +38,24 @@ public partial class ReleaseDetailScaffold : ComponentBase
|
||||
/// </summary>
|
||||
[Parameter] public RenderFragment? TopRightAction { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional replacement for the header region (masthead + play affordance). When null, the
|
||||
/// scaffold renders its default masthead+play row wired to <see cref="PlayTrack"/>. A composer
|
||||
/// that needs a different header arrangement (e.g. the Cut album's left-meta / right-cover split
|
||||
/// with its own Play/Share buttons) supplies this — layout variance rides the slot, never a
|
||||
/// boolean flag (Phase 9 §5.3).
|
||||
/// </summary>
|
||||
[Parameter] public RenderFragment? Header { get; set; }
|
||||
|
||||
/// <summary>Medium-specific hero visual (cover art, hero image, or waveform background).</summary>
|
||||
[Parameter] public RenderFragment? Hero { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional body region rendered below the meta block — the Cut album's multi-track listing.
|
||||
/// Single-track media leave it null.
|
||||
/// </summary>
|
||||
[Parameter] public RenderFragment? BodyContent { get; set; }
|
||||
|
||||
/// <summary>Optional medium-specific metadata block, rendered under a divider when present.</summary>
|
||||
[Parameter] public RenderFragment? MetaContent { get; set; }
|
||||
|
||||
@@ -51,6 +66,13 @@ public partial class ReleaseDetailScaffold : ComponentBase
|
||||
/// </summary>
|
||||
[Parameter] public bool ShowMeta { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gate for the default track-keyed share row at the foot of the scaffold. A composer that owns
|
||||
/// its own share affordance (the Cut header carries Play + Share inline) sets this false to
|
||||
/// suppress the duplicate. Defaults to shown.
|
||||
/// </summary>
|
||||
[Parameter] public bool ShowShareRow { get; set; } = true;
|
||||
|
||||
private async Task PlayTrack()
|
||||
{
|
||||
if (Track is null || PlayerService is null) return;
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
@page "/cuts/{Id:long}"
|
||||
@using DeepDrftModels.DTOs
|
||||
@using DeepDrftPublic.Client.Controls
|
||||
@using DeepDrftPublic.Client.Services
|
||||
@inherits CutDetailBase
|
||||
|
||||
<PageTitle>@(ViewModel.Release?.Title ?? "Cut") - DeepDrft</PageTitle>
|
||||
|
||||
@if (ViewModel.IsLoading)
|
||||
{
|
||||
<div class="deepdrft-track-detail-container">
|
||||
<div class="deepdrft-track-detail-masthead">
|
||||
<MudSkeleton SkeletonType="SkeletonType.Text" Width="70%" Height="56px" />
|
||||
<MudSkeleton SkeletonType="SkeletonType.Text" Width="40%" Height="32px" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (ViewModel.NotFound || ViewModel.Release is null)
|
||||
{
|
||||
<div class="deepdrft-track-detail-container">
|
||||
<div class="deepdrft-track-detail-masthead">
|
||||
<MudText Typo="Typo.h4" Align="Align.Center">Cut not found.</MudText>
|
||||
<div class="d-flex justify-center mt-4">
|
||||
<MudButton Href="/cuts"
|
||||
Variant="Variant.Text"
|
||||
StartIcon="@Icons.Material.Filled.ArrowBack">
|
||||
All cuts
|
||||
</MudButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
var release = ViewModel.Release;
|
||||
var hasGenre = release.Genre is not null;
|
||||
var hasYear = release.ReleaseDate is not null;
|
||||
var firstTrack = ViewModel.Tracks.Count > 0 ? ViewModel.Tracks[0] : null;
|
||||
|
||||
<ReleaseDetailScaffold Title="@release.Title"
|
||||
Artist="@release.Artist"
|
||||
Track="@firstTrack"
|
||||
BackHref="/cuts"
|
||||
BackLabel="All cuts"
|
||||
ShowShareRow="false">
|
||||
<Header>
|
||||
@* Header split: meta + Play/Share on the LEFT, bordered cover on the RIGHT (spec §3.1). *@
|
||||
<div class="cut-detail-header">
|
||||
<div class="cut-detail-meta">
|
||||
<MudText Typo="Typo.h3">@release.Title</MudText>
|
||||
<MudText Typo="Typo.h6" Color="Color.Primary">@release.Artist</MudText>
|
||||
|
||||
@if (hasGenre || hasYear)
|
||||
{
|
||||
<div class="cut-detail-subline">
|
||||
@if (hasGenre)
|
||||
{
|
||||
<span class="cut-detail-genre">@release.Genre</span>
|
||||
}
|
||||
@if (hasGenre && hasYear)
|
||||
{
|
||||
<span class="cut-detail-sep">·</span>
|
||||
}
|
||||
@if (hasYear)
|
||||
{
|
||||
<span class="cut-detail-year">@release.ReleaseDate!.Value.Year</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="cut-detail-actions">
|
||||
@* Header Play starts the album's first track. Wired to the single-slot player
|
||||
today; the §3.4 queue seam means a future swap to QueueService.PlayRelease
|
||||
is a one-line change inside PlayAlbum, not a markup edit. Disabled until a
|
||||
streamable track is resolved. *@
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Secondary"
|
||||
StartIcon="@Icons.Material.Filled.PlayArrow"
|
||||
Disabled="@(firstTrack is null || !RendererInfo.IsInteractive)"
|
||||
OnClick="@PlayAlbum">
|
||||
Play
|
||||
</MudButton>
|
||||
|
||||
@if (firstTrack is not null)
|
||||
{
|
||||
<SharePopover EntryKey="@firstTrack.EntryKey" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cut-detail-cover">
|
||||
@if (!string.IsNullOrEmpty(release.ImagePath))
|
||||
{
|
||||
<MudPaper Elevation="2" Class="deepdrft-track-detail-cover-art"
|
||||
Style="@($"background-image: url('api/image/{Uri.EscapeDataString(release.ImagePath)}');")" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudPaper Elevation="2" Class="deepdrft-track-detail-cover-placeholder deepdrft-gradient-soft-secondary">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Album" Color="Color.Primary" />
|
||||
</MudPaper>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</Header>
|
||||
<BodyContent>
|
||||
<MudDivider Class="cut-detail-divider" />
|
||||
@if (ViewModel.Tracks.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="cut-detail-empty">No tracks in this cut yet.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="cut-detail-tracklist">
|
||||
@foreach (var track in ViewModel.Tracks)
|
||||
{
|
||||
<div class="cut-detail-track-row">
|
||||
<span class="cut-detail-track-number">@track.TrackNumber</span>
|
||||
<div class="cut-detail-track-play">
|
||||
<PlayStateIcon Track="@track"
|
||||
Size="Size.Medium"
|
||||
Color="Color.Secondary"
|
||||
OnToggle="@(() => PlayTrack(track))" />
|
||||
</div>
|
||||
<span class="cut-detail-track-name text-truncate">@track.TrackName</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</BodyContent>
|
||||
</ReleaseDetailScaffold>
|
||||
}
|
||||
|
||||
@code {
|
||||
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
|
||||
|
||||
// Header Play: start the album's first track. The §3.4 queue seam lives here — swapping this body
|
||||
// to `Queue.PlayRelease(ViewModel.Tracks)` once IQueueService (track 11.F) lands is a one-line
|
||||
// change with no other edit to this page. The queue type is not referenced here because it does
|
||||
// not exist in this worktree.
|
||||
private Task PlayAlbum()
|
||||
{
|
||||
var first = ViewModel.Tracks.Count > 0 ? ViewModel.Tracks[0] : null;
|
||||
return first is null ? Task.CompletedTask : PlayTrack(first);
|
||||
}
|
||||
|
||||
// Row play: toggle if this track is already active, otherwise start a fresh stream. Mirrors the
|
||||
// scaffold's own PlayTrack wiring (SessionDetail uses the same idiom for its diverged layout).
|
||||
private async Task PlayTrack(TrackDto track)
|
||||
{
|
||||
if (PlayerService is null) return;
|
||||
|
||||
var isThisTrack = PlayerService.CurrentTrack?.Id == track.Id;
|
||||
if (isThisTrack && (PlayerService.IsPlaying || PlayerService.IsPaused))
|
||||
{
|
||||
await PlayerService.TogglePlayPause();
|
||||
}
|
||||
else
|
||||
{
|
||||
await PlayerService.SelectTrackStreaming(track);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftPublic.Client.ViewModels;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace DeepDrftPublic.Client.Pages;
|
||||
|
||||
/// <summary>
|
||||
/// Load + prerender-bridge logic for the Cut album-detail page (/cuts/{id}). Mirrors
|
||||
/// <see cref="ReleaseDetailBase"/>'s discipline (id-addressed load in OnParametersSetAsync,
|
||||
/// PersistentComponentState bridge guarded on id) but carries the multi-track payload (release +
|
||||
/// ordered track list) the Cut page needs. Kept separate from the single-track base so neither
|
||||
/// grows a medium conditional — the two release shapes are genuinely different (one track vs many).
|
||||
/// </summary>
|
||||
public abstract class CutDetailBase : ComponentBase, IDisposable
|
||||
{
|
||||
private const string PersistKey = "cut-detail";
|
||||
|
||||
[Parameter] public long Id { get; set; }
|
||||
[Inject] public required CutDetailViewModel ViewModel { get; set; }
|
||||
[Inject] public required PersistentComponentState PersistentState { get; set; }
|
||||
|
||||
private PersistingComponentStateSubscription _persistingSubscription;
|
||||
|
||||
// The release id the ViewModel currently holds — tracks param-only navigations (e.g.
|
||||
// /cuts/5 -> /cuts/8) which reuse this component instance and fire OnParametersSet without
|
||||
// re-running OnInitialized. Without it the page would keep the prior album's tracks.
|
||||
private long _loadedId;
|
||||
private bool _loaded;
|
||||
|
||||
protected override void OnInitialized()
|
||||
=> _persistingSubscription = PersistentState.RegisterOnPersisting(Persist);
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (_loaded && _loadedId == Id) return;
|
||||
|
||||
// Capture the id synchronously before any await so a re-entrant call (rapid navigation or a
|
||||
// re-render that changes Id while Load is in flight) sees the correct guard state.
|
||||
_loadedId = Id;
|
||||
_loaded = true;
|
||||
|
||||
// The bridged payload carries the release and its ordered tracks so the interactive pass
|
||||
// renders identically without a second round-trip. Guard on the id: a payload for a different
|
||||
// release must not seed this page (stale-bridge bleed across navigation).
|
||||
if (PersistentState.TryTakeFromJson<BridgedCut>(PersistKey, out var restored)
|
||||
&& restored?.Release is not null
|
||||
&& restored.Release.Id == Id)
|
||||
{
|
||||
ViewModel.Restore(restored.Release, restored.Tracks);
|
||||
}
|
||||
else
|
||||
{
|
||||
await ViewModel.Load(Id);
|
||||
}
|
||||
}
|
||||
|
||||
private Task Persist()
|
||||
{
|
||||
if (ViewModel.Release is not null)
|
||||
PersistentState.PersistAsJson(PersistKey, new BridgedCut(ViewModel.Release, ViewModel.Tracks));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Dispose() => _persistingSubscription.Dispose();
|
||||
|
||||
// JSON-serializable bridge payload. Round-trips through PersistentComponentState's serializer.
|
||||
protected sealed record BridgedCut(ReleaseDto Release, IReadOnlyList<TrackDto> Tracks);
|
||||
}
|
||||
@@ -43,6 +43,9 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
|
||||
/// <inheritdoc />
|
||||
public event Action? StateChanged;
|
||||
|
||||
/// <inheritdoc />
|
||||
public event Action? TrackEnded;
|
||||
|
||||
protected AudioPlayerService(AudioInteropService audioInterop, TrackMediaClient trackMediaClient)
|
||||
{
|
||||
_audioInterop = audioInterop;
|
||||
@@ -268,6 +271,12 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
|
||||
CurrentTime = 0;
|
||||
Duration = null;
|
||||
await NotifyStateChanged();
|
||||
|
||||
// Fire AFTER the state notification so any queue orchestrator that advances on this
|
||||
// signal selects the next track against a fully-settled idle state. Raised only on
|
||||
// organic end-of-stream — stop/unload/track-switch go through ResetToIdle, which does
|
||||
// not raise this — so a subscriber can treat it unambiguously as "advance the queue."
|
||||
TrackEnded?.Invoke();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -40,6 +40,15 @@ public interface IPlayerService
|
||||
/// <see cref="OnStateChanged"/> (throttled to ~10/s during streaming).
|
||||
/// </summary>
|
||||
event Action? StateChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Raised once when the current track reaches its natural end of playback (the JS
|
||||
/// end-of-stream callback), distinct from a stop/unload/track-switch. This is the single
|
||||
/// hook the play-queue subscribes to in order to auto-advance to the next track. It does
|
||||
/// NOT fire when playback is stopped, the track is switched, or the player is unloaded —
|
||||
/// only on organic completion — so an orchestrator can treat it as "advance the queue."
|
||||
/// </summary>
|
||||
event Action? TrackEnded;
|
||||
|
||||
// Control methods
|
||||
Task InitializeAsync();
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
|
||||
namespace DeepDrftPublic.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates ordered playback ("what plays next") <em>above</em> the single-slot
|
||||
/// <see cref="IStreamingPlayerService"/>. The player stays a single-track device; the queue owns the
|
||||
/// track list, the current position, skip-forward/back, and auto-advance on natural track end. It
|
||||
/// drives playback solely through the player's existing <see cref="IStreamingPlayerService.SelectTrackStreaming"/>
|
||||
/// — it adds no new playback semantics.
|
||||
///
|
||||
/// <para>
|
||||
/// Extension posture (open/closed): future shuffle, repeat modes, reordering, and persistence are
|
||||
/// expected. They are additive — a shuffle/repeat strategy slots in behind <see cref="Next"/>/
|
||||
/// <see cref="Previous"/> as the "which index is next" decision; reordering mutates <see cref="Items"/>
|
||||
/// and re-emits <see cref="QueueChanged"/>; persistence snapshots/restores <see cref="Items"/> +
|
||||
/// <see cref="CurrentIndex"/>. None of those require changing this interface's existing members, only
|
||||
/// adding new ones — so consumers written against today's surface keep working.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// With an empty queue (<see cref="CurrentIndex"/> == -1) the queue is dormant: it drives nothing and
|
||||
/// auto-advances nothing, so direct single-track play through the player behaves exactly as it did
|
||||
/// before the queue existed.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface IQueueService
|
||||
{
|
||||
/// <summary>The ordered tracks currently queued. Empty when nothing is enqueued.</summary>
|
||||
IReadOnlyList<TrackDto> Items { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Index into <see cref="Items"/> of the track the queue considers current, or -1 when the
|
||||
/// queue is empty. Always a valid index into <see cref="Items"/> when non-negative.
|
||||
/// </summary>
|
||||
int CurrentIndex { get; }
|
||||
|
||||
/// <summary>The current track, or null when the queue is empty.</summary>
|
||||
TrackDto? Current { get; }
|
||||
|
||||
/// <summary>True when there is a track after <see cref="CurrentIndex"/> to advance to.</summary>
|
||||
bool HasNext { get; }
|
||||
|
||||
/// <summary>True when there is a track before <see cref="CurrentIndex"/> to step back to.</summary>
|
||||
bool HasPrevious { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Raised whenever the queue's contents or current position change. The player bar subscribes
|
||||
/// to re-render its skip-forward/back affordances. Fires on enqueue, advance, step-back, and clear.
|
||||
/// </summary>
|
||||
event Action? QueueChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the queue with <paramref name="tracks"/> (in the order given) and begins streaming
|
||||
/// the track at <paramref name="startIndex"/>. This is the "play album" entry point the Cuts
|
||||
/// detail page consumes: pass the release's tracks in ordinal order. A header Play uses
|
||||
/// <c>startIndex: 0</c>; a mid-album row play passes that row's index so the queue continues to
|
||||
/// the end from there. No-op when <paramref name="tracks"/> is empty.
|
||||
/// </summary>
|
||||
Task PlayRelease(IEnumerable<TrackDto> tracks, int startIndex = 0);
|
||||
|
||||
/// <summary>Appends a track to the end of the queue without changing what is currently playing.</summary>
|
||||
void Enqueue(TrackDto track);
|
||||
|
||||
/// <summary>Appends tracks to the end of the queue without changing what is currently playing.</summary>
|
||||
void EnqueueRange(IEnumerable<TrackDto> tracks);
|
||||
|
||||
/// <summary>
|
||||
/// Advances to the next track and streams it. No-op when <see cref="HasNext"/> is false.
|
||||
/// </summary>
|
||||
Task Next();
|
||||
|
||||
/// <summary>
|
||||
/// Steps back to the previous track and streams it. No-op when <see cref="HasPrevious"/> is false.
|
||||
/// </summary>
|
||||
Task Previous();
|
||||
|
||||
/// <summary>Empties the queue and resets the position. Does not stop the player.</summary>
|
||||
void Clear();
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
|
||||
namespace DeepDrftPublic.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IQueueService"/>: a single-slot orchestrator over an
|
||||
/// <see cref="IStreamingPlayerService"/>. Holds the ordered list and current index as pure state,
|
||||
/// drives playback through the player's existing <see cref="IStreamingPlayerService.SelectTrackStreaming"/>,
|
||||
/// and auto-advances on the player's <see cref="IPlayerService.TrackEnded"/> signal.
|
||||
///
|
||||
/// <para>
|
||||
/// The player instance is not DI-registered — <c>AudioPlayerProvider</c> constructs and cascades it.
|
||||
/// So the queue is bound to the player via <see cref="Attach"/> (called once by the provider after it
|
||||
/// creates the player) rather than constructor injection. This keeps the player single-slot, avoids a
|
||||
/// construction cycle between provider/player/queue, and needs no <c>IServiceProvider</c>. The queue's
|
||||
/// own constructor stays parameterless, so the queue logic is unit-testable against a fake player with
|
||||
/// no container.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class QueueService : IQueueService, IDisposable
|
||||
{
|
||||
private readonly List<TrackDto> _items = new();
|
||||
private IStreamingPlayerService? _player;
|
||||
|
||||
public IReadOnlyList<TrackDto> Items => _items;
|
||||
|
||||
public int CurrentIndex { get; private set; } = -1;
|
||||
|
||||
public TrackDto? Current =>
|
||||
CurrentIndex >= 0 && CurrentIndex < _items.Count ? _items[CurrentIndex] : null;
|
||||
|
||||
public bool HasNext => CurrentIndex >= 0 && CurrentIndex < _items.Count - 1;
|
||||
|
||||
public bool HasPrevious => CurrentIndex > 0;
|
||||
|
||||
public event Action? QueueChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Binds the queue to the player instance the provider owns, and subscribes to its track-ended
|
||||
/// signal so the queue auto-advances. Idempotent and re-bindable: re-attaching detaches the prior
|
||||
/// player first, so the queue never holds a stale subscription after a player swap. Owned by the
|
||||
/// provider's lifecycle; <see cref="Dispose"/> unsubscribes.
|
||||
/// </summary>
|
||||
public void Attach(IStreamingPlayerService player)
|
||||
{
|
||||
if (ReferenceEquals(_player, player)) return;
|
||||
|
||||
if (_player != null)
|
||||
_player.TrackEnded -= OnTrackEnded;
|
||||
|
||||
_player = player;
|
||||
_player.TrackEnded += OnTrackEnded;
|
||||
}
|
||||
|
||||
public async Task PlayRelease(IEnumerable<TrackDto> tracks, int startIndex = 0)
|
||||
{
|
||||
var list = tracks as IReadOnlyList<TrackDto> ?? tracks.ToList();
|
||||
if (list.Count == 0) return;
|
||||
|
||||
var start = Math.Clamp(startIndex, 0, list.Count - 1);
|
||||
|
||||
_items.Clear();
|
||||
_items.AddRange(list);
|
||||
CurrentIndex = start;
|
||||
QueueChanged?.Invoke();
|
||||
|
||||
await PlayCurrent();
|
||||
}
|
||||
|
||||
public void Enqueue(TrackDto track)
|
||||
{
|
||||
_items.Add(track);
|
||||
QueueChanged?.Invoke();
|
||||
}
|
||||
|
||||
public void EnqueueRange(IEnumerable<TrackDto> tracks)
|
||||
{
|
||||
var before = _items.Count;
|
||||
_items.AddRange(tracks);
|
||||
if (_items.Count != before)
|
||||
QueueChanged?.Invoke();
|
||||
}
|
||||
|
||||
public async Task Next()
|
||||
{
|
||||
if (!HasNext) return;
|
||||
CurrentIndex++;
|
||||
QueueChanged?.Invoke();
|
||||
await PlayCurrent();
|
||||
}
|
||||
|
||||
public async Task Previous()
|
||||
{
|
||||
if (!HasPrevious) return;
|
||||
CurrentIndex--;
|
||||
QueueChanged?.Invoke();
|
||||
await PlayCurrent();
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
if (_items.Count == 0 && CurrentIndex == -1) return;
|
||||
_items.Clear();
|
||||
CurrentIndex = -1;
|
||||
QueueChanged?.Invoke();
|
||||
}
|
||||
|
||||
// Advance on organic end-of-stream only. TrackEnded is not raised by stop/unload/track-switch,
|
||||
// so a manual stop or a fresh single-track selection elsewhere never spuriously advances the
|
||||
// queue. When the queue is past its last track, end-of-stream simply stops — nothing to advance.
|
||||
//
|
||||
// Guard: only advance when the track that just ended is the queue's own current item. Call sites
|
||||
// that stream a single track directly (SessionDetail, StreamNowButton, resume from AudioPlayerBar)
|
||||
// overwrite the player's CurrentTrack without touching the queue. If their track reaches natural
|
||||
// end, the player fires TrackEnded — but the queue's Current no longer matches the player's
|
||||
// CurrentTrack, so we must not advance. Id-based equality is used rather than ReferenceEquals
|
||||
// because DTO copies through serialisation are not reference-equal.
|
||||
private void OnTrackEnded()
|
||||
{
|
||||
if (!HasNext) return;
|
||||
if (_player?.CurrentTrack?.Id != Current?.Id) return;
|
||||
// Fire-and-forget is deliberate: TrackEnded is a synchronous event invoked from the player's
|
||||
// end-of-playback callback continuation; we must not block it. Advancing kicks off the next
|
||||
// stream, whose own failures surface through the player's ErrorMessage/state — the queue does
|
||||
// not own playback error handling.
|
||||
_ = Next();
|
||||
}
|
||||
|
||||
private async Task PlayCurrent()
|
||||
{
|
||||
var track = Current;
|
||||
if (track is null || _player is null) return;
|
||||
await _player.SelectTrackStreaming(track);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_player != null)
|
||||
{
|
||||
_player.TrackEnded -= OnTrackEnded;
|
||||
_player = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ public static class Startup
|
||||
services.AddScoped<ReleaseClient>();
|
||||
services.AddScoped<IReleaseDataService, ReleaseClientDataService>();
|
||||
services.AddScoped<ReleaseDetailViewModel>();
|
||||
services.AddScoped<CutDetailViewModel>();
|
||||
|
||||
// Mix visualizer controls — scoped so the four slider positions persist across navigation
|
||||
// within a session and reset on a fresh page load (see MixVisualizerControlState).
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftPublic.Client.Services;
|
||||
|
||||
namespace DeepDrftPublic.Client.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// State for the Cut album-detail page (/cuts/{id}). Unlike <see cref="ReleaseDetailViewModel"/>
|
||||
/// (which resolves a single playable track for Session/Mix), a Cut is multi-track: it loads the
|
||||
/// release and the full ordered track list for that release. The list is fetched through the
|
||||
/// existing releaseId-filtered track page sorted by TrackNumber — the explicit 1-based ordinal
|
||||
/// (Phase 8) that the public read both sorts on and projects onto TrackDto. Scoped; every flag is
|
||||
/// reset per <see cref="Load"/> so a reused instance never bleeds across navigations.
|
||||
/// </summary>
|
||||
public class CutDetailViewModel
|
||||
{
|
||||
private readonly IReleaseDataService _releaseData;
|
||||
private readonly ITrackDataService _trackData;
|
||||
|
||||
// A Cut covers the whole album in one page. Matches the gallery's page-size convention; a single
|
||||
// album never approaches this ceiling (the API caps PageSize at 100 regardless).
|
||||
private const int AlbumPageSize = 100;
|
||||
|
||||
public ReleaseDto? Release { get; private set; }
|
||||
public IReadOnlyList<TrackDto> Tracks { get; private set; } = [];
|
||||
public bool IsLoading { get; private set; } = true;
|
||||
public bool NotFound { get; private set; }
|
||||
|
||||
public CutDetailViewModel(IReleaseDataService releaseData, ITrackDataService trackData)
|
||||
{
|
||||
_releaseData = releaseData;
|
||||
_trackData = trackData;
|
||||
}
|
||||
|
||||
/// <summary>Seed state directly from a bridged prerender payload — no fetch.</summary>
|
||||
public void Restore(ReleaseDto release, IReadOnlyList<TrackDto> tracks)
|
||||
{
|
||||
Release = release;
|
||||
Tracks = tracks;
|
||||
NotFound = false;
|
||||
IsLoading = false;
|
||||
}
|
||||
|
||||
public async Task Load(long releaseId)
|
||||
{
|
||||
IsLoading = true;
|
||||
NotFound = false;
|
||||
Release = null;
|
||||
Tracks = [];
|
||||
|
||||
try
|
||||
{
|
||||
var releaseResult = await _releaseData.GetById(releaseId);
|
||||
if (releaseResult is not { Success: true, Value: { } release })
|
||||
{
|
||||
NotFound = true;
|
||||
return;
|
||||
}
|
||||
|
||||
Release = release;
|
||||
|
||||
// The album's tracks via the releaseId-filtered page — an exact join, not a title string
|
||||
// (which collides across same-titled releases and breaks on rename). Sorted by TrackNumber
|
||||
// so rows render in saved order. A Cut with no streamable tracks simply leaves the list
|
||||
// empty (the page renders the header with no rows).
|
||||
var trackResult = await _trackData.GetPage(
|
||||
pageNumber: 1,
|
||||
pageSize: AlbumPageSize,
|
||||
sortColumn: "TrackNumber",
|
||||
releaseId: release.Id);
|
||||
if (trackResult is { Success: true, Value: { Items: { } items } })
|
||||
Tracks = items.ToList();
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user