refactor(player): split the queue row out of QueueScreen.kt — #2435
android / Build + lint + test (push) Successful in 3m40s
android / Build + lint + test (push) Successful in 3m40s
detekt TooManyFunctions: the swipe work took the file to 12 functions against a limit of 11. Suppressing it was the option; splitting is the better one, because the seam was already there — the row carries two gestures, a swipe background, and its own accessibility surface, which is more behaviour than the screen that merely lists it. QueueScreen.kt keeps the screen, list, pill, and summary (4). QueueRow.kt takes the row and its helpers (8). No behaviour change: same code, same order, per-file imports recomputed, QueueRow internal so QueueList can still call it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N6vZoJ4Se5YyaqdtGVkap5
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.detectDragGesturesAfterLongPress
|
|
||||||
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
|
||||||
@@ -25,51 +18,26 @@ 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
|
||||||
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.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.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.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.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 com.fabledsword.minstrel.theme.LocalActionColors
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -209,291 +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
|
|
||||||
}
|
|
||||||
// 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(" · ")
|
|
||||||
|
|
||||||
/** "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 {
|
||||||
@@ -507,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
|
||||||
|
|||||||
Reference in New Issue
Block a user