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

StaveNavHost.kt

Each line, with the release that last changed it.

0.5.01package dev.stave.ui.nav
2
3import androidx.compose.foundation.background
4import androidx.compose.foundation.layout.Box
5import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.fillMaxSize
7import androidx.compose.foundation.layout.systemBarsPadding
8import androidx.compose.runtime.Composable
9import androidx.compose.runtime.CompositionLocalProvider
10import androidx.compose.runtime.LaunchedEffect
11import androidx.compose.runtime.collectAsState
12import androidx.compose.runtime.getValue
13import androidx.compose.runtime.rememberCoroutineScope
14import dev.stave.local.toDidlTrack
15import kotlinx.coroutines.launch
16import androidx.compose.runtime.mutableStateOf
17import androidx.compose.runtime.remember
18import androidx.compose.runtime.setValue
19import androidx.compose.ui.Modifier
20import androidx.compose.ui.focus.FocusDirection
21import androidx.compose.ui.focus.FocusRequester
22import androidx.compose.ui.focus.onFocusChanged
23import androidx.compose.ui.input.key.Key
24import androidx.compose.ui.input.key.KeyEventType
25import androidx.compose.ui.input.key.key
26import androidx.compose.ui.input.key.onKeyEvent
27import androidx.compose.ui.input.key.type
28import androidx.compose.ui.platform.LocalContext
29import androidx.compose.ui.platform.LocalFocusManager
30import androidx.hilt.navigation.compose.hiltViewModel
31import androidx.navigation.compose.NavHost
32import androidx.navigation.compose.composable
33import androidx.navigation.compose.currentBackStackEntryAsState
34import androidx.navigation.compose.rememberNavController
35import dev.stave.ui.about.AboutScreen
36import dev.stave.ui.browse.BrowseScreen
37import dev.stave.ui.components.LocalMarqueeEnabled
38import dev.stave.ui.detail.DetailScreen
39import dev.stave.ui.guide.GuideScreen
40import dev.stave.ui.lyrics.LyricsScreen
41import dev.stave.ui.metadata.MetadataScreen
42import dev.stave.ui.more.MoreMode
43import dev.stave.ui.more.MoreScreen
44import dev.stave.ui.nowplaying.NowPlayingBar
45import dev.stave.ui.nowplaying.NowPlayingScreen
46import dev.stave.ui.nowplaying.NowPlayingViewModel
47import dev.stave.ui.playlist.PlaylistsScreen
48import dev.stave.ui.playlist.SavePlaylistScreen
49import dev.stave.ui.queue.QueueScreen
50import dev.stave.ui.random.RandomPickerScreen
51import dev.stave.ui.search.SearchScreen
52import dev.stave.ui.servers.ServersScreen
53import dev.stave.ui.servers.ServersViewModel
54import dev.stave.ui.settings.PlaybackSettingsScreen
55import dev.stave.ui.settings.SettingsScreen
56import dev.stave.ui.settings.SettingsViewModel
57import dev.stave.ui.theme.SurfaceBase
58import dev.stave.ui.theme.isCompactWidth
59import dev.stave.ui.welcome.WelcomeScreen
60import kotlinx.coroutines.flow.Flow
61import kotlinx.coroutines.flow.filterNotNull
62import kotlinx.coroutines.flow.first
63
64@Composable
65fun EarhornNavHost(
66 /** Stream of (route, optional argument) pairs emitted by
67 * MainActivity when the system MediaSession's notification fires
68 * a deep-link intent. Null in unit-test / preview contexts where
69 * no activity-level integration exists. */
70 navCommands: Flow<Pair<String, String?>>? = null,
71) {
72 val navController = rememberNavController()
73 val nowPlayingVm: NowPlayingViewModel = hiltViewModel()
74 val serversVm: ServersViewModel = hiltViewModel()
75 // Second SettingsViewModel instance exists solely to read marqueeEnabled at NavHost level.
76 // Its other state (metadata fields toggle) is unused here; the SettingsScreen composable
77 // grabs its own VM instance. The cost is trivial — DataStore reads are cached.
78 val settingsVm: SettingsViewModel = hiltViewModel()
79 val navHostVm: NavHostViewModel = hiltViewModel()
80 val scope = rememberCoroutineScope()
81 // Maintainer, 2026-10-29: Now Playing VM's one-line user messages (failures
82 // that used to be silent) shown as Toasts; context also used by the
83 // "needs a music server" / empty-playlist Toasts below.
84 val navContext = LocalContext.current
85 LaunchedEffect(nowPlayingVm, navContext) {
86 nowPlayingVm.messages.collect { msg ->
87 android.widget.Toast.makeText(navContext, msg, android.widget.Toast.LENGTH_SHORT).show()
88 }
89 }
90 val playbackState by nowPlayingVm.state.collectAsState()
91 // Issue 4: When the TV is observing a Cast session via raw protocol,
92 // the NowPlayingBar must show the OBSERVED track, not the local
93 // renderer's stale queue. Same switch the full NowPlayingScreen does
94 // at line ~278 (if (rawCastObserving) rawCastState else state).
95 val rawCastObservingForBar by nowPlayingVm.rawCast.observing.collectAsState()
96 val rawCastStateForBar by nowPlayingVm.rawCast.playbackState.collectAsState()
97 val observedDeviceNameForUi by nowPlayingVm.rawCast.connectedDeviceName.collectAsState()
98 val barState = if (rawCastObservingForBar) rawCastStateForBar else playbackState
99
100 // Cast/Observe picker opened from the Browse header — the entry point
101 // into observer mode when the queue is empty (NowPlaying and its bar
102 // are unreachable with nothing queued). Mounted at NavHost level so it
103 // overlays whatever screen is showing. TV gets the D-pad-native
104 // RawCastPickerSheet; phone/tablet get CastPickerSheet with both the
105 // SDK cast-to section and the raw observe section.
106 var browseCastPickerOpen by remember { mutableStateOf(false) }
107 val navConfig = androidx.compose.ui.platform.LocalConfiguration.current
108 val navIsTv = (navConfig.uiMode and android.content.res.Configuration.UI_MODE_TYPE_MASK) ==
109 android.content.res.Configuration.UI_MODE_TYPE_TELEVISION
110 val currentBackStackEntry by navController.currentBackStackEntryAsState()
111 val currentRoute = currentBackStackEntry?.destination?.route
112 val servers by serversVm.servers.collectAsState()
113 // The server UDN we're currently using. In the current app there's only one browse flow
114 // active, and we persist the user's pick, so the preferred UDN is the practical "active"
115 // server. Used by NowPlayingScreen's More buttons to know which server to search.
116 val preferredServerUdn by serversVm.preferredServerUdn.collectAsState(initial = null)
117 val marqueeEnabled by settingsVm.marqueeEnabled.collectAsState()
118
119 // Bar play/pause target — the D-pad-Left shortcut requests focus here.
120 val barPlayFocus = remember { FocusRequester() }
121 val barExpandFocus = remember { FocusRequester() }
122 // True while any descendant of the bar Row has focus. Used to disarm the global Left
123 // intercept so Left-nav BETWEEN bar buttons works normally. Without this, pressing Left
124 // while focused on the bar's Play/Pause would re-steal focus to itself — i.e., nothing.
125 var barHasFocus by remember { mutableStateOf(false) }
126
127 // Notification deep-link consumer. MainActivity emits (route, arg)
128 // pairs into this flow when the user taps an action button or the
129 // body of the system media notification. We translate the simple
130 // string routes (defined as PlaybackService.NAV_*) into NavHost
131 // destinations and navigate. preferredServerUdn is captured in the
132 // closure because the More-by-artist / More-from-album routes need
133 // it to build the MORE destination.
134 // Declared BEFORE the navCommands collector because the NOW_PLAYING
135 // branch below claims auto-nav (see comment there).
136 var autoNavDone by remember { mutableStateOf(false) }
137 // Maintainer, 2026-10-29: Welcome stays blank until startup has decided
138 // whether to auto-navigate, so returning users no longer see Welcome
139 // flash before Browse. It becomes visible only when Welcome is going to
140 // stay: no saved server (the settings flow emits null at once on a fresh
141 // install), or auto-nav standing down below. Never set before a
142 // navigate-to-Browse, so the leaving Welcome can't redraw mid-fade.
143 // Saveable so a rotation on a fresh-install Welcome doesn't blank it.
144 var startupResolved by androidx.compose.runtime.saveable.rememberSaveable { mutableStateOf(false) }
145 LaunchedEffect(Unit) {
146 if (serversVm.preferredServerUdn.first() == null) startupResolved = true
147 }
148
149 LaunchedEffect(navController, navCommands, preferredServerUdn) {
150 navCommands?.collect { (route, arg) ->
151 val destination = when (route) {
152 dev.stave.playback.PlaybackService.NAV_QUEUE -> Destinations.QUEUE
153 dev.stave.playback.PlaybackService.NAV_LYRICS -> Destinations.LYRICS
154 dev.stave.playback.PlaybackService.NAV_NOW_PLAYING -> {
155 // Cold-process launches into NowPlaying (voice-cast
156 // auto-observe intent, notification body tap) race the
157 // auto-nav-to-Browse effect below, which is parked on a
158 // DataStore read. Whichever navigation ran LAST used to
159 // win — and auto-nav pops everything above WELCOME, so a
160 // late-resolving preferred server stomped the freshly
161 // opened NowPlaying and dumped a fixed screen in Browse.
162 // Resolve the ordering here: claim auto-nav, and when the
163 // preferred server is already known lay Browse underneath
164 // first so back-from-NowPlaying still lands somewhere
165 // useful. If it isn't known yet (fresh install, DataStore
166 // still reading), NowPlaying simply sits above WELCOME —
167 // observing works with zero UPnP servers configured.
168 if (!autoNavDone) {
169 autoNavDone = true
170 preferredServerUdn?.let { server ->
171 navController.navigate(Destinations.browse(server, "0")) {
172 popUpTo(Destinations.WELCOME) { inclusive = true }
173 }
174 }
175 }
176 Destinations.NOW_PLAYING
177 }
178 dev.stave.playback.PlaybackService.NAV_MORE_ARTIST -> {
179 val server = preferredServerUdn ?: return@collect
180 val q = arg.orEmpty()
181 if (q.isBlank()) return@collect
182 Destinations.more(server, "byArtist", q)
183 }
184 dev.stave.playback.PlaybackService.NAV_MORE_ALBUM -> {
185 val server = preferredServerUdn ?: return@collect
186 val q = arg.orEmpty()
187 if (q.isBlank()) return@collect
188 Destinations.more(server, "byAlbum", q)
189 }
190 dev.stave.playback.PlaybackService.NAV_BROWSE -> {
191 val server = preferredServerUdn
192 if (server != null) Destinations.browse(server, "0") else Destinations.SERVERS
193 }
194 else -> return@collect
195 }
196 // Maintainer, 2026-10-29: a deep link left the NavHost-level cast
197 // picker open over the new screen; close it on any handled command.
198 browseCastPickerOpen = false
199 navController.navigateSingleton(destination)
200 }
201 }
202
203 // Auto-navigate to Browse once we know which server to use.
204 //
205 // Boot flow:
206 // 1. App launches → NavHost starts at WELCOME (branded "pick a server in settings"
207 // placeholder — no back-stack-polluting picker).
208 // 2. If the user has previously picked a server, its UDN is in DataStore. We collect
209 // the flow; as soon as it resolves non-null, we nav to Browse(udn, "0") and pop
210 // WELCOME inclusively so back from Browse exits the app cleanly.
211 // 3. If no preferred UDN is set, WELCOME stays put and the user is directed to
212 // Settings → Choose server.
213 //
214 // Note: we no longer wait for the server to *reappear on the network* before auto-nav.
215 // Browse will show its own empty state until discovery completes. This is intentional:
216 // users expect to land in Browse, not in a discovery waiting room. If the server never
217 // comes back (wrong network, etc.) they can clear the preference from Settings.
218 LaunchedEffect(Unit) {
219 val preferred = serversVm.preferredServerUdn.filterNotNull().first()
220 // Maintainer, 2026-10-29: auto-nav standing down — Welcome may render.
221 if (autoNavDone) {
222 startupResolved = true
223 return@LaunchedEffect
224 }
225 // This effect suspends on a DataStore/discovery read that can resolve
226 // seconds after launch. By then the user may have navigated somewhere
227 // deliberately — casting from the picker lands on Now Playing, for
228 // instance — and popUpTo(WELCOME, inclusive) would yank them back to
229 // Browse. That was the "bounce to browse" after choosing a speaker.
230 // Auto-nav exists only to move OFF the welcome screen at startup, so
231 // it applies only if that is still where we are.
232 val current = navController.currentDestination?.route
233 android.util.Log.i("EarhornNav",
234 "startup auto-nav resolved: currentRoute='$current' autoNavDone=$autoNavDone")
235 // Maintainer, 2026-10-29: a notification tap (NAV_QUEUE / NAV_LYRICS)
236 // on a cold start can land before the saved server is read, leaving
237 // WELCOME under Queue/Lyrics. In that case put Browse underneath
238 // (dropping WELCOME) and reopen the same screen on top.
239 val reopenRoute = when (current) {
240 Destinations.QUEUE -> Destinations.QUEUE
241 Destinations.LYRICS -> Destinations.LYRICS
242 else -> null
243 }
244 if (reopenRoute != null &&
245 runCatching { navController.getBackStackEntry(Destinations.WELCOME) }.isSuccess
246 ) {
247 autoNavDone = true
248 navController.navigate(Destinations.browse(preferred, "0")) {
249 popUpTo(Destinations.WELCOME) { inclusive = true }
250 }
251 navController.navigate(reopenRoute)
252 return@LaunchedEffect
253 }
254 if (current != null && current != Destinations.WELCOME) {
255 // Maintainer, 2026-10-29: Welcome may still be in the stack below.
256 startupResolved = true
257 return@LaunchedEffect
258 }
259 autoNavDone = true
260 navController.navigate(Destinations.browse(preferred, "0")) {
261 popUpTo(Destinations.WELCOME) { inclusive = true }
262 }
263 }
264
265 // Bar stays visible whenever a track is loaded — including the
266 // post-Stop state. Hiding it on Stop strands the user: they can
267 // browse but can't resume the track, can't reach the NowPlaying
268 // screen via the album-art tap, and can't see what was last
269 // playing. Stop now means "pause at 0 and leave the affordance"
270 // (the Play button is enabled because isStopped is still
271 // available to the bar's children for visual treatment if they
272 // want it). Bar still hides on the NowPlaying screen itself
273 // (would be redundant) and when nothing has ever been loaded.
274 // Observer-aware: barState (defined above) is the observed session's
275 // state while following, local otherwise. Keying visibility on the
276 // LOCAL state made the bar invisible whenever the local queue was
277 // empty — which, after a cache clear, is ALWAYS — so starting an
278 // observation from the Browse cast picker left the user with a
279 // working observation and no visible NPB and therefore no route to
280 // the NowPlaying screen at all (field report: "screen flashed and
281 // nothing... dumped back to browse with no visible NPB").
282 val barIsVisible = barState.current != null
283 && currentRoute != Destinations.NOW_PLAYING
284
285 val focusManager = LocalFocusManager.current
286 // Walks up ContextWrapper chain to find the hosting Activity. LocalContext returns
287 // whatever Context was set — on Hilt-injected Activities this is a wrapped instance,
288 // so we unwrap. Used by Settings → Exit.
289 val activity = run {
290 var ctx: android.content.Context? = LocalContext.current
291 while (ctx is android.content.ContextWrapper) {
292 if (ctx is android.app.Activity) return@run ctx
293 ctx = ctx.baseContext
294 }
295 null
296 }
297
298 // Captured once so closures (like the browse-root onBack) can read it
299 // without needing to be @Composable themselves.
300 val isCompact = isCompactWidth()
301
302 // Last-focused content row. Content screens (Browse's list, etc.)
303 // write their row FRs here on focus gain; the NPB's Up handler reads
304 // this to restore focus to exactly where the user was, instead of
305 // relying on Compose's spatial focus which can land anywhere nearby.
306 val lastContentFocus = remember { mutableStateOf<FocusRequester?>(null) }
307
308 CompositionLocalProvider(
309 LocalMarqueeEnabled provides marqueeEnabled,
310 LocalLastContentFocus provides lastContentFocus,
311 ) {
312 Box(
313 Modifier
314 .fillMaxSize()
315 .background(SurfaceBase)
316 // D-pad Left shortcut to the NowPlayingBar. onKeyEvent (post-order, inner
317 // handlers run first). This lets screen-specific Left handlers — notably
318 // Browse's Queue button arming the search field — consume the event before
319 // we intervene. If nothing inner consumed it, we try focusManager.moveFocus(Left);
320 // if that fails too (we're at a genuine leftmost dead-end), fall back to
321 // jumping focus to the bar's play/pause button.
322 .onKeyEvent { event ->
323 if (!barIsVisible || barHasFocus) return@onKeyEvent false
324 if (event.type != KeyEventType.KeyDown) return@onKeyEvent false
325 when (event.key) {
326 Key.DirectionLeft -> {
327 val moved = focusManager.moveFocus(FocusDirection.Left)
328 if (!moved) runCatching { barPlayFocus.requestFocus() }
329 true
330 }
331 Key.DirectionUp -> {
332 // At the dead-top of content, wrap to the NPB.
333 val moved = focusManager.moveFocus(FocusDirection.Up)
334 if (!moved) runCatching { barPlayFocus.requestFocus() }
335 moved || true
336 }
337 else -> false
338 }
339 },
340 ) {
341 // systemBarsPadding only on compact (phone). TV must NOT have this
342 // applied: the original TV layout draws to all four edges, and
343 // changing that is a regression. Even though Android TV typically
344 // reports zero system-bar insets (so the padding would be zero),
345 // we keep it conditional to be unambiguously safe.
346 // systemBarsPadding on compact (phone) AND large-portrait
347 // (Chromebase/tablet in portrait — the ChromeOS shelf/nav inset
348 // was cutting the bottom half of the NowPlaying bar without it).
349 // TV must NOT have this applied: the original TV layout draws to
350 // all four edges, and isLargePortrait() explicitly excludes TV.
351 // Gate rewritten from an allowlist (compact ∨ large-portrait) to
352 // the rule it always meant: EVERY touch form factor gets system-
353 // bar padding; only TV draws edge-to-edge. The allowlist missed
354 // tablet landscape (neither compact-width nor large-portrait), so
355 // the NowPlayingBar sat under the tablet's bottom system bar on
356 // every screen (field-reported from the lyrics screen). TV is
357 // excluded by explicit uiMode check, preserving its byte-for-byte
358 // edge-to-edge layout.
359 val navCfg = androidx.compose.ui.platform.LocalConfiguration.current
360 val navIsTv = (navCfg.uiMode and android.content.res.Configuration.UI_MODE_TYPE_MASK) ==
361 android.content.res.Configuration.UI_MODE_TYPE_TELEVISION
362 val rootModifier = if (!navIsTv) {
363 Modifier.fillMaxSize().systemBarsPadding()
364 } else {
365 Modifier.fillMaxSize()
366 }
367 Column(rootModifier) {
368 NavHost(
369 navController = navController,
370 startDestination = Destinations.WELCOME,
371 modifier = Modifier.weight(1f)
372 ) {
373 composable(Destinations.WELCOME) {
374 WelcomeScreen(
375 onPickServer = { navController.navigate(Destinations.SERVERS) },
376 onOpenSettings = { navController.navigateSingleton(Destinations.SETTINGS) },
377 // Maintainer, 2026-10-29: Cast/Observe entry from Welcome
378 // (opens the NavHost-level picker), and no Welcome
379 // flash before startup auto-nav decides.
380 onOpenCast = { browseCastPickerOpen = true },
381 ready = startupResolved,
382 )
383 }
384 composable(Destinations.SERVERS) {
385 ServersScreen(
386 onServerClick = { udn ->
387 // Clean slate on pick: pop WELCOME and SERVERS both, land on
388 // Browse. Back from Browse now exits the app.
389 // Maintainer, 2026-10-29: Settings → Choose server
390 // no longer drops WELCOME first, so pop the whole
391 // graph — popUpTo(WELCOME) was ignored there and
392 // left the old Browse/Settings underneath.
393 navController.navigate(Destinations.browse(udn, "0")) {
394 popUpTo(navController.graph.id) { inclusive = true }
395 }
396 },
397 onLocalClick = { navController.navigate(Destinations.LOCAL_HUB) },
398 onSettingsClick = { navController.navigateSingleton(Destinations.SETTINGS) },
399 onOpenQueue = { navController.navigateSingleton(Destinations.QUEUE) },
400 // The standalone search page's only entry point (decision,
401 // 2026-09-02: it was wired to nothing).
402 onOpenSearch = { navController.navigate(Destinations.SEARCH) },
403 onOpenRandom = { navController.navigate(Destinations.RANDOM_PICKER) },
404 // Maintainer, 2026-10-29: Servers header Cast action opens
405 // the NavHost-level Cast/Observe picker.
406 onOpenCast = { browseCastPickerOpen = true },
407 )
408 }
409 composable(Destinations.LOCAL_HUB) {
410 dev.stave.ui.local.LocalHubScreen(
411 onOpenAlbums = { navController.navigate(Destinations.LOCAL_ALBUMS) },
412 onOpenArtists = { navController.navigate(Destinations.LOCAL_ARTISTS) },
413 onOpenSongs = { navController.navigate(Destinations.LOCAL_SONGS) },
414 onBack = { navController.popBackStack() },
415 )
416 }
417 composable(Destinations.LOCAL_ALBUMS) {
418 dev.stave.ui.local.LocalAlbumsScreen(
419 onAlbumClick = { id ->
420 navController.navigate(Destinations.localAlbum(id))
421 },
422 onBack = { navController.popBackStack() },
423 )
424 }
425 composable(Destinations.LOCAL_ARTISTS) {
426 dev.stave.ui.local.LocalArtistsScreen(
427 onArtistClick = { id, name ->
428 navController.navigate(Destinations.localArtist(id, name))
429 },
430 onBack = { navController.popBackStack() },
431 )
432 }
433 composable(Destinations.LOCAL_SONGS) {
434 dev.stave.ui.local.LocalSongsScreen(
435 onBack = { navController.popBackStack() },
436 onTrackPlaying = { navController.navigateSingleton(Destinations.NOW_PLAYING) },
437 )
438 }
439 composable(
440 Destinations.LOCAL_GENRE_DETAIL,
441 arguments = listOf(
442 androidx.navigation.navArgument("genreName") { type = androidx.navigation.NavType.StringType }
443 )
444 ) { entry ->
445 val genreName = entry.arguments?.getString("genreName").orEmpty()
446 dev.stave.ui.local.LocalGenreScreen(
447 genreName = genreName,
448 onBack = { navController.popBackStack() },
449 onTrackPlaying = { navController.navigateSingleton(Destinations.NOW_PLAYING) },
450 onAlbumClick = { albumId ->
451 navController.navigate(Destinations.localAlbum(albumId))
452 },
453 )
454 }
455 composable(
456 Destinations.LOCAL_ALBUM,
457 arguments = listOf(
458 androidx.navigation.navArgument("albumId") { type = androidx.navigation.NavType.LongType }
459 )
460 ) { entry ->
461 val albumId = entry.arguments?.getLong("albumId") ?: 0L
462 dev.stave.ui.local.LocalAlbumScreen(
463 albumId = albumId,
464 onBack = { navController.popBackStack() },
465 onTrackPlaying = { navController.navigateSingleton(Destinations.NOW_PLAYING) },
466 )
467 }
468 composable(
469 Destinations.LOCAL_ARTIST,
470 arguments = listOf(
471 androidx.navigation.navArgument("artistId") { type = androidx.navigation.NavType.LongType },
472 androidx.navigation.navArgument("artistName") { type = androidx.navigation.NavType.StringType }
473 )
474 ) { entry ->
475 val artistName = entry.arguments?.getString("artistName").orEmpty()
476 dev.stave.ui.local.LocalArtistScreen(
477 artistName = artistName,
478 onAlbumClick = { id ->
479 navController.navigate(Destinations.localAlbum(id))
480 },
481 onBack = { navController.popBackStack() },
482 onTrackPlaying = { navController.navigateSingleton(Destinations.NOW_PLAYING) },
483 )
484 }
485 composable(Destinations.BROWSE) { entry ->
486 val udn = entry.arguments?.getString("udn").orEmpty()
487 val containerId = entry.arguments?.getString("containerId").orEmpty()
488 // One-shot flag set by NowPlaying's search button when navigating to
489 // Browse. Read once, immediately clear so back-nav to this entry
490 // (from Queue, etc.) doesn't re-trigger Queue-focus.
491 val focusQueueOnEntry = entry.savedStateHandle
492 .remove<Boolean>("focusQueueOnEntry") ?: false
493 BrowseScreen(
494 udn = udn,
495 containerId = containerId,
496 focusQueueOnEntry = focusQueueOnEntry,
497 onContainerClick = { id, isAlbum ->
498 // Local album containers have id "local_album:<id>"
499 // Maintainer, 2026-10-29: BrowseViewModel's local
500 // mode issues "local_album:<id>" while this only
501 // matched "earhorn_local:album:<id>", so a local
502 // album tapped in local-only Browse opened the
503 // server Detail screen and failed. Accept both.
504 val localPrefix = when {
505 id.startsWith(dev.stave.local.LocalMediaScanner.LOCAL_ALBUM) ->
506 dev.stave.local.LocalMediaScanner.LOCAL_ALBUM
507 id.startsWith("local_album:") -> "local_album:"
508 else -> null
509 }
510 val localAlbumId = localPrefix?.let { id.removePrefix(it).toLongOrNull() }
511 if (localAlbumId != null) {
512 navController.navigate(Destinations.localAlbum(localAlbumId))
513 } else if (isAlbum) {
514 navController.navigate(Destinations.detail(udn, id))
515 } else {
516 navController.navigate(Destinations.browse(udn, id))
517 }
518 },
519 onTrackPlay = { tracks, index ->
520 nowPlayingVm.playTracks(tracks, index) { navController.navigateSingleton(Destinations.NOW_PLAYING) }
521 },
522 onEnqueueTrack = { track ->
523 nowPlayingVm.enqueueTracks(listOf(track))
524 },
525 onEnqueueAndPlayTrack = { track ->
526 nowPlayingVm.enqueueAndPlay(listOf(track)) { navController.navigateSingleton(Destinations.NOW_PLAYING) }
527 },
528 onMoreByArtist = { artist ->
529 navController.navigate(
530 Destinations.more(udn, MoreMode.ARTIST.name, artist)
531 )
532 },
533 onTracksByArtist = { artist ->
534 navController.navigate(
535 Destinations.more(udn, MoreMode.TRACKS_BY_ARTIST.name, artist)
536 )
537 },
538 onShowMetadata = { track ->
539 navHostVm.metadataHandoff.pending = track
540 navController.navigateSingleton(Destinations.METADATA)
541 },
542 queueIsEmpty = playbackState.queue.isEmpty(),
543 onBack = {
544 if (containerId == "0") {
545 // Browse root can't pop — nothing below.
546 // On TV (LEANBACK), exit is the expected
547 // behavior. On phone (portrait OR landscape),
548 // go to Now Playing so back gesture doesn't
549 // accidentally exit.
550 // Maintainer, 2026-10-29: TV test is now the same
551 // uiMode test as the layout (navIsTv), and on
552 // non-TV Now Playing is used only when something
553 // is loaded or observed — otherwise Back exits,
554 // ending the Browse ⇄ empty Now Playing loop.
555 if (navIsTv || barState.current == null) {
556 activity?.finish()
557 } else {
558 navController.navigateSingleton(Destinations.NOW_PLAYING)
559 }
560 } else {
561 navController.popBackStack()
562 }
563 },
564 onSettingsClick = { navController.navigateSingleton(Destinations.SETTINGS) },
565 onOpenQueue = { navController.navigateSingleton(Destinations.QUEUE) },
566 onSubmitSearch = { q ->
567 navController.navigate(
568 Destinations.more(udn, MoreMode.SEARCH_TITLE.name, q)
569 )
570 },
571 onOpenRandom = { navController.navigate(Destinations.RANDOM_PICKER) },
572 onMusicNow = {
573 // "Music Now" — 42 random tracks, enqueue and play. Uses
574 // loadRandomTracks (queue-replace semantics) since Music Now
575 // is explicitly a party-start action; the confirm sheet in
576 // BrowseScreen gates this when the queue is non-empty.
577 nowPlayingVm.loadRandomTracks(udn, 42) { navController.navigateSingleton(Destinations.NOW_PLAYING) }
578 },
579 onOpenCastPicker = { browseCastPickerOpen = true },
580 isObservingCast = rawCastObservingForBar,
581 observedDeviceName = observedDeviceNameForUi,
582 // Same disarm-and-return rule as the gate's confirm
583 // (review N.43): an armed fixed screen is not yanked
584 // back mid-party; it returns to kiosk when the party stops.
585 onStopObserving = { nowPlayingVm.playbackGate.leaveObservingForLocal() },
586 onFocusBarInfo = {
587 // Up from the search pill / MusicNow, or Left
588 // from the pill: land on the bar's info/expand
589 // zone (art + metadata) — the left edge of the
590 // bar, matching the visual direction. No-op
591 // when the bar isn't visible.
592 if (barIsVisible) {
593 runCatching { barExpandFocus.requestFocus() }
594 }
595 },
596 onFocusBarPlayPause = {
597 // Up from the rest of the header action row
598 // (Queue/Random/Cast/Grid/Settings): land on
599 // the bar's play/pause — the transport is what
600 // those utility buttons sit under.
601 if (barIsVisible) {
602 runCatching { barPlayFocus.requestFocus() }
603 }
604 },
605 onLocalAlbumClick = { albumId ->
606 navController.navigate(Destinations.localAlbum(albumId))
607 },
608 )
609 }
610 composable(Destinations.DETAIL) { entry ->
611 val udn = entry.arguments?.getString("udn").orEmpty()
612 val containerId = entry.arguments?.getString("containerId").orEmpty()
613 DetailScreen(
614 udn = udn,
615 containerId = containerId,
616 onPlay = { tracks, index ->
617 nowPlayingVm.playTracks(tracks, index) { navController.navigateSingleton(Destinations.NOW_PLAYING) }
618 },
619 onEnqueue = { tracks ->
620 nowPlayingVm.enqueueTracks(tracks)
621 },
622 onPlayNext = { tracks ->
623 nowPlayingVm.playNextTracks(tracks)
624 },
625 onEnqueueAndPlay = { tracks ->
626 nowPlayingVm.enqueueAndPlay(tracks)
627 },
628 onMoreByArtist = { artist ->
629 navController.navigate(
630 Destinations.more(udn, MoreMode.ARTIST.name, artist)
631 )
632 },
633 onTracksByArtist = { artist ->
634 navController.navigate(
635 Destinations.more(udn, MoreMode.TRACKS_BY_ARTIST.name, artist)
636 )
637 },
638 onShowMetadata = { track ->
639 // Stash the track in the handoff singleton BEFORE navigating —
640 // MetadataViewModel reads it in its init and clears it.
641 navHostVm.metadataHandoff.pending = track
642 navController.navigateSingleton(Destinations.METADATA)
643 },
644 // One question only while following (review N.53,
645 // design decision 2026-09-02): the gate asks "stop
646 // following & play here?"; the album screen's own
647 // "clear queue?" sheet is skipped in that case.
648 queueIsEmpty = playbackState.queue.isEmpty() || rawCastObservingForBar,
649 onBack = { navController.popBackStack() }
650 )
651 }
652 composable(Destinations.MORE) { entry ->
653 val udn = entry.arguments?.getString("udn").orEmpty()
654 val modeName = entry.arguments?.getString("mode").orEmpty()
655 val query = entry.arguments?.getString("query").orEmpty()
656 // Optional highlight: if the caller passed a container ID (the
657 // now-playing album, when arriving from NowPlayingScreen's
658 // "Albums by …" picker), MoreScreen scrolls to and focuses
659 // that row instead of row 0. Null/empty for callers that
660 // don't pass it (Browse, Detail, Random) — they get default
661 // first-row focus.
662 val highlight = entry.arguments?.getString("highlight")?.takeIf { it.isNotEmpty() }
663 // MoreMode.valueOf throws on unknown strings — guard with runCatching so a
664 // bad deep-link doesn't crash the app.
665 val mode = runCatching { MoreMode.valueOf(modeName) }.getOrDefault(MoreMode.ARTIST)
666 MoreScreen(
667 udn = udn,
668 mode = mode,
669 query = query,
670 highlightContainerId = highlight,
671 onAlbumClick = { id ->
672 navController.navigate(Destinations.detail(udn, id))
673 },
674 onTrackClick = { track ->
675 // Single click = enqueue + play. Non-destructive: existing
676 // queue entries stay; this track appends and playback jumps
677 // to it. Navigate to NowPlaying so the user sees what started.
678 nowPlayingVm.enqueueAndPlay(listOf(track)) { navController.navigateSingleton(Destinations.NOW_PLAYING) }
679 },
680 onPlayAlbum = { id ->
681 nowPlayingVm.playContainer(udn, id) { navController.navigateSingleton(Destinations.NOW_PLAYING) }
682 },
683 onQueueAlbum = { id -> nowPlayingVm.enqueueContainer(udn, id) },
684 onPlayNextAlbum = { id -> nowPlayingVm.playNextContainer(udn, id) },
685 onPlayTracks = { tracks, idx ->
686 nowPlayingVm.playTracks(tracks, idx) { navController.navigateSingleton(Destinations.NOW_PLAYING) }
687 },
688 onEnqueueTracks = { tracks -> nowPlayingVm.enqueueTracks(tracks) },
689 onEnqueueAndPlayTracks = { tracks ->
690 nowPlayingVm.enqueueAndPlay(tracks) { navController.navigateSingleton(Destinations.NOW_PLAYING) }
691 },
692 onPlayNextTracks = { tracks -> nowPlayingVm.playNextTracks(tracks) },
693 onEnqueueAndPlayTrack = { track ->
694 nowPlayingVm.enqueueAndPlay(listOf(track))
695 },
696 onMoreByArtist = { artist ->
697 navController.navigate(
698 Destinations.more(udn, MoreMode.ARTIST.name, artist)
699 )
700 },
701 onTracksByArtist = { artist ->
702 navController.navigate(
703 Destinations.more(udn, MoreMode.TRACKS_BY_ARTIST.name, artist)
704 )
705 },
706 onShowMetadata = { track ->
707 navHostVm.metadataHandoff.pending = track
708 navController.navigateSingleton(Destinations.METADATA)
709 },
710 onBack = { navController.popBackStack() },
711 )
712 }
713 composable(Destinations.NOW_PLAYING) {
714 NowPlayingScreen(
715 activeServerUdn = preferredServerUdn,
716 onMoreByArtist = { artist ->
717 val udn = preferredServerUdn
718 if (udn != null) {
719 val highlight = nowPlayingVm.state.value.current?.parentId
720 navController.navigate(
721 Destinations.more(udn, MoreMode.ARTIST.name, artist, highlight)
722 )
723 } else {
724 // Maintainer, 2026-10-29: was a silent no-op with no server saved.
725 android.widget.Toast.makeText(
726 navContext,
727 "That needs a music server",
728 android.widget.Toast.LENGTH_SHORT,
729 ).show()
730 }
731 },
732 onMoreByGenre = { genre ->
733 val udn = preferredServerUdn
734 if (udn != null) {
735 navController.navigate(Destinations.more(udn, MoreMode.GENRE.name, genre))
736 } else {
737 // Maintainer, 2026-10-29: was a silent no-op with no server saved.
738 android.widget.Toast.makeText(
739 navContext,
740 "That needs a music server",
741 android.widget.Toast.LENGTH_SHORT,
742 ).show()
743 }
744 },
745 onMoreFromThisAlbum = { albumTitle ->
746 val udn = preferredServerUdn
747 if (udn != null) {
748 navController.navigate(
749 Destinations.more(udn, MoreMode.TRACKS_BY_ALBUM.name, albumTitle)
750 )
751 } else {
752 // Local playback — try parentId first, fall back to
753 // searching local albums by title.
754 val parentId = nowPlayingVm.state.value.current?.parentId.orEmpty()
755 val albumId = parentId.removePrefix(
756 dev.stave.local.LocalMediaScanner.LOCAL_ALBUM
757 ).toLongOrNull()
758 if (albumId != null) {
759 navController.navigate(Destinations.localAlbum(albumId))
760 } else {
761 // parentId didn't parse — search by album title
762 scope.launch {
763 val match = navHostVm.localScanner.scanAlbums()
764 .firstOrNull { it.title.equals(albumTitle, ignoreCase = true) }
765 if (match != null) {
766 navController.navigate(Destinations.localAlbum(match.id))
767 }
768 }
769 }
770 }
771 },
772 onOpenQueue = { navController.navigateSingleton(Destinations.QUEUE) },
773 onOpenSearch = {
774 val udn = preferredServerUdn
775 // Maintainer, 2026-10-29: pushing a second Browse root
776 // made Back from it finish the app on TV. Return to
777 // the Browse entry already in the stack and set its
778 // one-shot focus flag; push only when there is none.
779 if (navController.popBackStack(Destinations.BROWSE, inclusive = false)) {
780 navController.currentBackStackEntry
781 ?.savedStateHandle?.set("focusQueueOnEntry", true)
782 } else if (udn != null) {
783 navController.navigate(Destinations.browse(udn, "0"))
784 navController.currentBackStackEntry
785 ?.savedStateHandle?.set("focusQueueOnEntry", true)
786 } else {
787 // Local playback — open Browse with empty UDN. The
788 // search keyboard works with local files (BrowseViewModel
789 // falls back to LocalMediaScanner when UDN is blank).
790 navController.navigate(Destinations.browse("", "0"))
791 navController.currentBackStackEntry
792 ?.savedStateHandle?.set("focusQueueOnEntry", true)
793 }
794 },
795 onOpenLyrics = { navController.navigateSingleton(Destinations.LYRICS) },
796 onShowMetadata = { track ->
797 navHostVm.metadataHandoff.pending = track
798 navController.navigateSingleton(Destinations.METADATA)
799 },
800 onBack = { navController.popBackStack() },
801 // Maintainer, 2026-10-29: exits from the empty Now
802 // Playing screen. Library returns to the Browse
803 // screen already in the stack if there is one;
804 // otherwise opens the saved server's root, or the
805 // server picker when none is saved.
806 onOpenLibrary = {
807 if (!navController.popBackStack(Destinations.BROWSE, inclusive = false)) {
808 val udn = preferredServerUdn
809 if (udn != null) {
810 navController.navigate(Destinations.browse(udn, "0"))
811 } else {
812 navController.navigateSingleton(Destinations.SERVERS)
813 }
814 }
815 },
816 onOpenSettings = { navController.navigateSingleton(Destinations.SETTINGS) },
817 // Maintainer, 2026-10-29: use the NavHost-level VM; the
818 // per-entry default VM died on leaving Now Playing and
819 // cancelled the sleep timer with it.
820 viewModel = nowPlayingVm,
821 )
822 }
823 composable(Destinations.QUEUE) {
824 QueueScreen(
825 onBack = { navController.popBackStack() },
826 onSavePlaylist = { navController.navigate(Destinations.PLAYLIST_SAVE) },
827 onLoadPlaylist = { navController.navigate(Destinations.PLAYLISTS) },
828 onShowMetadata = { track ->
829 navHostVm.metadataHandoff.pending = track
830 navController.navigateSingleton(Destinations.METADATA)
831 },
832 onMoreByArtist = { artist ->
833 val udn = preferredServerUdn
834 if (udn != null) {
835 navController.navigate(
836 Destinations.more(udn, MoreMode.ARTIST.name, artist)
837 )
838 } else {
839 // Maintainer, 2026-10-29: say why instead of a silent no-op.
840 android.widget.Toast.makeText(
841 navContext,
842 "That needs a music server",
843 android.widget.Toast.LENGTH_SHORT,
844 ).show()
845 }
846 // No-op when there's no preferred server (purely local playback).
847 // Matches the behavior of NowPlayingScreen's onMoreByArtist at this
848 // same site — without a server to browse against, "More by" has
849 // nowhere to go. The action shouldn't have been visible in the long
850 // press menu in that case anyway, but the guard is here as a safety.
851 },
852 // Maintainer, 2026-10-29: empty-queue "Browse library" —
853 // same as Now Playing's onOpenLibrary: back to the Browse
854 // entry in the stack, else the saved server's root, else
855 // the server picker.
856 onBrowse = {
857 if (!navController.popBackStack(Destinations.BROWSE, inclusive = false)) {
858 val udn = preferredServerUdn
859 if (udn != null) {
860 navController.navigate(Destinations.browse(udn, "0"))
861 } else {
862 navController.navigateSingleton(Destinations.SERVERS)
863 }
864 }
865 },
866 )
867 }
868 composable(Destinations.PLAYLISTS) {
869 PlaylistsScreen(
870 onBack = { navController.popBackStack() },
871 onPlaylistChosen = { tracks ->
872 if (tracks.isNotEmpty()) {
873 // Replace the queue with the chosen playlist and start
874 // playback. Same semantics as tapping Play on an album.
875 nowPlayingVm.playTracks(tracks, 0) {
876 // Pop PLAYLISTS off (and anything between it and its
877 // caller — typically Queue), then singleton-navigate to
878 // NowPlaying. Two-step because navigateSingleton provides
879 // its own NavOptionsBuilder and can't take a trailing one.
880 navController.popBackStack(
881 Destinations.PLAYLISTS,
882 inclusive = true,
883 )
884 navController.navigateSingleton(Destinations.NOW_PLAYING)
885 }
886 } else {
887 // Maintainer, 2026-10-29: was a silent no-op.
888 android.widget.Toast.makeText(
889 navContext,
890 "That playlist is empty or couldn't be read",
891 android.widget.Toast.LENGTH_SHORT,
892 ).show()
893 }
894 },
895 )
896 }
897 composable(Destinations.PLAYLIST_SAVE) {
898 SavePlaylistScreen(
899 onSaved = { navController.popBackStack() },
900 onBack = { navController.popBackStack() },
901 )
902 }
903 composable(Destinations.SEARCH) {
904 // Maintainer, 2026-10-29: with no saved server, Submit did
905 // nothing. Browse("", "0") can't be handed the typed query
906 // (its route has no query arg), so say why and close.
907 val searchContext = LocalContext.current
908 SearchScreen(
909 onSubmit = { query ->
910 val udn = preferredServerUdn
911 if (udn == null) {
912 android.widget.Toast.makeText(
913 searchContext,
914 "Pick a server first to search it",
915 android.widget.Toast.LENGTH_SHORT,
916 ).show()
917 navController.popBackStack()
918 return@SearchScreen
919 }
920 navController.navigate(
921 Destinations.more(udn, MoreMode.SEARCH_TITLE.name, query)
922 ) {
923 // Replace SEARCH in the back stack so pressing Back from the
924 // results page goes to the previous screen (Browse/Servers),
925 // not back to the text field.
926 popUpTo(Destinations.SEARCH) { inclusive = true }
927 }
928 },
929 onBack = { navController.popBackStack() },
930 )
931 }
932 composable(Destinations.RANDOM_PICKER) {
933 // Maintainer, 2026-10-29: for the "nothing found" Toast below.
934 val randomContext = LocalContext.current
935 RandomPickerScreen(
936 onPickAlbums = { count ->
937 val udn = preferredServerUdn
938 if (udn != null) {
939 navController.navigate(
940 Destinations.more(udn, MoreMode.RANDOM_ALBUMS.name, count.toString())
941 ) {
942 popUpTo(Destinations.RANDOM_PICKER) { inclusive = true }
943 }
944 } else {
945 // Local random albums
946 navController.navigate("local_random_albums/$count") {
947 popUpTo(Destinations.RANDOM_PICKER) { inclusive = true }
948 }
949 }
950 },
951 onPickTracks = { count ->
952 val udn = preferredServerUdn
953 if (udn != null) {
954 navController.navigate(
955 Destinations.more(udn, MoreMode.RANDOM_TRACKS.name, count.toString())
956 ) {
957 popUpTo(Destinations.RANDOM_PICKER) { inclusive = true }
958 }
959 } else {
960 // Local random tracks — load into queue and play
961 scope.launch {
962 val scanner = navHostVm.localScanner
963 val tracks = scanner.randomTracks(count)
964 if (tracks.isNotEmpty()) {
965 val didl = tracks.map { it.toDidlTrack() }
966 nowPlayingVm.playTracks(didl, 0) {
967 navController.navigate(Destinations.NOW_PLAYING) {
968 popUpTo(Destinations.RANDOM_PICKER) { inclusive = true }
969 }
970 }
971 } else {
972 // Maintainer, 2026-10-29: no local tracks —
973 // say so instead of doing nothing.
974 android.widget.Toast.makeText(
975 randomContext,
976 "No music found on this device",
977 android.widget.Toast.LENGTH_SHORT,
978 ).show()
979 }
980 }
981 }
982 },
983 onBack = { navController.popBackStack() },
984 )
985 }
986 // Local random albums — shows a grid of randomly selected albums
987 composable(
988 "local_random_albums/{count}",
989 arguments = listOf(
990 androidx.navigation.navArgument("count") { type = androidx.navigation.NavType.IntType }
991 )
992 ) { entry ->
993 val count = entry.arguments?.getInt("count") ?: 25
994 dev.stave.ui.local.LocalRandomAlbumsScreen(
995 count = count,
996 onAlbumClick = { albumId ->
997 navController.navigate(Destinations.localAlbum(albumId))
998 },
999 onBack = { navController.popBackStack() },
1000 )
1001 }
1002 composable(Destinations.SETTINGS) {
1003 SettingsScreen(
1004 onBack = { navController.popBackStack() },
1005 onOpenAbout = { navController.navigate(Destinations.ABOUT) },
1006 onOpenGuide = { navController.navigate(Destinations.GUIDE) },
1007 onOpenPlayback = { navController.navigate(Destinations.PLAYBACK_SETTINGS) },
1008 onChooseServer = {
1009 // Maintainer, 2026-10-29: was forgetAndRescan() + popUpTo(WELCOME)
1010 // inclusive — opening the picker deleted the saved server and
1011 // Back exited the app. Now only rescan (+ random cache drop);
1012 // the preference changes when the user picks a server, and
1013 // Back from the picker returns to Settings.
1014 serversVm.rescanForPick()
1015 navController.navigate(Destinations.SERVERS)
1016 },
1017 onExit = {
1018 // design decision 2026-09-02: "Close the app
1019 // completely" means completely. Stop local
1020 // playback, drop any cast session, release the
1021 // observed house session (the screen stops
1022 // listening until kiosk sends it out again), stop
1023 // the background service, then finish.
1024 scope.launch {
1025 // The LOCAL player only. nowPlayingVm.stop()
1026 // routes to the group while following — Exit
1027 // must never send STOP to the group
1028 // (review C.8, 2026-09-02).
1029 runCatching { nowPlayingVm.stopLocalOnly() }
1030 runCatching { nowPlayingVm.rawCast.disconnect(clearPersisted = true) }
1031 runCatching { nowPlayingVm.castIntegration.disconnect() }
1032 activity?.let { a ->
1033 runCatching {
1034 a.stopService(android.content.Intent(a, dev.stave.playback.PlaybackService::class.java))
1035 }
1036 }
1037 activity?.finishAndRemoveTask()
1038 }
1039 },
1040 )
1041 }
1042 composable(Destinations.PLAYBACK_SETTINGS) {
1043 PlaybackSettingsScreen(onBack = { navController.popBackStack() })
1044 }
1045 composable(Destinations.ABOUT) {
1046 AboutScreen(onBack = { navController.popBackStack() })
1047 }
1048 composable(Destinations.GUIDE) {
1049 GuideScreen(onBack = { navController.popBackStack() })
1050 }
1051 composable(Destinations.METADATA) {
1052 MetadataScreen(onBack = { navController.popBackStack() })
1053 }
1054 composable(Destinations.LYRICS) {
1055 LyricsScreen(
1056 onBack = { navController.popBackStack() },
1057 onRequestPlayPauseFocus = {
1058 if (barIsVisible) {
1059 runCatching { barPlayFocus.requestFocus() }
1060 }
1061 },
1062 )
1063 }
1064 }
1065
1066 if (barIsVisible) {
1067 NowPlayingBar(
1068 state = barState,
1069 onExpand = { navController.navigateSingleton(Destinations.NOW_PLAYING) },
1070 onTogglePlay = {
1071 if (rawCastObservingForBar) scope.launch {
1072 if (barState.isPlaying) nowPlayingVm.rawCast.pause()
1073 else nowPlayingVm.rawCast.play()
1074 } else nowPlayingVm.togglePlay()
1075 },
1076 onStop = {
1077 if (rawCastObservingForBar) scope.launch { nowPlayingVm.rawCast.stop() }
1078 else nowPlayingVm.stop()
1079 },
1080 onPrev = {
1081 if (rawCastObservingForBar) scope.launch { nowPlayingVm.rawCast.previous() }
1082 else nowPlayingVm.previous()
1083 },
1084 onNext = {
1085 if (rawCastObservingForBar) scope.launch { nowPlayingVm.rawCast.next() }
1086 else nowPlayingVm.next()
1087 },
1088 onOpenQueue = { navController.navigateSingleton(Destinations.QUEUE) },
1089 playPauseFocusRequester = barPlayFocus,
1090 expandFocusRequester = barExpandFocus,
1091 // Up and Down from the bar both try the last-focused
1092 // content row FIRST (restores the user to exactly
1093 // where they were), then fall back to Compose's
1094 // spatial focus resolver when that FR has been
1095 // disposed (e.g. scrolled out of a LazyColumn).
1096 onUpFromBar = {
1097 val tracked = lastContentFocus.value
1098 val restored = tracked != null &&
1099 runCatching { tracked.requestFocus() }.isSuccess
1100 if (!restored) {
1101 runCatching { focusManager.moveFocus(FocusDirection.Up) }
1102 }
1103 },
1104 onDownFromBar = {
1105 // Down from bar → top of content. moveFocus(Up)
1106 // goes to the content ABOVE the bar (which is
1107 // visually above since the bar is at the bottom).
1108 runCatching { focusManager.moveFocus(FocusDirection.Up) }
1109 },
1110 // hasFocus, not isFocused — the bar itself isn't focusable, only its
1111 // button children are. We want true whenever ANY child has focus so the
1112 // Left intercept stands down and inter-button Left-nav works.
1113 modifier = Modifier.onFocusChanged { barHasFocus = it.hasFocus },
1114 )
1115 }
1116 }
1117
1118 // Universal local-playback gate confirmation. ANY local-playback
1119 // starter (play album, track tap, Music Now, local library, Play
1120 // Next into an empty queue) invoked while following a Cast session
1121 // parks the action in LocalPlaybackGate and raises this sheet.
1122 val playGateVisible by nowPlayingVm.playbackGate.confirmVisible.collectAsState()
1123 if (playGateVisible) {
1124 dev.stave.ui.components.ActionSheet(
1125 title = "Following ${observedDeviceNameForUi ?: "a Cast device"}",
1126 subtitle = "You're listening to a Cast session. Stop following " +
1127 "and play on this device instead?",
1128 actions = listOf(
1129 dev.stave.ui.components.SheetAction("▶", "Stop following & play here") {
1130 nowPlayingVm.playbackGate.confirm()
1131 },
1132 ),
1133 onDismiss = { nowPlayingVm.playbackGate.dismiss() },
1134 )
1135 }
1136
1137 // Cast/Observe picker overlay — opened from the Browse header Cast
1138 // action. Mounted at NavHost level (above the nav content) so it works
1139 // regardless of which screen is showing and with an empty queue.
1140 if (navIsTv) {
1141 dev.stave.ui.cast.RawCastPickerSheet(
1142 rawCast = nowPlayingVm.rawCast,
1143 visible = browseCastPickerOpen,
1144 onDismiss = { browseCastPickerOpen = false },
1145 onObserveStarted = {
1146 navController.navigateSingleton(Destinations.NOW_PLAYING)
1147 },
1148 )
1149 } else {
1150 dev.stave.ui.cast.CastPickerSheet(
1151 cast = nowPlayingVm.castIntegration,
1152 visible = browseCastPickerOpen,
1153 onDismiss = { browseCastPickerOpen = false },
1154 rawCast = nowPlayingVm.rawCast,
1155 onObserveStarted = {
1156 navController.navigateSingleton(Destinations.NOW_PLAYING)
1157 },
1158 )
1159 }
1160 }
1161 }
1162}
1163
1164/**
1165 * Navigate to [route] as a singleton — if it's already in the back stack, pop
1166 * everything above it and reveal that existing instance; if not, push fresh.
1167 *
1168 * This solves the accumulating-instances problem. Without it, opening
1169 * NowPlaying → Lyrics → Back → NowPlaying → Lyrics → Back (etc.) stacks up
1170 * duplicate NowPlaying entries; pressing Back repeatedly then walks through
1171 * all those stale NowPlaying/Lyrics/etc. screens instead of taking the user
1172 * to a meaningful earlier place.
1173 *
1174 * Implementation note: uses [popBackStack] + [navigate] rather than
1175 * `navigate(route) { popUpTo(route) { inclusive = false } }`. The
1176 * popUpTo-inside-navigate form triggers exit animations on every popped
1177 * destination, which briefly render during teardown and produce a visible
1178 * "flutter" of intermediate pages as the stack unwinds. popBackStack pops
1179 * synchronously without transition, so the user sees a clean cut to the
1180 * existing instance.
1181 *
1182 * popBackStack returns true if it found the route and popped to it (in which
1183 * case we're done — the existing instance is now the current destination).
1184 * If it returns false, the route wasn't in the stack at all, so we push a
1185 * fresh instance.
1186 *
1187 * Use this for "main page" destinations (NowPlaying, Queue, Settings,
1188 * Lyrics, Metadata) where there should only ever be ONE in the stack. Do
1189 * NOT use for drill-down destinations (Detail, More, Browse-subcategory)
1190 * where stacking is the whole point of Back navigation.
1191 */
1192private fun androidx.navigation.NavController.navigateSingleton(route: String) {
1193 if (!popBackStack(route, inclusive = false)) {
1194 navigate(route)
1195 }
1196}