Merge pull request 'Queue row gestures: album art as grab surface + swipe-to-remove' (#118) from dev into main
This commit was merged in pull request #118.
This commit is contained in:
@@ -0,0 +1,348 @@
|
|||||||
|
package com.fabledsword.minstrel.player.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SwipeToDismissBox
|
||||||
|
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.rememberSwipeToDismissBoxState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.composed
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
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.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
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.fabledsword.minstrel.models.TrackRef
|
||||||
|
import com.fabledsword.minstrel.shared.formatDuration
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
||||||
|
import com.fabledsword.minstrel.theme.LocalActionColors
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A single queue row, split out of QueueScreen.kt when swipe-to-remove (#2435)
|
||||||
|
* pushed that file past detekt's TooManyFunctions limit. The seam is real and
|
||||||
|
* not just a way to satisfy the analyzer: the row now carries two gestures, a
|
||||||
|
* swipe background, and its own accessibility surface, which is more behaviour
|
||||||
|
* than the screen that lists it. `internal` rather than `private` only because
|
||||||
|
* QueueList (still in QueueScreen.kt) is the caller.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@Suppress("LongParameterList") // Compose row wiring — layout + queue callbacks, not logic.
|
||||||
|
@Composable
|
||||||
|
internal fun QueueRow(
|
||||||
|
track: TrackRef,
|
||||||
|
index: Int,
|
||||||
|
queueSize: Int,
|
||||||
|
isCurrent: Boolean,
|
||||||
|
liked: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onToggleLike: () -> Unit,
|
||||||
|
onRemove: () -> Unit,
|
||||||
|
onMove: (Int, Int) -> Unit,
|
||||||
|
) {
|
||||||
|
var dragOffsetY by remember { mutableFloatStateOf(0f) }
|
||||||
|
var rowHeightPx by remember { mutableIntStateOf(0) }
|
||||||
|
val highlight = if (isCurrent) {
|
||||||
|
MaterialTheme.colorScheme.primary.copy(alpha = HIGHLIGHT_ALPHA)
|
||||||
|
} 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()
|
||||||
|
// 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,
|
||||||
|
onRemove = onRemove,
|
||||||
|
)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
// 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,
|
||||||
|
queueSize = queueSize,
|
||||||
|
rowHeightPx = rowHeightPx,
|
||||||
|
onOffsetChange = onDragOffset,
|
||||||
|
onMove = onMove,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (isCurrent) {
|
||||||
|
Icon(
|
||||||
|
Lucide.Volume2,
|
||||||
|
contentDescription = "Now playing",
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
QueueRowText(track = track, isCurrent = isCurrent, modifier = Modifier.weight(1f))
|
||||||
|
if (track.durationSec > 0) {
|
||||||
|
Text(
|
||||||
|
text = formatDuration(track.durationSec),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
LikeButton(liked = liked, onToggle = onToggleLike)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 and removal for a queue row.
|
||||||
|
*
|
||||||
|
* 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") {
|
||||||
|
if (index > 0) { onMove(index, index - 1); true } else false
|
||||||
|
},
|
||||||
|
CustomAccessibilityAction("Move down") {
|
||||||
|
if (index < queueSize - 1) { onMove(index, index + 1); true } else false
|
||||||
|
},
|
||||||
|
CustomAccessibilityAction("Remove from queue") { onRemove(); true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
queueSize: Int,
|
||||||
|
rowHeightPx: Int,
|
||||||
|
onOffsetChange: (Float) -> Unit,
|
||||||
|
onMove: (Int, Int) -> Unit,
|
||||||
|
): Modifier = composed {
|
||||||
|
// 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.
|
||||||
|
var offset by remember { mutableFloatStateOf(0f) }
|
||||||
|
pointerInput(index, queueSize, rowHeightPx) {
|
||||||
|
detectDragGesturesAfterLongPress(
|
||||||
|
onDrag = { change, dragAmount ->
|
||||||
|
change.consume()
|
||||||
|
offset += dragAmount.y
|
||||||
|
onOffsetChange(offset)
|
||||||
|
},
|
||||||
|
onDragEnd = {
|
||||||
|
val delta = if (rowHeightPx > 0) (offset / rowHeightPx).roundToInt() else 0
|
||||||
|
val target = (index + delta).coerceIn(0, queueSize - 1)
|
||||||
|
if (target != index) onMove(index, target)
|
||||||
|
offset = 0f
|
||||||
|
onOffsetChange(0f)
|
||||||
|
},
|
||||||
|
onDragCancel = {
|
||||||
|
offset = 0f
|
||||||
|
onOffsetChange(0f)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun QueueRowThumbnail(track: TrackRef, dragModifier: Modifier = Modifier) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.clip(RoundedCornerShape(4.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
.then(dragModifier),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
ServerImage(
|
||||||
|
url = track.coverUrl,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Lucide.Music,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun QueueRowText(track: TrackRef, isCurrent: Boolean, modifier: Modifier = Modifier) {
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
Text(
|
||||||
|
text = track.title,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
fontWeight = if (isCurrent) FontWeight.Medium else FontWeight.Normal,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
val subtitle = queueSubtitle(track)
|
||||||
|
if (subtitle.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Artist · Album" — collapses gracefully when either is missing. */
|
||||||
|
private fun queueSubtitle(track: TrackRef): String = listOf(track.artistName, track.albumTitle)
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
.joinToString(" · ")
|
||||||
|
|
||||||
|
private const val HIGHLIGHT_ALPHA = 0.12f
|
||||||
@@ -1,23 +1,16 @@
|
|||||||
package com.fabledsword.minstrel.player.ui
|
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.clickable
|
|
||||||
import androidx.compose.foundation.gestures.detectDragGestures
|
|
||||||
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
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
@@ -31,39 +24,20 @@ 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
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
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.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.zIndex
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
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.Trash2
|
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.models.TrackRef
|
||||||
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.ServerImage
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -203,157 +177,6 @@ private fun JumpToCurrentPill(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("LongParameterList") // Compose row wiring — layout + queue callbacks, not logic.
|
|
||||||
@Composable
|
|
||||||
private fun QueueRow(
|
|
||||||
track: TrackRef,
|
|
||||||
index: Int,
|
|
||||||
queueSize: Int,
|
|
||||||
isCurrent: Boolean,
|
|
||||||
liked: Boolean,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
onToggleLike: () -> Unit,
|
|
||||||
onRemove: () -> Unit,
|
|
||||||
onMove: (Int, Int) -> Unit,
|
|
||||||
) {
|
|
||||||
var dragOffsetY by remember { mutableFloatStateOf(0f) }
|
|
||||||
var rowHeightPx by remember { mutableIntStateOf(0) }
|
|
||||||
val highlight = if (isCurrent) {
|
|
||||||
MaterialTheme.colorScheme.primary.copy(alpha = HIGHLIGHT_ALPHA)
|
|
||||||
} else {
|
|
||||||
Color.Transparent
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.onSizeChanged { rowHeightPx = it.height }
|
|
||||||
.zIndex(if (dragOffsetY != 0f) 1f else 0f)
|
|
||||||
.graphicsLayer { translationY = dragOffsetY }
|
|
||||||
.background(highlight)
|
|
||||||
.clickable(onClick = onClick)
|
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
DragHandle(
|
|
||||||
index = index,
|
|
||||||
queueSize = queueSize,
|
|
||||||
rowHeightPx = rowHeightPx,
|
|
||||||
onOffsetChange = { dragOffsetY = it },
|
|
||||||
onMove = onMove,
|
|
||||||
)
|
|
||||||
QueueRowThumbnail(track = track)
|
|
||||||
if (isCurrent) {
|
|
||||||
Icon(
|
|
||||||
Lucide.Volume2,
|
|
||||||
contentDescription = "Now playing",
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
QueueRowText(track = track, isCurrent = isCurrent, modifier = Modifier.weight(1f))
|
|
||||||
if (track.durationSec > 0) {
|
|
||||||
Text(
|
|
||||||
text = formatDuration(track.durationSec),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
LikeButton(liked = liked, onToggle = onToggleLike)
|
|
||||||
IconButton(onClick = onRemove) {
|
|
||||||
Icon(Lucide.X, contentDescription = "Remove from queue")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DragHandle(
|
|
||||||
index: Int,
|
|
||||||
queueSize: Int,
|
|
||||||
rowHeightPx: Int,
|
|
||||||
onOffsetChange: (Float) -> Unit,
|
|
||||||
onMove: (Int, Int) -> Unit,
|
|
||||||
) {
|
|
||||||
// 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.
|
|
||||||
var offset by remember { mutableFloatStateOf(0f) }
|
|
||||||
Icon(
|
|
||||||
Lucide.GripVertical,
|
|
||||||
contentDescription = "Reorder track",
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.pointerInput(index, queueSize, rowHeightPx) {
|
|
||||||
detectDragGestures(
|
|
||||||
onDrag = { change, dragAmount ->
|
|
||||||
change.consume()
|
|
||||||
offset += dragAmount.y
|
|
||||||
onOffsetChange(offset)
|
|
||||||
},
|
|
||||||
onDragEnd = {
|
|
||||||
val delta = if (rowHeightPx > 0) (offset / rowHeightPx).roundToInt() else 0
|
|
||||||
val target = (index + delta).coerceIn(0, queueSize - 1)
|
|
||||||
if (target != index) onMove(index, target)
|
|
||||||
offset = 0f
|
|
||||||
onOffsetChange(0f)
|
|
||||||
},
|
|
||||||
onDragCancel = {
|
|
||||||
offset = 0f
|
|
||||||
onOffsetChange(0f)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun QueueRowThumbnail(track: TrackRef) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(48.dp)
|
|
||||||
.clip(RoundedCornerShape(4.dp))
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
ServerImage(
|
|
||||||
url = track.coverUrl,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(48.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Lucide.Music,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun QueueRowText(track: TrackRef, isCurrent: Boolean, modifier: Modifier = Modifier) {
|
|
||||||
Column(modifier = modifier) {
|
|
||||||
Text(
|
|
||||||
text = track.title,
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
fontWeight = if (isCurrent) FontWeight.Medium else FontWeight.Normal,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
val subtitle = queueSubtitle(track)
|
|
||||||
if (subtitle.isNotEmpty()) {
|
|
||||||
Text(
|
|
||||||
text = subtitle,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "Artist · Album" — collapses gracefully when either is missing. */
|
|
||||||
private fun queueSubtitle(track: TrackRef): String = listOf(track.artistName, track.albumTitle)
|
|
||||||
.filter { it.isNotEmpty() }
|
|
||||||
.joinToString(" · ")
|
|
||||||
|
|
||||||
/** "N tracks · 12 min" header summary. */
|
/** "N tracks · 12 min" header summary. */
|
||||||
private fun queueSummary(tracks: List<TrackRef>): String {
|
private fun queueSummary(tracks: List<TrackRef>): String {
|
||||||
@@ -367,6 +190,5 @@ private fun queueSummary(tracks: List<TrackRef>): String {
|
|||||||
return "${tracks.size} $noun · $length"
|
return "${tracks.size} $noun · $length"
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val HIGHLIGHT_ALPHA = 0.12f
|
|
||||||
private const val SECONDS_PER_MINUTE = 60
|
private const val SECONDS_PER_MINUTE = 60
|
||||||
private const val MINUTES_PER_HOUR = 60
|
private const val MINUTES_PER_HOUR = 60
|
||||||
|
|||||||
@@ -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' : ''}"
|
||||||
>
|
>
|
||||||
<button
|
<!--
|
||||||
type="button"
|
The album art is the grab surface (#2395). The grip used to occupy its own
|
||||||
aria-label="Reorder track (use arrow keys)"
|
column in every row; it now sits OVER the art, so it costs no horizontal
|
||||||
aria-keyshortcuts="ArrowUp ArrowDown"
|
space at all. `use:draggable` is on the row (above), so dragging already
|
||||||
onkeydown={handleHandleKeydown}
|
worked from anywhere — the grip's real jobs are being the visual cue and
|
||||||
class="cursor-grab text-text-secondary hover:text-text-primary flex-shrink-0"
|
the keyboard target, and both survive here.
|
||||||
>
|
|
||||||
<GripVertical size={16} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<img
|
It stays VISIBLE at rest, just quiet — it is the only thing that says this
|
||||||
src={coverUrl(track.album_id)}
|
list can be reordered at all, so hiding it until hover would trade the
|
||||||
alt=""
|
operator's space complaint for a discoverability one (rule #24), and would
|
||||||
onerror={(e) => ((e.currentTarget as HTMLImageElement).src = FALLBACK_COVER)}
|
leave nothing for touch, which has no hover. The scrim only appears on
|
||||||
class="h-10 w-10 flex-shrink-0 rounded object-cover"
|
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
|
||||||
|
type="button"
|
||||||
|
aria-label="Reorder track (use arrow keys)"
|
||||||
|
aria-keyshortcuts="ArrowUp ArrowDown"
|
||||||
|
onkeydown={handleHandleKeydown}
|
||||||
|
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} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<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