feat(player): swipe a queue row left to remove it — #2435
android / Build + lint + test (push) Failing after 1m25s
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:
@@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user