From f2dcf2596dc75ac12d050beadb4b31df6ba9d74b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 22 Jul 2026 20:47:21 -0400 Subject: [PATCH 1/9] =?UTF-8?q?fix(player):=20render=20QueueDrawer=20insid?= =?UTF-8?q?e=20QueryClientProvider=20so=20queue=20LikeButtons=20resolve=20?= =?UTF-8?q?=E2=80=94=20#1928?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The queue drawer's diff --git a/web/src/lib/components/QueueList.svelte b/web/src/lib/components/QueueList.svelte index 4f5ab7e9..1fb2dd48 100644 --- a/web/src/lib/components/QueueList.svelte +++ b/web/src/lib/components/QueueList.svelte @@ -1,4 +1,5 @@ -
+

Queue

@@ -54,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} @@ -76,4 +122,17 @@ {/each} {/if}
+ + {#if active && player.queue.length > 0 && !currentInView} + + {/if}
diff --git a/web/src/lib/player/store.svelte.ts b/web/src/lib/player/store.svelte.ts index 1e41432b..031f8c70 100644 --- a/web/src/lib/player/store.svelte.ts +++ b/web/src/lib/player/store.svelte.ts @@ -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; From dc7b9b78fa0914e218f4ab1ce9e0388b374e77c1 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 22 Jul 2026 23:12:23 -0400 Subject: [PATCH 7/9] =?UTF-8?q?feat(player):=20queue=20move/remove/clear?= =?UTF-8?q?=20on=20PlayerController=20+=20VM=20=E2=80=94=20#1944?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../minstrel/player/PlayerController.kt | 31 +++++++++++++++++++ .../minstrel/player/ui/PlayerViewModel.kt | 3 ++ 2 files changed, 34 insertions(+) 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..674728f8 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 @@ -55,6 +55,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 From 509cbe79b232eced30424fb8e9637ea2ecb0aa15 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 22 Jul 2026 23:12:23 -0400 Subject: [PATCH 8/9] =?UTF-8?q?feat(player):=20Android=20queue=20=E2=80=94?= =?UTF-8?q?=20reorder,=20remove,=20art,=20auto-follow,=20clear=20=E2=80=94?= =?UTF-8?q?=20#1944?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../minstrel/player/ui/QueueScreen.kt | 266 +++++++++++++++--- 1 file changed, 231 insertions(+), 35 deletions(-) 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 38d1b365..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,18 +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 @@ -21,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 @@ -51,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 -> @@ -73,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, @@ -86,36 +134,90 @@ private fun QueueList( likedTrackIds: Set, onJumpTo: (Int) -> Unit, onToggleLike: (String) -> Unit, + onMove: (Int, Int) -> Unit, + onRemove: (Int) -> Unit, ) { - // Open scrolled to the now-playing track so it's in view immediately. - // Seeding the initial index (rather than animating post-layout) avoids a - // flash of the list top; it's captured once per entry, so the view stays - // put as the track later auto-advances — matching "show me where I am now." val listState = rememberLazyListState( initialFirstVisibleItemIndex = currentIndex.coerceIn(0, tracks.lastIndex), ) - LazyColumn(state = listState, 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 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 { @@ -124,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, @@ -137,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), @@ -165,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, + ) + } } } @@ -173,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 From 0774f5f55fb91230504fd8f3fc19de2011b2502d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 22 Jul 2026 23:16:17 -0400 Subject: [PATCH 9/9] =?UTF-8?q?fix(player):=20suppress=20TooManyFunctions?= =?UTF-8?q?=20on=20PlayerViewModel=20facade=20=E2=80=94=20#1944?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../java/com/fabledsword/minstrel/player/ui/PlayerViewModel.kt | 1 + 1 file changed, 1 insertion(+) 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 674728f8..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,