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

NowPlayingScreen.kt

package dev.stave.ui.nowplaying

import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Album
import androidx.compose.material.icons.filled.AvTimer
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.filled.Cast
import androidx.compose.material.icons.filled.CastConnected
import androidx.compose.material.icons.filled.LocalOffer
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.QueueMusic
import androidx.compose.material.icons.filled.Repeat
import androidx.compose.material.icons.filled.RepeatOn
import androidx.compose.material.icons.filled.RepeatOneOn
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.material.icons.filled.ShuffleOn
import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material.icons.filled.SkipPrevious
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
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.layout.ContentScale
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import dev.stave.R
import dev.stave.domain.model.parseGenres
import dev.stave.domain.model.RemoteRenderer
import dev.stave.domain.renderer.RepeatMode
import dev.stave.ui.components.ActionSheet
import dev.stave.ui.components.LocalMarqueeEnabled
import dev.stave.ui.components.SheetAction
import dev.stave.ui.components.staveFocusable
import dev.stave.ui.components.AlbumArt
import dev.stave.ui.components.staveMarquee
import dev.stave.ui.cast.CastPickerSheet
import dev.stave.ui.cast.RawCastPickerSheet
import dev.stave.ui.theme.AccentAmber
import dev.stave.ui.theme.CastReceiving
import dev.stave.ui.theme.OnAmber
import dev.stave.ui.theme.OnMuted
import dev.stave.ui.theme.OnPrimary
import dev.stave.ui.theme.OnQuiet
import dev.stave.ui.theme.SurfaceBase
import dev.stave.ui.theme.SurfaceDivider
import dev.stave.ui.theme.SurfaceRaised
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import dev.stave.ui.theme.adaptiveScreenPaddingHorizontal
import dev.stave.ui.theme.adaptiveScreenPaddingVertical
import dev.stave.ui.theme.staveInterFamily
import dev.stave.ui.theme.withInterDigits

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun NowPlayingScreen(
    activeServerUdn: String?,
    onMoreByArtist: (artistName: String) -> Unit,
    onMoreByGenre: (genreName: String) -> Unit,
    /** Open the "more tracks from this album" screen for the album the
     *  current track belongs to. Receives the album TITLE rather than the
     *  parentId, because the parentId on tag-based UPnP servers (MinimServer
     *  in particular) often points to a virtual aggregate container holding
     *  hundreds of items rather than the album as the user thinks of it.
     *  Searching by album title via `upnp:album = "X"` reliably returns
     *  exactly the tracks tagged with that album name. */
    onMoreFromThisAlbum: (albumTitle: String) -> Unit,
    onOpenQueue: () -> Unit,
    onOpenSearch: () -> Unit,
    onOpenLyrics: () -> Unit,
    onShowMetadata: (track: dev.stave.domain.model.DidlItem.Track) -> Unit,
    onBack: () -> Unit,
    /** Maintainer, 2026-10-29: the empty state had one button (Cast) and no
     *  way to the library or Settings — a dead end. These two give it exits. */
    onOpenLibrary: (() -> Unit)? = null,
    onOpenSettings: (() -> Unit)? = null,
    viewModel: NowPlayingViewModel = hiltViewModel(),
) {
    val state by viewModel.state.collectAsState()
    val activeUdn by viewModel.activeRendererUdn.collectAsState()
    // Maintainer, 2026-10-29: the remote actually playing (null = this device).
    // [activeUdn] is only the saved choice — kept for the picker's mark —
    // and the coordinator stays local while that renderer is undiscovered.
    val remoteUdn by viewModel.activeRemoteUdn.collectAsState()
    val renderers by viewModel.renderers.collectAsState()
    var showRendererPicker by remember { mutableStateOf(false) }
    BackHandler(onBack = onBack)

    // ── Observer-aware effective state (hoisted from the TV branch) ────
    // The TV branch below does its own rawCastState swap, but that code
    // sits AFTER the phone dispatch and AFTER the empty-state early
    // return. Result before this fix: phone/tablet layouts always
    // rendered the LOCAL renderer state (observer mode looked completely
    // dead), and with an empty local queue even the TV never reached its
    // own swap because `state.current == null` bailed out at the top.
    val obsActive by viewModel.rawCast.observing.collectAsState()
    val obsState by viewModel.rawCast.playbackState.collectAsState()
    val obsDeviceName by viewModel.rawCast.connectedDeviceName.collectAsState()
    // Voice-cast auto-observe: group named by the OBSERVE intent while
    // discovery/connect is still in flight (observing not yet true). Drives
    // the same "Connecting…" placeholder — E2 mandates a harmless connecting
    // state during the grace window, never an error dialog.
    val obsPendingName by viewModel.rawCast.pendingTargetName.collectAsState()
    // Maintainer spec 2026-09-02 (F2 on the screen's face): the observer's
    // liveness verdict — false once the session is IDLE, the socket has
    // gone silent past STATUS_SILENCE_MS, or the freeze latch fired. Until
    // this read existed, hand-back was the only consumer, so a handheld or
    // unarmed screen kept painting the dead song forever (Classical KC,
    // four hours, silent house). A dead session now renders the empty
    // state on every screen; a real status recomputes it and the track
    // returns.
    val obsSessionLiveRaw by viewModel.rawCast.sessionLive.collectAsState()
    // The raw flag flaps to false for under a second at every track
    // boundary (the receiver reports IDLE before it loads the next item).
    // The go-home rule has always waited SESSION_DEAD_DEBOUNCE_MS before
    // believing it; the screen must wait the same, or every track change
    // tears the face down to the empty state and rebuilds it (decision, 
    // 2026-09-03 — introduced by the F2 read on 2026-09-02). Live is
    // believed at once; dead only after the debounce.
    var obsSessionLive by remember { mutableStateOf(obsSessionLiveRaw) }
    LaunchedEffect(obsSessionLiveRaw) {
        if (obsSessionLiveRaw) {
            obsSessionLive = true
        } else {
            kotlinx.coroutines.delay(dev.stave.cast.raw.RawCastObserver.SESSION_DEAD_DEBOUNCE_MS)
            obsSessionLive = false
        }
    }
    // Has THIS observation ever had a live session? Decides the dead-
    // session label: a session that ended (including a receiver CLOSE,
    // which nulls obsState.current) reads "Waiting for…", not
    // "Connecting to…". Reset when observation ends.
    var obsSawLive by remember { mutableStateOf(false) }
    LaunchedEffect(obsActive, obsSessionLive) {
        if (!obsActive) obsSawLive = false else if (obsSessionLive) obsSawLive = true
    }
    // Maintainer, 2026-10-29: an observation that has had no live session for
    // ~20 s (idle device) reads "Waiting for…" instead of "Connecting to…"
    // forever. Restarts whenever observing or liveness changes.
    var obsWaitedLong by remember { mutableStateOf(false) }
    LaunchedEffect(obsActive, obsSessionLive) {
        obsWaitedLong = false
        if (obsActive && !obsSessionLive) {
            delay(20_000L)
            obsWaitedLong = true
        }
    }
    // Sticky observed track — bridges the brief null between MEDIA_STATUS
    // updates at track boundaries (same rationale as the TV branch's
    // lastObservedTrack, applied at dispatch level).
    var obsSticky by remember { mutableStateOf<dev.stave.domain.model.DidlItem.Track?>(null) }
    LaunchedEffect(obsActive, obsSessionLive, obsState.current?.id, obsState.current?.title) {
        if (obsActive && obsSessionLive) obsState.current?.let { obsSticky = it } else obsSticky = null
    }
    val effState = if (obsActive) obsState else state

    // ── Radio song metadata (voice memo 2026-08-27) ──────────────
    // Radio sessions carry no song in the Cast media session, so it is read
    // from musicnow's /status instead. Polling is deliberately narrow: only
    // while observing, only while this screen is composed AND the activity
    // is resumed, and the client latches itself off after one read that
    // says the session has no station metadata. musicnow serves one request
    // at a time and other screens already poll it.
    val uiForeground by viewModel.rawCast.uiForeground.collectAsState()
    val radioNow by viewModel.radioStatus.now.collectAsState()
    // Re-arm key: the client latches OFF after repeated reads saying the
    // session has no station metadata, so something must switch it back on
    // when the session changes (musicnow moving an already-observed group
    // from library to radio fires no new OBSERVE, by design).
    //
    // The key MUST ignore transient nulls. obsState.current goes null
    // between MEDIA_STATUS updates — track boundary, session change,
    // network hiccup — as the TV branch below already documents. An earlier
    // version keyed a DisposableEffect directly on resourceUrl, so every
    // one of those nulls re-ran the effect, whose onDispose called stop(),
    // which cleared the metadata and cancelled the poller. Symptom: the
    // song appeared for an instant and was replaced by the station name and
    // the default art, one beat after the cast connected. Hold the last
    // non-null contentId instead, and keep teardown separate from re-arm.
    var lastContentKey by remember { mutableStateOf<String?>(null) }
    val liveContentKey = if (obsActive) obsState.current?.resourceUrl else null
    LaunchedEffect(liveContentKey) {
        if (liveContentKey != null) lastContentKey = liveContentKey
    }
    @Suppress("LocalVariableName")
    val NO_POLL_TOKEN = -1L
    // Interest handle for this composition. Overlapping compositions each
    // hold their own acquired token; polling survives while ANY holder
    // remains, so the outgoing composition's release during a nav
    // transition cannot kill the incoming one's session — the failure the
    // single-owner design had at 17:11:46.248.
    val pollToken = remember { longArrayOf(NO_POLL_TOKEN) }
    // Gated on liveness too (audit 2026-09-02): a dead session has no
    // station to show, and polling musicnow's /status every 12 s for it
    // is load on the server for nothing.
    LaunchedEffect(obsActive, obsSessionLive, obsDeviceName, uiForeground, lastContentKey) {
        val group = obsDeviceName
        if (obsActive && obsSessionLive && uiForeground && group != null) {
            // Acquire the new interest BEFORE releasing the old one so the
            // holder count never transits zero (a zero-crossing stops the
            // poller and clears the metadata).
            val fresh = viewModel.radioStatus.acquire(group)
            if (pollToken[0] != NO_POLL_TOKEN && pollToken[0] != fresh) {
                viewModel.radioStatus.release(pollToken[0])
            }
            pollToken[0] = fresh
        } else if (pollToken[0] != NO_POLL_TOKEN) {
            viewModel.radioStatus.release(pollToken[0])
            pollToken[0] = NO_POLL_TOKEN
        }
    }
    DisposableEffect(Unit) {
        onDispose {
            if (pollToken[0] != NO_POLL_TOKEN) {
                viewModel.radioStatus.release(pollToken[0])
            }
        }
    }


    // Seekability of what's on screen: local playback always seeks; an
    // observed session seeks per the receiver's caps with the LIVE
    // override. Passed to every layout's ProgressBar.
    val obsCaps by viewModel.observedCaps.collectAsState()
    val seekableNow = if (obsActive) (obsCaps?.canSeek ?: true) else true
    val track = run {
        val base = when {
            obsActive && !obsSessionLive -> null   // dead session: nothing to show
            obsActive -> obsState.current ?: obsSticky
            else -> state.current
        }
        val rn = radioNow
        // Overlay only when observing and /status reported a station: song
        // and artist take the primary lines, the station name moves to the
        // album line (the station's own `album` field is low-confidence —
        // often the literal "Unknown Album" — so it is not used), and art
        // is used only when musicnow verified it. Everything else falls
        // through untouched, which is the "behave exactly as today" path.
        // Radio overlay applies ONLY to sessions that ARE radio (no
        // duration). Without this, a stale RadioNow — preserved through
        // server failures by design — overlaid library playback on the
        // same group: title fell through, artist/album/art were replaced.
        // Observed live 2026-08-29 while the server's HTTP was shedding: the
        // failure-keeps rule made the stale overlay immortal. caps==null
        // (pre-attach) permits, matching radio's no-duration state.
        if (obsActive && base != null && rn != null && (obsCaps?.isLive ?: true)) {
            base.copy(
                title = rn.title ?: base.title,
                artist = rn.artist,
                albumArtist = rn.artist,
                album = rn.station,
                // NO fallback to the session's art: for a radio overlay
                // that art is whatever was on screen before (often the
                // previous song, or a stale library track), so falling
                // back leaves a wrong image up until the next art arrives.
                // Null here draws the normal placeholder, per the memo.
                albumArtUri = rn.albumArtUrl,
            )
        } else base
    }
    // Maintainer, 2026-10-29: Dom-Etha panel state, its observer hooks and the
    // panel itself hoisted above the empty-state early return. A session going
    // dead mid-use dropped into the empty state and unmounted the panel.
    // Mounted once here (same slot whether or not a track shows) so it stays
    // open across the switch. Feeds the observer so hand-back treats an open
    // panel as "person present" and never yanks the screen mid-use.
    var domEthaPanelOpen by remember { mutableStateOf(false) }
    LaunchedEffect(domEthaPanelOpen) {
        viewModel.rawCast.setUiPanelOpen(domEthaPanelOpen)
    }
    DisposableEffect(Unit) {
        onDispose { viewModel.rawCast.setUiPanelOpen(false) }
    }
    val domEthaUrl by viewModel.domEthaUrl.collectAsState()
    if (domEthaPanelOpen) {
        domEthaUrl?.let { panelUrl ->
            dev.stave.ui.dometha.DomEthaPanel(
                url = panelUrl,
                onDismiss = { domEthaPanelOpen = false },
            )
        }
    }
    if (track == null) {
        // Empty state — nothing in the local queue and not observing (or
        // observing but the first MEDIA_STATUS hasn't arrived yet). Must
        // still offer a Cast/Observe entry point: requiring the user to
        // enqueue a local track just to observe CCA playback is terrible
        // UX, and on TV this was previously the ONLY way in.
        var emptyCastPickerOpen by remember { mutableStateOf(false) }
        val emptyConfig = LocalConfiguration.current
        val emptyIsTv = (emptyConfig.uiMode and android.content.res.Configuration.UI_MODE_TYPE_MASK) ==
            android.content.res.Configuration.UI_MODE_TYPE_TELEVISION
        val emptyCastFocus = remember { FocusRequester() }
        val emptyCastInteraction = remember { MutableInteractionSource() }
        LaunchedEffect(Unit) { runCatching { emptyCastFocus.requestFocus() } }

        Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
            // Scrolls so the buttons are never pushed off a short screen
            // (phone landscape) — maintainer, 2026-10-29.
            Column(
                Modifier.verticalScroll(androidx.compose.foundation.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(
                    when {
                        // Maintainer, 2026-10-29: also after ~20 s of no live session.
                        obsActive && !obsSessionLive && (obsSawLive || obsWaitedLong) ->
                            "Waiting for ${obsDeviceName ?: "device"}…"
                        obsActive -> "Connecting to ${obsDeviceName ?: "device"}…"
                        obsPendingName != null -> "Connecting to $obsPendingName…"
                        else -> "Nothing playing."
                    },
                    style = MaterialTheme.typography.headlineSmall,
                    color = OnMuted,
                )
                Spacer(Modifier.height(24.dp))
                // Cast / Observe entry — D-pad focusable on TV, tappable
                // elsewhere. Opens the platform-appropriate picker.
                Box(
                    Modifier
                        .focusRequester(emptyCastFocus)
                        .staveFocusable(emptyCastInteraction)
                        .clickable(emptyCastInteraction, indication = null) {
                            emptyCastPickerOpen = true
                        }
                        .background(SurfaceRaised, RoundedCornerShape(8.dp))
                        .padding(horizontal = 24.dp, vertical = 12.dp),
                ) {
                    Row(
                        verticalAlignment = Alignment.CenterVertically,
                        horizontalArrangement = Arrangement.spacedBy(10.dp),
                    ) {
                        Icon(
                            Icons.Filled.Cast,
                            contentDescription = null,
                            tint = OnPrimary,
                            modifier = Modifier.size(24.dp),
                        )
                        Text(
                            if (emptyIsTv) "Observe a Cast device" else "Cast / Observe",
                            style = MaterialTheme.typography.titleMedium,
                            color = OnPrimary,
                        )
                    }
                }
                // Exits from the empty state (decision, 2026-10-29).
                // Maintainer, 2026-10-29: + "Stop following" while observing,
                // so an idle observed device doesn't hide the local queue.
                if (onOpenLibrary != null || onOpenSettings != null || obsActive) {
                    Spacer(Modifier.height(12.dp))
                    Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
                        if (obsActive) {
                            // Same stop-following path as Browse's onStopObserving.
                            val stopFollowInteraction = remember { MutableInteractionSource() }
                            Box(
                                Modifier
                                    .staveFocusable(stopFollowInteraction)
                                    .clickable(stopFollowInteraction, indication = null) {
                                        viewModel.playbackGate.leaveObservingForLocal()
                                    }
                                    .background(SurfaceRaised, RoundedCornerShape(8.dp))
                                    .padding(horizontal = 20.dp, vertical = 12.dp),
                            ) {
                                Text(
                                    "Stop following",
                                    style = MaterialTheme.typography.titleMedium,
                                    color = OnPrimary,
                                )
                            }
                        }
                        if (onOpenLibrary != null) {
                            val libraryInteraction = remember { MutableInteractionSource() }
                            Box(
                                Modifier
                                    .staveFocusable(libraryInteraction)
                                    .clickable(libraryInteraction, indication = null) { onOpenLibrary() }
                                    .background(SurfaceRaised, RoundedCornerShape(8.dp))
                                    .padding(horizontal = 20.dp, vertical = 12.dp),
                            ) {
                                Text(
                                    "Library",
                                    style = MaterialTheme.typography.titleMedium,
                                    color = OnPrimary,
                                )
                            }
                        }
                        if (onOpenSettings != null) {
                            val settingsInteraction = remember { MutableInteractionSource() }
                            Box(
                                Modifier
                                    .staveFocusable(settingsInteraction)
                                    .clickable(settingsInteraction, indication = null) { onOpenSettings() }
                                    .background(SurfaceRaised, RoundedCornerShape(8.dp))
                                    .padding(horizontal = 20.dp, vertical = 12.dp),
                            ) {
                                Text(
                                    "Settings",
                                    style = MaterialTheme.typography.titleMedium,
                                    color = OnPrimary,
                                )
                            }
                        }
                    }
                }
            }
        }
        if (emptyIsTv) {
            RawCastPickerSheet(
                rawCast = viewModel.rawCast,
                visible = emptyCastPickerOpen,
                onDismiss = { emptyCastPickerOpen = false },
            )
        } else {
            CastPickerSheet(
                cast = viewModel.castIntegration,
                visible = emptyCastPickerOpen,
                onDismiss = { emptyCastPickerOpen = false },
                rawCast = viewModel.rawCast,
            )
        }
        return
    }

    val playFocus = remember { FocusRequester() }
    val sheetScope = rememberCoroutineScope()
    // Design §4.7: initial focus lands on play/pause. Request after composition so the node
    // is attached. This runs every time `track` appears (including after a navigation return),
    // which fixes the bug where clicking the Now Playing bar would navigate here but leave
    // focus floating — user had to press Up blindly to find the transport.
    // Initial focus on Play/Pause — once per visit. Until 2026-09-02 this
    // ran on EVERY track identity change, so a person navigating elsewhere on
    // a fixed screen was yanked back to Play/Pause each time the group
    // advanced (review N.21; design decision: stop the jump).
    LaunchedEffect(Unit) {
        runCatching { playFocus.requestFocus() }
    }

    // Sleep timer picker visibility
    var sleepTimerPickerOpen by remember { mutableStateOf(false) }
    // Maintainer, 2026-10-29: Dom-Etha panel state moved above the empty-state
    // early return (see there).
    // Corner-slot decision, computed ONCE here in the dispatcher because
    // FOUR layouts render a corner button (TV row, phone portrait, phone
    // landscape, large portrait). The first Dom-Etha build swapped only the
    // TV row, so other screens — large portrait — never showed the
    // button at all.
    // Maintainer, 2026-10-29: domEthaUrl and the panel itself are now read/
    // mounted above the empty-state early return.
    val cornerDomEthaSetting by viewModel.cornerButtonDomEtha.collectAsState()
    val cornerDomEtha = cornerDomEthaSetting && domEthaUrl != null
    val sleepTimerFocus = remember { FocusRequester() }
    // dpad-down target from Sleep Timer. Queue is always rendered in the more-
    // actions row below, so routing the down-press here gives a deterministic
    // landing — without the override, the default direction-aware focus search
    // can land in slot 4 (artist) or slot 5 (genre) depending on whether those
    // conditional buttons are present for the current track.
    val queueFocus = remember { FocusRequester() }

    // Artist picker visibility — shown when "More albums" is pressed on a
    // various-artists compilation. Lets the user choose between the track
    // artist and the album artist (which is typically "Various Artists").
    var artistPickerOpen by remember { mutableStateOf(false) }
    val moreByArtistFocus = remember { FocusRequester() }

    // Routing strategy:
    //   - Android TV (uiMode=TELEVISION) → original horizontal Row layout below.
    //     TV needs to remain byte-identical, so anything inside this if-block
    //     leaves the TV path untouched.
    //   - Phone portrait (compact width)  → NowPlayingCompact (vertical scroll).
    //   - Phone landscape (wide but not TV) → NowPlayingCompactLandscape, a
    //     horizontal split with the big touch transport buttons on the right.
    //     Previously phone landscape fell through to the TV layout via screen
    //     width alone (~914dp = "expanded"), which produced a usable-on-TV-only
    //     control row of 28dp icon buttons that are unreachable for thumb-tap.
    val configuration = LocalConfiguration.current
    val isAndroidTv = (configuration.uiMode and android.content.res.Configuration.UI_MODE_TYPE_MASK) ==
        android.content.res.Configuration.UI_MODE_TYPE_TELEVISION
    if (!isAndroidTv) {
        val isLandscape = configuration.orientation ==
            android.content.res.Configuration.ORIENTATION_LANDSCAPE
        val largePortrait = dev.stave.ui.theme.isLargePortrait()
        // design decision 2026-09-02 (I.40): a remembered "Play through"
        // choice silently sends every local play to a remote UPnP renderer.
        // Say so whenever one is selected. The layout tree below is the
        // 0.4.15 tree — no wrapper at all — unless a remote renderer IS
        // selected, in which case the layout is hosted in a Box with the
        // banner overlaid at the top. The earlier always-on Column/weighted-
        // Box wrapper changed how the large-portrait layout measured its
        // space and the art stopped filling its field (decision, 2026-09-03).
        // Maintainer, 2026-10-29: keyed on the ATTACHED remote, not the saved
        // setting — the banner said "Playing on X" while audio played here.
        val bannerHost: @Composable (@Composable () -> Unit) -> Unit =
            if (remoteUdn != null) { content ->
                Box(Modifier.fillMaxSize()) {
                    content()
                    RemoteRendererBanner(
                        activeUdn = remoteUdn,
                        renderers = renderers,
                        modifier = Modifier.align(Alignment.TopCenter),
                    )
                }
            } else { content -> content() }
        bannerHost {
        if (largePortrait) {
            // Large-screen portrait (HP Chromebase, tablet in portrait).
            // Neither the compact phone layout (cramped on a 21" screen)
            // nor the TV landscape Row (leaves 40% dead space at the
            // bottom in portrait) is appropriate. This layout expands
            // art, text, and controls to fill the generous vertical space.
            NowPlayingLargePortrait(
                seekable = seekableNow,
                cornerDomEtha = cornerDomEtha,
                onDomEtha = { domEthaPanelOpen = true },
                track = track,
                state = effState,
                viewModel = viewModel,
                playFocus = playFocus,
                activeServerUdn = activeServerUdn,
                onOpenQueue = onOpenQueue,
                onOpenSearch = onOpenSearch,
                onOpenLyrics = onOpenLyrics,
                onMoreByArtist = onMoreByArtist,
                onMoreByGenre = onMoreByGenre,
                onMoreFromThisAlbum = onMoreFromThisAlbum,
                onShowMetadata = onShowMetadata,
            )
        } else if (isLandscape) {
            NowPlayingCompactLandscape(
                seekable = seekableNow,
                cornerDomEtha = cornerDomEtha,
                onDomEtha = { domEthaPanelOpen = true },
                track = track,
                state = effState,
                viewModel = viewModel,
                playFocus = playFocus,
                activeServerUdn = activeServerUdn,
                onOpenQueue = onOpenQueue,
                onOpenSearch = onOpenSearch,
                onOpenLyrics = onOpenLyrics,
                onMoreByArtist = onMoreByArtist,
                onMoreByGenre = onMoreByGenre,
                onMoreFromThisAlbum = onMoreFromThisAlbum,
                onShowMetadata = onShowMetadata,
            )
        } else {
            NowPlayingCompact(
                seekable = seekableNow,
                cornerDomEtha = cornerDomEtha,
                onDomEtha = { domEthaPanelOpen = true },
                track = track,
                state = effState,
                viewModel = viewModel,
                playFocus = playFocus,
                activeServerUdn = activeServerUdn,
                onOpenQueue = onOpenQueue,
                onOpenSearch = onOpenSearch,
                onOpenLyrics = onOpenLyrics,
                onMoreByArtist = onMoreByArtist,
                onMoreByGenre = onMoreByGenre,
                onMoreFromThisAlbum = onMoreFromThisAlbum,
                onShowMetadata = onShowMetadata,
            )
        }
        }  // bannerHost
        return
    }

    // Cast picker for TV — uses raw protocol (no Cast SDK sender needed)
    var castPickerOpen by remember { mutableStateOf(false) }
    val rawCastObserving by viewModel.rawCast.observing.collectAsState()
    val castDeviceName by viewModel.rawCast.connectedDeviceName.collectAsState()
    val rawCastState by viewModel.rawCast.playbackState.collectAsState()
    // Open picker on Cast button tap
    LaunchedEffect(viewModel) {
        viewModel.showCastPicker.collect { castPickerOpen = true }
    }

    val castScope = rememberCoroutineScope()

    // When observing a Cast session via raw protocol, show Cast state.
    // Persist the last non-null observed track to bridge brief gaps where
    // rawCastState.current goes null between MEDIA_STATUS updates (track
    // boundary, network hiccup) — without this, tvTrack falls back to the
    // local renderer's stale track and AlbumArt redraws with a different URI,
    // producing a brown-background flash between tracks.
    val tvState = if (rawCastObserving) rawCastState else state
    var lastObservedTrack by remember { mutableStateOf<dev.stave.domain.model.DidlItem.Track?>(null) }
    LaunchedEffect(rawCastObserving, rawCastState.current?.id, rawCastState.current?.title) {
        if (rawCastObserving) {
            rawCastState.current?.let { lastObservedTrack = it }
        } else {
            lastObservedTrack = null
        }
    }
    // Enriched observed track: rawCast metadata + UPnP-side technical fields
    // looked up from the user's preferred server. When the lookup hasn't
    // resolved yet (first ~100ms) enrichedObserved is null and we use the
    // raw observed track. Empty fields stay null and the metadata row collapses
    // gracefully.
    val enrichedObserved by viewModel.observedTrackEnriched.collectAsState()
    // Radio overlay must be re-applied here: this branch resolves its own
    // observed track from rawCastState/enrichment and only falls back to
    // the dispatcher's `track` last, so the overlay applied there would be
    // bypassed on exactly other screens this feature is for. Polling
    // lifecycle stays owned by the dispatcher's DisposableEffect.
    val tvTrackBase = when {
        rawCastObserving -> enrichedObserved
            ?: rawCastState.current
            ?: lastObservedTrack
            ?: track
        else -> track
    }
    val tvTrack = radioNow.let { rn ->
        if (rawCastObserving && rn != null && (obsCaps?.isLive ?: true)) tvTrackBase.copy(
            title = rn.title ?: tvTrackBase.title,
            artist = rn.artist,
            albumArtist = rn.artist,
            album = rn.station,
            // Same as the dispatcher overlay: no fallback to session art,
            // which would leave the previous song's image on screen.
            albumArtUri = rn.albumArtUrl,
        ) else tvTrackBase
    }

        Row(Modifier.fillMaxSize().padding(horizontal = adaptiveScreenPaddingHorizontal(), vertical = adaptiveScreenPaddingVertical())) {
        // Art — left-aligned, square, capped at 420dp so the title Column gets
        // the overflow on large (4k) viewports.
        AlbumArt(
            model = tvTrack.albumArtUri,
            modifier = Modifier
                .fillMaxHeight()
                .aspectRatio(1f)
                .sizeIn(maxWidth = 420.dp, maxHeight = 420.dp)
                .clip(RoundedCornerShape(2.dp)),
        )
        Spacer(Modifier.width(60.dp))

        // Genre picker state — lives at the Column level so ActionSheet can render
        // as an overlay without being clipped by the Column's bounds.
        // All action surfaces below derive from tvTrack — when observing CCA we want
        // "More by", genre picker, "More from this album" etc. to target what's
        // playing on the speaker, not the stale local renderer state. When not
        // observing, tvTrack == track via the (tvState.current ?: track) coalesce.
        val artistForSearch = tvTrack.albumArtist ?: tvTrack.artist
        // For Various Artists compilations: if tvTrack.artist and tvTrack.albumArtist are
        // BOTH present and different, clicking "More albums" should offer an interstitial
        // letting the user choose which artist to browse. The track artist is the
        // performer on this specific track; the album artist is whoever curated the
        // compilation (often literally "Various Artists").
        val trackArtist = tvTrack.artist
        val albumArtist = tvTrack.albumArtist
        val hasMultipleArtists = !trackArtist.isNullOrBlank()
            && !albumArtist.isNullOrBlank()
            && !trackArtist.equals(albumArtist, ignoreCase = true)
        val genres = parseGenres(tvTrack.genre)
        var genrePickerOpen by remember { mutableStateOf(false) }

        Column(
            Modifier.weight(1f).fillMaxHeight(),
            verticalArrangement = Arrangement.Center,
        ) {
            val marqueeOn = LocalMarqueeEnabled.current
            // Title/artist/album: auto-fit block. Marquee path (single-line
            // scrolling) still uses the fixed-size Texts. Non-marquee path uses
            // AutoFitTitleBlock which guarantees complete text display by
            // shrinking all three fields proportionally when combined height
            // would exceed available space. Differentiation (title > artist =
            // album) is preserved because the ratio between sizes stays
            // constant; only the base font size scales down under pressure.
            val year = tvTrack.date?.let { Regex("""\d{4}""").find(it)?.value }
            val albumLine = tvTrack.album
            val byline = tvTrack.artist ?: tvTrack.albumArtist
            if (marqueeOn) {
                Text(
                    withInterDigits(tvTrack.title),
                    style = MaterialTheme.typography.headlineMedium,
                    color = OnPrimary,
                    maxLines = 1,
                    overflow = TextOverflow.Ellipsis,
                    modifier = Modifier.staveMarquee(true),
                )
                if (!byline.isNullOrBlank()) {
                    Spacer(Modifier.height(16.dp))
                    Text(
                        withInterDigits(byline),
                        style = MaterialTheme.typography.titleLarge,
                        color = OnPrimary,
                        maxLines = 1,
                        overflow = TextOverflow.Ellipsis,
                        modifier = Modifier.staveMarquee(true),
                    )
                }
                if (albumLine != null) {
                    Spacer(Modifier.height(16.dp))
                    Text(
                        withInterDigits(albumLine),
                        style = MaterialTheme.typography.titleLarge,
                        color = OnPrimary,
                        maxLines = 1,
                        overflow = TextOverflow.Ellipsis,
                        modifier = Modifier.staveMarquee(true),
                    )
                }
            } else {
                AutoFitTitleBlock(
                    title = tvTrack.title,
                    artist = byline,
                    album = albumLine,
                )
            }

            // Metadata row. Order: year · format · sample-rate · bit-depth.
            // Year used to be suffixed to the album line ("Rubber Factory
            // (2004)"); moving it down here keeps the album title clean and
            // groups all numeric/technical facts together. Only non-null items
            // appear, joined by bullets. Omitted entirely when no fields
            // resolve. Track Nº used to live here too but moved into the
            // progress bar's center label so this row is pure recording-tech
            // facts (year/format/rate/depth) and Track Nº sits with the
            // playhead — same category as the podcasts app's "Ends: …".
            val trackNoLabel = tvTrack.trackNumber?.takeIf { it > 0 }?.let { "Track Nº $it" }
            val format = listOfNotNull(
                year,
                tvTrack.mimeType?.let { cleanMimeFormat(it) },
                tvTrack.sampleFrequency?.let { "${it / 1000.0} kHz" },
                tvTrack.bitsPerSample?.let { "$it bit" },
            ).joinToString("  ·  ")
            if (format.isNotBlank()) {
                Spacer(Modifier.height(20.dp))
                Text(
                    withInterDigits(format),
                    style = MaterialTheme.typography.labelMedium,
                    color = OnQuiet,
                    maxLines = 1,
                )
            }

            Spacer(Modifier.height(36.dp))

            ProgressBar(
                seekable = seekableNow,
                position = tvState.position,
                duration = tvState.duration,
                onSeek = { target -> viewModel.seekTo(target) },
                centerLabel = trackNoLabel,
            )

            // ── Renderer output indicator ─────────────────────────
            // Shows which device is playing. Hidden when local (default).
            // Maintainer, 2026-10-29: name the ATTACHED remote when there is one;
            // otherwise the saved choice, labelled as not found below. Still
            // shown whenever a renderer is saved, so the picker stays reachable.
            val savedUdn = activeUdn
            val attachedUdn = remoteUdn
            val shownUdn = attachedUdn ?: savedUdn
            val activeRendererName = if (shownUdn != null) {
                renderers.firstOrNull { it.udn == shownUdn }?.friendlyName
                    ?: shownUdn.removePrefix("uuid:").take(8)
            } else null
            if (activeRendererName != null) {
                Spacer(Modifier.height(8.dp))
                Box(
                    Modifier
                        .fillMaxWidth()
                        .clickable { showRendererPicker = true },
                    contentAlignment = Alignment.Center,
                ) {
                    Text(
                        when {
                            rawCastObserving && castDeviceName != null -> "Casting from $castDeviceName"
                            attachedUdn != null -> "Playing on $activeRendererName ▾"
                            // Maintainer, 2026-10-29: saved but not attached —
                            // audio is on this device; don't claim otherwise.
                            else -> "$activeRendererName not found — playing here ▾"
                        },
                        style = MaterialTheme.typography.labelMedium,
                        color = AccentAmber,
                    )
                }
            }

            Spacer(Modifier.height(33.dp))

            // TRANSPORT ROW — five equal weighted columns:
            //   [Shuffle/Repeat stacked] | Previous | Play/Pause | Next | [Cast/SleepTimer stacked]
            // Each slot is weight(1f) so widths are equal; the internal Box in each slot
            // centers the control. That guarantees Play/Pause is visually dead-center no
            // matter what the other buttons' intrinsic sizes are.
            Row(
                Modifier.fillMaxWidth(),
                verticalAlignment = Alignment.CenterVertically,
            ) {
                // Slot 1: stacked secondary controls
                Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
                    Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
                        // Maintainer, 2026-10-29: tvState (observed when following
                        // a Cast group), not local `state`, as the phone layouts
                        // already do via effState — icons showed the idle local
                        // flags while presses acted on the group.
                        SmallTransportButton(
                            icon = if (tvState.shuffleEnabled) Icons.Filled.ShuffleOn else Icons.Filled.Shuffle,
                            description = if (tvState.shuffleEnabled) "Shuffle on" else "Shuffle off",
                            tintAmber = tvState.shuffleEnabled,
                            onClick = viewModel::toggleShuffle,
                        )
                        val (repeatIcon, repeatDesc, repeatActive) = when (tvState.repeatMode) {
                            RepeatMode.OFF -> Triple(Icons.Filled.Repeat, "Repeat off", false)
                            RepeatMode.ALL -> Triple(Icons.Filled.RepeatOn, "Repeat all", true)
                            RepeatMode.ONE -> Triple(Icons.Filled.RepeatOneOn, "Repeat one", true)
                        }
                        SmallTransportButton(
                            icon = repeatIcon,
                            description = repeatDesc,
                            tintAmber = repeatActive,
                            onClick = viewModel::cycleRepeat,
                        )
                    }
                }
                // Slot 2: previous
                Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
                    TransportButton(Icons.Filled.SkipPrevious, "Previous") {
                        if (rawCastObserving) castScope.launch { viewModel.rawCast.previous() }
                        else viewModel.previous()
                    }
                }
                // Slot 3: play/pause — dead center of the row
                Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
                    PrimaryTransport(
                        icon = if (tvState.isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
                        contentDescription = if (tvState.isPlaying) "Pause (long-press to stop)" else "Play (long-press to stop)",
                        onClick = {
                            if (rawCastObserving) castScope.launch {
                                if (tvState.isPlaying) viewModel.rawCast.pause() else viewModel.rawCast.play()
                            } else viewModel.togglePlay()
                        },
                        onLongClick = {
                            if (rawCastObserving) castScope.launch { viewModel.rawCast.stop() }
                            else viewModel.stop()
                        },
                        focusRequester = playFocus,
                    )
                }
                // Slot 4: next
                Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
                    TransportButton(Icons.Filled.SkipNext, "Next") {
                        if (rawCastObserving) castScope.launch { viewModel.rawCast.next() }
                        else viewModel.next()
                    }
                }
                // Slot 5: stacked Cast + Sleep Timer — symmetric with Slot 1's
                // stacked Shuffle/Repeat. Cast is the TOP of the stack so that
                // dpad-right traversal from Play (via Next, slot 4) lands on
                // Cast first — Cast is by far the more-used of the two and
                // earns the easier-to-reach position. Sleep Timer below it is
                // still one dpad-down away. dpad-up from Cast goes to the
                // ProgressBar; dpad-down from Sleep Timer goes to the more-
                // actions row below. CastReceiving (blue) tint when observing
                // — a different cast session is playing elsewhere and we're
                // following along — vs amber when we (would be) sending.
                // Distinguishes the two states without needing a different
                // icon (the CCA chevron still reads as "Cast" in either
                // direction).
                Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
                    Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
                        SmallTransportButton(
                            icon = if (rawCastObserving) Icons.Filled.CastConnected else Icons.Filled.Cast,
                            description = when {
                                rawCastObserving && castDeviceName != null -> "Receiving from $castDeviceName"
                                rawCastObserving -> "Receiving cast"
                                else -> "Cast to device"
                            },
                            tintAmber = false,
                            tintOverride = if (rawCastObserving) CastReceiving else null,
                            onClick = { castPickerOpen = true },
                        )
                        val sleepActive by viewModel.sleepTimerMinutesLeft.collectAsState()
                        // Corner slot: maintainer-selectable (computed in the
                        // dispatcher; false when no URL is configured).
                        if (cornerDomEtha) {
                            SmallTransportButton(
                                icon = Icons.Filled.Tune,
                                description = "Dom-Etha panel",
                                tintAmber = domEthaPanelOpen,
                                onClick = { domEthaPanelOpen = true },
                                focusRequester = sleepTimerFocus,
                                nextDown = queueFocus,
                            )
                        } else {
                            SmallTransportButton(
                                icon = Icons.Filled.AvTimer,
                                description = if (sleepActive != null) "Sleep timer: ${sleepActive}m" else "Sleep timer",
                                tintAmber = sleepActive != null,
                                onClick = { sleepTimerPickerOpen = true },
                                focusRequester = sleepTimerFocus,
                                nextDown = queueFocus,
                            )
                        }
                    }
                }
            }

            Spacer(Modifier.height(16.dp))

            // MORE-ACTIONS ROW — five equal weighted columns, same pattern as the
            // transport row above. Each slot Box-centers its control so icons are
            // visually balanced regardless of glyph intrinsic width. Conditional
            // buttons (artist/genre) only render when data is available; absent
            // slots make the remaining ones wider equally.
            Row(
                Modifier.fillMaxWidth(),
                verticalAlignment = Alignment.CenterVertically,
            ) {
                Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
                    TransportButton(Icons.Filled.Search, "Search library", onClick = onOpenSearch)
                }
                Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
                    // Lyrics — custom VectorDrawable "singer with mic, arm raised".
                    // Works with Compose tint because it's a VD (not a raster PNG).
                    TransportButton(
                        painter = painterResource(R.drawable.ic_lyrics_singer),
                        description = "Show lyrics",
                        onClick = onOpenLyrics,
                    )
                }
                Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
                    TransportButton(
                        icon = Icons.Filled.QueueMusic,
                        description = "Open queue",
                        focusRequester = queueFocus,
                        nextUp = playFocus,
                        onClick = onOpenQueue,
                    )
                }
                Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
                    if (artistForSearch != null) {
                        TransportButton(
                            painter = painterResource(R.drawable.ic_headphones_vinyl),
                            description = "More by $artistForSearch",
                            focusRequester = moreByArtistFocus,
                        ) {
                            // Always show a picker so the user can choose between
                            // artist catalog browsing and "more from this album".
                            artistPickerOpen = true
                        }
                    }
                }
                Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
                    when (genres.size) {
                        0 -> Unit
                        1 -> TransportButton(Icons.Filled.LocalOffer, "More in ${genres[0]}") {
                            onMoreByGenre(genres[0])
                        }
                        else -> TransportButton(Icons.Filled.LocalOffer, "Browse genres") {
                            genrePickerOpen = true
                        }
                    }
                }
            }
        }

        if (genrePickerOpen) {
            ActionSheet(
                title = "Browse by genre",
                subtitle = "This track is tagged with multiple genres.",
                actions = genres.map { g ->
                    SheetAction("▸", g) { onMoreByGenre(g) }
                },
                onDismiss = {
                    genrePickerOpen = false
                    // Restore focus to play button — genre button may not exist
                    // on next recomposition if track changes
                    sheetScope.launch {
                        delay(50)
                        runCatching { playFocus.requestFocus() }
                    }
                },
            )
        }

    
        RawCastPickerSheet(
            rawCast = viewModel.rawCast,
            visible = castPickerOpen,
            onDismiss = {
                castPickerOpen = false
                // Restore focus to play button — same pattern as the genre picker
                // above. Cast button may not exist on next recomposition if the
                // track changes during the picker's lifetime, so play/pause is
                // the reliable anchor.
                sheetScope.launch {
                    delay(50)
                    runCatching { playFocus.requestFocus() }
                }
            },
        )



        if (artistPickerOpen) {
            ActionSheet(
                title = "More from…",
                actions = buildList {
                    // Artist catalog browsing — one entry when track artist ==
                    // album artist, two when they differ (compilations/VA).
                    val primaryArtist = trackArtist ?: albumArtist
                    if (primaryArtist != null) {
                        add(SheetAction("♫", "Albums by $primaryArtist") {
                            onMoreByArtist(primaryArtist)
                        })
                    }
                    if (hasMultipleArtists && albumArtist != null) {
                        add(SheetAction("♫", "Albums by $albumArtist") {
                            onMoreByArtist(albumArtist)
                        })
                    }
                    // "More tracks from this album" — always present when album
                    // title is available. Useful for compilations, soundtracks,
                    // and any "what else is on this disc" intent. On VA
                    // compilations (track.artist differs from track.albumArtist),
                    // drop the artist disambiguator — UPnP can't filter by
                    // albumArtist reliably, so adding upnp:artist = "<this track's
                    // artist>" would exclude every other artist on the comp.
                    // For single-artist albums, keep the disambiguator to
                    // differentiate same-titled albums by different artists.
                    tvTrack.album?.takeIf { it.isNotBlank() }?.let { albumTitle ->
                        add(SheetAction("▤", "More tracks from this album") {
                            val disambiguator = if (hasMultipleArtists) "" else (tvTrack.artist ?: "")
                            onMoreFromThisAlbum("$albumTitle|||$disambiguator")
                        })
                    }
                    add(SheetAction("ⓘ", "Show id3 tags") {
                        onShowMetadata(tvTrack)
                    })
                },
                onDismiss = {
                    artistPickerOpen = false
                    sheetScope.launch {
                        delay(50)
                        runCatching { moreByArtistFocus.requestFocus() }
                    }
                },
            )
        }

        // Sleep timer picker
        if (sleepTimerPickerOpen) {
            val sleepMinutes by viewModel.sleepTimerMinutesLeft.collectAsState()
            ActionSheet(
                title = if (sleepMinutes != null) "Sleep timer (${sleepMinutes}m left)" else "Sleep timer",
                subtitle = "Playback will stop after the selected time.",
                actions = buildList {
                    if (sleepMinutes != null) {
                        add(SheetAction("✕", "Cancel timer") { viewModel.cancelSleepTimer() })
                    }
                    add(SheetAction("⏱", "15 minutes") { viewModel.startSleepTimer(15) })
                    add(SheetAction("⏱", "30 minutes") { viewModel.startSleepTimer(30) })
                    add(SheetAction("⏱", "45 minutes") { viewModel.startSleepTimer(45) })
                    add(SheetAction("⏱", "60 minutes") { viewModel.startSleepTimer(60) })
                    add(SheetAction("⏱", "90 minutes") { viewModel.startSleepTimer(90) })
                    add(SheetAction("⏱", "120 minutes") { viewModel.startSleepTimer(120) })
                },
                onDismiss = {
                    sleepTimerPickerOpen = false
                    sheetScope.launch {
                        delay(50)
                        runCatching { sleepTimerFocus.requestFocus() }
                    }
                },
            )
        }
    }

    // ── Renderer picker dialog ──────────────────────────────────────
    if (showRendererPicker) {
        androidx.compose.ui.window.Dialog(
            onDismissRequest = { showRendererPicker = false },
        ) {
            Column(
                Modifier
                    .fillMaxWidth()
                    .background(
                        dev.stave.ui.theme.SurfaceRaised,
                        androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
                    )
                    .padding(24.dp),
                verticalArrangement = Arrangement.spacedBy(8.dp),
            ) {
                Text(
                    "Play through",
                    style = MaterialTheme.typography.titleMedium,
                    color = OnPrimary,
                )
                Spacer(Modifier.height(8.dp))
                // "This device" option
                Box(
                    Modifier
                        .fillMaxWidth()
                        .clickable {
                            viewModel.setActiveRenderer(null)
                            showRendererPicker = false
                        }
                        .padding(vertical = 10.dp),
                ) {
                    Row(verticalAlignment = Alignment.CenterVertically) {
                        Text(
                            if (activeUdn == null) "◉" else "○",
                            color = AccentAmber,
                        )
                        Spacer(Modifier.width(12.dp))
                        Text("This device", color = OnPrimary)
                    }
                }
                // Discovered renderers
                renderers.forEach { r ->
                    Box(
                        Modifier
                            .fillMaxWidth()
                            .clickable {
                                viewModel.setActiveRenderer(r.udn)
                                showRendererPicker = false
                            }
                            .padding(vertical = 10.dp),
                    ) {
                        Row(verticalAlignment = Alignment.CenterVertically) {
                            Text(
                                if (activeUdn == r.udn) "◉" else "○",
                                color = AccentAmber,
                            )
                            Spacer(Modifier.width(12.dp))
                            Column {
                                Text(r.friendlyName, color = OnPrimary)
                                Text(
                                    r.udn.removePrefix("uuid:").take(8) + "…",
                                    style = MaterialTheme.typography.bodySmall,
                                    color = OnMuted,
                                )
                            }
                        }
                    }
                }
                if (renderers.isEmpty()) {
                    Text(
                        "No other Earhorn devices found. Enable mesh in Settings.",
                        style = MaterialTheme.typography.bodySmall,
                        color = OnMuted,
                    )
                }
            }
        }
    }
}

// ── Phone adaptive metadata block ────────────────────────────────────
// Direct adaptation of the TV's field-proven AutoFitTitleBlock above:
// SubcomposeLayout measures the whole title/artist/album group at scale
// 1.0 with UNLIMITED line wrap; if the measured height exceeds the slot
// (the weight(1f) leftover on phone portrait/landscape, or unbounded on
// large portrait where the art's weighted box absorbs growth), the
// entire block rescales proportionally and re-measures. Measurement-
// before-placement cannot miss — unlike the previous AutoSizeText-
// inside-fractional-cap approach, whose per-text shrink loops failed to
// engage under height-capped multi-line constraints (field-reported:
// full-size single-line ellipsis eating both a long title and the
// album's year on "Events Leading Up To The Collapse Of Detective
// Dulllight").
// Guarantees:
//  - ALL information always rendered — unlimited wrap means even 4+
//    line monsters scale down rather than ellipsize;
//  - short metadata renders at full size, typographically identical to
//    the original single-line look;
//  - in bounded slots the block occupies EXACTLY the leftover, so the
//    control stack below is immovable by construction.
@Composable
private fun PhoneAutoFitMetadataBlock(
    title: String,
    artist: String?,
    album: String?,
    modifier: Modifier = Modifier,
    titleBaseSp: Float = 28f,
    artistBaseSp: Float = 22f,
    albumBaseSp: Float = 18f,
) {
    val minScale = 0.40f
    // Upper bound for GROWING into surplus space. 3x base puts a short
    // title at up to 84sp on a tablet — commanding without becoming a
    // billboard, and the visual hierarchy (title > artist > album)
    // scales uniformly so it always reads correctly.
    val maxScale = 3f

    SubcomposeLayout(modifier.fillMaxWidth()) { constraints ->
        val widthCx = constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity)

        fun composeBlock(scale: Float) = subcompose(scale) {
            Column(Modifier.fillMaxWidth()) {
                Text(
                    withInterDigits(title),
                    style = MaterialTheme.typography.headlineMedium.copy(
                        fontSize = (titleBaseSp * scale).sp,
                        // lineHeight MUST scale with fontSize: the theme
                        // pins a fixed lineHeight, which at non-native
                        // sizes produced huge inter-line gaps AND broke
                        // the proportional-rescale height math (content
                        // overdrew into the transport row; field-
                        // reported). 1.2x is a tight, consistent leading
                        // for the Fraunces display faces.
                        lineHeight = (titleBaseSp * scale * 1.2f).sp,
                    ),
                    color = OnPrimary,
                    maxLines = Int.MAX_VALUE,
                    softWrap = true,
                )
                if (!artist.isNullOrBlank()) {
                    Spacer(Modifier.height((2 * scale).dp))
                    Text(
                        withInterDigits(artist),
                        style = MaterialTheme.typography.titleMedium.copy(
                            fontSize = (artistBaseSp * scale).sp,
                            lineHeight = (artistBaseSp * scale * 1.2f).sp,
                        ),
                        color = OnPrimary,
                        maxLines = Int.MAX_VALUE,
                        softWrap = true,
                    )
                }
                if (!album.isNullOrBlank()) {
                    Spacer(Modifier.height((2 * scale).dp))
                    Text(
                        withInterDigits(album),
                        style = MaterialTheme.typography.titleSmall.copy(
                            fontSize = (albumBaseSp * scale).sp,
                            lineHeight = (albumBaseSp * scale * 1.2f).sp,
                        ),
                        color = OnMuted,
                        maxLines = Int.MAX_VALUE,
                        softWrap = true,
                    )
                }
            }
        }

        val firstPlaceable = composeBlock(1f).first().measure(widthCx)
        val availableH = constraints.maxHeight

        // Bidirectional adaptive fit. The contract runs BOTH directions:
        // oversized metadata shrinks (with a corrective pass), and short
        // metadata GROWS to occupy the surplus — a bounded slot with
        // "INXS / The One Thing / Live Baby Live" at base size was
        // leaving ~25% of a tablet-landscape screen empty (field-
        // reported). Growth uses the proportional ceiling first; if
        // upscaling causes rewrapping (height jumps non-proportionally),
        // a short binary search finds the largest fitting scale. Every
        // candidate is MEASURED before placement, so overdraw remains
        // impossible in both directions. Each subcomposition slot id is
        // a distinct scale value (1f, the ceiling, strictly-narrowing
        // midpoints), so no slot is ever composed twice in one pass.
        var finalPlaceable = firstPlaceable
        if (availableH != Constraints.Infinity) {
            if (firstPlaceable.height > availableH) {
                // SHRINK — proportional estimate + one corrective pass
                // (wrap-quantization can leave the re-measure a line
                // over; the correction guarantees fit, bounded by
                // minScale).
                var sc = (availableH.toFloat() / firstPlaceable.height.toFloat())
                    .coerceAtLeast(minScale)
                finalPlaceable = composeBlock(sc).first().measure(widthCx)
                if (finalPlaceable.height > availableH && sc > minScale) {
                    sc = (sc * availableH.toFloat() / finalPlaceable.height.toFloat())
                        .coerceAtLeast(minScale)
                    finalPlaceable = composeBlock(sc).first().measure(widthCx)
                }
            } else {
                // GROW — fill the slot with as much text as fits.
                val ceiling = (availableH.toFloat() / firstPlaceable.height.toFloat())
                    .coerceAtMost(maxScale)
                if (ceiling > 1.05f) {
                    val probe = composeBlock(ceiling).first().measure(widthCx)
                    if (probe.height <= availableH) {
                        finalPlaceable = probe
                    } else {
                        var lo = 1f
                        var hi = ceiling
                        repeat(3) {
                            val mid = (lo + hi) / 2f
                            val pl = composeBlock(mid).first().measure(widthCx)
                            if (pl.height <= availableH) {
                                finalPlaceable = pl
                                lo = mid
                            } else {
                                hi = mid
                            }
                        }
                    }
                }
            }
        }

        val laidOutHeight = if (availableH == Constraints.Infinity) {
            finalPlaceable.height
        } else {
            finalPlaceable.height.coerceAtMost(availableH)
        }
        layout(finalPlaceable.width, laidOutHeight) {
            finalPlaceable.place(0, 0)
        }
    }
}

@Composable
private fun ProgressBar(
    position: Duration,
    duration: Duration,
    onSeek: (Duration) -> Unit,
    centerLabel: String? = null,
    /** False on LIVE/observed-unseekable sessions: gestures and d-pad are
     *  ignored, the knob is hidden, and the right label shows LIVE. The
     *  receiver may CLAIM seek on LIVE streams (The Bridge does); the
     *  caller decides, per the caps spec (2026-08-29: dim, don't hide;
     *  elapsed + LIVE badge; pause stays). */
    seekable: Boolean = true,
) {
    val frac = if (duration.inWholeMilliseconds > 0)
        (position.inWholeMilliseconds.toFloat() / duration.inWholeMilliseconds.toFloat()).coerceIn(0f, 1f)
    else 0f

    val interaction = remember { MutableInteractionSource() }
    val isFocused by interaction.collectIsFocusedAsState()
    val step = 10.seconds
    val barHeight = if (isFocused) 6.dp else 3.dp
    val knobSize = 16.dp

    Column(
        Modifier
            .fillMaxWidth()
            .padding(vertical = 4.dp)
    ) {
        // Focusable region is JUST the bar, not the whole Column. The time labels
        // underneath live outside this box so the amber focus outline never overlaps
        // numeric text. Internal padding gives the knob some breathing room from the
        // outline edges rather than having it hug right up against them.
        //
        // Scrub behaviour works here because NowPlayingScreen hides the NowPlayingBar
        // (barIsVisible == false when currentRoute == NOW_PLAYING), so EarhornNavHost's
        // global Left-intercept is disarmed and Left/Right reach this key handler.
        //
        // Touch scrub: tap-to-seek + horizontal drag. Both translate x position
        // within the gesture-area's pixel bounds to a fraction of duration.
        // Layered on top of the existing D-pad Left/Right handler — TV behaviour
        // is unchanged because TV has no touch surface and the touch gesture
        // detectors never fire there.
        BoxWithConstraints(
            Modifier
                .fillMaxWidth()
                .staveFocusable(interaction, cornerRadiusDp = 6)
                .focusable(interactionSource = interaction)
                .onKeyEvent { event ->
                    if (!seekable) return@onKeyEvent false
                    if (event.type != KeyEventType.KeyDown) return@onKeyEvent false
                    when (event.key) {
                        Key.DirectionLeft -> {
                            val target = (position - step).coerceAtLeast(Duration.ZERO)
                            if (seekable) onSeek(target)
                            true
                        }
                        Key.DirectionRight -> {
                            // Clamp to duration-1s so "scrub to near the end" doesn't
                            // accidentally trip the track-ended logic.
                            val cap = (duration - 1.seconds).coerceAtLeast(Duration.ZERO)
                            val target = (position + step).coerceAtMost(cap)
                            if (seekable) onSeek(target)
                            true
                        }
                        else -> false
                    }
                }
                .pointerInput(seekable, duration) {
                    detectTapGestures { offset ->
                        val w = size.width.toFloat()
                        val totalMs = duration.inWholeMilliseconds
                        if (w > 0f && totalMs > 0L) {
                            val ratio = (offset.x / w).coerceIn(0f, 1f)
                            if (seekable) onSeek((totalMs * ratio).toLong().milliseconds)
                        }
                    }
                }
                .pointerInput(seekable, duration) {
                    detectHorizontalDragGestures { change, _ ->
                        val w = size.width.toFloat()
                        val totalMs = duration.inWholeMilliseconds
                        if (w > 0f && totalMs > 0L) {
                            val ratio = (change.position.x / w).coerceIn(0f, 1f)
                            if (seekable) onSeek((totalMs * ratio).toLong().milliseconds)
                        }
                    }
                }
                .padding(horizontal = 8.dp, vertical = 6.dp)
                .height(knobSize)
        ) {
            val totalWidth = maxWidth - 16.dp  // subtract horizontal padding we just added
            // Knob position math, hoisted out of the focused branch so the fill
            // width can align with the knob center even when the clamp is active.
            // `centered` is the knob's intended left edge if it were placed so the
            // knob center sits at totalWidth*frac; coerceIn clamps to keep the knob
            // fully on-canvas at the extremes (otherwise the dot would only show
            // its right half at frac=0 and its left half at frac=1).
            val centered = totalWidth * frac - knobSize / 2
            val knobX = centered.coerceIn(0.dp, (totalWidth - knobSize).coerceAtLeast(0.dp))
            val knobCenterX = knobX + knobSize / 2
            // Fill end: when focused, match the knob CENTER so the knob's amber
            // circle covers the radius-wide strip between fill end and canvas
            // edge. Without this, the fill formula (totalWidth*frac) and the
            // clamped knob-center diverge by up to knobSize/2 inside the clamp
            // zones (frac < ~0.02 or frac > ~0.98), producing a visible
            // misalignment between brass fill and knob — same bug family as the
            // Earhorn Podcasts TV app fix. When unfocused there's no knob to
            // align with, so the fill uses the natural totalWidth*frac.
            val fillEnd = if (isFocused) knobCenterX else totalWidth * frac
            // Background track + amber fill, centered vertically in the knob-height container.
            Box(
                Modifier
                    .align(Alignment.CenterStart)
                    .fillMaxWidth()
                    .height(barHeight)
                    .background(SurfaceDivider)
            ) {
                Box(
                    Modifier
                        .width(fillEnd)
                        .height(barHeight)
                        .background(AccentAmber)
                )
            }
            // Playhead knob — only rendered when the bar has focus. X position
            // computed above so the fill can align with it.
            if (isFocused && seekable) {
                Box(
                    Modifier
                        .align(Alignment.CenterStart)
                        .offset(x = knobX)
                        .size(knobSize)
                        .clip(CircleShape)
                        .background(AccentAmber)
                )
            }
        }
        Spacer(Modifier.height(4.dp))
        // Minimum widths for the two time labels so they don't jostle as the
        // ticking digits change width. widthIn (not width) — at unusual system
        // font scales the natural width can exceed the min; we want the label
        // to grow rather than wrap. maxLines=1 + softWrap=false guarantee a
        // single line at any scale. Values are generous worst-case sizes for
        // the category, including a glyph's worth of headroom for the leading
        // "-" on the remaining label.
        val (posMinWidth, remMinWidth) = when {
            duration.inWholeHours >= 1 -> 64.dp to 72.dp   // "59:59:59" / "-59:59:59"
            duration.inWholeMinutes >= 10 -> 44.dp to 52.dp // "59:59"    / "-59:59"
            else -> 36.dp to 44.dp                          // "9:59"     / "-9:59"
        }
        // Box layout (not Row + SpaceBetween) so the center label is at the true
        // geometric midpoint independent of how wide the side labels render. The
        // sides anchor to start/end; the optional center label (e.g. "Track Nº 3")
        // sits in between. Matches the pattern from the Earhorn Podcasts app
        // ("Ends: 7:08 AM" centered between elapsed and remaining).
        Box(Modifier.fillMaxWidth()) {
            Text(
                position.formatTime(),
                style = MaterialTheme.typography.labelMedium.copy(fontFamily = staveInterFamily()),
                color = OnMuted,
                maxLines = 1,
                softWrap = false,
                modifier = Modifier
                    .align(Alignment.CenterStart)
                    .widthIn(min = posMinWidth),
            )
            if (centerLabel != null) {
                Text(
                    withInterDigits(centerLabel),
                    style = MaterialTheme.typography.labelMedium.copy(
                        fontWeight = FontWeight.SemiBold,
                    ),
                    color = OnMuted,
                    maxLines = 1,
                    softWrap = false,
                    modifier = Modifier.align(Alignment.Center),
                )
            }
            val remaining = (duration - position).coerceAtLeast(kotlin.time.Duration.ZERO)
            // LIVE: there is no remaining time on an endless stream; the
            // right slot carries the badge instead. Elapsed (left) keeps
            // ticking — the monotonic anchor makes it accurate.
            Text(
                if (!seekable && duration <= kotlin.time.Duration.ZERO) "LIVE"
                else "-${remaining.formatTime()}",
                style = MaterialTheme.typography.labelMedium.copy(fontFamily = staveInterFamily()),
                color = OnMuted,
                maxLines = 1,
                softWrap = false,
                modifier = Modifier
                    .align(Alignment.CenterEnd)
                    .widthIn(min = remMinWidth),
                textAlign = androidx.compose.ui.text.style.TextAlign.End,
            )
        }
    }
}

@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun SmallTransportButton(
    icon: ImageVector,
    description: String,
    tintAmber: Boolean,
    onClick: () -> Unit,
    focusRequester: FocusRequester? = null,
    tintOverride: Color? = null,
    nextDown: FocusRequester? = null,
) {
    val interaction = remember { MutableInteractionSource() }
    // 28dp tall × 56dp wide fits two stacked in the same 56dp bounding box a full-size
    // TransportButton occupies, with a 2dp gap between them. The 2dp amber focus border
    // needs at least 3dp clear of the icon edge to read cleanly, so icon is 20dp inside
    // the 28dp box. Amber tint when the state is active so users can see at a glance
    // whether repeat/shuffle/sleep-timer are on. tintOverride wins when non-null —
    // currently used by Cast in observe-mode (CastReceiving blue). nextDown overrides
    // the default direction-aware focus search for D-pad Down — used by Sleep Timer
    // to land deterministically on Queue regardless of which conditional buttons
    // (artist/genre) render in the more-actions row below.
    Box(
        Modifier
            .size(width = 56.dp, height = 28.dp)
            .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
            .then(if (nextDown != null) Modifier.focusProperties { down = nextDown } else Modifier)
            .staveFocusable(interaction)
            .clickable(interaction, indication = null) { onClick() },
        contentAlignment = Alignment.Center,
    ) {
        Icon(
            icon,
            contentDescription = description,
            tint = tintOverride ?: if (tintAmber) AccentAmber else OnPrimary,
            modifier = Modifier.size(20.dp),
        )
    }
}

@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun TransportButton(
    icon: ImageVector,
    description: String,
    focusRequester: FocusRequester? = null,
    nextUp: FocusRequester? = null,
    onClick: () -> Unit,
) {
    val interaction = remember { MutableInteractionSource() }
    Box(
        Modifier
            .size(56.dp)
            .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
            .then(if (nextUp != null) Modifier.focusProperties { up = nextUp } else Modifier)
            .staveFocusable(interaction)
            .clickable(interaction, indication = null) { onClick() },
        contentAlignment = Alignment.Center
    ) {
        // Explicit 28dp icon size. Default Icon sizing is 24dp and some vectors
        // (LocalOffer, Label, Mic) don't fill their glyph box as densely as others
        // (QueueMusic, LibraryMusic, Search) — at 24dp they look undersized next to
        // their neighbours. 28dp compensates.
        Icon(
            icon,
            contentDescription = description,
            tint = OnPrimary,
            modifier = Modifier.size(28.dp),
        )
    }
}

/** Overload that takes a Painter — for custom VectorDrawables loaded from res/drawable.
 *  Matches size and tint of the ImageVector variant so all buttons read uniformly.
 *  Set [tinted]=false for raster art (e.g. the 8-disc stack) that must preserve
 *  its own colors, outlines, and alpha — tinting flattens it to a silhouette. */
@Composable
private fun TransportButton(
    painter: Painter,
    description: String,
    tinted: Boolean = true,
    focusRequester: FocusRequester? = null,
    onClick: () -> Unit,
) {
    val interaction = remember { MutableInteractionSource() }
    Box(
        Modifier
            .size(56.dp)
            .then(
                if (focusRequester != null) Modifier.focusRequester(focusRequester)
                else Modifier
            )
            .staveFocusable(interaction)
            .clickable(interaction, indication = null) { onClick() },
        contentAlignment = Alignment.Center
    ) {
        if (tinted) {
            Icon(
                painter = painter,
                contentDescription = description,
                tint = OnPrimary,
                modifier = Modifier.size(28.dp),
            )
        } else {
            Image(
                painter = painter,
                contentDescription = description,
                modifier = Modifier.size(28.dp),
            )
        }
    }
}

@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun PrimaryTransport(
    icon: ImageVector,
    contentDescription: String,
    onClick: () -> Unit,
    focusRequester: FocusRequester? = null,
    onLongClick: (() -> Unit)? = null,
) {
    val interaction = remember { MutableInteractionSource() }
    val focused by interaction.collectIsFocusedAsState()
    val shape = RoundedCornerShape(36.dp)
    // Inline the focus visual rather than using staveFocusable: its default 2dp AccentAmber
    // border is invisible against an AccentAmber fill. Focused state inverts the palette so
    // it reads clearly at TV distance — dark field, amber icon, amber ring.
    Box(
        Modifier
            .size(72.dp)
            .clip(shape)
            .background(if (focused) SurfaceBase else AccentAmber, shape)
            .border(
                width = if (focused) 2.dp else 0.dp,
                color = if (focused) AccentAmber else Color.Transparent,
                shape = shape,
            )
            .let { if (focusRequester != null) it.focusRequester(focusRequester) else it }
            .then(
                if (onLongClick != null) {
                    Modifier.combinedClickable(
                        interactionSource = interaction,
                        indication = null,
                        onClick = onClick,
                        onLongClick = onLongClick,
                    )
                } else {
                    Modifier.clickable(interaction, indication = null) { onClick() }
                }
            ),
        contentAlignment = Alignment.Center,
    ) {
        Icon(
            imageVector = icon,
            contentDescription = contentDescription,
            tint = if (focused) AccentAmber else OnAmber,
        )
    }
}

private fun Duration.formatTime(): String {
    val total = inWholeSeconds
    val h = total / 3600
    val m = (total % 3600) / 60
    val s = total % 60
    return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%d:%02d".format(m, s)
}

/**
 * Normalize a UPnP-reported MIME string to a short display format name.
 *
 * UPnP servers report wildly varying MIME types for the same format. MinimServer says
 * "audio/flac", BubbleUPnP says "audio/x-flac", Windows Media says "audio/vnd.wave". We
 * strip wrapper prefixes (x-, vnd.) and map a few known weird ones to their canonical
 * uppercase name so the Now Playing subtitle stays consistent.
 *
 * Returns null for blank/garbage input so the caller can omit the field entirely.
 */
private fun cleanMimeFormat(mime: String): String? {
    val subtype = mime.substringAfter('/', missingDelimiterValue = mime)
        .substringAfter("x-")
        .substringAfter("vnd.")
        .lowercase()
        .ifBlank { return null }
    return when (subtype) {
        "mpeg", "mpeg3", "mp3" -> "MP3"
        "mp4", "m4a", "aac" -> "AAC"
        "flac", "flac+ogg" -> "FLAC"
        "ogg", "vorbis", "ogg-vorbis" -> "OGG"
        "opus" -> "Opus"
        "wav", "wave" -> "WAV"
        "aiff", "aif" -> "AIFF"
        "alac" -> "ALAC"
        "dsd", "dsf" -> "DSD"
        else -> subtype.uppercase()
    }
}

/** Title / artist / album block that always shows complete text.
 *
 *  Uses SubcomposeLayout in two passes: measure at default sizes, and if the
 *  block would overflow the available vertical space, recompose all three
 *  fields at a uniformly scaled-down font size. The ratio between title and
 *  artist/album is fixed (title = 1.4× base, artist = album = 1.0× base), so
 *  visual differentiation is preserved at every scale.
 *
 *  Floor clamp at 0.55× prevents scaling below readable sizes on a TV
 *  viewed from across a room; if content would still overflow at the floor,
 *  it clips rather than becoming unreadable. In practice that edge is rare
 *  (title AND artist AND album all have to be unusually long at once).
 */
@Composable
private fun AutoFitTitleBlock(
    title: String,
    artist: String?,
    album: String?,
    modifier: Modifier = Modifier,
) {
    val titleBaseSp = 28f
    val subBaseSp = 20f
    val minScale = 0.55f

    SubcomposeLayout(modifier.fillMaxWidth()) { constraints ->
        val widthCx = constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity)

        fun composeBlock(scale: Float) = subcompose(scale) {
            Column {
                Text(
                    withInterDigits(title),
                    style = MaterialTheme.typography.headlineMedium.copy(
                        fontSize = (titleBaseSp * scale).sp,
                    ),
                    color = OnPrimary,
                    maxLines = Int.MAX_VALUE,
                    softWrap = true,
                )
                if (!artist.isNullOrBlank()) {
                    Spacer(Modifier.height((12 * scale).dp))
                    Text(
                        withInterDigits(artist),
                        style = MaterialTheme.typography.titleLarge.copy(
                            fontSize = (subBaseSp * scale).sp,
                        ),
                        color = OnPrimary,
                        maxLines = Int.MAX_VALUE,
                        softWrap = true,
                    )
                }
                if (!album.isNullOrBlank()) {
                    Spacer(Modifier.height((12 * scale).dp))
                    Text(
                        withInterDigits(album),
                        style = MaterialTheme.typography.titleLarge.copy(
                            fontSize = (subBaseSp * scale).sp,
                        ),
                        color = OnPrimary,
                        maxLines = Int.MAX_VALUE,
                        softWrap = true,
                    )
                }
            }
        }

        val firstPlaceable = composeBlock(1f).first().measure(widthCx)
        val availableH = constraints.maxHeight
        val scale = if (
            availableH == Constraints.Infinity || firstPlaceable.height <= availableH
        ) {
            1f
        } else {
            (availableH.toFloat() / firstPlaceable.height.toFloat())
                .coerceAtLeast(minScale)
        }

        val finalPlaceable = if (scale == 1f) {
            firstPlaceable
        } else {
            composeBlock(scale).first().measure(widthCx)
        }

        val laidOutHeight = if (availableH == Constraints.Infinity) {
            finalPlaceable.height
        } else {
            finalPlaceable.height.coerceAtMost(availableH)
        }
        layout(finalPlaceable.width, laidOutHeight) {
            finalPlaceable.place(0, 0)
        }
    }
}

/**
 * Compact-width Now Playing layout. Stacks vertically: art on top, then
 * title/artist/album, then progress bar, then a single touch-friendly
 * transport row. Skips the secondary actions (sleep timer picker, more-by
 * artist sheets, lyrics, etc.) — those remain accessible from the main TV
 * layout. The goal here is to produce a working phone Now Playing view that
 * fits the available pixels without overflow or off-screen controls.
 *
 * Touch targets: primary play/pause is 72dp, skip prev/next are 56dp,
 * shuffle/repeat/stop are 44dp. All comfortably above Material's 48dp
 * minimum tappable for primary, and large enough on a phone screen to be
 * reliable without precise aim.
 */
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun NowPlayingCompact(
    seekable: Boolean,
    cornerDomEtha: Boolean,
    onDomEtha: () -> Unit,
    track: dev.stave.domain.model.DidlItem.Track,
    state: dev.stave.domain.renderer.PlaybackState,
    viewModel: NowPlayingViewModel,
    playFocus: FocusRequester,
    activeServerUdn: String?,
    onOpenQueue: () -> Unit,
    onOpenSearch: () -> Unit,
    onOpenLyrics: () -> Unit,
    onMoreByArtist: (artistName: String) -> Unit,
    onMoreByGenre: (genreName: String) -> Unit,
    onMoreFromThisAlbum: (albumTitle: String) -> Unit,
    onShowMetadata: (track: dev.stave.domain.model.DidlItem.Track) -> Unit,
) {
    // Picker state — sleep timer, artist disambiguation (various-artists
    // compilations), and genre picker (when the track has multiple genres).
    // ActionSheet renders the dialogs after the main scrollable content.
    var sleepTimerPickerOpen by remember { mutableStateOf(false) }
    var artistPickerOpen by remember { mutableStateOf(false) }
    var genrePickerOpen by remember { mutableStateOf(false) }
    // Cast picker visibility — toggled when the user taps the Cast
    // button in the modifier row. The button calls
    // viewModel.toggleCast() which emits to viewModel.showCastPicker;
    // we collect the emission and flip this state to show the sheet.
    var castPickerOpen by remember { mutableStateOf(false) }
    if (viewModel.castIntegration.available) {
        LaunchedEffect(viewModel) {
            viewModel.showCastPicker.collect { castPickerOpen = true }
        }
    }

    val trackArtist = track.artist
    val albumArtist = track.albumArtist
    val hasMultipleArtists = !trackArtist.isNullOrBlank() &&
        !albumArtist.isNullOrBlank() &&
        !trackArtist.equals(albumArtist, ignoreCase = true)
    val artistForSearch = albumArtist ?: trackArtist
    val genres = parseGenres(track.genre)

    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        // ── Album art with volume touch zones — square, full width ────
        // Top half = volume up, bottom half = volume down. Shows a
        // brief overlay with the current level. On phones with hardware
        // volume keys this is supplementary; on the HP Chromebase
        // (which has no useful hardware volume while using the
        // touchscreen) this is the primary volume interface.
        VolumeAwareAlbumArt(
            model = track.albumArtUri,
            volumePercent = state.volumePercent,
            onSetVolume = { viewModel.setVolumeTo(it) },
            modifier = Modifier
                .fillMaxWidth()
                .aspectRatio(1f),
            contentScale = ContentScale.Fit,
        )
        // ── Padded content below — fills remaining vertical space ───
        // weight(1f) so the inner Column claims everything left after
        // the art's 411dp. Children pack at the top via the default
        // Arrangement.Top; any leftover at the bottom (small, since
        // content is sized to the available space) sits as black below
        // the modifier row.
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .weight(1f)
                .padding(start = 16.dp, end = 16.dp, top = 0.dp, bottom = 4.dp),
            horizontalAlignment = Alignment.CenterHorizontally,
        ) {
        // ── Adaptive metadata — title / artist / album (+ year) ──────
        // See PhoneAutoFitMetadataBlock: measured fit, all information
        // always shown, block = exactly the leftover, controls below
        // immovable. Year parity: "(YYYY)" always rides the album line.
        PhoneAutoFitMetadataBlock(
            title = track.title,
            artist = track.artist ?: track.albumArtist,
            album = track.album?.takeIf { it.isNotBlank() }?.let { alb ->
                val y = track.date?.let { Regex("""\d{4}""").find(it)?.value }
                alb + (y?.let { " ($it)" } ?: "")
            },
            modifier = Modifier
                .fillMaxWidth()
                .weight(1f),
        )
        Spacer(Modifier.height(4.dp))
        // Progress bar — reuse the existing ProgressBar composable defined
        // earlier in this file. It handles seek, focus, time labels.
        ProgressBar(
            seekable = seekable,
            position = state.position,
            duration = state.duration,
            onSeek = { target -> viewModel.seekTo(target) },
            centerLabel = track.trackNumber?.takeIf { it > 0 }?.let { "Track Nº $it" },
        )
        Spacer(Modifier.height(2.dp))
        // Primary transport row — Skip Previous, Play/Pause (large), Skip Next.
        // Centered, with comfortable touch targets.
        Row(
            modifier = Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.SpaceEvenly,
            verticalAlignment = Alignment.CenterVertically,
        ) {
            CompactTransport(
                icon = Icons.Filled.SkipPrevious,
                description = "Previous",
                size = 48.dp,
                onClick = viewModel::previous,
            )
            CompactPrimaryTransport(
                icon = if (state.isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
                description = if (state.isPlaying) "Pause (long-press to stop)" else "Play (long-press to stop)",
                size = 64.dp,
                onClick = viewModel::togglePlay,
                onLongClick = viewModel::stop,
                focusRequester = playFocus,
                modifier = Modifier.offset(y = 30.dp),
            )
            CompactTransport(
                icon = Icons.Filled.SkipNext,
                description = "Next",
                size = 48.dp,
                onClick = viewModel::next,
            )
        }
        Spacer(Modifier.height(2.dp))
        // ── Two action triads ───────────────────────────────────────
        // Replaces the prior single row of 6 small (40dp) icons. Same
        // six actions, regrouped semantically and rendered larger
        // (56dp) so each one is a deliberate target.
        //
        //   LEFT triad (track-context actions):
        //                  [ Lyrics ]
        //          [ Sleep timer ] [ Search ]
        //
        //   RIGHT triad (catalog-pivot actions):
        //                  [ This album ]
        //          [ More by artist ] [ Genre ]
        //
        // Top icons sit centered above the bottom pair — the inverted
        // pyramid arrangement uses the vertical space the previous
        // single-row layout left empty, and the two triads each have
        // a clear visual identity.
        val sleepActive by viewModel.sleepTimerMinutesLeft.collectAsState()
        Row(
            modifier = Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.SpaceEvenly,
            verticalAlignment = Alignment.Top,
        ) {
            // ── LEFT TRIAD: track-context ───────────────────────────
            Column(horizontalAlignment = Alignment.CenterHorizontally) {
                // Top: lyrics
                CompactTransportPainter(
                    painter = painterResource(R.drawable.ic_lyrics_singer),
                    description = "Show lyrics",
                    size = 56.dp,
                    onClick = onOpenLyrics,
                )
                Spacer(Modifier.height(2.dp))
                Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
                    // Bottom-left: sleep timer
                    if (cornerDomEtha) CompactTransport(
                        icon = Icons.Filled.Tune,
                        description = "Dom-Etha panel",
                        size = 56.dp,
                        tintAmber = false,
                        onClick = onDomEtha,
                        ) else CompactTransport(
                        icon = Icons.Filled.AvTimer,
                        description = if (sleepActive != null) "Sleep timer: ${sleepActive}m" else "Sleep timer",
                        size = 56.dp,
                        tintAmber = sleepActive != null,
                        onClick = { sleepTimerPickerOpen = true },
                        )
                    // Bottom-right: search library
                    CompactTransport(
                        icon = Icons.Filled.Search,
                        description = "Search library",
                        size = 56.dp,
                        onClick = onOpenSearch,
                    )
                }
            }
            // ── RIGHT TRIAD: catalog-pivot ──────────────────────────
            Column(horizontalAlignment = Alignment.CenterHorizontally) {
                // Top: this album
                CompactTransport(
                    icon = Icons.Filled.Album,
                    description = "More from this album",
                    size = 56.dp,
                    onClick = {
                        val albumTitle = track.album
                        if (!albumTitle.isNullOrBlank()) {
                            val disambiguator = if (hasMultipleArtists) "" else (track.artist ?: "")
                            onMoreFromThisAlbum("$albumTitle|||$disambiguator")
                        }
                    },
                )
                Spacer(Modifier.height(2.dp))
                Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
                    // Bottom-left: more by artist
                    CompactTransportPainter(
                        painter = painterResource(R.drawable.ic_headphones_vinyl),
                        description = if (artistForSearch != null) "More by $artistForSearch" else "More by artist",
                        size = 56.dp,
                        onClick = {
                            // Always open the picker (TV parity): the sheet
                            // carries "More tracks from this album" and
                            // "Show id3 tags" alongside artist browsing, so
                            // it must be reachable for single-artist tracks
                            // too, not just VA compilations.
                            if (artistForSearch != null) artistPickerOpen = true
                        },
                    )
                    // Bottom-right: genre tag
                    CompactTransport(
                        icon = Icons.Filled.LocalOffer,
                        description = when (genres.size) {
                            0 -> "Browse genres"
                            1 -> "More in ${genres[0]}"
                            else -> "Browse genres"
                        },
                        size = 56.dp,
                        onClick = {
                                when (genres.size) {
                                    0 -> Unit
                                    1 -> onMoreByGenre(genres[0])
                                    else -> genrePickerOpen = true
                            }
                        },
                    )
                }
            }
        }
        Spacer(Modifier.height(2.dp))
        // Secondary row 2 — playback modifiers: Shuffle, Repeat, Cast, Stop, Queue.
        // Stop is essential for battery protection (full session teardown
        // releases the foreground service and wake locks) and is part of the
        // intended layout balance — long-press on play also stops, but the
        // explicit button stays.
        Row(
            modifier = Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.SpaceEvenly,
            verticalAlignment = Alignment.CenterVertically,
        ) {
            CompactTransport(
                icon = if (state.shuffleEnabled) Icons.Filled.ShuffleOn else Icons.Filled.Shuffle,
                description = if (state.shuffleEnabled) "Shuffle on" else "Shuffle off",
                size = 56.dp,
                tintAmber = state.shuffleEnabled,
                onClick = viewModel::toggleShuffle,
            )
            val (repeatIcon, repeatDesc, repeatActive) = when (state.repeatMode) {
                RepeatMode.OFF -> Triple(Icons.Filled.Repeat, "Repeat off", false)
                RepeatMode.ALL -> Triple(Icons.Filled.RepeatOn, "Repeat all", true)
                RepeatMode.ONE -> Triple(Icons.Filled.RepeatOneOn, "Repeat one", true)
            }
            CompactTransport(
                icon = repeatIcon,
                description = repeatDesc,
                size = 56.dp,
                tintAmber = repeatActive,
                onClick = viewModel::cycleRepeat,
            )
            if (viewModel.castIntegration.available) {
                val isCasting by viewModel.castIntegration.isCasting.collectAsState()
                // Observer-mode indication — same visual language as the TV:
                // CastConnected glyph + CastReceiving (cool blue) tint, so
                // "we're following, not driving" reads identically on phone.
                val observingCast by viewModel.rawCast.observing.collectAsState()
                CompactTransport(
                    icon = if (isCasting || observingCast) Icons.Filled.CastConnected else Icons.Filled.Cast,
                    description = when {
                        observingCast -> "Receiving cast — tap to switch"
                        isCasting -> "Casting — tap to switch"
                        else -> "Cast to device"
                    },
                    size = 56.dp,
                    tintAmber = isCasting,
                    tintOverride = if (observingCast) CastReceiving else null,
                    onClick = viewModel::toggleCast,
                )
            }
            CompactTransport(
                icon = Icons.Filled.Stop,
                description = "Stop",
                size = 56.dp,
                onClick = viewModel::stop,
            )
            CompactTransport(
                icon = Icons.Filled.QueueMusic,
                description = "Queue",
                size = 56.dp,
                onClick = onOpenQueue,
            )
        }
        }  // end inner padded Column
    }

    // Sleep timer picker — same options as the TV layout for consistency.
    if (sleepTimerPickerOpen) {
        val sleepMinutes by viewModel.sleepTimerMinutesLeft.collectAsState()
        ActionSheet(
            title = if (sleepMinutes != null) "Sleep timer (${sleepMinutes}m left)" else "Sleep timer",
            subtitle = "Playback will stop after the selected time.",
            actions = buildList {
                if (sleepMinutes != null) {
                    add(SheetAction("✕", "Cancel timer") { viewModel.cancelSleepTimer() })
                }
                add(SheetAction("⏱", "15 minutes") { viewModel.startSleepTimer(15) })
                add(SheetAction("⏱", "30 minutes") { viewModel.startSleepTimer(30) })
                add(SheetAction("⏱", "45 minutes") { viewModel.startSleepTimer(45) })
                add(SheetAction("⏱", "60 minutes") { viewModel.startSleepTimer(60) })
                add(SheetAction("⏱", "90 minutes") { viewModel.startSleepTimer(90) })
                add(SheetAction("⏱", "120 minutes") { viewModel.startSleepTimer(120) })
            },
            onDismiss = { sleepTimerPickerOpen = false },
        )
    }

    // Artist picker — only meaningful for various-artists compilations.
    // Identical action set to the TV layout's artist picker, including
    // "More tracks from this album" appended when album info is present.
    if (artistPickerOpen) {
        // TV-parity sheet: opens for EVERY track (not just VA compilations),
        // with artist catalog browsing, "More tracks from this album", and
        // "Show id3 tags" — previously the id3 sheet was unreachable from
        // the phone's Now Playing entirely.
        ActionSheet(
            title = "More from…",
            actions = buildList {
                val primaryArtist = trackArtist ?: albumArtist
                if (primaryArtist != null) {
                    add(SheetAction("♫", "Albums by $primaryArtist") { onMoreByArtist(primaryArtist) })
                }
                if (hasMultipleArtists && albumArtist != null) {
                    add(SheetAction("♫", "Albums by $albumArtist") { onMoreByArtist(albumArtist) })
                }
                // On VA compilations drop the artist disambiguator — UPnP
                // can't filter by albumArtist reliably, and adding this
                // track's artist would exclude every other artist on the
                // comp (same rationale as the TV branch).
                track.album?.takeIf { it.isNotBlank() }?.let { albumTitle ->
                    add(SheetAction("▤", "More tracks from this album") {
                        val disambiguator = if (hasMultipleArtists) "" else (track.artist ?: "")
                        onMoreFromThisAlbum("$albumTitle|||$disambiguator")
                    })
                }
                add(SheetAction("ⓘ", "Show id3 tags") { onShowMetadata(track) })
            },
            onDismiss = { artistPickerOpen = false },
        )
    }

    // Genre picker — when a track has multiple genres, let user pick which.
    if (genrePickerOpen) {
        ActionSheet(
            title = "Browse genres",
            subtitle = "Pick a genre tag from this track.",
            actions = genres.map { g ->
                SheetAction("▸", g) { onMoreByGenre(g) }
            },
            onDismiss = { genrePickerOpen = false },
        )
    }

    // Cast picker — Compose-native sheet showing discovered Cast
    // devices. Mounted only when the play flavor's Cast SDK is
    // present (libre flavor's CastIntegration.available is false and
    // the sheet never instantiates). The sheet handles its own
    // lifecycle for discovery start/stop while visible.
    // Issue 2: rawCast enables "Observe a device" section so a second
    // phone can see and control what another sender is playing.
    CastPickerSheet(
        cast = viewModel.castIntegration,
        visible = castPickerOpen,
        onDismiss = { castPickerOpen = false },
        rawCast = viewModel.rawCast,
    )
}

/**
 * Phone-landscape Now Playing — horizontal split, art on left, all controls
 * on right. Replaces the TV horizontal Row layout (which was being misrouted
 * to phone landscape because ~914dp counts as "expanded" by Material width
 * classes alone). Reuses [CompactTransport] / [CompactPrimaryTransport] so
 * touch targets are full thumb-sized, not the TV's 28dp `SmallTransportButton`.
 *
 * Sized for ~390dp of available height after status + nav bars on a Pixel
 * 8a turned sideways: art fills the height (so it's the limiting factor),
 * the right column gets ~525dp of width and stacks title block, format,
 * scrub bar, primary transport, and the two secondary rows.
 */
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun NowPlayingCompactLandscape(
    seekable: Boolean,
    cornerDomEtha: Boolean,
    onDomEtha: () -> Unit,
    track: dev.stave.domain.model.DidlItem.Track,
    state: dev.stave.domain.renderer.PlaybackState,
    viewModel: NowPlayingViewModel,
    playFocus: FocusRequester,
    activeServerUdn: String?,
    onOpenQueue: () -> Unit,
    onOpenSearch: () -> Unit,
    onOpenLyrics: () -> Unit,
    onMoreByArtist: (artistName: String) -> Unit,
    onMoreByGenre: (genreName: String) -> Unit,
    onMoreFromThisAlbum: (albumTitle: String) -> Unit,
    onShowMetadata: (track: dev.stave.domain.model.DidlItem.Track) -> Unit,
) {
    var sleepTimerPickerOpen by remember { mutableStateOf(false) }
    var artistPickerOpen by remember { mutableStateOf(false) }
    var genrePickerOpen by remember { mutableStateOf(false) }
    // Cast picker — same wiring as the portrait NowPlayingCompact.
    // Compiled out in libre flavor (CastIntegration.available = false).
    var castPickerOpen by remember { mutableStateOf(false) }
    if (viewModel.castIntegration.available) {
        LaunchedEffect(viewModel) {
            viewModel.showCastPicker.collect { castPickerOpen = true }
        }
    }

    val trackArtist = track.artist
    val albumArtist = track.albumArtist
    val hasMultipleArtists = !trackArtist.isNullOrBlank() &&
        !albumArtist.isNullOrBlank() &&
        !trackArtist.equals(albumArtist, ignoreCase = true)
    val artistForSearch = albumArtist ?: trackArtist
    val genres = parseGenres(track.genre)

    Row(
        modifier = Modifier
            .fillMaxSize(),
        // No padding at the Row level — the album art needs to reach the
        // top and bottom of the physical screen edge-to-edge while
        // maintaining its 1:1 aspect ratio. systemBarsPadding and the
        // horizontal/vertical padding that used to live here have moved
        // down to the right Column so the title/artist/controls still
        // respect status-bar and gesture-nav insets without constraining
        // the art's height.
        verticalAlignment = Alignment.CenterVertically,
    ) {
        // Art with volume touch zones — fills full screen height.
        VolumeAwareAlbumArt(
            model = track.albumArtUri,
            volumePercent = state.volumePercent,
            onSetVolume = { viewModel.setVolumeTo(it) },
            modifier = Modifier
                // Keep the art out from under the status bar / camera
                // cutout: edge-to-edge drew the cover's top rows beneath
                // system UI, which read as "landscape crops the art"
                // (field-reported with Five Leaves Left). Inset top+start
                // only — the art stays flush to the bottom edge.
                .windowInsetsPadding(
                    WindowInsets.safeDrawing.only(
                        WindowInsetsSides.Top + WindowInsetsSides.Start,
                    ),
                )
                .fillMaxHeight()
                .aspectRatio(1f),
        )
        Spacer(Modifier.width(20.dp))
        Column(
            modifier = Modifier
                .weight(1f)
                .fillMaxHeight()
                // Phone landscape's width (~893dp on Pixel 8a sideways)
                // exceeds the navhost's compact-width threshold (600dp),
                // so the systemBarsPadding applied at the navhost root
                // doesn't run for this layout. Apply it here on the
                // text/controls column so the title row doesn't draw
                // under the status bar / camera cutout. The album art
                // (left of this column) deliberately DOES draw edge-to-
                // edge through the system-bar area.
                .systemBarsPadding()
                .padding(end = 16.dp, top = 12.dp, bottom = 0.dp),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Top,
        ) {
        // ── Adaptive metadata — title / artist / album (+ year) ──────
        // See PhoneAutoFitMetadataBlock: measured fit, all information
        // always shown, block = exactly the leftover, controls below
        // immovable. Year parity: "(YYYY)" always rides the album line.
        PhoneAutoFitMetadataBlock(
            title = track.title,
            artist = track.artist ?: track.albumArtist,
            album = track.album?.takeIf { it.isNotBlank() }?.let { alb ->
                val y = track.date?.let { Regex("""\d{4}""").find(it)?.value }
                alb + (y?.let { " ($it)" } ?: "")
            },
            modifier = Modifier
                .fillMaxWidth()
                .weight(1f),
        )
        // Primary transport row — Skip Previous, Play/Pause (large), Skip Next.
        // Centered, with comfortable touch targets.
        Row(
            modifier = Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.SpaceEvenly,
            verticalAlignment = Alignment.CenterVertically,
        ) {
            CompactTransport(
                icon = Icons.Filled.SkipPrevious,
                description = "Previous",
                size = 40.dp,
                onClick = viewModel::previous,
            )
            CompactPrimaryTransport(
                icon = if (state.isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
                description = if (state.isPlaying) "Pause (long-press to stop)" else "Play (long-press to stop)",
                size = 48.dp,
                onClick = viewModel::togglePlay,
                onLongClick = viewModel::stop,
                focusRequester = playFocus,
            )
            CompactTransport(
                icon = Icons.Filled.SkipNext,
                description = "Next",
                size = 40.dp,
                onClick = viewModel::next,
            )
        }
        Spacer(Modifier.height(2.dp))
        // Single row of 6 content-navigation icons — flattened from
        // the portrait's inverted-pyramid triads to save vertical
        // space in landscape.
        val sleepActive by viewModel.sleepTimerMinutesLeft.collectAsState()
        Row(
            modifier = Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.SpaceEvenly,
            verticalAlignment = Alignment.CenterVertically,
        ) {
            CompactTransportPainter(
                painter = painterResource(R.drawable.ic_lyrics_singer),
                description = "Show lyrics",
                size = 44.dp,
                onClick = onOpenLyrics,
            )
            if (cornerDomEtha) CompactTransport(
                icon = Icons.Filled.Tune,
                description = "Dom-Etha panel",
                size = 44.dp,
                tintAmber = false,
                onClick = onDomEtha,
                ) else CompactTransport(
                icon = Icons.Filled.AvTimer,
                description = if (sleepActive != null) "Sleep timer: ${sleepActive}m" else "Sleep timer",
                size = 44.dp,
                tintAmber = sleepActive != null,
                onClick = { sleepTimerPickerOpen = true },
                )
            CompactTransport(
                icon = Icons.Filled.Search,
                description = "Search library",
                size = 44.dp,
                onClick = onOpenSearch,
            )
            CompactTransport(
                icon = Icons.Filled.Album,
                description = "More from this album",
                size = 44.dp,
                onClick = {
                    val albumTitle = track.album
                    if (!albumTitle.isNullOrBlank()) {
                        val disambiguator = if (hasMultipleArtists) "" else (track.artist ?: "")
                        onMoreFromThisAlbum("$albumTitle|||$disambiguator")
                    }
                },
            )
            CompactTransportPainter(
                painter = painterResource(R.drawable.ic_headphones_vinyl),
                description = if (artistForSearch != null) "More by $artistForSearch" else "More by artist",
                size = 44.dp,
                onClick = {
                    // Always open the picker (TV parity) — see the
                    // portrait layout's note: the sheet now carries album
                    // and id3-tag actions for every track, not just VA.
                    if (artistForSearch != null) artistPickerOpen = true
                },
            )
            CompactTransport(
                icon = Icons.Filled.LocalOffer,
                description = when (genres.size) {
                    0 -> "Browse genres"
                    1 -> "More in ${genres[0]}"
                    else -> "Browse genres"
                },
                size = 44.dp,
                onClick = {
                        when (genres.size) {
                            0 -> Unit
                            1 -> onMoreByGenre(genres[0])
                            else -> genrePickerOpen = true
                    }
                },
            )
        }
        Spacer(Modifier.height(2.dp))
        // Playback modifiers row
        Row(
            modifier = Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.SpaceEvenly,
            verticalAlignment = Alignment.CenterVertically,
        ) {
            CompactTransport(
                icon = if (state.shuffleEnabled) Icons.Filled.ShuffleOn else Icons.Filled.Shuffle,
                description = if (state.shuffleEnabled) "Shuffle on" else "Shuffle off",
                size = 44.dp,
                tintAmber = state.shuffleEnabled,
                onClick = viewModel::toggleShuffle,
            )
            val (repeatIcon, repeatDesc, repeatActive) = when (state.repeatMode) {
                RepeatMode.OFF -> Triple(Icons.Filled.Repeat, "Repeat off", false)
                RepeatMode.ALL -> Triple(Icons.Filled.RepeatOn, "Repeat all", true)
                RepeatMode.ONE -> Triple(Icons.Filled.RepeatOneOn, "Repeat one", true)
            }
            CompactTransport(
                icon = repeatIcon,
                description = repeatDesc,
                size = 44.dp,
                tintAmber = repeatActive,
                onClick = viewModel::cycleRepeat,
            )
            if (viewModel.castIntegration.available) {
                val isCasting by viewModel.castIntegration.isCasting.collectAsState()
                // Observer-mode indication — see the portrait layout's note.
                val observingCast by viewModel.rawCast.observing.collectAsState()
                CompactTransport(
                    icon = if (isCasting || observingCast) Icons.Filled.CastConnected else Icons.Filled.Cast,
                    description = when {
                        observingCast -> "Receiving cast — tap to switch"
                        isCasting -> "Casting — tap to switch"
                        else -> "Cast to device"
                    },
                    size = 44.dp,
                    tintAmber = isCasting,
                    tintOverride = if (observingCast) CastReceiving else null,
                    onClick = viewModel::toggleCast,
                )
            }
            CompactTransport(
                icon = Icons.Filled.Stop,
                description = "Stop",
                size = 44.dp,
                onClick = viewModel::stop,
            )
            CompactTransport(
                icon = Icons.Filled.QueueMusic,
                description = "Queue",
                size = 44.dp,
                onClick = onOpenQueue,
            )
        }
        ProgressBar(
            seekable = seekable,
            position = state.position,
            duration = state.duration,
            onSeek = { target -> viewModel.seekTo(target) },
            centerLabel = track.trackNumber?.takeIf { it > 0 }?.let { "Track Nº $it" },
        )
        }
    }

    // Picker dialogs — same set as NowPlayingCompact / TV path. Defined as
    // overlays at the end so the Row above isn't affected by their state
    // changes.
    if (sleepTimerPickerOpen) {
        val sleepMinutes by viewModel.sleepTimerMinutesLeft.collectAsState()
        ActionSheet(
            title = if (sleepMinutes != null) "Sleep timer (${sleepMinutes}m left)" else "Sleep timer",
            subtitle = "Playback will stop after the selected time.",
            actions = buildList {
                if (sleepMinutes != null) {
                    add(SheetAction("✕", "Cancel timer") { viewModel.cancelSleepTimer() })
                }
                add(SheetAction("⏱", "15 minutes") { viewModel.startSleepTimer(15) })
                add(SheetAction("⏱", "30 minutes") { viewModel.startSleepTimer(30) })
                add(SheetAction("⏱", "45 minutes") { viewModel.startSleepTimer(45) })
                add(SheetAction("⏱", "60 minutes") { viewModel.startSleepTimer(60) })
                add(SheetAction("⏱", "90 minutes") { viewModel.startSleepTimer(90) })
                add(SheetAction("⏱", "120 minutes") { viewModel.startSleepTimer(120) })
            },
            onDismiss = { sleepTimerPickerOpen = false },
        )
    }
    if (artistPickerOpen) {
        // TV-parity sheet: opens for EVERY track (not just VA compilations),
        // with artist catalog browsing, "More tracks from this album", and
        // "Show id3 tags" — previously the id3 sheet was unreachable from
        // the phone's Now Playing entirely.
        ActionSheet(
            title = "More from…",
            actions = buildList {
                val primaryArtist = trackArtist ?: albumArtist
                if (primaryArtist != null) {
                    add(SheetAction("♫", "Albums by $primaryArtist") { onMoreByArtist(primaryArtist) })
                }
                if (hasMultipleArtists && albumArtist != null) {
                    add(SheetAction("♫", "Albums by $albumArtist") { onMoreByArtist(albumArtist) })
                }
                // On VA compilations drop the artist disambiguator — UPnP
                // can't filter by albumArtist reliably, and adding this
                // track's artist would exclude every other artist on the
                // comp (same rationale as the TV branch).
                track.album?.takeIf { it.isNotBlank() }?.let { albumTitle ->
                    add(SheetAction("▤", "More tracks from this album") {
                        val disambiguator = if (hasMultipleArtists) "" else (track.artist ?: "")
                        onMoreFromThisAlbum("$albumTitle|||$disambiguator")
                    })
                }
                add(SheetAction("ⓘ", "Show id3 tags") { onShowMetadata(track) })
            },
            onDismiss = { artistPickerOpen = false },
        )
    }
    if (genrePickerOpen) {
        ActionSheet(
            title = "Browse genres",
            subtitle = "Pick a genre tag from this track.",
            actions = genres.map { g ->
                SheetAction("▸", g) { onMoreByGenre(g) }
            },
            onDismiss = { genrePickerOpen = false },
        )
    }

    // Cast picker — see NowPlayingCompact for rationale.
    CastPickerSheet(
        cast = viewModel.castIntegration,
        visible = castPickerOpen,
        onDismiss = { castPickerOpen = false },
        rawCast = viewModel.rawCast,
    )
}

/**
 * Text that auto-sizes between [minFontSize] and [maxFontSize] to fill
 * the available width. Short strings ("1984") render at the maximum;
 * long strings shrink until they fit in a single line. Uses
 * [onTextLayout] overflow detection + drawWithContent to hide
 * intermediate sizes so only the final size is visible.
 */
@Composable
private fun AutoSizeText(
    text: androidx.compose.ui.text.AnnotatedString,
    style: androidx.compose.ui.text.TextStyle,
    color: Color,
    modifier: Modifier = Modifier,
    maxFontSize: androidx.compose.ui.unit.TextUnit = style.fontSize,
    minFontSize: androidx.compose.ui.unit.TextUnit = 14.sp,
    maxLines: Int = 1,
) {
    var scaledSize by remember(text, maxFontSize) { mutableFloatStateOf(maxFontSize.value) }
    var readyToDraw by remember(text, maxFontSize) { mutableStateOf(false) }

    Text(
        text = text,
        style = style.copy(fontSize = scaledSize.sp),
        color = color,
        // With maxLines > 1, softWrap must be on so text can flow to
        // the extra line(s); hasVisualOverflow then fires only when
        // the text exceeds maxLines, so the shrink loop sizes down
        // until the wrapped text fits — ellipsis only at the floor.
        maxLines = maxLines,
        overflow = TextOverflow.Ellipsis,
        softWrap = maxLines > 1,
        modifier = modifier.then(
            Modifier.drawWithContent { if (readyToDraw) drawContent() }
        ),
        onTextLayout = { result ->
            if (result.hasVisualOverflow && scaledSize > minFontSize.value) {
                scaledSize = (scaledSize - 1f).coerceAtLeast(minFontSize.value)
            } else {
                readyToDraw = true
            }
        },
    )
}

/**
 * Variant of [CompactTransport] that takes a [Painter] (for drawable
 * resources like `ic_lyrics_singer` or `ic_headphones_vinyl`) instead of
 * an [ImageVector]. Identical sizing/tinting/clickable behavior.
 */
@Composable
private fun CompactTransportPainter(
    painter: androidx.compose.ui.graphics.painter.Painter,
    description: String,
    size: androidx.compose.ui.unit.Dp,
    tintAmber: Boolean = false,
    onClick: () -> Unit,
) {
    Box(
        modifier = Modifier
            .size(size)
            .clickable(onClick = onClick),
        contentAlignment = Alignment.Center,
    ) {
        Icon(
            painter = painter,
            contentDescription = description,
            tint = if (tintAmber) AccentAmber else OnPrimary,
            modifier = Modifier.size(size * 0.6f),
        )
    }
}

@Composable
private fun CompactTransport(
    icon: ImageVector,
    description: String,
    size: androidx.compose.ui.unit.Dp,
    tintAmber: Boolean = false,
    /** Wins over tintAmber when non-null — used by the Cast button to show
     *  CastReceiving blue while observing, mirroring the TV's
     *  SmallTransportButton.tintOverride. */
    tintOverride: Color? = null,
    onClick: () -> Unit,
) {
    Box(
        modifier = Modifier
            .size(size)
            .clickable(onClick = onClick),
        contentAlignment = Alignment.Center,
    ) {
        Icon(
            icon,
            contentDescription = description,
            tint = tintOverride ?: if (tintAmber) AccentAmber else OnPrimary,
            modifier = Modifier.size(size * 0.6f),
        )
    }
}

@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun CompactPrimaryTransport(
    icon: ImageVector,
    description: String,
    size: androidx.compose.ui.unit.Dp = 64.dp,
    onClick: () -> Unit,
    focusRequester: FocusRequester,
    onLongClick: (() -> Unit)? = null,
    modifier: Modifier = Modifier,
) {
    val interaction = remember { MutableInteractionSource() }
    Box(
        modifier = modifier
            .size(size)
            .focusRequester(focusRequester)
            .background(AccentAmber, CircleShape)
            .then(
                if (onLongClick != null) {
                    Modifier.combinedClickable(
                        interactionSource = interaction,
                        indication = null,
                        onClick = onClick,
                        onLongClick = onLongClick,
                    )
                } else {
                    Modifier.clickable(onClick = onClick)
                }
            ),
        contentAlignment = Alignment.Center,
    ) {
        Icon(
            icon,
            contentDescription = description,
            tint = OnAmber,
            modifier = Modifier.size(size * 0.5f),
        )
    }
}

// ══════════════════════════════════════════════════════════════════════
// Issue 6 — Volume touch zones on album art
// ══════════════════════════════════════════════════════════════════════

/**
 * Wraps [AlbumArt] with touch zones for on-screen volume control.
 * Top half = volume up, bottom half = volume down. Shows a brief
 * overlay with the current level on each adjustment.
 *
 * Key design:
 *  - `localVol` tracks volume inside the composable so rapid taps
 *    accumulate correctly (the renderer's state.volumePercent lags
 *    behind by one player-listener cycle).
 *  - `pointerInput(Unit)` never restarts; callbacks are reached via
 *    `rememberUpdatedState` so the gesture detector is stable.
 *  - Overlay auto-hides via LaunchedEffect keyed to a tick counter —
 *    no job juggling, no stuck toast.
 *  - Long press: fires one adjustment immediately, then launches a
 *    repeating coroutine that cancels on finger up.
 */
@Composable
private fun VolumeAwareAlbumArt(
    model: String?,
    volumePercent: Int,
    onSetVolume: (percent: Int) -> Unit,
    modifier: Modifier = Modifier,
    step: Int = 5,
    contentScale: ContentScale = ContentScale.Fit,
) {
    // Local volume — updated immediately per tap so sequential taps
    // don't read stale state. Re-synced from the renderer when it
    // catches up — but ONLY outside an active gesture window. Without
    // the window, any transiently-wrong upstream value (a status
    // broadcast racing our optimistic echo, a stream-volume report
    // from an older build's speaker, etc.) re-syncs localVol between
    // taps and the next tap computes from garbage — the "pause, tap
    // down, volume jumps UP to 95" hijack. 2.5s after the last user
    // adjustment, upstream becomes authoritative again.
    var localVol by remember { mutableFloatStateOf(volumePercent.toFloat()) }
    var lastUserAdjustAt by remember { mutableStateOf(0L) }
    LaunchedEffect(volumePercent) {
        if (System.currentTimeMillis() - lastUserAdjustAt > 2_500) {
            localVol = volumePercent.toFloat()
        }
    }

    val currentOnSetVolume by androidx.compose.runtime.rememberUpdatedState(onSetVolume)

    // Overlay — text + auto-dismiss keyed to a counter.
    var overlayLabel by remember { mutableStateOf<String?>(null) }
    var dismissTick by remember { mutableStateOf(0) }
    LaunchedEffect(dismissTick) {
        if (overlayLabel != null) {
            delay(1500)
            overlayLabel = null
        }
    }

    // Shared adjustment logic. Called from gesture handlers.
    val scope = rememberCoroutineScope()
    fun doAdjust(up: Boolean) {
        lastUserAdjustAt = System.currentTimeMillis()
        val newVol = if (up) (localVol + step).coerceAtMost(100f)
                     else   (localVol - step).coerceAtLeast(0f)
        localVol = newVol
        currentOnSetVolume(newVol.toInt())
        val pct = newVol.toInt()
        overlayLabel = when {
            pct <= 0   -> "Volume: 0% (Muted)"
            pct >= 100 -> "Volume: 100%"
            else       -> "Volume: $pct%"
        }
        dismissTick++
    }

    // Continuous-adjust coroutine for long press. Cancelled on finger up.
    var longPressJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }

    Box(modifier = modifier) {
        AlbumArt(
            model = model,
            modifier = Modifier.fillMaxSize(),
            contentScale = contentScale,
        )

        // Top half — volume up
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .fillMaxHeight(0.5f)
                .align(Alignment.TopCenter)
                .pointerInput(Unit) {
                    detectTapGestures(
                        onPress = {
                            // Immediate single adjustment on finger contact.
                            doAdjust(true)
                            // Start long-press ramp after 400ms.
                            longPressJob?.cancel()
                            longPressJob = scope.launch {
                                delay(400)
                                var interval = 250L
                                val t0 = System.currentTimeMillis()
                                while (true) {
                                    doAdjust(true)
                                    delay(interval)
                                    if (System.currentTimeMillis() - t0 > 1000L) interval = 80L
                                }
                            }
                            // tryAwaitRelease suspends until the finger lifts
                            // (returns true) or the gesture is cancelled (false).
                            // Either way, stop the ramp.
                            val released = tryAwaitRelease()
                            longPressJob?.cancel()
                            longPressJob = null
                        },
                    )
                },
        ) {}

        // Bottom half — volume down
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .fillMaxHeight(0.5f)
                .align(Alignment.BottomCenter)
                .pointerInput(Unit) {
                    detectTapGestures(
                        onPress = {
                            doAdjust(false)
                            longPressJob?.cancel()
                            longPressJob = scope.launch {
                                delay(400)
                                var interval = 250L
                                val t0 = System.currentTimeMillis()
                                while (true) {
                                    doAdjust(false)
                                    delay(interval)
                                    if (System.currentTimeMillis() - t0 > 1000L) interval = 80L
                                }
                            }
                            val released = tryAwaitRelease()
                            longPressJob?.cancel()
                            longPressJob = null
                        },
                    )
                },
        ) {}

        // Volume overlay — centered
        if (overlayLabel != null) {
            Box(
                modifier = Modifier
                    .align(Alignment.Center)
                    .background(
                        Color.Black.copy(alpha = 0.75f),
                        RoundedCornerShape(8.dp),
                    )
                    .padding(horizontal = 20.dp, vertical = 10.dp),
                contentAlignment = Alignment.Center,
            ) {
                Text(
                    text = overlayLabel!!,
                    color = OnPrimary,
                    style = MaterialTheme.typography.titleMedium,
                )
            }
        }
    }
}

// ══════════════════════════════════════════════════════════════════════
// Issue 7 — NowPlayingLargePortrait
// ══════════════════════════════════════════════════════════════════════

/**
 * Large-screen portrait Now Playing — designed for HP Chromebase (21.5"
 * touchscreen) in portrait mode and tablets in portrait. The key
 * difference from [NowPlayingCompact]: everything is scaled up to fill
 * the generous vertical space instead of leaving dead space at the bottom.
 *
 * Layout (top to bottom, SpaceBetween fills full height):
 *  - Album art: ~55% of viewport height, centered, with volume touch zones
 *  - Title / artist / album: headlineLarge + headlineMedium + titleLarge
 *  - Progress bar: full width with large touch targets
 *  - Primary transport: 72dp prev/next, 96dp play/pause
 *  - Action triads + modifier row: same as compact but 64dp icons
 */
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun NowPlayingLargePortrait(
    seekable: Boolean,
    cornerDomEtha: Boolean,
    onDomEtha: () -> Unit,
    track: dev.stave.domain.model.DidlItem.Track,
    state: dev.stave.domain.renderer.PlaybackState,
    viewModel: NowPlayingViewModel,
    playFocus: FocusRequester,
    activeServerUdn: String?,
    onOpenQueue: () -> Unit,
    onOpenSearch: () -> Unit,
    onOpenLyrics: () -> Unit,
    onMoreByArtist: (artistName: String) -> Unit,
    onMoreByGenre: (genreName: String) -> Unit,
    onMoreFromThisAlbum: (albumTitle: String) -> Unit,
    onShowMetadata: (track: dev.stave.domain.model.DidlItem.Track) -> Unit,
) {
    var sleepTimerPickerOpen by remember { mutableStateOf(false) }
    var artistPickerOpen by remember { mutableStateOf(false) }
    var genrePickerOpen by remember { mutableStateOf(false) }
    var castPickerOpen by remember { mutableStateOf(false) }
    if (viewModel.castIntegration.available) {
        LaunchedEffect(viewModel) {
            viewModel.showCastPicker.collect { castPickerOpen = true }
        }
    }

    val trackArtist = track.artist
    val albumArtist = track.albumArtist
    val hasMultipleArtists = !trackArtist.isNullOrBlank() &&
        !albumArtist.isNullOrBlank() &&
        !trackArtist.equals(albumArtist, ignoreCase = true)
    val artistForSearch = albumArtist ?: trackArtist
    val genres = parseGenres(track.genre)

    Column(
        modifier = Modifier
            .fillMaxSize()
            .systemBarsPadding(),
        horizontalAlignment = Alignment.CenterHorizontally,
        // SpaceEvenly distributes all remaining space equally between
        // items. Combined with weight(1f) on the art below, this ensures
        // the controls and modifier row are always fully visible at the
        // bottom of the screen. The previous SpaceBetween + aspectRatio(1f)
        // made the art a 510dp square that pushed the modifier row off the
        // bottom edge on the Chromebase's portrait viewport.
        verticalArrangement = Arrangement.spacedBy(4.dp),
    ) {
        Spacer(Modifier.height(4.dp))

        // ── Album art with volume touch zones — square, full width ────
        // the maintainer's form, from the phone layout (2026-05-20: "must
        // always be displayed at correct aspect ratio", edge to edge):
        // a direct child, full width, square. The 2026-07-07 large-
        // portrait layout wrapped it in a weighted Box at 85% width with
        // Fit and a 32dp margin; the maintainer never asked for smaller
        // art. Restored 2026-09-03.
        VolumeAwareAlbumArt(
            model = track.albumArtUri,
            volumePercent = state.volumePercent,
            onSetVolume = { viewModel.setVolumeTo(it) },
            modifier = Modifier
                .fillMaxWidth()
                .aspectRatio(1f),
            contentScale = ContentScale.Fit,
        )
        // Maintainer, 2026-09-04 — item 7 of 2026-07-07 done as asked: the art
        // above is full width; the text and controls below GROW to fill
        // whatever height remains, and SHRINK to fit it, instead of being
        // fixed at 96/72/64dp and falling off the bottom. `k` scales every
        // size below from the height actually available.
        androidx.compose.foundation.layout.BoxWithConstraints(
            Modifier.fillMaxWidth().weight(1f).padding(horizontal = 32.dp),
        ) {
        val availableH = maxHeight
        // Height the content needs at full size: metadata block + progress +
        // transport + three icon rows + spacers (≈ 600dp).
        val neededH = 600.dp
        // Maintainer, 2026-10-29: scale only the part that scales. ProgressBar
        // is fixed-size: 4 (top pad) + 6 + 16 (knob box) + 6 + 4 (spacer)
        // + 18 (labelMedium line height) + 4 (bottom pad) = 58dp. Dividing the
        // whole height by 600dp let those 58dp overflow and clipped the
        // bottom Shuffle/Repeat/Cast/Stop/Queue row on smaller tablets.
        val fixedH = 58.dp
        val k: Float = ((availableH - fixedH) / (neededH - fixedH)).coerceIn(0.5f, 1.35f)
        fun d(v: Int): androidx.compose.ui.unit.Dp = (v * k).dp
        Column(
            modifier = Modifier.fillMaxSize(),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.SpaceEvenly,
        ) {

        Spacer(Modifier.height(d(16)))

        // ── Track metadata — scaled up typography ─────────────────────
        Column(
            modifier = Modifier.fillMaxWidth(),
            horizontalAlignment = Alignment.CenterHorizontally,
        ) {
            // ── Adaptive metadata (see PhoneAutoFitMetadataBlock) ────
            // BOUNDED slot: a fixed 176dp height budget. Previously this
            // was unbounded — long metadata grew freely and the art's
            // weighted Box above absorbed the loss, height-crushing the
            // artwork to a small centered square while the text loomed at
            // full base size (field-rejected: "text absurdly large, art
            // tiny"). Inverted: the art is the fixed element — with the
            // metadata capped, the weighted Box reclaims all remaining
            // height and the art renders width-limited (full viewport
            // width) on any sane geometry, while the controls stay pinned
            // exactly as the weighted-Box design guarantees. Inside the
            // constant budget the bidirectional fit makes text scale a
            // function of TEXT LENGTH alone: short metadata grows to the
            // cap, long metadata wraps and shrinks — the art never pays.
            PhoneAutoFitMetadataBlock(
                title = track.title,
                artist = track.artist ?: track.albumArtist,
                album = track.album?.takeIf { it.isNotBlank() }?.let { alb ->
                    val y = track.date?.let { Regex("""\d{4}""").find(it)?.value }
                    alb + (y?.let { " ($it)" } ?: "")
                },
                modifier = Modifier
                    .fillMaxWidth()
                    .heightIn(max = d(176)),
                titleBaseSp = 40f,
                artistBaseSp = 32f,
                albumBaseSp = 26f,
            )
        }

        Spacer(Modifier.height(d(12)))

        // ── Progress bar ──────────────────────────────────────────────
        ProgressBar(
            seekable = seekable,
            position = state.position,
            duration = state.duration,
            onSeek = { target -> viewModel.seekTo(target) },
            centerLabel = track.trackNumber?.takeIf { it > 0 }?.let { "Track Nº $it" },
        )

        Spacer(Modifier.height(d(8)))

        // ── Primary transport — large buttons ─────────────────────────
        Row(
            modifier = Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.SpaceEvenly,
            verticalAlignment = Alignment.CenterVertically,
        ) {
            CompactTransport(
                icon = Icons.Filled.SkipPrevious,
                description = "Previous",
                size = d(72),
                onClick = viewModel::previous,
            )
            CompactPrimaryTransport(
                icon = if (state.isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
                description = if (state.isPlaying) "Pause" else "Play",
                size = d(96),
                onClick = viewModel::togglePlay,
                onLongClick = viewModel::stop,
                focusRequester = playFocus,
            )
            CompactTransport(
                icon = Icons.Filled.SkipNext,
                description = "Next",
                size = d(72),
                onClick = viewModel::next,
            )
        }

        Spacer(Modifier.height(d(8)))

        // ── Action triads — same pattern as compact, larger icons ─────
        val sleepActive by viewModel.sleepTimerMinutesLeft.collectAsState()
        Row(
            modifier = Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.SpaceEvenly,
            verticalAlignment = Alignment.Top,
        ) {
            // LEFT TRIAD: track-context
            Column(horizontalAlignment = Alignment.CenterHorizontally) {
                CompactTransportPainter(
                    painter = painterResource(R.drawable.ic_lyrics_singer),
                    description = "Show lyrics",
                    size = d(64),
                    onClick = onOpenLyrics,
                )
                Spacer(Modifier.height(d(4)))
                Row(horizontalArrangement = Arrangement.spacedBy(d(20))) {
                    if (cornerDomEtha) CompactTransport(
                        icon = Icons.Filled.Tune,
                        description = "Dom-Etha panel",
                        size = d(64),
                        tintAmber = false,
                        onClick = onDomEtha,
                        ) else CompactTransport(
                        icon = Icons.Filled.AvTimer,
                        description = if (sleepActive != null) "Sleep timer: ${sleepActive}m" else "Sleep timer",
                        size = d(64),
                        tintAmber = sleepActive != null,
                        onClick = { sleepTimerPickerOpen = true },
                        )
                    CompactTransport(
                        icon = Icons.Filled.Search,
                        description = "Search library",
                        size = d(64),
                        onClick = onOpenSearch,
                    )
                }
            }
            // RIGHT TRIAD: catalog-pivot
            Column(horizontalAlignment = Alignment.CenterHorizontally) {
                CompactTransport(
                    icon = Icons.Filled.Album,
                    description = "More from this album",
                    size = d(64),
                    onClick = {
                        val albumTitle = track.album
                        if (!albumTitle.isNullOrBlank()) {
                            val disambiguator = if (hasMultipleArtists) "" else (track.artist ?: "")
                            onMoreFromThisAlbum("$albumTitle|||$disambiguator")
                        }
                    },
                )
                Spacer(Modifier.height(d(4)))
                Row(horizontalArrangement = Arrangement.spacedBy(d(20))) {
                    CompactTransportPainter(
                        painter = painterResource(R.drawable.ic_headphones_vinyl),
                        description = if (artistForSearch != null) "More by $artistForSearch" else "More by artist",
                        size = d(64),
                        onClick = {
                            // Always open the picker (TV parity): the sheet
                            // carries "More tracks from this album" and
                            // "Show id3 tags" alongside artist browsing, so
                            // it must be reachable for single-artist tracks
                            // too, not just VA compilations.
                            if (artistForSearch != null) artistPickerOpen = true
                        },
                    )
                    CompactTransport(
                        icon = Icons.Filled.LocalOffer,
                        description = when (genres.size) {
                            0 -> "Browse genres"
                            1 -> "More in ${genres[0]}"
                            else -> "Browse genres"
                        },
                        size = d(64),
                        onClick = {
                            when (genres.size) {
                                0 -> Unit
                                1 -> onMoreByGenre(genres[0])
                                else -> genrePickerOpen = true
                            }
                        },
                    )
                }
            }
        }

        Spacer(Modifier.height(d(4)))

        // ── Modifier row — shuffle, repeat, cast, stop, queue ─────────
        Row(
            modifier = Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.SpaceEvenly,
            verticalAlignment = Alignment.CenterVertically,
        ) {
            CompactTransport(
                icon = if (state.shuffleEnabled) Icons.Filled.ShuffleOn else Icons.Filled.Shuffle,
                description = if (state.shuffleEnabled) "Shuffle on" else "Shuffle off",
                size = d(64),
                tintAmber = state.shuffleEnabled,
                onClick = viewModel::toggleShuffle,
            )
            val (repeatIcon, repeatDesc, repeatActive) = when (state.repeatMode) {
                RepeatMode.OFF -> Triple(Icons.Filled.Repeat, "Repeat off", false)
                RepeatMode.ALL -> Triple(Icons.Filled.RepeatOn, "Repeat all", true)
                RepeatMode.ONE -> Triple(Icons.Filled.RepeatOneOn, "Repeat one", true)
            }
            CompactTransport(
                icon = repeatIcon,
                description = repeatDesc,
                size = d(64),
                tintAmber = repeatActive,
                onClick = viewModel::cycleRepeat,
            )
            if (viewModel.castIntegration.available) {
                val isCasting by viewModel.castIntegration.isCasting.collectAsState()
                // Observer-mode indication — see the portrait layout's note.
                val observingCast by viewModel.rawCast.observing.collectAsState()
                CompactTransport(
                    icon = if (isCasting || observingCast) Icons.Filled.CastConnected else Icons.Filled.Cast,
                    description = when {
                        observingCast -> "Receiving"
                        isCasting -> "Casting"
                        else -> "Cast"
                    },
                    size = d(64),
                    tintAmber = isCasting,
                    tintOverride = if (observingCast) CastReceiving else null,
                    onClick = viewModel::toggleCast,
                )
            }
            CompactTransport(
                icon = Icons.Filled.Stop,
                description = "Stop",
                size = d(64),
                onClick = viewModel::stop,
            )
            CompactTransport(
                icon = Icons.Filled.QueueMusic,
                description = "Queue",
                size = d(64),
                onClick = onOpenQueue,
            )
        }

        Spacer(Modifier.height(d(8)))
        }  // content column
        }  // BoxWithConstraints
    }
    // ── Dialogs (same as NowPlayingCompact) ───────────────────────────
    if (sleepTimerPickerOpen) {
        val sleepMinutes by viewModel.sleepTimerMinutesLeft.collectAsState()
        ActionSheet(
            title = if (sleepMinutes != null) "Sleep timer (${sleepMinutes}m left)" else "Sleep timer",
            subtitle = "Playback will stop after the selected time.",
            actions = buildList {
                if (sleepMinutes != null) {
                    add(SheetAction("✕", "Cancel timer") { viewModel.cancelSleepTimer() })
                }
                add(SheetAction("⏱", "15 minutes") { viewModel.startSleepTimer(15) })
                add(SheetAction("⏱", "30 minutes") { viewModel.startSleepTimer(30) })
                add(SheetAction("⏱", "45 minutes") { viewModel.startSleepTimer(45) })
                add(SheetAction("⏱", "60 minutes") { viewModel.startSleepTimer(60) })
                add(SheetAction("⏱", "90 minutes") { viewModel.startSleepTimer(90) })
                add(SheetAction("⏱", "120 minutes") { viewModel.startSleepTimer(120) })
            },
            onDismiss = { sleepTimerPickerOpen = false },
        )
    }
    if (artistPickerOpen) {
        // TV-parity sheet: opens for EVERY track (not just VA compilations),
        // with artist catalog browsing, "More tracks from this album", and
        // "Show id3 tags" — previously the id3 sheet was unreachable from
        // the phone's Now Playing entirely.
        ActionSheet(
            title = "More from…",
            actions = buildList {
                val primaryArtist = trackArtist ?: albumArtist
                if (primaryArtist != null) {
                    add(SheetAction("♫", "Albums by $primaryArtist") { onMoreByArtist(primaryArtist) })
                }
                if (hasMultipleArtists && albumArtist != null) {
                    add(SheetAction("♫", "Albums by $albumArtist") { onMoreByArtist(albumArtist) })
                }
                // On VA compilations drop the artist disambiguator — UPnP
                // can't filter by albumArtist reliably, and adding this
                // track's artist would exclude every other artist on the
                // comp (same rationale as the TV branch).
                track.album?.takeIf { it.isNotBlank() }?.let { albumTitle ->
                    add(SheetAction("▤", "More tracks from this album") {
                        val disambiguator = if (hasMultipleArtists) "" else (track.artist ?: "")
                        onMoreFromThisAlbum("$albumTitle|||$disambiguator")
                    })
                }
                add(SheetAction("ⓘ", "Show id3 tags") { onShowMetadata(track) })
            },
            onDismiss = { artistPickerOpen = false },
        )
    }
    if (genrePickerOpen) {
        ActionSheet(
            title = "Browse genres",
            subtitle = "Pick a genre tag from this track.",
            actions = genres.map { g ->
                SheetAction("▸", g) { onMoreByGenre(g) }
            },
            onDismiss = { genrePickerOpen = false },
        )
    }
    CastPickerSheet(
        cast = viewModel.castIntegration,
        visible = castPickerOpen,
        onDismiss = { castPickerOpen = false },
        rawCast = viewModel.rawCast,
    )
}

/** Warning strip shown while a remote UPnP renderer is the selected output:
 *  "Playing on <name> — not this device". Nothing when local is selected. */
@Composable
private fun RemoteRendererBanner(
    activeUdn: String?,
    renderers: List<dev.stave.domain.model.RemoteRenderer>,
    modifier: Modifier = Modifier,
) {
    if (activeUdn == null) return
    val name = renderers.firstOrNull { it.udn == activeUdn }?.friendlyName
        ?: activeUdn.removePrefix("uuid:").take(8)
    Box(
        modifier
            .fillMaxWidth()
            .background(dev.stave.ui.theme.AccentAmberSoft)
            .padding(horizontal = 16.dp, vertical = 8.dp),
        contentAlignment = Alignment.Center,
    ) {
        Text(
            "Playing on $name — not this device (Settings → Play through)",
            style = MaterialTheme.typography.labelMedium,
            color = dev.stave.ui.theme.AccentAmber,
        )
    }
}