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:
@@ -21,12 +21,12 @@ import com.fabledsword.minstrel.library.ui.AlbumDetailScreen
|
|||||||
import com.fabledsword.minstrel.library.ui.ArtistDetailScreen
|
import com.fabledsword.minstrel.library.ui.ArtistDetailScreen
|
||||||
import com.fabledsword.minstrel.library.ui.LibraryScreen
|
import com.fabledsword.minstrel.library.ui.LibraryScreen
|
||||||
import com.fabledsword.minstrel.player.ui.NowPlayingScreen
|
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.PlaylistDetailScreen
|
||||||
import com.fabledsword.minstrel.playlists.ui.PlaylistsListScreen
|
import com.fabledsword.minstrel.playlists.ui.PlaylistsListScreen
|
||||||
import com.fabledsword.minstrel.requests.ui.RequestsScreen
|
import com.fabledsword.minstrel.requests.ui.RequestsScreen
|
||||||
import com.fabledsword.minstrel.search.ui.SearchScreen
|
import com.fabledsword.minstrel.search.ui.SearchScreen
|
||||||
import com.fabledsword.minstrel.settings.ui.SettingsScreen
|
import com.fabledsword.minstrel.settings.ui.SettingsScreen
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
|
||||||
import com.fabledsword.minstrel.shared.widgets.ShellScaffold
|
import com.fabledsword.minstrel.shared.widgets.ShellScaffold
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -146,14 +146,10 @@ private fun NavGraphBuilder.outsideShell(navController: NavHostController) {
|
|||||||
popEnterTransition = { slideInVertically(initialOffsetY = { it }) },
|
popEnterTransition = { slideInVertically(initialOffsetY = { it }) },
|
||||||
popExitTransition = { slideOutVertically(targetOffsetY = { 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<ServerUrl> { ServerUrlScreen(navController = navController) }
|
||||||
composable<Login> { LoginScreen(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 skipToNext() { mediaController?.seekToNextMediaItem() }
|
||||||
fun skipToPrevious() { mediaController?.seekToPreviousMediaItem() }
|
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].
|
* Replace the queue with [tracks] and start playing from [initialIndex].
|
||||||
* [source] tags the queue with its origin (e.g. "for_you") so the
|
* [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.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
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.Lucide
|
||||||
import com.composables.icons.lucide.Music
|
import com.composables.icons.lucide.Music
|
||||||
import com.composables.icons.lucide.Pause
|
import com.composables.icons.lucide.Pause
|
||||||
import com.composables.icons.lucide.Play
|
import com.composables.icons.lucide.Play
|
||||||
import com.composables.icons.lucide.SkipBack
|
import com.composables.icons.lucide.SkipBack
|
||||||
import com.composables.icons.lucide.SkipForward
|
import com.composables.icons.lucide.SkipForward
|
||||||
|
import com.fabledsword.minstrel.nav.Queue
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
|
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
|
||||||
import com.fabledsword.minstrel.theme.LocalActionColors
|
import com.fabledsword.minstrel.theme.LocalActionColors
|
||||||
@@ -53,6 +56,7 @@ private const val SECONDS_PER_MINUTE = 60L
|
|||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun NowPlayingScreen(
|
fun NowPlayingScreen(
|
||||||
|
navController: NavHostController,
|
||||||
viewModel: PlayerViewModel = hiltViewModel(),
|
viewModel: PlayerViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
@@ -91,6 +95,19 @@ fun NowPlayingScreen(
|
|||||||
onPlayPause = { if (state.isPlaying) viewModel.pause() else viewModel.play() },
|
onPlayPause = { if (state.isPlaying) viewModel.pause() else viewModel.play() },
|
||||||
onNext = viewModel::skipToNext,
|
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 seekTo(positionMs: Long) = controller.seekTo(positionMs)
|
||||||
fun skipToNext() = controller.skipToNext()
|
fun skipToNext() = controller.skipToNext()
|
||||||
fun skipToPrevious() = controller.skipToPrevious()
|
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
|
||||||
Reference in New Issue
Block a user