Merge pull request 'Queue fix + cross-client queue enhancements' (#112) from dev into main
test-web / test (push) Successful in 1m10s
android / Build + lint + test (push) Successful in 4m59s
release / Build signed APK (tag releases only) (push) Successful in 4m3s
release / Build + push container image (push) Successful in 1m42s

This commit was merged in pull request #112.
This commit is contained in:
2026-07-23 08:31:34 -04:00
10 changed files with 414 additions and 52 deletions
@@ -288,6 +288,37 @@ class PlayerController @Inject constructor(
controller.addMediaItem(track.toMediaItem(source = null)) controller.addMediaItem(track.toMediaItem(source = null))
} }
/**
* Reorder the queue: move the item at [from] to [to], keeping the domain
* snapshot in lock-step with the player's MediaItem timeline. Media3 emits
* onEvents → uiState reflects the new order (and the still-playing item's
* index). No-op on bad indices or a no-move.
*/
fun moveInQueue(from: Int, to: Int) {
val controller = mediaController ?: return
if (from !in queueRefs.indices || to !in queueRefs.indices || from == to) return
queueRefs = queueRefs.toMutableList().apply { add(to, removeAt(from)) }
controller.moveMediaItem(from, to)
}
/**
* Remove the queue item at [index]. When it's the currently-playing item
* Media3 advances to the next automatically. No-op on a bad index.
*/
fun removeFromQueue(index: Int) {
val controller = mediaController ?: return
if (index !in queueRefs.indices) return
queueRefs = queueRefs.toMutableList().apply { removeAt(index) }
controller.removeMediaItem(index)
}
/** Empty the queue and stop playback. */
fun clearQueue() {
val controller = mediaController ?: return
queueRefs = emptyList()
controller.clearMediaItems()
}
/** /**
* Seed a fresh radio queue from [trackId]. The `source` tag is * Seed a fresh radio queue from [trackId]. The `source` tag is
* "radio:<id>" so the server-side rotation reporter can * "radio:<id>" so the server-side rotation reporter can
@@ -25,6 +25,7 @@ import javax.inject.Inject
* stub-test for ViewModel-level logic when it grows). * stub-test for ViewModel-level logic when it grows).
*/ */
@HiltViewModel @HiltViewModel
@Suppress("TooManyFunctions") // Thin transport facade — each fun forwards to PlayerController.
class PlayerViewModel @Inject constructor( class PlayerViewModel @Inject constructor(
private val controller: PlayerController, private val controller: PlayerController,
private val likes: LikesRepository, private val likes: LikesRepository,
@@ -55,6 +56,9 @@ class PlayerViewModel @Inject constructor(
fun seekToIndex(index: Int) = controller.seekToIndex(index) fun seekToIndex(index: Int) = controller.seekToIndex(index)
fun toggleShuffle() = controller.toggleShuffle() fun toggleShuffle() = controller.toggleShuffle()
fun cycleRepeat() = controller.cycleRepeat() fun cycleRepeat() = controller.cycleRepeat()
fun moveInQueue(from: Int, to: Int) = controller.moveInQueue(from, to)
fun removeFromQueue(index: Int) = controller.removeFromQueue(index)
fun clearQueue() = controller.clearQueue()
fun toggleLikeTrack(trackId: String) { fun toggleLikeTrack(trackId: String) {
val desired = trackId !in likedTrackIds.value val desired = trackId !in likedTrackIds.value
@@ -1,17 +1,25 @@
package com.fabledsword.minstrel.player.ui package com.fabledsword.minstrel.player.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -20,23 +28,43 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import com.composables.icons.lucide.ArrowDown
import com.composables.icons.lucide.ArrowLeft import com.composables.icons.lucide.ArrowLeft
import com.composables.icons.lucide.GripVertical
import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Music
import com.composables.icons.lucide.Trash2
import com.composables.icons.lucide.Volume2 import com.composables.icons.lucide.Volume2
import com.composables.icons.lucide.X
import com.fabledsword.minstrel.models.TrackRef import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.shared.formatDuration import com.fabledsword.minstrel.shared.formatDuration
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.LikeButton import com.fabledsword.minstrel.shared.widgets.LikeButton
import com.fabledsword.minstrel.shared.widgets.ServerImage
import kotlin.math.roundToInt
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -50,12 +78,30 @@ fun QueueScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("Queue") }, title = {
Column {
Text("Queue")
if (state.queue.isNotEmpty()) {
Text(
text = queueSummary(state.queue),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
navigationIcon = { navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) { IconButton(onClick = { navController.popBackStack() }) {
Icon(Lucide.ArrowLeft, contentDescription = "Back") Icon(Lucide.ArrowLeft, contentDescription = "Back")
} }
}, },
actions = {
if (state.queue.isNotEmpty()) {
IconButton(onClick = viewModel::clearQueue) {
Icon(Lucide.Trash2, contentDescription = "Clear queue")
}
}
},
) )
}, },
) { inner -> ) { inner ->
@@ -72,12 +118,15 @@ fun QueueScreen(
likedTrackIds = likedTrackIds, likedTrackIds = likedTrackIds,
onJumpTo = viewModel::seekToIndex, onJumpTo = viewModel::seekToIndex,
onToggleLike = viewModel::toggleLikeTrack, onToggleLike = viewModel::toggleLikeTrack,
onMove = viewModel::moveInQueue,
onRemove = viewModel::removeFromQueue,
) )
} }
} }
} }
} }
@Suppress("LongParameterList") // Compose list wiring — layout + queue callbacks, not logic.
@Composable @Composable
private fun QueueList( private fun QueueList(
tracks: List<TrackRef>, tracks: List<TrackRef>,
@@ -85,29 +134,90 @@ private fun QueueList(
likedTrackIds: Set<String>, likedTrackIds: Set<String>,
onJumpTo: (Int) -> Unit, onJumpTo: (Int) -> Unit,
onToggleLike: (String) -> Unit, onToggleLike: (String) -> Unit,
onMove: (Int, Int) -> Unit,
onRemove: (Int) -> Unit,
) { ) {
LazyColumn(modifier = Modifier.fillMaxSize()) { val listState = rememberLazyListState(
itemsIndexed(items = tracks, key = { _, track -> track.id }) { index, track -> initialFirstVisibleItemIndex = currentIndex.coerceIn(0, tracks.lastIndex),
QueueRow( )
track = track, val scope = rememberCoroutineScope()
isCurrent = index == currentIndex,
liked = track.id in likedTrackIds, // Follow the now-playing row as the track auto-advances, but only while it's
onClick = { onJumpTo(index) }, // near the visible window — if the user has scrolled away to browse, leave
onToggleLike = { onToggleLike(track.id) }, // them there (the pill offers the way back). Parity with the web queue.
) LaunchedEffect(currentIndex) {
HorizontalDivider() if (currentIndex < 0) return@LaunchedEffect
val visible = listState.layoutInfo.visibleItemsInfo
val first = visible.firstOrNull()?.index ?: 0
val last = visible.lastOrNull()?.index ?: 0
if (currentIndex in (first - 1)..(last + 1)) {
listState.animateScrollToItem(currentIndex)
} }
} }
val currentVisible by remember {
derivedStateOf {
listState.layoutInfo.visibleItemsInfo.any { it.index == currentIndex }
}
}
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
itemsIndexed(items = tracks, key = { _, track -> track.id }) { index, track ->
QueueRow(
track = track,
index = index,
queueSize = tracks.size,
isCurrent = index == currentIndex,
liked = track.id in likedTrackIds,
onClick = { onJumpTo(index) },
onToggleLike = { onToggleLike(track.id) },
onRemove = { onRemove(index) },
onMove = onMove,
)
HorizontalDivider()
}
}
JumpToCurrentPill(
visible = currentIndex >= 0 && !currentVisible,
onClick = {
scope.launch { listState.animateScrollToItem(currentIndex.coerceAtLeast(0)) }
},
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 16.dp),
)
}
} }
@Composable
private fun JumpToCurrentPill(
visible: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(visible = visible, modifier = modifier) {
FilledTonalButton(onClick = onClick) {
Icon(Lucide.ArrowDown, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(6.dp))
Text("Jump to current")
}
}
}
@Suppress("LongParameterList") // Compose row wiring — layout + queue callbacks, not logic.
@Composable @Composable
private fun QueueRow( private fun QueueRow(
track: TrackRef, track: TrackRef,
index: Int,
queueSize: Int,
isCurrent: Boolean, isCurrent: Boolean,
liked: Boolean, liked: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
onToggleLike: () -> Unit, onToggleLike: () -> Unit,
onRemove: () -> Unit,
onMove: (Int, Int) -> Unit,
) { ) {
var dragOffsetY by remember { mutableFloatStateOf(0f) }
var rowHeightPx by remember { mutableIntStateOf(0) }
val highlight = if (isCurrent) { val highlight = if (isCurrent) {
MaterialTheme.colorScheme.primary.copy(alpha = HIGHLIGHT_ALPHA) MaterialTheme.colorScheme.primary.copy(alpha = HIGHLIGHT_ALPHA)
} else { } else {
@@ -116,12 +226,23 @@ private fun QueueRow(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.onSizeChanged { rowHeightPx = it.height }
.zIndex(if (dragOffsetY != 0f) 1f else 0f)
.graphicsLayer { translationY = dragOffsetY }
.background(highlight) .background(highlight)
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
DragHandle(
index = index,
queueSize = queueSize,
rowHeightPx = rowHeightPx,
onOffsetChange = { dragOffsetY = it },
onMove = onMove,
)
QueueRowThumbnail(track = track)
if (isCurrent) { if (isCurrent) {
Icon( Icon(
Lucide.Volume2, Lucide.Volume2,
@@ -129,26 +250,7 @@ private fun QueueRow(
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
) )
} }
Column(modifier = Modifier.weight(1f)) { QueueRowText(track = track, isCurrent = isCurrent, modifier = Modifier.weight(1f))
Text(
text = track.title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = if (isCurrent) FontWeight.Medium else FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val subtitle = queueSubtitle(track)
if (subtitle.isNotEmpty()) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
if (track.durationSec > 0) { if (track.durationSec > 0) {
Text( Text(
text = formatDuration(track.durationSec), text = formatDuration(track.durationSec),
@@ -157,6 +259,94 @@ private fun QueueRow(
) )
} }
LikeButton(liked = liked, onToggle = onToggleLike) LikeButton(liked = liked, onToggle = onToggleLike)
IconButton(onClick = onRemove) {
Icon(Lucide.X, contentDescription = "Remove from queue")
}
}
}
@Composable
private fun DragHandle(
index: Int,
queueSize: Int,
rowHeightPx: Int,
onOffsetChange: (Float) -> Unit,
onMove: (Int, Int) -> Unit,
) {
// Mirrors the web queue: the row follows the finger during a drag, then on
// release we translate the accumulated offset into a row delta and reorder.
var offset by remember { mutableFloatStateOf(0f) }
Icon(
Lucide.GripVertical,
contentDescription = "Reorder track",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.pointerInput(index, queueSize, rowHeightPx) {
detectDragGestures(
onDrag = { change, dragAmount ->
change.consume()
offset += dragAmount.y
onOffsetChange(offset)
},
onDragEnd = {
val delta = if (rowHeightPx > 0) (offset / rowHeightPx).roundToInt() else 0
val target = (index + delta).coerceIn(0, queueSize - 1)
if (target != index) onMove(index, target)
offset = 0f
onOffsetChange(0f)
},
onDragCancel = {
offset = 0f
onOffsetChange(0f)
},
)
},
)
}
@Composable
private fun QueueRowThumbnail(track: TrackRef) {
Box(
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
ServerImage(
url = track.coverUrl,
contentDescription = null,
modifier = Modifier.size(48.dp),
) {
Icon(
Lucide.Music,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun QueueRowText(track: TrackRef, isCurrent: Boolean, modifier: Modifier = Modifier) {
Column(modifier = modifier) {
Text(
text = track.title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = if (isCurrent) FontWeight.Medium else FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val subtitle = queueSubtitle(track)
if (subtitle.isNotEmpty()) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
} }
} }
@@ -165,4 +355,18 @@ private fun queueSubtitle(track: TrackRef): String = listOf(track.artistName, tr
.filter { it.isNotEmpty() } .filter { it.isNotEmpty() }
.joinToString(" · ") .joinToString(" · ")
/** "N tracks · 12 min" header summary. */
private fun queueSummary(tracks: List<TrackRef>): String {
val minutes = tracks.sumOf { it.durationSec } / SECONDS_PER_MINUTE
val length = if (minutes >= MINUTES_PER_HOUR) {
"${minutes / MINUTES_PER_HOUR}h ${minutes % MINUTES_PER_HOUR}m"
} else {
"$minutes min"
}
val noun = if (tracks.size == 1) "track" else "tracks"
return "${tracks.size} $noun · $length"
}
private const val HIGHLIGHT_ALPHA = 0.12f private const val HIGHLIGHT_ALPHA = 0.12f
private const val SECONDS_PER_MINUTE = 60
private const val MINUTES_PER_HOUR = 60
+5 -1
View File
@@ -35,5 +35,9 @@
transition-transform duration-200 transition-transform duration-200
{player.queueDrawerOpen ? 'translate-x-0' : 'translate-x-full'}" {player.queueDrawerOpen ? 'translate-x-0' : 'translate-x-full'}"
> >
<QueueList onClose={() => closeQueueDrawer()} bind:closeButtonRef={closeButton} /> <QueueList
onClose={() => closeQueueDrawer()}
active={player.queueDrawerOpen}
bind:closeButtonRef={closeButton}
/>
</aside> </aside>
+4 -2
View File
@@ -25,10 +25,12 @@ vi.mock('$lib/player/store.svelte', () => ({
get queueDrawerOpen() { return openValue; } get queueDrawerOpen() { return openValue; }
}, },
// QueueTrackRow imports these from the store; provide stubs so its // QueueTrackRow imports these from the store; provide stubs so its
// module-load doesn't break when QueueDrawer renders rows. // module-load doesn't break when QueueDrawer renders rows. QueueList
// imports clearQueue for its header action.
playFromQueueIndex: vi.fn(), playFromQueueIndex: vi.fn(),
removeFromQueue: vi.fn(), removeFromQueue: vi.fn(),
moveQueueItem: vi.fn() moveQueueItem: vi.fn(),
clearQueue: vi.fn()
})); }));
import QueueDrawer from './QueueDrawer.svelte'; import QueueDrawer from './QueueDrawer.svelte';
+97 -16
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { X } from 'lucide-svelte'; import { untrack } from 'svelte';
import { player } from '$lib/player/store.svelte'; import { X, Trash2, ArrowDown } from 'lucide-svelte';
import { player, clearQueue } from '$lib/player/store.svelte';
import QueueTrackRow from './QueueTrackRow.svelte'; import QueueTrackRow from './QueueTrackRow.svelte';
// onClose: when provided, renders an X button in the header so the // onClose: when provided, renders an X button in the header so the
@@ -8,12 +9,66 @@
// now-playing route (visible at lg+ widths) omits it. // now-playing route (visible at lg+ widths) omits it.
// closeButtonRef: bind:this hook so the drawer can focus the X for // closeButtonRef: bind:this hook so the drawer can focus the X for
// keyboard users on open. // keyboard users on open.
// active: true when the queue is on-screen (drawer open, or the always-
// visible now-playing panel). Gates the scroll-to-current behavior.
type Props = { type Props = {
onClose?: () => void; onClose?: () => void;
closeButtonRef?: HTMLButtonElement; closeButtonRef?: HTMLButtonElement;
active?: boolean;
}; };
let { onClose, closeButtonRef = $bindable() }: Props = $props(); let { onClose, closeButtonRef = $bindable(), active = true }: Props = $props();
let scrollBody: HTMLElement | undefined = $state();
// Whether the now-playing row is (at least partly) within the scroll
// viewport. Drives auto-follow (only follow while the user is watching the
// current track) and the "Jump to current" pill (shown when it's off-screen).
let currentInView = $state(true);
let sawFirstIndex = false;
function scrollToCurrent(block: ScrollLogicalPosition, behavior: ScrollBehavior = 'auto') {
(scrollBody?.children[player.index] as HTMLElement | undefined)?.scrollIntoView({
block,
behavior,
});
currentInView = true;
}
function recomputeInView() {
const row = scrollBody?.children[player.index] as HTMLElement | undefined;
if (!scrollBody || !row) {
currentInView = true;
return;
}
const b = scrollBody.getBoundingClientRect();
const r = row.getBoundingClientRect();
currentInView = r.bottom > b.top && r.top < b.bottom;
}
// On open (active flips true, or on mount for the always-visible panel),
// center the now-playing row — parity with the Android queue.
$effect(() => {
if (!active) return;
if (untrack(() => player.queue.length) === 0) return;
requestAnimationFrame(() => scrollToCurrent('center'));
});
// Follow the current track as it auto-advances, but only while the user is
// still watching it — if they've scrolled away, leave them there (the pill
// offers the way back). block:'nearest' keeps it minimal (no yank when the
// row is already visible). Index is tracked; currentInView is read untracked
// so a scroll that hides the row doesn't itself re-trigger a scroll.
$effect(() => {
player.index; // subscribe: follow on advance
if (!sawFirstIndex) {
sawFirstIndex = true;
return; // the open effect already handled the initial position
}
if (!active) return;
if (untrack(() => player.queue.length) === 0) return;
if (!untrack(() => currentInView)) return;
requestAnimationFrame(() => scrollToCurrent('nearest'));
});
function totalDurationLabel(tracks: { duration_sec: number }[]): string { function totalDurationLabel(tracks: { duration_sec: number }[]): string {
const totalSec = tracks.reduce((s, tr) => s + (tr.duration_sec ?? 0), 0); const totalSec = tracks.reduce((s, tr) => s + (tr.duration_sec ?? 0), 0);
@@ -23,7 +78,7 @@
} }
</script> </script>
<div class="flex h-full flex-col"> <div class="relative flex h-full flex-col">
<div class="flex items-center justify-between border-b border-border px-4 py-3"> <div class="flex items-center justify-between border-b border-border px-4 py-3">
<div> <div>
<h2 class="text-lg font-semibold">Queue</h2> <h2 class="text-lg font-semibold">Queue</h2>
@@ -32,20 +87,33 @@
{#if player.queue.length > 0} · {totalDurationLabel(player.queue)}{/if} {#if player.queue.length > 0} · {totalDurationLabel(player.queue)}{/if}
</p> </p>
</div> </div>
{#if onClose} <div class="flex items-center gap-1">
<button {#if player.queue.length > 0}
type="button" <button
bind:this={closeButtonRef} type="button"
aria-label="Close queue" aria-label="Clear queue"
onclick={onClose} title="Clear queue"
class="text-text-secondary hover:text-text-primary" onclick={() => clearQueue()}
> class="rounded p-1 text-text-secondary hover:text-text-primary"
<X size={20} /> >
</button> <Trash2 size={18} />
{/if} </button>
{/if}
{#if onClose}
<button
type="button"
bind:this={closeButtonRef}
aria-label="Close queue"
onclick={onClose}
class="rounded p-1 text-text-secondary hover:text-text-primary"
>
<X size={20} />
</button>
{/if}
</div>
</div> </div>
<div class="flex-1 overflow-y-auto"> <div bind:this={scrollBody} onscroll={recomputeInView} class="flex-1 overflow-y-auto">
{#if player.queue.length === 0} {#if player.queue.length === 0}
<p class="text-text-secondary text-center p-8">No tracks queued.</p> <p class="text-text-secondary text-center p-8">No tracks queued.</p>
{:else} {:else}
@@ -54,4 +122,17 @@
{/each} {/each}
{/if} {/if}
</div> </div>
{#if active && player.queue.length > 0 && !currentInView}
<button
type="button"
onclick={() => scrollToCurrent('center', 'smooth')}
class="absolute bottom-4 left-1/2 flex -translate-x-1/2 items-center gap-1.5
rounded-full bg-action-secondary px-3 py-1.5 text-xs font-medium
text-action-fg shadow-lg"
>
<ArrowDown size={14} />
Jump to current
</button>
{/if}
</div> </div>
@@ -3,6 +3,7 @@
import { draggable, type DragEventData } from '@neodrag/svelte'; import { draggable, type DragEventData } from '@neodrag/svelte';
import type { TrackRef } from '$lib/api/types'; import type { TrackRef } from '$lib/api/types';
import { playFromQueueIndex, removeFromQueue, moveQueueItem } from '$lib/player/store.svelte'; import { playFromQueueIndex, removeFromQueue, moveQueueItem } from '$lib/player/store.svelte';
import { coverUrl, FALLBACK_COVER } from '$lib/media/covers';
import { offsetToDelta } from './queue-row-math'; import { offsetToDelta } from './queue-row-math';
import LikeButton from './LikeButton.svelte'; import LikeButton from './LikeButton.svelte';
@@ -66,6 +67,13 @@
<GripVertical size={16} /> <GripVertical size={16} />
</button> </button>
<img
src={coverUrl(track.album_id)}
alt=""
onerror={(e) => ((e.currentTarget as HTMLImageElement).src = FALLBACK_COVER)}
class="h-10 w-10 flex-shrink-0 rounded object-cover"
/>
<button <button
type="button" type="button"
onclick={handleBodyClick} onclick={handleBodyClick}
+15
View File
@@ -505,6 +505,21 @@ export function removeFromQueue(idx: number): void {
_error = null; _error = null;
} }
// Clear the whole queue and stop playback — mirrors removeFromQueue's
// empty-queue branch. Also drops the radio/system source + self-heal closure
// so the emptied player doesn't try to refill from a now-irrelevant source.
export function clearQueue(): void {
_queue = [];
_index = 0;
_state = 'idle';
_position = 0;
_duration = 0;
_error = null;
_radioSeedId = null;
_queueSource = null;
_queueRefetch = null;
}
export function playFromQueueIndex(idx: number): void { export function playFromQueueIndex(idx: number): void {
if (idx < 0 || idx >= _queue.length) return; if (idx < 0 || idx >= _queue.length) return;
_radioSeedId = null; _radioSeedId = null;
+7 -2
View File
@@ -168,9 +168,14 @@
style="display: none" style="display: none"
></audio> ></audio>
<QueueDrawer />
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<!-- QueueDrawer must be inside the provider: its rows render LikeButton,
which calls useQueryClient() at init. The drawer's <aside> is always
mounted, so the moment the queue is populated (on first play) those
LikeButtons instantiate — outside the provider they throw
"No QueryClient was found" and abort the play flush. -->
<QueueDrawer />
{#if user.value !== null && page.url.pathname !== '/login' && page.url.pathname !== '/now-playing'} {#if user.value !== null && page.url.pathname !== '/login' && page.url.pathname !== '/now-playing'}
<Shell>{@render children()}</Shell> <Shell>{@render children()}</Shell>
{:else} {:else}
+8
View File
@@ -37,6 +37,14 @@ if (typeof window !== 'undefined') {
Object.defineProperty(window, 'sessionStorage', { configurable: true, value: memSession }); Object.defineProperty(window, 'sessionStorage', { configurable: true, value: memSession });
} }
// jsdom doesn't implement Element.prototype.scrollIntoView. Components that
// call it (queue auto-scroll to the now-playing row, the alphabetical rail)
// would throw an unhandled TypeError in tests — which fails the run even when
// every assertion passes. No-op it; tests never assert on scroll position.
if (typeof Element !== 'undefined' && !Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
// W-T3 moved toast rendering out of per-page markup into a single // W-T3 moved toast rendering out of per-page markup into a single
// <ToastHost /> mounted in +layout.svelte. Tests render individual pages // <ToastHost /> mounted in +layout.svelte. Tests render individual pages
// without the layout, so we mount ToastHost here so `pushToast()` calls // without the layout, so we mount ToastHost here so `pushToast()` calls