EARHORNMUSIC UPnP · Android TV · Phones · Android Auto · GNU/Linux
Release 0.5.0

QueueScreen.kt

Each line, with the release that last changed it.

0.5.01package dev.stave.ui.queue
2
3import androidx.activity.compose.BackHandler
4import androidx.compose.foundation.ExperimentalFoundationApi
5import androidx.compose.foundation.background
6import androidx.compose.foundation.clickable
7import androidx.compose.foundation.combinedClickable
8import androidx.compose.foundation.interaction.MutableInteractionSource
9import androidx.compose.foundation.layout.Arrangement
10import androidx.compose.foundation.layout.IntrinsicSize
11import androidx.compose.foundation.layout.aspectRatio
12import androidx.compose.foundation.layout.fillMaxHeight
13import androidx.compose.foundation.layout.heightIn
14import androidx.compose.foundation.layout.Box
15import androidx.compose.foundation.layout.Column
16import androidx.compose.foundation.layout.Row
17import androidx.compose.foundation.layout.Spacer
18import androidx.compose.foundation.layout.fillMaxSize
19import androidx.compose.foundation.layout.fillMaxWidth
20import androidx.compose.foundation.layout.height
21import androidx.compose.foundation.layout.padding
22import androidx.compose.foundation.layout.size
23import androidx.compose.foundation.layout.width
24import androidx.compose.foundation.lazy.LazyColumn
25import androidx.compose.foundation.lazy.itemsIndexed
26import androidx.compose.foundation.lazy.rememberLazyListState
27// Maintainer, 2026-10-29: empty-state scroll + TV detection for the Browse library button.
28import androidx.compose.foundation.rememberScrollState
29import androidx.compose.foundation.verticalScroll
30import androidx.compose.ui.platform.LocalConfiguration
31import androidx.compose.foundation.shape.RoundedCornerShape
32import androidx.compose.runtime.Composable
33import androidx.compose.runtime.LaunchedEffect
34import androidx.compose.runtime.collectAsState
35import androidx.compose.runtime.getValue
36import androidx.compose.runtime.mutableStateOf
37import androidx.compose.runtime.remember
38import androidx.compose.runtime.setValue
39import androidx.compose.ui.Alignment
40import androidx.compose.ui.Modifier
41import androidx.compose.ui.draw.clip
42import androidx.compose.ui.focus.FocusRequester
43import androidx.compose.ui.focus.focusRequester
44import androidx.compose.ui.focus.onFocusChanged
45import androidx.compose.ui.text.style.TextOverflow
46import androidx.compose.ui.unit.dp
47import androidx.hilt.navigation.compose.hiltViewModel
48import androidx.compose.material.icons.Icons
49import androidx.compose.material.icons.filled.DeleteSweep
50import androidx.compose.material.icons.filled.FolderOpen
51import androidx.compose.material.icons.filled.Pause
52import androidx.compose.material.icons.filled.PlayArrow
53import androidx.compose.material.icons.filled.Save
54import androidx.compose.material.icons.filled.Stop
55import androidx.tv.material3.Icon
56import androidx.tv.material3.MaterialTheme
57import androidx.tv.material3.Text
58import dev.stave.domain.model.DidlItem
59import dev.stave.domain.renderer.RepeatMode
60import dev.stave.ui.components.ActionSheet
61import dev.stave.ui.components.AlbumArt
62import dev.stave.ui.components.SheetAction
63import dev.stave.ui.components.onDpadLongPress
64import dev.stave.ui.components.staveFocusable
65import dev.stave.ui.nav.LocalLastContentFocus
66import dev.stave.ui.nowplaying.NowPlayingViewModel
67import dev.stave.ui.theme.AccentAmber
68import androidx.compose.ui.graphics.vector.ImageVector
69import dev.stave.ui.theme.AccentAmberSoft
70import dev.stave.ui.theme.OnMuted
71import dev.stave.ui.theme.OnPrimary
72import dev.stave.ui.theme.OnQuiet
73import dev.stave.ui.theme.SurfaceDivider
74import dev.stave.ui.theme.SurfaceRaised
75import dev.stave.ui.theme.adaptiveScreenPaddingHorizontal
76import dev.stave.ui.theme.adaptiveScreenPaddingVertical
77import dev.stave.ui.theme.isCompactWidth
78import dev.stave.ui.theme.staveInterFamily
79import dev.stave.ui.theme.withInterDigits
80
81/**
82 * View and manipulate the playback queue. Short-press a row = jump to that track and play.
83 * Long-press = open the action sheet with remove / move / play options. Header has a Clear
84 * action that empties the queue and stops playback.
85 *
86 * Reuses [NowPlayingViewModel] rather than defining its own VM — the queue lives on the
87 * renderer's state flow; there's no additional state to manage here.
88 */
89@Composable
90fun QueueScreen(
91 onBack: () -> Unit,
92 onSavePlaylist: () -> Unit,
93 onLoadPlaylist: () -> Unit,
94 onShowMetadata: (track: DidlItem.Track) -> Unit,
95 onMoreByArtist: (artistName: String) -> Unit,
96 viewModel: NowPlayingViewModel = hiltViewModel(),
97 // Maintainer, 2026-10-29: optional — when non-null the empty state shows "Browse library".
98 onBrowse: (() -> Unit)? = null,
99) {
100 // Cast follow-mode awareness — when observing a CCA session via raw-cast,
101 // the queue shown is the RECEIVER's live queue (fetched over the raw
102 // protocol and refreshed on QUEUE_CHANGE), so followers see exactly what
103 // the sender sees, including live edits. Tapping a row jumps the observed
104 // playback. Remove/Move/Clear stay hidden in observer mode: mutating the
105 // receiver queue from a follower would desync the sender's authoritative
106 // local copy (the sender full-reloads the receiver queue on its own edits
107 // and would clobber follower changes).
108 val isObservingCast by viewModel.rawCast.observing.collectAsState()
109 val castDeviceName by viewModel.rawCast.connectedDeviceName.collectAsState()
110 val localState by viewModel.state.collectAsState()
111 val observedState by viewModel.rawCast.playbackState.collectAsState()
112 val state = if (isObservingCast) observedState else localState
113 BackHandler(onBack = onBack)
114
115 // Long-press target — null when sheet is closed, non-null when open.
116 var sheetIndex: Int? by remember { mutableStateOf(null) }
117
118 // Scroll + focus control so opening the Queue screen lands the user on the currently-
119 // playing track, centered. Without this, a long queue opens scrolled to the top with
120 // focus nowhere useful — user has to page through dozens of rows to find "now playing."
121 val listState = rememberLazyListState()
122 val currentFocus = remember { FocusRequester() }
123 // Follow the playing row on every track change, local or following —
124 // the original behaviour. (A version that held still while following
125 // was built without the maintainer's word and reverted on his ruling,
126 // 2026-09-02.)
127 LaunchedEffect(state.queueIndex, state.queue.size) {
128 val idx = state.queueIndex
129 if (idx < 0 || idx >= state.queue.size) return@LaunchedEffect
130 // Scroll + focus. If the screen is in the middle of disposal (user pressed
131 // back during a track transition), the target focus node may not exist;
132 // runCatching swallows that cleanly. Small delay lets the LazyColumn settle
133 // its layout before we try to measure viewport + focus a row — reduces the
134 // race where layoutInfo reports 0 height or the row hasn't been composed yet.
135 kotlinx.coroutines.delay(50)
136 runCatching {
137 val viewport = listState.layoutInfo.viewportSize.height
138 val offset = -(viewport / 2)
139 listState.scrollToItem(idx, offset)
140 }
141 runCatching { currentFocus.requestFocus() }
142 }
143
144 Column(
145 Modifier
146 .fillMaxSize()
147 .padding(horizontal = adaptiveScreenPaddingHorizontal(), vertical = adaptiveScreenPaddingVertical())
148 ) {
149 // Compact (phone-portrait) header: one row, "Queue · N tracks" on the
150 // left + iconified Load / Save / Clear on the right. The prior layout
151 // stacked the title above the action button row — two rows ate ~80dp
152 // of headroom above the queue itself for two pieces of UI that
153 // comfortably fit on one. Title at titleMedium (was titleLarge), the
154 // three actions are 40dp icon boxes with contentDescription for
155 // screen readers and tooltips. Clear is hidden when the queue is
156 // empty (same conditional the prior layout used). On a 411dp screen
157 // the title runs ~150dp and 3 × 40dp + spacing = ~136dp, fitting
158 // comfortably with the SpaceBetween arrangement.
159 val modeIndicators = buildList {
160 if (state.shuffleEnabled) add("Shuffle")
161 when (state.repeatMode) {
162 RepeatMode.ALL -> add("Repeat")
163 RepeatMode.ONE -> add("Repeat 1")
164 RepeatMode.OFF -> {}
165 }
166 }.joinToString(" · ")
167
168 if (isCompactWidth()) {
169 Row(
170 Modifier.fillMaxWidth(),
171 horizontalArrangement = Arrangement.SpaceBetween,
172 verticalAlignment = Alignment.CenterVertically,
173 ) {
174 val countLabel = if (state.queue.isEmpty()) "Empty" else "${state.queue.size} tracks"
175 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
176 Text(
177 withInterDigits("Queue · $countLabel"),
178 style = MaterialTheme.typography.titleMedium,
179 color = OnPrimary,
180 )
181 if (modeIndicators.isNotBlank()) {
182 Text(
183 modeIndicators,
184 style = MaterialTheme.typography.labelSmall,
185 color = AccentAmber,
186 )
187 }
188 }
189 Row(
190 horizontalArrangement = Arrangement.spacedBy(4.dp),
191 verticalAlignment = Alignment.CenterVertically,
192 ) {
193 CompactIconHeaderAction(
194 icon = Icons.Filled.FolderOpen,
195 description = "Load playlist",
196 onClick = onLoadPlaylist,
197 )
198 if (state.queue.isNotEmpty() && !isObservingCast) {
199 CompactIconHeaderAction(
200 icon = Icons.Filled.Save,
201 description = "Save queue as playlist",
202 onClick = onSavePlaylist,
203 )
204 CompactIconHeaderAction(
205 icon = Icons.Filled.DeleteSweep,
206 description = "Clear queue",
207 onClick = { viewModel.clearQueue() },
208 )
209 }
210 }
211 }
212 } else {
213 // Wide (tablet/landscape) header: EVERYTHING above the queue on
214 // ONE baseline-aligned line — title, count, mode indicators, and
215 // the following-notice — with the action buttons at the trailing
216 // edge. The prior layout stacked title over count and floated the
217 // notice on its own line below a 28dp spacer, burning three rows
218 // of headroom on what fits comfortably in one at tablet widths
219 // (field-rejected).
220 Row(
221 Modifier.fillMaxWidth(),
222 horizontalArrangement = Arrangement.SpaceBetween,
223 verticalAlignment = Alignment.CenterVertically
224 ) {
225 Row(
226 horizontalArrangement = Arrangement.spacedBy(12.dp),
227 modifier = Modifier.weight(1f, fill = false),
228 ) {
229 Text(
230 "Queue",
231 style = MaterialTheme.typography.displaySmall,
232 color = OnPrimary,
233 maxLines = 1,
234 modifier = Modifier.alignByBaseline(),
235 )
236 Text(
237 withInterDigits(if (state.queue.isEmpty()) "Empty" else "${state.queue.size} tracks"),
238 style = MaterialTheme.typography.bodyMedium,
239 color = OnMuted,
240 maxLines = 1,
241 modifier = Modifier.alignByBaseline(),
242 )
243 if (modeIndicators.isNotBlank()) {
244 Text(
245 modeIndicators,
246 style = MaterialTheme.typography.bodySmall,
247 color = AccentAmber,
248 maxLines = 1,
249 modifier = Modifier.alignByBaseline(),
250 )
251 }
252 if (isObservingCast) {
253 val label = if (castDeviceName != null) {
254 "Following $castDeviceName — queue is read-only here"
255 } else {
256 "Following Chromecast — queue is read-only here"
257 }
258 Text(
259 label,
260 style = MaterialTheme.typography.labelSmall,
261 color = dev.stave.ui.theme.CastReceiving,
262 maxLines = 1,
263 overflow = TextOverflow.Ellipsis,
264 modifier = Modifier.alignByBaseline(),
265 )
266 }
267 }
268 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
269 HeaderAction(label = "Load") { onLoadPlaylist() }
270 if (state.queue.isNotEmpty() && !isObservingCast) {
271 HeaderAction(label = "Save") { onSavePlaylist() }
272 HeaderAction(label = "Clear") { viewModel.clearQueue() }
273 }
274 }
275 }
276 }
277
278 Spacer(Modifier.height(if (isCompactWidth()) 6.dp else 12.dp))
279
280 // Observe-mode notice — only when following a CCA session. Tells the
281 // user why move actions are gone from the long-press sheet. CastReceiving
282 // blue matches the cast button tint on NowPlaying so the same color
283 // means "we're following, not driving" across surfaces.
284 if (isObservingCast && isCompactWidth()) {
285 // Wide layouts carry this inline in the single-line header
286 // above; only the compact (phone-portrait) header still needs
287 // the standalone line.
288 val label = if (castDeviceName != null) "Following $castDeviceName — queue is read-only here"
289 else "Following Chromecast — queue is read-only here"
290 Text(
291 label,
292 style = MaterialTheme.typography.labelSmall,
293 color = dev.stave.ui.theme.CastReceiving,
294 modifier = Modifier
295 .fillMaxWidth()
296 .padding(bottom = 6.dp),
297 )
298 }
299
300 if (state.queue.isEmpty()) {
301 // Maintainer, 2026-10-29: optional "Browse library" button under the empty-state
302 // text (shown when onBrowse is wired). It takes focus on TV so D-pad Center
303 // works immediately. The column scrolls so the button stays reachable on phone
304 // landscape (~245dp usable); when everything fits the Box still centers it.
305 val browseFocus = remember { FocusRequester() }
306 val emptyConfig = LocalConfiguration.current
307 val isTv = (emptyConfig.uiMode and android.content.res.Configuration.UI_MODE_TYPE_MASK) ==
308 android.content.res.Configuration.UI_MODE_TYPE_TELEVISION
309 if (onBrowse != null) {
310 LaunchedEffect(isTv) {
311 if (isTv) runCatching { browseFocus.requestFocus() }
312 }
313 }
314 Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
315 Column(
316 Modifier.verticalScroll(rememberScrollState()),
317 horizontalAlignment = Alignment.CenterHorizontally,
318 ) {
319 coil3.compose.AsyncImage(
320 model = dev.stave.R.drawable.earhorn_gramophone_glow,
321 contentDescription = null,
322 modifier = Modifier.size(160.dp),
323 )
324 Spacer(Modifier.height(16.dp))
325 Text(
326 "Nothing Queued.\nBrowse the library\n& play something",
327 style = MaterialTheme.typography.bodyLarge,
328 color = OnMuted,
329 textAlign = androidx.compose.ui.text.style.TextAlign.Center,
330 )
331 if (onBrowse != null) {
332 Spacer(Modifier.height(16.dp))
333 val browseInteraction = remember { MutableInteractionSource() }
334 Box(
335 Modifier
336 .focusRequester(browseFocus)
337 .background(SurfaceRaised)
338 .staveFocusable(browseInteraction, cornerRadiusDp = 6)
339 .clickable(browseInteraction, indication = null) { onBrowse() }
340 .padding(horizontal = 20.dp, vertical = 10.dp)
341 ) {
342 Text("Browse library", style = MaterialTheme.typography.labelLarge, color = OnPrimary)
343 }
344 }
345 }
346 }
347 } else {
348 val queueKeys = remember(state.queue) {
349 val seen = HashMap<String, Int>()
350 state.queue.map { t ->
351 val base = "${t.id}_${t.resourceUrl}"
352 val n = (seen[base] ?: 0) + 1
353 seen[base] = n
354 "$base#$n"
355 }
356 }
357 LazyColumn(
358 state = listState,
359 verticalArrangement = Arrangement.spacedBy(4.dp),
360 ) {
361 // Maintainer, 2026-10-29: the same track queued twice produced identical
362 // id+url keys and crashed LazyColumn ("Key was already used"). Keys stay
363 // track-based (so Move/Remove keep the focused row's identity) and only
364 // repeats are numbered: "<id>_<url>#1", "#2", …
365 itemsIndexed(state.queue, key = { i, _ -> queueKeys[i] }) { index, track ->
366 QueueRow(
367 index = index,
368 track = track,
369 isCurrent = index == state.queueIndex,
370 isPlaying = state.isPlaying,
371 isStopped = state.isStopped,
372 focusRequester = if (index == state.queueIndex) currentFocus else null,
373 onClick = { viewModel.skipToQueueItem(index) },
374 onLongClick = { sheetIndex = index },
375 )
376 }
377 }
378 }
379 }
380
381 // Action sheet for the long-pressed track. If the track disappears from the queue
382 // (auto-advance, user clears queue, playlist ends) while the sheet is open, we
383 // close it in a LaunchedEffect — mutating sheetIndex directly during composition
384 // is undefined behavior and a plausible source of the intermittent back-navigation
385 // crash during track transitions.
386 LaunchedEffect(sheetIndex, state.queue.size) {
387 val idx = sheetIndex
388 if (idx != null && (idx < 0 || idx >= state.queue.size)) {
389 sheetIndex = null
390 }
391 }
392 sheetIndex?.let { idx ->
393 val track = state.queue.getOrNull(idx)
394 if (track != null) {
395 ActionSheet(
396 title = track.title,
397 subtitle = listOfNotNull(track.artist, track.album).joinToString(" · ")
398 .ifBlank { null },
399 actions = buildList {
400 add(SheetAction("▶", "Play from here") {
401 viewModel.skipToQueueItem(idx)
402 })
403 // "Play next" — move THIS item to the slot right after
404 // the currently playing track (long-press item 27 while
405 // #3 plays → 27 becomes the new #4). Hidden when it's
406 // meaningless (nothing playing, pressing the playing
407 // track itself, or the item is already next) and while
408 // observing (queue moves are follower-prohibited — the
409 // sender's local copy is authoritative).
410 val curIdx = state.queueIndex
411 if (!isObservingCast && curIdx >= 0 &&
412 idx != curIdx && idx != curIdx + 1
413 ) {
414 add(SheetAction("⤵", "Play next") {
415 // Move semantics are remove-then-insert: pulling
416 // an item from BEFORE the playing track shifts
417 // the playing index down by one, so the "next"
418 // slot is curIdx itself in that case.
419 val target = if (idx < curIdx) curIdx else curIdx + 1
420 viewModel.moveInQueue(idx, target)
421 })
422 }
423 if (!isObservingCast) {
424 add(SheetAction("✕", "Remove from queue") {
425 viewModel.removeFromQueue(idx)
426 })
427 }
428 // Matches the pattern in DetailScreen / BrowseScreen / MoreScreen — surface
429 // "Albums by <artist>" so long-pressing a queue row gives a path back to the
430 // artist's catalog without leaving the queue. Track may have an albumArtist
431 // (compilation-style) or just artist; prefer albumArtist when present so the
432 // browse target is the credited album artist not the per-track artist.
433 val artistForMore = track.albumArtist?.takeIf { it.isNotBlank() }
434 ?: track.artist?.takeIf { it.isNotBlank() }
435 if (artistForMore != null) {
436 add(SheetAction("♫", "Albums by $artistForMore") {
437 onMoreByArtist(artistForMore)
438 })
439 }
440 // Move controls are hidden while following: the queue shown here is
441 // the RECEIVER's live queue (see the header of this file), and the
442 // sender's local copy is authoritative — a follower's edit would be
443 // clobbered by the sender's next full reload.
444 if (!isObservingCast) {
445 if (idx > 0) {
446 add(SheetAction("▲", "Move up") {
447 viewModel.moveInQueue(idx, idx - 1)
448 })
449 }
450 if (idx < state.queue.lastIndex) {
451 add(SheetAction("▼", "Move down") {
452 viewModel.moveInQueue(idx, idx + 1)
453 })
454 }
455 }
456 add(SheetAction("ⓘ", "Show tags") {
457 onShowMetadata(track)
458 })
459 },
460 onDismiss = { sheetIndex = null },
461 )
462 }
463 }
464}
465
466@OptIn(ExperimentalFoundationApi::class)
467@Composable
468private fun QueueRow(
469 index: Int,
470 track: DidlItem.Track,
471 isCurrent: Boolean,
472 isPlaying: Boolean,
473 isStopped: Boolean,
474 focusRequester: FocusRequester?,
475 onClick: () -> Unit,
476 onLongClick: () -> Unit,
477) {
478 val interaction = remember { MutableInteractionSource() }
479 // Ensure every row has a stable FocusRequester we can report into
480 // LocalLastContentFocus. If the parent passed one (the current track's),
481 // we reuse it; otherwise create a local one.
482 val ownRequester = remember { FocusRequester() }
483 val effectiveRequester = focusRequester ?: ownRequester
484 val lastFocusSink = LocalLastContentFocus.current
485 Box(
486 Modifier
487 .fillMaxWidth()
488 .background(if (isCurrent) AccentAmberSoft else SurfaceRaised)
489 .focusRequester(effectiveRequester)
490 .onFocusChanged { if (it.isFocused) lastFocusSink.value = effectiveRequester }
491 .staveFocusable(interaction)
492 .onDpadLongPress(onLongClick)
493 .combinedClickable(
494 interactionSource = interaction,
495 indication = null,
496 onClick = onClick,
497 onLongClick = onLongClick,
498 )
499 .padding(
500 start = if (isCompactWidth()) 2.dp else 24.dp,
501 end = if (isCompactWidth()) 8.dp else 24.dp,
502 top = if (isCompactWidth()) 4.dp else 10.dp,
503 bottom = if (isCompactWidth()) 2.dp else 10.dp,
504 )
505 ) {
506 Row(
507 verticalAlignment = Alignment.Top,
508 horizontalArrangement = Arrangement.spacedBy(if (isCompactWidth()) 12.dp else 16.dp),
509 ) {
510 // Marker / index column — TV/wide only. Phone reclaims the
511 // space for the text block (the now-playing bar already
512 // shows playback state).
513 if (!isCompactWidth()) {
514 if (isCurrent) {
515 val markerIcon = when {
516 isStopped -> Icons.Filled.Stop
517 isPlaying -> Icons.Filled.PlayArrow
518 else -> Icons.Filled.Pause
519 }
520 val markerDesc = when {
521 isStopped -> "Stopped"
522 isPlaying -> "Playing"
523 else -> "Paused"
524 }
525 Icon(
526 markerIcon,
527 contentDescription = markerDesc,
528 tint = AccentAmber,
529 modifier = Modifier
530 .width(40.dp)
531 .size(28.dp),
532 )
533 } else {
534 Text(
535 "${index + 1}.",
536 style = MaterialTheme.typography.titleMedium.copy(fontFamily = staveInterFamily()),
537 color = OnQuiet,
538 modifier = Modifier.width(40.dp),
539 )
540 }
541 }
542
543 if (isCompactWidth()) {
544 val year = track.date?.let {
545 Regex("""\d{4}""").find(it)?.value
546 }
547 val numAndDur = buildList<String> {
548 track.trackNumber?.takeIf { it > 0 }?.let { add("#$it") }
549 track.duration?.let {
550 add("%d:%02d".format(it.inWholeMinutes, it.inWholeSeconds % 60))
551 }
552 }.joinToString(" \u2022 ")
553 Column {
554 Row(
555 Modifier.height(IntrinsicSize.Min),
556 horizontalArrangement = Arrangement.spacedBy(12.dp),
557 ) {
558 AlbumArt(
559 model = track.albumArtUri,
560 modifier = Modifier
561 .size(96.dp)
562 .clip(RoundedCornerShape(4.dp)),
563 )
564 Column(
565 Modifier.weight(1f).fillMaxHeight(),
566 verticalArrangement = Arrangement.SpaceEvenly,
567 ) {
568 Text(
569 withInterDigits(track.title),
570 style = MaterialTheme.typography.bodySmall,
571 color = OnPrimary,
572 maxLines = 2,
573 overflow = TextOverflow.Ellipsis,
574 )
575 val albumLine = track.album?.takeIf { it.isNotBlank() }
576 if (albumLine != null) {
577 Text(
578 withInterDigits(albumLine),
579 style = MaterialTheme.typography.labelSmall,
580 color = OnMuted,
581 maxLines = 1,
582 overflow = TextOverflow.Ellipsis,
583 )
584 }
585 val artistLine = track.artist?.takeIf { it.isNotBlank() }
586 if (artistLine != null) {
587 Text(
588 withInterDigits(artistLine),
589 style = MaterialTheme.typography.labelSmall,
590 color = OnMuted,
591 maxLines = 1,
592 overflow = TextOverflow.Ellipsis,
593 )
594 }
595 }
596 }
597 Spacer(Modifier.height(4.dp))
598 Row(verticalAlignment = Alignment.CenterVertically) {
599 Box(Modifier.width(96.dp), contentAlignment = Alignment.Center) {
600 if (year != null) {
601 Text(
602 year,
603 style = MaterialTheme.typography.labelMedium.copy(fontFamily = staveInterFamily()),
604 color = OnMuted,
605 )
606 }
607 }
608 Spacer(Modifier.width(12.dp))
609 if (numAndDur.isNotEmpty()) {
610 Text(
611 numAndDur,
612 style = MaterialTheme.typography.labelMedium.copy(fontFamily = staveInterFamily()),
613 color = OnMuted,
614 )
615 }
616 }
617 }
618 } else {
619 // ── TV / wide / phone-landscape layout ──
620 // 80dp art (was 48dp) so the artwork registers visually
621 // alongside the now-running title/artist/album column —
622 // the previous 48dp left the row looking like it had a
623 // postage stamp glued to a slab of text.
624 AlbumArt(
625 model = track.albumArtUri,
626 modifier = Modifier
627 .size(80.dp)
628 .clip(RoundedCornerShape(4.dp)),
629 )
630 Column(Modifier.weight(1f)) {
631 Text(
632 withInterDigits(track.title),
633 style = MaterialTheme.typography.titleMedium,
634 color = OnPrimary,
635 maxLines = 2,
636 overflow = TextOverflow.Ellipsis,
637 )
638 val artistLine = buildList {
639 track.trackNumber?.takeIf { it > 0 }?.let { add("#$it") }
640 track.artist?.takeIf { it.isNotBlank() }?.let { add(it) }
641 }.joinToString(" \u00b7 ")
642 if (artistLine.isNotEmpty()) {
643 Text(
644 withInterDigits(artistLine),
645 style = MaterialTheme.typography.bodyMedium,
646 color = OnMuted,
647 maxLines = 1,
648 overflow = TextOverflow.Ellipsis,
649 )
650 }
651 val albumLine = track.album?.takeIf { it.isNotBlank() }
652 if (albumLine != null) {
653 Text(
654 withInterDigits(albumLine),
655 style = MaterialTheme.typography.bodyMedium,
656 color = OnMuted,
657 maxLines = 1,
658 overflow = TextOverflow.Ellipsis,
659 )
660 }
661 }
662 track.duration?.let {
663 Text(
664 "%d:%02d".format(it.inWholeMinutes, (it.inWholeSeconds % 60)),
665 style = MaterialTheme.typography.bodyLarge.copy(fontFamily = staveInterFamily()),
666 color = OnMuted,
667 )
668 }
669 }
670 }
671 }
672}
673
674@Composable
675private fun HeaderAction(label: String, onClick: () -> Unit) {
676 val interaction = remember { MutableInteractionSource() }
677 Box(
678 Modifier
679 .background(SurfaceRaised)
680 .staveFocusable(interaction, cornerRadiusDp = 6)
681 .clickable(interaction, indication = null) { onClick() }
682 .padding(horizontal = 20.dp, vertical = 10.dp)
683 ) {
684 Text(label, style = MaterialTheme.typography.labelLarge, color = OnPrimary)
685 }
686}
687
688/** Iconified compact-header action — square 40dp tap target on the
689 * raised surface, used for the Load / Save / Clear strip on the phone
690 * Queue screen. Same focus + interaction wiring as [HeaderAction] so
691 * D-pad nav still works if a phone with hardware nav keys is in use. */
692@Composable
693private fun CompactIconHeaderAction(
694 icon: ImageVector,
695 description: String,
696 onClick: () -> Unit,
697) {
698 val interaction = remember { MutableInteractionSource() }
699 Box(
700 Modifier
701 .size(40.dp)
702 .background(SurfaceRaised)
703 .staveFocusable(interaction, cornerRadiusDp = 6)
704 .clickable(interaction, indication = null, onClick = onClick),
705 contentAlignment = Alignment.Center,
706 ) {
707 Icon(
708 icon,
709 contentDescription = description,
710 tint = OnPrimary,
711 modifier = Modifier.size(22.dp),
712 )
713 }
714}