diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/QueueRow.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/QueueRow.kt new file mode 100644 index 00000000..d9b55b0a --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/QueueRow.kt @@ -0,0 +1,348 @@ +package com.fabledsword.minstrel.player.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.Text +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +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.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.semantics +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 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.fabledsword.minstrel.models.TrackRef +import com.fabledsword.minstrel.shared.formatDuration +import com.fabledsword.minstrel.shared.widgets.LikeButton +import com.fabledsword.minstrel.shared.widgets.ServerImage +import com.fabledsword.minstrel.theme.LocalActionColors +import kotlin.math.roundToInt + +/* + * A single queue row, split out of QueueScreen.kt when swipe-to-remove (#2435) + * pushed that file past detekt's TooManyFunctions limit. The seam is real and + * not just a way to satisfy the analyzer: the row now carries two gestures, a + * swipe background, and its own accessibility surface, which is more behaviour + * than the screen that lists it. `internal` rather than `private` only because + * QueueList (still in QueueScreen.kt) is the caller. + */ + +@Suppress("LongParameterList") // Compose row wiring — layout + queue callbacks, not logic. +@Composable +internal 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 { + Color.Transparent + } + // Swipe left to remove, replacing the X button (#2395 follow-up). Only + // end-to-start is enabled: a right-swipe has no meaning here, and leaving it + // live would delete tracks on a mis-aimed gesture in either direction. + val dismissState = rememberSwipeToDismissBoxState( + confirmValueChange = { value -> + if (value == SwipeToDismissBoxValue.EndToStart) { + onRemove() + true + } else { + false + } + }, + ) + SwipeToDismissBox( + state = dismissState, + enableDismissFromStartToEnd = false, + backgroundContent = { RemoveSwipeBackground() }, + // The reorder lift lives out here so a row being dragged vertically + // carries its swipe container with it rather than sliding out of one. + modifier = Modifier + .onSizeChanged { rowHeightPx = it.height } + .zIndex(if (dragOffsetY != 0f) 1f else 0f) + .graphicsLayer { translationY = dragOffsetY }, + ) { + QueueRowContent( + track = track, + index = index, + queueSize = queueSize, + isCurrent = isCurrent, + liked = liked, + highlight = highlight, + rowHeightPx = rowHeightPx, + onClick = onClick, + onToggleLike = onToggleLike, + onRemove = onRemove, + onMove = onMove, + onDragOffset = { dragOffsetY = it }, + ) + } +} + +@Suppress("LongParameterList") // Compose row wiring — layout + queue callbacks, not logic. +@Composable +private fun QueueRowContent( + track: TrackRef, + index: Int, + queueSize: Int, + isCurrent: Boolean, + liked: Boolean, + highlight: Color, + rowHeightPx: Int, + onClick: () -> Unit, + onToggleLike: () -> Unit, + onRemove: () -> Unit, + onMove: (Int, Int) -> Unit, + onDragOffset: (Float) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + // Opaque: this sits ON TOP of the red remove background, so a + // transparent row would show the fill through it at rest. + .background(MaterialTheme.colorScheme.surface) + .background(highlight) + .clickable(onClick = onClick) + .queueReorderActions( + index = index, + queueSize = queueSize, + onMove = onMove, + onRemove = onRemove, + ) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + // The album art IS the grab surface (#2395). The grip icon it replaces + // cost ~36dp of every row's width — icon plus its 12dp gap — on the + // narrowest surface in the app, competing with the title for space. + QueueRowThumbnail( + track = track, + dragModifier = Modifier.queueReorderDrag( + index = index, + queueSize = queueSize, + rowHeightPx = rowHeightPx, + onOffsetChange = onDragOffset, + onMove = onMove, + ), + ) + if (isCurrent) { + Icon( + Lucide.Volume2, + contentDescription = "Now playing", + tint = MaterialTheme.colorScheme.primary, + ) + } + QueueRowText(track = track, isCurrent = isCurrent, modifier = Modifier.weight(1f)) + if (track.durationSec > 0) { + Text( + text = formatDuration(track.durationSec), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + LikeButton(liked = liked, onToggle = onToggleLike) + } +} + +/** + * What the row slides off to reveal: the destructive colour with a trash glyph, + * pinned to the trailing edge because that is the edge the swipe uncovers. + * + * Oxblood (LocalActionColors.destructive), NOT colorScheme.error. The design + * system keeps those apart deliberately — an error is a failure that already + * happened, a destructive action is one about to happen — and using the error + * colour here would dress an intentional gesture as a fault report. + */ +@Composable +private fun RemoveSwipeBackground() { + val actions = LocalActionColors.current + Box( + modifier = Modifier + .fillMaxSize() + .background(actions.destructive) + .padding(horizontal = 24.dp), + contentAlignment = Alignment.CenterEnd, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon(Lucide.Trash2, contentDescription = null, tint = actions.onAction) + Text( + text = "Remove", + style = MaterialTheme.typography.labelLarge, + color = actions.onAction, + ) + } + } +} + +/** + * Screen-reader reordering and removal for a queue row. + * + * Both gestures this row now relies on — long-press-drag to reorder, swipe to + * remove — are touch-only and unavailable under TalkBack, and each replaced a + * control that a screen reader COULD find (the grip's "Reorder track", the X's + * "Remove from queue"). Without these actions the row would have lost both + * capabilities for anyone not using touch. They're the Android counterpart to + * the web row's ArrowUp/ArrowDown keys and its still-present X button. + */ +private fun Modifier.queueReorderActions( + index: Int, + queueSize: Int, + onMove: (Int, Int) -> Unit, + onRemove: () -> Unit, +): Modifier = semantics { + customActions = listOf( + CustomAccessibilityAction("Move up") { + if (index > 0) { onMove(index, index - 1); true } else false + }, + CustomAccessibilityAction("Move down") { + if (index < queueSize - 1) { onMove(index, index + 1); true } else false + }, + CustomAccessibilityAction("Remove from queue") { onRemove(); true }, + ) +} + +/** + * Reorder-drag behaviour for a queue row, applied to whatever element is the + * grab surface — the album art, since #2395 removed the grip icon. + * + * Uses **detectDragGesturesAfterLongPress**, not detectDragGestures, and that + * is the load-bearing detail. The grip was a small target, so a plain drag + * gesture on it never competed with anything. A 48dp thumbnail is a large + * chunk of every row, and with a plain drag detector any vertical pan starting + * on artwork would be swallowed as a row-reorder instead of scrolling the + * queue — the list would feel broken precisely where it's easiest to touch. + * Long-press-then-drag separates the two: pan scrolls, long-press reorders, + * tap still plays (the detector doesn't consume a plain tap, so it falls + * through to the row's clickable). + */ +private fun Modifier.queueReorderDrag( + index: Int, + queueSize: Int, + rowHeightPx: Int, + onOffsetChange: (Float) -> Unit, + onMove: (Int, Int) -> Unit, +): Modifier = composed { + // 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) } + pointerInput(index, queueSize, rowHeightPx) { + detectDragGesturesAfterLongPress( + 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, dragModifier: Modifier = Modifier) { + Box( + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .then(dragModifier), + 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, + ) + } + } +} + +/** "Artist · Album" — collapses gracefully when either is missing. */ +private fun queueSubtitle(track: TrackRef): String = listOf(track.artistName, track.albumTitle) + .filter { it.isNotEmpty() } + .joinToString(" · ") + +private const val HIGHLIGHT_ALPHA = 0.12f 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 2f2b5f91..913d8cf0 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,23 +1,16 @@ 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 @@ -31,39 +24,20 @@ 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) @@ -203,157 +177,6 @@ private fun JumpToCurrentPill( } } -@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 { - Color.Transparent - } - 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, - contentDescription = "Now playing", - tint = MaterialTheme.colorScheme.primary, - ) - } - QueueRowText(track = track, isCurrent = isCurrent, modifier = Modifier.weight(1f)) - if (track.durationSec > 0) { - Text( - text = formatDuration(track.durationSec), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - 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, - ) - } - } -} - -/** "Artist · Album" — collapses gracefully when either is missing. */ -private fun queueSubtitle(track: TrackRef): String = listOf(track.artistName, track.albumTitle) - .filter { it.isNotEmpty() } - .joinToString(" · ") /** "N tracks · 12 min" header summary. */ private fun queueSummary(tracks: List): String { @@ -367,6 +190,5 @@ private fun queueSummary(tracks: List): String { 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/QueueTrackRow.svelte b/web/src/lib/components/QueueTrackRow.svelte index 1c40c17c..e14a245c 100644 --- a/web/src/lib/components/QueueTrackRow.svelte +++ b/web/src/lib/components/QueueTrackRow.svelte @@ -57,22 +57,41 @@ class="flex items-center gap-2 border-b border-border px-3 py-2 h-16 {isCurrent ? 'border-l-2 border-l-accent bg-surface-hover' : ''}" > - + +
+ ((e.currentTarget as HTMLImageElement).src = FALLBACK_COVER)} + class="h-10 w-10 rounded object-cover" + /> + +