feat(player): Android queue — reorder, remove, art, auto-follow, clear — #1944
android / Build + lint + test (push) Failing after 1m26s
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>
This commit is contained in:
@@ -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<TrackRef>,
|
||||
@@ -86,36 +134,90 @@ private fun QueueList(
|
||||
likedTrackIds: Set<String>,
|
||||
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<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
|
||||
|
||||
Reference in New Issue
Block a user