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
4 changed files with 251 additions and 12 deletions
Showing only changes of commit 5697bfeedf - Show all commits
@@ -4,10 +4,18 @@ 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.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.rememberNavController
import com.fabledsword.minstrel.auth.ui.AuthGateViewModel
import com.fabledsword.minstrel.nav.MinstrelNavGraph
import com.fabledsword.minstrel.theme.MinstrelTheme
import dagger.hilt.android.AndroidEntryPoint
@@ -24,12 +32,29 @@ class MainActivity : ComponentActivity() {
}
@Composable
private fun App() {
// No bottom nav, no drawer — navigation is per-screen via the
// AppBar actions row + kebab (`MainAppBarActions`). The MiniPlayer
// is included by each in-shell route's `ShellScaffold` wrap;
// full-screen routes (NowPlaying / Queue / unauthenticated) bypass
// the shell entirely.
val navController = rememberNavController()
MinstrelNavGraph(navController = navController, modifier = Modifier.fillMaxSize())
private fun App(gate: AuthGateViewModel = hiltViewModel()) {
val startDestination by gate.startDestination.collectAsStateWithLifecycle()
val resolved = startDestination
if (resolved == null) {
BootSplash()
} else {
// No bottom nav, no drawer — navigation is per-screen via the
// AppBar actions row + kebab (`MainAppBarActions`). The MiniPlayer
// is included by each in-shell route's `ShellScaffold` wrap;
// full-screen routes (NowPlaying / Queue / unauthenticated)
// bypass the shell entirely.
val navController = rememberNavController()
MinstrelNavGraph(
navController = navController,
startDestination = resolved,
modifier = Modifier.fillMaxSize(),
)
}
}
@Composable
private fun BootSplash() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
}
@@ -0,0 +1,51 @@
package com.fabledsword.minstrel.auth.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
import com.fabledsword.minstrel.nav.Home
import com.fabledsword.minstrel.nav.Login
import com.fabledsword.minstrel.nav.ServerUrl
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Computes the initial startDestination for the root NavHost based on
* persisted auth state. Sits on top of AuthSessionDao directly rather
* than AuthStore's StateFlow because the StateFlow defaults to null
* until Room's first async emission — we need a definitive answer
* before drawing any nav graph.
*
* - no row at all → ServerUrl (first launch)
* - row with baseUrl, no cookie → Login (URL configured, not yet signed in)
* - row with cookie → Home (signed in)
*
* `startDestination` emits null while resolving (UI shows a spinner)
* and then exactly one resolved value.
*/
@HiltViewModel
class AuthGateViewModel @Inject constructor(
private val dao: AuthSessionDao,
) : ViewModel() {
private val internal = MutableStateFlow<Any?>(null)
val startDestination: StateFlow<Any?> = internal.asStateFlow()
init {
viewModelScope.launch {
val row = dao.get()
internal.value = when {
row == null -> ServerUrl
row.baseUrl == AuthStore.DEFAULT_BASE_URL && row.sessionCookie.isNullOrEmpty() ->
ServerUrl
row.sessionCookie.isNullOrEmpty() -> Login
else -> Home
}
}
}
}
@@ -0,0 +1,163 @@
package com.fabledsword.minstrel.auth.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.nav.Login
import com.fabledsword.minstrel.nav.ServerUrl
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
// ─── State + VM ───────────────────────────────────────────────────────
data class ServerUrlFormState(
val url: String = "",
val saving: Boolean = false,
val saved: Boolean = false,
val errorMessage: String? = null,
)
@HiltViewModel
class ServerUrlViewModel @Inject constructor(
private val authStore: AuthStore,
) : ViewModel() {
private val internal = MutableStateFlow(
ServerUrlFormState(url = authStore.baseUrl.value),
)
val state: StateFlow<ServerUrlFormState> = internal.asStateFlow()
fun setUrl(value: String) {
internal.update { it.copy(url = value, errorMessage = null) }
}
fun submit() {
val raw = internal.value.url.trim().trimEnd('/')
if (raw.isEmpty()) {
internal.update { it.copy(errorMessage = "Enter a server URL.") }
return
}
if (!raw.startsWith("http://") && !raw.startsWith("https://")) {
internal.update {
it.copy(errorMessage = "URL must start with http:// or https://")
}
return
}
viewModelScope.launch {
internal.update { it.copy(saving = true, errorMessage = null) }
authStore.setBaseUrl(raw)
internal.update { it.copy(saving = false, saved = true) }
}
}
fun consumeSaved() {
internal.update { it.copy(saved = false) }
}
}
// ─── Screen ───────────────────────────────────────────────────────────
@Composable
fun ServerUrlScreen(
navController: NavHostController,
viewModel: ServerUrlViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(state.saved) {
if (state.saved) {
navController.navigate(Login) {
popUpTo(ServerUrl) { inclusive = true }
}
viewModel.consumeSaved()
}
}
Box(modifier = Modifier.fillMaxSize()) {
ServerUrlForm(
state = state,
onUrlChange = viewModel::setUrl,
onSubmit = viewModel::submit,
)
}
}
@Composable
private fun ServerUrlForm(
state: ServerUrlFormState,
onUrlChange: (String) -> Unit,
onSubmit: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "Connect to your Minstrel",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground,
)
Text(
text = "Enter the URL of your Minstrel server.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = state.url,
onValueChange = onUrlChange,
modifier = Modifier.fillMaxWidth(),
label = { Text("Server URL") },
placeholder = { Text("https://minstrel.example.com") },
singleLine = true,
enabled = !state.saving,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = { onSubmit() }),
)
if (state.errorMessage != null) {
Text(
text = state.errorMessage,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
Button(
onClick = onSubmit,
enabled = !state.saving && state.url.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) { Text("Continue") }
}
}
@@ -15,6 +15,7 @@ import com.fabledsword.minstrel.admin.ui.AdminQuarantineScreen
import com.fabledsword.minstrel.admin.ui.AdminRequestsScreen
import com.fabledsword.minstrel.admin.ui.AdminUsersScreen
import com.fabledsword.minstrel.auth.ui.LoginScreen
import com.fabledsword.minstrel.auth.ui.ServerUrlScreen
import com.fabledsword.minstrel.discover.ui.DiscoverScreen
import com.fabledsword.minstrel.home.ui.HomeScreen
import com.fabledsword.minstrel.library.ui.LibraryScreen
@@ -36,12 +37,13 @@ import com.fabledsword.minstrel.shared.widgets.ShellScaffold
@Composable
fun MinstrelNavGraph(
navController: NavHostController,
startDestination: Any,
modifier: Modifier = Modifier,
) {
val expandPlayer = { navController.navigate(NowPlaying) }
NavHost(
navController = navController,
startDestination = Home,
startDestination = startDestination,
modifier = modifier.fillMaxSize(),
) {
inShellTopLevel(navController, expandPlayer)
@@ -146,9 +148,7 @@ private fun NavGraphBuilder.outsideShell(navController: NavHostController) {
NowPlayingScreen()
}
composable<Queue> { ComingSoon("Queue", "Lands in a later 6.x slice.") }
composable<ServerUrl> {
ComingSoon("Connect to your Minstrel", "Server-URL entry — Phase 11.")
}
composable<ServerUrl> { ServerUrlScreen(navController = navController) }
composable<Login> { LoginScreen(navController = navController) }
}