StaveNavHost.kt
Each line, with the release that last changed it.
| 0.5.0 | 1 | package dev.stave.ui.nav |
| 2 | ||
| 3 | import androidx.compose.foundation.background | |
| 4 | import androidx.compose.foundation.layout.Box | |
| 5 | import androidx.compose.foundation.layout.Column | |
| 6 | import androidx.compose.foundation.layout.fillMaxSize | |
| 7 | import androidx.compose.foundation.layout.systemBarsPadding | |
| 8 | import androidx.compose.runtime.Composable | |
| 9 | import androidx.compose.runtime.CompositionLocalProvider | |
| 10 | import androidx.compose.runtime.LaunchedEffect | |
| 11 | import androidx.compose.runtime.collectAsState | |
| 12 | import androidx.compose.runtime.getValue | |
| 13 | import androidx.compose.runtime.rememberCoroutineScope | |
| 14 | import dev.stave.local.toDidlTrack | |
| 15 | import kotlinx.coroutines.launch | |
| 16 | import androidx.compose.runtime.mutableStateOf | |
| 17 | import androidx.compose.runtime.remember | |
| 18 | import androidx.compose.runtime.setValue | |
| 19 | import androidx.compose.ui.Modifier | |
| 20 | import androidx.compose.ui.focus.FocusDirection | |
| 21 | import androidx.compose.ui.focus.FocusRequester | |
| 22 | import androidx.compose.ui.focus.onFocusChanged | |
| 23 | import androidx.compose.ui.input.key.Key | |
| 24 | import androidx.compose.ui.input.key.KeyEventType | |
| 25 | import androidx.compose.ui.input.key.key | |
| 26 | import androidx.compose.ui.input.key.onKeyEvent | |
| 27 | import androidx.compose.ui.input.key.type | |
| 28 | import androidx.compose.ui.platform.LocalContext | |
| 29 | import androidx.compose.ui.platform.LocalFocusManager | |
| 30 | import androidx.hilt.navigation.compose.hiltViewModel | |
| 31 | import androidx.navigation.compose.NavHost | |
| 32 | import androidx.navigation.compose.composable | |
| 33 | import androidx.navigation.compose.currentBackStackEntryAsState | |
| 34 | import androidx.navigation.compose.rememberNavController | |
| 35 | import dev.stave.ui.about.AboutScreen | |
| 36 | import dev.stave.ui.browse.BrowseScreen | |
| 37 | import dev.stave.ui.components.LocalMarqueeEnabled | |
| 38 | import dev.stave.ui.detail.DetailScreen | |
| 39 | import dev.stave.ui.guide.GuideScreen | |
| 40 | import dev.stave.ui.lyrics.LyricsScreen | |
| 41 | import dev.stave.ui.metadata.MetadataScreen | |
| 42 | import dev.stave.ui.more.MoreMode | |
| 43 | import dev.stave.ui.more.MoreScreen | |
| 44 | import dev.stave.ui.nowplaying.NowPlayingBar | |
| 45 | import dev.stave.ui.nowplaying.NowPlayingScreen | |
| 46 | import dev.stave.ui.nowplaying.NowPlayingViewModel | |
| 47 | import dev.stave.ui.playlist.PlaylistsScreen | |
| 48 | import dev.stave.ui.playlist.SavePlaylistScreen | |
| 49 | import dev.stave.ui.queue.QueueScreen | |
| 50 | import dev.stave.ui.random.RandomPickerScreen | |
| 51 | import dev.stave.ui.search.SearchScreen | |
| 52 | import dev.stave.ui.servers.ServersScreen | |
| 53 | import dev.stave.ui.servers.ServersViewModel | |
| 54 | import dev.stave.ui.settings.PlaybackSettingsScreen | |
| 55 | import dev.stave.ui.settings.SettingsScreen | |
| 56 | import dev.stave.ui.settings.SettingsViewModel | |
| 57 | import dev.stave.ui.theme.SurfaceBase | |
| 58 | import dev.stave.ui.theme.isCompactWidth | |
| 59 | import dev.stave.ui.welcome.WelcomeScreen | |
| 60 | import kotlinx.coroutines.flow.Flow | |
| 61 | import kotlinx.coroutines.flow.filterNotNull | |
| 62 | import kotlinx.coroutines.flow.first | |
| 63 | ||
| 64 | @Composable | |
| 65 | fun 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 | */ | |
| 1192 | private fun androidx.navigation.NavController.navigateSingleton(route: String) { | |
| 1193 | if (!popBackStack(route, inclusive = false)) { | |
| 1194 | navigate(route) | |
| 1195 | } | |
| 1196 | } |


