diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt index 6af12b3c..0a4222b6 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt @@ -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:" so the server-side rotation reporter can diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/PlayerViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/PlayerViewModel.kt index e20a3407..1f7810ce 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/PlayerViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/PlayerViewModel.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/QueueScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/QueueScreen.kt index 683c430e..2f2b5f91 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/QueueScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/QueueScreen.kt @@ -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, @@ -85,29 +134,90 @@ private fun QueueList( likedTrackIds: Set, 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): 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 diff --git a/web/src/lib/components/QueueDrawer.svelte b/web/src/lib/components/QueueDrawer.svelte index 075c52f4..fbf6d78b 100644 --- a/web/src/lib/components/QueueDrawer.svelte +++ b/web/src/lib/components/QueueDrawer.svelte @@ -35,5 +35,9 @@ transition-transform duration-200 {player.queueDrawerOpen ? 'translate-x-0' : 'translate-x-full'}" > - closeQueueDrawer()} bind:closeButtonRef={closeButton} /> + closeQueueDrawer()} + active={player.queueDrawerOpen} + bind:closeButtonRef={closeButton} + /> diff --git a/web/src/lib/components/QueueDrawer.test.ts b/web/src/lib/components/QueueDrawer.test.ts index ec82c755..c9b81cfa 100644 --- a/web/src/lib/components/QueueDrawer.test.ts +++ b/web/src/lib/components/QueueDrawer.test.ts @@ -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'; diff --git a/web/src/lib/components/QueueList.svelte b/web/src/lib/components/QueueList.svelte index 4f5ab7e9..cc223183 100644 --- a/web/src/lib/components/QueueList.svelte +++ b/web/src/lib/components/QueueList.svelte @@ -1,6 +1,7 @@ -
+

Queue

@@ -32,20 +87,33 @@ {#if player.queue.length > 0} · {totalDurationLabel(player.queue)}{/if}

- {#if onClose} - - {/if} +
+ {#if player.queue.length > 0} + + {/if} + {#if onClose} + + {/if} +
-
+
{#if player.queue.length === 0}

No tracks queued.

{:else} @@ -54,4 +122,17 @@ {/each} {/if}
+ + {#if active && player.queue.length > 0 && !currentInView} + + {/if}
diff --git a/web/src/lib/components/QueueTrackRow.svelte b/web/src/lib/components/QueueTrackRow.svelte index d6d043d4..1c40c17c 100644 --- a/web/src/lib/components/QueueTrackRow.svelte +++ b/web/src/lib/components/QueueTrackRow.svelte @@ -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 @@ + ((e.currentTarget as HTMLImageElement).src = FALLBACK_COVER)} + class="h-10 w-10 flex-shrink-0 rounded object-cover" + /> +