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


