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

QueueScreen.kt

package dev.stave.ui.queue

import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.heightIn
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.height
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
// Maintainer, 2026-10-29: empty-state scroll + TV detection for the Browse library button.
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Save
import androidx.compose.material.icons.filled.Stop
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import dev.stave.domain.model.DidlItem
import dev.stave.domain.renderer.RepeatMode
import dev.stave.ui.components.ActionSheet
import dev.stave.ui.components.AlbumArt
import dev.stave.ui.components.SheetAction
import dev.stave.ui.components.onDpadLongPress
import dev.stave.ui.components.staveFocusable
import dev.stave.ui.nav.LocalLastContentFocus
import dev.stave.ui.nowplaying.NowPlayingViewModel
import dev.stave.ui.theme.AccentAmber
import androidx.compose.ui.graphics.vector.ImageVector
import dev.stave.ui.theme.AccentAmberSoft
import dev.stave.ui.theme.OnMuted
import dev.stave.ui.theme.OnPrimary
import dev.stave.ui.theme.OnQuiet
import dev.stave.ui.theme.SurfaceDivider
import dev.stave.ui.theme.SurfaceRaised
import dev.stave.ui.theme.adaptiveScreenPaddingHorizontal
import dev.stave.ui.theme.adaptiveScreenPaddingVertical
import dev.stave.ui.theme.isCompactWidth
import dev.stave.ui.theme.staveInterFamily
import dev.stave.ui.theme.withInterDigits

/**
 * View and manipulate the playback queue. Short-press a row = jump to that track and play.
 * Long-press = open the action sheet with remove / move / play options. Header has a Clear
 * action that empties the queue and stops playback.
 *
 * Reuses [NowPlayingViewModel] rather than defining its own VM — the queue lives on the
 * renderer's state flow; there's no additional state to manage here.
 */
@Composable
fun QueueScreen(
    onBack: () -> Unit,
    onSavePlaylist: () -> Unit,
    onLoadPlaylist: () -> Unit,
    onShowMetadata: (track: DidlItem.Track) -> Unit,
    onMoreByArtist: (artistName: String) -> Unit,
    viewModel: NowPlayingViewModel = hiltViewModel(),
    // Maintainer, 2026-10-29: optional — when non-null the empty state shows "Browse library".
    onBrowse: (() -> Unit)? = null,
) {
    // Cast follow-mode awareness — when observing a CCA session via raw-cast,
    // the queue shown is the RECEIVER's live queue (fetched over the raw
    // protocol and refreshed on QUEUE_CHANGE), so followers see exactly what
    // the sender sees, including live edits. Tapping a row jumps the observed
    // playback. Remove/Move/Clear stay hidden in observer mode: mutating the
    // receiver queue from a follower would desync the sender's authoritative
    // local copy (the sender full-reloads the receiver queue on its own edits
    // and would clobber follower changes).
    val isObservingCast by viewModel.rawCast.observing.collectAsState()
    val castDeviceName by viewModel.rawCast.connectedDeviceName.collectAsState()
    val localState by viewModel.state.collectAsState()
    val observedState by viewModel.rawCast.playbackState.collectAsState()
    val state = if (isObservingCast) observedState else localState
    BackHandler(onBack = onBack)

    // Long-press target — null when sheet is closed, non-null when open.
    var sheetIndex: Int? by remember { mutableStateOf(null) }

    // Scroll + focus control so opening the Queue screen lands the user on the currently-
    // playing track, centered. Without this, a long queue opens scrolled to the top with
    // focus nowhere useful — user has to page through dozens of rows to find "now playing."
    val listState = rememberLazyListState()
    val currentFocus = remember { FocusRequester() }
    // Follow the playing row on every track change, local or following —
    // the original behaviour. (A version that held still while following
    // was built without the maintainer's word and reverted on his ruling,
    // 2026-09-02.)
    LaunchedEffect(state.queueIndex, state.queue.size) {
        val idx = state.queueIndex
        if (idx < 0 || idx >= state.queue.size) return@LaunchedEffect
        // Scroll + focus. If the screen is in the middle of disposal (user pressed
        // back during a track transition), the target focus node may not exist;
        // runCatching swallows that cleanly. Small delay lets the LazyColumn settle
        // its layout before we try to measure viewport + focus a row — reduces the
        // race where layoutInfo reports 0 height or the row hasn't been composed yet.
        kotlinx.coroutines.delay(50)
        runCatching {
            val viewport = listState.layoutInfo.viewportSize.height
            val offset = -(viewport / 2)
            listState.scrollToItem(idx, offset)
        }
        runCatching { currentFocus.requestFocus() }
    }

    Column(
        Modifier
            .fillMaxSize()
            .padding(horizontal = adaptiveScreenPaddingHorizontal(), vertical = adaptiveScreenPaddingVertical())
    ) {
        // Compact (phone-portrait) header: one row, "Queue · N tracks" on the
        // left + iconified Load / Save / Clear on the right. The prior layout
        // stacked the title above the action button row — two rows ate ~80dp
        // of headroom above the queue itself for two pieces of UI that
        // comfortably fit on one. Title at titleMedium (was titleLarge), the
        // three actions are 40dp icon boxes with contentDescription for
        // screen readers and tooltips. Clear is hidden when the queue is
        // empty (same conditional the prior layout used). On a 411dp screen
        // the title runs ~150dp and 3 × 40dp + spacing = ~136dp, fitting
        // comfortably with the SpaceBetween arrangement.
        val modeIndicators = buildList {
            if (state.shuffleEnabled) add("Shuffle")
            when (state.repeatMode) {
                RepeatMode.ALL -> add("Repeat")
                RepeatMode.ONE -> add("Repeat 1")
                RepeatMode.OFF -> {}
            }
        }.joinToString(" · ")

        if (isCompactWidth()) {
            Row(
                Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceBetween,
                verticalAlignment = Alignment.CenterVertically,
            ) {
                val countLabel = if (state.queue.isEmpty()) "Empty" else "${state.queue.size} tracks"
                Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                    Text(
                        withInterDigits("Queue · $countLabel"),
                        style = MaterialTheme.typography.titleMedium,
                        color = OnPrimary,
                    )
                    if (modeIndicators.isNotBlank()) {
                        Text(
                            modeIndicators,
                            style = MaterialTheme.typography.labelSmall,
                            color = AccentAmber,
                        )
                    }
                }
                Row(
                    horizontalArrangement = Arrangement.spacedBy(4.dp),
                    verticalAlignment = Alignment.CenterVertically,
                ) {
                    CompactIconHeaderAction(
                        icon = Icons.Filled.FolderOpen,
                        description = "Load playlist",
                        onClick = onLoadPlaylist,
                    )
                    if (state.queue.isNotEmpty() && !isObservingCast) {
                        CompactIconHeaderAction(
                            icon = Icons.Filled.Save,
                            description = "Save queue as playlist",
                            onClick = onSavePlaylist,
                        )
                        CompactIconHeaderAction(
                            icon = Icons.Filled.DeleteSweep,
                            description = "Clear queue",
                            onClick = { viewModel.clearQueue() },
                        )
                    }
                }
            }
        } else {
        // Wide (tablet/landscape) header: EVERYTHING above the queue on
        // ONE baseline-aligned line — title, count, mode indicators, and
        // the following-notice — with the action buttons at the trailing
        // edge. The prior layout stacked title over count and floated the
        // notice on its own line below a 28dp spacer, burning three rows
        // of headroom on what fits comfortably in one at tablet widths
        // (field-rejected).
        Row(
            Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.SpaceBetween,
            verticalAlignment = Alignment.CenterVertically
        ) {
            Row(
                horizontalArrangement = Arrangement.spacedBy(12.dp),
                modifier = Modifier.weight(1f, fill = false),
            ) {
                Text(
                    "Queue",
                    style = MaterialTheme.typography.displaySmall,
                    color = OnPrimary,
                    maxLines = 1,
                    modifier = Modifier.alignByBaseline(),
                )
                Text(
                    withInterDigits(if (state.queue.isEmpty()) "Empty" else "${state.queue.size} tracks"),
                    style = MaterialTheme.typography.bodyMedium,
                    color = OnMuted,
                    maxLines = 1,
                    modifier = Modifier.alignByBaseline(),
                )
                if (modeIndicators.isNotBlank()) {
                    Text(
                        modeIndicators,
                        style = MaterialTheme.typography.bodySmall,
                        color = AccentAmber,
                        maxLines = 1,
                        modifier = Modifier.alignByBaseline(),
                    )
                }
                if (isObservingCast) {
                    val label = if (castDeviceName != null) {
                        "Following $castDeviceName — queue is read-only here"
                    } else {
                        "Following Chromecast — queue is read-only here"
                    }
                    Text(
                        label,
                        style = MaterialTheme.typography.labelSmall,
                        color = dev.stave.ui.theme.CastReceiving,
                        maxLines = 1,
                        overflow = TextOverflow.Ellipsis,
                        modifier = Modifier.alignByBaseline(),
                    )
                }
            }
            Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                HeaderAction(label = "Load") { onLoadPlaylist() }
                if (state.queue.isNotEmpty() && !isObservingCast) {
                    HeaderAction(label = "Save") { onSavePlaylist() }
                    HeaderAction(label = "Clear") { viewModel.clearQueue() }
                }
            }
        }
        }

        Spacer(Modifier.height(if (isCompactWidth()) 6.dp else 12.dp))

        // Observe-mode notice — only when following a CCA session. Tells the
        // user why move actions are gone from the long-press sheet. CastReceiving
        // blue matches the cast button tint on NowPlaying so the same color
        // means "we're following, not driving" across surfaces.
        if (isObservingCast && isCompactWidth()) {
            // Wide layouts carry this inline in the single-line header
            // above; only the compact (phone-portrait) header still needs
            // the standalone line.
            val label = if (castDeviceName != null) "Following $castDeviceName — queue is read-only here"
                else "Following Chromecast — queue is read-only here"
            Text(
                label,
                style = MaterialTheme.typography.labelSmall,
                color = dev.stave.ui.theme.CastReceiving,
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(bottom = 6.dp),
            )
        }

        if (state.queue.isEmpty()) {
            // Maintainer, 2026-10-29: optional "Browse library" button under the empty-state
            // text (shown when onBrowse is wired). It takes focus on TV so D-pad Center
            // works immediately. The column scrolls so the button stays reachable on phone
            // landscape (~245dp usable); when everything fits the Box still centers it.
            val browseFocus = remember { FocusRequester() }
            val emptyConfig = LocalConfiguration.current
            val isTv = (emptyConfig.uiMode and android.content.res.Configuration.UI_MODE_TYPE_MASK) ==
                android.content.res.Configuration.UI_MODE_TYPE_TELEVISION
            if (onBrowse != null) {
                LaunchedEffect(isTv) {
                    if (isTv) runCatching { browseFocus.requestFocus() }
                }
            }
            Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
                Column(
                    Modifier.verticalScroll(rememberScrollState()),
                    horizontalAlignment = Alignment.CenterHorizontally,
                ) {
                    coil3.compose.AsyncImage(
                        model = dev.stave.R.drawable.earhorn_gramophone_glow,
                        contentDescription = null,
                        modifier = Modifier.size(160.dp),
                    )
                    Spacer(Modifier.height(16.dp))
                    Text(
                        "Nothing Queued.\nBrowse the library\n& play something",
                        style = MaterialTheme.typography.bodyLarge,
                        color = OnMuted,
                        textAlign = androidx.compose.ui.text.style.TextAlign.Center,
                    )
                    if (onBrowse != null) {
                        Spacer(Modifier.height(16.dp))
                        val browseInteraction = remember { MutableInteractionSource() }
                        Box(
                            Modifier
                                .focusRequester(browseFocus)
                                .background(SurfaceRaised)
                                .staveFocusable(browseInteraction, cornerRadiusDp = 6)
                                .clickable(browseInteraction, indication = null) { onBrowse() }
                                .padding(horizontal = 20.dp, vertical = 10.dp)
                        ) {
                            Text("Browse library", style = MaterialTheme.typography.labelLarge, color = OnPrimary)
                        }
                    }
                }
            }
        } else {
            val queueKeys = remember(state.queue) {
                val seen = HashMap<String, Int>()
                state.queue.map { t ->
                    val base = "${t.id}_${t.resourceUrl}"
                    val n = (seen[base] ?: 0) + 1
                    seen[base] = n
                    "$base#$n"
                }
            }
            LazyColumn(
                state = listState,
                verticalArrangement = Arrangement.spacedBy(4.dp),
            ) {
                // Maintainer, 2026-10-29: the same track queued twice produced identical
                // id+url keys and crashed LazyColumn ("Key was already used"). Keys stay
                // track-based (so Move/Remove keep the focused row's identity) and only
                // repeats are numbered: "<id>_<url>#1", "#2", …
                itemsIndexed(state.queue, key = { i, _ -> queueKeys[i] }) { index, track ->
                    QueueRow(
                        index = index,
                        track = track,
                        isCurrent = index == state.queueIndex,
                        isPlaying = state.isPlaying,
                        isStopped = state.isStopped,
                        focusRequester = if (index == state.queueIndex) currentFocus else null,
                        onClick = { viewModel.skipToQueueItem(index) },
                        onLongClick = { sheetIndex = index },
                    )
                }
            }
        }
    }

    // Action sheet for the long-pressed track. If the track disappears from the queue
    // (auto-advance, user clears queue, playlist ends) while the sheet is open, we
    // close it in a LaunchedEffect — mutating sheetIndex directly during composition
    // is undefined behavior and a plausible source of the intermittent back-navigation
    // crash during track transitions.
    LaunchedEffect(sheetIndex, state.queue.size) {
        val idx = sheetIndex
        if (idx != null && (idx < 0 || idx >= state.queue.size)) {
            sheetIndex = null
        }
    }
    sheetIndex?.let { idx ->
        val track = state.queue.getOrNull(idx)
        if (track != null) {
            ActionSheet(
                title = track.title,
                subtitle = listOfNotNull(track.artist, track.album).joinToString(" · ")
                    .ifBlank { null },
                actions = buildList {
                    add(SheetAction("▶", "Play from here") {
                        viewModel.skipToQueueItem(idx)
                    })
                    // "Play next" — move THIS item to the slot right after
                    // the currently playing track (long-press item 27 while
                    // #3 plays → 27 becomes the new #4). Hidden when it's
                    // meaningless (nothing playing, pressing the playing
                    // track itself, or the item is already next) and while
                    // observing (queue moves are follower-prohibited — the
                    // sender's local copy is authoritative).
                    val curIdx = state.queueIndex
                    if (!isObservingCast && curIdx >= 0 &&
                        idx != curIdx && idx != curIdx + 1
                    ) {
                        add(SheetAction("⤵", "Play next") {
                            // Move semantics are remove-then-insert: pulling
                            // an item from BEFORE the playing track shifts
                            // the playing index down by one, so the "next"
                            // slot is curIdx itself in that case.
                            val target = if (idx < curIdx) curIdx else curIdx + 1
                            viewModel.moveInQueue(idx, target)
                        })
                    }
                    if (!isObservingCast) {
                        add(SheetAction("✕", "Remove from queue") {
                            viewModel.removeFromQueue(idx)
                        })
                    }
                    // Matches the pattern in DetailScreen / BrowseScreen / MoreScreen — surface
                    // "Albums by <artist>" so long-pressing a queue row gives a path back to the
                    // artist's catalog without leaving the queue. Track may have an albumArtist
                    // (compilation-style) or just artist; prefer albumArtist when present so the
                    // browse target is the credited album artist not the per-track artist.
                    val artistForMore = track.albumArtist?.takeIf { it.isNotBlank() }
                        ?: track.artist?.takeIf { it.isNotBlank() }
                    if (artistForMore != null) {
                        add(SheetAction("♫", "Albums by $artistForMore") {
                            onMoreByArtist(artistForMore)
                        })
                    }
                    // Move controls are hidden while following: the queue shown here is
                    // the RECEIVER's live queue (see the header of this file), and the
                    // sender's local copy is authoritative — a follower's edit would be
                    // clobbered by the sender's next full reload.
                    if (!isObservingCast) {
                        if (idx > 0) {
                            add(SheetAction("▲", "Move up") {
                                viewModel.moveInQueue(idx, idx - 1)
                            })
                        }
                        if (idx < state.queue.lastIndex) {
                            add(SheetAction("▼", "Move down") {
                                viewModel.moveInQueue(idx, idx + 1)
                            })
                        }
                    }
                    add(SheetAction("ⓘ", "Show tags") {
                        onShowMetadata(track)
                    })
                },
                onDismiss = { sheetIndex = null },
            )
        }
    }
}

@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun QueueRow(
    index: Int,
    track: DidlItem.Track,
    isCurrent: Boolean,
    isPlaying: Boolean,
    isStopped: Boolean,
    focusRequester: FocusRequester?,
    onClick: () -> Unit,
    onLongClick: () -> Unit,
) {
    val interaction = remember { MutableInteractionSource() }
    // Ensure every row has a stable FocusRequester we can report into
    // LocalLastContentFocus. If the parent passed one (the current track's),
    // we reuse it; otherwise create a local one.
    val ownRequester = remember { FocusRequester() }
    val effectiveRequester = focusRequester ?: ownRequester
    val lastFocusSink = LocalLastContentFocus.current
    Box(
        Modifier
            .fillMaxWidth()
            .background(if (isCurrent) AccentAmberSoft else SurfaceRaised)
            .focusRequester(effectiveRequester)
            .onFocusChanged { if (it.isFocused) lastFocusSink.value = effectiveRequester }
            .staveFocusable(interaction)
            .onDpadLongPress(onLongClick)
            .combinedClickable(
                interactionSource = interaction,
                indication = null,
                onClick = onClick,
                onLongClick = onLongClick,
            )
            .padding(
                start = if (isCompactWidth()) 2.dp else 24.dp,
                end = if (isCompactWidth()) 8.dp else 24.dp,
                top = if (isCompactWidth()) 4.dp else 10.dp,
                bottom = if (isCompactWidth()) 2.dp else 10.dp,
            )
    ) {
        Row(
            verticalAlignment = Alignment.Top,
            horizontalArrangement = Arrangement.spacedBy(if (isCompactWidth()) 12.dp else 16.dp),
        ) {
            // Marker / index column — TV/wide only. Phone reclaims the
            // space for the text block (the now-playing bar already
            // shows playback state).
            if (!isCompactWidth()) {
                if (isCurrent) {
                    val markerIcon = when {
                        isStopped -> Icons.Filled.Stop
                        isPlaying -> Icons.Filled.PlayArrow
                        else -> Icons.Filled.Pause
                    }
                    val markerDesc = when {
                        isStopped -> "Stopped"
                        isPlaying -> "Playing"
                        else -> "Paused"
                    }
                    Icon(
                        markerIcon,
                        contentDescription = markerDesc,
                        tint = AccentAmber,
                        modifier = Modifier
                            .width(40.dp)
                            .size(28.dp),
                    )
                } else {
                    Text(
                        "${index + 1}.",
                        style = MaterialTheme.typography.titleMedium.copy(fontFamily = staveInterFamily()),
                        color = OnQuiet,
                        modifier = Modifier.width(40.dp),
                    )
                }
            }

        if (isCompactWidth()) {
                val year = track.date?.let {
                    Regex("""\d{4}""").find(it)?.value
                }
                val numAndDur = buildList<String> {
                    track.trackNumber?.takeIf { it > 0 }?.let { add("#$it") }
                    track.duration?.let {
                        add("%d:%02d".format(it.inWholeMinutes, it.inWholeSeconds % 60))
                    }
                }.joinToString(" \u2022 ")
                Column {
                    Row(
                        Modifier.height(IntrinsicSize.Min),
                        horizontalArrangement = Arrangement.spacedBy(12.dp),
                    ) {
                        AlbumArt(
                            model = track.albumArtUri,
                            modifier = Modifier
                                .size(96.dp)
                                .clip(RoundedCornerShape(4.dp)),
                        )
                        Column(
                            Modifier.weight(1f).fillMaxHeight(),
                            verticalArrangement = Arrangement.SpaceEvenly,
                        ) {
                            Text(
                                withInterDigits(track.title),
                                style = MaterialTheme.typography.bodySmall,
                                color = OnPrimary,
                                maxLines = 2,
                                overflow = TextOverflow.Ellipsis,
                            )
                            val albumLine = track.album?.takeIf { it.isNotBlank() }
                            if (albumLine != null) {
                                Text(
                                    withInterDigits(albumLine),
                                    style = MaterialTheme.typography.labelSmall,
                                    color = OnMuted,
                                    maxLines = 1,
                                    overflow = TextOverflow.Ellipsis,
                                )
                            }
                            val artistLine = track.artist?.takeIf { it.isNotBlank() }
                            if (artistLine != null) {
                                Text(
                                    withInterDigits(artistLine),
                                    style = MaterialTheme.typography.labelSmall,
                                    color = OnMuted,
                                    maxLines = 1,
                                    overflow = TextOverflow.Ellipsis,
                                )
                            }
                        }
                    }
                    Spacer(Modifier.height(4.dp))
                    Row(verticalAlignment = Alignment.CenterVertically) {
                        Box(Modifier.width(96.dp), contentAlignment = Alignment.Center) {
                            if (year != null) {
                                Text(
                                    year,
                                    style = MaterialTheme.typography.labelMedium.copy(fontFamily = staveInterFamily()),
                                    color = OnMuted,
                                )
                            }
                        }
                        Spacer(Modifier.width(12.dp))
                        if (numAndDur.isNotEmpty()) {
                            Text(
                                numAndDur,
                                style = MaterialTheme.typography.labelMedium.copy(fontFamily = staveInterFamily()),
                                color = OnMuted,
                            )
                        }
                    }
                }
            } else {
                // ── TV / wide / phone-landscape layout ──
                // 80dp art (was 48dp) so the artwork registers visually
                // alongside the now-running title/artist/album column —
                // the previous 48dp left the row looking like it had a
                // postage stamp glued to a slab of text.
                AlbumArt(
                    model = track.albumArtUri,
                    modifier = Modifier
                        .size(80.dp)
                        .clip(RoundedCornerShape(4.dp)),
                )
                Column(Modifier.weight(1f)) {
                    Text(
                        withInterDigits(track.title),
                        style = MaterialTheme.typography.titleMedium,
                        color = OnPrimary,
                        maxLines = 2,
                        overflow = TextOverflow.Ellipsis,
                    )
                    val artistLine = buildList {
                        track.trackNumber?.takeIf { it > 0 }?.let { add("#$it") }
                        track.artist?.takeIf { it.isNotBlank() }?.let { add(it) }
                    }.joinToString(" \u00b7 ")
                    if (artistLine.isNotEmpty()) {
                        Text(
                            withInterDigits(artistLine),
                            style = MaterialTheme.typography.bodyMedium,
                            color = OnMuted,
                            maxLines = 1,
                            overflow = TextOverflow.Ellipsis,
                        )
                    }
                    val albumLine = track.album?.takeIf { it.isNotBlank() }
                    if (albumLine != null) {
                        Text(
                            withInterDigits(albumLine),
                            style = MaterialTheme.typography.bodyMedium,
                            color = OnMuted,
                            maxLines = 1,
                            overflow = TextOverflow.Ellipsis,
                        )
                    }
                }
                track.duration?.let {
                    Text(
                        "%d:%02d".format(it.inWholeMinutes, (it.inWholeSeconds % 60)),
                        style = MaterialTheme.typography.bodyLarge.copy(fontFamily = staveInterFamily()),
                        color = OnMuted,
                    )
                }
            }
        }
    }
}

@Composable
private fun HeaderAction(label: String, onClick: () -> Unit) {
    val interaction = remember { MutableInteractionSource() }
    Box(
        Modifier
            .background(SurfaceRaised)
            .staveFocusable(interaction, cornerRadiusDp = 6)
            .clickable(interaction, indication = null) { onClick() }
            .padding(horizontal = 20.dp, vertical = 10.dp)
    ) {
        Text(label, style = MaterialTheme.typography.labelLarge, color = OnPrimary)
    }
}

/** Iconified compact-header action — square 40dp tap target on the
 *  raised surface, used for the Load / Save / Clear strip on the phone
 *  Queue screen. Same focus + interaction wiring as [HeaderAction] so
 *  D-pad nav still works if a phone with hardware nav keys is in use. */
@Composable
private fun CompactIconHeaderAction(
    icon: ImageVector,
    description: String,
    onClick: () -> Unit,
) {
    val interaction = remember { MutableInteractionSource() }
    Box(
        Modifier
            .size(40.dp)
            .background(SurfaceRaised)
            .staveFocusable(interaction, cornerRadiusDp = 6)
            .clickable(interaction, indication = null, onClick = onClick),
        contentAlignment = Alignment.Center,
    ) {
        Icon(
            icon,
            contentDescription = description,
            tint = OnPrimary,
            modifier = Modifier.size(22.dp),
        )
    }
}