NowPlayingScreen.kt
Each line, with the release that last changed it.
| 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 | } |


