QueueScreen.kt
714 lines · 33.9 KB
Changed in: 0.5.0
| 1 | package dev.stave.ui.queue |
| 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.IntrinsicSize |
| 11 | import androidx.compose.foundation.layout.aspectRatio |
| 12 | import androidx.compose.foundation.layout.fillMaxHeight |
| 13 | import androidx.compose.foundation.layout.heightIn |
| 14 | import androidx.compose.foundation.layout.Box |
| 15 | import androidx.compose.foundation.layout.Column |
| 16 | import androidx.compose.foundation.layout.Row |
| 17 | import androidx.compose.foundation.layout.Spacer |
| 18 | import androidx.compose.foundation.layout.fillMaxSize |
| 19 | import androidx.compose.foundation.layout.fillMaxWidth |
| 20 | import androidx.compose.foundation.layout.height |
| 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.lazy.LazyColumn |
| 25 | import androidx.compose.foundation.lazy.itemsIndexed |
| 26 | import androidx.compose.foundation.lazy.rememberLazyListState |
| 27 | // Maintainer, 2026-10-29: empty-state scroll + TV detection for the Browse library button. |
| 28 | import androidx.compose.foundation.rememberScrollState |
| 29 | import androidx.compose.foundation.verticalScroll |
| 30 | import androidx.compose.ui.platform.LocalConfiguration |
| 31 | import androidx.compose.foundation.shape.RoundedCornerShape |
| 32 | import androidx.compose.runtime.Composable |
| 33 | import androidx.compose.runtime.LaunchedEffect |
| 34 | import androidx.compose.runtime.collectAsState |
| 35 | import androidx.compose.runtime.getValue |
| 36 | import androidx.compose.runtime.mutableStateOf |
| 37 | import androidx.compose.runtime.remember |
| 38 | import androidx.compose.runtime.setValue |
| 39 | import androidx.compose.ui.Alignment |
| 40 | import androidx.compose.ui.Modifier |
| 41 | import androidx.compose.ui.draw.clip |
| 42 | import androidx.compose.ui.focus.FocusRequester |
| 43 | import androidx.compose.ui.focus.focusRequester |
| 44 | import androidx.compose.ui.focus.onFocusChanged |
| 45 | import androidx.compose.ui.text.style.TextOverflow |
| 46 | import androidx.compose.ui.unit.dp |
| 47 | import androidx.hilt.navigation.compose.hiltViewModel |
| 48 | import androidx.compose.material.icons.Icons |
| 49 | import androidx.compose.material.icons.filled.DeleteSweep |
| 50 | import androidx.compose.material.icons.filled.FolderOpen |
| 51 | import androidx.compose.material.icons.filled.Pause |
| 52 | import androidx.compose.material.icons.filled.PlayArrow |
| 53 | import androidx.compose.material.icons.filled.Save |
| 54 | import androidx.compose.material.icons.filled.Stop |
| 55 | import androidx.tv.material3.Icon |
| 56 | import androidx.tv.material3.MaterialTheme |
| 57 | import androidx.tv.material3.Text |
| 58 | import dev.stave.domain.model.DidlItem |
| 59 | import dev.stave.domain.renderer.RepeatMode |
| 60 | import dev.stave.ui.components.ActionSheet |
| 61 | import dev.stave.ui.components.AlbumArt |
| 62 | import dev.stave.ui.components.SheetAction |
| 63 | import dev.stave.ui.components.onDpadLongPress |
| 64 | import dev.stave.ui.components.staveFocusable |
| 65 | import dev.stave.ui.nav.LocalLastContentFocus |
| 66 | import dev.stave.ui.nowplaying.NowPlayingViewModel |
| 67 | import dev.stave.ui.theme.AccentAmber |
| 68 | import androidx.compose.ui.graphics.vector.ImageVector |
| 69 | import dev.stave.ui.theme.AccentAmberSoft |
| 70 | import dev.stave.ui.theme.OnMuted |
| 71 | import dev.stave.ui.theme.OnPrimary |
| 72 | import dev.stave.ui.theme.OnQuiet |
| 73 | import dev.stave.ui.theme.SurfaceDivider |
| 74 | import dev.stave.ui.theme.SurfaceRaised |
| 75 | import dev.stave.ui.theme.adaptiveScreenPaddingHorizontal |
| 76 | import dev.stave.ui.theme.adaptiveScreenPaddingVertical |
| 77 | import dev.stave.ui.theme.isCompactWidth |
| 78 | import dev.stave.ui.theme.staveInterFamily |
| 79 | import dev.stave.ui.theme.withInterDigits |
| 80 | |
| 81 | /** |
| 82 | * View and manipulate the playback queue. Short-press a row = jump to that track and play. |
| 83 | * Long-press = open the action sheet with remove / move / play options. Header has a Clear |
| 84 | * action that empties the queue and stops playback. |
| 85 | * |
| 86 | * Reuses [NowPlayingViewModel] rather than defining its own VM — the queue lives on the |
| 87 | * renderer's state flow; there's no additional state to manage here. |
| 88 | */ |
| 89 | @Composable |
| 90 | fun QueueScreen( |
| 91 | onBack: () -> Unit, |
| 92 | onSavePlaylist: () -> Unit, |
| 93 | onLoadPlaylist: () -> Unit, |
| 94 | onShowMetadata: (track: DidlItem.Track) -> Unit, |
| 95 | onMoreByArtist: (artistName: String) -> Unit, |
| 96 | viewModel: NowPlayingViewModel = hiltViewModel(), |
| 97 | // Maintainer, 2026-10-29: optional — when non-null the empty state shows "Browse library". |
| 98 | onBrowse: (() -> Unit)? = null, |
| 99 | ) { |
| 100 | // Cast follow-mode awareness — when observing a CCA session via raw-cast, |
| 101 | // the queue shown is the RECEIVER's live queue (fetched over the raw |
| 102 | // protocol and refreshed on QUEUE_CHANGE), so followers see exactly what |
| 103 | // the sender sees, including live edits. Tapping a row jumps the observed |
| 104 | // playback. Remove/Move/Clear stay hidden in observer mode: mutating the |
| 105 | // receiver queue from a follower would desync the sender's authoritative |
| 106 | // local copy (the sender full-reloads the receiver queue on its own edits |
| 107 | // and would clobber follower changes). |
| 108 | val isObservingCast by viewModel.rawCast.observing.collectAsState() |
| 109 | val castDeviceName by viewModel.rawCast.connectedDeviceName.collectAsState() |
| 110 | val localState by viewModel.state.collectAsState() |
| 111 | val observedState by viewModel.rawCast.playbackState.collectAsState() |
| 112 | val state = if (isObservingCast) observedState else localState |
| 113 | BackHandler(onBack = onBack) |
| 114 | |
| 115 | // Long-press target — null when sheet is closed, non-null when open. |
| 116 | var sheetIndex: Int? by remember { mutableStateOf(null) } |
| 117 | |
| 118 | // Scroll + focus control so opening the Queue screen lands the user on the currently- |
| 119 | // playing track, centered. Without this, a long queue opens scrolled to the top with |
| 120 | // focus nowhere useful — user has to page through dozens of rows to find "now playing." |
| 121 | val listState = rememberLazyListState() |
| 122 | val currentFocus = remember { FocusRequester() } |
| 123 | // Follow the playing row on every track change, local or following — |
| 124 | // the original behaviour. (A version that held still while following |
| 125 | // was built without the maintainer's word and reverted on his ruling, |
| 126 | // 2026-09-02.) |
| 127 | LaunchedEffect(state.queueIndex, state.queue.size) { |
| 128 | val idx = state.queueIndex |
| 129 | if (idx < 0 || idx >= state.queue.size) return@LaunchedEffect |
| 130 | // Scroll + focus. If the screen is in the middle of disposal (user pressed |
| 131 | // back during a track transition), the target focus node may not exist; |
| 132 | // runCatching swallows that cleanly. Small delay lets the LazyColumn settle |
| 133 | // its layout before we try to measure viewport + focus a row — reduces the |
| 134 | // race where layoutInfo reports 0 height or the row hasn't been composed yet. |
| 135 | kotlinx.coroutines.delay(50) |
| 136 | runCatching { |
| 137 | val viewport = listState.layoutInfo.viewportSize.height |
| 138 | val offset = -(viewport / 2) |
| 139 | listState.scrollToItem(idx, offset) |
| 140 | } |
| 141 | runCatching { currentFocus.requestFocus() } |
| 142 | } |
| 143 | |
| 144 | Column( |
| 145 | Modifier |
| 146 | .fillMaxSize() |
| 147 | .padding(horizontal = adaptiveScreenPaddingHorizontal(), vertical = adaptiveScreenPaddingVertical()) |
| 148 | ) { |
| 149 | // Compact (phone-portrait) header: one row, "Queue · N tracks" on the |
| 150 | // left + iconified Load / Save / Clear on the right. The prior layout |
| 151 | // stacked the title above the action button row — two rows ate ~80dp |
| 152 | // of headroom above the queue itself for two pieces of UI that |
| 153 | // comfortably fit on one. Title at titleMedium (was titleLarge), the |
| 154 | // three actions are 40dp icon boxes with contentDescription for |
| 155 | // screen readers and tooltips. Clear is hidden when the queue is |
| 156 | // empty (same conditional the prior layout used). On a 411dp screen |
| 157 | // the title runs ~150dp and 3 × 40dp + spacing = ~136dp, fitting |
| 158 | // comfortably with the SpaceBetween arrangement. |
| 159 | val modeIndicators = buildList { |
| 160 | if (state.shuffleEnabled) add("Shuffle") |
| 161 | when (state.repeatMode) { |
| 162 | RepeatMode.ALL -> add("Repeat") |
| 163 | RepeatMode.ONE -> add("Repeat 1") |
| 164 | RepeatMode.OFF -> {} |
| 165 | } |
| 166 | }.joinToString(" · ") |
| 167 | |
| 168 | if (isCompactWidth()) { |
| 169 | Row( |
| 170 | Modifier.fillMaxWidth(), |
| 171 | horizontalArrangement = Arrangement.SpaceBetween, |
| 172 | verticalAlignment = Alignment.CenterVertically, |
| 173 | ) { |
| 174 | val countLabel = if (state.queue.isEmpty()) "Empty" else "${state.queue.size} tracks" |
| 175 | Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { |
| 176 | Text( |
| 177 | withInterDigits("Queue · $countLabel"), |
| 178 | style = MaterialTheme.typography.titleMedium, |
| 179 | color = OnPrimary, |
| 180 | ) |
| 181 | if (modeIndicators.isNotBlank()) { |
| 182 | Text( |
| 183 | modeIndicators, |
| 184 | style = MaterialTheme.typography.labelSmall, |
| 185 | color = AccentAmber, |
| 186 | ) |
| 187 | } |
| 188 | } |
| 189 | Row( |
| 190 | horizontalArrangement = Arrangement.spacedBy(4.dp), |
| 191 | verticalAlignment = Alignment.CenterVertically, |
| 192 | ) { |
| 193 | CompactIconHeaderAction( |
| 194 | icon = Icons.Filled.FolderOpen, |
| 195 | description = "Load playlist", |
| 196 | onClick = onLoadPlaylist, |
| 197 | ) |
| 198 | if (state.queue.isNotEmpty() && !isObservingCast) { |
| 199 | CompactIconHeaderAction( |
| 200 | icon = Icons.Filled.Save, |
| 201 | description = "Save queue as playlist", |
| 202 | onClick = onSavePlaylist, |
| 203 | ) |
| 204 | CompactIconHeaderAction( |
| 205 | icon = Icons.Filled.DeleteSweep, |
| 206 | description = "Clear queue", |
| 207 | onClick = { viewModel.clearQueue() }, |
| 208 | ) |
| 209 | } |
| 210 | } |
| 211 | } |
| 212 | } else { |
| 213 | // Wide (tablet/landscape) header: EVERYTHING above the queue on |
| 214 | // ONE baseline-aligned line — title, count, mode indicators, and |
| 215 | // the following-notice — with the action buttons at the trailing |
| 216 | // edge. The prior layout stacked title over count and floated the |
| 217 | // notice on its own line below a 28dp spacer, burning three rows |
| 218 | // of headroom on what fits comfortably in one at tablet widths |
| 219 | // (field-rejected). |
| 220 | Row( |
| 221 | Modifier.fillMaxWidth(), |
| 222 | horizontalArrangement = Arrangement.SpaceBetween, |
| 223 | verticalAlignment = Alignment.CenterVertically |
| 224 | ) { |
| 225 | Row( |
| 226 | horizontalArrangement = Arrangement.spacedBy(12.dp), |
| 227 | modifier = Modifier.weight(1f, fill = false), |
| 228 | ) { |
| 229 | Text( |
| 230 | "Queue", |
| 231 | style = MaterialTheme.typography.displaySmall, |
| 232 | color = OnPrimary, |
| 233 | maxLines = 1, |
| 234 | modifier = Modifier.alignByBaseline(), |
| 235 | ) |
| 236 | Text( |
| 237 | withInterDigits(if (state.queue.isEmpty()) "Empty" else "${state.queue.size} tracks"), |
| 238 | style = MaterialTheme.typography.bodyMedium, |
| 239 | color = OnMuted, |
| 240 | maxLines = 1, |
| 241 | modifier = Modifier.alignByBaseline(), |
| 242 | ) |
| 243 | if (modeIndicators.isNotBlank()) { |
| 244 | Text( |
| 245 | modeIndicators, |
| 246 | style = MaterialTheme.typography.bodySmall, |
| 247 | color = AccentAmber, |
| 248 | maxLines = 1, |
| 249 | modifier = Modifier.alignByBaseline(), |
| 250 | ) |
| 251 | } |
| 252 | if (isObservingCast) { |
| 253 | val label = if (castDeviceName != null) { |
| 254 | "Following $castDeviceName — queue is read-only here" |
| 255 | } else { |
| 256 | "Following Chromecast — queue is read-only here" |
| 257 | } |
| 258 | Text( |
| 259 | label, |
| 260 | style = MaterialTheme.typography.labelSmall, |
| 261 | color = dev.stave.ui.theme.CastReceiving, |
| 262 | maxLines = 1, |
| 263 | overflow = TextOverflow.Ellipsis, |
| 264 | modifier = Modifier.alignByBaseline(), |
| 265 | ) |
| 266 | } |
| 267 | } |
| 268 | Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { |
| 269 | HeaderAction(label = "Load") { onLoadPlaylist() } |
| 270 | if (state.queue.isNotEmpty() && !isObservingCast) { |
| 271 | HeaderAction(label = "Save") { onSavePlaylist() } |
| 272 | HeaderAction(label = "Clear") { viewModel.clearQueue() } |
| 273 | } |
| 274 | } |
| 275 | } |
| 276 | } |
| 277 | |
| 278 | Spacer(Modifier.height(if (isCompactWidth()) 6.dp else 12.dp)) |
| 279 | |
| 280 | // Observe-mode notice — only when following a CCA session. Tells the |
| 281 | // user why move actions are gone from the long-press sheet. CastReceiving |
| 282 | // blue matches the cast button tint on NowPlaying so the same color |
| 283 | // means "we're following, not driving" across surfaces. |
| 284 | if (isObservingCast && isCompactWidth()) { |
| 285 | // Wide layouts carry this inline in the single-line header |
| 286 | // above; only the compact (phone-portrait) header still needs |
| 287 | // the standalone line. |
| 288 | val label = if (castDeviceName != null) "Following $castDeviceName — queue is read-only here" |
| 289 | else "Following Chromecast — queue is read-only here" |
| 290 | Text( |
| 291 | label, |
| 292 | style = MaterialTheme.typography.labelSmall, |
| 293 | color = dev.stave.ui.theme.CastReceiving, |
| 294 | modifier = Modifier |
| 295 | .fillMaxWidth() |
| 296 | .padding(bottom = 6.dp), |
| 297 | ) |
| 298 | } |
| 299 | |
| 300 | if (state.queue.isEmpty()) { |
| 301 | // Maintainer, 2026-10-29: optional "Browse library" button under the empty-state |
| 302 | // text (shown when onBrowse is wired). It takes focus on TV so D-pad Center |
| 303 | // works immediately. The column scrolls so the button stays reachable on phone |
| 304 | // landscape (~245dp usable); when everything fits the Box still centers it. |
| 305 | val browseFocus = remember { FocusRequester() } |
| 306 | val emptyConfig = LocalConfiguration.current |
| 307 | val isTv = (emptyConfig.uiMode and android.content.res.Configuration.UI_MODE_TYPE_MASK) == |
| 308 | android.content.res.Configuration.UI_MODE_TYPE_TELEVISION |
| 309 | if (onBrowse != null) { |
| 310 | LaunchedEffect(isTv) { |
| 311 | if (isTv) runCatching { browseFocus.requestFocus() } |
| 312 | } |
| 313 | } |
| 314 | Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { |
| 315 | Column( |
| 316 | Modifier.verticalScroll(rememberScrollState()), |
| 317 | horizontalAlignment = Alignment.CenterHorizontally, |
| 318 | ) { |
| 319 | coil3.compose.AsyncImage( |
| 320 | model = dev.stave.R.drawable.earhorn_gramophone_glow, |
| 321 | contentDescription = null, |
| 322 | modifier = Modifier.size(160.dp), |
| 323 | ) |
| 324 | Spacer(Modifier.height(16.dp)) |
| 325 | Text( |
| 326 | "Nothing Queued.\nBrowse the library\n& play something", |
| 327 | style = MaterialTheme.typography.bodyLarge, |
| 328 | color = OnMuted, |
| 329 | textAlign = androidx.compose.ui.text.style.TextAlign.Center, |
| 330 | ) |
| 331 | if (onBrowse != null) { |
| 332 | Spacer(Modifier.height(16.dp)) |
| 333 | val browseInteraction = remember { MutableInteractionSource() } |
| 334 | Box( |
| 335 | Modifier |
| 336 | .focusRequester(browseFocus) |
| 337 | .background(SurfaceRaised) |
| 338 | .staveFocusable(browseInteraction, cornerRadiusDp = 6) |
| 339 | .clickable(browseInteraction, indication = null) { onBrowse() } |
| 340 | .padding(horizontal = 20.dp, vertical = 10.dp) |
| 341 | ) { |
| 342 | Text("Browse library", style = MaterialTheme.typography.labelLarge, color = OnPrimary) |
| 343 | } |
| 344 | } |
| 345 | } |
| 346 | } |
| 347 | } else { |
| 348 | val queueKeys = remember(state.queue) { |
| 349 | val seen = HashMap<String, Int>() |
| 350 | state.queue.map { t -> |
| 351 | val base = "${t.id}_${t.resourceUrl}" |
| 352 | val n = (seen[base] ?: 0) + 1 |
| 353 | seen[base] = n |
| 354 | "$base#$n" |
| 355 | } |
| 356 | } |
| 357 | LazyColumn( |
| 358 | state = listState, |
| 359 | verticalArrangement = Arrangement.spacedBy(4.dp), |
| 360 | ) { |
| 361 | // Maintainer, 2026-10-29: the same track queued twice produced identical |
| 362 | // id+url keys and crashed LazyColumn ("Key was already used"). Keys stay |
| 363 | // track-based (so Move/Remove keep the focused row's identity) and only |
| 364 | // repeats are numbered: "<id>_<url>#1", "#2", … |
| 365 | itemsIndexed(state.queue, key = { i, _ -> queueKeys[i] }) { index, track -> |
| 366 | QueueRow( |
| 367 | index = index, |
| 368 | track = track, |
| 369 | isCurrent = index == state.queueIndex, |
| 370 | isPlaying = state.isPlaying, |
| 371 | isStopped = state.isStopped, |
| 372 | focusRequester = if (index == state.queueIndex) currentFocus else null, |
| 373 | onClick = { viewModel.skipToQueueItem(index) }, |
| 374 | onLongClick = { sheetIndex = index }, |
| 375 | ) |
| 376 | } |
| 377 | } |
| 378 | } |
| 379 | } |
| 380 | |
| 381 | // Action sheet for the long-pressed track. If the track disappears from the queue |
| 382 | // (auto-advance, user clears queue, playlist ends) while the sheet is open, we |
| 383 | // close it in a LaunchedEffect — mutating sheetIndex directly during composition |
| 384 | // is undefined behavior and a plausible source of the intermittent back-navigation |
| 385 | // crash during track transitions. |
| 386 | LaunchedEffect(sheetIndex, state.queue.size) { |
| 387 | val idx = sheetIndex |
| 388 | if (idx != null && (idx < 0 || idx >= state.queue.size)) { |
| 389 | sheetIndex = null |
| 390 | } |
| 391 | } |
| 392 | sheetIndex?.let { idx -> |
| 393 | val track = state.queue.getOrNull(idx) |
| 394 | if (track != null) { |
| 395 | ActionSheet( |
| 396 | title = track.title, |
| 397 | subtitle = listOfNotNull(track.artist, track.album).joinToString(" · ") |
| 398 | .ifBlank { null }, |
| 399 | actions = buildList { |
| 400 | add(SheetAction("▶", "Play from here") { |
| 401 | viewModel.skipToQueueItem(idx) |
| 402 | }) |
| 403 | // "Play next" — move THIS item to the slot right after |
| 404 | // the currently playing track (long-press item 27 while |
| 405 | // #3 plays → 27 becomes the new #4). Hidden when it's |
| 406 | // meaningless (nothing playing, pressing the playing |
| 407 | // track itself, or the item is already next) and while |
| 408 | // observing (queue moves are follower-prohibited — the |
| 409 | // sender's local copy is authoritative). |
| 410 | val curIdx = state.queueIndex |
| 411 | if (!isObservingCast && curIdx >= 0 && |
| 412 | idx != curIdx && idx != curIdx + 1 |
| 413 | ) { |
| 414 | add(SheetAction("⤵", "Play next") { |
| 415 | // Move semantics are remove-then-insert: pulling |
| 416 | // an item from BEFORE the playing track shifts |
| 417 | // the playing index down by one, so the "next" |
| 418 | // slot is curIdx itself in that case. |
| 419 | val target = if (idx < curIdx) curIdx else curIdx + 1 |
| 420 | viewModel.moveInQueue(idx, target) |
| 421 | }) |
| 422 | } |
| 423 | if (!isObservingCast) { |
| 424 | add(SheetAction("✕", "Remove from queue") { |
| 425 | viewModel.removeFromQueue(idx) |
| 426 | }) |
| 427 | } |
| 428 | // Matches the pattern in DetailScreen / BrowseScreen / MoreScreen — surface |
| 429 | // "Albums by <artist>" so long-pressing a queue row gives a path back to the |
| 430 | // artist's catalog without leaving the queue. Track may have an albumArtist |
| 431 | // (compilation-style) or just artist; prefer albumArtist when present so the |
| 432 | // browse target is the credited album artist not the per-track artist. |
| 433 | val artistForMore = track.albumArtist?.takeIf { it.isNotBlank() } |
| 434 | ?: track.artist?.takeIf { it.isNotBlank() } |
| 435 | if (artistForMore != null) { |
| 436 | add(SheetAction("♫", "Albums by $artistForMore") { |
| 437 | onMoreByArtist(artistForMore) |
| 438 | }) |
| 439 | } |
| 440 | // Move controls are hidden while following: the queue shown here is |
| 441 | // the RECEIVER's live queue (see the header of this file), and the |
| 442 | // sender's local copy is authoritative — a follower's edit would be |
| 443 | // clobbered by the sender's next full reload. |
| 444 | if (!isObservingCast) { |
| 445 | if (idx > 0) { |
| 446 | add(SheetAction("▲", "Move up") { |
| 447 | viewModel.moveInQueue(idx, idx - 1) |
| 448 | }) |
| 449 | } |
| 450 | if (idx < state.queue.lastIndex) { |
| 451 | add(SheetAction("▼", "Move down") { |
| 452 | viewModel.moveInQueue(idx, idx + 1) |
| 453 | }) |
| 454 | } |
| 455 | } |
| 456 | add(SheetAction("ⓘ", "Show tags") { |
| 457 | onShowMetadata(track) |
| 458 | }) |
| 459 | }, |
| 460 | onDismiss = { sheetIndex = null }, |
| 461 | ) |
| 462 | } |
| 463 | } |
| 464 | } |
| 465 | |
| 466 | @OptIn(ExperimentalFoundationApi::class) |
| 467 | @Composable |
| 468 | private fun QueueRow( |
| 469 | index: Int, |
| 470 | track: DidlItem.Track, |
| 471 | isCurrent: Boolean, |
| 472 | isPlaying: Boolean, |
| 473 | isStopped: Boolean, |
| 474 | focusRequester: FocusRequester?, |
| 475 | onClick: () -> Unit, |
| 476 | onLongClick: () -> Unit, |
| 477 | ) { |
| 478 | val interaction = remember { MutableInteractionSource() } |
| 479 | // Ensure every row has a stable FocusRequester we can report into |
| 480 | // LocalLastContentFocus. If the parent passed one (the current track's), |
| 481 | // we reuse it; otherwise create a local one. |
| 482 | val ownRequester = remember { FocusRequester() } |
| 483 | val effectiveRequester = focusRequester ?: ownRequester |
| 484 | val lastFocusSink = LocalLastContentFocus.current |
| 485 | Box( |
| 486 | Modifier |
| 487 | .fillMaxWidth() |
| 488 | .background(if (isCurrent) AccentAmberSoft else SurfaceRaised) |
| 489 | .focusRequester(effectiveRequester) |
| 490 | .onFocusChanged { if (it.isFocused) lastFocusSink.value = effectiveRequester } |
| 491 | .staveFocusable(interaction) |
| 492 | .onDpadLongPress(onLongClick) |
| 493 | .combinedClickable( |
| 494 | interactionSource = interaction, |
| 495 | indication = null, |
| 496 | onClick = onClick, |
| 497 | onLongClick = onLongClick, |
| 498 | ) |
| 499 | .padding( |
| 500 | start = if (isCompactWidth()) 2.dp else 24.dp, |
| 501 | end = if (isCompactWidth()) 8.dp else 24.dp, |
| 502 | top = if (isCompactWidth()) 4.dp else 10.dp, |
| 503 | bottom = if (isCompactWidth()) 2.dp else 10.dp, |
| 504 | ) |
| 505 | ) { |
| 506 | Row( |
| 507 | verticalAlignment = Alignment.Top, |
| 508 | horizontalArrangement = Arrangement.spacedBy(if (isCompactWidth()) 12.dp else 16.dp), |
| 509 | ) { |
| 510 | // Marker / index column — TV/wide only. Phone reclaims the |
| 511 | // space for the text block (the now-playing bar already |
| 512 | // shows playback state). |
| 513 | if (!isCompactWidth()) { |
| 514 | if (isCurrent) { |
| 515 | val markerIcon = when { |
| 516 | isStopped -> Icons.Filled.Stop |
| 517 | isPlaying -> Icons.Filled.PlayArrow |
| 518 | else -> Icons.Filled.Pause |
| 519 | } |
| 520 | val markerDesc = when { |
| 521 | isStopped -> "Stopped" |
| 522 | isPlaying -> "Playing" |
| 523 | else -> "Paused" |
| 524 | } |
| 525 | Icon( |
| 526 | markerIcon, |
| 527 | contentDescription = markerDesc, |
| 528 | tint = AccentAmber, |
| 529 | modifier = Modifier |
| 530 | .width(40.dp) |
| 531 | .size(28.dp), |
| 532 | ) |
| 533 | } else { |
| 534 | Text( |
| 535 | "${index + 1}.", |
| 536 | style = MaterialTheme.typography.titleMedium.copy(fontFamily = staveInterFamily()), |
| 537 | color = OnQuiet, |
| 538 | modifier = Modifier.width(40.dp), |
| 539 | ) |
| 540 | } |
| 541 | } |
| 542 | |
| 543 | if (isCompactWidth()) { |
| 544 | val year = track.date?.let { |
| 545 | Regex("""\d{4}""").find(it)?.value |
| 546 | } |
| 547 | val numAndDur = buildList<String> { |
| 548 | track.trackNumber?.takeIf { it > 0 }?.let { add("#$it") } |
| 549 | track.duration?.let { |
| 550 | add("%d:%02d".format(it.inWholeMinutes, it.inWholeSeconds % 60)) |
| 551 | } |
| 552 | }.joinToString(" \u2022 ") |
| 553 | Column { |
| 554 | Row( |
| 555 | Modifier.height(IntrinsicSize.Min), |
| 556 | horizontalArrangement = Arrangement.spacedBy(12.dp), |
| 557 | ) { |
| 558 | AlbumArt( |
| 559 | model = track.albumArtUri, |
| 560 | modifier = Modifier |
| 561 | .size(96.dp) |
| 562 | .clip(RoundedCornerShape(4.dp)), |
| 563 | ) |
| 564 | Column( |
| 565 | Modifier.weight(1f).fillMaxHeight(), |
| 566 | verticalArrangement = Arrangement.SpaceEvenly, |
| 567 | ) { |
| 568 | Text( |
| 569 | withInterDigits(track.title), |
| 570 | style = MaterialTheme.typography.bodySmall, |
| 571 | color = OnPrimary, |
| 572 | maxLines = 2, |
| 573 | overflow = TextOverflow.Ellipsis, |
| 574 | ) |
| 575 | val albumLine = track.album?.takeIf { it.isNotBlank() } |
| 576 | if (albumLine != null) { |
| 577 | Text( |
| 578 | withInterDigits(albumLine), |
| 579 | style = MaterialTheme.typography.labelSmall, |
| 580 | color = OnMuted, |
| 581 | maxLines = 1, |
| 582 | overflow = TextOverflow.Ellipsis, |
| 583 | ) |
| 584 | } |
| 585 | val artistLine = track.artist?.takeIf { it.isNotBlank() } |
| 586 | if (artistLine != null) { |
| 587 | Text( |
| 588 | withInterDigits(artistLine), |
| 589 | style = MaterialTheme.typography.labelSmall, |
| 590 | color = OnMuted, |
| 591 | maxLines = 1, |
| 592 | overflow = TextOverflow.Ellipsis, |
| 593 | ) |
| 594 | } |
| 595 | } |
| 596 | } |
| 597 | Spacer(Modifier.height(4.dp)) |
| 598 | Row(verticalAlignment = Alignment.CenterVertically) { |
| 599 | Box(Modifier.width(96.dp), contentAlignment = Alignment.Center) { |
| 600 | if (year != null) { |
| 601 | Text( |
| 602 | year, |
| 603 | style = MaterialTheme.typography.labelMedium.copy(fontFamily = staveInterFamily()), |
| 604 | color = OnMuted, |
| 605 | ) |
| 606 | } |
| 607 | } |
| 608 | Spacer(Modifier.width(12.dp)) |
| 609 | if (numAndDur.isNotEmpty()) { |
| 610 | Text( |
| 611 | numAndDur, |
| 612 | style = MaterialTheme.typography.labelMedium.copy(fontFamily = staveInterFamily()), |
| 613 | color = OnMuted, |
| 614 | ) |
| 615 | } |
| 616 | } |
| 617 | } |
| 618 | } else { |
| 619 | // ── TV / wide / phone-landscape layout ── |
| 620 | // 80dp art (was 48dp) so the artwork registers visually |
| 621 | // alongside the now-running title/artist/album column — |
| 622 | // the previous 48dp left the row looking like it had a |
| 623 | // postage stamp glued to a slab of text. |
| 624 | AlbumArt( |
| 625 | model = track.albumArtUri, |
| 626 | modifier = Modifier |
| 627 | .size(80.dp) |
| 628 | .clip(RoundedCornerShape(4.dp)), |
| 629 | ) |
| 630 | Column(Modifier.weight(1f)) { |
| 631 | Text( |
| 632 | withInterDigits(track.title), |
| 633 | style = MaterialTheme.typography.titleMedium, |
| 634 | color = OnPrimary, |
| 635 | maxLines = 2, |
| 636 | overflow = TextOverflow.Ellipsis, |
| 637 | ) |
| 638 | val artistLine = buildList { |
| 639 | track.trackNumber?.takeIf { it > 0 }?.let { add("#$it") } |
| 640 | track.artist?.takeIf { it.isNotBlank() }?.let { add(it) } |
| 641 | }.joinToString(" \u00b7 ") |
| 642 | if (artistLine.isNotEmpty()) { |
| 643 | Text( |
| 644 | withInterDigits(artistLine), |
| 645 | style = MaterialTheme.typography.bodyMedium, |
| 646 | color = OnMuted, |
| 647 | maxLines = 1, |
| 648 | overflow = TextOverflow.Ellipsis, |
| 649 | ) |
| 650 | } |
| 651 | val albumLine = track.album?.takeIf { it.isNotBlank() } |
| 652 | if (albumLine != null) { |
| 653 | Text( |
| 654 | withInterDigits(albumLine), |
| 655 | style = MaterialTheme.typography.bodyMedium, |
| 656 | color = OnMuted, |
| 657 | maxLines = 1, |
| 658 | overflow = TextOverflow.Ellipsis, |
| 659 | ) |
| 660 | } |
| 661 | } |
| 662 | track.duration?.let { |
| 663 | Text( |
| 664 | "%d:%02d".format(it.inWholeMinutes, (it.inWholeSeconds % 60)), |
| 665 | style = MaterialTheme.typography.bodyLarge.copy(fontFamily = staveInterFamily()), |
| 666 | color = OnMuted, |
| 667 | ) |
| 668 | } |
| 669 | } |
| 670 | } |
| 671 | } |
| 672 | } |
| 673 | |
| 674 | @Composable |
| 675 | private fun HeaderAction(label: String, onClick: () -> Unit) { |
| 676 | val interaction = remember { MutableInteractionSource() } |
| 677 | Box( |
| 678 | Modifier |
| 679 | .background(SurfaceRaised) |
| 680 | .staveFocusable(interaction, cornerRadiusDp = 6) |
| 681 | .clickable(interaction, indication = null) { onClick() } |
| 682 | .padding(horizontal = 20.dp, vertical = 10.dp) |
| 683 | ) { |
| 684 | Text(label, style = MaterialTheme.typography.labelLarge, color = OnPrimary) |
| 685 | } |
| 686 | } |
| 687 | |
| 688 | /** Iconified compact-header action — square 40dp tap target on the |
| 689 | * raised surface, used for the Load / Save / Clear strip on the phone |
| 690 | * Queue screen. Same focus + interaction wiring as [HeaderAction] so |
| 691 | * D-pad nav still works if a phone with hardware nav keys is in use. */ |
| 692 | @Composable |
| 693 | private fun CompactIconHeaderAction( |
| 694 | icon: ImageVector, |
| 695 | description: String, |
| 696 | onClick: () -> Unit, |
| 697 | ) { |
| 698 | val interaction = remember { MutableInteractionSource() } |
| 699 | Box( |
| 700 | Modifier |
| 701 | .size(40.dp) |
| 702 | .background(SurfaceRaised) |
| 703 | .staveFocusable(interaction, cornerRadiusDp = 6) |
| 704 | .clickable(interaction, indication = null, onClick = onClick), |
| 705 | contentAlignment = Alignment.Center, |
| 706 | ) { |
| 707 | Icon( |
| 708 | icon, |
| 709 | contentDescription = description, |
| 710 | tint = OnPrimary, |
| 711 | modifier = Modifier.size(22.dp), |
| 712 | ) |
| 713 | } |
| 714 | } |


