From 8b630e71cab41e3f806e7a96de6112ed40e60594 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 4 Aug 2026 10:52:14 -0400 Subject: [PATCH] =?UTF-8?q?refactor(player):=20split=20the=20queue=20row?= =?UTF-8?q?=20out=20of=20QueueScreen.kt=20=E2=80=94=20#2435?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit detekt TooManyFunctions: the swipe work took the file to 12 functions against a limit of 11. Suppressing it was the option; splitting is the better one, because the seam was already there — the row carries two gestures, a swipe background, and its own accessibility surface, which is more behaviour than the screen that merely lists it. QueueScreen.kt keeps the screen, list, pill, and summary (4). QueueRow.kt takes the row and its helpers (8). No behaviour change: same code, same order, per-file imports recomputed, QueueRow internal so QueueList can still call it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01N6vZoJ4Se5YyaqdtGVkap5 --- .../minstrel/player/ui/QueueRow.kt | 348 ++++++++++++++++++ .../minstrel/player/ui/QueueScreen.kt | 318 ---------------- 2 files changed, 348 insertions(+), 318 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/player/ui/QueueRow.kt 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 a47cf2cf..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.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.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 @@ -25,51 +18,26 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.SwipeToDismissBox -import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar -import androidx.compose.material3.rememberSwipeToDismissBoxState 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.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 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.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.EmptyState -import com.fabledsword.minstrel.shared.widgets.LikeButton -import com.fabledsword.minstrel.shared.widgets.ServerImage -import com.fabledsword.minstrel.theme.LocalActionColors -import kotlin.math.roundToInt import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @@ -209,291 +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 - } - // 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(" · ") /** "N tracks · 12 min" header summary. */ private fun queueSummary(tracks: List): String { @@ -507,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