feat: M3 weighted shuffle v1 — real /api/radio with scoring #21

Merged
bvandeusen merged 262 commits from dev into main 2026-04-27 11:00:29 -04:00
3 changed files with 167 additions and 8 deletions
Showing only changes of commit 0742b45e3d - Show all commits
@@ -4,15 +4,34 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import com.fabledsword.minstrel.library.ui.LibraryScreen
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.composables.icons.lucide.House
import com.composables.icons.lucide.LibraryBig
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Search
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.Search as SearchRoute
import com.fabledsword.minstrel.nav.Settings as SettingsRoute
import com.fabledsword.minstrel.theme.MinstrelTheme
import dagger.hilt.android.AndroidEntryPoint
import kotlin.reflect.KClass
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@@ -27,12 +46,44 @@ class MainActivity : ComponentActivity() {
@Composable
private fun App() {
// Bottom-bar nav-graph wiring lands in Phase 5.5. For now render
// LibraryScreen directly so the new UI is reachable end-to-end.
// onArtistClick / onAlbumClick are no-op until the nav graph lands.
Scaffold(modifier = Modifier.fillMaxSize()) { inner ->
Box(modifier = Modifier.padding(inner)) {
LibraryScreen()
val navController = rememberNavController()
Scaffold(
modifier = Modifier.fillMaxSize(),
bottomBar = { MinstrelBottomBar(navController) },
) { inner ->
MinstrelNavGraph(
navController = navController,
modifier = Modifier.padding(inner),
)
}
}
private data class BottomBarTab(
val label: String,
val icon: ImageVector,
val routeClass: KClass<*>,
val navigate: NavHostController.() -> Unit,
)
@Composable
private fun MinstrelBottomBar(navController: NavHostController) {
val tabs = listOf(
BottomBarTab("Home", Lucide.House, Home::class) { navigate(Home) },
BottomBarTab("Library", Lucide.LibraryBig, Library::class) { navigate(Library) },
BottomBarTab("Search", Lucide.Search, SearchRoute::class) { navigate(SearchRoute) },
BottomBarTab("Settings", Lucide.Settings, SettingsRoute::class) { navigate(SettingsRoute) },
)
val backStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = backStackEntry?.destination
NavigationBar {
tabs.forEach { tab ->
val selected = currentDestination?.hasRoute(tab.routeClass) == true
NavigationBarItem(
selected = selected,
onClick = { tab.navigate(navController) },
icon = { Icon(tab.icon, contentDescription = tab.label) },
label = { Text(tab.label) },
)
}
}
}
@@ -0,0 +1,88 @@
package com.fabledsword.minstrel.nav
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
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.shared.widgets.EmptyState
/**
* Single NavHost for the whole app. Top-level tabs (Home / Library /
* Search / Settings) are direct destinations; detail screens push on
* top with their argument types passed via the @Serializable route
* classes' fields.
*
* Stub composables call EmptyState — the real screens land in their
* respective feature phases.
*/
@Composable
fun MinstrelNavGraph(
navController: NavHostController,
modifier: Modifier = Modifier,
) {
NavHost(
navController = navController,
startDestination = Library,
modifier = modifier.fillMaxSize(),
) {
// ── Top-level tabs ────────────────────────────────────────────────
composable<Home> { ComingSoon(title = "Home", body = "Lands in Phase 6.") }
composable<Library> {
LibraryScreen(
onArtistClick = { artist -> navController.navigate(ArtistDetail(artist.id)) },
onAlbumClick = { album -> navController.navigate(AlbumDetail(album.id)) },
)
}
composable<Search> { ComingSoon(title = "Search", body = "Lands in a later phase.") }
composable<Settings> { ComingSoon(title = "Settings", body = "Lands in Phase 11.") }
// ── Detail / overlay screens ──────────────────────────────────────
composable<AlbumDetail> { backStackEntry ->
val route: AlbumDetail = backStackEntry.toRoute()
ComingSoon(
title = "Album",
body = "Album detail for ${route.id} — lands in a later 5.x slice.",
)
}
composable<ArtistDetail> { backStackEntry ->
val route: ArtistDetail = backStackEntry.toRoute()
ComingSoon(
title = "Artist",
body = "Artist detail for ${route.id} — lands in a later 5.x slice.",
)
}
composable<PlaylistDetail> { backStackEntry ->
val route: PlaylistDetail = backStackEntry.toRoute()
ComingSoon(
title = "Playlist",
body = "Playlist detail for ${route.id} — lands in Phase 7.",
)
}
composable<NowPlaying> { ComingSoon(title = "Now playing", body = "Lands in Phase 6.4.") }
composable<Queue> { ComingSoon(title = "Queue", body = "Lands in Phase 6.") }
composable<Login> { ComingSoon(title = "Sign in", body = "Lands in a later phase.") }
}
}
@Composable
private fun ComingSoon(title: String, body: String) {
// Reuse EmptyState as a generic placeholder for unbuilt screens —
// keeps the design system intact rather than ad-hoc Text.
EmptyState(
title = title,
body = body,
modifier = Modifier.fillMaxSize(),
)
}
@@ -0,0 +1,20 @@
package com.fabledsword.minstrel.nav
import kotlinx.serialization.Serializable
// Top-level destinations (bottom-bar tabs).
@Serializable data object Home
@Serializable data object Library
@Serializable data object Search
@Serializable data object Settings
// Push-on-top destinations (no bottom-bar entry).
@Serializable data class AlbumDetail(val id: String)
@Serializable data class ArtistDetail(val id: String)
@Serializable data class PlaylistDetail(val id: String)
@Serializable data object NowPlaying
@Serializable data object Queue
@Serializable data object Login