refactor(player): split the queue row out of QueueScreen.kt — #2435
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:
2026-08-04 10:52:14 -04:00
co-authored by Claude Opus 5
parent 1910a5ce61
commit 8b630e71ca
2 changed files with 348 additions and 318 deletions
@@ -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
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.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
@@ -25,51 +18,26 @@ 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
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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 androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.composables.icons.lucide.ArrowDown
import com.composables.icons.lucide.ArrowLeft
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.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
@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. */
private fun queueSummary(tracks: List<TrackRef>): String {
@@ -507,6 +190,5 @@ private fun queueSummary(tracks: List<TrackRef>): String {
return "${tracks.size} $noun · $length"
}
private const val HIGHLIGHT_ALPHA = 0.12f
private const val SECONDS_PER_MINUTE = 60
private const val MINUTES_PER_HOUR = 60