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