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