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

StaveNavHost.kt

package dev.stave.ui.nav

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import dev.stave.local.toDidlTrack
import kotlinx.coroutines.launch
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.onFocusChanged
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.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import dev.stave.ui.about.AboutScreen
import dev.stave.ui.browse.BrowseScreen
import dev.stave.ui.components.LocalMarqueeEnabled
import dev.stave.ui.detail.DetailScreen
import dev.stave.ui.guide.GuideScreen
import dev.stave.ui.lyrics.LyricsScreen
import dev.stave.ui.metadata.MetadataScreen
import dev.stave.ui.more.MoreMode
import dev.stave.ui.more.MoreScreen
import dev.stave.ui.nowplaying.NowPlayingBar
import dev.stave.ui.nowplaying.NowPlayingScreen
import dev.stave.ui.nowplaying.NowPlayingViewModel
import dev.stave.ui.playlist.PlaylistsScreen
import dev.stave.ui.playlist.SavePlaylistScreen
import dev.stave.ui.queue.QueueScreen
import dev.stave.ui.random.RandomPickerScreen
import dev.stave.ui.search.SearchScreen
import dev.stave.ui.servers.ServersScreen
import dev.stave.ui.servers.ServersViewModel
import dev.stave.ui.settings.PlaybackSettingsScreen
import dev.stave.ui.settings.SettingsScreen
import dev.stave.ui.settings.SettingsViewModel
import dev.stave.ui.theme.SurfaceBase
import dev.stave.ui.theme.isCompactWidth
import dev.stave.ui.welcome.WelcomeScreen
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first

@Composable
fun EarhornNavHost(
    /** Stream of (route, optional argument) pairs emitted by
     *  MainActivity when the system MediaSession's notification fires
     *  a deep-link intent. Null in unit-test / preview contexts where
     *  no activity-level integration exists. */
    navCommands: Flow<Pair<String, String?>>? = null,
) {
    val navController = rememberNavController()
    val nowPlayingVm: NowPlayingViewModel = hiltViewModel()
    val serversVm: ServersViewModel = hiltViewModel()
    // Second SettingsViewModel instance exists solely to read marqueeEnabled at NavHost level.
    // Its other state (metadata fields toggle) is unused here; the SettingsScreen composable
    // grabs its own VM instance. The cost is trivial — DataStore reads are cached.
    val settingsVm: SettingsViewModel = hiltViewModel()
    val navHostVm: NavHostViewModel = hiltViewModel()
    val scope = rememberCoroutineScope()
    // Maintainer, 2026-10-29: Now Playing VM's one-line user messages (failures
    // that used to be silent) shown as Toasts; context also used by the
    // "needs a music server" / empty-playlist Toasts below.
    val navContext = LocalContext.current
    LaunchedEffect(nowPlayingVm, navContext) {
        nowPlayingVm.messages.collect { msg ->
            android.widget.Toast.makeText(navContext, msg, android.widget.Toast.LENGTH_SHORT).show()
        }
    }
    val playbackState by nowPlayingVm.state.collectAsState()
    // Issue 4: When the TV is observing a Cast session via raw protocol,
    // the NowPlayingBar must show the OBSERVED track, not the local
    // renderer's stale queue. Same switch the full NowPlayingScreen does
    // at line ~278 (if (rawCastObserving) rawCastState else state).
    val rawCastObservingForBar by nowPlayingVm.rawCast.observing.collectAsState()
    val rawCastStateForBar by nowPlayingVm.rawCast.playbackState.collectAsState()
    val observedDeviceNameForUi by nowPlayingVm.rawCast.connectedDeviceName.collectAsState()
    val barState = if (rawCastObservingForBar) rawCastStateForBar else playbackState

    // Cast/Observe picker opened from the Browse header — the entry point
    // into observer mode when the queue is empty (NowPlaying and its bar
    // are unreachable with nothing queued). Mounted at NavHost level so it
    // overlays whatever screen is showing. TV gets the D-pad-native
    // RawCastPickerSheet; phone/tablet get CastPickerSheet with both the
    // SDK cast-to section and the raw observe section.
    var browseCastPickerOpen by remember { mutableStateOf(false) }
    val navConfig = androidx.compose.ui.platform.LocalConfiguration.current
    val navIsTv = (navConfig.uiMode and android.content.res.Configuration.UI_MODE_TYPE_MASK) ==
        android.content.res.Configuration.UI_MODE_TYPE_TELEVISION
    val currentBackStackEntry by navController.currentBackStackEntryAsState()
    val currentRoute = currentBackStackEntry?.destination?.route
    val servers by serversVm.servers.collectAsState()
    // The server UDN we're currently using. In the current app there's only one browse flow
    // active, and we persist the user's pick, so the preferred UDN is the practical "active"
    // server. Used by NowPlayingScreen's More buttons to know which server to search.
    val preferredServerUdn by serversVm.preferredServerUdn.collectAsState(initial = null)
    val marqueeEnabled by settingsVm.marqueeEnabled.collectAsState()

    // Bar play/pause target — the D-pad-Left shortcut requests focus here.
    val barPlayFocus = remember { FocusRequester() }
    val barExpandFocus = remember { FocusRequester() }
    // True while any descendant of the bar Row has focus. Used to disarm the global Left
    // intercept so Left-nav BETWEEN bar buttons works normally. Without this, pressing Left
    // while focused on the bar's Play/Pause would re-steal focus to itself — i.e., nothing.
    var barHasFocus by remember { mutableStateOf(false) }

    // Notification deep-link consumer. MainActivity emits (route, arg)
    // pairs into this flow when the user taps an action button or the
    // body of the system media notification. We translate the simple
    // string routes (defined as PlaybackService.NAV_*) into NavHost
    // destinations and navigate. preferredServerUdn is captured in the
    // closure because the More-by-artist / More-from-album routes need
    // it to build the MORE destination.
    // Declared BEFORE the navCommands collector because the NOW_PLAYING
    // branch below claims auto-nav (see comment there).
    var autoNavDone by remember { mutableStateOf(false) }
    // Maintainer, 2026-10-29: Welcome stays blank until startup has decided
    // whether to auto-navigate, so returning users no longer see Welcome
    // flash before Browse. It becomes visible only when Welcome is going to
    // stay: no saved server (the settings flow emits null at once on a fresh
    // install), or auto-nav standing down below. Never set before a
    // navigate-to-Browse, so the leaving Welcome can't redraw mid-fade.
    // Saveable so a rotation on a fresh-install Welcome doesn't blank it.
    var startupResolved by androidx.compose.runtime.saveable.rememberSaveable { mutableStateOf(false) }
    LaunchedEffect(Unit) {
        if (serversVm.preferredServerUdn.first() == null) startupResolved = true
    }

    LaunchedEffect(navController, navCommands, preferredServerUdn) {
        navCommands?.collect { (route, arg) ->
            val destination = when (route) {
                dev.stave.playback.PlaybackService.NAV_QUEUE       -> Destinations.QUEUE
                dev.stave.playback.PlaybackService.NAV_LYRICS      -> Destinations.LYRICS
                dev.stave.playback.PlaybackService.NAV_NOW_PLAYING -> {
                    // Cold-process launches into NowPlaying (voice-cast
                    // auto-observe intent, notification body tap) race the
                    // auto-nav-to-Browse effect below, which is parked on a
                    // DataStore read. Whichever navigation ran LAST used to
                    // win — and auto-nav pops everything above WELCOME, so a
                    // late-resolving preferred server stomped the freshly
                    // opened NowPlaying and dumped a fixed screen in Browse.
                    // Resolve the ordering here: claim auto-nav, and when the
                    // preferred server is already known lay Browse underneath
                    // first so back-from-NowPlaying still lands somewhere
                    // useful. If it isn't known yet (fresh install, DataStore
                    // still reading), NowPlaying simply sits above WELCOME —
                    // observing works with zero UPnP servers configured.
                    if (!autoNavDone) {
                        autoNavDone = true
                        preferredServerUdn?.let { server ->
                            navController.navigate(Destinations.browse(server, "0")) {
                                popUpTo(Destinations.WELCOME) { inclusive = true }
                            }
                        }
                    }
                    Destinations.NOW_PLAYING
                }
                dev.stave.playback.PlaybackService.NAV_MORE_ARTIST -> {
                    val server = preferredServerUdn ?: return@collect
                    val q = arg.orEmpty()
                    if (q.isBlank()) return@collect
                    Destinations.more(server, "byArtist", q)
                }
                dev.stave.playback.PlaybackService.NAV_MORE_ALBUM  -> {
                    val server = preferredServerUdn ?: return@collect
                    val q = arg.orEmpty()
                    if (q.isBlank()) return@collect
                    Destinations.more(server, "byAlbum", q)
                }
                dev.stave.playback.PlaybackService.NAV_BROWSE -> {
                    val server = preferredServerUdn
                    if (server != null) Destinations.browse(server, "0") else Destinations.SERVERS
                }
                else -> return@collect
            }
            // Maintainer, 2026-10-29: a deep link left the NavHost-level cast
            // picker open over the new screen; close it on any handled command.
            browseCastPickerOpen = false
            navController.navigateSingleton(destination)
        }
    }

    // Auto-navigate to Browse once we know which server to use.
    //
    // Boot flow:
    //   1. App launches → NavHost starts at WELCOME (branded "pick a server in settings"
    //      placeholder — no back-stack-polluting picker).
    //   2. If the user has previously picked a server, its UDN is in DataStore. We collect
    //      the flow; as soon as it resolves non-null, we nav to Browse(udn, "0") and pop
    //      WELCOME inclusively so back from Browse exits the app cleanly.
    //   3. If no preferred UDN is set, WELCOME stays put and the user is directed to
    //      Settings → Choose server.
    //
    // Note: we no longer wait for the server to *reappear on the network* before auto-nav.
    // Browse will show its own empty state until discovery completes. This is intentional:
    // users expect to land in Browse, not in a discovery waiting room. If the server never
    // comes back (wrong network, etc.) they can clear the preference from Settings.
    LaunchedEffect(Unit) {
        val preferred = serversVm.preferredServerUdn.filterNotNull().first()
        // Maintainer, 2026-10-29: auto-nav standing down — Welcome may render.
        if (autoNavDone) {
            startupResolved = true
            return@LaunchedEffect
        }
        // This effect suspends on a DataStore/discovery read that can resolve
        // seconds after launch. By then the user may have navigated somewhere
        // deliberately — casting from the picker lands on Now Playing, for
        // instance — and popUpTo(WELCOME, inclusive) would yank them back to
        // Browse. That was the "bounce to browse" after choosing a speaker.
        // Auto-nav exists only to move OFF the welcome screen at startup, so
        // it applies only if that is still where we are.
        val current = navController.currentDestination?.route
        android.util.Log.i("EarhornNav",
            "startup auto-nav resolved: currentRoute='$current' autoNavDone=$autoNavDone")
        // Maintainer, 2026-10-29: a notification tap (NAV_QUEUE / NAV_LYRICS)
        // on a cold start can land before the saved server is read, leaving
        // WELCOME under Queue/Lyrics. In that case put Browse underneath
        // (dropping WELCOME) and reopen the same screen on top.
        val reopenRoute = when (current) {
            Destinations.QUEUE -> Destinations.QUEUE
            Destinations.LYRICS -> Destinations.LYRICS
            else -> null
        }
        if (reopenRoute != null &&
            runCatching { navController.getBackStackEntry(Destinations.WELCOME) }.isSuccess
        ) {
            autoNavDone = true
            navController.navigate(Destinations.browse(preferred, "0")) {
                popUpTo(Destinations.WELCOME) { inclusive = true }
            }
            navController.navigate(reopenRoute)
            return@LaunchedEffect
        }
        if (current != null && current != Destinations.WELCOME) {
            // Maintainer, 2026-10-29: Welcome may still be in the stack below.
            startupResolved = true
            return@LaunchedEffect
        }
        autoNavDone = true
        navController.navigate(Destinations.browse(preferred, "0")) {
            popUpTo(Destinations.WELCOME) { inclusive = true }
        }
    }

    // Bar stays visible whenever a track is loaded — including the
    // post-Stop state. Hiding it on Stop strands the user: they can
    // browse but can't resume the track, can't reach the NowPlaying
    // screen via the album-art tap, and can't see what was last
    // playing. Stop now means "pause at 0 and leave the affordance"
    // (the Play button is enabled because isStopped is still
    // available to the bar's children for visual treatment if they
    // want it). Bar still hides on the NowPlaying screen itself
    // (would be redundant) and when nothing has ever been loaded.
    // Observer-aware: barState (defined above) is the observed session's
    // state while following, local otherwise. Keying visibility on the
    // LOCAL state made the bar invisible whenever the local queue was
    // empty — which, after a cache clear, is ALWAYS — so starting an
    // observation from the Browse cast picker left the user with a
    // working observation and no visible NPB and therefore no route to
    // the NowPlaying screen at all (field report: "screen flashed and
    // nothing... dumped back to browse with no visible NPB").
    val barIsVisible = barState.current != null
        && currentRoute != Destinations.NOW_PLAYING

    val focusManager = LocalFocusManager.current
    // Walks up ContextWrapper chain to find the hosting Activity. LocalContext returns
    // whatever Context was set — on Hilt-injected Activities this is a wrapped instance,
    // so we unwrap. Used by Settings → Exit.
    val activity = run {
        var ctx: android.content.Context? = LocalContext.current
        while (ctx is android.content.ContextWrapper) {
            if (ctx is android.app.Activity) return@run ctx
            ctx = ctx.baseContext
        }
        null
    }

    // Captured once so closures (like the browse-root onBack) can read it
    // without needing to be @Composable themselves.
    val isCompact = isCompactWidth()

    // Last-focused content row. Content screens (Browse's list, etc.)
    // write their row FRs here on focus gain; the NPB's Up handler reads
    // this to restore focus to exactly where the user was, instead of
    // relying on Compose's spatial focus which can land anywhere nearby.
    val lastContentFocus = remember { mutableStateOf<FocusRequester?>(null) }

    CompositionLocalProvider(
        LocalMarqueeEnabled provides marqueeEnabled,
        LocalLastContentFocus provides lastContentFocus,
    ) {
    Box(
        Modifier
            .fillMaxSize()
            .background(SurfaceBase)
            // D-pad Left shortcut to the NowPlayingBar. onKeyEvent (post-order, inner
            // handlers run first). This lets screen-specific Left handlers — notably
            // Browse's Queue button arming the search field — consume the event before
            // we intervene. If nothing inner consumed it, we try focusManager.moveFocus(Left);
            // if that fails too (we're at a genuine leftmost dead-end), fall back to
            // jumping focus to the bar's play/pause button.
            .onKeyEvent { event ->
                if (!barIsVisible || barHasFocus) return@onKeyEvent false
                if (event.type != KeyEventType.KeyDown) return@onKeyEvent false
                when (event.key) {
                    Key.DirectionLeft -> {
                        val moved = focusManager.moveFocus(FocusDirection.Left)
                        if (!moved) runCatching { barPlayFocus.requestFocus() }
                        true
                    }
                    Key.DirectionUp -> {
                        // At the dead-top of content, wrap to the NPB.
                        val moved = focusManager.moveFocus(FocusDirection.Up)
                        if (!moved) runCatching { barPlayFocus.requestFocus() }
                        moved || true
                    }
                    else -> false
                }
            },
    ) {
        // systemBarsPadding only on compact (phone). TV must NOT have this
        // applied: the original TV layout draws to all four edges, and
        // changing that is a regression. Even though Android TV typically
        // reports zero system-bar insets (so the padding would be zero),
        // we keep it conditional to be unambiguously safe.
        // systemBarsPadding on compact (phone) AND large-portrait
        // (Chromebase/tablet in portrait — the ChromeOS shelf/nav inset
        // was cutting the bottom half of the NowPlaying bar without it).
        // TV must NOT have this applied: the original TV layout draws to
        // all four edges, and isLargePortrait() explicitly excludes TV.
        // Gate rewritten from an allowlist (compact ∨ large-portrait) to
        // the rule it always meant: EVERY touch form factor gets system-
        // bar padding; only TV draws edge-to-edge. The allowlist missed
        // tablet landscape (neither compact-width nor large-portrait), so
        // the NowPlayingBar sat under the tablet's bottom system bar on
        // every screen (field-reported from the lyrics screen). TV is
        // excluded by explicit uiMode check, preserving its byte-for-byte
        // edge-to-edge layout.
        val navCfg = androidx.compose.ui.platform.LocalConfiguration.current
        val navIsTv = (navCfg.uiMode and android.content.res.Configuration.UI_MODE_TYPE_MASK) ==
            android.content.res.Configuration.UI_MODE_TYPE_TELEVISION
        val rootModifier = if (!navIsTv) {
            Modifier.fillMaxSize().systemBarsPadding()
        } else {
            Modifier.fillMaxSize()
        }
        Column(rootModifier) {
            NavHost(
                navController = navController,
                startDestination = Destinations.WELCOME,
                modifier = Modifier.weight(1f)
            ) {
                composable(Destinations.WELCOME) {
                    WelcomeScreen(
                        onPickServer = { navController.navigate(Destinations.SERVERS) },
                        onOpenSettings = { navController.navigateSingleton(Destinations.SETTINGS) },
                        // Maintainer, 2026-10-29: Cast/Observe entry from Welcome
                        // (opens the NavHost-level picker), and no Welcome
                        // flash before startup auto-nav decides.
                        onOpenCast = { browseCastPickerOpen = true },
                        ready = startupResolved,
                    )
                }
                composable(Destinations.SERVERS) {
                    ServersScreen(
                        onServerClick = { udn ->
                            // Clean slate on pick: pop WELCOME and SERVERS both, land on
                            // Browse. Back from Browse now exits the app.
                            // Maintainer, 2026-10-29: Settings → Choose server
                            // no longer drops WELCOME first, so pop the whole
                            // graph — popUpTo(WELCOME) was ignored there and
                            // left the old Browse/Settings underneath.
                            navController.navigate(Destinations.browse(udn, "0")) {
                                popUpTo(navController.graph.id) { inclusive = true }
                            }
                        },
                        onLocalClick = { navController.navigate(Destinations.LOCAL_HUB) },
                        onSettingsClick = { navController.navigateSingleton(Destinations.SETTINGS) },
                        onOpenQueue = { navController.navigateSingleton(Destinations.QUEUE) },
                        // The standalone search page's only entry point (decision, 
                        // 2026-09-02: it was wired to nothing).
                        onOpenSearch = { navController.navigate(Destinations.SEARCH) },
                        onOpenRandom = { navController.navigate(Destinations.RANDOM_PICKER) },
                        // Maintainer, 2026-10-29: Servers header Cast action opens
                        // the NavHost-level Cast/Observe picker.
                        onOpenCast = { browseCastPickerOpen = true },
                    )
                }
                composable(Destinations.LOCAL_HUB) {
                    dev.stave.ui.local.LocalHubScreen(
                        onOpenAlbums = { navController.navigate(Destinations.LOCAL_ALBUMS) },
                        onOpenArtists = { navController.navigate(Destinations.LOCAL_ARTISTS) },
                        onOpenSongs = { navController.navigate(Destinations.LOCAL_SONGS) },
                        onBack = { navController.popBackStack() },
                    )
                }
                composable(Destinations.LOCAL_ALBUMS) {
                    dev.stave.ui.local.LocalAlbumsScreen(
                        onAlbumClick = { id ->
                            navController.navigate(Destinations.localAlbum(id))
                        },
                        onBack = { navController.popBackStack() },
                    )
                }
                composable(Destinations.LOCAL_ARTISTS) {
                    dev.stave.ui.local.LocalArtistsScreen(
                        onArtistClick = { id, name ->
                            navController.navigate(Destinations.localArtist(id, name))
                        },
                        onBack = { navController.popBackStack() },
                    )
                }
                composable(Destinations.LOCAL_SONGS) {
                    dev.stave.ui.local.LocalSongsScreen(
                        onBack = { navController.popBackStack() },
                        onTrackPlaying = { navController.navigateSingleton(Destinations.NOW_PLAYING) },
                    )
                }
                composable(
                    Destinations.LOCAL_GENRE_DETAIL,
                    arguments = listOf(
                        androidx.navigation.navArgument("genreName") { type = androidx.navigation.NavType.StringType }
                    )
                ) { entry ->
                    val genreName = entry.arguments?.getString("genreName").orEmpty()
                    dev.stave.ui.local.LocalGenreScreen(
                        genreName = genreName,
                        onBack = { navController.popBackStack() },
                        onTrackPlaying = { navController.navigateSingleton(Destinations.NOW_PLAYING) },
                        onAlbumClick = { albumId ->
                            navController.navigate(Destinations.localAlbum(albumId))
                        },
                    )
                }
                composable(
                    Destinations.LOCAL_ALBUM,
                    arguments = listOf(
                        androidx.navigation.navArgument("albumId") { type = androidx.navigation.NavType.LongType }
                    )
                ) { entry ->
                    val albumId = entry.arguments?.getLong("albumId") ?: 0L
                    dev.stave.ui.local.LocalAlbumScreen(
                        albumId = albumId,
                        onBack = { navController.popBackStack() },
                        onTrackPlaying = { navController.navigateSingleton(Destinations.NOW_PLAYING) },
                    )
                }
                composable(
                    Destinations.LOCAL_ARTIST,
                    arguments = listOf(
                        androidx.navigation.navArgument("artistId") { type = androidx.navigation.NavType.LongType },
                        androidx.navigation.navArgument("artistName") { type = androidx.navigation.NavType.StringType }
                    )
                ) { entry ->
                    val artistName = entry.arguments?.getString("artistName").orEmpty()
                    dev.stave.ui.local.LocalArtistScreen(
                        artistName = artistName,
                        onAlbumClick = { id ->
                            navController.navigate(Destinations.localAlbum(id))
                        },
                        onBack = { navController.popBackStack() },
                        onTrackPlaying = { navController.navigateSingleton(Destinations.NOW_PLAYING) },
                    )
                }
                composable(Destinations.BROWSE) { entry ->
                    val udn = entry.arguments?.getString("udn").orEmpty()
                    val containerId = entry.arguments?.getString("containerId").orEmpty()
                    // One-shot flag set by NowPlaying's search button when navigating to
                    // Browse. Read once, immediately clear so back-nav to this entry
                    // (from Queue, etc.) doesn't re-trigger Queue-focus.
                    val focusQueueOnEntry = entry.savedStateHandle
                        .remove<Boolean>("focusQueueOnEntry") ?: false
                    BrowseScreen(
                        udn = udn,
                        containerId = containerId,
                        focusQueueOnEntry = focusQueueOnEntry,
                        onContainerClick = { id, isAlbum ->
                            // Local album containers have id "local_album:<id>"
                            // Maintainer, 2026-10-29: BrowseViewModel's local
                            // mode issues "local_album:<id>" while this only
                            // matched "earhorn_local:album:<id>", so a local
                            // album tapped in local-only Browse opened the
                            // server Detail screen and failed. Accept both.
                            val localPrefix = when {
                                id.startsWith(dev.stave.local.LocalMediaScanner.LOCAL_ALBUM) ->
                                    dev.stave.local.LocalMediaScanner.LOCAL_ALBUM
                                id.startsWith("local_album:") -> "local_album:"
                                else -> null
                            }
                            val localAlbumId = localPrefix?.let { id.removePrefix(it).toLongOrNull() }
                            if (localAlbumId != null) {
                                navController.navigate(Destinations.localAlbum(localAlbumId))
                            } else if (isAlbum) {
                                navController.navigate(Destinations.detail(udn, id))
                            } else {
                                navController.navigate(Destinations.browse(udn, id))
                            }
                        },
                        onTrackPlay = { tracks, index ->
                            nowPlayingVm.playTracks(tracks, index) { navController.navigateSingleton(Destinations.NOW_PLAYING) }
                        },
                        onEnqueueTrack = { track ->
                            nowPlayingVm.enqueueTracks(listOf(track))
                        },
                        onEnqueueAndPlayTrack = { track ->
                            nowPlayingVm.enqueueAndPlay(listOf(track)) { navController.navigateSingleton(Destinations.NOW_PLAYING) }
                        },
                        onMoreByArtist = { artist ->
                            navController.navigate(
                                Destinations.more(udn, MoreMode.ARTIST.name, artist)
                            )
                        },
                        onTracksByArtist = { artist ->
                            navController.navigate(
                                Destinations.more(udn, MoreMode.TRACKS_BY_ARTIST.name, artist)
                            )
                        },
                        onShowMetadata = { track ->
                            navHostVm.metadataHandoff.pending = track
                            navController.navigateSingleton(Destinations.METADATA)
                        },
                        queueIsEmpty = playbackState.queue.isEmpty(),
                        onBack = {
                            if (containerId == "0") {
                                // Browse root can't pop — nothing below.
                                // On TV (LEANBACK), exit is the expected
                                // behavior. On phone (portrait OR landscape),
                                // go to Now Playing so back gesture doesn't
                                // accidentally exit.
                                // Maintainer, 2026-10-29: TV test is now the same
                                // uiMode test as the layout (navIsTv), and on
                                // non-TV Now Playing is used only when something
                                // is loaded or observed — otherwise Back exits,
                                // ending the Browse ⇄ empty Now Playing loop.
                                if (navIsTv || barState.current == null) {
                                    activity?.finish()
                                } else {
                                    navController.navigateSingleton(Destinations.NOW_PLAYING)
                                }
                            } else {
                                navController.popBackStack()
                            }
                        },
                        onSettingsClick = { navController.navigateSingleton(Destinations.SETTINGS) },
                        onOpenQueue = { navController.navigateSingleton(Destinations.QUEUE) },
                        onSubmitSearch = { q ->
                            navController.navigate(
                                Destinations.more(udn, MoreMode.SEARCH_TITLE.name, q)
                            )
                        },
                        onOpenRandom = { navController.navigate(Destinations.RANDOM_PICKER) },
                        onMusicNow = {
                            // "Music Now" — 42 random tracks, enqueue and play. Uses
                            // loadRandomTracks (queue-replace semantics) since Music Now
                            // is explicitly a party-start action; the confirm sheet in
                            // BrowseScreen gates this when the queue is non-empty.
                            nowPlayingVm.loadRandomTracks(udn, 42) { navController.navigateSingleton(Destinations.NOW_PLAYING) }
                        },
                        onOpenCastPicker = { browseCastPickerOpen = true },
                        isObservingCast = rawCastObservingForBar,
                        observedDeviceName = observedDeviceNameForUi,
                        // Same disarm-and-return rule as the gate's confirm
                        // (review N.43): an armed fixed screen is not yanked
                        // back mid-party; it returns to kiosk when the party stops.
                        onStopObserving = { nowPlayingVm.playbackGate.leaveObservingForLocal() },
                        onFocusBarInfo = {
                            // Up from the search pill / MusicNow, or Left
                            // from the pill: land on the bar's info/expand
                            // zone (art + metadata) — the left edge of the
                            // bar, matching the visual direction. No-op
                            // when the bar isn't visible.
                            if (barIsVisible) {
                                runCatching { barExpandFocus.requestFocus() }
                            }
                        },
                        onFocusBarPlayPause = {
                            // Up from the rest of the header action row
                            // (Queue/Random/Cast/Grid/Settings): land on
                            // the bar's play/pause — the transport is what
                            // those utility buttons sit under.
                            if (barIsVisible) {
                                runCatching { barPlayFocus.requestFocus() }
                            }
                        },
                        onLocalAlbumClick = { albumId ->
                            navController.navigate(Destinations.localAlbum(albumId))
                        },
                    )
                }
                composable(Destinations.DETAIL) { entry ->
                    val udn = entry.arguments?.getString("udn").orEmpty()
                    val containerId = entry.arguments?.getString("containerId").orEmpty()
                    DetailScreen(
                        udn = udn,
                        containerId = containerId,
                        onPlay = { tracks, index ->
                            nowPlayingVm.playTracks(tracks, index) { navController.navigateSingleton(Destinations.NOW_PLAYING) }
                        },
                        onEnqueue = { tracks ->
                            nowPlayingVm.enqueueTracks(tracks)
                        },
                        onPlayNext = { tracks ->
                            nowPlayingVm.playNextTracks(tracks)
                        },
                        onEnqueueAndPlay = { tracks ->
                            nowPlayingVm.enqueueAndPlay(tracks)
                        },
                        onMoreByArtist = { artist ->
                            navController.navigate(
                                Destinations.more(udn, MoreMode.ARTIST.name, artist)
                            )
                        },
                        onTracksByArtist = { artist ->
                            navController.navigate(
                                Destinations.more(udn, MoreMode.TRACKS_BY_ARTIST.name, artist)
                            )
                        },
                        onShowMetadata = { track ->
                            // Stash the track in the handoff singleton BEFORE navigating —
                            // MetadataViewModel reads it in its init and clears it.
                            navHostVm.metadataHandoff.pending = track
                            navController.navigateSingleton(Destinations.METADATA)
                        },
                        // One question only while following (review N.53,
                        // design decision 2026-09-02): the gate asks "stop
                        // following & play here?"; the album screen's own
                        // "clear queue?" sheet is skipped in that case.
                        queueIsEmpty = playbackState.queue.isEmpty() || rawCastObservingForBar,
                        onBack = { navController.popBackStack() }
                    )
                }
                composable(Destinations.MORE) { entry ->
                    val udn = entry.arguments?.getString("udn").orEmpty()
                    val modeName = entry.arguments?.getString("mode").orEmpty()
                    val query = entry.arguments?.getString("query").orEmpty()
                    // Optional highlight: if the caller passed a container ID (the
                    // now-playing album, when arriving from NowPlayingScreen's
                    // "Albums by …" picker), MoreScreen scrolls to and focuses
                    // that row instead of row 0. Null/empty for callers that
                    // don't pass it (Browse, Detail, Random) — they get default
                    // first-row focus.
                    val highlight = entry.arguments?.getString("highlight")?.takeIf { it.isNotEmpty() }
                    // MoreMode.valueOf throws on unknown strings — guard with runCatching so a
                    // bad deep-link doesn't crash the app.
                    val mode = runCatching { MoreMode.valueOf(modeName) }.getOrDefault(MoreMode.ARTIST)
                    MoreScreen(
                        udn = udn,
                        mode = mode,
                        query = query,
                        highlightContainerId = highlight,
                        onAlbumClick = { id ->
                            navController.navigate(Destinations.detail(udn, id))
                        },
                        onTrackClick = { track ->
                            // Single click = enqueue + play. Non-destructive: existing
                            // queue entries stay; this track appends and playback jumps
                            // to it. Navigate to NowPlaying so the user sees what started.
                            nowPlayingVm.enqueueAndPlay(listOf(track)) { navController.navigateSingleton(Destinations.NOW_PLAYING) }
                        },
                        onPlayAlbum = { id ->
                            nowPlayingVm.playContainer(udn, id) { navController.navigateSingleton(Destinations.NOW_PLAYING) }
                        },
                        onQueueAlbum = { id -> nowPlayingVm.enqueueContainer(udn, id) },
                        onPlayNextAlbum = { id -> nowPlayingVm.playNextContainer(udn, id) },
                        onPlayTracks = { tracks, idx ->
                            nowPlayingVm.playTracks(tracks, idx) { navController.navigateSingleton(Destinations.NOW_PLAYING) }
                        },
                        onEnqueueTracks = { tracks -> nowPlayingVm.enqueueTracks(tracks) },
                        onEnqueueAndPlayTracks = { tracks ->
                            nowPlayingVm.enqueueAndPlay(tracks) { navController.navigateSingleton(Destinations.NOW_PLAYING) }
                        },
                        onPlayNextTracks = { tracks -> nowPlayingVm.playNextTracks(tracks) },
                        onEnqueueAndPlayTrack = { track ->
                            nowPlayingVm.enqueueAndPlay(listOf(track))
                        },
                        onMoreByArtist = { artist ->
                            navController.navigate(
                                Destinations.more(udn, MoreMode.ARTIST.name, artist)
                            )
                        },
                        onTracksByArtist = { artist ->
                            navController.navigate(
                                Destinations.more(udn, MoreMode.TRACKS_BY_ARTIST.name, artist)
                            )
                        },
                        onShowMetadata = { track ->
                            navHostVm.metadataHandoff.pending = track
                            navController.navigateSingleton(Destinations.METADATA)
                        },
                        onBack = { navController.popBackStack() },
                    )
                }
                composable(Destinations.NOW_PLAYING) {
                    NowPlayingScreen(
                        activeServerUdn = preferredServerUdn,
                        onMoreByArtist = { artist ->
                            val udn = preferredServerUdn
                            if (udn != null) {
                                val highlight = nowPlayingVm.state.value.current?.parentId
                                navController.navigate(
                                    Destinations.more(udn, MoreMode.ARTIST.name, artist, highlight)
                                )
                            } else {
                                // Maintainer, 2026-10-29: was a silent no-op with no server saved.
                                android.widget.Toast.makeText(
                                    navContext,
                                    "That needs a music server",
                                    android.widget.Toast.LENGTH_SHORT,
                                ).show()
                            }
                        },
                        onMoreByGenre = { genre ->
                            val udn = preferredServerUdn
                            if (udn != null) {
                                navController.navigate(Destinations.more(udn, MoreMode.GENRE.name, genre))
                            } else {
                                // Maintainer, 2026-10-29: was a silent no-op with no server saved.
                                android.widget.Toast.makeText(
                                    navContext,
                                    "That needs a music server",
                                    android.widget.Toast.LENGTH_SHORT,
                                ).show()
                            }
                        },
                        onMoreFromThisAlbum = { albumTitle ->
                            val udn = preferredServerUdn
                            if (udn != null) {
                                navController.navigate(
                                    Destinations.more(udn, MoreMode.TRACKS_BY_ALBUM.name, albumTitle)
                                )
                            } else {
                                // Local playback — try parentId first, fall back to
                                // searching local albums by title.
                                val parentId = nowPlayingVm.state.value.current?.parentId.orEmpty()
                                val albumId = parentId.removePrefix(
                                    dev.stave.local.LocalMediaScanner.LOCAL_ALBUM
                                ).toLongOrNull()
                                if (albumId != null) {
                                    navController.navigate(Destinations.localAlbum(albumId))
                                } else {
                                    // parentId didn't parse — search by album title
                                    scope.launch {
                                        val match = navHostVm.localScanner.scanAlbums()
                                            .firstOrNull { it.title.equals(albumTitle, ignoreCase = true) }
                                        if (match != null) {
                                            navController.navigate(Destinations.localAlbum(match.id))
                                        }
                                    }
                                }
                            }
                        },
                        onOpenQueue = { navController.navigateSingleton(Destinations.QUEUE) },
                        onOpenSearch = {
                            val udn = preferredServerUdn
                            // Maintainer, 2026-10-29: pushing a second Browse root
                            // made Back from it finish the app on TV. Return to
                            // the Browse entry already in the stack and set its
                            // one-shot focus flag; push only when there is none.
                            if (navController.popBackStack(Destinations.BROWSE, inclusive = false)) {
                                navController.currentBackStackEntry
                                    ?.savedStateHandle?.set("focusQueueOnEntry", true)
                            } else if (udn != null) {
                                navController.navigate(Destinations.browse(udn, "0"))
                                navController.currentBackStackEntry
                                    ?.savedStateHandle?.set("focusQueueOnEntry", true)
                            } else {
                                // Local playback — open Browse with empty UDN. The
                                // search keyboard works with local files (BrowseViewModel
                                // falls back to LocalMediaScanner when UDN is blank).
                                navController.navigate(Destinations.browse("", "0"))
                                navController.currentBackStackEntry
                                    ?.savedStateHandle?.set("focusQueueOnEntry", true)
                            }
                        },
                        onOpenLyrics = { navController.navigateSingleton(Destinations.LYRICS) },
                        onShowMetadata = { track ->
                            navHostVm.metadataHandoff.pending = track
                            navController.navigateSingleton(Destinations.METADATA)
                        },
                        onBack = { navController.popBackStack() },
                        // Maintainer, 2026-10-29: exits from the empty Now
                        // Playing screen. Library returns to the Browse
                        // screen already in the stack if there is one;
                        // otherwise opens the saved server's root, or the
                        // server picker when none is saved.
                        onOpenLibrary = {
                            if (!navController.popBackStack(Destinations.BROWSE, inclusive = false)) {
                                val udn = preferredServerUdn
                                if (udn != null) {
                                    navController.navigate(Destinations.browse(udn, "0"))
                                } else {
                                    navController.navigateSingleton(Destinations.SERVERS)
                                }
                            }
                        },
                        onOpenSettings = { navController.navigateSingleton(Destinations.SETTINGS) },
                        // Maintainer, 2026-10-29: use the NavHost-level VM; the
                        // per-entry default VM died on leaving Now Playing and
                        // cancelled the sleep timer with it.
                        viewModel = nowPlayingVm,
                    )
                }
                composable(Destinations.QUEUE) {
                    QueueScreen(
                        onBack = { navController.popBackStack() },
                        onSavePlaylist = { navController.navigate(Destinations.PLAYLIST_SAVE) },
                        onLoadPlaylist = { navController.navigate(Destinations.PLAYLISTS) },
                        onShowMetadata = { track ->
                            navHostVm.metadataHandoff.pending = track
                            navController.navigateSingleton(Destinations.METADATA)
                        },
                        onMoreByArtist = { artist ->
                            val udn = preferredServerUdn
                            if (udn != null) {
                                navController.navigate(
                                    Destinations.more(udn, MoreMode.ARTIST.name, artist)
                                )
                            } else {
                                // Maintainer, 2026-10-29: say why instead of a silent no-op.
                                android.widget.Toast.makeText(
                                    navContext,
                                    "That needs a music server",
                                    android.widget.Toast.LENGTH_SHORT,
                                ).show()
                            }
                            // No-op when there's no preferred server (purely local playback).
                            // Matches the behavior of NowPlayingScreen's onMoreByArtist at this
                            // same site — without a server to browse against, "More by" has
                            // nowhere to go. The action shouldn't have been visible in the long
                            // press menu in that case anyway, but the guard is here as a safety.
                        },
                        // Maintainer, 2026-10-29: empty-queue "Browse library" —
                        // same as Now Playing's onOpenLibrary: back to the Browse
                        // entry in the stack, else the saved server's root, else
                        // the server picker.
                        onBrowse = {
                            if (!navController.popBackStack(Destinations.BROWSE, inclusive = false)) {
                                val udn = preferredServerUdn
                                if (udn != null) {
                                    navController.navigate(Destinations.browse(udn, "0"))
                                } else {
                                    navController.navigateSingleton(Destinations.SERVERS)
                                }
                            }
                        },
                    )
                }
                composable(Destinations.PLAYLISTS) {
                    PlaylistsScreen(
                        onBack = { navController.popBackStack() },
                        onPlaylistChosen = { tracks ->
                            if (tracks.isNotEmpty()) {
                                // Replace the queue with the chosen playlist and start
                                // playback. Same semantics as tapping Play on an album.
                                nowPlayingVm.playTracks(tracks, 0) {
                                    // Pop PLAYLISTS off (and anything between it and its
                                    // caller — typically Queue), then singleton-navigate to
                                    // NowPlaying. Two-step because navigateSingleton provides
                                    // its own NavOptionsBuilder and can't take a trailing one.
                                    navController.popBackStack(
                                        Destinations.PLAYLISTS,
                                        inclusive = true,
                                    )
                                    navController.navigateSingleton(Destinations.NOW_PLAYING)
                                }
                            } else {
                                // Maintainer, 2026-10-29: was a silent no-op.
                                android.widget.Toast.makeText(
                                    navContext,
                                    "That playlist is empty or couldn't be read",
                                    android.widget.Toast.LENGTH_SHORT,
                                ).show()
                            }
                        },
                    )
                }
                composable(Destinations.PLAYLIST_SAVE) {
                    SavePlaylistScreen(
                        onSaved = { navController.popBackStack() },
                        onBack = { navController.popBackStack() },
                    )
                }
                composable(Destinations.SEARCH) {
                    // Maintainer, 2026-10-29: with no saved server, Submit did
                    // nothing. Browse("", "0") can't be handed the typed query
                    // (its route has no query arg), so say why and close.
                    val searchContext = LocalContext.current
                    SearchScreen(
                        onSubmit = { query ->
                            val udn = preferredServerUdn
                            if (udn == null) {
                                android.widget.Toast.makeText(
                                    searchContext,
                                    "Pick a server first to search it",
                                    android.widget.Toast.LENGTH_SHORT,
                                ).show()
                                navController.popBackStack()
                                return@SearchScreen
                            }
                            navController.navigate(
                                Destinations.more(udn, MoreMode.SEARCH_TITLE.name, query)
                            ) {
                                // Replace SEARCH in the back stack so pressing Back from the
                                // results page goes to the previous screen (Browse/Servers),
                                // not back to the text field.
                                popUpTo(Destinations.SEARCH) { inclusive = true }
                            }
                        },
                        onBack = { navController.popBackStack() },
                    )
                }
                composable(Destinations.RANDOM_PICKER) {
                    // Maintainer, 2026-10-29: for the "nothing found" Toast below.
                    val randomContext = LocalContext.current
                    RandomPickerScreen(
                        onPickAlbums = { count ->
                            val udn = preferredServerUdn
                            if (udn != null) {
                                navController.navigate(
                                    Destinations.more(udn, MoreMode.RANDOM_ALBUMS.name, count.toString())
                                ) {
                                    popUpTo(Destinations.RANDOM_PICKER) { inclusive = true }
                                }
                            } else {
                                // Local random albums
                                navController.navigate("local_random_albums/$count") {
                                    popUpTo(Destinations.RANDOM_PICKER) { inclusive = true }
                                }
                            }
                        },
                        onPickTracks = { count ->
                            val udn = preferredServerUdn
                            if (udn != null) {
                                navController.navigate(
                                    Destinations.more(udn, MoreMode.RANDOM_TRACKS.name, count.toString())
                                ) {
                                    popUpTo(Destinations.RANDOM_PICKER) { inclusive = true }
                                }
                            } else {
                                // Local random tracks — load into queue and play
                                scope.launch {
                                    val scanner = navHostVm.localScanner
                                    val tracks = scanner.randomTracks(count)
                                    if (tracks.isNotEmpty()) {
                                        val didl = tracks.map { it.toDidlTrack() }
                                        nowPlayingVm.playTracks(didl, 0) {
                                            navController.navigate(Destinations.NOW_PLAYING) {
                                                popUpTo(Destinations.RANDOM_PICKER) { inclusive = true }
                                            }
                                        }
                                    } else {
                                        // Maintainer, 2026-10-29: no local tracks —
                                        // say so instead of doing nothing.
                                        android.widget.Toast.makeText(
                                            randomContext,
                                            "No music found on this device",
                                            android.widget.Toast.LENGTH_SHORT,
                                        ).show()
                                    }
                                }
                            }
                        },
                        onBack = { navController.popBackStack() },
                    )
                }
                // Local random albums — shows a grid of randomly selected albums
                composable(
                    "local_random_albums/{count}",
                    arguments = listOf(
                        androidx.navigation.navArgument("count") { type = androidx.navigation.NavType.IntType }
                    )
                ) { entry ->
                    val count = entry.arguments?.getInt("count") ?: 25
                    dev.stave.ui.local.LocalRandomAlbumsScreen(
                        count = count,
                        onAlbumClick = { albumId ->
                            navController.navigate(Destinations.localAlbum(albumId))
                        },
                        onBack = { navController.popBackStack() },
                    )
                }
                composable(Destinations.SETTINGS) {
                    SettingsScreen(
                        onBack = { navController.popBackStack() },
                        onOpenAbout = { navController.navigate(Destinations.ABOUT) },
                        onOpenGuide = { navController.navigate(Destinations.GUIDE) },
                        onOpenPlayback = { navController.navigate(Destinations.PLAYBACK_SETTINGS) },
                        onChooseServer = {
                            // Maintainer, 2026-10-29: was forgetAndRescan() + popUpTo(WELCOME)
                            // inclusive — opening the picker deleted the saved server and
                            // Back exited the app. Now only rescan (+ random cache drop);
                            // the preference changes when the user picks a server, and
                            // Back from the picker returns to Settings.
                            serversVm.rescanForPick()
                            navController.navigate(Destinations.SERVERS)
                        },
                        onExit = {
                            // design decision 2026-09-02: "Close the app
                            // completely" means completely. Stop local
                            // playback, drop any cast session, release the
                            // observed house session (the screen stops
                            // listening until kiosk sends it out again), stop
                            // the background service, then finish.
                            scope.launch {
                                // The LOCAL player only. nowPlayingVm.stop()
                                // routes to the group while following — Exit
                                // must never send STOP to the group
                                // (review C.8, 2026-09-02).
                                runCatching { nowPlayingVm.stopLocalOnly() }
                                runCatching { nowPlayingVm.rawCast.disconnect(clearPersisted = true) }
                                runCatching { nowPlayingVm.castIntegration.disconnect() }
                                activity?.let { a ->
                                    runCatching {
                                        a.stopService(android.content.Intent(a, dev.stave.playback.PlaybackService::class.java))
                                    }
                                }
                                activity?.finishAndRemoveTask()
                            }
                        },
                    )
                }
                composable(Destinations.PLAYBACK_SETTINGS) {
                    PlaybackSettingsScreen(onBack = { navController.popBackStack() })
                }
                composable(Destinations.ABOUT) {
                    AboutScreen(onBack = { navController.popBackStack() })
                }
                composable(Destinations.GUIDE) {
                    GuideScreen(onBack = { navController.popBackStack() })
                }
                composable(Destinations.METADATA) {
                    MetadataScreen(onBack = { navController.popBackStack() })
                }
                composable(Destinations.LYRICS) {
                    LyricsScreen(
                        onBack = { navController.popBackStack() },
                        onRequestPlayPauseFocus = {
                            if (barIsVisible) {
                                runCatching { barPlayFocus.requestFocus() }
                            }
                        },
                    )
                }
            }

            if (barIsVisible) {
                NowPlayingBar(
                    state = barState,
                    onExpand = { navController.navigateSingleton(Destinations.NOW_PLAYING) },
                    onTogglePlay = {
                        if (rawCastObservingForBar) scope.launch {
                            if (barState.isPlaying) nowPlayingVm.rawCast.pause()
                            else nowPlayingVm.rawCast.play()
                        } else nowPlayingVm.togglePlay()
                    },
                    onStop = {
                        if (rawCastObservingForBar) scope.launch { nowPlayingVm.rawCast.stop() }
                        else nowPlayingVm.stop()
                    },
                    onPrev = {
                        if (rawCastObservingForBar) scope.launch { nowPlayingVm.rawCast.previous() }
                        else nowPlayingVm.previous()
                    },
                    onNext = {
                        if (rawCastObservingForBar) scope.launch { nowPlayingVm.rawCast.next() }
                        else nowPlayingVm.next()
                    },
                    onOpenQueue = { navController.navigateSingleton(Destinations.QUEUE) },
                    playPauseFocusRequester = barPlayFocus,
                    expandFocusRequester = barExpandFocus,
                    // Up and Down from the bar both try the last-focused
                    // content row FIRST (restores the user to exactly
                    // where they were), then fall back to Compose's
                    // spatial focus resolver when that FR has been
                    // disposed (e.g. scrolled out of a LazyColumn).
                    onUpFromBar = {
                        val tracked = lastContentFocus.value
                        val restored = tracked != null &&
                            runCatching { tracked.requestFocus() }.isSuccess
                        if (!restored) {
                            runCatching { focusManager.moveFocus(FocusDirection.Up) }
                        }
                    },
                    onDownFromBar = {
                        // Down from bar → top of content. moveFocus(Up)
                        // goes to the content ABOVE the bar (which is
                        // visually above since the bar is at the bottom).
                        runCatching { focusManager.moveFocus(FocusDirection.Up) }
                    },
                    // hasFocus, not isFocused — the bar itself isn't focusable, only its
                    // button children are. We want true whenever ANY child has focus so the
                    // Left intercept stands down and inter-button Left-nav works.
                    modifier = Modifier.onFocusChanged { barHasFocus = it.hasFocus },
                )
            }
        }

    // Universal local-playback gate confirmation. ANY local-playback
    // starter (play album, track tap, Music Now, local library, Play
    // Next into an empty queue) invoked while following a Cast session
    // parks the action in LocalPlaybackGate and raises this sheet.
    val playGateVisible by nowPlayingVm.playbackGate.confirmVisible.collectAsState()
    if (playGateVisible) {
        dev.stave.ui.components.ActionSheet(
            title = "Following ${observedDeviceNameForUi ?: "a Cast device"}",
            subtitle = "You're listening to a Cast session. Stop following " +
                "and play on this device instead?",
            actions = listOf(
                dev.stave.ui.components.SheetAction("▶", "Stop following & play here") {
                    nowPlayingVm.playbackGate.confirm()
                },
            ),
            onDismiss = { nowPlayingVm.playbackGate.dismiss() },
        )
    }

    // Cast/Observe picker overlay — opened from the Browse header Cast
    // action. Mounted at NavHost level (above the nav content) so it works
    // regardless of which screen is showing and with an empty queue.
    if (navIsTv) {
        dev.stave.ui.cast.RawCastPickerSheet(
            rawCast = nowPlayingVm.rawCast,
            visible = browseCastPickerOpen,
            onDismiss = { browseCastPickerOpen = false },
            onObserveStarted = {
                navController.navigateSingleton(Destinations.NOW_PLAYING)
            },
        )
    } else {
        dev.stave.ui.cast.CastPickerSheet(
            cast = nowPlayingVm.castIntegration,
            visible = browseCastPickerOpen,
            onDismiss = { browseCastPickerOpen = false },
            rawCast = nowPlayingVm.rawCast,
            onObserveStarted = {
                navController.navigateSingleton(Destinations.NOW_PLAYING)
            },
        )
    }
    }
    }
}

/**
 * Navigate to [route] as a singleton — if it's already in the back stack, pop
 * everything above it and reveal that existing instance; if not, push fresh.
 *
 * This solves the accumulating-instances problem. Without it, opening
 * NowPlaying → Lyrics → Back → NowPlaying → Lyrics → Back (etc.) stacks up
 * duplicate NowPlaying entries; pressing Back repeatedly then walks through
 * all those stale NowPlaying/Lyrics/etc. screens instead of taking the user
 * to a meaningful earlier place.
 *
 * Implementation note: uses [popBackStack] + [navigate] rather than
 * `navigate(route) { popUpTo(route) { inclusive = false } }`. The
 * popUpTo-inside-navigate form triggers exit animations on every popped
 * destination, which briefly render during teardown and produce a visible
 * "flutter" of intermediate pages as the stack unwinds. popBackStack pops
 * synchronously without transition, so the user sees a clean cut to the
 * existing instance.
 *
 * popBackStack returns true if it found the route and popped to it (in which
 * case we're done — the existing instance is now the current destination).
 * If it returns false, the route wasn't in the stack at all, so we push a
 * fresh instance.
 *
 * Use this for "main page" destinations (NowPlaying, Queue, Settings,
 * Lyrics, Metadata) where there should only ever be ONE in the stack. Do
 * NOT use for drill-down destinations (Detail, More, Browse-subcategory)
 * where stacking is the whole point of Back navigation.
 */
private fun androidx.navigation.NavController.navigateSingleton(route: String) {
    if (!popBackStack(route, inclusive = false)) {
        navigate(route)
    }
}