From f0278ed2bf37f4d86a7f1f7cfdd64aa98ccc11f8 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 25 May 2026 21:30:06 -0400 Subject: [PATCH] =?UTF-8?q?feat(android):=20Phase=2016=20=E2=80=94=20Queue?= =?UTF-8?q?=20screen=20+=20View-queue=20affordance=20on=20NowPlaying?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the last ComingSoon stub in the nav graph. Queue route now renders the live PlayerController queue with the active row highlighted; tap a row to jump to that position via the new seekToIndex transport method. New: - player/ui/QueueScreen.kt — Scaffold + back-button AppBar; reads the same PlayerViewModel that powers MiniPlayer / NowPlaying so queue state stays in sync across all three. Active row gets a 12% primary-tinted background + Volume2 leading icon so the user sees where they are. Empty queue shows "Queue is empty" hint. Modified: - player/PlayerController.kt — adds `seekToIndex(index: Int)`: bounds-checked jump to a queue position via MediaController.seekTo(mediaItemIndex, 0L) + auto-play. - player/ui/PlayerViewModel.kt — exposes seekToIndex pass-through. - player/ui/NowPlayingScreen.kt — takes navController now; adds a ListMusic icon button below the transport row that navigates to Queue. - nav/MinstrelNavGraph.kt — Queue route renders QueueScreen; NowPlaying composable threads navController. Drops the ComingSoon helper + its EmptyState import — every route now has a real screen, no stub fallback needed. Closes Phase 16. Every named v2026.05.21.0 route has a working native screen now. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../minstrel/nav/MinstrelNavGraph.kt | 10 +- .../minstrel/player/PlayerController.kt | 8 + .../minstrel/player/ui/NowPlayingScreen.kt | 17 +++ .../minstrel/player/ui/PlayerViewModel.kt | 1 + .../minstrel/player/ui/QueueScreen.kt | 139 ++++++++++++++++++ 5 files changed, 168 insertions(+), 7 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/player/ui/QueueScreen.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt b/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt index 8d0c2a37..e2bde56a 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt @@ -21,12 +21,12 @@ import com.fabledsword.minstrel.library.ui.AlbumDetailScreen import com.fabledsword.minstrel.library.ui.ArtistDetailScreen import com.fabledsword.minstrel.library.ui.LibraryScreen import com.fabledsword.minstrel.player.ui.NowPlayingScreen +import com.fabledsword.minstrel.player.ui.QueueScreen import com.fabledsword.minstrel.playlists.ui.PlaylistDetailScreen import com.fabledsword.minstrel.playlists.ui.PlaylistsListScreen import com.fabledsword.minstrel.requests.ui.RequestsScreen import com.fabledsword.minstrel.search.ui.SearchScreen import com.fabledsword.minstrel.settings.ui.SettingsScreen -import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.ShellScaffold /** @@ -146,14 +146,10 @@ private fun NavGraphBuilder.outsideShell(navController: NavHostController) { popEnterTransition = { slideInVertically(initialOffsetY = { it }) }, popExitTransition = { slideOutVertically(targetOffsetY = { it }) }, ) { - NowPlayingScreen() + NowPlayingScreen(navController = navController) } - composable { ComingSoon("Queue", "Lands in a later 6.x slice.") } + composable { QueueScreen(navController = navController) } composable { ServerUrlScreen(navController = navController) } composable { LoginScreen(navController = navController) } } -@Composable -private fun ComingSoon(title: String, body: String) { - EmptyState(title = title, body = body, modifier = Modifier.fillMaxSize()) -} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt index 5022fa5a..54303daa 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt @@ -70,6 +70,14 @@ class PlayerController @Inject constructor( fun skipToNext() { mediaController?.seekToNextMediaItem() } fun skipToPrevious() { mediaController?.seekToPreviousMediaItem() } + /** Jump to [index] in the current queue. No-op if index is out of range. */ + fun seekToIndex(index: Int) { + val controller = mediaController ?: return + if (index !in queueRefs.indices) return + controller.seekTo(index, /* positionMs = */ 0L) + controller.play() + } + /** * Replace the queue with [tracks] and start playing from [initialIndex]. * [source] tags the queue with its origin (e.g. "for_you") so the diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt index 250ebed6..8798f07b 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt @@ -29,12 +29,15 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import com.composables.icons.lucide.ListMusic import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Music import com.composables.icons.lucide.Pause import com.composables.icons.lucide.Play import com.composables.icons.lucide.SkipBack import com.composables.icons.lucide.SkipForward +import com.fabledsword.minstrel.nav.Queue import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.theme.FabledSwordFlatTokens import com.fabledsword.minstrel.theme.LocalActionColors @@ -53,6 +56,7 @@ private const val SECONDS_PER_MINUTE = 60L */ @Composable fun NowPlayingScreen( + navController: NavHostController, viewModel: PlayerViewModel = hiltViewModel(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() @@ -91,6 +95,19 @@ fun NowPlayingScreen( onPlayPause = { if (state.isPlaying) viewModel.pause() else viewModel.play() }, onNext = viewModel::skipToNext, ) + Spacer(Modifier.height(16.dp)) + ViewQueueButton(onClick = { navController.navigate(Queue) }) + } +} + +@Composable +private fun ViewQueueButton(onClick: () -> Unit) { + IconButton(onClick = onClick) { + Icon( + imageVector = Lucide.ListMusic, + contentDescription = "View queue", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/PlayerViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/PlayerViewModel.kt index b24b5e47..9e927433 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/PlayerViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/PlayerViewModel.kt @@ -30,4 +30,5 @@ class PlayerViewModel @Inject constructor( fun seekTo(positionMs: Long) = controller.seekTo(positionMs) fun skipToNext() = controller.skipToNext() fun skipToPrevious() = controller.skipToPrevious() + fun seekToIndex(index: Int) = controller.seekToIndex(index) } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/QueueScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/QueueScreen.kt new file mode 100644 index 00000000..7e464f21 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/QueueScreen.kt @@ -0,0 +1,139 @@ +package com.fabledsword.minstrel.player.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import com.composables.icons.lucide.ArrowLeft +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.Volume2 +import com.fabledsword.minstrel.models.TrackRef +import com.fabledsword.minstrel.shared.widgets.EmptyState + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun QueueScreen( + navController: NavHostController, + viewModel: PlayerViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Queue") }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(Lucide.ArrowLeft, contentDescription = "Back") + } + }, + ) + }, + ) { inner -> + Box(modifier = Modifier.fillMaxSize().padding(inner)) { + if (state.queue.isEmpty()) { + EmptyState( + title = "Queue is empty", + body = "Pick a track from the library and it'll show up here.", + ) + } else { + QueueList( + tracks = state.queue, + currentIndex = state.queueIndex, + onJumpTo = viewModel::seekToIndex, + ) + } + } + } +} + +@Composable +private fun QueueList( + tracks: List, + currentIndex: Int, + onJumpTo: (Int) -> Unit, +) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + itemsIndexed(items = tracks, key = { _, track -> track.id }) { index, track -> + QueueRow( + track = track, + isCurrent = index == currentIndex, + onClick = { onJumpTo(index) }, + ) + HorizontalDivider() + } + } +} + +@Composable +private fun QueueRow(track: TrackRef, isCurrent: Boolean, onClick: () -> Unit) { + val highlight = if (isCurrent) { + MaterialTheme.colorScheme.primary.copy(alpha = HIGHLIGHT_ALPHA) + } else { + Color.Transparent + } + Row( + modifier = Modifier + .fillMaxWidth() + .background(highlight) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (isCurrent) { + Icon( + Lucide.Volume2, + contentDescription = "Now playing", + tint = MaterialTheme.colorScheme.primary, + ) + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = track.title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = if (isCurrent) FontWeight.Medium else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (track.artistName.isNotEmpty()) { + Text( + text = track.artistName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +private const val HIGHLIGHT_ALPHA = 0.12f