Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a26ef4e93c | |||
| 0774f5f55f | |||
| 509cbe79b2 | |||
| dc7b9b78fa | |||
| cde74b5965 | |||
| 0efbf5fcaa | |||
| 2038028d42 | |||
| 723293110d | |||
| 41ebf1405b | |||
| f2dcf2596d |
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user