feat(android): Phase 16 — Queue screen + View-queue affordance on NowPlaying

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 21:30:06 -04:00
parent b342a7c41b
commit f0278ed2bf
5 changed files with 168 additions and 7 deletions
@@ -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<Queue> { ComingSoon("Queue", "Lands in a later 6.x slice.") }
composable<Queue> { QueueScreen(navController = navController) }
composable<ServerUrl> { ServerUrlScreen(navController = navController) }
composable<Login> { LoginScreen(navController = navController) }
}
@Composable
private fun ComingSoon(title: String, body: String) {
EmptyState(title = title, body = body, modifier = Modifier.fillMaxSize())
}
@@ -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
@@ -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,
)
}
}
@@ -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)
}
@@ -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<TrackRef>,
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