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

NowPlayingScreen.kt

3379 lines · 160.0 KB

Changed in: 0.5.0

1package dev.stave.ui.nowplaying
2
3import androidx.activity.compose.BackHandler
4import androidx.compose.foundation.ExperimentalFoundationApi
5import androidx.compose.foundation.Image
6import androidx.compose.foundation.background
7import androidx.compose.foundation.border
8import androidx.compose.foundation.clickable
9import androidx.compose.foundation.verticalScroll
10import androidx.compose.foundation.combinedClickable
11import androidx.compose.foundation.focusable
12import androidx.compose.foundation.gestures.detectHorizontalDragGestures
13import androidx.compose.foundation.gestures.detectTapGestures
14import androidx.compose.foundation.interaction.MutableInteractionSource
15import androidx.compose.foundation.interaction.collectIsFocusedAsState
16import androidx.compose.foundation.layout.Arrangement
17import androidx.compose.foundation.layout.Box
18import androidx.compose.foundation.layout.BoxWithConstraints
19import androidx.compose.foundation.layout.Column
20import androidx.compose.foundation.layout.Row
21import androidx.compose.foundation.layout.Spacer
22import androidx.compose.foundation.layout.aspectRatio
23import androidx.compose.foundation.layout.sizeIn
24import androidx.compose.foundation.layout.fillMaxHeight
25import androidx.compose.foundation.layout.fillMaxSize
26import androidx.compose.foundation.layout.fillMaxWidth
27import androidx.compose.foundation.layout.windowInsetsPadding
28import androidx.compose.foundation.layout.safeDrawing
29import androidx.compose.foundation.layout.only
30import androidx.compose.foundation.layout.WindowInsetsSides
31import androidx.compose.foundation.layout.WindowInsets
32import androidx.compose.foundation.layout.heightIn
33import androidx.compose.foundation.layout.height
34import androidx.compose.foundation.layout.offset
35import androidx.compose.foundation.layout.padding
36import androidx.compose.foundation.layout.systemBarsPadding
37import androidx.compose.foundation.layout.size
38import androidx.compose.foundation.layout.width
39import androidx.compose.foundation.layout.widthIn
40import androidx.compose.foundation.shape.CircleShape
41import androidx.compose.foundation.shape.RoundedCornerShape
42import androidx.compose.material.icons.Icons
43import androidx.compose.material.icons.filled.Album
44import androidx.compose.material.icons.filled.AvTimer
45import androidx.compose.material.icons.filled.Tune
46import androidx.compose.material.icons.filled.Cast
47import androidx.compose.material.icons.filled.CastConnected
48import androidx.compose.material.icons.filled.LocalOffer
49import androidx.compose.material.icons.filled.Mic
50import androidx.compose.material.icons.filled.Pause
51import androidx.compose.material.icons.filled.PlayArrow
52import androidx.compose.material.icons.filled.QueueMusic
53import androidx.compose.material.icons.filled.Repeat
54import androidx.compose.material.icons.filled.RepeatOn
55import androidx.compose.material.icons.filled.RepeatOneOn
56import androidx.compose.material.icons.filled.Search
57import androidx.compose.material.icons.filled.Shuffle
58import androidx.compose.material.icons.filled.ShuffleOn
59import androidx.compose.material.icons.filled.SkipNext
60import androidx.compose.material.icons.filled.SkipPrevious
61import androidx.compose.material.icons.filled.Stop
62import androidx.compose.material3.Icon
63import androidx.compose.runtime.Composable
64import androidx.compose.runtime.DisposableEffect
65import androidx.compose.runtime.LaunchedEffect
66import androidx.compose.runtime.collectAsState
67import androidx.compose.runtime.getValue
68import androidx.compose.runtime.mutableFloatStateOf
69import androidx.compose.runtime.mutableStateOf
70import androidx.compose.runtime.remember
71import androidx.compose.runtime.rememberCoroutineScope
72import kotlinx.coroutines.delay
73import kotlinx.coroutines.launch
74import androidx.compose.runtime.setValue
75import androidx.compose.ui.Alignment
76import androidx.compose.ui.Modifier
77import androidx.compose.ui.draw.clip
78import androidx.compose.ui.layout.ContentScale
79import androidx.compose.ui.draw.drawWithContent
80import androidx.compose.ui.ExperimentalComposeUiApi
81import androidx.compose.ui.focus.FocusRequester
82import androidx.compose.ui.focus.focusProperties
83import androidx.compose.ui.focus.focusRequester
84import androidx.compose.ui.graphics.Color
85import androidx.compose.ui.graphics.painter.Painter
86import androidx.compose.ui.graphics.vector.ImageVector
87import androidx.compose.ui.res.painterResource
88import androidx.compose.ui.input.key.Key
89import androidx.compose.ui.input.key.KeyEventType
90import androidx.compose.ui.input.key.key
91import androidx.compose.ui.input.key.onKeyEvent
92import androidx.compose.ui.input.key.type
93import androidx.compose.ui.input.pointer.pointerInput
94import androidx.compose.ui.platform.LocalConfiguration
95import androidx.compose.ui.text.font.FontWeight
96import androidx.compose.ui.text.style.TextOverflow
97import androidx.compose.ui.layout.SubcomposeLayout
98import androidx.compose.ui.unit.Constraints
99import androidx.compose.ui.unit.dp
100import androidx.compose.ui.unit.sp
101import androidx.hilt.navigation.compose.hiltViewModel
102import androidx.tv.material3.MaterialTheme
103import androidx.tv.material3.Text
104import dev.stave.R
105import dev.stave.domain.model.parseGenres
106import dev.stave.domain.model.RemoteRenderer
107import dev.stave.domain.renderer.RepeatMode
108import dev.stave.ui.components.ActionSheet
109import dev.stave.ui.components.LocalMarqueeEnabled
110import dev.stave.ui.components.SheetAction
111import dev.stave.ui.components.staveFocusable
112import dev.stave.ui.components.AlbumArt
113import dev.stave.ui.components.staveMarquee
114import dev.stave.ui.cast.CastPickerSheet
115import dev.stave.ui.cast.RawCastPickerSheet
116import dev.stave.ui.theme.AccentAmber
117import dev.stave.ui.theme.CastReceiving
118import dev.stave.ui.theme.OnAmber
119import dev.stave.ui.theme.OnMuted
120import dev.stave.ui.theme.OnPrimary
121import dev.stave.ui.theme.OnQuiet
122import dev.stave.ui.theme.SurfaceBase
123import dev.stave.ui.theme.SurfaceDivider
124import dev.stave.ui.theme.SurfaceRaised
125import kotlin.time.Duration
126import kotlin.time.Duration.Companion.milliseconds
127import kotlin.time.Duration.Companion.seconds
128import dev.stave.ui.theme.adaptiveScreenPaddingHorizontal
129import dev.stave.ui.theme.adaptiveScreenPaddingVertical
130import dev.stave.ui.theme.staveInterFamily
131import dev.stave.ui.theme.withInterDigits
132
133@OptIn(ExperimentalFoundationApi::class)
134@Composable
135fun NowPlayingScreen(
136 activeServerUdn: String?,
137 onMoreByArtist: (artistName: String) -> Unit,
138 onMoreByGenre: (genreName: String) -> Unit,
139 /** Open the "more tracks from this album" screen for the album the
140 * current track belongs to. Receives the album TITLE rather than the
141 * parentId, because the parentId on tag-based UPnP servers (MinimServer
142 * in particular) often points to a virtual aggregate container holding
143 * hundreds of items rather than the album as the user thinks of it.
144 * Searching by album title via `upnp:album = "X"` reliably returns
145 * exactly the tracks tagged with that album name. */
146 onMoreFromThisAlbum: (albumTitle: String) -> Unit,
147 onOpenQueue: () -> Unit,
148 onOpenSearch: () -> Unit,
149 onOpenLyrics: () -> Unit,
150 onShowMetadata: (track: dev.stave.domain.model.DidlItem.Track) -> Unit,
151 onBack: () -> Unit,
152 /** Maintainer, 2026-10-29: the empty state had one button (Cast) and no
153 * way to the library or Settings — a dead end. These two give it exits. */
154 onOpenLibrary: (() -> Unit)? = null,
155 onOpenSettings: (() -> Unit)? = null,
156 viewModel: NowPlayingViewModel = hiltViewModel(),
157) {
158 val state by viewModel.state.collectAsState()
159 val activeUdn by viewModel.activeRendererUdn.collectAsState()
160 // Maintainer, 2026-10-29: the remote actually playing (null = this device).
161 // [activeUdn] is only the saved choice — kept for the picker's mark —
162 // and the coordinator stays local while that renderer is undiscovered.
163 val remoteUdn by viewModel.activeRemoteUdn.collectAsState()
164 val renderers by viewModel.renderers.collectAsState()
165 var showRendererPicker by remember { mutableStateOf(false) }
166 BackHandler(onBack = onBack)
167
168 // ── Observer-aware effective state (hoisted from the TV branch) ────
169 // The TV branch below does its own rawCastState swap, but that code
170 // sits AFTER the phone dispatch and AFTER the empty-state early
171 // return. Result before this fix: phone/tablet layouts always
172 // rendered the LOCAL renderer state (observer mode looked completely
173 // dead), and with an empty local queue even the TV never reached its
174 // own swap because `state.current == null` bailed out at the top.
175 val obsActive by viewModel.rawCast.observing.collectAsState()
176 val obsState by viewModel.rawCast.playbackState.collectAsState()
177 val obsDeviceName by viewModel.rawCast.connectedDeviceName.collectAsState()
178 // Voice-cast auto-observe: group named by the OBSERVE intent while
179 // discovery/connect is still in flight (observing not yet true). Drives
180 // the same "Connecting…" placeholder — E2 mandates a harmless connecting
181 // state during the grace window, never an error dialog.
182 val obsPendingName by viewModel.rawCast.pendingTargetName.collectAsState()
183 // Maintainer spec 2026-09-02 (F2 on the screen's face): the observer's
184 // liveness verdict — false once the session is IDLE, the socket has
185 // gone silent past STATUS_SILENCE_MS, or the freeze latch fired. Until
186 // this read existed, hand-back was the only consumer, so a handheld or
187 // unarmed screen kept painting the dead song forever (Classical KC,
188 // four hours, silent house). A dead session now renders the empty
189 // state on every screen; a real status recomputes it and the track
190 // returns.
191 val obsSessionLiveRaw by viewModel.rawCast.sessionLive.collectAsState()
192 // The raw flag flaps to false for under a second at every track
193 // boundary (the receiver reports IDLE before it loads the next item).
194 // The go-home rule has always waited SESSION_DEAD_DEBOUNCE_MS before
195 // believing it; the screen must wait the same, or every track change
196 // tears the face down to the empty state and rebuilds it (decision,
197 // 2026-09-03 — introduced by the F2 read on 2026-09-02). Live is
198 // believed at once; dead only after the debounce.
199 var obsSessionLive by remember { mutableStateOf(obsSessionLiveRaw) }
200 LaunchedEffect(obsSessionLiveRaw) {
201 if (obsSessionLiveRaw) {
202 obsSessionLive = true
203 } else {
204 kotlinx.coroutines.delay(dev.stave.cast.raw.RawCastObserver.SESSION_DEAD_DEBOUNCE_MS)
205 obsSessionLive = false
206 }
207 }
208 // Has THIS observation ever had a live session? Decides the dead-
209 // session label: a session that ended (including a receiver CLOSE,
210 // which nulls obsState.current) reads "Waiting for…", not
211 // "Connecting to…". Reset when observation ends.
212 var obsSawLive by remember { mutableStateOf(false) }
213 LaunchedEffect(obsActive, obsSessionLive) {
214 if (!obsActive) obsSawLive = false else if (obsSessionLive) obsSawLive = true
215 }
216 // Maintainer, 2026-10-29: an observation that has had no live session for
217 // ~20 s (idle device) reads "Waiting for…" instead of "Connecting to…"
218 // forever. Restarts whenever observing or liveness changes.
219 var obsWaitedLong by remember { mutableStateOf(false) }
220 LaunchedEffect(obsActive, obsSessionLive) {
221 obsWaitedLong = false
222 if (obsActive && !obsSessionLive) {
223 delay(20_000L)
224 obsWaitedLong = true
225 }
226 }
227 // Sticky observed track — bridges the brief null between MEDIA_STATUS
228 // updates at track boundaries (same rationale as the TV branch's
229 // lastObservedTrack, applied at dispatch level).
230 var obsSticky by remember { mutableStateOf<dev.stave.domain.model.DidlItem.Track?>(null) }
231 LaunchedEffect(obsActive, obsSessionLive, obsState.current?.id, obsState.current?.title) {
232 if (obsActive && obsSessionLive) obsState.current?.let { obsSticky = it } else obsSticky = null
233 }
234 val effState = if (obsActive) obsState else state
235
236 // ── Radio song metadata (voice memo 2026-08-27) ──────────────
237 // Radio sessions carry no song in the Cast media session, so it is read
238 // from musicnow's /status instead. Polling is deliberately narrow: only
239 // while observing, only while this screen is composed AND the activity
240 // is resumed, and the client latches itself off after one read that
241 // says the session has no station metadata. musicnow serves one request
242 // at a time and other screens already poll it.
243 val uiForeground by viewModel.rawCast.uiForeground.collectAsState()
244 val radioNow by viewModel.radioStatus.now.collectAsState()
245 // Re-arm key: the client latches OFF after repeated reads saying the
246 // session has no station metadata, so something must switch it back on
247 // when the session changes (musicnow moving an already-observed group
248 // from library to radio fires no new OBSERVE, by design).
249 //
250 // The key MUST ignore transient nulls. obsState.current goes null
251 // between MEDIA_STATUS updates — track boundary, session change,
252 // network hiccup — as the TV branch below already documents. An earlier
253 // version keyed a DisposableEffect directly on resourceUrl, so every
254 // one of those nulls re-ran the effect, whose onDispose called stop(),
255 // which cleared the metadata and cancelled the poller. Symptom: the
256 // song appeared for an instant and was replaced by the station name and
257 // the default art, one beat after the cast connected. Hold the last
258 // non-null contentId instead, and keep teardown separate from re-arm.
259 var lastContentKey by remember { mutableStateOf<String?>(null) }
260 val liveContentKey = if (obsActive) obsState.current?.resourceUrl else null
261 LaunchedEffect(liveContentKey) {
262 if (liveContentKey != null) lastContentKey = liveContentKey
263 }
264 @Suppress("LocalVariableName")
265 val NO_POLL_TOKEN = -1L
266 // Interest handle for this composition. Overlapping compositions each
267 // hold their own acquired token; polling survives while ANY holder
268 // remains, so the outgoing composition's release during a nav
269 // transition cannot kill the incoming one's session — the failure the
270 // single-owner design had at 17:11:46.248.
271 val pollToken = remember { longArrayOf(NO_POLL_TOKEN) }
272 // Gated on liveness too (audit 2026-09-02): a dead session has no
273 // station to show, and polling musicnow's /status every 12 s for it
274 // is load on the server for nothing.
275 LaunchedEffect(obsActive, obsSessionLive, obsDeviceName, uiForeground, lastContentKey) {
276 val group = obsDeviceName
277 if (obsActive && obsSessionLive && uiForeground && group != null) {
278 // Acquire the new interest BEFORE releasing the old one so the
279 // holder count never transits zero (a zero-crossing stops the
280 // poller and clears the metadata).
281 val fresh = viewModel.radioStatus.acquire(group)
282 if (pollToken[0] != NO_POLL_TOKEN && pollToken[0] != fresh) {
283 viewModel.radioStatus.release(pollToken[0])
284 }
285 pollToken[0] = fresh
286 } else if (pollToken[0] != NO_POLL_TOKEN) {
287 viewModel.radioStatus.release(pollToken[0])
288 pollToken[0] = NO_POLL_TOKEN
289 }
290 }
291 DisposableEffect(Unit) {
292 onDispose {
293 if (pollToken[0] != NO_POLL_TOKEN) {
294 viewModel.radioStatus.release(pollToken[0])
295 }
296 }
297 }
298
299
300 // Seekability of what's on screen: local playback always seeks; an
301 // observed session seeks per the receiver's caps with the LIVE
302 // override. Passed to every layout's ProgressBar.
303 val obsCaps by viewModel.observedCaps.collectAsState()
304 val seekableNow = if (obsActive) (obsCaps?.canSeek ?: true) else true
305 val track = run {
306 val base = when {
307 obsActive && !obsSessionLive -> null // dead session: nothing to show
308 obsActive -> obsState.current ?: obsSticky
309 else -> state.current
310 }
311 val rn = radioNow
312 // Overlay only when observing and /status reported a station: song
313 // and artist take the primary lines, the station name moves to the
314 // album line (the station's own `album` field is low-confidence —
315 // often the literal "Unknown Album" — so it is not used), and art
316 // is used only when musicnow verified it. Everything else falls
317 // through untouched, which is the "behave exactly as today" path.
318 // Radio overlay applies ONLY to sessions that ARE radio (no
319 // duration). Without this, a stale RadioNow — preserved through
320 // server failures by design — overlaid library playback on the
321 // same group: title fell through, artist/album/art were replaced.
322 // Observed live 2026-08-29 while the server's HTTP was shedding: the
323 // failure-keeps rule made the stale overlay immortal. caps==null
324 // (pre-attach) permits, matching radio's no-duration state.
325 if (obsActive && base != null && rn != null && (obsCaps?.isLive ?: true)) {
326 base.copy(
327 title = rn.title ?: base.title,
328 artist = rn.artist,
329 albumArtist = rn.artist,
330 album = rn.station,
331 // NO fallback to the session's art: for a radio overlay
332 // that art is whatever was on screen before (often the
333 // previous song, or a stale library track), so falling
334 // back leaves a wrong image up until the next art arrives.
335 // Null here draws the normal placeholder, per the memo.
336 albumArtUri = rn.albumArtUrl,
337 )
338 } else base
339 }
340 // Maintainer, 2026-10-29: Dom-Etha panel state, its observer hooks and the
341 // panel itself hoisted above the empty-state early return. A session going
342 // dead mid-use dropped into the empty state and unmounted the panel.
343 // Mounted once here (same slot whether or not a track shows) so it stays
344 // open across the switch. Feeds the observer so hand-back treats an open
345 // panel as "person present" and never yanks the screen mid-use.
346 var domEthaPanelOpen by remember { mutableStateOf(false) }
347 LaunchedEffect(domEthaPanelOpen) {
348 viewModel.rawCast.setUiPanelOpen(domEthaPanelOpen)
349 }
350 DisposableEffect(Unit) {
351 onDispose { viewModel.rawCast.setUiPanelOpen(false) }
352 }
353 val domEthaUrl by viewModel.domEthaUrl.collectAsState()
354 if (domEthaPanelOpen) {
355 domEthaUrl?.let { panelUrl ->
356 dev.stave.ui.dometha.DomEthaPanel(
357 url = panelUrl,
358 onDismiss = { domEthaPanelOpen = false },
359 )
360 }
361 }
362 if (track == null) {
363 // Empty state — nothing in the local queue and not observing (or
364 // observing but the first MEDIA_STATUS hasn't arrived yet). Must
365 // still offer a Cast/Observe entry point: requiring the user to
366 // enqueue a local track just to observe CCA playback is terrible
367 // UX, and on TV this was previously the ONLY way in.
368 var emptyCastPickerOpen by remember { mutableStateOf(false) }
369 val emptyConfig = LocalConfiguration.current
370 val emptyIsTv = (emptyConfig.uiMode and android.content.res.Configuration.UI_MODE_TYPE_MASK) ==
371 android.content.res.Configuration.UI_MODE_TYPE_TELEVISION
372 val emptyCastFocus = remember { FocusRequester() }
373 val emptyCastInteraction = remember { MutableInteractionSource() }
374 LaunchedEffect(Unit) { runCatching { emptyCastFocus.requestFocus() } }
375
376 Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
377 // Scrolls so the buttons are never pushed off a short screen
378 // (phone landscape) — maintainer, 2026-10-29.
379 Column(
380 Modifier.verticalScroll(androidx.compose.foundation.rememberScrollState()),
381 horizontalAlignment = Alignment.CenterHorizontally,
382 ) {
383 coil3.compose.AsyncImage(
384 model = dev.stave.R.drawable.earhorn_gramophone_glow,
385 contentDescription = null,
386 modifier = Modifier.size(160.dp),
387 )
388 Spacer(Modifier.height(16.dp))
389 Text(
390 when {
391 // Maintainer, 2026-10-29: also after ~20 s of no live session.
392 obsActive && !obsSessionLive && (obsSawLive || obsWaitedLong) ->
393 "Waiting for ${obsDeviceName ?: "device"}…"
394 obsActive -> "Connecting to ${obsDeviceName ?: "device"}…"
395 obsPendingName != null -> "Connecting to $obsPendingName…"
396 else -> "Nothing playing."
397 },
398 style = MaterialTheme.typography.headlineSmall,
399 color = OnMuted,
400 )
401 Spacer(Modifier.height(24.dp))
402 // Cast / Observe entry — D-pad focusable on TV, tappable
403 // elsewhere. Opens the platform-appropriate picker.
404 Box(
405 Modifier
406 .focusRequester(emptyCastFocus)
407 .staveFocusable(emptyCastInteraction)
408 .clickable(emptyCastInteraction, indication = null) {
409 emptyCastPickerOpen = true
410 }
411 .background(SurfaceRaised, RoundedCornerShape(8.dp))
412 .padding(horizontal = 24.dp, vertical = 12.dp),
413 ) {
414 Row(
415 verticalAlignment = Alignment.CenterVertically,
416 horizontalArrangement = Arrangement.spacedBy(10.dp),
417 ) {
418 Icon(
419 Icons.Filled.Cast,
420 contentDescription = null,
421 tint = OnPrimary,
422 modifier = Modifier.size(24.dp),
423 )
424 Text(
425 if (emptyIsTv) "Observe a Cast device" else "Cast / Observe",
426 style = MaterialTheme.typography.titleMedium,
427 color = OnPrimary,
428 )
429 }
430 }
431 // Exits from the empty state (decision, 2026-10-29).
432 // Maintainer, 2026-10-29: + "Stop following" while observing,
433 // so an idle observed device doesn't hide the local queue.
434 if (onOpenLibrary != null || onOpenSettings != null || obsActive) {
435 Spacer(Modifier.height(12.dp))
436 Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
437 if (obsActive) {
438 // Same stop-following path as Browse's onStopObserving.
439 val stopFollowInteraction = remember { MutableInteractionSource() }
440 Box(
441 Modifier
442 .staveFocusable(stopFollowInteraction)
443 .clickable(stopFollowInteraction, indication = null) {
444 viewModel.playbackGate.leaveObservingForLocal()
445 }
446 .background(SurfaceRaised, RoundedCornerShape(8.dp))
447 .padding(horizontal = 20.dp, vertical = 12.dp),
448 ) {
449 Text(
450 "Stop following",
451 style = MaterialTheme.typography.titleMedium,
452 color = OnPrimary,
453 )
454 }
455 }
456 if (onOpenLibrary != null) {
457 val libraryInteraction = remember { MutableInteractionSource() }
458 Box(
459 Modifier
460 .staveFocusable(libraryInteraction)
461 .clickable(libraryInteraction, indication = null) { onOpenLibrary() }
462 .background(SurfaceRaised, RoundedCornerShape(8.dp))
463 .padding(horizontal = 20.dp, vertical = 12.dp),
464 ) {
465 Text(
466 "Library",
467 style = MaterialTheme.typography.titleMedium,
468 color = OnPrimary,
469 )
470 }
471 }
472 if (onOpenSettings != null) {
473 val settingsInteraction = remember { MutableInteractionSource() }
474 Box(
475 Modifier
476 .staveFocusable(settingsInteraction)
477 .clickable(settingsInteraction, indication = null) { onOpenSettings() }
478 .background(SurfaceRaised, RoundedCornerShape(8.dp))
479 .padding(horizontal = 20.dp, vertical = 12.dp),
480 ) {
481 Text(
482 "Settings",
483 style = MaterialTheme.typography.titleMedium,
484 color = OnPrimary,
485 )
486 }
487 }
488 }
489 }
490 }
491 }
492 if (emptyIsTv) {
493 RawCastPickerSheet(
494 rawCast = viewModel.rawCast,
495 visible = emptyCastPickerOpen,
496 onDismiss = { emptyCastPickerOpen = false },
497 )
498 } else {
499 CastPickerSheet(
500 cast = viewModel.castIntegration,
501 visible = emptyCastPickerOpen,
502 onDismiss = { emptyCastPickerOpen = false },
503 rawCast = viewModel.rawCast,
504 )
505 }
506 return
507 }
508
509 val playFocus = remember { FocusRequester() }
510 val sheetScope = rememberCoroutineScope()
511 // Design §4.7: initial focus lands on play/pause. Request after composition so the node
512 // is attached. This runs every time `track` appears (including after a navigation return),
513 // which fixes the bug where clicking the Now Playing bar would navigate here but leave
514 // focus floating — user had to press Up blindly to find the transport.
515 // Initial focus on Play/Pause — once per visit. Until 2026-09-02 this
516 // ran on EVERY track identity change, so a person navigating elsewhere on
517 // a fixed screen was yanked back to Play/Pause each time the group
518 // advanced (review N.21; design decision: stop the jump).
519 LaunchedEffect(Unit) {
520 runCatching { playFocus.requestFocus() }
521 }
522
523 // Sleep timer picker visibility
524 var sleepTimerPickerOpen by remember { mutableStateOf(false) }
525 // Maintainer, 2026-10-29: Dom-Etha panel state moved above the empty-state
526 // early return (see there).
527 // Corner-slot decision, computed ONCE here in the dispatcher because
528 // FOUR layouts render a corner button (TV row, phone portrait, phone
529 // landscape, large portrait). The first Dom-Etha build swapped only the
530 // TV row, so other screens — large portrait — never showed the
531 // button at all.
532 // Maintainer, 2026-10-29: domEthaUrl and the panel itself are now read/
533 // mounted above the empty-state early return.
534 val cornerDomEthaSetting by viewModel.cornerButtonDomEtha.collectAsState()
535 val cornerDomEtha = cornerDomEthaSetting && domEthaUrl != null
536 val sleepTimerFocus = remember { FocusRequester() }
537 // dpad-down target from Sleep Timer. Queue is always rendered in the more-
538 // actions row below, so routing the down-press here gives a deterministic
539 // landing — without the override, the default direction-aware focus search
540 // can land in slot 4 (artist) or slot 5 (genre) depending on whether those
541 // conditional buttons are present for the current track.
542 val queueFocus = remember { FocusRequester() }
543
544 // Artist picker visibility — shown when "More albums" is pressed on a
545 // various-artists compilation. Lets the user choose between the track
546 // artist and the album artist (which is typically "Various Artists").
547 var artistPickerOpen by remember { mutableStateOf(false) }
548 val moreByArtistFocus = remember { FocusRequester() }
549
550 // Routing strategy:
551 // - Android TV (uiMode=TELEVISION) → original horizontal Row layout below.
552 // TV needs to remain byte-identical, so anything inside this if-block
553 // leaves the TV path untouched.
554 // - Phone portrait (compact width) → NowPlayingCompact (vertical scroll).
555 // - Phone landscape (wide but not TV) → NowPlayingCompactLandscape, a
556 // horizontal split with the big touch transport buttons on the right.
557 // Previously phone landscape fell through to the TV layout via screen
558 // width alone (~914dp = "expanded"), which produced a usable-on-TV-only
559 // control row of 28dp icon buttons that are unreachable for thumb-tap.
560 val configuration = LocalConfiguration.current
561 val isAndroidTv = (configuration.uiMode and android.content.res.Configuration.UI_MODE_TYPE_MASK) ==
562 android.content.res.Configuration.UI_MODE_TYPE_TELEVISION
563 if (!isAndroidTv) {
564 val isLandscape = configuration.orientation ==
565 android.content.res.Configuration.ORIENTATION_LANDSCAPE
566 val largePortrait = dev.stave.ui.theme.isLargePortrait()
567 // design decision 2026-09-02 (I.40): a remembered "Play through"
568 // choice silently sends every local play to a remote UPnP renderer.
569 // Say so whenever one is selected. The layout tree below is the
570 // 0.4.15 tree — no wrapper at all — unless a remote renderer IS
571 // selected, in which case the layout is hosted in a Box with the
572 // banner overlaid at the top. The earlier always-on Column/weighted-
573 // Box wrapper changed how the large-portrait layout measured its
574 // space and the art stopped filling its field (decision, 2026-09-03).
575 // Maintainer, 2026-10-29: keyed on the ATTACHED remote, not the saved
576 // setting — the banner said "Playing on X" while audio played here.
577 val bannerHost: @Composable (@Composable () -> Unit) -> Unit =
578 if (remoteUdn != null) { content ->
579 Box(Modifier.fillMaxSize()) {
580 content()
581 RemoteRendererBanner(
582 activeUdn = remoteUdn,
583 renderers = renderers,
584 modifier = Modifier.align(Alignment.TopCenter),
585 )
586 }
587 } else { content -> content() }
588 bannerHost {
589 if (largePortrait) {
590 // Large-screen portrait (HP Chromebase, tablet in portrait).
591 // Neither the compact phone layout (cramped on a 21" screen)
592 // nor the TV landscape Row (leaves 40% dead space at the
593 // bottom in portrait) is appropriate. This layout expands
594 // art, text, and controls to fill the generous vertical space.
595 NowPlayingLargePortrait(
596 seekable = seekableNow,
597 cornerDomEtha = cornerDomEtha,
598 onDomEtha = { domEthaPanelOpen = true },
599 track = track,
600 state = effState,
601 viewModel = viewModel,
602 playFocus = playFocus,
603 activeServerUdn = activeServerUdn,
604 onOpenQueue = onOpenQueue,
605 onOpenSearch = onOpenSearch,
606 onOpenLyrics = onOpenLyrics,
607 onMoreByArtist = onMoreByArtist,
608 onMoreByGenre = onMoreByGenre,
609 onMoreFromThisAlbum = onMoreFromThisAlbum,
610 onShowMetadata = onShowMetadata,
611 )
612 } else if (isLandscape) {
613 NowPlayingCompactLandscape(
614 seekable = seekableNow,
615 cornerDomEtha = cornerDomEtha,
616 onDomEtha = { domEthaPanelOpen = true },
617 track = track,
618 state = effState,
619 viewModel = viewModel,
620 playFocus = playFocus,
621 activeServerUdn = activeServerUdn,
622 onOpenQueue = onOpenQueue,
623 onOpenSearch = onOpenSearch,
624 onOpenLyrics = onOpenLyrics,
625 onMoreByArtist = onMoreByArtist,
626 onMoreByGenre = onMoreByGenre,
627 onMoreFromThisAlbum = onMoreFromThisAlbum,
628 onShowMetadata = onShowMetadata,
629 )
630 } else {
631 NowPlayingCompact(
632 seekable = seekableNow,
633 cornerDomEtha = cornerDomEtha,
634 onDomEtha = { domEthaPanelOpen = true },
635 track = track,
636 state = effState,
637 viewModel = viewModel,
638 playFocus = playFocus,
639 activeServerUdn = activeServerUdn,
640 onOpenQueue = onOpenQueue,
641 onOpenSearch = onOpenSearch,
642 onOpenLyrics = onOpenLyrics,
643 onMoreByArtist = onMoreByArtist,
644 onMoreByGenre = onMoreByGenre,
645 onMoreFromThisAlbum = onMoreFromThisAlbum,
646 onShowMetadata = onShowMetadata,
647 )
648 }
649 } // bannerHost
650 return
651 }
652
653 // Cast picker for TV — uses raw protocol (no Cast SDK sender needed)
654 var castPickerOpen by remember { mutableStateOf(false) }
655 val rawCastObserving by viewModel.rawCast.observing.collectAsState()
656 val castDeviceName by viewModel.rawCast.connectedDeviceName.collectAsState()
657 val rawCastState by viewModel.rawCast.playbackState.collectAsState()
658 // Open picker on Cast button tap
659 LaunchedEffect(viewModel) {
660 viewModel.showCastPicker.collect { castPickerOpen = true }
661 }
662
663 val castScope = rememberCoroutineScope()
664
665 // When observing a Cast session via raw protocol, show Cast state.
666 // Persist the last non-null observed track to bridge brief gaps where
667 // rawCastState.current goes null between MEDIA_STATUS updates (track
668 // boundary, network hiccup) — without this, tvTrack falls back to the
669 // local renderer's stale track and AlbumArt redraws with a different URI,
670 // producing a brown-background flash between tracks.
671 val tvState = if (rawCastObserving) rawCastState else state
672 var lastObservedTrack by remember { mutableStateOf<dev.stave.domain.model.DidlItem.Track?>(null) }
673 LaunchedEffect(rawCastObserving, rawCastState.current?.id, rawCastState.current?.title) {
674 if (rawCastObserving) {
675 rawCastState.current?.let { lastObservedTrack = it }
676 } else {
677 lastObservedTrack = null
678 }
679 }
680 // Enriched observed track: rawCast metadata + UPnP-side technical fields
681 // looked up from the user's preferred server. When the lookup hasn't
682 // resolved yet (first ~100ms) enrichedObserved is null and we use the
683 // raw observed track. Empty fields stay null and the metadata row collapses
684 // gracefully.
685 val enrichedObserved by viewModel.observedTrackEnriched.collectAsState()
686 // Radio overlay must be re-applied here: this branch resolves its own
687 // observed track from rawCastState/enrichment and only falls back to
688 // the dispatcher's `track` last, so the overlay applied there would be
689 // bypassed on exactly other screens this feature is for. Polling
690 // lifecycle stays owned by the dispatcher's DisposableEffect.
691 val tvTrackBase = when {
692 rawCastObserving -> enrichedObserved
693 ?: rawCastState.current
694 ?: lastObservedTrack
695 ?: track
696 else -> track
697 }
698 val tvTrack = radioNow.let { rn ->
699 if (rawCastObserving && rn != null && (obsCaps?.isLive ?: true)) tvTrackBase.copy(
700 title = rn.title ?: tvTrackBase.title,
701 artist = rn.artist,
702 albumArtist = rn.artist,
703 album = rn.station,
704 // Same as the dispatcher overlay: no fallback to session art,
705 // which would leave the previous song's image on screen.
706 albumArtUri = rn.albumArtUrl,
707 ) else tvTrackBase
708 }
709
710 Row(Modifier.fillMaxSize().padding(horizontal = adaptiveScreenPaddingHorizontal(), vertical = adaptiveScreenPaddingVertical())) {
711 // Art — left-aligned, square, capped at 420dp so the title Column gets
712 // the overflow on large (4k) viewports.
713 AlbumArt(
714 model = tvTrack.albumArtUri,
715 modifier = Modifier
716 .fillMaxHeight()
717 .aspectRatio(1f)
718 .sizeIn(maxWidth = 420.dp, maxHeight = 420.dp)
719 .clip(RoundedCornerShape(2.dp)),
720 )
721 Spacer(Modifier.width(60.dp))
722
723 // Genre picker state — lives at the Column level so ActionSheet can render
724 // as an overlay without being clipped by the Column's bounds.
725 // All action surfaces below derive from tvTrack — when observing CCA we want
726 // "More by", genre picker, "More from this album" etc. to target what's
727 // playing on the speaker, not the stale local renderer state. When not
728 // observing, tvTrack == track via the (tvState.current ?: track) coalesce.
729 val artistForSearch = tvTrack.albumArtist ?: tvTrack.artist
730 // For Various Artists compilations: if tvTrack.artist and tvTrack.albumArtist are
731 // BOTH present and different, clicking "More albums" should offer an interstitial
732 // letting the user choose which artist to browse. The track artist is the
733 // performer on this specific track; the album artist is whoever curated the
734 // compilation (often literally "Various Artists").
735 val trackArtist = tvTrack.artist
736 val albumArtist = tvTrack.albumArtist
737 val hasMultipleArtists = !trackArtist.isNullOrBlank()
738 && !albumArtist.isNullOrBlank()
739 && !trackArtist.equals(albumArtist, ignoreCase = true)
740 val genres = parseGenres(tvTrack.genre)
741 var genrePickerOpen by remember { mutableStateOf(false) }
742
743 Column(
744 Modifier.weight(1f).fillMaxHeight(),
745 verticalArrangement = Arrangement.Center,
746 ) {
747 val marqueeOn = LocalMarqueeEnabled.current
748 // Title/artist/album: auto-fit block. Marquee path (single-line
749 // scrolling) still uses the fixed-size Texts. Non-marquee path uses
750 // AutoFitTitleBlock which guarantees complete text display by
751 // shrinking all three fields proportionally when combined height
752 // would exceed available space. Differentiation (title > artist =
753 // album) is preserved because the ratio between sizes stays
754 // constant; only the base font size scales down under pressure.
755 val year = tvTrack.date?.let { Regex("""\d{4}""").find(it)?.value }
756 val albumLine = tvTrack.album
757 val byline = tvTrack.artist ?: tvTrack.albumArtist
758 if (marqueeOn) {
759 Text(
760 withInterDigits(tvTrack.title),
761 style = MaterialTheme.typography.headlineMedium,
762 color = OnPrimary,
763 maxLines = 1,
764 overflow = TextOverflow.Ellipsis,
765 modifier = Modifier.staveMarquee(true),
766 )
767 if (!byline.isNullOrBlank()) {
768 Spacer(Modifier.height(16.dp))
769 Text(
770 withInterDigits(byline),
771 style = MaterialTheme.typography.titleLarge,
772 color = OnPrimary,
773 maxLines = 1,
774 overflow = TextOverflow.Ellipsis,
775 modifier = Modifier.staveMarquee(true),
776 )
777 }
778 if (albumLine != null) {
779 Spacer(Modifier.height(16.dp))
780 Text(
781 withInterDigits(albumLine),
782 style = MaterialTheme.typography.titleLarge,
783 color = OnPrimary,
784 maxLines = 1,
785 overflow = TextOverflow.Ellipsis,
786 modifier = Modifier.staveMarquee(true),
787 )
788 }
789 } else {
790 AutoFitTitleBlock(
791 title = tvTrack.title,
792 artist = byline,
793 album = albumLine,
794 )
795 }
796
797 // Metadata row. Order: year · format · sample-rate · bit-depth.
798 // Year used to be suffixed to the album line ("Rubber Factory
799 // (2004)"); moving it down here keeps the album title clean and
800 // groups all numeric/technical facts together. Only non-null items
801 // appear, joined by bullets. Omitted entirely when no fields
802 // resolve. Track Nº used to live here too but moved into the
803 // progress bar's center label so this row is pure recording-tech
804 // facts (year/format/rate/depth) and Track Nº sits with the
805 // playhead — same category as the podcasts app's "Ends: …".
806 val trackNoLabel = tvTrack.trackNumber?.takeIf { it > 0 }?.let { "Track Nº $it" }
807 val format = listOfNotNull(
808 year,
809 tvTrack.mimeType?.let { cleanMimeFormat(it) },
810 tvTrack.sampleFrequency?.let { "${it / 1000.0} kHz" },
811 tvTrack.bitsPerSample?.let { "$it bit" },
812 ).joinToString(" · ")
813 if (format.isNotBlank()) {
814 Spacer(Modifier.height(20.dp))
815 Text(
816 withInterDigits(format),
817 style = MaterialTheme.typography.labelMedium,
818 color = OnQuiet,
819 maxLines = 1,
820 )
821 }
822
823 Spacer(Modifier.height(36.dp))
824
825 ProgressBar(
826 seekable = seekableNow,
827 position = tvState.position,
828 duration = tvState.duration,
829 onSeek = { target -> viewModel.seekTo(target) },
830 centerLabel = trackNoLabel,
831 )
832
833 // ── Renderer output indicator ─────────────────────────
834 // Shows which device is playing. Hidden when local (default).
835 // Maintainer, 2026-10-29: name the ATTACHED remote when there is one;
836 // otherwise the saved choice, labelled as not found below. Still
837 // shown whenever a renderer is saved, so the picker stays reachable.
838 val savedUdn = activeUdn
839 val attachedUdn = remoteUdn
840 val shownUdn = attachedUdn ?: savedUdn
841 val activeRendererName = if (shownUdn != null) {
842 renderers.firstOrNull { it.udn == shownUdn }?.friendlyName
843 ?: shownUdn.removePrefix("uuid:").take(8)
844 } else null
845 if (activeRendererName != null) {
846 Spacer(Modifier.height(8.dp))
847 Box(
848 Modifier
849 .fillMaxWidth()
850 .clickable { showRendererPicker = true },
851 contentAlignment = Alignment.Center,
852 ) {
853 Text(
854 when {
855 rawCastObserving && castDeviceName != null -> "Casting from $castDeviceName"
856 attachedUdn != null -> "Playing on $activeRendererName ▾"
857 // Maintainer, 2026-10-29: saved but not attached —
858 // audio is on this device; don't claim otherwise.
859 else -> "$activeRendererName not found — playing here ▾"
860 },
861 style = MaterialTheme.typography.labelMedium,
862 color = AccentAmber,
863 )
864 }
865 }
866
867 Spacer(Modifier.height(33.dp))
868
869 // TRANSPORT ROW — five equal weighted columns:
870 // [Shuffle/Repeat stacked] | Previous | Play/Pause | Next | [Cast/SleepTimer stacked]
871 // Each slot is weight(1f) so widths are equal; the internal Box in each slot
872 // centers the control. That guarantees Play/Pause is visually dead-center no
873 // matter what the other buttons' intrinsic sizes are.
874 Row(
875 Modifier.fillMaxWidth(),
876 verticalAlignment = Alignment.CenterVertically,
877 ) {
878 // Slot 1: stacked secondary controls
879 Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
880 Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
881 // Maintainer, 2026-10-29: tvState (observed when following
882 // a Cast group), not local `state`, as the phone layouts
883 // already do via effState — icons showed the idle local
884 // flags while presses acted on the group.
885 SmallTransportButton(
886 icon = if (tvState.shuffleEnabled) Icons.Filled.ShuffleOn else Icons.Filled.Shuffle,
887 description = if (tvState.shuffleEnabled) "Shuffle on" else "Shuffle off",
888 tintAmber = tvState.shuffleEnabled,
889 onClick = viewModel::toggleShuffle,
890 )
891 val (repeatIcon, repeatDesc, repeatActive) = when (tvState.repeatMode) {
892 RepeatMode.OFF -> Triple(Icons.Filled.Repeat, "Repeat off", false)
893 RepeatMode.ALL -> Triple(Icons.Filled.RepeatOn, "Repeat all", true)
894 RepeatMode.ONE -> Triple(Icons.Filled.RepeatOneOn, "Repeat one", true)
895 }
896 SmallTransportButton(
897 icon = repeatIcon,
898 description = repeatDesc,
899 tintAmber = repeatActive,
900 onClick = viewModel::cycleRepeat,
901 )
902 }
903 }
904 // Slot 2: previous
905 Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
906 TransportButton(Icons.Filled.SkipPrevious, "Previous") {
907 if (rawCastObserving) castScope.launch { viewModel.rawCast.previous() }
908 else viewModel.previous()
909 }
910 }
911 // Slot 3: play/pause — dead center of the row
912 Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
913 PrimaryTransport(
914 icon = if (tvState.isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
915 contentDescription = if (tvState.isPlaying) "Pause (long-press to stop)" else "Play (long-press to stop)",
916 onClick = {
917 if (rawCastObserving) castScope.launch {
918 if (tvState.isPlaying) viewModel.rawCast.pause() else viewModel.rawCast.play()
919 } else viewModel.togglePlay()
920 },
921 onLongClick = {
922 if (rawCastObserving) castScope.launch { viewModel.rawCast.stop() }
923 else viewModel.stop()
924 },
925 focusRequester = playFocus,
926 )
927 }
928 // Slot 4: next
929 Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
930 TransportButton(Icons.Filled.SkipNext, "Next") {
931 if (rawCastObserving) castScope.launch { viewModel.rawCast.next() }
932 else viewModel.next()
933 }
934 }
935 // Slot 5: stacked Cast + Sleep Timer — symmetric with Slot 1's
936 // stacked Shuffle/Repeat. Cast is the TOP of the stack so that
937 // dpad-right traversal from Play (via Next, slot 4) lands on
938 // Cast first — Cast is by far the more-used of the two and
939 // earns the easier-to-reach position. Sleep Timer below it is
940 // still one dpad-down away. dpad-up from Cast goes to the
941 // ProgressBar; dpad-down from Sleep Timer goes to the more-
942 // actions row below. CastReceiving (blue) tint when observing
943 // — a different cast session is playing elsewhere and we're
944 // following along — vs amber when we (would be) sending.
945 // Distinguishes the two states without needing a different
946 // icon (the CCA chevron still reads as "Cast" in either
947 // direction).
948 Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
949 Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
950 SmallTransportButton(
951 icon = if (rawCastObserving) Icons.Filled.CastConnected else Icons.Filled.Cast,
952 description = when {
953 rawCastObserving && castDeviceName != null -> "Receiving from $castDeviceName"
954 rawCastObserving -> "Receiving cast"
955 else -> "Cast to device"
956 },
957 tintAmber = false,
958 tintOverride = if (rawCastObserving) CastReceiving else null,
959 onClick = { castPickerOpen = true },
960 )
961 val sleepActive by viewModel.sleepTimerMinutesLeft.collectAsState()
962 // Corner slot: maintainer-selectable (computed in the
963 // dispatcher; false when no URL is configured).
964 if (cornerDomEtha) {
965 SmallTransportButton(
966 icon = Icons.Filled.Tune,
967 description = "Dom-Etha panel",
968 tintAmber = domEthaPanelOpen,
969 onClick = { domEthaPanelOpen = true },
970 focusRequester = sleepTimerFocus,
971 nextDown = queueFocus,
972 )
973 } else {
974 SmallTransportButton(
975 icon = Icons.Filled.AvTimer,
976 description = if (sleepActive != null) "Sleep timer: ${sleepActive}m" else "Sleep timer",
977 tintAmber = sleepActive != null,
978 onClick = { sleepTimerPickerOpen = true },
979 focusRequester = sleepTimerFocus,
980 nextDown = queueFocus,
981 )
982 }
983 }
984 }
985 }
986
987 Spacer(Modifier.height(16.dp))
988
989 // MORE-ACTIONS ROW — five equal weighted columns, same pattern as the
990 // transport row above. Each slot Box-centers its control so icons are
991 // visually balanced regardless of glyph intrinsic width. Conditional
992 // buttons (artist/genre) only render when data is available; absent
993 // slots make the remaining ones wider equally.
994 Row(
995 Modifier.fillMaxWidth(),
996 verticalAlignment = Alignment.CenterVertically,
997 ) {
998 Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
999 TransportButton(Icons.Filled.Search, "Search library", onClick = onOpenSearch)
1000 }
1001 Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
1002 // Lyrics — custom VectorDrawable "singer with mic, arm raised".
1003 // Works with Compose tint because it's a VD (not a raster PNG).
1004 TransportButton(
1005 painter = painterResource(R.drawable.ic_lyrics_singer),
1006 description = "Show lyrics",
1007 onClick = onOpenLyrics,
1008 )
1009 }
1010 Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
1011 TransportButton(
1012 icon = Icons.Filled.QueueMusic,
1013 description = "Open queue",
1014 focusRequester = queueFocus,
1015 nextUp = playFocus,
1016 onClick = onOpenQueue,
1017 )
1018 }
1019 Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
1020 if (artistForSearch != null) {
1021 TransportButton(
1022 painter = painterResource(R.drawable.ic_headphones_vinyl),
1023 description = "More by $artistForSearch",
1024 focusRequester = moreByArtistFocus,
1025 ) {
1026 // Always show a picker so the user can choose between
1027 // artist catalog browsing and "more from this album".
1028 artistPickerOpen = true
1029 }
1030 }
1031 }
1032 Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
1033 when (genres.size) {
1034 0 -> Unit
1035 1 -> TransportButton(Icons.Filled.LocalOffer, "More in ${genres[0]}") {
1036 onMoreByGenre(genres[0])
1037 }
1038 else -> TransportButton(Icons.Filled.LocalOffer, "Browse genres") {
1039 genrePickerOpen = true
1040 }
1041 }
1042 }
1043 }
1044 }
1045
1046 if (genrePickerOpen) {
1047 ActionSheet(
1048 title = "Browse by genre",
1049 subtitle = "This track is tagged with multiple genres.",
1050 actions = genres.map { g ->
1051 SheetAction("▸", g) { onMoreByGenre(g) }
1052 },
1053 onDismiss = {
1054 genrePickerOpen = false
1055 // Restore focus to play button — genre button may not exist
1056 // on next recomposition if track changes
1057 sheetScope.launch {
1058 delay(50)
1059 runCatching { playFocus.requestFocus() }
1060 }
1061 },
1062 )
1063 }
1064
1065
1066 RawCastPickerSheet(
1067 rawCast = viewModel.rawCast,
1068 visible = castPickerOpen,
1069 onDismiss = {
1070 castPickerOpen = false
1071 // Restore focus to play button — same pattern as the genre picker
1072 // above. Cast button may not exist on next recomposition if the
1073 // track changes during the picker's lifetime, so play/pause is
1074 // the reliable anchor.
1075 sheetScope.launch {
1076 delay(50)
1077 runCatching { playFocus.requestFocus() }
1078 }
1079 },
1080 )
1081
1082
1083
1084 if (artistPickerOpen) {
1085 ActionSheet(
1086 title = "More from…",
1087 actions = buildList {
1088 // Artist catalog browsing — one entry when track artist ==
1089 // album artist, two when they differ (compilations/VA).
1090 val primaryArtist = trackArtist ?: albumArtist
1091 if (primaryArtist != null) {
1092 add(SheetAction("♫", "Albums by $primaryArtist") {
1093 onMoreByArtist(primaryArtist)
1094 })
1095 }
1096 if (hasMultipleArtists && albumArtist != null) {
1097 add(SheetAction("♫", "Albums by $albumArtist") {
1098 onMoreByArtist(albumArtist)
1099 })
1100 }
1101 // "More tracks from this album" — always present when album
1102 // title is available. Useful for compilations, soundtracks,
1103 // and any "what else is on this disc" intent. On VA
1104 // compilations (track.artist differs from track.albumArtist),
1105 // drop the artist disambiguator — UPnP can't filter by
1106 // albumArtist reliably, so adding upnp:artist = "<this track's
1107 // artist>" would exclude every other artist on the comp.
1108 // For single-artist albums, keep the disambiguator to
1109 // differentiate same-titled albums by different artists.
1110 tvTrack.album?.takeIf { it.isNotBlank() }?.let { albumTitle ->
1111 add(SheetAction("▤", "More tracks from this album") {
1112 val disambiguator = if (hasMultipleArtists) "" else (tvTrack.artist ?: "")
1113 onMoreFromThisAlbum("$albumTitle|||$disambiguator")
1114 })
1115 }
1116 add(SheetAction("ⓘ", "Show id3 tags") {
1117 onShowMetadata(tvTrack)
1118 })
1119 },
1120 onDismiss = {
1121 artistPickerOpen = false
1122 sheetScope.launch {
1123 delay(50)
1124 runCatching { moreByArtistFocus.requestFocus() }
1125 }
1126 },
1127 )
1128 }
1129
1130 // Sleep timer picker
1131 if (sleepTimerPickerOpen) {
1132 val sleepMinutes by viewModel.sleepTimerMinutesLeft.collectAsState()
1133 ActionSheet(
1134 title = if (sleepMinutes != null) "Sleep timer (${sleepMinutes}m left)" else "Sleep timer",
1135 subtitle = "Playback will stop after the selected time.",
1136 actions = buildList {
1137 if (sleepMinutes != null) {
1138 add(SheetAction("✕", "Cancel timer") { viewModel.cancelSleepTimer() })
1139 }
1140 add(SheetAction("⏱", "15 minutes") { viewModel.startSleepTimer(15) })
1141 add(SheetAction("⏱", "30 minutes") { viewModel.startSleepTimer(30) })
1142 add(SheetAction("⏱", "45 minutes") { viewModel.startSleepTimer(45) })
1143 add(SheetAction("⏱", "60 minutes") { viewModel.startSleepTimer(60) })
1144 add(SheetAction("⏱", "90 minutes") { viewModel.startSleepTimer(90) })
1145 add(SheetAction("⏱", "120 minutes") { viewModel.startSleepTimer(120) })
1146 },
1147 onDismiss = {
1148 sleepTimerPickerOpen = false
1149 sheetScope.launch {
1150 delay(50)
1151 runCatching { sleepTimerFocus.requestFocus() }
1152 }
1153 },
1154 )
1155 }
1156 }
1157
1158 // ── Renderer picker dialog ──────────────────────────────────────
1159 if (showRendererPicker) {
1160 androidx.compose.ui.window.Dialog(
1161 onDismissRequest = { showRendererPicker = false },
1162 ) {
1163 Column(
1164 Modifier
1165 .fillMaxWidth()
1166 .background(
1167 dev.stave.ui.theme.SurfaceRaised,
1168 androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
1169 )
1170 .padding(24.dp),
1171 verticalArrangement = Arrangement.spacedBy(8.dp),
1172 ) {
1173 Text(
1174 "Play through",
1175 style = MaterialTheme.typography.titleMedium,
1176 color = OnPrimary,
1177 )
1178 Spacer(Modifier.height(8.dp))
1179 // "This device" option
1180 Box(
1181 Modifier
1182 .fillMaxWidth()
1183 .clickable {
1184 viewModel.setActiveRenderer(null)
1185 showRendererPicker = false
1186 }
1187 .padding(vertical = 10.dp),
1188 ) {
1189 Row(verticalAlignment = Alignment.CenterVertically) {
1190 Text(
1191 if (activeUdn == null) "◉" else "○",
1192 color = AccentAmber,
1193 )
1194 Spacer(Modifier.width(12.dp))
1195 Text("This device", color = OnPrimary)
1196 }
1197 }
1198 // Discovered renderers
1199 renderers.forEach { r ->
1200 Box(
1201 Modifier
1202 .fillMaxWidth()
1203 .clickable {
1204 viewModel.setActiveRenderer(r.udn)
1205 showRendererPicker = false
1206 }
1207 .padding(vertical = 10.dp),
1208 ) {
1209 Row(verticalAlignment = Alignment.CenterVertically) {
1210 Text(
1211 if (activeUdn == r.udn) "◉" else "○",
1212 color = AccentAmber,
1213 )
1214 Spacer(Modifier.width(12.dp))
1215 Column {
1216 Text(r.friendlyName, color = OnPrimary)
1217 Text(
1218 r.udn.removePrefix("uuid:").take(8) + "…",
1219 style = MaterialTheme.typography.bodySmall,
1220 color = OnMuted,
1221 )
1222 }
1223 }
1224 }
1225 }
1226 if (renderers.isEmpty()) {
1227 Text(
1228 "No other Earhorn devices found. Enable mesh in Settings.",
1229 style = MaterialTheme.typography.bodySmall,
1230 color = OnMuted,
1231 )
1232 }
1233 }
1234 }
1235 }
1236}
1237
1238// ── Phone adaptive metadata block ────────────────────────────────────
1239// Direct adaptation of the TV's field-proven AutoFitTitleBlock above:
1240// SubcomposeLayout measures the whole title/artist/album group at scale
1241// 1.0 with UNLIMITED line wrap; if the measured height exceeds the slot
1242// (the weight(1f) leftover on phone portrait/landscape, or unbounded on
1243// large portrait where the art's weighted box absorbs growth), the
1244// entire block rescales proportionally and re-measures. Measurement-
1245// before-placement cannot miss — unlike the previous AutoSizeText-
1246// inside-fractional-cap approach, whose per-text shrink loops failed to
1247// engage under height-capped multi-line constraints (field-reported:
1248// full-size single-line ellipsis eating both a long title and the
1249// album's year on "Events Leading Up To The Collapse Of Detective
1250// Dulllight").
1251// Guarantees:
1252// - ALL information always rendered — unlimited wrap means even 4+
1253// line monsters scale down rather than ellipsize;
1254// - short metadata renders at full size, typographically identical to
1255// the original single-line look;
1256// - in bounded slots the block occupies EXACTLY the leftover, so the
1257// control stack below is immovable by construction.
1258@Composable
1259private fun PhoneAutoFitMetadataBlock(
1260 title: String,
1261 artist: String?,
1262 album: String?,
1263 modifier: Modifier = Modifier,
1264 titleBaseSp: Float = 28f,
1265 artistBaseSp: Float = 22f,
1266 albumBaseSp: Float = 18f,
1267) {
1268 val minScale = 0.40f
1269 // Upper bound for GROWING into surplus space. 3x base puts a short
1270 // title at up to 84sp on a tablet — commanding without becoming a
1271 // billboard, and the visual hierarchy (title > artist > album)
1272 // scales uniformly so it always reads correctly.
1273 val maxScale = 3f
1274
1275 SubcomposeLayout(modifier.fillMaxWidth()) { constraints ->
1276 val widthCx = constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity)
1277
1278 fun composeBlock(scale: Float) = subcompose(scale) {
1279 Column(Modifier.fillMaxWidth()) {
1280 Text(
1281 withInterDigits(title),
1282 style = MaterialTheme.typography.headlineMedium.copy(
1283 fontSize = (titleBaseSp * scale).sp,
1284 // lineHeight MUST scale with fontSize: the theme
1285 // pins a fixed lineHeight, which at non-native
1286 // sizes produced huge inter-line gaps AND broke
1287 // the proportional-rescale height math (content
1288 // overdrew into the transport row; field-
1289 // reported). 1.2x is a tight, consistent leading
1290 // for the Fraunces display faces.
1291 lineHeight = (titleBaseSp * scale * 1.2f).sp,
1292 ),
1293 color = OnPrimary,
1294 maxLines = Int.MAX_VALUE,
1295 softWrap = true,
1296 )
1297 if (!artist.isNullOrBlank()) {
1298 Spacer(Modifier.height((2 * scale).dp))
1299 Text(
1300 withInterDigits(artist),
1301 style = MaterialTheme.typography.titleMedium.copy(
1302 fontSize = (artistBaseSp * scale).sp,
1303 lineHeight = (artistBaseSp * scale * 1.2f).sp,
1304 ),
1305 color = OnPrimary,
1306 maxLines = Int.MAX_VALUE,
1307 softWrap = true,
1308 )
1309 }
1310 if (!album.isNullOrBlank()) {
1311 Spacer(Modifier.height((2 * scale).dp))
1312 Text(
1313 withInterDigits(album),
1314 style = MaterialTheme.typography.titleSmall.copy(
1315 fontSize = (albumBaseSp * scale).sp,
1316 lineHeight = (albumBaseSp * scale * 1.2f).sp,
1317 ),
1318 color = OnMuted,
1319 maxLines = Int.MAX_VALUE,
1320 softWrap = true,
1321 )
1322 }
1323 }
1324 }
1325
1326 val firstPlaceable = composeBlock(1f).first().measure(widthCx)
1327 val availableH = constraints.maxHeight
1328
1329 // Bidirectional adaptive fit. The contract runs BOTH directions:
1330 // oversized metadata shrinks (with a corrective pass), and short
1331 // metadata GROWS to occupy the surplus — a bounded slot with
1332 // "INXS / The One Thing / Live Baby Live" at base size was
1333 // leaving ~25% of a tablet-landscape screen empty (field-
1334 // reported). Growth uses the proportional ceiling first; if
1335 // upscaling causes rewrapping (height jumps non-proportionally),
1336 // a short binary search finds the largest fitting scale. Every
1337 // candidate is MEASURED before placement, so overdraw remains
1338 // impossible in both directions. Each subcomposition slot id is
1339 // a distinct scale value (1f, the ceiling, strictly-narrowing
1340 // midpoints), so no slot is ever composed twice in one pass.
1341 var finalPlaceable = firstPlaceable
1342 if (availableH != Constraints.Infinity) {
1343 if (firstPlaceable.height > availableH) {
1344 // SHRINK — proportional estimate + one corrective pass
1345 // (wrap-quantization can leave the re-measure a line
1346 // over; the correction guarantees fit, bounded by
1347 // minScale).
1348 var sc = (availableH.toFloat() / firstPlaceable.height.toFloat())
1349 .coerceAtLeast(minScale)
1350 finalPlaceable = composeBlock(sc).first().measure(widthCx)
1351 if (finalPlaceable.height > availableH && sc > minScale) {
1352 sc = (sc * availableH.toFloat() / finalPlaceable.height.toFloat())
1353 .coerceAtLeast(minScale)
1354 finalPlaceable = composeBlock(sc).first().measure(widthCx)
1355 }
1356 } else {
1357 // GROW — fill the slot with as much text as fits.
1358 val ceiling = (availableH.toFloat() / firstPlaceable.height.toFloat())
1359 .coerceAtMost(maxScale)
1360 if (ceiling > 1.05f) {
1361 val probe = composeBlock(ceiling).first().measure(widthCx)
1362 if (probe.height <= availableH) {
1363 finalPlaceable = probe
1364 } else {
1365 var lo = 1f
1366 var hi = ceiling
1367 repeat(3) {
1368 val mid = (lo + hi) / 2f
1369 val pl = composeBlock(mid).first().measure(widthCx)
1370 if (pl.height <= availableH) {
1371 finalPlaceable = pl
1372 lo = mid
1373 } else {
1374 hi = mid
1375 }
1376 }
1377 }
1378 }
1379 }
1380 }
1381
1382 val laidOutHeight = if (availableH == Constraints.Infinity) {
1383 finalPlaceable.height
1384 } else {
1385 finalPlaceable.height.coerceAtMost(availableH)
1386 }
1387 layout(finalPlaceable.width, laidOutHeight) {
1388 finalPlaceable.place(0, 0)
1389 }
1390 }
1391}
1392
1393@Composable
1394private fun ProgressBar(
1395 position: Duration,
1396 duration: Duration,
1397 onSeek: (Duration) -> Unit,
1398 centerLabel: String? = null,
1399 /** False on LIVE/observed-unseekable sessions: gestures and d-pad are
1400 * ignored, the knob is hidden, and the right label shows LIVE. The
1401 * receiver may CLAIM seek on LIVE streams (The Bridge does); the
1402 * caller decides, per the caps spec (2026-08-29: dim, don't hide;
1403 * elapsed + LIVE badge; pause stays). */
1404 seekable: Boolean = true,
1405) {
1406 val frac = if (duration.inWholeMilliseconds > 0)
1407 (position.inWholeMilliseconds.toFloat() / duration.inWholeMilliseconds.toFloat()).coerceIn(0f, 1f)
1408 else 0f
1409
1410 val interaction = remember { MutableInteractionSource() }
1411 val isFocused by interaction.collectIsFocusedAsState()
1412 val step = 10.seconds
1413 val barHeight = if (isFocused) 6.dp else 3.dp
1414 val knobSize = 16.dp
1415
1416 Column(
1417 Modifier
1418 .fillMaxWidth()
1419 .padding(vertical = 4.dp)
1420 ) {
1421 // Focusable region is JUST the bar, not the whole Column. The time labels
1422 // underneath live outside this box so the amber focus outline never overlaps
1423 // numeric text. Internal padding gives the knob some breathing room from the
1424 // outline edges rather than having it hug right up against them.
1425 //
1426 // Scrub behaviour works here because NowPlayingScreen hides the NowPlayingBar
1427 // (barIsVisible == false when currentRoute == NOW_PLAYING), so EarhornNavHost's
1428 // global Left-intercept is disarmed and Left/Right reach this key handler.
1429 //
1430 // Touch scrub: tap-to-seek + horizontal drag. Both translate x position
1431 // within the gesture-area's pixel bounds to a fraction of duration.
1432 // Layered on top of the existing D-pad Left/Right handler — TV behaviour
1433 // is unchanged because TV has no touch surface and the touch gesture
1434 // detectors never fire there.
1435 BoxWithConstraints(
1436 Modifier
1437 .fillMaxWidth()
1438 .staveFocusable(interaction, cornerRadiusDp = 6)
1439 .focusable(interactionSource = interaction)
1440 .onKeyEvent { event ->
1441 if (!seekable) return@onKeyEvent false
1442 if (event.type != KeyEventType.KeyDown) return@onKeyEvent false
1443 when (event.key) {
1444 Key.DirectionLeft -> {
1445 val target = (position - step).coerceAtLeast(Duration.ZERO)
1446 if (seekable) onSeek(target)
1447 true
1448 }
1449 Key.DirectionRight -> {
1450 // Clamp to duration-1s so "scrub to near the end" doesn't
1451 // accidentally trip the track-ended logic.
1452 val cap = (duration - 1.seconds).coerceAtLeast(Duration.ZERO)
1453 val target = (position + step).coerceAtMost(cap)
1454 if (seekable) onSeek(target)
1455 true
1456 }
1457 else -> false
1458 }
1459 }
1460 .pointerInput(seekable, duration) {
1461 detectTapGestures { offset ->
1462 val w = size.width.toFloat()
1463 val totalMs = duration.inWholeMilliseconds
1464 if (w > 0f && totalMs > 0L) {
1465 val ratio = (offset.x / w).coerceIn(0f, 1f)
1466 if (seekable) onSeek((totalMs * ratio).toLong().milliseconds)
1467 }
1468 }
1469 }
1470 .pointerInput(seekable, duration) {
1471 detectHorizontalDragGestures { change, _ ->
1472 val w = size.width.toFloat()
1473 val totalMs = duration.inWholeMilliseconds
1474 if (w > 0f && totalMs > 0L) {
1475 val ratio = (change.position.x / w).coerceIn(0f, 1f)
1476 if (seekable) onSeek((totalMs * ratio).toLong().milliseconds)
1477 }
1478 }
1479 }
1480 .padding(horizontal = 8.dp, vertical = 6.dp)
1481 .height(knobSize)
1482 ) {
1483 val totalWidth = maxWidth - 16.dp // subtract horizontal padding we just added
1484 // Knob position math, hoisted out of the focused branch so the fill
1485 // width can align with the knob center even when the clamp is active.
1486 // `centered` is the knob's intended left edge if it were placed so the
1487 // knob center sits at totalWidth*frac; coerceIn clamps to keep the knob
1488 // fully on-canvas at the extremes (otherwise the dot would only show
1489 // its right half at frac=0 and its left half at frac=1).
1490 val centered = totalWidth * frac - knobSize / 2
1491 val knobX = centered.coerceIn(0.dp, (totalWidth - knobSize).coerceAtLeast(0.dp))
1492 val knobCenterX = knobX + knobSize / 2
1493 // Fill end: when focused, match the knob CENTER so the knob's amber
1494 // circle covers the radius-wide strip between fill end and canvas
1495 // edge. Without this, the fill formula (totalWidth*frac) and the
1496 // clamped knob-center diverge by up to knobSize/2 inside the clamp
1497 // zones (frac < ~0.02 or frac > ~0.98), producing a visible
1498 // misalignment between brass fill and knob — same bug family as the
1499 // Earhorn Podcasts TV app fix. When unfocused there's no knob to
1500 // align with, so the fill uses the natural totalWidth*frac.
1501 val fillEnd = if (isFocused) knobCenterX else totalWidth * frac
1502 // Background track + amber fill, centered vertically in the knob-height container.
1503 Box(
1504 Modifier
1505 .align(Alignment.CenterStart)
1506 .fillMaxWidth()
1507 .height(barHeight)
1508 .background(SurfaceDivider)
1509 ) {
1510 Box(
1511 Modifier
1512 .width(fillEnd)
1513 .height(barHeight)
1514 .background(AccentAmber)
1515 )
1516 }
1517 // Playhead knob — only rendered when the bar has focus. X position
1518 // computed above so the fill can align with it.
1519 if (isFocused && seekable) {
1520 Box(
1521 Modifier
1522 .align(Alignment.CenterStart)
1523 .offset(x = knobX)
1524 .size(knobSize)
1525 .clip(CircleShape)
1526 .background(AccentAmber)
1527 )
1528 }
1529 }
1530 Spacer(Modifier.height(4.dp))
1531 // Minimum widths for the two time labels so they don't jostle as the
1532 // ticking digits change width. widthIn (not width) — at unusual system
1533 // font scales the natural width can exceed the min; we want the label
1534 // to grow rather than wrap. maxLines=1 + softWrap=false guarantee a
1535 // single line at any scale. Values are generous worst-case sizes for
1536 // the category, including a glyph's worth of headroom for the leading
1537 // "-" on the remaining label.
1538 val (posMinWidth, remMinWidth) = when {
1539 duration.inWholeHours >= 1 -> 64.dp to 72.dp // "59:59:59" / "-59:59:59"
1540 duration.inWholeMinutes >= 10 -> 44.dp to 52.dp // "59:59" / "-59:59"
1541 else -> 36.dp to 44.dp // "9:59" / "-9:59"
1542 }
1543 // Box layout (not Row + SpaceBetween) so the center label is at the true
1544 // geometric midpoint independent of how wide the side labels render. The
1545 // sides anchor to start/end; the optional center label (e.g. "Track Nº 3")
1546 // sits in between. Matches the pattern from the Earhorn Podcasts app
1547 // ("Ends: 7:08 AM" centered between elapsed and remaining).
1548 Box(Modifier.fillMaxWidth()) {
1549 Text(
1550 position.formatTime(),
1551 style = MaterialTheme.typography.labelMedium.copy(fontFamily = staveInterFamily()),
1552 color = OnMuted,
1553 maxLines = 1,
1554 softWrap = false,
1555 modifier = Modifier
1556 .align(Alignment.CenterStart)
1557 .widthIn(min = posMinWidth),
1558 )
1559 if (centerLabel != null) {
1560 Text(
1561 withInterDigits(centerLabel),
1562 style = MaterialTheme.typography.labelMedium.copy(
1563 fontWeight = FontWeight.SemiBold,
1564 ),
1565 color = OnMuted,
1566 maxLines = 1,
1567 softWrap = false,
1568 modifier = Modifier.align(Alignment.Center),
1569 )
1570 }
1571 val remaining = (duration - position).coerceAtLeast(kotlin.time.Duration.ZERO)
1572 // LIVE: there is no remaining time on an endless stream; the
1573 // right slot carries the badge instead. Elapsed (left) keeps
1574 // ticking — the monotonic anchor makes it accurate.
1575 Text(
1576 if (!seekable && duration <= kotlin.time.Duration.ZERO) "LIVE"
1577 else "-${remaining.formatTime()}",
1578 style = MaterialTheme.typography.labelMedium.copy(fontFamily = staveInterFamily()),
1579 color = OnMuted,
1580 maxLines = 1,
1581 softWrap = false,
1582 modifier = Modifier
1583 .align(Alignment.CenterEnd)
1584 .widthIn(min = remMinWidth),
1585 textAlign = androidx.compose.ui.text.style.TextAlign.End,
1586 )
1587 }
1588 }
1589}
1590
1591@OptIn(ExperimentalComposeUiApi::class)
1592@Composable
1593private fun SmallTransportButton(
1594 icon: ImageVector,
1595 description: String,
1596 tintAmber: Boolean,
1597 onClick: () -> Unit,
1598 focusRequester: FocusRequester? = null,
1599 tintOverride: Color? = null,
1600 nextDown: FocusRequester? = null,
1601) {
1602 val interaction = remember { MutableInteractionSource() }
1603 // 28dp tall × 56dp wide fits two stacked in the same 56dp bounding box a full-size
1604 // TransportButton occupies, with a 2dp gap between them. The 2dp amber focus border
1605 // needs at least 3dp clear of the icon edge to read cleanly, so icon is 20dp inside
1606 // the 28dp box. Amber tint when the state is active so users can see at a glance
1607 // whether repeat/shuffle/sleep-timer are on. tintOverride wins when non-null —
1608 // currently used by Cast in observe-mode (CastReceiving blue). nextDown overrides
1609 // the default direction-aware focus search for D-pad Down — used by Sleep Timer
1610 // to land deterministically on Queue regardless of which conditional buttons
1611 // (artist/genre) render in the more-actions row below.
1612 Box(
1613 Modifier
1614 .size(width = 56.dp, height = 28.dp)
1615 .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
1616 .then(if (nextDown != null) Modifier.focusProperties { down = nextDown } else Modifier)
1617 .staveFocusable(interaction)
1618 .clickable(interaction, indication = null) { onClick() },
1619 contentAlignment = Alignment.Center,
1620 ) {
1621 Icon(
1622 icon,
1623 contentDescription = description,
1624 tint = tintOverride ?: if (tintAmber) AccentAmber else OnPrimary,
1625 modifier = Modifier.size(20.dp),
1626 )
1627 }
1628}
1629
1630@OptIn(ExperimentalComposeUiApi::class)
1631@Composable
1632private fun TransportButton(
1633 icon: ImageVector,
1634 description: String,
1635 focusRequester: FocusRequester? = null,
1636 nextUp: FocusRequester? = null,
1637 onClick: () -> Unit,
1638) {
1639 val interaction = remember { MutableInteractionSource() }
1640 Box(
1641 Modifier
1642 .size(56.dp)
1643 .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
1644 .then(if (nextUp != null) Modifier.focusProperties { up = nextUp } else Modifier)
1645 .staveFocusable(interaction)
1646 .clickable(interaction, indication = null) { onClick() },
1647 contentAlignment = Alignment.Center
1648 ) {
1649 // Explicit 28dp icon size. Default Icon sizing is 24dp and some vectors
1650 // (LocalOffer, Label, Mic) don't fill their glyph box as densely as others
1651 // (QueueMusic, LibraryMusic, Search) — at 24dp they look undersized next to
1652 // their neighbours. 28dp compensates.
1653 Icon(
1654 icon,
1655 contentDescription = description,
1656 tint = OnPrimary,
1657 modifier = Modifier.size(28.dp),
1658 )
1659 }
1660}
1661
1662/** Overload that takes a Painter — for custom VectorDrawables loaded from res/drawable.
1663 * Matches size and tint of the ImageVector variant so all buttons read uniformly.
1664 * Set [tinted]=false for raster art (e.g. the 8-disc stack) that must preserve
1665 * its own colors, outlines, and alpha — tinting flattens it to a silhouette. */
1666@Composable
1667private fun TransportButton(
1668 painter: Painter,
1669 description: String,
1670 tinted: Boolean = true,
1671 focusRequester: FocusRequester? = null,
1672 onClick: () -> Unit,
1673) {
1674 val interaction = remember { MutableInteractionSource() }
1675 Box(
1676 Modifier
1677 .size(56.dp)
1678 .then(
1679 if (focusRequester != null) Modifier.focusRequester(focusRequester)
1680 else Modifier
1681 )
1682 .staveFocusable(interaction)
1683 .clickable(interaction, indication = null) { onClick() },
1684 contentAlignment = Alignment.Center
1685 ) {
1686 if (tinted) {
1687 Icon(
1688 painter = painter,
1689 contentDescription = description,
1690 tint = OnPrimary,
1691 modifier = Modifier.size(28.dp),
1692 )
1693 } else {
1694 Image(
1695 painter = painter,
1696 contentDescription = description,
1697 modifier = Modifier.size(28.dp),
1698 )
1699 }
1700 }
1701}
1702
1703@OptIn(ExperimentalFoundationApi::class)
1704@Composable
1705private fun PrimaryTransport(
1706 icon: ImageVector,
1707 contentDescription: String,
1708 onClick: () -> Unit,
1709 focusRequester: FocusRequester? = null,
1710 onLongClick: (() -> Unit)? = null,
1711) {
1712 val interaction = remember { MutableInteractionSource() }
1713 val focused by interaction.collectIsFocusedAsState()
1714 val shape = RoundedCornerShape(36.dp)
1715 // Inline the focus visual rather than using staveFocusable: its default 2dp AccentAmber
1716 // border is invisible against an AccentAmber fill. Focused state inverts the palette so
1717 // it reads clearly at TV distance — dark field, amber icon, amber ring.
1718 Box(
1719 Modifier
1720 .size(72.dp)
1721 .clip(shape)
1722 .background(if (focused) SurfaceBase else AccentAmber, shape)
1723 .border(
1724 width = if (focused) 2.dp else 0.dp,
1725 color = if (focused) AccentAmber else Color.Transparent,
1726 shape = shape,
1727 )
1728 .let { if (focusRequester != null) it.focusRequester(focusRequester) else it }
1729 .then(
1730 if (onLongClick != null) {
1731 Modifier.combinedClickable(
1732 interactionSource = interaction,
1733 indication = null,
1734 onClick = onClick,
1735 onLongClick = onLongClick,
1736 )
1737 } else {
1738 Modifier.clickable(interaction, indication = null) { onClick() }
1739 }
1740 ),
1741 contentAlignment = Alignment.Center,
1742 ) {
1743 Icon(
1744 imageVector = icon,
1745 contentDescription = contentDescription,
1746 tint = if (focused) AccentAmber else OnAmber,
1747 )
1748 }
1749}
1750
1751private fun Duration.formatTime(): String {
1752 val total = inWholeSeconds
1753 val h = total / 3600
1754 val m = (total % 3600) / 60
1755 val s = total % 60
1756 return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%d:%02d".format(m, s)
1757}
1758
1759/**
1760 * Normalize a UPnP-reported MIME string to a short display format name.
1761 *
1762 * UPnP servers report wildly varying MIME types for the same format. MinimServer says
1763 * "audio/flac", BubbleUPnP says "audio/x-flac", Windows Media says "audio/vnd.wave". We
1764 * strip wrapper prefixes (x-, vnd.) and map a few known weird ones to their canonical
1765 * uppercase name so the Now Playing subtitle stays consistent.
1766 *
1767 * Returns null for blank/garbage input so the caller can omit the field entirely.
1768 */
1769private fun cleanMimeFormat(mime: String): String? {
1770 val subtype = mime.substringAfter('/', missingDelimiterValue = mime)
1771 .substringAfter("x-")
1772 .substringAfter("vnd.")
1773 .lowercase()
1774 .ifBlank { return null }
1775 return when (subtype) {
1776 "mpeg", "mpeg3", "mp3" -> "MP3"
1777 "mp4", "m4a", "aac" -> "AAC"
1778 "flac", "flac+ogg" -> "FLAC"
1779 "ogg", "vorbis", "ogg-vorbis" -> "OGG"
1780 "opus" -> "Opus"
1781 "wav", "wave" -> "WAV"
1782 "aiff", "aif" -> "AIFF"
1783 "alac" -> "ALAC"
1784 "dsd", "dsf" -> "DSD"
1785 else -> subtype.uppercase()
1786 }
1787}
1788
1789/** Title / artist / album block that always shows complete text.
1790 *
1791 * Uses SubcomposeLayout in two passes: measure at default sizes, and if the
1792 * block would overflow the available vertical space, recompose all three
1793 * fields at a uniformly scaled-down font size. The ratio between title and
1794 * artist/album is fixed (title = 1.4× base, artist = album = 1.0× base), so
1795 * visual differentiation is preserved at every scale.
1796 *
1797 * Floor clamp at 0.55× prevents scaling below readable sizes on a TV
1798 * viewed from across a room; if content would still overflow at the floor,
1799 * it clips rather than becoming unreadable. In practice that edge is rare
1800 * (title AND artist AND album all have to be unusually long at once).
1801 */
1802@Composable
1803private fun AutoFitTitleBlock(
1804 title: String,
1805 artist: String?,
1806 album: String?,
1807 modifier: Modifier = Modifier,
1808) {
1809 val titleBaseSp = 28f
1810 val subBaseSp = 20f
1811 val minScale = 0.55f
1812
1813 SubcomposeLayout(modifier.fillMaxWidth()) { constraints ->
1814 val widthCx = constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity)
1815
1816 fun composeBlock(scale: Float) = subcompose(scale) {
1817 Column {
1818 Text(
1819 withInterDigits(title),
1820 style = MaterialTheme.typography.headlineMedium.copy(
1821 fontSize = (titleBaseSp * scale).sp,
1822 ),
1823 color = OnPrimary,
1824 maxLines = Int.MAX_VALUE,
1825 softWrap = true,
1826 )
1827 if (!artist.isNullOrBlank()) {
1828 Spacer(Modifier.height((12 * scale).dp))
1829 Text(
1830 withInterDigits(artist),
1831 style = MaterialTheme.typography.titleLarge.copy(
1832 fontSize = (subBaseSp * scale).sp,
1833 ),
1834 color = OnPrimary,
1835 maxLines = Int.MAX_VALUE,
1836 softWrap = true,
1837 )
1838 }
1839 if (!album.isNullOrBlank()) {
1840 Spacer(Modifier.height((12 * scale).dp))
1841 Text(
1842 withInterDigits(album),
1843 style = MaterialTheme.typography.titleLarge.copy(
1844 fontSize = (subBaseSp * scale).sp,
1845 ),
1846 color = OnPrimary,
1847 maxLines = Int.MAX_VALUE,
1848 softWrap = true,
1849 )
1850 }
1851 }
1852 }
1853
1854 val firstPlaceable = composeBlock(1f).first().measure(widthCx)
1855 val availableH = constraints.maxHeight
1856 val scale = if (
1857 availableH == Constraints.Infinity || firstPlaceable.height <= availableH
1858 ) {
1859 1f
1860 } else {
1861 (availableH.toFloat() / firstPlaceable.height.toFloat())
1862 .coerceAtLeast(minScale)
1863 }
1864
1865 val finalPlaceable = if (scale == 1f) {
1866 firstPlaceable
1867 } else {
1868 composeBlock(scale).first().measure(widthCx)
1869 }
1870
1871 val laidOutHeight = if (availableH == Constraints.Infinity) {
1872 finalPlaceable.height
1873 } else {
1874 finalPlaceable.height.coerceAtMost(availableH)
1875 }
1876 layout(finalPlaceable.width, laidOutHeight) {
1877 finalPlaceable.place(0, 0)
1878 }
1879 }
1880}
1881
1882/**
1883 * Compact-width Now Playing layout. Stacks vertically: art on top, then
1884 * title/artist/album, then progress bar, then a single touch-friendly
1885 * transport row. Skips the secondary actions (sleep timer picker, more-by
1886 * artist sheets, lyrics, etc.) — those remain accessible from the main TV
1887 * layout. The goal here is to produce a working phone Now Playing view that
1888 * fits the available pixels without overflow or off-screen controls.
1889 *
1890 * Touch targets: primary play/pause is 72dp, skip prev/next are 56dp,
1891 * shuffle/repeat/stop are 44dp. All comfortably above Material's 48dp
1892 * minimum tappable for primary, and large enough on a phone screen to be
1893 * reliable without precise aim.
1894 */
1895@OptIn(ExperimentalFoundationApi::class)
1896@Composable
1897private fun NowPlayingCompact(
1898 seekable: Boolean,
1899 cornerDomEtha: Boolean,
1900 onDomEtha: () -> Unit,
1901 track: dev.stave.domain.model.DidlItem.Track,
1902 state: dev.stave.domain.renderer.PlaybackState,
1903 viewModel: NowPlayingViewModel,
1904 playFocus: FocusRequester,
1905 activeServerUdn: String?,
1906 onOpenQueue: () -> Unit,
1907 onOpenSearch: () -> Unit,
1908 onOpenLyrics: () -> Unit,
1909 onMoreByArtist: (artistName: String) -> Unit,
1910 onMoreByGenre: (genreName: String) -> Unit,
1911 onMoreFromThisAlbum: (albumTitle: String) -> Unit,
1912 onShowMetadata: (track: dev.stave.domain.model.DidlItem.Track) -> Unit,
1913) {
1914 // Picker state — sleep timer, artist disambiguation (various-artists
1915 // compilations), and genre picker (when the track has multiple genres).
1916 // ActionSheet renders the dialogs after the main scrollable content.
1917 var sleepTimerPickerOpen by remember { mutableStateOf(false) }
1918 var artistPickerOpen by remember { mutableStateOf(false) }
1919 var genrePickerOpen by remember { mutableStateOf(false) }
1920 // Cast picker visibility — toggled when the user taps the Cast
1921 // button in the modifier row. The button calls
1922 // viewModel.toggleCast() which emits to viewModel.showCastPicker;
1923 // we collect the emission and flip this state to show the sheet.
1924 var castPickerOpen by remember { mutableStateOf(false) }
1925 if (viewModel.castIntegration.available) {
1926 LaunchedEffect(viewModel) {
1927 viewModel.showCastPicker.collect { castPickerOpen = true }
1928 }
1929 }
1930
1931 val trackArtist = track.artist
1932 val albumArtist = track.albumArtist
1933 val hasMultipleArtists = !trackArtist.isNullOrBlank() &&
1934 !albumArtist.isNullOrBlank() &&
1935 !trackArtist.equals(albumArtist, ignoreCase = true)
1936 val artistForSearch = albumArtist ?: trackArtist
1937 val genres = parseGenres(track.genre)
1938
1939 Column(
1940 modifier = Modifier.fillMaxSize(),
1941 horizontalAlignment = Alignment.CenterHorizontally,
1942 ) {
1943 // ── Album art with volume touch zones — square, full width ────
1944 // Top half = volume up, bottom half = volume down. Shows a
1945 // brief overlay with the current level. On phones with hardware
1946 // volume keys this is supplementary; on the HP Chromebase
1947 // (which has no useful hardware volume while using the
1948 // touchscreen) this is the primary volume interface.
1949 VolumeAwareAlbumArt(
1950 model = track.albumArtUri,
1951 volumePercent = state.volumePercent,
1952 onSetVolume = { viewModel.setVolumeTo(it) },
1953 modifier = Modifier
1954 .fillMaxWidth()
1955 .aspectRatio(1f),
1956 contentScale = ContentScale.Fit,
1957 )
1958 // ── Padded content below — fills remaining vertical space ───
1959 // weight(1f) so the inner Column claims everything left after
1960 // the art's 411dp. Children pack at the top via the default
1961 // Arrangement.Top; any leftover at the bottom (small, since
1962 // content is sized to the available space) sits as black below
1963 // the modifier row.
1964 Column(
1965 modifier = Modifier
1966 .fillMaxWidth()
1967 .weight(1f)
1968 .padding(start = 16.dp, end = 16.dp, top = 0.dp, bottom = 4.dp),
1969 horizontalAlignment = Alignment.CenterHorizontally,
1970 ) {
1971 // ── Adaptive metadata — title / artist / album (+ year) ──────
1972 // See PhoneAutoFitMetadataBlock: measured fit, all information
1973 // always shown, block = exactly the leftover, controls below
1974 // immovable. Year parity: "(YYYY)" always rides the album line.
1975 PhoneAutoFitMetadataBlock(
1976 title = track.title,
1977 artist = track.artist ?: track.albumArtist,
1978 album = track.album?.takeIf { it.isNotBlank() }?.let { alb ->
1979 val y = track.date?.let { Regex("""\d{4}""").find(it)?.value }
1980 alb + (y?.let { " ($it)" } ?: "")
1981 },
1982 modifier = Modifier
1983 .fillMaxWidth()
1984 .weight(1f),
1985 )
1986 Spacer(Modifier.height(4.dp))
1987 // Progress bar — reuse the existing ProgressBar composable defined
1988 // earlier in this file. It handles seek, focus, time labels.
1989 ProgressBar(
1990 seekable = seekable,
1991 position = state.position,
1992 duration = state.duration,
1993 onSeek = { target -> viewModel.seekTo(target) },
1994 centerLabel = track.trackNumber?.takeIf { it > 0 }?.let { "Track Nº $it" },
1995 )
1996 Spacer(Modifier.height(2.dp))
1997 // Primary transport row — Skip Previous, Play/Pause (large), Skip Next.
1998 // Centered, with comfortable touch targets.
1999 Row(
2000 modifier = Modifier.fillMaxWidth(),
2001 horizontalArrangement = Arrangement.SpaceEvenly,
2002 verticalAlignment = Alignment.CenterVertically,
2003 ) {
2004 CompactTransport(
2005 icon = Icons.Filled.SkipPrevious,
2006 description = "Previous",
2007 size = 48.dp,
2008 onClick = viewModel::previous,
2009 )
2010 CompactPrimaryTransport(
2011 icon = if (state.isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
2012 description = if (state.isPlaying) "Pause (long-press to stop)" else "Play (long-press to stop)",
2013 size = 64.dp,
2014 onClick = viewModel::togglePlay,
2015 onLongClick = viewModel::stop,
2016 focusRequester = playFocus,
2017 modifier = Modifier.offset(y = 30.dp),
2018 )
2019 CompactTransport(
2020 icon = Icons.Filled.SkipNext,
2021 description = "Next",
2022 size = 48.dp,
2023 onClick = viewModel::next,
2024 )
2025 }
2026 Spacer(Modifier.height(2.dp))
2027 // ── Two action triads ───────────────────────────────────────
2028 // Replaces the prior single row of 6 small (40dp) icons. Same
2029 // six actions, regrouped semantically and rendered larger
2030 // (56dp) so each one is a deliberate target.
2031 //
2032 // LEFT triad (track-context actions):
2033 // [ Lyrics ]
2034 // [ Sleep timer ] [ Search ]
2035 //
2036 // RIGHT triad (catalog-pivot actions):
2037 // [ This album ]
2038 // [ More by artist ] [ Genre ]
2039 //
2040 // Top icons sit centered above the bottom pair — the inverted
2041 // pyramid arrangement uses the vertical space the previous
2042 // single-row layout left empty, and the two triads each have
2043 // a clear visual identity.
2044 val sleepActive by viewModel.sleepTimerMinutesLeft.collectAsState()
2045 Row(
2046 modifier = Modifier.fillMaxWidth(),
2047 horizontalArrangement = Arrangement.SpaceEvenly,
2048 verticalAlignment = Alignment.Top,
2049 ) {
2050 // ── LEFT TRIAD: track-context ───────────────────────────
2051 Column(horizontalAlignment = Alignment.CenterHorizontally) {
2052 // Top: lyrics
2053 CompactTransportPainter(
2054 painter = painterResource(R.drawable.ic_lyrics_singer),
2055 description = "Show lyrics",
2056 size = 56.dp,
2057 onClick = onOpenLyrics,
2058 )
2059 Spacer(Modifier.height(2.dp))
2060 Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
2061 // Bottom-left: sleep timer
2062 if (cornerDomEtha) CompactTransport(
2063 icon = Icons.Filled.Tune,
2064 description = "Dom-Etha panel",
2065 size = 56.dp,
2066 tintAmber = false,
2067 onClick = onDomEtha,
2068 ) else CompactTransport(
2069 icon = Icons.Filled.AvTimer,
2070 description = if (sleepActive != null) "Sleep timer: ${sleepActive}m" else "Sleep timer",
2071 size = 56.dp,
2072 tintAmber = sleepActive != null,
2073 onClick = { sleepTimerPickerOpen = true },
2074 )
2075 // Bottom-right: search library
2076 CompactTransport(
2077 icon = Icons.Filled.Search,
2078 description = "Search library",
2079 size = 56.dp,
2080 onClick = onOpenSearch,
2081 )
2082 }
2083 }
2084 // ── RIGHT TRIAD: catalog-pivot ──────────────────────────
2085 Column(horizontalAlignment = Alignment.CenterHorizontally) {
2086 // Top: this album
2087 CompactTransport(
2088 icon = Icons.Filled.Album,
2089 description = "More from this album",
2090 size = 56.dp,
2091 onClick = {
2092 val albumTitle = track.album
2093 if (!albumTitle.isNullOrBlank()) {
2094 val disambiguator = if (hasMultipleArtists) "" else (track.artist ?: "")
2095 onMoreFromThisAlbum("$albumTitle|||$disambiguator")
2096 }
2097 },
2098 )
2099 Spacer(Modifier.height(2.dp))
2100 Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
2101 // Bottom-left: more by artist
2102 CompactTransportPainter(
2103 painter = painterResource(R.drawable.ic_headphones_vinyl),
2104 description = if (artistForSearch != null) "More by $artistForSearch" else "More by artist",
2105 size = 56.dp,
2106 onClick = {
2107 // Always open the picker (TV parity): the sheet
2108 // carries "More tracks from this album" and
2109 // "Show id3 tags" alongside artist browsing, so
2110 // it must be reachable for single-artist tracks
2111 // too, not just VA compilations.
2112 if (artistForSearch != null) artistPickerOpen = true
2113 },
2114 )
2115 // Bottom-right: genre tag
2116 CompactTransport(
2117 icon = Icons.Filled.LocalOffer,
2118 description = when (genres.size) {
2119 0 -> "Browse genres"
2120 1 -> "More in ${genres[0]}"
2121 else -> "Browse genres"
2122 },
2123 size = 56.dp,
2124 onClick = {
2125 when (genres.size) {
2126 0 -> Unit
2127 1 -> onMoreByGenre(genres[0])
2128 else -> genrePickerOpen = true
2129 }
2130 },
2131 )
2132 }
2133 }
2134 }
2135 Spacer(Modifier.height(2.dp))
2136 // Secondary row 2 — playback modifiers: Shuffle, Repeat, Cast, Stop, Queue.
2137 // Stop is essential for battery protection (full session teardown
2138 // releases the foreground service and wake locks) and is part of the
2139 // intended layout balance — long-press on play also stops, but the
2140 // explicit button stays.
2141 Row(
2142 modifier = Modifier.fillMaxWidth(),
2143 horizontalArrangement = Arrangement.SpaceEvenly,
2144 verticalAlignment = Alignment.CenterVertically,
2145 ) {
2146 CompactTransport(
2147 icon = if (state.shuffleEnabled) Icons.Filled.ShuffleOn else Icons.Filled.Shuffle,
2148 description = if (state.shuffleEnabled) "Shuffle on" else "Shuffle off",
2149 size = 56.dp,
2150 tintAmber = state.shuffleEnabled,
2151 onClick = viewModel::toggleShuffle,
2152 )
2153 val (repeatIcon, repeatDesc, repeatActive) = when (state.repeatMode) {
2154 RepeatMode.OFF -> Triple(Icons.Filled.Repeat, "Repeat off", false)
2155 RepeatMode.ALL -> Triple(Icons.Filled.RepeatOn, "Repeat all", true)
2156 RepeatMode.ONE -> Triple(Icons.Filled.RepeatOneOn, "Repeat one", true)
2157 }
2158 CompactTransport(
2159 icon = repeatIcon,
2160 description = repeatDesc,
2161 size = 56.dp,
2162 tintAmber = repeatActive,
2163 onClick = viewModel::cycleRepeat,
2164 )
2165 if (viewModel.castIntegration.available) {
2166 val isCasting by viewModel.castIntegration.isCasting.collectAsState()
2167 // Observer-mode indication — same visual language as the TV:
2168 // CastConnected glyph + CastReceiving (cool blue) tint, so
2169 // "we're following, not driving" reads identically on phone.
2170 val observingCast by viewModel.rawCast.observing.collectAsState()
2171 CompactTransport(
2172 icon = if (isCasting || observingCast) Icons.Filled.CastConnected else Icons.Filled.Cast,
2173 description = when {
2174 observingCast -> "Receiving cast — tap to switch"
2175 isCasting -> "Casting — tap to switch"
2176 else -> "Cast to device"
2177 },
2178 size = 56.dp,
2179 tintAmber = isCasting,
2180 tintOverride = if (observingCast) CastReceiving else null,
2181 onClick = viewModel::toggleCast,
2182 )
2183 }
2184 CompactTransport(
2185 icon = Icons.Filled.Stop,
2186 description = "Stop",
2187 size = 56.dp,
2188 onClick = viewModel::stop,
2189 )
2190 CompactTransport(
2191 icon = Icons.Filled.QueueMusic,
2192 description = "Queue",
2193 size = 56.dp,
2194 onClick = onOpenQueue,
2195 )
2196 }
2197 } // end inner padded Column
2198 }
2199
2200 // Sleep timer picker — same options as the TV layout for consistency.
2201 if (sleepTimerPickerOpen) {
2202 val sleepMinutes by viewModel.sleepTimerMinutesLeft.collectAsState()
2203 ActionSheet(
2204 title = if (sleepMinutes != null) "Sleep timer (${sleepMinutes}m left)" else "Sleep timer",
2205 subtitle = "Playback will stop after the selected time.",
2206 actions = buildList {
2207 if (sleepMinutes != null) {
2208 add(SheetAction("✕", "Cancel timer") { viewModel.cancelSleepTimer() })
2209 }
2210 add(SheetAction("⏱", "15 minutes") { viewModel.startSleepTimer(15) })
2211 add(SheetAction("⏱", "30 minutes") { viewModel.startSleepTimer(30) })
2212 add(SheetAction("⏱", "45 minutes") { viewModel.startSleepTimer(45) })
2213 add(SheetAction("⏱", "60 minutes") { viewModel.startSleepTimer(60) })
2214 add(SheetAction("⏱", "90 minutes") { viewModel.startSleepTimer(90) })
2215 add(SheetAction("⏱", "120 minutes") { viewModel.startSleepTimer(120) })
2216 },
2217 onDismiss = { sleepTimerPickerOpen = false },
2218 )
2219 }
2220
2221 // Artist picker — only meaningful for various-artists compilations.
2222 // Identical action set to the TV layout's artist picker, including
2223 // "More tracks from this album" appended when album info is present.
2224 if (artistPickerOpen) {
2225 // TV-parity sheet: opens for EVERY track (not just VA compilations),
2226 // with artist catalog browsing, "More tracks from this album", and
2227 // "Show id3 tags" — previously the id3 sheet was unreachable from
2228 // the phone's Now Playing entirely.
2229 ActionSheet(
2230 title = "More from…",
2231 actions = buildList {
2232 val primaryArtist = trackArtist ?: albumArtist
2233 if (primaryArtist != null) {
2234 add(SheetAction("♫", "Albums by $primaryArtist") { onMoreByArtist(primaryArtist) })
2235 }
2236 if (hasMultipleArtists && albumArtist != null) {
2237 add(SheetAction("♫", "Albums by $albumArtist") { onMoreByArtist(albumArtist) })
2238 }
2239 // On VA compilations drop the artist disambiguator — UPnP
2240 // can't filter by albumArtist reliably, and adding this
2241 // track's artist would exclude every other artist on the
2242 // comp (same rationale as the TV branch).
2243 track.album?.takeIf { it.isNotBlank() }?.let { albumTitle ->
2244 add(SheetAction("▤", "More tracks from this album") {
2245 val disambiguator = if (hasMultipleArtists) "" else (track.artist ?: "")
2246 onMoreFromThisAlbum("$albumTitle|||$disambiguator")
2247 })
2248 }
2249 add(SheetAction("ⓘ", "Show id3 tags") { onShowMetadata(track) })
2250 },
2251 onDismiss = { artistPickerOpen = false },
2252 )
2253 }
2254
2255 // Genre picker — when a track has multiple genres, let user pick which.
2256 if (genrePickerOpen) {
2257 ActionSheet(
2258 title = "Browse genres",
2259 subtitle = "Pick a genre tag from this track.",
2260 actions = genres.map { g ->
2261 SheetAction("▸", g) { onMoreByGenre(g) }
2262 },
2263 onDismiss = { genrePickerOpen = false },
2264 )
2265 }
2266
2267 // Cast picker — Compose-native sheet showing discovered Cast
2268 // devices. Mounted only when the play flavor's Cast SDK is
2269 // present (libre flavor's CastIntegration.available is false and
2270 // the sheet never instantiates). The sheet handles its own
2271 // lifecycle for discovery start/stop while visible.
2272 // Issue 2: rawCast enables "Observe a device" section so a second
2273 // phone can see and control what another sender is playing.
2274 CastPickerSheet(
2275 cast = viewModel.castIntegration,
2276 visible = castPickerOpen,
2277 onDismiss = { castPickerOpen = false },
2278 rawCast = viewModel.rawCast,
2279 )
2280}
2281
2282/**
2283 * Phone-landscape Now Playing — horizontal split, art on left, all controls
2284 * on right. Replaces the TV horizontal Row layout (which was being misrouted
2285 * to phone landscape because ~914dp counts as "expanded" by Material width
2286 * classes alone). Reuses [CompactTransport] / [CompactPrimaryTransport] so
2287 * touch targets are full thumb-sized, not the TV's 28dp `SmallTransportButton`.
2288 *
2289 * Sized for ~390dp of available height after status + nav bars on a Pixel
2290 * 8a turned sideways: art fills the height (so it's the limiting factor),
2291 * the right column gets ~525dp of width and stacks title block, format,
2292 * scrub bar, primary transport, and the two secondary rows.
2293 */
2294@OptIn(ExperimentalFoundationApi::class)
2295@Composable
2296private fun NowPlayingCompactLandscape(
2297 seekable: Boolean,
2298 cornerDomEtha: Boolean,
2299 onDomEtha: () -> Unit,
2300 track: dev.stave.domain.model.DidlItem.Track,
2301 state: dev.stave.domain.renderer.PlaybackState,
2302 viewModel: NowPlayingViewModel,
2303 playFocus: FocusRequester,
2304 activeServerUdn: String?,
2305 onOpenQueue: () -> Unit,
2306 onOpenSearch: () -> Unit,
2307 onOpenLyrics: () -> Unit,
2308 onMoreByArtist: (artistName: String) -> Unit,
2309 onMoreByGenre: (genreName: String) -> Unit,
2310 onMoreFromThisAlbum: (albumTitle: String) -> Unit,
2311 onShowMetadata: (track: dev.stave.domain.model.DidlItem.Track) -> Unit,
2312) {
2313 var sleepTimerPickerOpen by remember { mutableStateOf(false) }
2314 var artistPickerOpen by remember { mutableStateOf(false) }
2315 var genrePickerOpen by remember { mutableStateOf(false) }
2316 // Cast picker — same wiring as the portrait NowPlayingCompact.
2317 // Compiled out in libre flavor (CastIntegration.available = false).
2318 var castPickerOpen by remember { mutableStateOf(false) }
2319 if (viewModel.castIntegration.available) {
2320 LaunchedEffect(viewModel) {
2321 viewModel.showCastPicker.collect { castPickerOpen = true }
2322 }
2323 }
2324
2325 val trackArtist = track.artist
2326 val albumArtist = track.albumArtist
2327 val hasMultipleArtists = !trackArtist.isNullOrBlank() &&
2328 !albumArtist.isNullOrBlank() &&
2329 !trackArtist.equals(albumArtist, ignoreCase = true)
2330 val artistForSearch = albumArtist ?: trackArtist
2331 val genres = parseGenres(track.genre)
2332
2333 Row(
2334 modifier = Modifier
2335 .fillMaxSize(),
2336 // No padding at the Row level — the album art needs to reach the
2337 // top and bottom of the physical screen edge-to-edge while
2338 // maintaining its 1:1 aspect ratio. systemBarsPadding and the
2339 // horizontal/vertical padding that used to live here have moved
2340 // down to the right Column so the title/artist/controls still
2341 // respect status-bar and gesture-nav insets without constraining
2342 // the art's height.
2343 verticalAlignment = Alignment.CenterVertically,
2344 ) {
2345 // Art with volume touch zones — fills full screen height.
2346 VolumeAwareAlbumArt(
2347 model = track.albumArtUri,
2348 volumePercent = state.volumePercent,
2349 onSetVolume = { viewModel.setVolumeTo(it) },
2350 modifier = Modifier
2351 // Keep the art out from under the status bar / camera
2352 // cutout: edge-to-edge drew the cover's top rows beneath
2353 // system UI, which read as "landscape crops the art"
2354 // (field-reported with Five Leaves Left). Inset top+start
2355 // only — the art stays flush to the bottom edge.
2356 .windowInsetsPadding(
2357 WindowInsets.safeDrawing.only(
2358 WindowInsetsSides.Top + WindowInsetsSides.Start,
2359 ),
2360 )
2361 .fillMaxHeight()
2362 .aspectRatio(1f),
2363 )
2364 Spacer(Modifier.width(20.dp))
2365 Column(
2366 modifier = Modifier
2367 .weight(1f)
2368 .fillMaxHeight()
2369 // Phone landscape's width (~893dp on Pixel 8a sideways)
2370 // exceeds the navhost's compact-width threshold (600dp),
2371 // so the systemBarsPadding applied at the navhost root
2372 // doesn't run for this layout. Apply it here on the
2373 // text/controls column so the title row doesn't draw
2374 // under the status bar / camera cutout. The album art
2375 // (left of this column) deliberately DOES draw edge-to-
2376 // edge through the system-bar area.
2377 .systemBarsPadding()
2378 .padding(end = 16.dp, top = 12.dp, bottom = 0.dp),
2379 horizontalAlignment = Alignment.CenterHorizontally,
2380 verticalArrangement = Arrangement.Top,
2381 ) {
2382 // ── Adaptive metadata — title / artist / album (+ year) ──────
2383 // See PhoneAutoFitMetadataBlock: measured fit, all information
2384 // always shown, block = exactly the leftover, controls below
2385 // immovable. Year parity: "(YYYY)" always rides the album line.
2386 PhoneAutoFitMetadataBlock(
2387 title = track.title,
2388 artist = track.artist ?: track.albumArtist,
2389 album = track.album?.takeIf { it.isNotBlank() }?.let { alb ->
2390 val y = track.date?.let { Regex("""\d{4}""").find(it)?.value }
2391 alb + (y?.let { " ($it)" } ?: "")
2392 },
2393 modifier = Modifier
2394 .fillMaxWidth()
2395 .weight(1f),
2396 )
2397 // Primary transport row — Skip Previous, Play/Pause (large), Skip Next.
2398 // Centered, with comfortable touch targets.
2399 Row(
2400 modifier = Modifier.fillMaxWidth(),
2401 horizontalArrangement = Arrangement.SpaceEvenly,
2402 verticalAlignment = Alignment.CenterVertically,
2403 ) {
2404 CompactTransport(
2405 icon = Icons.Filled.SkipPrevious,
2406 description = "Previous",
2407 size = 40.dp,
2408 onClick = viewModel::previous,
2409 )
2410 CompactPrimaryTransport(
2411 icon = if (state.isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
2412 description = if (state.isPlaying) "Pause (long-press to stop)" else "Play (long-press to stop)",
2413 size = 48.dp,
2414 onClick = viewModel::togglePlay,
2415 onLongClick = viewModel::stop,
2416 focusRequester = playFocus,
2417 )
2418 CompactTransport(
2419 icon = Icons.Filled.SkipNext,
2420 description = "Next",
2421 size = 40.dp,
2422 onClick = viewModel::next,
2423 )
2424 }
2425 Spacer(Modifier.height(2.dp))
2426 // Single row of 6 content-navigation icons — flattened from
2427 // the portrait's inverted-pyramid triads to save vertical
2428 // space in landscape.
2429 val sleepActive by viewModel.sleepTimerMinutesLeft.collectAsState()
2430 Row(
2431 modifier = Modifier.fillMaxWidth(),
2432 horizontalArrangement = Arrangement.SpaceEvenly,
2433 verticalAlignment = Alignment.CenterVertically,
2434 ) {
2435 CompactTransportPainter(
2436 painter = painterResource(R.drawable.ic_lyrics_singer),
2437 description = "Show lyrics",
2438 size = 44.dp,
2439 onClick = onOpenLyrics,
2440 )
2441 if (cornerDomEtha) CompactTransport(
2442 icon = Icons.Filled.Tune,
2443 description = "Dom-Etha panel",
2444 size = 44.dp,
2445 tintAmber = false,
2446 onClick = onDomEtha,
2447 ) else CompactTransport(
2448 icon = Icons.Filled.AvTimer,
2449 description = if (sleepActive != null) "Sleep timer: ${sleepActive}m" else "Sleep timer",
2450 size = 44.dp,
2451 tintAmber = sleepActive != null,
2452 onClick = { sleepTimerPickerOpen = true },
2453 )
2454 CompactTransport(
2455 icon = Icons.Filled.Search,
2456 description = "Search library",
2457 size = 44.dp,
2458 onClick = onOpenSearch,
2459 )
2460 CompactTransport(
2461 icon = Icons.Filled.Album,
2462 description = "More from this album",
2463 size = 44.dp,
2464 onClick = {
2465 val albumTitle = track.album
2466 if (!albumTitle.isNullOrBlank()) {
2467 val disambiguator = if (hasMultipleArtists) "" else (track.artist ?: "")
2468 onMoreFromThisAlbum("$albumTitle|||$disambiguator")
2469 }
2470 },
2471 )
2472 CompactTransportPainter(
2473 painter = painterResource(R.drawable.ic_headphones_vinyl),
2474 description = if (artistForSearch != null) "More by $artistForSearch" else "More by artist",
2475 size = 44.dp,
2476 onClick = {
2477 // Always open the picker (TV parity) — see the
2478 // portrait layout's note: the sheet now carries album
2479 // and id3-tag actions for every track, not just VA.
2480 if (artistForSearch != null) artistPickerOpen = true
2481 },
2482 )
2483 CompactTransport(
2484 icon = Icons.Filled.LocalOffer,
2485 description = when (genres.size) {
2486 0 -> "Browse genres"
2487 1 -> "More in ${genres[0]}"
2488 else -> "Browse genres"
2489 },
2490 size = 44.dp,
2491 onClick = {
2492 when (genres.size) {
2493 0 -> Unit
2494 1 -> onMoreByGenre(genres[0])
2495 else -> genrePickerOpen = true
2496 }
2497 },
2498 )
2499 }
2500 Spacer(Modifier.height(2.dp))
2501 // Playback modifiers row
2502 Row(
2503 modifier = Modifier.fillMaxWidth(),
2504 horizontalArrangement = Arrangement.SpaceEvenly,
2505 verticalAlignment = Alignment.CenterVertically,
2506 ) {
2507 CompactTransport(
2508 icon = if (state.shuffleEnabled) Icons.Filled.ShuffleOn else Icons.Filled.Shuffle,
2509 description = if (state.shuffleEnabled) "Shuffle on" else "Shuffle off",
2510 size = 44.dp,
2511 tintAmber = state.shuffleEnabled,
2512 onClick = viewModel::toggleShuffle,
2513 )
2514 val (repeatIcon, repeatDesc, repeatActive) = when (state.repeatMode) {
2515 RepeatMode.OFF -> Triple(Icons.Filled.Repeat, "Repeat off", false)
2516 RepeatMode.ALL -> Triple(Icons.Filled.RepeatOn, "Repeat all", true)
2517 RepeatMode.ONE -> Triple(Icons.Filled.RepeatOneOn, "Repeat one", true)
2518 }
2519 CompactTransport(
2520 icon = repeatIcon,
2521 description = repeatDesc,
2522 size = 44.dp,
2523 tintAmber = repeatActive,
2524 onClick = viewModel::cycleRepeat,
2525 )
2526 if (viewModel.castIntegration.available) {
2527 val isCasting by viewModel.castIntegration.isCasting.collectAsState()
2528 // Observer-mode indication — see the portrait layout's note.
2529 val observingCast by viewModel.rawCast.observing.collectAsState()
2530 CompactTransport(
2531 icon = if (isCasting || observingCast) Icons.Filled.CastConnected else Icons.Filled.Cast,
2532 description = when {
2533 observingCast -> "Receiving cast — tap to switch"
2534 isCasting -> "Casting — tap to switch"
2535 else -> "Cast to device"
2536 },
2537 size = 44.dp,
2538 tintAmber = isCasting,
2539 tintOverride = if (observingCast) CastReceiving else null,
2540 onClick = viewModel::toggleCast,
2541 )
2542 }
2543 CompactTransport(
2544 icon = Icons.Filled.Stop,
2545 description = "Stop",
2546 size = 44.dp,
2547 onClick = viewModel::stop,
2548 )
2549 CompactTransport(
2550 icon = Icons.Filled.QueueMusic,
2551 description = "Queue",
2552 size = 44.dp,
2553 onClick = onOpenQueue,
2554 )
2555 }
2556 ProgressBar(
2557 seekable = seekable,
2558 position = state.position,
2559 duration = state.duration,
2560 onSeek = { target -> viewModel.seekTo(target) },
2561 centerLabel = track.trackNumber?.takeIf { it > 0 }?.let { "Track Nº $it" },
2562 )
2563 }
2564 }
2565
2566 // Picker dialogs — same set as NowPlayingCompact / TV path. Defined as
2567 // overlays at the end so the Row above isn't affected by their state
2568 // changes.
2569 if (sleepTimerPickerOpen) {
2570 val sleepMinutes by viewModel.sleepTimerMinutesLeft.collectAsState()
2571 ActionSheet(
2572 title = if (sleepMinutes != null) "Sleep timer (${sleepMinutes}m left)" else "Sleep timer",
2573 subtitle = "Playback will stop after the selected time.",
2574 actions = buildList {
2575 if (sleepMinutes != null) {
2576 add(SheetAction("✕", "Cancel timer") { viewModel.cancelSleepTimer() })
2577 }
2578 add(SheetAction("⏱", "15 minutes") { viewModel.startSleepTimer(15) })
2579 add(SheetAction("⏱", "30 minutes") { viewModel.startSleepTimer(30) })
2580 add(SheetAction("⏱", "45 minutes") { viewModel.startSleepTimer(45) })
2581 add(SheetAction("⏱", "60 minutes") { viewModel.startSleepTimer(60) })
2582 add(SheetAction("⏱", "90 minutes") { viewModel.startSleepTimer(90) })
2583 add(SheetAction("⏱", "120 minutes") { viewModel.startSleepTimer(120) })
2584 },
2585 onDismiss = { sleepTimerPickerOpen = false },
2586 )
2587 }
2588 if (artistPickerOpen) {
2589 // TV-parity sheet: opens for EVERY track (not just VA compilations),
2590 // with artist catalog browsing, "More tracks from this album", and
2591 // "Show id3 tags" — previously the id3 sheet was unreachable from
2592 // the phone's Now Playing entirely.
2593 ActionSheet(
2594 title = "More from…",
2595 actions = buildList {
2596 val primaryArtist = trackArtist ?: albumArtist
2597 if (primaryArtist != null) {
2598 add(SheetAction("♫", "Albums by $primaryArtist") { onMoreByArtist(primaryArtist) })
2599 }
2600 if (hasMultipleArtists && albumArtist != null) {
2601 add(SheetAction("♫", "Albums by $albumArtist") { onMoreByArtist(albumArtist) })
2602 }
2603 // On VA compilations drop the artist disambiguator — UPnP
2604 // can't filter by albumArtist reliably, and adding this
2605 // track's artist would exclude every other artist on the
2606 // comp (same rationale as the TV branch).
2607 track.album?.takeIf { it.isNotBlank() }?.let { albumTitle ->
2608 add(SheetAction("▤", "More tracks from this album") {
2609 val disambiguator = if (hasMultipleArtists) "" else (track.artist ?: "")
2610 onMoreFromThisAlbum("$albumTitle|||$disambiguator")
2611 })
2612 }
2613 add(SheetAction("ⓘ", "Show id3 tags") { onShowMetadata(track) })
2614 },
2615 onDismiss = { artistPickerOpen = false },
2616 )
2617 }
2618 if (genrePickerOpen) {
2619 ActionSheet(
2620 title = "Browse genres",
2621 subtitle = "Pick a genre tag from this track.",
2622 actions = genres.map { g ->
2623 SheetAction("▸", g) { onMoreByGenre(g) }
2624 },
2625 onDismiss = { genrePickerOpen = false },
2626 )
2627 }
2628
2629 // Cast picker — see NowPlayingCompact for rationale.
2630 CastPickerSheet(
2631 cast = viewModel.castIntegration,
2632 visible = castPickerOpen,
2633 onDismiss = { castPickerOpen = false },
2634 rawCast = viewModel.rawCast,
2635 )
2636}
2637
2638/**
2639 * Text that auto-sizes between [minFontSize] and [maxFontSize] to fill
2640 * the available width. Short strings ("1984") render at the maximum;
2641 * long strings shrink until they fit in a single line. Uses
2642 * [onTextLayout] overflow detection + drawWithContent to hide
2643 * intermediate sizes so only the final size is visible.
2644 */
2645@Composable
2646private fun AutoSizeText(
2647 text: androidx.compose.ui.text.AnnotatedString,
2648 style: androidx.compose.ui.text.TextStyle,
2649 color: Color,
2650 modifier: Modifier = Modifier,
2651 maxFontSize: androidx.compose.ui.unit.TextUnit = style.fontSize,
2652 minFontSize: androidx.compose.ui.unit.TextUnit = 14.sp,
2653 maxLines: Int = 1,
2654) {
2655 var scaledSize by remember(text, maxFontSize) { mutableFloatStateOf(maxFontSize.value) }
2656 var readyToDraw by remember(text, maxFontSize) { mutableStateOf(false) }
2657
2658 Text(
2659 text = text,
2660 style = style.copy(fontSize = scaledSize.sp),
2661 color = color,
2662 // With maxLines > 1, softWrap must be on so text can flow to
2663 // the extra line(s); hasVisualOverflow then fires only when
2664 // the text exceeds maxLines, so the shrink loop sizes down
2665 // until the wrapped text fits — ellipsis only at the floor.
2666 maxLines = maxLines,
2667 overflow = TextOverflow.Ellipsis,
2668 softWrap = maxLines > 1,
2669 modifier = modifier.then(
2670 Modifier.drawWithContent { if (readyToDraw) drawContent() }
2671 ),
2672 onTextLayout = { result ->
2673 if (result.hasVisualOverflow && scaledSize > minFontSize.value) {
2674 scaledSize = (scaledSize - 1f).coerceAtLeast(minFontSize.value)
2675 } else {
2676 readyToDraw = true
2677 }
2678 },
2679 )
2680}
2681
2682/**
2683 * Variant of [CompactTransport] that takes a [Painter] (for drawable
2684 * resources like `ic_lyrics_singer` or `ic_headphones_vinyl`) instead of
2685 * an [ImageVector]. Identical sizing/tinting/clickable behavior.
2686 */
2687@Composable
2688private fun CompactTransportPainter(
2689 painter: androidx.compose.ui.graphics.painter.Painter,
2690 description: String,
2691 size: androidx.compose.ui.unit.Dp,
2692 tintAmber: Boolean = false,
2693 onClick: () -> Unit,
2694) {
2695 Box(
2696 modifier = Modifier
2697 .size(size)
2698 .clickable(onClick = onClick),
2699 contentAlignment = Alignment.Center,
2700 ) {
2701 Icon(
2702 painter = painter,
2703 contentDescription = description,
2704 tint = if (tintAmber) AccentAmber else OnPrimary,
2705 modifier = Modifier.size(size * 0.6f),
2706 )
2707 }
2708}
2709
2710@Composable
2711private fun CompactTransport(
2712 icon: ImageVector,
2713 description: String,
2714 size: androidx.compose.ui.unit.Dp,
2715 tintAmber: Boolean = false,
2716 /** Wins over tintAmber when non-null — used by the Cast button to show
2717 * CastReceiving blue while observing, mirroring the TV's
2718 * SmallTransportButton.tintOverride. */
2719 tintOverride: Color? = null,
2720 onClick: () -> Unit,
2721) {
2722 Box(
2723 modifier = Modifier
2724 .size(size)
2725 .clickable(onClick = onClick),
2726 contentAlignment = Alignment.Center,
2727 ) {
2728 Icon(
2729 icon,
2730 contentDescription = description,
2731 tint = tintOverride ?: if (tintAmber) AccentAmber else OnPrimary,
2732 modifier = Modifier.size(size * 0.6f),
2733 )
2734 }
2735}
2736
2737@OptIn(ExperimentalFoundationApi::class)
2738@Composable
2739private fun CompactPrimaryTransport(
2740 icon: ImageVector,
2741 description: String,
2742 size: androidx.compose.ui.unit.Dp = 64.dp,
2743 onClick: () -> Unit,
2744 focusRequester: FocusRequester,
2745 onLongClick: (() -> Unit)? = null,
2746 modifier: Modifier = Modifier,
2747) {
2748 val interaction = remember { MutableInteractionSource() }
2749 Box(
2750 modifier = modifier
2751 .size(size)
2752 .focusRequester(focusRequester)
2753 .background(AccentAmber, CircleShape)
2754 .then(
2755 if (onLongClick != null) {
2756 Modifier.combinedClickable(
2757 interactionSource = interaction,
2758 indication = null,
2759 onClick = onClick,
2760 onLongClick = onLongClick,
2761 )
2762 } else {
2763 Modifier.clickable(onClick = onClick)
2764 }
2765 ),
2766 contentAlignment = Alignment.Center,
2767 ) {
2768 Icon(
2769 icon,
2770 contentDescription = description,
2771 tint = OnAmber,
2772 modifier = Modifier.size(size * 0.5f),
2773 )
2774 }
2775}
2776
2777// ══════════════════════════════════════════════════════════════════════
2778// Issue 6 — Volume touch zones on album art
2779// ══════════════════════════════════════════════════════════════════════
2780
2781/**
2782 * Wraps [AlbumArt] with touch zones for on-screen volume control.
2783 * Top half = volume up, bottom half = volume down. Shows a brief
2784 * overlay with the current level on each adjustment.
2785 *
2786 * Key design:
2787 * - `localVol` tracks volume inside the composable so rapid taps
2788 * accumulate correctly (the renderer's state.volumePercent lags
2789 * behind by one player-listener cycle).
2790 * - `pointerInput(Unit)` never restarts; callbacks are reached via
2791 * `rememberUpdatedState` so the gesture detector is stable.
2792 * - Overlay auto-hides via LaunchedEffect keyed to a tick counter —
2793 * no job juggling, no stuck toast.
2794 * - Long press: fires one adjustment immediately, then launches a
2795 * repeating coroutine that cancels on finger up.
2796 */
2797@Composable
2798private fun VolumeAwareAlbumArt(
2799 model: String?,
2800 volumePercent: Int,
2801 onSetVolume: (percent: Int) -> Unit,
2802 modifier: Modifier = Modifier,
2803 step: Int = 5,
2804 contentScale: ContentScale = ContentScale.Fit,
2805) {
2806 // Local volume — updated immediately per tap so sequential taps
2807 // don't read stale state. Re-synced from the renderer when it
2808 // catches up — but ONLY outside an active gesture window. Without
2809 // the window, any transiently-wrong upstream value (a status
2810 // broadcast racing our optimistic echo, a stream-volume report
2811 // from an older build's speaker, etc.) re-syncs localVol between
2812 // taps and the next tap computes from garbage — the "pause, tap
2813 // down, volume jumps UP to 95" hijack. 2.5s after the last user
2814 // adjustment, upstream becomes authoritative again.
2815 var localVol by remember { mutableFloatStateOf(volumePercent.toFloat()) }
2816 var lastUserAdjustAt by remember { mutableStateOf(0L) }
2817 LaunchedEffect(volumePercent) {
2818 if (System.currentTimeMillis() - lastUserAdjustAt > 2_500) {
2819 localVol = volumePercent.toFloat()
2820 }
2821 }
2822
2823 val currentOnSetVolume by androidx.compose.runtime.rememberUpdatedState(onSetVolume)
2824
2825 // Overlay — text + auto-dismiss keyed to a counter.
2826 var overlayLabel by remember { mutableStateOf<String?>(null) }
2827 var dismissTick by remember { mutableStateOf(0) }
2828 LaunchedEffect(dismissTick) {
2829 if (overlayLabel != null) {
2830 delay(1500)
2831 overlayLabel = null
2832 }
2833 }
2834
2835 // Shared adjustment logic. Called from gesture handlers.
2836 val scope = rememberCoroutineScope()
2837 fun doAdjust(up: Boolean) {
2838 lastUserAdjustAt = System.currentTimeMillis()
2839 val newVol = if (up) (localVol + step).coerceAtMost(100f)
2840 else (localVol - step).coerceAtLeast(0f)
2841 localVol = newVol
2842 currentOnSetVolume(newVol.toInt())
2843 val pct = newVol.toInt()
2844 overlayLabel = when {
2845 pct <= 0 -> "Volume: 0% (Muted)"
2846 pct >= 100 -> "Volume: 100%"
2847 else -> "Volume: $pct%"
2848 }
2849 dismissTick++
2850 }
2851
2852 // Continuous-adjust coroutine for long press. Cancelled on finger up.
2853 var longPressJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
2854
2855 Box(modifier = modifier) {
2856 AlbumArt(
2857 model = model,
2858 modifier = Modifier.fillMaxSize(),
2859 contentScale = contentScale,
2860 )
2861
2862 // Top half — volume up
2863 Box(
2864 modifier = Modifier
2865 .fillMaxWidth()
2866 .fillMaxHeight(0.5f)
2867 .align(Alignment.TopCenter)
2868 .pointerInput(Unit) {
2869 detectTapGestures(
2870 onPress = {
2871 // Immediate single adjustment on finger contact.
2872 doAdjust(true)
2873 // Start long-press ramp after 400ms.
2874 longPressJob?.cancel()
2875 longPressJob = scope.launch {
2876 delay(400)
2877 var interval = 250L
2878 val t0 = System.currentTimeMillis()
2879 while (true) {
2880 doAdjust(true)
2881 delay(interval)
2882 if (System.currentTimeMillis() - t0 > 1000L) interval = 80L
2883 }
2884 }
2885 // tryAwaitRelease suspends until the finger lifts
2886 // (returns true) or the gesture is cancelled (false).
2887 // Either way, stop the ramp.
2888 val released = tryAwaitRelease()
2889 longPressJob?.cancel()
2890 longPressJob = null
2891 },
2892 )
2893 },
2894 ) {}
2895
2896 // Bottom half — volume down
2897 Box(
2898 modifier = Modifier
2899 .fillMaxWidth()
2900 .fillMaxHeight(0.5f)
2901 .align(Alignment.BottomCenter)
2902 .pointerInput(Unit) {
2903 detectTapGestures(
2904 onPress = {
2905 doAdjust(false)
2906 longPressJob?.cancel()
2907 longPressJob = scope.launch {
2908 delay(400)
2909 var interval = 250L
2910 val t0 = System.currentTimeMillis()
2911 while (true) {
2912 doAdjust(false)
2913 delay(interval)
2914 if (System.currentTimeMillis() - t0 > 1000L) interval = 80L
2915 }
2916 }
2917 val released = tryAwaitRelease()
2918 longPressJob?.cancel()
2919 longPressJob = null
2920 },
2921 )
2922 },
2923 ) {}
2924
2925 // Volume overlay — centered
2926 if (overlayLabel != null) {
2927 Box(
2928 modifier = Modifier
2929 .align(Alignment.Center)
2930 .background(
2931 Color.Black.copy(alpha = 0.75f),
2932 RoundedCornerShape(8.dp),
2933 )
2934 .padding(horizontal = 20.dp, vertical = 10.dp),
2935 contentAlignment = Alignment.Center,
2936 ) {
2937 Text(
2938 text = overlayLabel!!,
2939 color = OnPrimary,
2940 style = MaterialTheme.typography.titleMedium,
2941 )
2942 }
2943 }
2944 }
2945}
2946
2947// ══════════════════════════════════════════════════════════════════════
2948// Issue 7 — NowPlayingLargePortrait
2949// ══════════════════════════════════════════════════════════════════════
2950
2951/**
2952 * Large-screen portrait Now Playing — designed for HP Chromebase (21.5"
2953 * touchscreen) in portrait mode and tablets in portrait. The key
2954 * difference from [NowPlayingCompact]: everything is scaled up to fill
2955 * the generous vertical space instead of leaving dead space at the bottom.
2956 *
2957 * Layout (top to bottom, SpaceBetween fills full height):
2958 * - Album art: ~55% of viewport height, centered, with volume touch zones
2959 * - Title / artist / album: headlineLarge + headlineMedium + titleLarge
2960 * - Progress bar: full width with large touch targets
2961 * - Primary transport: 72dp prev/next, 96dp play/pause
2962 * - Action triads + modifier row: same as compact but 64dp icons
2963 */
2964@OptIn(ExperimentalFoundationApi::class)
2965@Composable
2966private fun NowPlayingLargePortrait(
2967 seekable: Boolean,
2968 cornerDomEtha: Boolean,
2969 onDomEtha: () -> Unit,
2970 track: dev.stave.domain.model.DidlItem.Track,
2971 state: dev.stave.domain.renderer.PlaybackState,
2972 viewModel: NowPlayingViewModel,
2973 playFocus: FocusRequester,
2974 activeServerUdn: String?,
2975 onOpenQueue: () -> Unit,
2976 onOpenSearch: () -> Unit,
2977 onOpenLyrics: () -> Unit,
2978 onMoreByArtist: (artistName: String) -> Unit,
2979 onMoreByGenre: (genreName: String) -> Unit,
2980 onMoreFromThisAlbum: (albumTitle: String) -> Unit,
2981 onShowMetadata: (track: dev.stave.domain.model.DidlItem.Track) -> Unit,
2982) {
2983 var sleepTimerPickerOpen by remember { mutableStateOf(false) }
2984 var artistPickerOpen by remember { mutableStateOf(false) }
2985 var genrePickerOpen by remember { mutableStateOf(false) }
2986 var castPickerOpen by remember { mutableStateOf(false) }
2987 if (viewModel.castIntegration.available) {
2988 LaunchedEffect(viewModel) {
2989 viewModel.showCastPicker.collect { castPickerOpen = true }
2990 }
2991 }
2992
2993 val trackArtist = track.artist
2994 val albumArtist = track.albumArtist
2995 val hasMultipleArtists = !trackArtist.isNullOrBlank() &&
2996 !albumArtist.isNullOrBlank() &&
2997 !trackArtist.equals(albumArtist, ignoreCase = true)
2998 val artistForSearch = albumArtist ?: trackArtist
2999 val genres = parseGenres(track.genre)
3000
3001 Column(
3002 modifier = Modifier
3003 .fillMaxSize()
3004 .systemBarsPadding(),
3005 horizontalAlignment = Alignment.CenterHorizontally,
3006 // SpaceEvenly distributes all remaining space equally between
3007 // items. Combined with weight(1f) on the art below, this ensures
3008 // the controls and modifier row are always fully visible at the
3009 // bottom of the screen. The previous SpaceBetween + aspectRatio(1f)
3010 // made the art a 510dp square that pushed the modifier row off the
3011 // bottom edge on the Chromebase's portrait viewport.
3012 verticalArrangement = Arrangement.spacedBy(4.dp),
3013 ) {
3014 Spacer(Modifier.height(4.dp))
3015
3016 // ── Album art with volume touch zones — square, full width ────
3017 // the maintainer's form, from the phone layout (2026-05-20: "must
3018 // always be displayed at correct aspect ratio", edge to edge):
3019 // a direct child, full width, square. The 2026-07-07 large-
3020 // portrait layout wrapped it in a weighted Box at 85% width with
3021 // Fit and a 32dp margin; the maintainer never asked for smaller
3022 // art. Restored 2026-09-03.
3023 VolumeAwareAlbumArt(
3024 model = track.albumArtUri,
3025 volumePercent = state.volumePercent,
3026 onSetVolume = { viewModel.setVolumeTo(it) },
3027 modifier = Modifier
3028 .fillMaxWidth()
3029 .aspectRatio(1f),
3030 contentScale = ContentScale.Fit,
3031 )
3032 // Maintainer, 2026-09-04 — item 7 of 2026-07-07 done as asked: the art
3033 // above is full width; the text and controls below GROW to fill
3034 // whatever height remains, and SHRINK to fit it, instead of being
3035 // fixed at 96/72/64dp and falling off the bottom. `k` scales every
3036 // size below from the height actually available.
3037 androidx.compose.foundation.layout.BoxWithConstraints(
3038 Modifier.fillMaxWidth().weight(1f).padding(horizontal = 32.dp),
3039 ) {
3040 val availableH = maxHeight
3041 // Height the content needs at full size: metadata block + progress +
3042 // transport + three icon rows + spacers (≈ 600dp).
3043 val neededH = 600.dp
3044 // Maintainer, 2026-10-29: scale only the part that scales. ProgressBar
3045 // is fixed-size: 4 (top pad) + 6 + 16 (knob box) + 6 + 4 (spacer)
3046 // + 18 (labelMedium line height) + 4 (bottom pad) = 58dp. Dividing the
3047 // whole height by 600dp let those 58dp overflow and clipped the
3048 // bottom Shuffle/Repeat/Cast/Stop/Queue row on smaller tablets.
3049 val fixedH = 58.dp
3050 val k: Float = ((availableH - fixedH) / (neededH - fixedH)).coerceIn(0.5f, 1.35f)
3051 fun d(v: Int): androidx.compose.ui.unit.Dp = (v * k).dp
3052 Column(
3053 modifier = Modifier.fillMaxSize(),
3054 horizontalAlignment = Alignment.CenterHorizontally,
3055 verticalArrangement = Arrangement.SpaceEvenly,
3056 ) {
3057
3058 Spacer(Modifier.height(d(16)))
3059
3060 // ── Track metadata — scaled up typography ─────────────────────
3061 Column(
3062 modifier = Modifier.fillMaxWidth(),
3063 horizontalAlignment = Alignment.CenterHorizontally,
3064 ) {
3065 // ── Adaptive metadata (see PhoneAutoFitMetadataBlock) ────
3066 // BOUNDED slot: a fixed 176dp height budget. Previously this
3067 // was unbounded — long metadata grew freely and the art's
3068 // weighted Box above absorbed the loss, height-crushing the
3069 // artwork to a small centered square while the text loomed at
3070 // full base size (field-rejected: "text absurdly large, art
3071 // tiny"). Inverted: the art is the fixed element — with the
3072 // metadata capped, the weighted Box reclaims all remaining
3073 // height and the art renders width-limited (full viewport
3074 // width) on any sane geometry, while the controls stay pinned
3075 // exactly as the weighted-Box design guarantees. Inside the
3076 // constant budget the bidirectional fit makes text scale a
3077 // function of TEXT LENGTH alone: short metadata grows to the
3078 // cap, long metadata wraps and shrinks — the art never pays.
3079 PhoneAutoFitMetadataBlock(
3080 title = track.title,
3081 artist = track.artist ?: track.albumArtist,
3082 album = track.album?.takeIf { it.isNotBlank() }?.let { alb ->
3083 val y = track.date?.let { Regex("""\d{4}""").find(it)?.value }
3084 alb + (y?.let { " ($it)" } ?: "")
3085 },
3086 modifier = Modifier
3087 .fillMaxWidth()
3088 .heightIn(max = d(176)),
3089 titleBaseSp = 40f,
3090 artistBaseSp = 32f,
3091 albumBaseSp = 26f,
3092 )
3093 }
3094
3095 Spacer(Modifier.height(d(12)))
3096
3097 // ── Progress bar ──────────────────────────────────────────────
3098 ProgressBar(
3099 seekable = seekable,
3100 position = state.position,
3101 duration = state.duration,
3102 onSeek = { target -> viewModel.seekTo(target) },
3103 centerLabel = track.trackNumber?.takeIf { it > 0 }?.let { "Track Nº $it" },
3104 )
3105
3106 Spacer(Modifier.height(d(8)))
3107
3108 // ── Primary transport — large buttons ─────────────────────────
3109 Row(
3110 modifier = Modifier.fillMaxWidth(),
3111 horizontalArrangement = Arrangement.SpaceEvenly,
3112 verticalAlignment = Alignment.CenterVertically,
3113 ) {
3114 CompactTransport(
3115 icon = Icons.Filled.SkipPrevious,
3116 description = "Previous",
3117 size = d(72),
3118 onClick = viewModel::previous,
3119 )
3120 CompactPrimaryTransport(
3121 icon = if (state.isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
3122 description = if (state.isPlaying) "Pause" else "Play",
3123 size = d(96),
3124 onClick = viewModel::togglePlay,
3125 onLongClick = viewModel::stop,
3126 focusRequester = playFocus,
3127 )
3128 CompactTransport(
3129 icon = Icons.Filled.SkipNext,
3130 description = "Next",
3131 size = d(72),
3132 onClick = viewModel::next,
3133 )
3134 }
3135
3136 Spacer(Modifier.height(d(8)))
3137
3138 // ── Action triads — same pattern as compact, larger icons ─────
3139 val sleepActive by viewModel.sleepTimerMinutesLeft.collectAsState()
3140 Row(
3141 modifier = Modifier.fillMaxWidth(),
3142 horizontalArrangement = Arrangement.SpaceEvenly,
3143 verticalAlignment = Alignment.Top,
3144 ) {
3145 // LEFT TRIAD: track-context
3146 Column(horizontalAlignment = Alignment.CenterHorizontally) {
3147 CompactTransportPainter(
3148 painter = painterResource(R.drawable.ic_lyrics_singer),
3149 description = "Show lyrics",
3150 size = d(64),
3151 onClick = onOpenLyrics,
3152 )
3153 Spacer(Modifier.height(d(4)))
3154 Row(horizontalArrangement = Arrangement.spacedBy(d(20))) {
3155 if (cornerDomEtha) CompactTransport(
3156 icon = Icons.Filled.Tune,
3157 description = "Dom-Etha panel",
3158 size = d(64),
3159 tintAmber = false,
3160 onClick = onDomEtha,
3161 ) else CompactTransport(
3162 icon = Icons.Filled.AvTimer,
3163 description = if (sleepActive != null) "Sleep timer: ${sleepActive}m" else "Sleep timer",
3164 size = d(64),
3165 tintAmber = sleepActive != null,
3166 onClick = { sleepTimerPickerOpen = true },
3167 )
3168 CompactTransport(
3169 icon = Icons.Filled.Search,
3170 description = "Search library",
3171 size = d(64),
3172 onClick = onOpenSearch,
3173 )
3174 }
3175 }
3176 // RIGHT TRIAD: catalog-pivot
3177 Column(horizontalAlignment = Alignment.CenterHorizontally) {
3178 CompactTransport(
3179 icon = Icons.Filled.Album,
3180 description = "More from this album",
3181 size = d(64),
3182 onClick = {
3183 val albumTitle = track.album
3184 if (!albumTitle.isNullOrBlank()) {
3185 val disambiguator = if (hasMultipleArtists) "" else (track.artist ?: "")
3186 onMoreFromThisAlbum("$albumTitle|||$disambiguator")
3187 }
3188 },
3189 )
3190 Spacer(Modifier.height(d(4)))
3191 Row(horizontalArrangement = Arrangement.spacedBy(d(20))) {
3192 CompactTransportPainter(
3193 painter = painterResource(R.drawable.ic_headphones_vinyl),
3194 description = if (artistForSearch != null) "More by $artistForSearch" else "More by artist",
3195 size = d(64),
3196 onClick = {
3197 // Always open the picker (TV parity): the sheet
3198 // carries "More tracks from this album" and
3199 // "Show id3 tags" alongside artist browsing, so
3200 // it must be reachable for single-artist tracks
3201 // too, not just VA compilations.
3202 if (artistForSearch != null) artistPickerOpen = true
3203 },
3204 )
3205 CompactTransport(
3206 icon = Icons.Filled.LocalOffer,
3207 description = when (genres.size) {
3208 0 -> "Browse genres"
3209 1 -> "More in ${genres[0]}"
3210 else -> "Browse genres"
3211 },
3212 size = d(64),
3213 onClick = {
3214 when (genres.size) {
3215 0 -> Unit
3216 1 -> onMoreByGenre(genres[0])
3217 else -> genrePickerOpen = true
3218 }
3219 },
3220 )
3221 }
3222 }
3223 }
3224
3225 Spacer(Modifier.height(d(4)))
3226
3227 // ── Modifier row — shuffle, repeat, cast, stop, queue ─────────
3228 Row(
3229 modifier = Modifier.fillMaxWidth(),
3230 horizontalArrangement = Arrangement.SpaceEvenly,
3231 verticalAlignment = Alignment.CenterVertically,
3232 ) {
3233 CompactTransport(
3234 icon = if (state.shuffleEnabled) Icons.Filled.ShuffleOn else Icons.Filled.Shuffle,
3235 description = if (state.shuffleEnabled) "Shuffle on" else "Shuffle off",
3236 size = d(64),
3237 tintAmber = state.shuffleEnabled,
3238 onClick = viewModel::toggleShuffle,
3239 )
3240 val (repeatIcon, repeatDesc, repeatActive) = when (state.repeatMode) {
3241 RepeatMode.OFF -> Triple(Icons.Filled.Repeat, "Repeat off", false)
3242 RepeatMode.ALL -> Triple(Icons.Filled.RepeatOn, "Repeat all", true)
3243 RepeatMode.ONE -> Triple(Icons.Filled.RepeatOneOn, "Repeat one", true)
3244 }
3245 CompactTransport(
3246 icon = repeatIcon,
3247 description = repeatDesc,
3248 size = d(64),
3249 tintAmber = repeatActive,
3250 onClick = viewModel::cycleRepeat,
3251 )
3252 if (viewModel.castIntegration.available) {
3253 val isCasting by viewModel.castIntegration.isCasting.collectAsState()
3254 // Observer-mode indication — see the portrait layout's note.
3255 val observingCast by viewModel.rawCast.observing.collectAsState()
3256 CompactTransport(
3257 icon = if (isCasting || observingCast) Icons.Filled.CastConnected else Icons.Filled.Cast,
3258 description = when {
3259 observingCast -> "Receiving"
3260 isCasting -> "Casting"
3261 else -> "Cast"
3262 },
3263 size = d(64),
3264 tintAmber = isCasting,
3265 tintOverride = if (observingCast) CastReceiving else null,
3266 onClick = viewModel::toggleCast,
3267 )
3268 }
3269 CompactTransport(
3270 icon = Icons.Filled.Stop,
3271 description = "Stop",
3272 size = d(64),
3273 onClick = viewModel::stop,
3274 )
3275 CompactTransport(
3276 icon = Icons.Filled.QueueMusic,
3277 description = "Queue",
3278 size = d(64),
3279 onClick = onOpenQueue,
3280 )
3281 }
3282
3283 Spacer(Modifier.height(d(8)))
3284 } // content column
3285 } // BoxWithConstraints
3286 }
3287 // ── Dialogs (same as NowPlayingCompact) ───────────────────────────
3288 if (sleepTimerPickerOpen) {
3289 val sleepMinutes by viewModel.sleepTimerMinutesLeft.collectAsState()
3290 ActionSheet(
3291 title = if (sleepMinutes != null) "Sleep timer (${sleepMinutes}m left)" else "Sleep timer",
3292 subtitle = "Playback will stop after the selected time.",
3293 actions = buildList {
3294 if (sleepMinutes != null) {
3295 add(SheetAction("✕", "Cancel timer") { viewModel.cancelSleepTimer() })
3296 }
3297 add(SheetAction("⏱", "15 minutes") { viewModel.startSleepTimer(15) })
3298 add(SheetAction("⏱", "30 minutes") { viewModel.startSleepTimer(30) })
3299 add(SheetAction("⏱", "45 minutes") { viewModel.startSleepTimer(45) })
3300 add(SheetAction("⏱", "60 minutes") { viewModel.startSleepTimer(60) })
3301 add(SheetAction("⏱", "90 minutes") { viewModel.startSleepTimer(90) })
3302 add(SheetAction("⏱", "120 minutes") { viewModel.startSleepTimer(120) })
3303 },
3304 onDismiss = { sleepTimerPickerOpen = false },
3305 )
3306 }
3307 if (artistPickerOpen) {
3308 // TV-parity sheet: opens for EVERY track (not just VA compilations),
3309 // with artist catalog browsing, "More tracks from this album", and
3310 // "Show id3 tags" — previously the id3 sheet was unreachable from
3311 // the phone's Now Playing entirely.
3312 ActionSheet(
3313 title = "More from…",
3314 actions = buildList {
3315 val primaryArtist = trackArtist ?: albumArtist
3316 if (primaryArtist != null) {
3317 add(SheetAction("♫", "Albums by $primaryArtist") { onMoreByArtist(primaryArtist) })
3318 }
3319 if (hasMultipleArtists && albumArtist != null) {
3320 add(SheetAction("♫", "Albums by $albumArtist") { onMoreByArtist(albumArtist) })
3321 }
3322 // On VA compilations drop the artist disambiguator — UPnP
3323 // can't filter by albumArtist reliably, and adding this
3324 // track's artist would exclude every other artist on the
3325 // comp (same rationale as the TV branch).
3326 track.album?.takeIf { it.isNotBlank() }?.let { albumTitle ->
3327 add(SheetAction("▤", "More tracks from this album") {
3328 val disambiguator = if (hasMultipleArtists) "" else (track.artist ?: "")
3329 onMoreFromThisAlbum("$albumTitle|||$disambiguator")
3330 })
3331 }
3332 add(SheetAction("ⓘ", "Show id3 tags") { onShowMetadata(track) })
3333 },
3334 onDismiss = { artistPickerOpen = false },
3335 )
3336 }
3337 if (genrePickerOpen) {
3338 ActionSheet(
3339 title = "Browse genres",
3340 subtitle = "Pick a genre tag from this track.",
3341 actions = genres.map { g ->
3342 SheetAction("▸", g) { onMoreByGenre(g) }
3343 },
3344 onDismiss = { genrePickerOpen = false },
3345 )
3346 }
3347 CastPickerSheet(
3348 cast = viewModel.castIntegration,
3349 visible = castPickerOpen,
3350 onDismiss = { castPickerOpen = false },
3351 rawCast = viewModel.rawCast,
3352 )
3353}
3354
3355/** Warning strip shown while a remote UPnP renderer is the selected output:
3356 * "Playing on <name> — not this device". Nothing when local is selected. */
3357@Composable
3358private fun RemoteRendererBanner(
3359 activeUdn: String?,
3360 renderers: List<dev.stave.domain.model.RemoteRenderer>,
3361 modifier: Modifier = Modifier,
3362) {
3363 if (activeUdn == null) return
3364 val name = renderers.firstOrNull { it.udn == activeUdn }?.friendlyName
3365 ?: activeUdn.removePrefix("uuid:").take(8)
3366 Box(
3367 modifier
3368 .fillMaxWidth()
3369 .background(dev.stave.ui.theme.AccentAmberSoft)
3370 .padding(horizontal = 16.dp, vertical = 8.dp),
3371 contentAlignment = Alignment.Center,
3372 ) {
3373 Text(
3374 "Playing on $name — not this device (Settings → Play through)",
3375 style = MaterialTheme.typography.labelMedium,
3376 color = dev.stave.ui.theme.AccentAmber,
3377 )
3378 }
3379}