feat(android): PlayerViewModel + MiniPlayer + NowPlayingScreen (M8 phase 6.4)
First visible player surfaces.
- player/ui/PlayerViewModel.kt — thin HiltViewModel wrapping the
singleton PlayerController. Both MiniPlayer and NowPlayingScreen
hiltViewModel() one of these; the underlying state is shared by
construction (controller is process-singleton).
- player/ui/MiniPlayer.kt — collapsed bar above the bottom nav.
Returns nothing when no track is loaded (zero footprint on fresh
install). Tap body → navigate(NowPlaying). Cover-art slot is a
Lucide placeholder for now; covers wire up when AlbumRef joins
land in a later 5.x slice.
- player/ui/NowPlayingScreen.kt — full-screen player. Square cover
(placeholder), title + artist + album, scrubber (Slider with
seek-on-release), transport row (prev / play-pause / next).
EmptyState fallback when no track. Play/pause button uses
LocalActionColors.primary (Moss) per design-system rule.
- MainActivity: Scaffold bottomBar slot now wraps MiniPlayer +
MinstrelBottomBar in a Column so the mini sits above the nav.
- MinstrelNavGraph: NowPlaying composable now renders the real
screen instead of the "Coming soon" stub.
scrubber-position-while-playing is event-driven for now (Media3
batches via Player.Listener.onEvents). A periodic 1Hz refresh for
smooth scrubber animation can come later if it's wanted; functional
seeking + position display work without it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,9 @@ import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBar
|
||||
@@ -27,8 +29,10 @@ import com.composables.icons.lucide.Settings
|
||||
import com.fabledsword.minstrel.nav.Home
|
||||
import com.fabledsword.minstrel.nav.Library
|
||||
import com.fabledsword.minstrel.nav.MinstrelNavGraph
|
||||
import com.fabledsword.minstrel.nav.NowPlaying
|
||||
import com.fabledsword.minstrel.nav.Search as SearchRoute
|
||||
import com.fabledsword.minstrel.nav.Settings as SettingsRoute
|
||||
import com.fabledsword.minstrel.player.ui.MiniPlayer
|
||||
import com.fabledsword.minstrel.theme.MinstrelTheme
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlin.reflect.KClass
|
||||
@@ -49,7 +53,14 @@ private fun App() {
|
||||
val navController = rememberNavController()
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
bottomBar = { MinstrelBottomBar(navController) },
|
||||
bottomBar = {
|
||||
// MiniPlayer sits above the bottom nav. It hides itself
|
||||
// (Composable returns nothing) when no track is loaded.
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
MiniPlayer(onExpandClick = { navController.navigate(NowPlaying) })
|
||||
MinstrelBottomBar(navController)
|
||||
}
|
||||
},
|
||||
) { inner ->
|
||||
MinstrelNavGraph(
|
||||
navController = navController,
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.toRoute
|
||||
import com.fabledsword.minstrel.library.ui.LibraryScreen
|
||||
import com.fabledsword.minstrel.player.ui.NowPlayingScreen
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
|
||||
/**
|
||||
@@ -70,7 +71,7 @@ fun MinstrelNavGraph(
|
||||
)
|
||||
}
|
||||
|
||||
composable<NowPlaying> { ComingSoon(title = "Now playing", body = "Lands in Phase 6.4.") }
|
||||
composable<NowPlaying> { NowPlayingScreen() }
|
||||
composable<Queue> { ComingSoon(title = "Queue", body = "Lands in Phase 6.") }
|
||||
composable<Login> { ComingSoon(title = "Sign in", body = "Lands in a later phase.") }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.fabledsword.minstrel.player.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
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 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.fabledsword.minstrel.theme.FabledSwordTokens
|
||||
|
||||
private const val MINI_BAR_HEIGHT_DP = 64
|
||||
private const val MINI_BAR_TONAL_ELEVATION_DP = 4
|
||||
private const val COVER_SIZE_DP = 48
|
||||
|
||||
/**
|
||||
* Collapsed player bar shown above the bottom nav. Returns nothing
|
||||
* when no track is loaded so it doesn't take screen space on a
|
||||
* fresh install. Tapping the body navigates to the full
|
||||
* NowPlayingScreen via [onExpandClick].
|
||||
*
|
||||
* Cover art is a placeholder Lucide icon for now — TrackRef carries
|
||||
* no coverUrl; covers come from the AlbumRef join we'll add in a
|
||||
* later 5.x slice. AsyncImage / Coil wire-up lands then.
|
||||
*/
|
||||
@Composable
|
||||
fun MiniPlayer(
|
||||
onExpandClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: PlayerViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val track = state.currentTrack ?: return
|
||||
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(MINI_BAR_HEIGHT_DP.dp)
|
||||
.clickable(onClick = onExpandClick),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = MINI_BAR_TONAL_ELEVATION_DP.dp,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(COVER_SIZE_DP.dp)
|
||||
.clip(RoundedCornerShape(FabledSwordTokens.radiusSm)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Lucide.Music,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = track.title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = track.artistName,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = { if (state.isPlaying) viewModel.pause() else viewModel.play() },
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (state.isPlaying) Lucide.Pause else Lucide.Play,
|
||||
contentDescription = if (state.isPlaying) "Pause" else "Play",
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package com.fabledsword.minstrel.player.ui
|
||||
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.SliderDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
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 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.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.theme.FabledSwordTokens
|
||||
import com.fabledsword.minstrel.theme.LocalActionColors
|
||||
|
||||
private const val COVER_MAX_WIDTH_DP = 320
|
||||
private const val TRANSPORT_ICON_DP = 36
|
||||
private const val PLAY_PAUSE_ICON_DP = 56
|
||||
private const val MS_PER_SECOND = 1000L
|
||||
private const val SECONDS_PER_MINUTE = 60L
|
||||
|
||||
/**
|
||||
* Full-screen player. Shows the cover (placeholder until AlbumRef join
|
||||
* lands), title + artist + album, a scrubber, and a transport row
|
||||
* (prev / play-pause / next). Renders the EmptyState widget when no
|
||||
* track is loaded.
|
||||
*/
|
||||
@Composable
|
||||
fun NowPlayingScreen(
|
||||
viewModel: PlayerViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val track = state.currentTrack
|
||||
if (track == null) {
|
||||
EmptyState(
|
||||
title = "Nothing playing",
|
||||
body = "Pick a track from the library to start.",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.widthIn(max = COVER_MAX_WIDTH_DP.dp)
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(FabledSwordTokens.radiusLg)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Lucide.Music,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(96.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
text = track.title,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = track.artistName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (track.albumTitle.isNotEmpty()) {
|
||||
Text(
|
||||
text = track.albumTitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
ScrubberRow(
|
||||
positionMs = state.positionMs,
|
||||
durationMs = state.durationMs,
|
||||
onSeek = viewModel::seekTo,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
TransportRow(
|
||||
isPlaying = state.isPlaying,
|
||||
onPrev = viewModel::skipToPrevious,
|
||||
onPlayPause = { if (state.isPlaying) viewModel.pause() else viewModel.play() },
|
||||
onNext = viewModel::skipToNext,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScrubberRow(positionMs: Long, durationMs: Long, onSeek: (Long) -> Unit) {
|
||||
val accent = MaterialTheme.colorScheme.primary
|
||||
val fraction = if (durationMs > 0) {
|
||||
(positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f)
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
Slider(
|
||||
value = fraction,
|
||||
onValueChange = { newFraction ->
|
||||
if (durationMs > 0) onSeek((newFraction * durationMs).toLong())
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = accent,
|
||||
activeTrackColor = accent,
|
||||
),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = formatMillis(positionMs),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = formatMillis(durationMs),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TransportRow(
|
||||
isPlaying: Boolean,
|
||||
onPrev: () -> Unit,
|
||||
onPlayPause: () -> Unit,
|
||||
onNext: () -> Unit,
|
||||
) {
|
||||
val actionColors = LocalActionColors.current
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = onPrev) {
|
||||
Icon(
|
||||
imageVector = Lucide.SkipBack,
|
||||
contentDescription = "Previous",
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.size(TRANSPORT_ICON_DP.dp),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onPlayPause) {
|
||||
Icon(
|
||||
imageVector = if (isPlaying) Lucide.Pause else Lucide.Play,
|
||||
contentDescription = if (isPlaying) "Pause" else "Play",
|
||||
tint = actionColors.primary,
|
||||
modifier = Modifier.size(PLAY_PAUSE_ICON_DP.dp),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onNext) {
|
||||
Icon(
|
||||
imageVector = Lucide.SkipForward,
|
||||
contentDescription = "Next",
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.size(TRANSPORT_ICON_DP.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatMillis(ms: Long): String {
|
||||
if (ms <= 0) return "0:00"
|
||||
val totalSeconds = ms / MS_PER_SECOND
|
||||
val minutes = totalSeconds / SECONDS_PER_MINUTE
|
||||
val seconds = totalSeconds % SECONDS_PER_MINUTE
|
||||
return "%d:%02d".format(minutes, seconds)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.fabledsword.minstrel.player.ui
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.fabledsword.minstrel.player.PlayerController
|
||||
import com.fabledsword.minstrel.player.PlayerUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Thin ViewModel wrapping the singleton [PlayerController] so
|
||||
* Composables can `hiltViewModel()` it via the standard Compose
|
||||
* pattern. Both MiniPlayer and NowPlayingScreen instantiate one of
|
||||
* these; the underlying PlayerController is a process-singleton, so
|
||||
* each VM exposes the same `uiState`.
|
||||
*
|
||||
* Transport methods are thin pass-throughs — the indirection earns
|
||||
* its keep by giving testing seams later (a fake PlayerController
|
||||
* stub-test for ViewModel-level logic when it grows).
|
||||
*/
|
||||
@HiltViewModel
|
||||
class PlayerViewModel @Inject constructor(
|
||||
private val controller: PlayerController,
|
||||
) : ViewModel() {
|
||||
|
||||
val uiState: StateFlow<PlayerUiState> = controller.uiState
|
||||
|
||||
fun play() = controller.play()
|
||||
fun pause() = controller.pause()
|
||||
fun seekTo(positionMs: Long) = controller.seekTo(positionMs)
|
||||
fun skipToNext() = controller.skipToNext()
|
||||
fun skipToPrevious() = controller.skipToPrevious()
|
||||
}
|
||||
Reference in New Issue
Block a user