From 079fc1e4ed30081d102927dd97576bc3120dbe2f Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 23 May 2026 22:55:29 -0400 Subject: [PATCH] feat(android): PlayerViewModel + MiniPlayer + NowPlayingScreen (M8 phase 6.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../com/fabledsword/minstrel/MainActivity.kt | 13 +- .../minstrel/nav/MinstrelNavGraph.kt | 3 +- .../minstrel/player/ui/MiniPlayer.kt | 109 +++++++++ .../minstrel/player/ui/NowPlayingScreen.kt | 215 ++++++++++++++++++ .../minstrel/player/ui/PlayerViewModel.kt | 33 +++ 5 files changed, 371 insertions(+), 2 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/player/ui/PlayerViewModel.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt b/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt index 9fe8be9f..04444c67 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt @@ -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, 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 398f8f73..b88ca2c0 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 @@ -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 { ComingSoon(title = "Now playing", body = "Lands in Phase 6.4.") } + composable { NowPlayingScreen() } composable { ComingSoon(title = "Queue", body = "Lands in Phase 6.") } composable { ComingSoon(title = "Sign in", body = "Lands in a later phase.") } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt new file mode 100644 index 00000000..26ba2741 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt @@ -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, + ) + } + } + } +} 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 new file mode 100644 index 00000000..d94c1c94 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt @@ -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) +} 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 new file mode 100644 index 00000000..b24b5e47 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/PlayerViewModel.kt @@ -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 = 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() +}