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.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 },
)
}