feat(player): swipe a queue row left to remove it — #2435
android / Build + lint + test (push) Failing after 1m25s

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6vZoJ4Se5YyaqdtGVkap5
This commit is contained in:
2026-08-04 10:47:48 -04:00
co-authored by Claude Opus 5
parent a92a9f2198
commit 1910a5ce61
@@ -25,8 +25,11 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf 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.Music
import com.composables.icons.lucide.Trash2 import com.composables.icons.lucide.Trash2
import com.composables.icons.lucide.Volume2 import com.composables.icons.lucide.Volume2
import com.composables.icons.lucide.X
import com.fabledsword.minstrel.models.TrackRef import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.shared.formatDuration import com.fabledsword.minstrel.shared.formatDuration
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.LikeButton import com.fabledsword.minstrel.shared.widgets.LikeButton
import com.fabledsword.minstrel.shared.widgets.ServerImage import com.fabledsword.minstrel.shared.widgets.ServerImage
import com.fabledsword.minstrel.theme.LocalActionColors
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -226,15 +229,77 @@ private fun QueueRow(
} else { } else {
Color.Transparent 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( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.onSizeChanged { rowHeightPx = it.height } // Opaque: this sits ON TOP of the red remove background, so a
.zIndex(if (dragOffsetY != 0f) 1f else 0f) // transparent row would show the fill through it at rest.
.graphicsLayer { translationY = dragOffsetY } .background(MaterialTheme.colorScheme.surface)
.background(highlight) .background(highlight)
.clickable(onClick = onClick) .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), .padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
@@ -248,7 +313,7 @@ private fun QueueRow(
index = index, index = index,
queueSize = queueSize, queueSize = queueSize,
rowHeightPx = rowHeightPx, rowHeightPx = rowHeightPx,
onOffsetChange = { dragOffsetY = it }, onOffsetChange = onDragOffset,
onMove = onMove, onMove = onMove,
), ),
) )
@@ -268,26 +333,57 @@ private fun QueueRow(
) )
} }
LikeButton(liked = liked, onToggle = onToggleLike) 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 * Both gestures this row now relies on — long-press-drag to reorder, swipe to
* ("Reorder track") was the ONLY thing telling a screen reader this list could * remove — are touch-only and unavailable under TalkBack, and each replaced a
* be reordered, and a long-press drag is not operable with TalkBack at all. * control that a screen reader COULD find (the grip's "Reorder track", the X's
* These are the Android counterpart to the web row's ArrowUp/ArrowDown keys — * "Remove from queue"). Without these actions the row would have lost both
* without them, dropping the grip would have quietly removed reordering for * capabilities for anyone not using touch. They're the Android counterpart to
* anyone not using touch. * the web row's ArrowUp/ArrowDown keys and its still-present X button.
*/ */
private fun Modifier.queueReorderActions( private fun Modifier.queueReorderActions(
index: Int, index: Int,
queueSize: Int, queueSize: Int,
onMove: (Int, Int) -> Unit, onMove: (Int, Int) -> Unit,
onRemove: () -> Unit,
): Modifier = semantics { ): Modifier = semantics {
customActions = listOf( customActions = listOf(
CustomAccessibilityAction("Move up") { CustomAccessibilityAction("Move up") {
@@ -296,6 +392,7 @@ private fun Modifier.queueReorderActions(
CustomAccessibilityAction("Move down") { CustomAccessibilityAction("Move down") {
if (index < queueSize - 1) { onMove(index, index + 1); true } else false if (index < queueSize - 1) { onMove(index, index + 1); true } else false
}, },
CustomAccessibilityAction("Remove from queue") { onRemove(); true },
) )
} }