DetailScreen.kt
950 lines · 42.8 KB
Changed in: 0.5.0
| 1 | package dev.stave.ui.detail |
| 2 | |
| 3 | import androidx.activity.compose.BackHandler |
| 4 | import androidx.compose.foundation.ExperimentalFoundationApi |
| 5 | import androidx.compose.foundation.background |
| 6 | import androidx.compose.foundation.clickable |
| 7 | import androidx.compose.foundation.combinedClickable |
| 8 | import androidx.compose.foundation.interaction.MutableInteractionSource |
| 9 | import androidx.compose.foundation.layout.Arrangement |
| 10 | import androidx.compose.foundation.layout.Box |
| 11 | import androidx.compose.foundation.layout.Column |
| 12 | import androidx.compose.foundation.layout.Row |
| 13 | import androidx.compose.foundation.layout.Spacer |
| 14 | import androidx.compose.foundation.layout.fillMaxSize |
| 15 | import androidx.compose.foundation.layout.defaultMinSize |
| 16 | import androidx.compose.foundation.layout.PaddingValues |
| 17 | import androidx.compose.foundation.layout.systemBarsPadding |
| 18 | import androidx.compose.foundation.layout.fillMaxWidth |
| 19 | import androidx.compose.foundation.layout.height |
| 20 | import androidx.compose.foundation.layout.heightIn |
| 21 | import androidx.compose.foundation.layout.padding |
| 22 | import androidx.compose.foundation.layout.size |
| 23 | import androidx.compose.foundation.layout.width |
| 24 | import androidx.compose.foundation.layout.widthIn |
| 25 | import androidx.compose.foundation.lazy.LazyColumn |
| 26 | import androidx.compose.foundation.lazy.items |
| 27 | import androidx.compose.foundation.rememberScrollState |
| 28 | import androidx.compose.foundation.shape.RoundedCornerShape |
| 29 | import androidx.compose.foundation.verticalScroll |
| 30 | import androidx.compose.material.icons.Icons |
| 31 | import androidx.compose.material.icons.filled.Add |
| 32 | import androidx.compose.material.icons.filled.Info |
| 33 | import androidx.compose.material.icons.filled.PlayArrow |
| 34 | import androidx.compose.foundation.Image |
| 35 | import androidx.compose.material3.Icon |
| 36 | import androidx.compose.runtime.Composable |
| 37 | import androidx.compose.runtime.LaunchedEffect |
| 38 | import androidx.compose.runtime.collectAsState |
| 39 | import androidx.compose.runtime.getValue |
| 40 | import androidx.compose.runtime.mutableStateOf |
| 41 | import androidx.compose.foundation.layout.aspectRatio |
| 42 | import dev.stave.data.SettingsStore |
| 43 | import androidx.compose.ui.platform.LocalContext |
| 44 | import androidx.compose.ui.platform.LocalConfiguration |
| 45 | import androidx.compose.runtime.remember |
| 46 | import androidx.compose.runtime.setValue |
| 47 | import androidx.compose.ui.Alignment |
| 48 | import androidx.compose.ui.Modifier |
| 49 | import androidx.compose.ui.draw.clip |
| 50 | import androidx.compose.ui.focus.FocusRequester |
| 51 | import androidx.compose.ui.focus.focusProperties |
| 52 | import androidx.compose.ui.focus.focusRequester |
| 53 | import androidx.compose.ui.focus.onFocusChanged |
| 54 | import androidx.compose.ui.input.key.Key |
| 55 | import androidx.compose.ui.input.key.KeyEventType |
| 56 | import androidx.compose.ui.input.key.key |
| 57 | import androidx.compose.ui.input.key.onKeyEvent |
| 58 | import androidx.compose.ui.input.key.type |
| 59 | import androidx.compose.ui.graphics.painter.Painter |
| 60 | import androidx.compose.ui.graphics.Color |
| 61 | import androidx.compose.ui.graphics.vector.rememberVectorPainter |
| 62 | import androidx.compose.ui.res.painterResource |
| 63 | import androidx.compose.ui.text.style.TextAlign |
| 64 | import androidx.compose.ui.text.style.TextOverflow |
| 65 | import androidx.compose.ui.unit.Dp |
| 66 | import androidx.compose.ui.unit.dp |
| 67 | import androidx.hilt.navigation.compose.hiltViewModel |
| 68 | import androidx.tv.material3.MaterialTheme |
| 69 | import androidx.tv.material3.Text |
| 70 | import dev.stave.R |
| 71 | import dev.stave.domain.model.DidlItem |
| 72 | import dev.stave.domain.model.MetadataField |
| 73 | import dev.stave.ui.components.ActionSheet |
| 74 | import dev.stave.ui.components.AlbumArt |
| 75 | import dev.stave.ui.components.SheetAction |
| 76 | import dev.stave.ui.components.onDpadLongPress |
| 77 | import dev.stave.ui.components.staveFocusable |
| 78 | import dev.stave.ui.theme.OnMuted |
| 79 | import dev.stave.ui.theme.OnPrimary |
| 80 | import dev.stave.ui.theme.OnQuiet |
| 81 | import dev.stave.ui.theme.SurfaceRaised |
| 82 | import kotlin.time.Duration |
| 83 | import dev.stave.ui.theme.adaptiveScreenPaddingHorizontal |
| 84 | import dev.stave.ui.theme.adaptiveScreenPaddingVertical |
| 85 | import dev.stave.ui.theme.isCompactHeight |
| 86 | import dev.stave.ui.theme.isCompactWidth |
| 87 | import dev.stave.ui.theme.staveInterFamily |
| 88 | import dev.stave.ui.theme.withInterDigits |
| 89 | |
| 90 | @Composable |
| 91 | fun DetailScreen( |
| 92 | udn: String, |
| 93 | containerId: String, |
| 94 | onPlay: (tracks: List<DidlItem.Track>, startIndex: Int) -> Unit, |
| 95 | onEnqueue: (tracks: List<DidlItem.Track>) -> Unit, |
| 96 | onPlayNext: (tracks: List<DidlItem.Track>) -> Unit, |
| 97 | onEnqueueAndPlay: (tracks: List<DidlItem.Track>) -> Unit, |
| 98 | onMoreByArtist: (artistName: String) -> Unit, |
| 99 | onTracksByArtist: (artistName: String) -> Unit, |
| 100 | onShowMetadata: (track: DidlItem.Track) -> Unit, |
| 101 | queueIsEmpty: Boolean, |
| 102 | onBack: () -> Unit, |
| 103 | viewModel: DetailViewModel = hiltViewModel(), |
| 104 | ) { |
| 105 | val state by viewModel.state.collectAsState() |
| 106 | |
| 107 | LaunchedEffect(udn, containerId) { |
| 108 | viewModel.load(udn, containerId) |
| 109 | } |
| 110 | |
| 111 | BackHandler(onBack = onBack) |
| 112 | |
| 113 | // Confirm-replace sheet payload — non-null when we need the user to confirm clearing a |
| 114 | // non-empty queue before starting. Carries the startIndex too so clicking track 5 mid- |
| 115 | // album goes to track 5 (not track 1) after confirming. "Always warn before clearing." |
| 116 | var confirmPlay: ConfirmPlay? by remember { mutableStateOf(null) } |
| 117 | |
| 118 | // Track-level long-press action sheet target. Null = closed. Holds the track + the list |
| 119 | // it was in + its index so Play-from-here has full context. |
| 120 | var trackAction: TrackAction? by remember { mutableStateOf(null) } |
| 121 | |
| 122 | // Album-info modal visibility. The viewmodel owns the actual fetch and |
| 123 | // result state — this flag just gates the modal render. Resetting happens |
| 124 | // on dismiss so re-opening triggers a fresh fetch (cheap; OkHttp caches). |
| 125 | var albumInfoOpen by remember { mutableStateOf(false) } |
| 126 | val albumInfoState by viewModel.albumInfo.collectAsState() |
| 127 | |
| 128 | Box(Modifier.fillMaxSize().padding(horizontal = adaptiveScreenPaddingHorizontal(), vertical = adaptiveScreenPaddingVertical())) { |
| 129 | when (val s = state) { |
| 130 | DetailUiState.Loading -> CenteredMessage("Loading album…", Modifier.fillMaxSize()) |
| 131 | // Maintainer, 2026-10-29: error state gets a focusable Retry (auto-focused for TV |
| 132 | // D-pad) that re-runs the load; it was a dead end with nothing focusable. |
| 133 | is DetailUiState.Error -> DetailErrorWithRetry( |
| 134 | message = "Couldn't load album: ${s.message}", |
| 135 | onRetry = { viewModel.load(udn, containerId) }, |
| 136 | ) |
| 137 | is DetailUiState.Content -> DetailContent( |
| 138 | content = s, |
| 139 | queueIsEmpty = queueIsEmpty, |
| 140 | onPlayNow = { tracks, startIndex -> onPlay(tracks, startIndex) }, |
| 141 | onRequestConfirmPlay = { tracks, startIndex -> |
| 142 | confirmPlay = ConfirmPlay(tracks, startIndex) |
| 143 | }, |
| 144 | onEnqueue = onEnqueue, |
| 145 | onEnqueueAndPlay = onEnqueueAndPlay, |
| 146 | onPlayNext = onPlayNext, |
| 147 | onMoreByArtist = onMoreByArtist, |
| 148 | onAlbumInfo = { |
| 149 | albumInfoOpen = true |
| 150 | viewModel.loadAlbumInfo() |
| 151 | }, |
| 152 | onTrackLongPress = { track, siblings, idx -> |
| 153 | trackAction = TrackAction(track, siblings, idx) |
| 154 | }, |
| 155 | ) |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | if (albumInfoOpen) { |
| 160 | AlbumInfoModal( |
| 161 | state = albumInfoState, |
| 162 | albumTitle = (state as? DetailUiState.Content)?.albumTitle ?: "", |
| 163 | onDismiss = { |
| 164 | albumInfoOpen = false |
| 165 | viewModel.resetAlbumInfo() |
| 166 | }, |
| 167 | ) |
| 168 | } |
| 169 | |
| 170 | confirmPlay?.let { payload -> |
| 171 | val content = state as? DetailUiState.Content |
| 172 | ActionSheet( |
| 173 | title = "Clear queue and play?", |
| 174 | subtitle = content?.albumTitle?.let { "This will replace the current queue with \"$it\"." }, |
| 175 | actions = listOf( |
| 176 | SheetAction("▶", "Play album") { |
| 177 | onPlay(payload.tracks, payload.startIndex) |
| 178 | }, |
| 179 | ), |
| 180 | onDismiss = { confirmPlay = null }, |
| 181 | ) |
| 182 | } |
| 183 | |
| 184 | trackAction?.let { target -> |
| 185 | val artistForMenu = target.track.albumArtist ?: target.track.artist |
| 186 | ActionSheet( |
| 187 | title = target.track.title, |
| 188 | subtitle = listOfNotNull(target.track.artist, target.track.album).joinToString(" · ") |
| 189 | .ifBlank { null }, |
| 190 | actions = buildList { |
| 191 | // Enqueue and play — matches the single-click default and is the primary |
| 192 | // action users want 95% of the time: "play this track now without destroying |
| 193 | // my queue". |
| 194 | add(SheetAction("⏭", "Enqueue and play") { |
| 195 | onEnqueueAndPlay(listOf(target.track)) |
| 196 | }) |
| 197 | add(SheetAction("+", "Queue this track") { |
| 198 | onEnqueue(listOf(target.track)) |
| 199 | }) |
| 200 | add(SheetAction("⤵", "Play next") { |
| 201 | onPlayNext(listOf(target.track)) |
| 202 | }) |
| 203 | if (target.index < target.siblings.lastIndex) { |
| 204 | add(SheetAction("▼", "Queue from here to end") { |
| 205 | onEnqueue(target.siblings.drop(target.index)) |
| 206 | }) |
| 207 | } |
| 208 | // Destructive: replaces the entire queue with this track. Renamed from |
| 209 | // "Play now" — the new name makes the destructive semantics obvious. |
| 210 | // Positioned below the non-destructive options so the user reads through |
| 211 | // the safe ones first. |
| 212 | add(SheetAction("▶", "Play from new playlist") { |
| 213 | if (queueIsEmpty) onPlay(listOf(target.track), 0) |
| 214 | else confirmPlay = ConfirmPlay(listOf(target.track), 0) |
| 215 | }) |
| 216 | // Navigation away from the album — jump to other work by this artist. |
| 217 | artistForMenu?.let { a -> |
| 218 | add(SheetAction("♫", "Albums by $a") { onMoreByArtist(a) }) |
| 219 | add(SheetAction("♪", "Tracks by $a") { onTracksByArtist(a) }) |
| 220 | } |
| 221 | add(SheetAction("ⓘ", "Show tags") { |
| 222 | onShowMetadata(target.track) |
| 223 | }) |
| 224 | }, |
| 225 | onDismiss = { trackAction = null }, |
| 226 | ) |
| 227 | } |
| 228 | } |
| 229 | |
| 230 | private data class ConfirmPlay(val tracks: List<DidlItem.Track>, val startIndex: Int) |
| 231 | private data class TrackAction( |
| 232 | val track: DidlItem.Track, |
| 233 | val siblings: List<DidlItem.Track>, |
| 234 | val index: Int, |
| 235 | ) |
| 236 | |
| 237 | @Composable |
| 238 | private fun DetailContent( |
| 239 | content: DetailUiState.Content, |
| 240 | queueIsEmpty: Boolean, |
| 241 | onPlayNow: (tracks: List<DidlItem.Track>, startIndex: Int) -> Unit, |
| 242 | onRequestConfirmPlay: (tracks: List<DidlItem.Track>, startIndex: Int) -> Unit, |
| 243 | onEnqueue: (tracks: List<DidlItem.Track>) -> Unit, |
| 244 | onEnqueueAndPlay: (tracks: List<DidlItem.Track>) -> Unit, |
| 245 | onPlayNext: (tracks: List<DidlItem.Track>) -> Unit, |
| 246 | onMoreByArtist: (artistName: String) -> Unit, |
| 247 | onAlbumInfo: () -> Unit, |
| 248 | onTrackLongPress: (track: DidlItem.Track, siblings: List<DidlItem.Track>, index: Int) -> Unit, |
| 249 | ) { |
| 250 | val firstTrack = content.tracks.firstOrNull() |
| 251 | val moreButtonFocus = remember { FocusRequester() } |
| 252 | var lastFocusedTrackId by remember { mutableStateOf<String?>(null) } |
| 253 | val trackFocusRequesters = remember { mutableMapOf<String, FocusRequester>() } |
| 254 | |
| 255 | val leftyContext = LocalContext.current |
| 256 | val leftyStore = remember { SettingsStore(leftyContext) } |
| 257 | val lefty by leftyStore.leftyMode.collectAsState(initial = false) |
| 258 | var showFullArt by remember { mutableStateOf(false) } |
| 259 | val compact = isCompactWidth() |
| 260 | val largePortrait = dev.stave.ui.theme.isLargePortrait() |
| 261 | if (compact || largePortrait) { |
| 262 | // Compact (phone) AND large-portrait (Chromebase / tablet in |
| 263 | // portrait) layout: art + actions + title at the top, track list |
| 264 | // filling the remaining height below. The TV Row layout puts a |
| 265 | // 360dp left pane next to a weight(1f) track list — on a portrait |
| 266 | // device (whether a 411dp phone or a 600dp+ Chromebase rotated |
| 267 | // portrait) that layout wastes vertical space and shows the album |
| 268 | // art at 1/3 screen height. Stacking vertically lets the art and |
| 269 | // tracks both use the full width. |
| 270 | Column(Modifier.fillMaxSize()) { |
| 271 | // Header: art fills width minus vertical button column. |
| 272 | // Lefty toggle swaps button column to the left side. |
| 273 | val buttonColumn: @Composable () -> Unit = { |
| 274 | Column( |
| 275 | verticalArrangement = Arrangement.spacedBy(8.dp), |
| 276 | modifier = Modifier.padding(top = 4.dp), |
| 277 | ) { |
| 278 | IconAction( |
| 279 | icon = rememberVectorPainter(Icons.Filled.Add), |
| 280 | description = "Queue album", |
| 281 | ) { |
| 282 | if (content.tracks.isNotEmpty()) onEnqueue(content.tracks) |
| 283 | } |
| 284 | IconAction( |
| 285 | icon = rememberVectorPainter(Icons.Filled.PlayArrow), |
| 286 | description = "Play album", |
| 287 | ) { |
| 288 | if (content.tracks.isEmpty()) return@IconAction |
| 289 | if (queueIsEmpty) onPlayNow(content.tracks, 0) |
| 290 | else onRequestConfirmPlay(content.tracks, 0) |
| 291 | } |
| 292 | IconAction( |
| 293 | icon = painterResource(R.drawable.ic_headphones_vinyl), |
| 294 | description = content.artistName?.let { "More by $it" } ?: "More", |
| 295 | enabled = content.artistName != null, |
| 296 | ) { |
| 297 | // Audit 2026-09-02: this returned when the QUEUE was empty, |
| 298 | // so "More by artist" did nothing on any device with nothing |
| 299 | // queued. Navigation has no dependency on the queue. |
| 300 | val artist = content.artistName ?: return@IconAction |
| 301 | onMoreByArtist(artist) |
| 302 | } |
| 303 | IconAction( |
| 304 | icon = rememberVectorPainter(Icons.Filled.Info), |
| 305 | description = "Album info", |
| 306 | enabled = content.tracks.isNotEmpty(), |
| 307 | ) { |
| 308 | onAlbumInfo() |
| 309 | } |
| 310 | } |
| 311 | } |
| 312 | |
| 313 | // Track count + year on a single line, bullet-separated. The year |
| 314 | // moved here from the title column so the artwork header reads |
| 315 | // top-down as: title → artist → buttons, and the metadata footer |
| 316 | // groups "how many tracks, what year" as the single descriptive |
| 317 | // line above the list. |
| 318 | val headerYear = firstTrack?.date?.let { raw -> |
| 319 | val yr = raw.take(4).takeIf { s -> s.length == 4 && s.all(Char::isDigit) } |
| 320 | yr ?: raw.takeIf { it.isNotBlank() } |
| 321 | } |
| 322 | val headerLine = buildString { |
| 323 | append("${content.tracks.size} tracks") |
| 324 | if (headerYear != null) { |
| 325 | append(" \u00b7 ") |
| 326 | append(headerYear) |
| 327 | } |
| 328 | } |
| 329 | LazyColumn( |
| 330 | modifier = Modifier.fillMaxWidth().weight(1f), |
| 331 | verticalArrangement = Arrangement.spacedBy(4.dp), |
| 332 | ) { |
| 333 | // Header items — scroll with the tracklist |
| 334 | item(key = "header_art") { |
| 335 | Row( |
| 336 | Modifier.fillMaxWidth(), |
| 337 | horizontalArrangement = Arrangement.spacedBy(8.dp), |
| 338 | ) { |
| 339 | if (lefty) buttonColumn() |
| 340 | AlbumArt( |
| 341 | model = content.albumArtUri, |
| 342 | modifier = Modifier |
| 343 | .weight(1f) |
| 344 | .aspectRatio(1f) |
| 345 | .clip(RoundedCornerShape(2.dp)), |
| 346 | ) |
| 347 | if (!lefty) buttonColumn() |
| 348 | } |
| 349 | } |
| 350 | item(key = "header_title") { |
| 351 | Column { |
| 352 | Spacer(Modifier.height(10.dp)) |
| 353 | Text( |
| 354 | withInterDigits(content.albumTitle), |
| 355 | style = MaterialTheme.typography.titleLarge, |
| 356 | color = OnPrimary, |
| 357 | maxLines = 2, |
| 358 | overflow = TextOverflow.Ellipsis, |
| 359 | ) |
| 360 | firstTrack?.let { track -> |
| 361 | val primaryArtist = track.albumArtist ?: track.artist |
| 362 | if (!primaryArtist.isNullOrBlank()) { |
| 363 | Spacer(Modifier.height(2.dp)) |
| 364 | Text( |
| 365 | withInterDigits(primaryArtist), |
| 366 | style = MaterialTheme.typography.bodyMedium, |
| 367 | color = OnMuted, |
| 368 | maxLines = 2, |
| 369 | overflow = TextOverflow.Ellipsis, |
| 370 | ) |
| 371 | } |
| 372 | } |
| 373 | } |
| 374 | } |
| 375 | item(key = "header_meta") { |
| 376 | Text( |
| 377 | withInterDigits(headerLine), |
| 378 | style = MaterialTheme.typography.labelLarge, |
| 379 | color = OnMuted, |
| 380 | ) |
| 381 | Spacer(Modifier.height(8.dp)) |
| 382 | } |
| 383 | items(content.tracks, key = { it.id }) { track -> |
| 384 | val idx = content.tracks.indexOf(track) |
| 385 | val fr = trackFocusRequesters.getOrPut(track.id) { FocusRequester() } |
| 386 | TrackLine( |
| 387 | track = track, |
| 388 | leftFocus = moreButtonFocus, |
| 389 | focusRequester = fr, |
| 390 | onFocused = { lastFocusedTrackId = track.id }, |
| 391 | onClick = { onEnqueueAndPlay(listOf(track)) }, |
| 392 | onLongClick = { onTrackLongPress(track, content.tracks, idx) }, |
| 393 | ) |
| 394 | } |
| 395 | } |
| 396 | } |
| 397 | return |
| 398 | } |
| 399 | |
| 400 | // ── TV / wide layout — original Row-based code ── |
| 401 | // Phone landscape (isCompactWidth=false, ~914dp) falls here too. |
| 402 | // Detect TV vs phone-landscape so we can add system bar inset |
| 403 | // padding on phone (display cutout on the left edge in landscape). |
| 404 | val detailConfig = LocalConfiguration.current |
| 405 | val isDetailTv = (detailConfig.uiMode and android.content.res.Configuration.UI_MODE_TYPE_MASK) == |
| 406 | android.content.res.Configuration.UI_MODE_TYPE_TELEVISION |
| 407 | Row( |
| 408 | Modifier |
| 409 | .fillMaxSize() |
| 410 | .then( |
| 411 | if (isDetailTv) Modifier |
| 412 | else Modifier.systemBarsPadding() |
| 413 | ) |
| 414 | ) { |
| 415 | // visual middle of its column rather than hard-left. verticalScroll lets long metadata |
| 416 | // blocks overflow gracefully when there are many enabled fields — D-pad can't focus |
| 417 | // into pure text, but the important focusables (the three action buttons) live at the |
| 418 | // top of the column so the user never needs to scroll to interact. |
| 419 | // Maintainer, 2026-10-29: phone landscape (~245dp usable height) squeezed the icon row |
| 420 | // to 25dp and the title to 0. On compact height only, the left column scrolls and the |
| 421 | // art shrinks to 120dp; TV and portrait keep the original fixed column and 210dp art. |
| 422 | val compactHeight = isCompactHeight() |
| 423 | val leftColumnScroll = rememberScrollState() |
| 424 | Column( |
| 425 | Modifier |
| 426 | .width(360.dp) |
| 427 | .padding(end = 40.dp) |
| 428 | .then(if (compactHeight) Modifier.verticalScroll(leftColumnScroll) else Modifier), |
| 429 | horizontalAlignment = Alignment.CenterHorizontally, |
| 430 | ) { |
| 431 | // 210dp art keeps the header compact; with metadata moved to the right column |
| 432 | // there's no need to grow it back up to fill vertical space. |
| 433 | AlbumArt( |
| 434 | model = content.albumArtUri, |
| 435 | modifier = Modifier |
| 436 | .size(if (compactHeight) 120.dp else 210.dp) |
| 437 | .clip(RoundedCornerShape(2.dp)), |
| 438 | ) |
| 439 | Spacer(Modifier.height(10.dp)) |
| 440 | |
| 441 | // Icon-button action row. The right-most button carries moreButtonFocus so |
| 442 | // track rows' focusProperties.left can pin to it deterministically. All |
| 443 | // four IconActions render at the same 32dp contentSize so the row reads |
| 444 | // as visually uniform; per the compact layout's same fix, hand-tuning |
| 445 | // the headphones glyph smaller than the +/▶ Material icons read as |
| 446 | // inconsistent rather than balanced. |
| 447 | Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { |
| 448 | IconAction( |
| 449 | icon = rememberVectorPainter(Icons.Filled.Add), |
| 450 | description = "Queue album", |
| 451 | ) { |
| 452 | if (content.tracks.isNotEmpty()) onEnqueue(content.tracks) |
| 453 | } |
| 454 | IconAction( |
| 455 | icon = rememberVectorPainter(Icons.Filled.PlayArrow), |
| 456 | description = "Play album", |
| 457 | ) { |
| 458 | if (content.tracks.isEmpty()) return@IconAction |
| 459 | if (queueIsEmpty) onPlayNow(content.tracks, 0) |
| 460 | else onRequestConfirmPlay(content.tracks, 0) |
| 461 | } |
| 462 | IconAction( |
| 463 | icon = painterResource(R.drawable.ic_headphones_vinyl), |
| 464 | description = content.artistName?.let { "More by $it" } ?: "More", |
| 465 | enabled = content.artistName != null, |
| 466 | ) { |
| 467 | // Audit 2026-09-02: same inverted guard as the compact layout — |
| 468 | // "More by artist" did nothing while the queue was empty. |
| 469 | val artist = content.artistName ?: return@IconAction |
| 470 | onMoreByArtist(artist) |
| 471 | } |
| 472 | // Album info — opens a modal showing the ID3 COMMENT tag from the |
| 473 | // first track (most ripped libraries put album reviews / liner |
| 474 | // notes here), or a sibling .txt file when the COMMENT is empty. |
| 475 | // Loader is best-effort: when neither source has anything the |
| 476 | // modal shows a "no info available" message rather than not |
| 477 | // opening at all, so the button never feels broken. |
| 478 | // |
| 479 | // Right-most position carries moreButtonFocus (track rows' leftward |
| 480 | // focus target) and the right-edge key handler that restores focus |
| 481 | // to the last-focused track row. |
| 482 | IconAction( |
| 483 | icon = rememberVectorPainter(Icons.Filled.Info), |
| 484 | description = "Album info", |
| 485 | enabled = content.tracks.isNotEmpty(), |
| 486 | modifier = Modifier |
| 487 | .focusRequester(moreButtonFocus) |
| 488 | .onKeyEvent { event -> |
| 489 | // Intercept Right to restore the remembered-row focus. Without |
| 490 | // this, spatial Right from the right-most action button walks |
| 491 | // by Y and lands on whichever row is nearest — which drifts by |
| 492 | // a couple of rows on each Left→Right cycle as the list |
| 493 | // auto-scrolls. |
| 494 | if (event.type == KeyEventType.KeyDown && |
| 495 | event.key == Key.DirectionRight |
| 496 | ) { |
| 497 | val id = lastFocusedTrackId |
| 498 | val fr = id?.let { trackFocusRequesters[it] } |
| 499 | if (fr != null) { |
| 500 | runCatching { fr.requestFocus() } |
| 501 | return@onKeyEvent true |
| 502 | } |
| 503 | } |
| 504 | false |
| 505 | }, |
| 506 | ) { |
| 507 | onAlbumInfo() |
| 508 | } |
| 509 | } |
| 510 | |
| 511 | Spacer(Modifier.height(10.dp)) |
| 512 | Text( |
| 513 | withInterDigits(content.albumTitle), |
| 514 | style = MaterialTheme.typography.headlineMedium, |
| 515 | color = OnPrimary, |
| 516 | maxLines = 2, |
| 517 | overflow = TextOverflow.Ellipsis, |
| 518 | textAlign = TextAlign.Center, |
| 519 | ) |
| 520 | firstTrack?.let { track -> |
| 521 | val primaryArtist = track.albumArtist ?: track.artist |
| 522 | // Year: prefer the 4-digit leading chunk; fall back to the whole |
| 523 | // date string when it's something like "2004-03" (uncommon but |
| 524 | // valid UPnP date form). |
| 525 | val yearOrDate = track.date?.let { raw -> |
| 526 | val yr = raw.take(4).takeIf { s -> s.length == 4 && s.all(Char::isDigit) } |
| 527 | yr ?: raw.takeIf { it.isNotBlank() } |
| 528 | } |
| 529 | if (!primaryArtist.isNullOrBlank()) { |
| 530 | Spacer(Modifier.height(4.dp)) |
| 531 | Text( |
| 532 | withInterDigits(primaryArtist), |
| 533 | style = MaterialTheme.typography.bodyLarge, |
| 534 | color = OnMuted, |
| 535 | textAlign = TextAlign.Center, |
| 536 | ) |
| 537 | } |
| 538 | if (yearOrDate != null) { |
| 539 | Spacer(Modifier.height(2.dp)) |
| 540 | Text( |
| 541 | yearOrDate, |
| 542 | style = MaterialTheme.typography.bodyMedium.copy(fontFamily = staveInterFamily()), |
| 543 | color = OnMuted, |
| 544 | textAlign = TextAlign.Center, |
| 545 | ) |
| 546 | } |
| 547 | // Album-y extras (publisher/label, rights, producer) pulled from |
| 548 | // the UPnP DIDL properties bag. Keys we consider "album-y" are |
| 549 | // ones that describe the RELEASE rather than the track — label |
| 550 | // and publisher are the most common. We skip long blob keys |
| 551 | // (description, lyrics) because they'd overwhelm the column. |
| 552 | AlbumExtrasBlock(track.extras) |
| 553 | } |
| 554 | // Technical specs — moved down here beneath the title/artist/year block |
| 555 | // into the open space of the left column. Generous vertical air above |
| 556 | // (24dp) so the specs read as their own grouping, separate from the |
| 557 | // subtitle text directly above. |
| 558 | Spacer(Modifier.height(24.dp)) |
| 559 | firstTrack?.let { TechnicalSpecBlock(track = it, enabled = content.enabledFields) } |
| 560 | // Rich embedded-tag display (ID3v2 / FLAC Vorbis / OGG / MP4 iTunes) |
| 561 | // still lives in the "..." → View metadata action via MetadataScreen. |
| 562 | } |
| 563 | |
| 564 | // Right pane: track list with a metadata footer. Putting metadata here rather than the |
| 565 | // left column means it rides the LazyColumn's scroll — users can page down through the |
| 566 | // tracks and the metadata appears after the last track. No separate scroll container |
| 567 | // on the left to cause disorienting column-level auto-scrolling when focus crosses over. |
| 568 | Column(Modifier.weight(1f)) { |
| 569 | Text( |
| 570 | withInterDigits("${content.tracks.size} tracks"), |
| 571 | style = MaterialTheme.typography.labelLarge, |
| 572 | color = OnMuted |
| 573 | ) |
| 574 | Spacer(Modifier.height(12.dp)) |
| 575 | LazyColumn(verticalArrangement = Arrangement.spacedBy(4.dp)) { |
| 576 | items(content.tracks, key = { it.id }) { track -> |
| 577 | val idx = content.tracks.indexOf(track) |
| 578 | val fr = trackFocusRequesters.getOrPut(track.id) { FocusRequester() } |
| 579 | TrackLine( |
| 580 | track = track, |
| 581 | leftFocus = moreButtonFocus, |
| 582 | focusRequester = fr, |
| 583 | onFocused = { lastFocusedTrackId = track.id }, |
| 584 | onClick = { onEnqueueAndPlay(listOf(track)) }, |
| 585 | onLongClick = { onTrackLongPress(track, content.tracks, idx) }, |
| 586 | ) |
| 587 | } |
| 588 | } |
| 589 | } |
| 590 | } |
| 591 | } |
| 592 | |
| 593 | /** |
| 594 | * Icon action button, 56dp square. Raised surface background, amber border on focus. |
| 595 | * [enabled] greys the icon when false (used for "More" when no parent was discovered). |
| 596 | */ |
| 597 | @Composable |
| 598 | private fun IconAction( |
| 599 | icon: Painter, |
| 600 | description: String, |
| 601 | enabled: Boolean = true, |
| 602 | tinted: Boolean = true, |
| 603 | contentSize: Dp = 32.dp, |
| 604 | modifier: Modifier = Modifier, |
| 605 | onClick: () -> Unit, |
| 606 | ) { |
| 607 | val interaction = remember { MutableInteractionSource() } |
| 608 | Box( |
| 609 | modifier |
| 610 | .size(56.dp) |
| 611 | .background(SurfaceRaised) |
| 612 | .staveFocusable(interaction, cornerRadiusDp = 8) |
| 613 | .clickable(interaction, indication = null, enabled = enabled) { onClick() }, |
| 614 | contentAlignment = Alignment.Center, |
| 615 | ) { |
| 616 | if (tinted) { |
| 617 | // Material vector icons — tint with the theme color so they read |
| 618 | // uniformly with the rest of the icon row. contentSize defaults to |
| 619 | // 32dp (up from Icon's 24dp default) so + and ▶ have visible |
| 620 | // presence at TV viewing distance, while leaving room around each |
| 621 | // glyph for the focus ring to sit clear of the artwork. |
| 622 | Icon( |
| 623 | painter = icon, |
| 624 | contentDescription = description, |
| 625 | tint = if (enabled) OnPrimary else OnQuiet, |
| 626 | modifier = Modifier.size(contentSize), |
| 627 | ) |
| 628 | } else { |
| 629 | // Raster art — preserve the artwork's own colors, outlines, and |
| 630 | // alpha. Uses alpha to grey out when disabled rather than tint. |
| 631 | Image( |
| 632 | painter = icon, |
| 633 | contentDescription = description, |
| 634 | alpha = if (enabled) 1f else 0.35f, |
| 635 | modifier = Modifier.size(contentSize), |
| 636 | ) |
| 637 | } |
| 638 | } |
| 639 | } |
| 640 | |
| 641 | @Composable |
| 642 | private fun TechnicalSpecBlock(track: DidlItem.Track, enabled: Set<MetadataField>) { |
| 643 | // Compact single-line-per-spec summary. Deliberately minimal — the old |
| 644 | // MetadataBlock showed everything DIDL-accessible (artist, composer, genre |
| 645 | // duplicates of what's already in the subtitle) which was noise. Rich |
| 646 | // embedded-tag info is available from the "..." → View metadata action, |
| 647 | // which uses AudioMetadataReader to fetch ID3/FLAC/Vorbis/MP4 tags. |
| 648 | val specs = buildList { |
| 649 | if (MetadataField.FORMAT in enabled) { |
| 650 | track.mimeType?.substringAfter('/')?.uppercase()?.let { add(it) } |
| 651 | } |
| 652 | if (MetadataField.BIT_DEPTH in enabled) { |
| 653 | track.bitsPerSample?.let { add("${it}-bit") } |
| 654 | } |
| 655 | if (MetadataField.SAMPLE_RATE in enabled) { |
| 656 | track.sampleFrequency?.let { add("${"%.1f".format(it / 1000.0)} kHz") } |
| 657 | } |
| 658 | if (MetadataField.CHANNELS in enabled) { |
| 659 | track.channels?.let { add(if (it == 2) "Stereo" else if (it == 1) "Mono" else "${it}ch") } |
| 660 | } |
| 661 | if (MetadataField.BITRATE in enabled) { |
| 662 | // Only show bitrate when useful — mostly lossy formats (mp3, aac, ogg). |
| 663 | // FLAC/ALAC bitrate is a derived number that just takes space. |
| 664 | val isLossy = track.mimeType?.let { mime -> |
| 665 | mime.contains("mpeg", true) || mime.contains("mp3", true) || |
| 666 | mime.contains("aac", true) || mime.contains("m4a", true) || |
| 667 | mime.contains("ogg", true) || mime.contains("opus", true) |
| 668 | } == true |
| 669 | if (isLossy) track.bitrate?.let { add("${it / 1000} kbps") } |
| 670 | } |
| 671 | } |
| 672 | if (specs.isEmpty()) return |
| 673 | Text( |
| 674 | withInterDigits(specs.joinToString(" \u00b7 ")), |
| 675 | style = MaterialTheme.typography.labelMedium, |
| 676 | color = OnQuiet, |
| 677 | textAlign = TextAlign.Center, |
| 678 | maxLines = 2, |
| 679 | ) |
| 680 | } |
| 681 | |
| 682 | /** Small stack of album-level metadata lines pulled from the UPnP DIDL extras |
| 683 | * bag. Not every server exposes these; when nothing matches, renders nothing. |
| 684 | * |
| 685 | * The curated key list is ordered by how commonly it's album-y rather than |
| 686 | * track-y, and keys are presented in a deterministic order so the visual |
| 687 | * rhythm stays consistent across tracks. "Label" wins over the raw UPnP |
| 688 | * "publisher" tag because "Label" is how music listeners think about it. |
| 689 | * |
| 690 | * Skipped: keys whose value is very long (lyrics, description) since they'd |
| 691 | * overwhelm the column, and keys we already surface as typed fields (genre, |
| 692 | * track number, album artist, etc.). */ |
| 693 | @Composable |
| 694 | private fun AlbumExtrasBlock(extras: Map<String, String>) { |
| 695 | if (extras.isEmpty()) return |
| 696 | // Map from a human label to a list of candidate UPnP property keys. First |
| 697 | // non-blank hit wins. Keys are matched case-insensitively. |
| 698 | val entries = listOf( |
| 699 | "Label" to listOf("label", "dc:publisher", "publisher"), |
| 700 | "Producer" to listOf("upnp:producer", "producer"), |
| 701 | "Rights" to listOf("dc:rights", "rights"), |
| 702 | ).mapNotNull { (label, keys) -> |
| 703 | val value = keys.asSequence() |
| 704 | .mapNotNull { k -> extras.entries.firstOrNull { it.key.equals(k, ignoreCase = true) }?.value } |
| 705 | .firstOrNull { it.isNotBlank() && it.length <= 120 } |
| 706 | value?.let { label to it } |
| 707 | } |
| 708 | if (entries.isEmpty()) return |
| 709 | Spacer(Modifier.height(6.dp)) |
| 710 | entries.forEach { (label, value) -> |
| 711 | Text( |
| 712 | withInterDigits("$label: $value"), |
| 713 | style = MaterialTheme.typography.labelMedium, |
| 714 | color = OnQuiet, |
| 715 | textAlign = TextAlign.Center, |
| 716 | maxLines = 2, |
| 717 | overflow = TextOverflow.Ellipsis, |
| 718 | ) |
| 719 | } |
| 720 | } |
| 721 | |
| 722 | @OptIn(ExperimentalFoundationApi::class) |
| 723 | @Composable |
| 724 | private fun TrackLine( |
| 725 | track: DidlItem.Track, |
| 726 | leftFocus: FocusRequester, |
| 727 | focusRequester: FocusRequester, |
| 728 | onFocused: () -> Unit, |
| 729 | onClick: () -> Unit, |
| 730 | onLongClick: () -> Unit, |
| 731 | ) { |
| 732 | val interaction = remember { MutableInteractionSource() } |
| 733 | // Scale up text and touch targets on large portrait screens (HP |
| 734 | // Chromebase, tablets in portrait) where phone-sized rows are too |
| 735 | // small to tap comfortably on a 21" touchscreen. |
| 736 | val large = dev.stave.ui.theme.isLargePortrait() |
| 737 | val rowPadding = if (large) PaddingValues(start = 8.dp, end = 16.dp, top = 12.dp, bottom = 10.dp) |
| 738 | else PaddingValues(start = 2.dp, end = 8.dp, top = 4.dp, bottom = 2.dp) |
| 739 | val numberStyle = if (large) MaterialTheme.typography.titleMedium.copy(fontFamily = staveInterFamily()) |
| 740 | else MaterialTheme.typography.labelMedium.copy(fontFamily = staveInterFamily()) |
| 741 | val titleStyle = if (large) MaterialTheme.typography.titleLarge |
| 742 | else MaterialTheme.typography.titleSmall |
| 743 | val durationStyle = if (large) MaterialTheme.typography.titleSmall.copy(fontFamily = staveInterFamily()) |
| 744 | else MaterialTheme.typography.labelSmall.copy(fontFamily = staveInterFamily()) |
| 745 | val numberMinWidth = if (large) 56.dp else 40.dp |
| 746 | |
| 747 | Box( |
| 748 | Modifier |
| 749 | .fillMaxWidth() |
| 750 | .background(SurfaceRaised) |
| 751 | // Pin Left navigation to the left-column "..." button. Without this, spatial |
| 752 | // Left would hunt by Y-coordinate — OK for rows near the button row's Y, but |
| 753 | // rows toward the bottom of the list would land on + or play, or sometimes |
| 754 | // fall through to the bar-shortcut. Explicit pinning is deterministic. |
| 755 | .focusProperties { left = leftFocus } |
| 756 | .focusRequester(focusRequester) |
| 757 | .onFocusChanged { if (it.isFocused) onFocused() } |
| 758 | .staveFocusable(interaction) |
| 759 | .onDpadLongPress(onLongClick) |
| 760 | .combinedClickable( |
| 761 | interactionSource = interaction, |
| 762 | indication = null, |
| 763 | onClick = onClick, |
| 764 | onLongClick = onLongClick, |
| 765 | ) |
| 766 | .padding(rowPadding) |
| 767 | ) { |
| 768 | Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) { |
| 769 | track.trackNumber?.let { |
| 770 | Text( |
| 771 | it.toString(), |
| 772 | style = numberStyle, |
| 773 | color = OnQuiet, |
| 774 | textAlign = TextAlign.End, |
| 775 | maxLines = 1, |
| 776 | modifier = Modifier.defaultMinSize(minWidth = numberMinWidth), |
| 777 | ) |
| 778 | } |
| 779 | Text( |
| 780 | withInterDigits(track.title), |
| 781 | style = titleStyle, |
| 782 | color = OnPrimary, |
| 783 | maxLines = 1, |
| 784 | overflow = TextOverflow.Ellipsis, |
| 785 | modifier = Modifier.weight(1f) |
| 786 | ) |
| 787 | track.duration?.let { |
| 788 | Text( |
| 789 | it.formatTime(), |
| 790 | style = durationStyle, |
| 791 | color = OnMuted, |
| 792 | ) |
| 793 | } |
| 794 | } |
| 795 | } |
| 796 | } |
| 797 | |
| 798 | @Composable |
| 799 | private fun CenteredMessage(text: String, modifier: Modifier = Modifier) { |
| 800 | Box(modifier, contentAlignment = Alignment.Center) { |
| 801 | Text(text, style = MaterialTheme.typography.bodyLarge, color = OnMuted) |
| 802 | } |
| 803 | } |
| 804 | |
| 805 | // Maintainer, 2026-10-29: error message + Retry button (same pattern as BrowseScreen's |
| 806 | // ErrorWithRetry). The button requests focus on entry so TV D-pad Center retries directly. |
| 807 | @Composable |
| 808 | private fun DetailErrorWithRetry(message: String, onRetry: () -> Unit) { |
| 809 | val retryFocus = remember { FocusRequester() } |
| 810 | LaunchedEffect(Unit) { |
| 811 | runCatching { retryFocus.requestFocus() } |
| 812 | } |
| 813 | val interaction = remember { MutableInteractionSource() } |
| 814 | Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { |
| 815 | Column( |
| 816 | horizontalAlignment = Alignment.CenterHorizontally, |
| 817 | verticalArrangement = Arrangement.spacedBy(16.dp), |
| 818 | ) { |
| 819 | Text( |
| 820 | message, |
| 821 | style = MaterialTheme.typography.bodyLarge, |
| 822 | color = OnMuted, |
| 823 | textAlign = TextAlign.Center, |
| 824 | ) |
| 825 | Box( |
| 826 | Modifier |
| 827 | .focusRequester(retryFocus) |
| 828 | .background(SurfaceRaised) |
| 829 | .staveFocusable(interaction, cornerRadiusDp = 6) |
| 830 | .clickable(interaction, indication = null) { onRetry() } |
| 831 | .padding(horizontal = 24.dp, vertical = 10.dp) |
| 832 | ) { |
| 833 | Text("Retry", style = MaterialTheme.typography.labelLarge, color = OnPrimary) |
| 834 | } |
| 835 | } |
| 836 | } |
| 837 | } |
| 838 | |
| 839 | private fun Duration.formatTime(): String { |
| 840 | val total = inWholeSeconds |
| 841 | return "%d:%02d".format(total / 60, total % 60) |
| 842 | } |
| 843 | |
| 844 | /** |
| 845 | * Modal overlay showing the album-info text fetched by [DetailViewModel]. |
| 846 | * |
| 847 | * Renders one of three states based on [state]: |
| 848 | * - [AlbumInfoState.Loading] — "Loading…" placeholder |
| 849 | * - [AlbumInfoState.Loaded] — scrollable prose |
| 850 | * - [AlbumInfoState.Empty] — "No information available" message |
| 851 | * - [AlbumInfoState.Idle] — should never reach UI; treated as Loading |
| 852 | * |
| 853 | * Dismiss handling: Back key, scrim tap, or D-pad-press anywhere outside |
| 854 | * the card all call [onDismiss]. Inside the card, scroll is consumed by |
| 855 | * the prose column; D-pad Up/Down advances the scroll, D-pad Back closes. |
| 856 | */ |
| 857 | @Composable |
| 858 | private fun AlbumInfoModal( |
| 859 | state: AlbumInfoState, |
| 860 | albumTitle: String, |
| 861 | onDismiss: () -> Unit, |
| 862 | ) { |
| 863 | BackHandler(onBack = onDismiss) |
| 864 | Box( |
| 865 | Modifier |
| 866 | .fillMaxSize() |
| 867 | .background(Color.Black.copy(alpha = 0.75f)) |
| 868 | .clickable( |
| 869 | interactionSource = remember { MutableInteractionSource() }, |
| 870 | indication = null, |
| 871 | onClick = onDismiss, |
| 872 | ), |
| 873 | contentAlignment = Alignment.Center, |
| 874 | ) { |
| 875 | Column( |
| 876 | Modifier |
| 877 | // Never wider than the screen (design decision 2026-09-02, |
| 878 | // I.52): min 640dp overflowed every phone. |
| 879 | .fillMaxWidth(0.92f) |
| 880 | .widthIn(max = 920.dp) |
| 881 | .heightIn(max = 540.dp) |
| 882 | .clip(RoundedCornerShape(16.dp)) |
| 883 | .background(SurfaceRaised) |
| 884 | // Absorb clicks so the scrim's dismiss-on-tap doesn't fire when |
| 885 | // the user clicks within the card itself. |
| 886 | .clickable( |
| 887 | interactionSource = remember { MutableInteractionSource() }, |
| 888 | indication = null, |
| 889 | onClick = {}, |
| 890 | ) |
| 891 | .padding(horizontal = 32.dp, vertical = 28.dp), |
| 892 | ) { |
| 893 | Text( |
| 894 | "Album info", |
| 895 | style = MaterialTheme.typography.titleLarge, |
| 896 | color = OnPrimary, |
| 897 | ) |
| 898 | if (albumTitle.isNotBlank()) { |
| 899 | Spacer(Modifier.height(4.dp)) |
| 900 | Text( |
| 901 | withInterDigits(albumTitle), |
| 902 | style = MaterialTheme.typography.bodyMedium, |
| 903 | color = OnMuted, |
| 904 | maxLines = 2, |
| 905 | overflow = TextOverflow.Ellipsis, |
| 906 | ) |
| 907 | } |
| 908 | Spacer(Modifier.height(20.dp)) |
| 909 | // Body. Loading and Empty states render once; Loaded scrolls. |
| 910 | when (state) { |
| 911 | is AlbumInfoState.Loaded -> { |
| 912 | val scroll = rememberScrollState() |
| 913 | Column( |
| 914 | Modifier |
| 915 | .fillMaxWidth() |
| 916 | .verticalScroll(scroll), |
| 917 | ) { |
| 918 | Text( |
| 919 | withInterDigits(state.text), |
| 920 | style = MaterialTheme.typography.bodyLarge, |
| 921 | color = OnPrimary, |
| 922 | ) |
| 923 | } |
| 924 | } |
| 925 | AlbumInfoState.Empty -> Text( |
| 926 | "No information available for this album.", |
| 927 | style = MaterialTheme.typography.bodyLarge, |
| 928 | color = OnMuted, |
| 929 | ) |
| 930 | // Loading and Idle render the same placeholder. Idle is |
| 931 | // theoretically unreachable here (the parent only opens the |
| 932 | // modal after calling loadAlbumInfo()), but covering it keeps |
| 933 | // the when-exhaustiveness satisfied and avoids a flash if the |
| 934 | // viewmodel is reset mid-render. |
| 935 | AlbumInfoState.Loading, |
| 936 | AlbumInfoState.Idle -> Text( |
| 937 | "Loading…", |
| 938 | style = MaterialTheme.typography.bodyLarge, |
| 939 | color = OnMuted, |
| 940 | ) |
| 941 | } |
| 942 | Spacer(Modifier.height(20.dp)) |
| 943 | Text( |
| 944 | "Press Back to close.", |
| 945 | style = MaterialTheme.typography.labelMedium, |
| 946 | color = OnQuiet, |
| 947 | ) |
| 948 | } |
| 949 | } |
| 950 | } |


