NowPlayingScreen.kt
3379 lines · 160.0 KB
Changed in: 0.5.0
| 1 | package dev.stave.ui.nowplaying |
| 2 | |
| 3 | import androidx.activity.compose.BackHandler |
| 4 | import androidx.compose.foundation.ExperimentalFoundationApi |
| 5 | import androidx.compose.foundation.Image |
| 6 | import androidx.compose.foundation.background |
| 7 | import androidx.compose.foundation.border |
| 8 | import androidx.compose.foundation.clickable |
| 9 | import androidx.compose.foundation.verticalScroll |
| 10 | import androidx.compose.foundation.combinedClickable |
| 11 | import androidx.compose.foundation.focusable |
| 12 | import androidx.compose.foundation.gestures.detectHorizontalDragGestures |
| 13 | import androidx.compose.foundation.gestures.detectTapGestures |
| 14 | import androidx.compose.foundation.interaction.MutableInteractionSource |
| 15 | import androidx.compose.foundation.interaction.collectIsFocusedAsState |
| 16 | import androidx.compose.foundation.layout.Arrangement |
| 17 | import androidx.compose.foundation.layout.Box |
| 18 | import androidx.compose.foundation.layout.BoxWithConstraints |
| 19 | import androidx.compose.foundation.layout.Column |
| 20 | import androidx.compose.foundation.layout.Row |
| 21 | import androidx.compose.foundation.layout.Spacer |
| 22 | import androidx.compose.foundation.layout.aspectRatio |
| 23 | import androidx.compose.foundation.layout.sizeIn |
| 24 | import androidx.compose.foundation.layout.fillMaxHeight |
| 25 | import androidx.compose.foundation.layout.fillMaxSize |
| 26 | import androidx.compose.foundation.layout.fillMaxWidth |
| 27 | import androidx.compose.foundation.layout.windowInsetsPadding |
| 28 | import androidx.compose.foundation.layout.safeDrawing |
| 29 | import androidx.compose.foundation.layout.only |
| 30 | import androidx.compose.foundation.layout.WindowInsetsSides |
| 31 | import androidx.compose.foundation.layout.WindowInsets |
| 32 | import androidx.compose.foundation.layout.heightIn |
| 33 | import androidx.compose.foundation.layout.height |
| 34 | import androidx.compose.foundation.layout.offset |
| 35 | import androidx.compose.foundation.layout.padding |
| 36 | import androidx.compose.foundation.layout.systemBarsPadding |
| 37 | import androidx.compose.foundation.layout.size |
| 38 | import androidx.compose.foundation.layout.width |
| 39 | import androidx.compose.foundation.layout.widthIn |
| 40 | import androidx.compose.foundation.shape.CircleShape |
| 41 | import androidx.compose.foundation.shape.RoundedCornerShape |
| 42 | import androidx.compose.material.icons.Icons |
| 43 | import androidx.compose.material.icons.filled.Album |
| 44 | import androidx.compose.material.icons.filled.AvTimer |
| 45 | import androidx.compose.material.icons.filled.Tune |
| 46 | import androidx.compose.material.icons.filled.Cast |
| 47 | import androidx.compose.material.icons.filled.CastConnected |
| 48 | import androidx.compose.material.icons.filled.LocalOffer |
| 49 | import androidx.compose.material.icons.filled.Mic |
| 50 | import androidx.compose.material.icons.filled.Pause |
| 51 | import androidx.compose.material.icons.filled.PlayArrow |
| 52 | import androidx.compose.material.icons.filled.QueueMusic |
| 53 | import androidx.compose.material.icons.filled.Repeat |
| 54 | import androidx.compose.material.icons.filled.RepeatOn |
| 55 | import androidx.compose.material.icons.filled.RepeatOneOn |
| 56 | import androidx.compose.material.icons.filled.Search |
| 57 | import androidx.compose.material.icons.filled.Shuffle |
| 58 | import androidx.compose.material.icons.filled.ShuffleOn |
| 59 | import androidx.compose.material.icons.filled.SkipNext |
| 60 | import androidx.compose.material.icons.filled.SkipPrevious |
| 61 | import androidx.compose.material.icons.filled.Stop |
| 62 | import androidx.compose.material3.Icon |
| 63 | import androidx.compose.runtime.Composable |
| 64 | import androidx.compose.runtime.DisposableEffect |
| 65 | import androidx.compose.runtime.LaunchedEffect |
| 66 | import androidx.compose.runtime.collectAsState |
| 67 | import androidx.compose.runtime.getValue |
| 68 | import androidx.compose.runtime.mutableFloatStateOf |
| 69 | import androidx.compose.runtime.mutableStateOf |
| 70 | import androidx.compose.runtime.remember |
| 71 | import androidx.compose.runtime.rememberCoroutineScope |
| 72 | import kotlinx.coroutines.delay |
| 73 | import kotlinx.coroutines.launch |
| 74 | import androidx.compose.runtime.setValue |
| 75 | import androidx.compose.ui.Alignment |
| 76 | import androidx.compose.ui.Modifier |
| 77 | import androidx.compose.ui.draw.clip |
| 78 | import androidx.compose.ui.layout.ContentScale |
| 79 | import androidx.compose.ui.draw.drawWithContent |
| 80 | import androidx.compose.ui.ExperimentalComposeUiApi |
| 81 | import androidx.compose.ui.focus.FocusRequester |
| 82 | import androidx.compose.ui.focus.focusProperties |
| 83 | import androidx.compose.ui.focus.focusRequester |
| 84 | import androidx.compose.ui.graphics.Color |
| 85 | import androidx.compose.ui.graphics.painter.Painter |
| 86 | import androidx.compose.ui.graphics.vector.ImageVector |
| 87 | import androidx.compose.ui.res.painterResource |
| 88 | import androidx.compose.ui.input.key.Key |
| 89 | import androidx.compose.ui.input.key.KeyEventType |
| 90 | import androidx.compose.ui.input.key.key |
| 91 | import androidx.compose.ui.input.key.onKeyEvent |
| 92 | import androidx.compose.ui.input.key.type |
| 93 | import androidx.compose.ui.input.pointer.pointerInput |
| 94 | import androidx.compose.ui.platform.LocalConfiguration |
| 95 | import androidx.compose.ui.text.font.FontWeight |
| 96 | import androidx.compose.ui.text.style.TextOverflow |
| 97 | import androidx.compose.ui.layout.SubcomposeLayout |
| 98 | import androidx.compose.ui.unit.Constraints |
| 99 | import androidx.compose.ui.unit.dp |
| 100 | import androidx.compose.ui.unit.sp |
| 101 | import androidx.hilt.navigation.compose.hiltViewModel |
| 102 | import androidx.tv.material3.MaterialTheme |
| 103 | import androidx.tv.material3.Text |
| 104 | import dev.stave.R |
| 105 | import dev.stave.domain.model.parseGenres |
| 106 | import dev.stave.domain.model.RemoteRenderer |
| 107 | import dev.stave.domain.renderer.RepeatMode |
| 108 | import dev.stave.ui.components.ActionSheet |
| 109 | import dev.stave.ui.components.LocalMarqueeEnabled |
| 110 | import dev.stave.ui.components.SheetAction |
| 111 | import dev.stave.ui.components.staveFocusable |
| 112 | import dev.stave.ui.components.AlbumArt |
| 113 | import dev.stave.ui.components.staveMarquee |
| 114 | import dev.stave.ui.cast.CastPickerSheet |
| 115 | import dev.stave.ui.cast.RawCastPickerSheet |
| 116 | import dev.stave.ui.theme.AccentAmber |
| 117 | import dev.stave.ui.theme.CastReceiving |
| 118 | import dev.stave.ui.theme.OnAmber |
| 119 | import dev.stave.ui.theme.OnMuted |
| 120 | import dev.stave.ui.theme.OnPrimary |
| 121 | import dev.stave.ui.theme.OnQuiet |
| 122 | import dev.stave.ui.theme.SurfaceBase |
| 123 | import dev.stave.ui.theme.SurfaceDivider |
| 124 | import dev.stave.ui.theme.SurfaceRaised |
| 125 | import kotlin.time.Duration |
| 126 | import kotlin.time.Duration.Companion.milliseconds |
| 127 | import kotlin.time.Duration.Companion.seconds |
| 128 | import dev.stave.ui.theme.adaptiveScreenPaddingHorizontal |
| 129 | import dev.stave.ui.theme.adaptiveScreenPaddingVertical |
| 130 | import dev.stave.ui.theme.staveInterFamily |
| 131 | import dev.stave.ui.theme.withInterDigits |
| 132 | |
| 133 | @OptIn(ExperimentalFoundationApi::class) |
| 134 | @Composable |
| 135 | fun 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 |
| 1259 | private 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 |
| 1394 | private 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 |
| 1593 | private 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 |
| 1632 | private 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 |
| 1667 | private 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 |
| 1705 | private 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 | |
| 1751 | private 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 | */ |
| 1769 | private 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 |
| 1803 | private 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 |
| 1897 | private 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 |
| 2296 | private 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 |
| 2646 | private 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 |
| 2688 | private 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 |
| 2711 | private 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 |
| 2739 | private 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 |
| 2798 | private 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 |
| 2966 | private 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 |
| 3358 | private 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 | } |


