From 1910a5ce61dd7804691bb1a085ca945360ff6122 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 4 Aug 2026 10:47:48 -0400 Subject: [PATCH] =?UTF-8?q?feat(player):=20swipe=20a=20queue=20row=20left?= =?UTF-8?q?=20to=20remove=20it=20=E2=80=94=20#2435?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the trailing X button on the Android queue row, for the same reason #2395 replaced the grip: horizontal space in the narrowest row in the app. Web keeps its X — the operator's call, and the right one, since the constraint being solved doesn't exist there. SwipeToDismissBox with enableDismissFromStartToEnd = false; a right-swipe means nothing here and would only delete tracks on a mis-aimed gesture. The red fill under the row is oxblood (LocalActionColors.destructive), not colorScheme.error — the design system keeps those apart because an error is a failure that happened and a destructive action is one about to. Adds a "Remove from queue" custom accessibility action. Both gestures the row now relies on are touch-only, and each replaced a control TalkBack could find, so without this the change would have quietly removed remove-from-queue for anyone not using touch. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01N6vZoJ4Se5YyaqdtGVkap5 --- .../minstrel/player/ui/QueueScreen.kt | 127 +++++++++++++++--- 1 file changed, 112 insertions(+), 15 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 d25b75b1..a47cf2cf 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 @@ -25,8 +25,11 @@ 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 @@ -60,12 +63,12 @@ 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 com.fabledsword.minstrel.theme.LocalActionColors import kotlin.math.roundToInt import kotlinx.coroutines.launch @@ -226,15 +229,77 @@ private fun QueueRow( } 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() - .onSizeChanged { rowHeightPx = it.height } - .zIndex(if (dragOffsetY != 0f) 1f else 0f) - .graphicsLayer { translationY = dragOffsetY } + // 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) + .queueReorderActions( + index = index, + queueSize = queueSize, + onMove = onMove, + onRemove = onRemove, + ) .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), @@ -248,7 +313,7 @@ private fun QueueRow( index = index, queueSize = queueSize, rowHeightPx = rowHeightPx, - onOffsetChange = { dragOffsetY = it }, + onOffsetChange = onDragOffset, onMove = onMove, ), ) @@ -268,26 +333,57 @@ private fun QueueRow( ) } LikeButton(liked = liked, onToggle = onToggleLike) - IconButton(onClick = onRemove) { - Icon(Lucide.X, contentDescription = "Remove from queue") + } +} + +/** + * 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 for a queue row. + * Screen-reader reordering and removal for a queue row. * - * Replaces the capability the grip icon carried: its contentDescription - * ("Reorder track") was the ONLY thing telling a screen reader this list could - * be reordered, and a long-press drag is not operable with TalkBack at all. - * These are the Android counterpart to the web row's ArrowUp/ArrowDown keys — - * without them, dropping the grip would have quietly removed reordering for - * anyone not using touch. + * 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") { @@ -296,6 +392,7 @@ private fun Modifier.queueReorderActions( CustomAccessibilityAction("Move down") { if (index < queueSize - 1) { onMove(index, index + 1); true } else false }, + CustomAccessibilityAction("Remove from queue") { onRemove(); true }, ) }