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:
2026-05-23 22:55:29 -04:00
parent 6aec03fc02
commit 079fc1e4ed
5 changed files with 371 additions and 2 deletions
@@ -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()
}