Queue row gestures: album art as grab surface + swipe-to-remove #118
@@ -3,7 +3,7 @@ package com.fabledsword.minstrel.player.ui
|
|||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectDragGestures
|
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -38,11 +38,15 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.composed
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
|
import androidx.compose.ui.semantics.CustomAccessibilityAction
|
||||||
|
import androidx.compose.ui.semantics.customActions
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -52,7 +56,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import com.composables.icons.lucide.ArrowDown
|
import com.composables.icons.lucide.ArrowDown
|
||||||
import com.composables.icons.lucide.ArrowLeft
|
import com.composables.icons.lucide.ArrowLeft
|
||||||
import com.composables.icons.lucide.GripVertical
|
|
||||||
import com.composables.icons.lucide.Lucide
|
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
|
||||||
@@ -231,18 +234,40 @@ private fun QueueRow(
|
|||||||
.graphicsLayer { translationY = dragOffsetY }
|
.graphicsLayer { translationY = dragOffsetY }
|
||||||
.background(highlight)
|
.background(highlight)
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
|
// 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 custom actions 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.
|
||||||
|
.semantics {
|
||||||
|
customActions = listOf(
|
||||||
|
CustomAccessibilityAction("Move up") {
|
||||||
|
if (index > 0) { onMove(index, index - 1); true } else false
|
||||||
|
},
|
||||||
|
CustomAccessibilityAction("Move down") {
|
||||||
|
if (index < queueSize - 1) { onMove(index, index + 1); true } else false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
.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),
|
||||||
) {
|
) {
|
||||||
DragHandle(
|
// The album art IS the grab surface (#2395). The grip icon it replaces
|
||||||
|
// cost ~36dp of every row's width — icon plus its 12dp gap — on the
|
||||||
|
// narrowest surface in the app, competing with the title for space.
|
||||||
|
QueueRowThumbnail(
|
||||||
|
track = track,
|
||||||
|
dragModifier = Modifier.queueReorderDrag(
|
||||||
index = index,
|
index = index,
|
||||||
queueSize = queueSize,
|
queueSize = queueSize,
|
||||||
rowHeightPx = rowHeightPx,
|
rowHeightPx = rowHeightPx,
|
||||||
onOffsetChange = { dragOffsetY = it },
|
onOffsetChange = { dragOffsetY = it },
|
||||||
onMove = onMove,
|
onMove = onMove,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
QueueRowThumbnail(track = track)
|
|
||||||
if (isCurrent) {
|
if (isCurrent) {
|
||||||
Icon(
|
Icon(
|
||||||
Lucide.Volume2,
|
Lucide.Volume2,
|
||||||
@@ -265,23 +290,32 @@ private fun QueueRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
/**
|
||||||
private fun DragHandle(
|
* Reorder-drag behaviour for a queue row, applied to whatever element is the
|
||||||
|
* grab surface — the album art, since #2395 removed the grip icon.
|
||||||
|
*
|
||||||
|
* Uses **detectDragGesturesAfterLongPress**, not detectDragGestures, and that
|
||||||
|
* is the load-bearing detail. The grip was a small target, so a plain drag
|
||||||
|
* gesture on it never competed with anything. A 48dp thumbnail is a large
|
||||||
|
* chunk of every row, and with a plain drag detector any vertical pan starting
|
||||||
|
* on artwork would be swallowed as a row-reorder instead of scrolling the
|
||||||
|
* queue — the list would feel broken precisely where it's easiest to touch.
|
||||||
|
* Long-press-then-drag separates the two: pan scrolls, long-press reorders,
|
||||||
|
* tap still plays (the detector doesn't consume a plain tap, so it falls
|
||||||
|
* through to the row's clickable).
|
||||||
|
*/
|
||||||
|
private fun Modifier.queueReorderDrag(
|
||||||
index: Int,
|
index: Int,
|
||||||
queueSize: Int,
|
queueSize: Int,
|
||||||
rowHeightPx: Int,
|
rowHeightPx: Int,
|
||||||
onOffsetChange: (Float) -> Unit,
|
onOffsetChange: (Float) -> Unit,
|
||||||
onMove: (Int, Int) -> Unit,
|
onMove: (Int, Int) -> Unit,
|
||||||
) {
|
): Modifier = composed {
|
||||||
// Mirrors the web queue: the row follows the finger during a drag, then on
|
// Mirrors the web queue: the row follows the finger during a drag, then on
|
||||||
// release we translate the accumulated offset into a row delta and reorder.
|
// release we translate the accumulated offset into a row delta and reorder.
|
||||||
var offset by remember { mutableFloatStateOf(0f) }
|
var offset by remember { mutableFloatStateOf(0f) }
|
||||||
Icon(
|
pointerInput(index, queueSize, rowHeightPx) {
|
||||||
Lucide.GripVertical,
|
detectDragGesturesAfterLongPress(
|
||||||
contentDescription = "Reorder track",
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.pointerInput(index, queueSize, rowHeightPx) {
|
|
||||||
detectDragGestures(
|
|
||||||
onDrag = { change, dragAmount ->
|
onDrag = { change, dragAmount ->
|
||||||
change.consume()
|
change.consume()
|
||||||
offset += dragAmount.y
|
offset += dragAmount.y
|
||||||
@@ -299,17 +333,17 @@ private fun DragHandle(
|
|||||||
onOffsetChange(0f)
|
onOffsetChange(0f)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun QueueRowThumbnail(track: TrackRef) {
|
private fun QueueRowThumbnail(track: TrackRef, dragModifier: Modifier = Modifier) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(48.dp)
|
.size(48.dp)
|
||||||
.clip(RoundedCornerShape(4.dp))
|
.clip(RoundedCornerShape(4.dp))
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
.then(dragModifier),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
ServerImage(
|
ServerImage(
|
||||||
|
|||||||
@@ -57,22 +57,41 @@
|
|||||||
class="flex items-center gap-2 border-b border-border px-3 py-2 h-16
|
class="flex items-center gap-2 border-b border-border px-3 py-2 h-16
|
||||||
{isCurrent ? 'border-l-2 border-l-accent bg-surface-hover' : ''}"
|
{isCurrent ? 'border-l-2 border-l-accent bg-surface-hover' : ''}"
|
||||||
>
|
>
|
||||||
|
<!--
|
||||||
|
The album art is the grab surface (#2395). The grip used to occupy its own
|
||||||
|
column in every row; it now sits OVER the art, so it costs no horizontal
|
||||||
|
space at all. `use:draggable` is on the row (above), so dragging already
|
||||||
|
worked from anywhere — the grip's real jobs are being the visual cue and
|
||||||
|
the keyboard target, and both survive here.
|
||||||
|
|
||||||
|
It stays VISIBLE at rest, just quiet — it is the only thing that says this
|
||||||
|
list can be reordered at all, so hiding it until hover would trade the
|
||||||
|
operator's space complaint for a discoverability one (rule #24), and would
|
||||||
|
leave nothing for touch, which has no hover. The scrim only appears on
|
||||||
|
hover/focus so the artwork stays legible the rest of the time; the drop
|
||||||
|
shadow is what keeps the glyph readable over pale covers without one.
|
||||||
|
-->
|
||||||
|
<div class="relative h-10 w-10 flex-shrink-0">
|
||||||
|
<img
|
||||||
|
src={coverUrl(track.album_id)}
|
||||||
|
alt=""
|
||||||
|
onerror={(e) => ((e.currentTarget as HTMLImageElement).src = FALLBACK_COVER)}
|
||||||
|
class="h-10 w-10 rounded object-cover"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Reorder track (use arrow keys)"
|
aria-label="Reorder track (use arrow keys)"
|
||||||
aria-keyshortcuts="ArrowUp ArrowDown"
|
aria-keyshortcuts="ArrowUp ArrowDown"
|
||||||
onkeydown={handleHandleKeydown}
|
onkeydown={handleHandleKeydown}
|
||||||
class="cursor-grab text-text-secondary hover:text-text-primary flex-shrink-0"
|
class="group absolute inset-0 flex cursor-grab items-center justify-center rounded
|
||||||
|
text-white/70 transition hover:bg-black/45 hover:text-white
|
||||||
|
focus-visible:bg-black/45 focus-visible:text-white
|
||||||
|
focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||||
|
style="filter: drop-shadow(0 1px 1px rgb(0 0 0 / 0.9))"
|
||||||
>
|
>
|
||||||
<GripVertical size={16} />
|
<GripVertical size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
<img
|
|
||||||
src={coverUrl(track.album_id)}
|
|
||||||
alt=""
|
|
||||||
onerror={(e) => ((e.currentTarget as HTMLImageElement).src = FALLBACK_COVER)}
|
|
||||||
class="h-10 w-10 flex-shrink-0 rounded object-cover"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -94,4 +94,30 @@ describe('QueueTrackRow', () => {
|
|||||||
await fireEvent.keyDown(handle, { key: ' ' });
|
await fireEvent.keyDown(handle, { key: ' ' });
|
||||||
expect(moveQueueItem).not.toHaveBeenCalled();
|
expect(moveQueueItem).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- handle placement (#2395) ---
|
||||||
|
|
||||||
|
it('the reorder handle overlays the album art instead of taking its own column', () => {
|
||||||
|
const { container } = render(QueueTrackRow, {
|
||||||
|
props: { track: sampleTrack, index: 3, isCurrent: false }
|
||||||
|
});
|
||||||
|
const handle = screen.getByLabelText(/reorder track/i);
|
||||||
|
const art = container.querySelector('img');
|
||||||
|
expect(art).not.toBeNull();
|
||||||
|
// Sharing a parent is what "overlaid" means structurally. If someone moves
|
||||||
|
// the grip back into its own flex slot, this fails — which is the point:
|
||||||
|
// that slot cost horizontal space in every row and is why #2395 exists.
|
||||||
|
expect(handle.parentElement).toBe(art!.parentElement);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the handle is visible at rest, not hover-revealed', () => {
|
||||||
|
render(QueueTrackRow, { props: { track: sampleTrack, index: 3, isCurrent: false } });
|
||||||
|
const handle = screen.getByLabelText(/reorder track/i);
|
||||||
|
// Overlaying already solved the space complaint, so there is nothing to buy
|
||||||
|
// by hiding it — and hiding it would cost the only cue that the queue can
|
||||||
|
// be reordered, on touch especially, where there is no hover at all.
|
||||||
|
// Asserting the absence of `opacity-0` is stylistic and a bit brittle, but
|
||||||
|
// it is the only handle jsdom gives us on a decision worth protecting.
|
||||||
|
expect(handle.className).not.toMatch(/\bopacity-0\b/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user