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.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 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user