Files
daniel-c-harvey 214f708e65 feat(queue): two-level deque model — PLAY prepends, add appends, last-track-end empties
Fixes five queue bugs: Playlist relabel, last-track-empties, dormant-seed-from-player on first add, immediate panel reactivity, and front/back deque semantics. Adds JumpTo for row jumps.
2026-06-20 15:26:37 -04:00

135 lines
6.1 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@namespace DeepDrftPublic.Client.Controls
@using DeepDrftModels.DTOs
@* Shared presentational queue list. Renders the ordered queue with the current track marked, and
(when Editable) drag-reorder handles + per-row remove controls. This is the single "view" both
the docked overlay (17.2) and the embedded panel (17.3) consume — one source, multiple views.
Purely presentational: owns no data fetch, no player wiring, and no IQueueService mutation of its
own. Order changes, removals, and row jumps are surfaced to the parent as EventCallbacks; the
parent calls the queue engine. It runs during prerender without JS interop (MudDropContainer's
drag work is client-only and inert when no drag occurs). *@
@if (Items is { Count: > 0 })
{
@if (Editable)
{
<MudDropContainer T="QueueRow" @ref="_dropContainer" Items="Rows" ItemsSelector="@((row, zone) => true)"
ItemDropped="OnItemDropped" Class="deepdrft-queue-list">
<ChildContent>
<MudDropZone T="QueueRow" Identifier="queue" Class="deepdrft-queue-zone" AllowReorder="true"/>
</ChildContent>
<ItemRenderer>
@RenderRow(context)
</ItemRenderer>
</MudDropContainer>
}
else
{
<div class="deepdrft-queue-list">
@foreach (var row in Rows)
{
@RenderRow(row)
}
</div>
}
}
@code {
/// <summary>The ordered tracks to render. Empty/null renders nothing.</summary>
[Parameter] public IReadOnlyList<TrackDto>? Items { get; set; }
/// <summary>
/// Index of the current track within <see cref="Items"/>, or -1 when none. The matching row is
/// rendered with a now-playing marker.
/// </summary>
[Parameter] public int CurrentIndex { get; set; } = -1;
/// <summary>
/// When true, rows show drag handles and a remove control and reorder is enabled. When false the
/// list is a read-only display (the embed's fixed-order shared queue).
/// </summary>
[Parameter] public bool Editable { get; set; }
/// <summary>
/// Raised when the user reorders a row: <c>(fromIndex, toIndex)</c>. The parent calls
/// <c>IQueueService.Move</c>. Only fires when <see cref="Editable"/>.
/// </summary>
[Parameter] public EventCallback<(int FromIndex, int ToIndex)> OnReorder { get; set; }
/// <summary>
/// Raised when the user removes a row, carrying the row's index. The parent calls
/// <c>IQueueService.RemoveAt</c>. Only fires when <see cref="Editable"/>.
/// </summary>
[Parameter] public EventCallback<int> OnRemove { get; set; }
/// <summary>
/// Raised when the user clicks a row body to jump playback to it, carrying the row's index. The
/// parent decides whether/how to honour it (e.g. play from that index).
/// </summary>
[Parameter] public EventCallback<int> OnJump { get; set; }
private MudDropContainer<QueueRow>? _dropContainer;
// MudDropContainer snapshots its Items into internal drop zones and does not re-read them on a
// plain re-render — so a Clear/remove/reorder that changes the parent's Items list must be pushed
// into the container explicitly, or the panel shows the stale order until reopened (bug #4). The
// parent passes a fresh Items reference per mutation; refreshing here on every parameter set re-flows
// the container's snapshot to match. Cheap: Refresh only re-reads the bound list.
protected override void OnParametersSet() => _dropContainer?.Refresh();
// Index-tagged view rows. The index is the row's position in Items at render time and is the
// value surfaced to the parent's callbacks — the component never mutates the underlying list.
private List<QueueRow> Rows =>
Items is null
? []
: Items.Select((track, index) => new QueueRow(index, track)).ToList();
private async Task OnItemDropped(MudItemDropInfo<QueueRow> dropInfo)
{
var from = dropInfo.Item!.Index;
var to = dropInfo.IndexInZone;
// MudDropContainer recomputes the list from the parent's next render; refresh its snapshot so
// the dragged row snaps back until the parent's Move re-flows the cascaded Items.
_dropContainer?.Refresh();
if (from == to) return;
await OnReorder.InvokeAsync((from, to));
}
private sealed record QueueRow(int Index, TrackDto Track);
private RenderFragment RenderRow(QueueRow row) => __builder =>
{
var isCurrent = row.Index == CurrentIndex;
<div class="@($"deepdrft-queue-row{(isCurrent ? " deepdrft-queue-row-current" : "")}")">
@if (Editable)
{
<MudIcon Icon="@Icons.Material.Filled.DragIndicator" Size="Size.Small"
Class="deepdrft-queue-drag-handle"/>
}
<span class="deepdrft-queue-position">@(row.Index + 1)</span>
<div class="deepdrft-queue-body" @onclick="() => OnJump.InvokeAsync(row.Index)">
<span class="deepdrft-queue-title">@row.Track.TrackName</span>
@if (row.Track.Release is { Artist: var artist } && !string.IsNullOrWhiteSpace(artist))
{
<span class="deepdrft-queue-artist">@artist</span>
}
</div>
@if (isCurrent)
{
<MudIcon Icon="@Icons.Material.Filled.GraphicEq" Size="Size.Small"
Color="Color.Primary" Class="deepdrft-queue-nowplaying"/>
}
@* The current track cannot be removed (OQ3/OQ11): the queue empties only organically as the
current ends with nothing after it. Suppress the × on the current row only — reorder of the
current track is still allowed. *@
@if (Editable && !isCurrent)
{
<MudIconButton Icon="@Icons.Material.Filled.Close" Size="Size.Small"
Class="deepdrft-queue-remove" aria-label="Remove from queue"
OnClick="() => OnRemove.InvokeAsync(row.Index)"/>
}
</div>
};
}