Merge p17-w2-t1-docked-overlay into dev (Phase 17 Wave 17.2: docked queue overlay + ClearUpcoming)
This commit is contained in:
@@ -25,6 +25,9 @@ else
|
|||||||
HasPrevious="HasPrevious"
|
HasPrevious="HasPrevious"
|
||||||
SkipNext="@SkipNext"
|
SkipNext="@SkipNext"
|
||||||
SkipPrevious="@SkipPrevious"
|
SkipPrevious="@SkipPrevious"
|
||||||
|
ShowQueueButton="ShowQueueButton"
|
||||||
|
QueueOpen="_queueOpen"
|
||||||
|
QueueToggle="@ToggleQueue"
|
||||||
Class="transport-zone"/>
|
Class="transport-zone"/>
|
||||||
|
|
||||||
<VolumeZone Volume="@Volume" VolumeChanged="@OnVolumeChange"/>
|
<VolumeZone Volume="@Volume" VolumeChanged="@OnVolumeChange"/>
|
||||||
@@ -49,12 +52,27 @@ else
|
|||||||
|
|
||||||
@if (!string.IsNullOrEmpty(ErrorMessage))
|
@if (!string.IsNullOrEmpty(ErrorMessage))
|
||||||
{
|
{
|
||||||
<MudAlert Severity="Severity.Error"
|
<MudAlert Severity="Severity.Error"
|
||||||
ShowCloseIcon="true"
|
ShowCloseIcon="true"
|
||||||
CloseIconClicked="ClearError"
|
CloseIconClicked="ClearError"
|
||||||
Class="ma-2">
|
Class="ma-2">
|
||||||
@ErrorMessage
|
@ErrorMessage
|
||||||
</MudAlert>
|
</MudAlert>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@* Docked queue overlay (Phase 17 §3.2). MudOverlay portals to the body, so its position here in
|
||||||
|
the dock subtree does not affect its screen-centered rendering. Only mounted in docked mode —
|
||||||
|
the Fixed embed gets its own inline panel in a later wave. *@
|
||||||
|
@if (ShowQueueButton)
|
||||||
|
{
|
||||||
|
<QueueOverlay Visible="_queueOpen"
|
||||||
|
Items="QueueItems"
|
||||||
|
CurrentIndex="QueueCurrentIndex"
|
||||||
|
OnClose="@CloseQueue"
|
||||||
|
OnClear="@ClearUpcoming"
|
||||||
|
OnReorder="@OnQueueReorder"
|
||||||
|
OnRemove="@OnQueueRemove"
|
||||||
|
OnJump="@OnQueueJump"/>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
|||||||
private bool _isMinimized = true;
|
private bool _isMinimized = true;
|
||||||
private bool _isSeeking = false;
|
private bool _isSeeking = false;
|
||||||
private double _seekPosition = 0;
|
private double _seekPosition = 0;
|
||||||
|
private bool _queueOpen = false;
|
||||||
private IStreamingPlayerService? _subscribedService;
|
private IStreamingPlayerService? _subscribedService;
|
||||||
private IQueueService? _subscribedQueue;
|
private IQueueService? _subscribedQueue;
|
||||||
|
|
||||||
@@ -63,6 +64,13 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
|||||||
private bool HasNext => QueueService?.HasNext ?? false;
|
private bool HasNext => QueueService?.HasNext ?? false;
|
||||||
private bool HasPrevious => QueueService?.HasPrevious ?? false;
|
private bool HasPrevious => QueueService?.HasPrevious ?? false;
|
||||||
|
|
||||||
|
// Queue overlay state. The button (and overlay) appear only in docked mode with a non-empty queue,
|
||||||
|
// mirroring the skip-affordance gating (AC1): with no queue the bar is byte-for-byte its pre-queue
|
||||||
|
// self. The Fixed embed gets an inline panel in a later wave, so the docked overlay is !Fixed-only.
|
||||||
|
private bool ShowQueueButton => !Fixed && (QueueService?.Items.Count ?? 0) > 0;
|
||||||
|
private IReadOnlyList<TrackDto> QueueItems => QueueService?.Items ?? [];
|
||||||
|
private int QueueCurrentIndex => QueueService?.CurrentIndex ?? -1;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Display time - shows seek position while dragging, otherwise current playback time.
|
/// Display time - shows seek position while dragging, otherwise current playback time.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -106,7 +114,16 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
|||||||
|
|
||||||
private void OnPlayerStateChanged() => InvokeAsync(StateHasChanged);
|
private void OnPlayerStateChanged() => InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
private void OnQueueChanged() => InvokeAsync(StateHasChanged);
|
private void OnQueueChanged()
|
||||||
|
{
|
||||||
|
// If a removal emptied the queue while the overlay was open, the button disappears (AC1) — close
|
||||||
|
// the overlay so it cannot strand open over an empty queue. The button gate hides the overlay
|
||||||
|
// mount too, so this keeps state and view consistent.
|
||||||
|
if (_queueOpen && (QueueService?.Items.Count ?? 0) == 0)
|
||||||
|
_queueOpen = false;
|
||||||
|
|
||||||
|
InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task SkipNext()
|
private async Task SkipNext()
|
||||||
{
|
{
|
||||||
@@ -120,6 +137,27 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
|||||||
await QueueService.Previous();
|
await QueueService.Previous();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ToggleQueue() => _queueOpen = !_queueOpen;
|
||||||
|
|
||||||
|
private void CloseQueue() => _queueOpen = false;
|
||||||
|
|
||||||
|
// Reorder/remove/clear are interop-free engine mutations (C2/C5): they never re-stream or interrupt
|
||||||
|
// the playing track. QueueChanged re-renders the bar and the overlay's list.
|
||||||
|
private void OnQueueReorder((int FromIndex, int ToIndex) move) =>
|
||||||
|
QueueService?.Move(move.FromIndex, move.ToIndex);
|
||||||
|
|
||||||
|
private void OnQueueRemove(int index) => QueueService?.RemoveAt(index);
|
||||||
|
|
||||||
|
private void ClearUpcoming() => QueueService?.ClearUpcoming();
|
||||||
|
|
||||||
|
// Jump reuses the existing "play from index" semantics (OQ2). This is the one queue action that
|
||||||
|
// touches playback — it streams the chosen track via the player.
|
||||||
|
private async Task OnQueueJump(int index)
|
||||||
|
{
|
||||||
|
if (QueueService == null) return;
|
||||||
|
await QueueService.PlayRelease(QueueService.Items, index);
|
||||||
|
}
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
{
|
{
|
||||||
// The Fixed embed is already in normal flow — no spacer/clip needed.
|
// The Fixed embed is already in normal flow — no spacer/clip needed.
|
||||||
@@ -260,6 +298,12 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
|||||||
_subscribedService = null;
|
_subscribedService = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_subscribedQueue != null)
|
||||||
|
{
|
||||||
|
_subscribedQueue.QueueChanged -= OnQueueChanged;
|
||||||
|
_subscribedQueue = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (_spacerModule is not null)
|
if (_spacerModule is not null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -20,5 +20,20 @@
|
|||||||
Indeterminate="@(LoadProgress == 0)"/>
|
Indeterminate="@(LoadProgress == 0)"/>
|
||||||
}
|
}
|
||||||
</MudStack>
|
</MudStack>
|
||||||
|
@* Queue toggle: a second row between the transport controls and the timestamp (§3.1 placement —
|
||||||
|
"below the control buttons, to the left of the timestamps"). Shown only when a queue is loaded,
|
||||||
|
mirroring the skip-affordance gating, so an empty/single-track player is byte-for-byte unchanged. *@
|
||||||
|
@if (ShowQueueButton)
|
||||||
|
{
|
||||||
|
<MudTooltip Text="Queue">
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.QueueMusic"
|
||||||
|
Color="Color.Primary"
|
||||||
|
Size="Size.Medium"
|
||||||
|
OnClick="QueueToggle"
|
||||||
|
aria-label="Queue"
|
||||||
|
aria-expanded="@QueueOpen"
|
||||||
|
Class="@($"deepdrft-queue-toggle{(QueueOpen ? " deepdrft-queue-toggle-active" : "")}")"/>
|
||||||
|
</MudTooltip>
|
||||||
|
}
|
||||||
<TimestampLabel CurrentTime="DisplayTime" Duration="@Duration"/>
|
<TimestampLabel CurrentTime="DisplayTime" Duration="@Duration"/>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
|
|||||||
@@ -18,5 +18,15 @@ public partial class PlayerTransportZone : ComponentBase
|
|||||||
[Parameter] public bool HasPrevious { get; set; }
|
[Parameter] public bool HasPrevious { get; set; }
|
||||||
[Parameter] public EventCallback SkipNext { get; set; }
|
[Parameter] public EventCallback SkipNext { get; set; }
|
||||||
[Parameter] public EventCallback SkipPrevious { get; set; }
|
[Parameter] public EventCallback SkipPrevious { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Whether to render the Queue toggle button. Gated on a non-empty queue by the bar.</summary>
|
||||||
|
[Parameter] public bool ShowQueueButton { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Whether the queue overlay is open. Drives the button's active state.</summary>
|
||||||
|
[Parameter] public bool QueueOpen { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Raised when the Queue button is clicked. The bar toggles the overlay.</summary>
|
||||||
|
[Parameter] public EventCallback QueueToggle { get; set; }
|
||||||
|
|
||||||
[Parameter] public string? Class { get; set; }
|
[Parameter] public string? Class { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,7 +113,10 @@
|
|||||||
<MudIcon Icon="@Icons.Material.Filled.GraphicEq" Size="Size.Small"
|
<MudIcon Icon="@Icons.Material.Filled.GraphicEq" Size="Size.Small"
|
||||||
Color="Color.Primary" Class="deepdrft-queue-nowplaying"/>
|
Color="Color.Primary" Class="deepdrft-queue-nowplaying"/>
|
||||||
}
|
}
|
||||||
@if (Editable)
|
@* 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"
|
<MudIconButton Icon="@Icons.Material.Filled.Close" Size="Size.Small"
|
||||||
Class="deepdrft-queue-remove" aria-label="Remove from queue"
|
Class="deepdrft-queue-remove" aria-label="Remove from queue"
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
@namespace DeepDrftPublic.Client.Controls
|
||||||
|
@using DeepDrftModels.DTOs
|
||||||
|
|
||||||
|
@* The docked player's queue panel: a screen-centered, mostly-square modal hosting the editable
|
||||||
|
QueueList (Phase 17 §3.2). The overlay shell, dismissal, and drag-safety are a direct lift of
|
||||||
|
WaveformVisualizerControlPopover (Phase 15 §4):
|
||||||
|
- MudOverlay (DarkBackground = mild tint, Modal = focus/scroll stay on the panel).
|
||||||
|
- Scrim OnClick closes; the panel stops click propagation so an inside click is not a dismissal.
|
||||||
|
- AutoClose left OFF; dismissal is the explicit scrim click only. A MudDropContainer drag that
|
||||||
|
ends outside the panel does not synthesise a click on the scrim, so a reorder drag never
|
||||||
|
dismisses (same drag-safety posture as the visualizer popover).
|
||||||
|
This host owns NO queue state and NO JS interop — it renders Items/CurrentIndex and forwards
|
||||||
|
QueueList's reorder/remove/jump callbacks plus a Clear action to the parent (AudioPlayerBar), which
|
||||||
|
holds the cascaded IQueueService. Purely presentational; prerender-safe. *@
|
||||||
|
|
||||||
|
<MudOverlay Visible="@Visible"
|
||||||
|
DarkBackground="true"
|
||||||
|
Modal="true"
|
||||||
|
OnClick="@OnClose"
|
||||||
|
Class="deepdrft-queue-overlay">
|
||||||
|
<div class="deepdrft-queue-modal" @onclick:stopPropagation="true">
|
||||||
|
<div class="deepdrft-queue-modal-header">
|
||||||
|
<span class="deepdrft-queue-modal-title">Up Next</span>
|
||||||
|
<MudButton Variant="Variant.Text"
|
||||||
|
Size="Size.Small"
|
||||||
|
Color="Color.Primary"
|
||||||
|
Disabled="@(!CanClear)"
|
||||||
|
OnClick="@OnClear"
|
||||||
|
Class="deepdrft-queue-clear">Clear</MudButton>
|
||||||
|
</div>
|
||||||
|
<div class="deepdrft-queue-modal-body">
|
||||||
|
<QueueList Items="Items"
|
||||||
|
CurrentIndex="CurrentIndex"
|
||||||
|
Editable="true"
|
||||||
|
OnReorder="OnReorder"
|
||||||
|
OnRemove="OnRemove"
|
||||||
|
OnJump="OnJump"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MudOverlay>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>Whether the overlay is shown. Owned by the parent (the Queue button toggles it).</summary>
|
||||||
|
[Parameter] public bool Visible { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The queue to render. Passed straight through to <see cref="QueueList"/>.</summary>
|
||||||
|
[Parameter] public IReadOnlyList<TrackDto>? Items { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Index of the current track within <see cref="Items"/>, or -1 when none.</summary>
|
||||||
|
[Parameter] public int CurrentIndex { get; set; } = -1;
|
||||||
|
|
||||||
|
/// <summary>Raised when the scrim is clicked to dismiss the overlay.</summary>
|
||||||
|
[Parameter] public EventCallback OnClose { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Raised when Clear is pressed — empties the up-next, keeping the current track playing.</summary>
|
||||||
|
[Parameter] public EventCallback OnClear { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Reorder callback forwarded from the hosted <see cref="QueueList"/>.</summary>
|
||||||
|
[Parameter] public EventCallback<(int FromIndex, int ToIndex)> OnReorder { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Remove callback forwarded from the hosted <see cref="QueueList"/>.</summary>
|
||||||
|
[Parameter] public EventCallback<int> OnRemove { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Jump-to-track callback forwarded from the hosted <see cref="QueueList"/>.</summary>
|
||||||
|
[Parameter] public EventCallback<int> OnJump { get; set; }
|
||||||
|
|
||||||
|
// Clear is meaningful only when there is something beyond the current track to discard.
|
||||||
|
private bool CanClear => Items is { Count: > 1 };
|
||||||
|
}
|
||||||
@@ -138,4 +138,15 @@ public interface IQueueService
|
|||||||
|
|
||||||
/// <summary>Empties the queue and resets the position. Does not stop the player.</summary>
|
/// <summary>Empties the queue and resets the position. Does not stop the player.</summary>
|
||||||
void Clear();
|
void Clear();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Empties the up-next while keeping the currently-playing track: removes every item except
|
||||||
|
/// <see cref="Current"/>, leaving it as the sole remaining item at <see cref="CurrentIndex"/> == 0,
|
||||||
|
/// and re-emits <see cref="QueueChanged"/>. Unlike <see cref="Clear"/> (which empties everything and
|
||||||
|
/// goes dormant), this preserves what is playing — the player is never stopped and the current track
|
||||||
|
/// stays queued, so playback continues uninterrupted while the rest of the queue is discarded.
|
||||||
|
/// Interop-free; safe during prerender. No-op (no throw, no <see cref="QueueChanged"/>) when the queue
|
||||||
|
/// is empty/dormant or already holds only the current track.
|
||||||
|
/// </summary>
|
||||||
|
void ClearUpcoming();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ public sealed class QueueService : IQueueService, IDisposable
|
|||||||
|
|
||||||
public async Task PlayRelease(IEnumerable<TrackDto> tracks, int startIndex = 0)
|
public async Task PlayRelease(IEnumerable<TrackDto> tracks, int startIndex = 0)
|
||||||
{
|
{
|
||||||
var list = tracks as IReadOnlyList<TrackDto> ?? tracks.ToList();
|
var list = tracks.ToList();
|
||||||
if (list.Count == 0) return;
|
if (list.Count == 0) return;
|
||||||
|
|
||||||
var start = Math.Clamp(startIndex, 0, list.Count - 1);
|
var start = Math.Clamp(startIndex, 0, list.Count - 1);
|
||||||
@@ -201,6 +201,20 @@ public sealed class QueueService : IQueueService, IDisposable
|
|||||||
QueueChanged?.Invoke();
|
QueueChanged?.Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void ClearUpcoming()
|
||||||
|
{
|
||||||
|
// Keep the currently-playing track, drop everything else. No current track (dormant/empty) or a
|
||||||
|
// queue that already holds only the current → nothing to clear.
|
||||||
|
var current = Current;
|
||||||
|
if (current is null || _items.Count <= 1) return;
|
||||||
|
|
||||||
|
_items.Clear();
|
||||||
|
_items.Add(current);
|
||||||
|
CurrentIndex = 0;
|
||||||
|
// Playback is untouched (C2): the current track keeps streaming; we only discarded the up-next.
|
||||||
|
QueueChanged?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
// Advance on organic end-of-stream only. TrackEnded is not raised by stop/unload/track-switch,
|
// 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
|
// 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.
|
// queue. When the queue is past its last track, end-of-stream simply stops — nothing to advance.
|
||||||
|
|||||||
@@ -750,3 +750,158 @@ body:has(.waveform-visualizer-control-overlay) {
|
|||||||
color: var(--mud-palette-text-primary);
|
color: var(--mud-palette-text-primary);
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
QUEUE OVERLAY + LIST (Phase 17 wave 17.2 — docked queue panel)
|
||||||
|
|
||||||
|
The overlay is a direct lift of the visualizer-control modal (Phase 15 §4): a centered MudOverlay
|
||||||
|
whose scrim tint + z-index + body-scroll lock match that idiom exactly. The panel chrome (square
|
||||||
|
corners, lighter-navy ground, thin light border) is the NowPlayingCard treatment (§5). MudOverlay
|
||||||
|
portals out of the component subtree to the body, so these are plain GLOBAL rules — CSS isolation
|
||||||
|
cannot reach portaled content.
|
||||||
|
============================================================================= */
|
||||||
|
|
||||||
|
/* Raise the overlay above the sticky header (100), the fixed player dock (1200), and the minimized
|
||||||
|
FAB (1300) — same stacking decision as the visualizer overlay so the scrim tints the whole viewport. */
|
||||||
|
.deepdrft-queue-overlay {
|
||||||
|
z-index: 1400 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mild modal tint from the shared scrim token. The doubled selector (0,2,0) outranks MudBlazor's own
|
||||||
|
.mud-overlay-dark (0,1,0) regardless of stylesheet load order. */
|
||||||
|
.deepdrft-queue-overlay .mud-overlay-scrim.mud-overlay-dark {
|
||||||
|
background-color: rgba(var(--deepdrft-scrim-rgb), var(--deepdrft-modal-scrim-alpha));
|
||||||
|
}
|
||||||
|
|
||||||
|
.deepdrft-queue-overlay .mud-overlay-content {
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lock body scroll while the queue overlay is open (matches the visualizer overlay). */
|
||||||
|
body:has(.deepdrft-queue-overlay) {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The mostly-square panel (§3.2: min(90vw, 520px)). NowPlayingCard chrome: square corners, lighter-navy
|
||||||
|
ground, thin light border. Internal column: fixed header over a scrollable list body. */
|
||||||
|
.deepdrft-queue-modal {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: min(90vw, 520px);
|
||||||
|
height: min(90vw, 520px);
|
||||||
|
max-height: 90vh;
|
||||||
|
background: var(--deepdrft-panel-ground);
|
||||||
|
border: 1px solid var(--deepdrft-border-light);
|
||||||
|
border-radius: 0;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deepdrft-queue-modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--deepdrft-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mono uppercase eyebrow — the NowPlayingCard .np-label typography, recoloured light (static). */
|
||||||
|
.deepdrft-queue-modal-title {
|
||||||
|
font-family: var(--deepdrft-font-mono);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--deepdrft-white);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deepdrft-queue-modal-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.5rem 0.5rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── The list itself (consumed by QueueList in both modes; styled here once). ── */
|
||||||
|
.deepdrft-queue-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deepdrft-queue-zone {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deepdrft-queue-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.45rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--deepdrft-white);
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deepdrft-queue-row:hover {
|
||||||
|
background: color-mix(in srgb, var(--deepdrft-white) 6%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Current track: a subtle green wash + left accent, matching the green = active principle. */
|
||||||
|
.deepdrft-queue-row-current {
|
||||||
|
background: color-mix(in srgb, var(--deepdrft-green-accent) 14%, transparent);
|
||||||
|
box-shadow: inset 2px 0 0 0 var(--deepdrft-green-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deepdrft-queue-drag-handle {
|
||||||
|
cursor: grab;
|
||||||
|
opacity: 0.45;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deepdrft-queue-position {
|
||||||
|
font-family: var(--deepdrft-font-mono);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
min-width: 1.4rem;
|
||||||
|
text-align: right;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Row body grows + truncates; clicking it jumps playback (OQ2). */
|
||||||
|
.deepdrft-queue-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deepdrft-queue-title {
|
||||||
|
font-size: 0.92rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deepdrft-queue-artist {
|
||||||
|
font-size: 0.74rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deepdrft-queue-nowplaying,
|
||||||
|
.deepdrft-queue-remove {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active (open) state for the bar's Queue toggle — a soft green chip behind the glyph, matching the
|
||||||
|
visualizer toggle's on-state idiom. */
|
||||||
|
.deepdrft-queue-toggle-active {
|
||||||
|
background: color-mix(in srgb, var(--deepdrft-green-accent) 22%, transparent);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -144,6 +144,33 @@ public class QueueServiceTests
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task PlayRelease_ViaLiveQueueItems_PreservesTracksAndJumpsToIndex()
|
||||||
|
{
|
||||||
|
// Regression guard for the aliasing bug: OnQueueJump calls PlayRelease(QueueService.Items, index).
|
||||||
|
// Items returns the backing list directly; without a defensive copy, the cast
|
||||||
|
// "tracks as IReadOnlyList<TrackDto>" aliases _items, so _items.Clear() also clears list,
|
||||||
|
// and _items.AddRange(list) adds nothing — wiping the queue and playing nothing.
|
||||||
|
await _queue.PlayRelease(Tracks(4)); // populate the live queue
|
||||||
|
|
||||||
|
// Jump to index 2 via the live Items reference, exactly as OnQueueJump does.
|
||||||
|
await _queue.PlayRelease(_queue.Items, 2);
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
// The queue must survive — all four tracks still present, in order.
|
||||||
|
Assert.That(_queue.Items, Has.Count.EqualTo(4));
|
||||||
|
Assert.That(_queue.Items.Select(t => t.EntryKey),
|
||||||
|
Is.EqualTo(new[] { "track-1", "track-2", "track-3", "track-4" }));
|
||||||
|
// CurrentIndex must be the jumped-to slot.
|
||||||
|
Assert.That(_queue.CurrentIndex, Is.EqualTo(2));
|
||||||
|
// Current must be the right track.
|
||||||
|
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-3"));
|
||||||
|
// The player must have streamed the jumped-to track.
|
||||||
|
Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("track-3"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- Arm: prerender-safe load without streaming (release embed) ---
|
// --- Arm: prerender-safe load without streaming (release embed) ---
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -678,6 +705,75 @@ public class QueueServiceTests
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- ClearUpcoming (OQ5: keep the current track, drop the up-next) — Phase 17 wave 17.2 ---
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ClearUpcoming_KeepsCurrentTrack_DropsTheRest_WithoutStopping()
|
||||||
|
{
|
||||||
|
// Current = track-2; ClearUpcoming leaves only track-2 at index 0 and does not stop the player.
|
||||||
|
await _queue.PlayRelease(Tracks(4), startIndex: 1);
|
||||||
|
var streamedBefore = _player.SelectedTracks.Count;
|
||||||
|
|
||||||
|
_queue.ClearUpcoming();
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "track-2" }));
|
||||||
|
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
|
||||||
|
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-2"));
|
||||||
|
Assert.That(_queue.HasNext, Is.False);
|
||||||
|
Assert.That(_queue.HasPrevious, Is.False);
|
||||||
|
Assert.That(_player.StopCount, Is.EqualTo(0), "ClearUpcoming must not stop playback (C2)");
|
||||||
|
Assert.That(_player.SelectedTracks, Has.Count.EqualTo(streamedBefore),
|
||||||
|
"ClearUpcoming must not re-stream");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ClearUpcoming_RaisesQueueChangedOnce()
|
||||||
|
{
|
||||||
|
await _queue.PlayRelease(Tracks(3));
|
||||||
|
var changed = 0;
|
||||||
|
_queue.QueueChanged += () => changed++;
|
||||||
|
|
||||||
|
_queue.ClearUpcoming();
|
||||||
|
|
||||||
|
Assert.That(changed, Is.EqualTo(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ClearUpcoming_WhenOnlyCurrentRemains_IsNoOpAndDoesNotRaiseQueueChanged()
|
||||||
|
{
|
||||||
|
await _queue.PlayRelease(Tracks(1));
|
||||||
|
var raised = false;
|
||||||
|
_queue.QueueChanged += () => raised = true;
|
||||||
|
|
||||||
|
_queue.ClearUpcoming();
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(_queue.Items, Has.Count.EqualTo(1));
|
||||||
|
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
|
||||||
|
Assert.That(raised, Is.False);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ClearUpcoming_OnEmptyQueue_IsNoOpAndDoesNotRaiseQueueChanged()
|
||||||
|
{
|
||||||
|
var raised = false;
|
||||||
|
_queue.QueueChanged += () => raised = true;
|
||||||
|
|
||||||
|
_queue.ClearUpcoming();
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(_queue.Items, Is.Empty);
|
||||||
|
Assert.That(_queue.CurrentIndex, Is.EqualTo(-1));
|
||||||
|
Assert.That(raised, Is.False);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- QueueChanged notifications ---
|
// --- QueueChanged notifications ---
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
|
|||||||
Reference in New Issue
Block a user