Compare commits

...

10 Commits

Author SHA1 Message Date
bvandeusen a26ef4e93c 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
2026-07-23 08:31:34 -04:00
bvandeusen 0774f5f55f fix(player): suppress TooManyFunctions on PlayerViewModel facade — #1944
android / Build + lint + test (push) Successful in 3m43s
Adding the queue move/remove/clear pass-throughs pushed the VM to 12 functions
(detekt cap 11). It's a thin transport facade forwarding to PlayerController, so
suppress with a rationale rather than splitting the delegating surface.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 23:16:17 -04:00
bvandeusen 509cbe79b2 feat(player): Android queue — reorder, remove, art, auto-follow, clear — #1944
android / Build + lint + test (push) Failing after 1m26s
Queue screen gains: album-art thumbnails (ServerImage), drag-to-reorder via a
grip handle (offset->delta on release, mirroring the web), a remove button per
row, auto-follow of the now-playing track with a 'Jump to current' pill when
scrolled away, a clear-queue action, and a header count + total-time summary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 23:12:23 -04:00
bvandeusen dc7b9b78fa feat(player): queue move/remove/clear on PlayerController + VM — #1944
Adds moveInQueue/removeFromQueue/clearQueue, each keeping the domain queueRefs
snapshot in lock-step with the Media3 timeline (mirrors playNext/enqueue). Media3
onEvents rebuilds uiState so the queue view reflects reorder/removal/clear.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 23:12:23 -04:00
bvandeusen cde74b5965 feat(player): web queue auto-follow + jump-to-current pill + clear-queue — #1944
test-web / test (push) Successful in 40s
QueueList now follows the now-playing row as the track auto-advances (only
while it's in view), centers it on open, and surfaces a 'Jump to current' pill
once the user scrolls it off-screen. Header gains a clear-queue action backed
by a new store clearQueue() that empties the queue and stops playback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 22:57:23 -04:00
bvandeusen 0efbf5fcaa feat(player): album-art thumbnails in web queue rows — #1944
Adds a 40px cover thumbnail (coverUrl(album_id), FALLBACK_COVER on error) to
each queue row, matching the artwork every comparable player shows in its
up-next list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 22:57:23 -04:00
bvandeusen 2038028d42 test(web): no-op scrollIntoView in vitest setup (jsdom lacks it) — #1931
test-web / test (push) Successful in 33s
The queue auto-scroll $effect calls scrollIntoView on render, and jsdom
doesn't implement it, so QueueDrawer.test.ts threw an unhandled TypeError that
failed the run even though every assertion passed. Polyfill it as a no-op in
the shared setup; tests never assert on scroll position.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 21:27:35 -04:00
bvandeusen 723293110d feat(player): scroll web queue to now-playing track on open (Android parity) — #1931
test-web / test (push) Failing after 38s
QueueList gains an `active` prop; when it flips true (drawer opens) or on mount
(now-playing panel) it centers the current row in view. Index/length are read
untracked so it positions once per open rather than following auto-advance,
matching the Android queue. QueueDrawer passes active={queueDrawerOpen} since
its aside is always mounted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 21:24:29 -04:00
bvandeusen 41ebf1405b fix(player): open Android queue scrolled to now-playing track — #1929
android / Build + lint + test (push) Successful in 4m8s
QueueList used a plain LazyColumn with no hoisted state, so the queue always
opened at the top and the current track could be off-screen. Seed a
rememberLazyListState with the current index (coerced into bounds) so the list
renders already positioned on the now-playing row — no post-layout scroll flash.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 21:11:32 -04:00
bvandeusen f2dcf2596d fix(player): render QueueDrawer inside QueryClientProvider so queue LikeButtons resolve — #1928
test-web / test (push) Successful in 40s
The queue drawer's <aside> is always mounted, so QueueTrackRow's LikeButton
(added in #1596) instantiates the moment the queue populates on first play.
LikeButton calls useQueryClient() at init; with the drawer outside the
provider it threw 'No QueryClient was found in Svelte context', aborting the
reactive flush that starts playback — so play appeared to do nothing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 20:47:21 -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))
}
/**
* 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
* "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).
*/
@HiltViewModel
@Suppress("TooManyFunctions") // Thin transport facade — each fun forwards to PlayerController.
class PlayerViewModel @Inject constructor(
private val controller: PlayerController,
private val likes: LikesRepository,
@@ -55,6 +56,9 @@ class PlayerViewModel @Inject constructor(
fun seekToIndex(index: Int) = controller.seekToIndex(index)
fun toggleShuffle() = controller.toggleShuffle()
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) {
val desired = trackId !in likedTrackIds.value
@@ -1,17 +1,25 @@
package com.fabledsword.minstrel.player.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -20,23 +28,43 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
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.Modifier
import androidx.compose.ui.draw.clip
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.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.composables.icons.lucide.ArrowDown
import com.composables.icons.lucide.ArrowLeft
import com.composables.icons.lucide.GripVertical
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.X
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.shared.formatDuration
import com.fabledsword.minstrel.shared.widgets.EmptyState
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)
@Composable
@@ -50,12 +78,30 @@ fun QueueScreen(
modifier = Modifier.fillMaxSize(),
topBar = {
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 = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(Lucide.ArrowLeft, contentDescription = "Back")
}
},
actions = {
if (state.queue.isNotEmpty()) {
IconButton(onClick = viewModel::clearQueue) {
Icon(Lucide.Trash2, contentDescription = "Clear queue")
}
}
},
)
},
) { inner ->
@@ -72,12 +118,15 @@ fun QueueScreen(
likedTrackIds = likedTrackIds,
onJumpTo = viewModel::seekToIndex,
onToggleLike = viewModel::toggleLikeTrack,
onMove = viewModel::moveInQueue,
onRemove = viewModel::removeFromQueue,
)
}
}
}
}
@Suppress("LongParameterList") // Compose list wiring — layout + queue callbacks, not logic.
@Composable
private fun QueueList(
tracks: List<TrackRef>,
@@ -85,29 +134,90 @@ private fun QueueList(
likedTrackIds: Set<String>,
onJumpTo: (Int) -> Unit,
onToggleLike: (String) -> Unit,
onMove: (Int, Int) -> Unit,
onRemove: (Int) -> Unit,
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
itemsIndexed(items = tracks, key = { _, track -> track.id }) { index, track ->
QueueRow(
track = track,
isCurrent = index == currentIndex,
liked = track.id in likedTrackIds,
onClick = { onJumpTo(index) },
onToggleLike = { onToggleLike(track.id) },
)
HorizontalDivider()
val listState = rememberLazyListState(
initialFirstVisibleItemIndex = currentIndex.coerceIn(0, tracks.lastIndex),
)
val scope = rememberCoroutineScope()
// Follow the now-playing row as the track auto-advances, but only while it's
// near the visible window — if the user has scrolled away to browse, leave
// them there (the pill offers the way back). Parity with the web queue.
LaunchedEffect(currentIndex) {
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
private fun QueueRow(
track: TrackRef,
index: Int,
queueSize: Int,
isCurrent: Boolean,
liked: Boolean,
onClick: () -> 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) {
MaterialTheme.colorScheme.primary.copy(alpha = HIGHLIGHT_ALPHA)
} else {
@@ -116,12 +226,23 @@ private fun QueueRow(
Row(
modifier = Modifier
.fillMaxWidth()
.onSizeChanged { rowHeightPx = it.height }
.zIndex(if (dragOffsetY != 0f) 1f else 0f)
.graphicsLayer { translationY = dragOffsetY }
.background(highlight)
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
DragHandle(
index = index,
queueSize = queueSize,
rowHeightPx = rowHeightPx,
onOffsetChange = { dragOffsetY = it },
onMove = onMove,
)
QueueRowThumbnail(track = track)
if (isCurrent) {
Icon(
Lucide.Volume2,
@@ -129,26 +250,7 @@ private fun QueueRow(
tint = MaterialTheme.colorScheme.primary,
)
}
Column(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,
)
}
}
QueueRowText(track = track, isCurrent = isCurrent, modifier = Modifier.weight(1f))
if (track.durationSec > 0) {
Text(
text = formatDuration(track.durationSec),
@@ -157,6 +259,94 @@ private fun QueueRow(
)
}
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() }
.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 SECONDS_PER_MINUTE = 60
private const val MINUTES_PER_HOUR = 60
+5 -1
View File
@@ -35,5 +35,9 @@
transition-transform duration-200
{player.queueDrawerOpen ? 'translate-x-0' : 'translate-x-full'}"
>
<QueueList onClose={() => closeQueueDrawer()} bind:closeButtonRef={closeButton} />
<QueueList
onClose={() => closeQueueDrawer()}
active={player.queueDrawerOpen}
bind:closeButtonRef={closeButton}
/>
</aside>
+4 -2
View File
@@ -25,10 +25,12 @@ vi.mock('$lib/player/store.svelte', () => ({
get queueDrawerOpen() { return openValue; }
},
// 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(),
removeFromQueue: vi.fn(),
moveQueueItem: vi.fn()
moveQueueItem: vi.fn(),
clearQueue: vi.fn()
}));
import QueueDrawer from './QueueDrawer.svelte';
+97 -16
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { X } from 'lucide-svelte';
import { player } from '$lib/player/store.svelte';
import { untrack } from 'svelte';
import { X, Trash2, ArrowDown } from 'lucide-svelte';
import { player, clearQueue } from '$lib/player/store.svelte';
import QueueTrackRow from './QueueTrackRow.svelte';
// 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.
// closeButtonRef: bind:this hook so the drawer can focus the X for
// 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 = {
onClose?: () => void;
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 {
const totalSec = tracks.reduce((s, tr) => s + (tr.duration_sec ?? 0), 0);
@@ -23,7 +78,7 @@
}
</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>
<h2 class="text-lg font-semibold">Queue</h2>
@@ -32,20 +87,33 @@
{#if player.queue.length > 0} · {totalDurationLabel(player.queue)}{/if}
</p>
</div>
{#if onClose}
<button
type="button"
bind:this={closeButtonRef}
aria-label="Close queue"
onclick={onClose}
class="text-text-secondary hover:text-text-primary"
>
<X size={20} />
</button>
{/if}
<div class="flex items-center gap-1">
{#if player.queue.length > 0}
<button
type="button"
aria-label="Clear queue"
title="Clear queue"
onclick={() => clearQueue()}
class="rounded p-1 text-text-secondary hover:text-text-primary"
>
<Trash2 size={18} />
</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 class="flex-1 overflow-y-auto">
<div bind:this={scrollBody} onscroll={recomputeInView} class="flex-1 overflow-y-auto">
{#if player.queue.length === 0}
<p class="text-text-secondary text-center p-8">No tracks queued.</p>
{:else}
@@ -54,4 +122,17 @@
{/each}
{/if}
</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>
@@ -3,6 +3,7 @@
import { draggable, type DragEventData } from '@neodrag/svelte';
import type { TrackRef } from '$lib/api/types';
import { playFromQueueIndex, removeFromQueue, moveQueueItem } from '$lib/player/store.svelte';
import { coverUrl, FALLBACK_COVER } from '$lib/media/covers';
import { offsetToDelta } from './queue-row-math';
import LikeButton from './LikeButton.svelte';
@@ -66,6 +67,13 @@
<GripVertical size={16} />
</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
type="button"
onclick={handleBodyClick}
+15
View File
@@ -505,6 +505,21 @@ export function removeFromQueue(idx: number): void {
_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 {
if (idx < 0 || idx >= _queue.length) return;
_radioSeedId = null;
+7 -2
View File
@@ -168,9 +168,14 @@
style="display: none"
></audio>
<QueueDrawer />
<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'}
<Shell>{@render children()}</Shell>
{:else}
+8
View File
@@ -37,6 +37,14 @@ if (typeof window !== 'undefined') {
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
// <ToastHost /> mounted in +layout.svelte. Tests render individual pages
// without the layout, so we mount ToastHost here so `pushToast()` calls