diff --git a/CHANGELOG.md b/CHANGELOG.md index 2052f43..e7fba1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,7 +57,7 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an | Timestamped play-log (own scrobble) | ✅ | ✅ | | Listening statistics view | ✅ | ✅ | | Stats cover art (artist/album) | ✅ | ✅ | -| Musik-Match (Head-to-Head) | ✅ | 🔜 | +| Musik-Match (Head-to-Head) | ✅ | ✅ | | Yearly Wrapped | 🔜 | 🔜 | | Romaji / Japanese sort | ✅ | ❌ | | Settings (Navidrome, ABS, etc.) | ✅ | ✅ | diff --git a/android/app/src/main/kotlin/com/novasonic/app/api/model/ScrobbleModels.kt b/android/app/src/main/kotlin/com/novasonic/app/api/model/ScrobbleModels.kt index bd8fb6b..7e65bba 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/api/model/ScrobbleModels.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/api/model/ScrobbleModels.kt @@ -63,3 +63,66 @@ data class StatHour( val hour: Int = 0, val plays: Int = 0, ) + +// ─── Head-to-Head "Musik-Match" ───────────────────────────────────────────── +// GET /scrobble/users — distinct users in the log, for the partner picker. +@Serializable +data class ScrobbleUsersResponse( + val me: String = "", + val users: List = emptyList(), +) + +@Serializable +data class ScrobbleUser( + val user: String = "", + val plays: Int = 0, + val lastPlayedAt: Long? = null, +) + +// GET /scrobble/compare?with= — taste comparison of the authed user (A) vs another (B). +@Serializable +data class CompareResult( + val userA: String = "", + val userB: String = "", + val matchScore: Int = 0, + val totalsA: CompareTotals = CompareTotals(), + val totalsB: CompareTotals = CompareTotals(), + val sharedArtists: List = emptyList(), + val sharedTracks: List = emptyList(), + val onlyA: List = emptyList(), + val onlyB: List = emptyList(), +) + +@Serializable +data class CompareTotals( + val plays: Int = 0, + val seconds: Long = 0, + val artists: Int = 0, +) + +@Serializable +data class SharedArtist( + val name: String? = null, + val artistId: String? = null, + val playsA: Int = 0, + val playsB: Int = 0, +) + +@Serializable +data class SharedTrack( + val songId: String? = null, + val title: String? = null, + val artist: String? = null, + val artistId: String? = null, + val albumId: String? = null, + val coverArt: String? = null, + val playsA: Int = 0, + val playsB: Int = 0, +) + +@Serializable +data class UniqueArtist( + val name: String? = null, + val artistId: String? = null, + val plays: Int = 0, +) diff --git a/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt b/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt index 1ed0b72..62591ab 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt @@ -62,6 +62,8 @@ class MusicRepository(private val context: Context) { suspend fun flushPlayLog() = playLog.flush() suspend fun fetchScrobbleStats(from: Long, to: Long, tz: Int, bucket: String, limit: Int) = playLog.fetchStats(from, to, tz, bucket, limit) + suspend fun fetchScrobbleUsers() = playLog.fetchUsers() + suspend fun fetchScrobbleCompare(with: String) = playLog.fetchCompare(with) suspend fun getArtistInfo(id: String) = api().getArtistInfo(id) suspend fun getTopSongs(artistName: String) = api().getTopSongs(artistName) suspend fun getGenres() = api().getGenres() diff --git a/android/app/src/main/kotlin/com/novasonic/app/data/PlayLogClient.kt b/android/app/src/main/kotlin/com/novasonic/app/data/PlayLogClient.kt index 06a09ac..9bbf514 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/data/PlayLogClient.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/data/PlayLogClient.kt @@ -135,6 +135,33 @@ class PlayLogClient(context: Context) { }.getOrNull() } + /** Head-to-Head: list the users in the log (for the partner picker). Null on failure. */ + suspend fun fetchUsers(): com.novasonic.app.api.model.ScrobbleUsersResponse? { + val s = prefs.appSettings.first() + val base = s.scrobbleUrl.trimEnd('/') + if (base.isBlank() || s.username.isBlank() || s.password.isBlank()) return null + val auth = authParams(s.username, s.password) + return runCatching { + val resp = http.get("$base/scrobble/users?$auth") + if (!resp.status.isSuccess()) return null + json.decodeFromString(resp.bodyAsText()) + }.getOrNull() + } + + /** Head-to-Head: compare the authed user against [with]. Null on failure. */ + suspend fun fetchCompare(with: String): com.novasonic.app.api.model.CompareResult? { + val s = prefs.appSettings.first() + val base = s.scrobbleUrl.trimEnd('/') + if (base.isBlank() || s.username.isBlank() || s.password.isBlank()) return null + val auth = authParams(s.username, s.password) + val url = "$base/scrobble/compare?$auth&with=${with.encodeURLParameter()}" + return runCatching { + val resp = http.get(url) + if (!resp.status.isSuccess()) return null + json.decodeFromString(resp.bodyAsText()) + }.getOrNull() + } + private fun readQueue(): List = runCatching { if (!queueFile.exists()) emptyList() diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt index cccc1c2..9323d84 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt @@ -388,7 +388,10 @@ fun AppNavigation() { } } composable(TopLevel.SETTINGS.route) { - SettingsScreen(onOpenStats = { navController.navigate("stats") }) + SettingsScreen( + onOpenStats = { navController.navigate("stats") }, + onOpenCompare = { navController.navigate("compare") }, + ) } composable("stats") { StatsScreen( @@ -398,6 +401,14 @@ fun AppNavigation() { onAlbumClick = { id -> navController.navigate("album/$id") }, ) } + composable("compare") { + CompareScreen( + coverUrl = { id -> coverUrl(id) }, + onBack = { navController.popBackStack() }, + onArtistClick = { id -> navController.navigate("artist/$id") }, + onAlbumClick = { id -> navController.navigate("album/$id") }, + ) + } composable( "album/{albumId}", diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/CompareScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/CompareScreen.kt new file mode 100644 index 0000000..9a614eb --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/CompareScreen.kt @@ -0,0 +1,345 @@ +package com.novasonic.app.ui.screen + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.AsyncImage +import com.novasonic.app.api.model.CompareResult +import com.novasonic.app.api.model.SharedArtist +import com.novasonic.app.api.model.SharedTrack +import com.novasonic.app.api.model.UniqueArtist +import com.novasonic.app.ui.theme.* +import com.novasonic.app.viewmodel.CompareViewModel + +@Composable +fun CompareScreen( + coverUrl: (String?) -> String?, + onBack: () -> Unit, + onArtistClick: (String) -> Unit = {}, + onAlbumClick: (String) -> Unit = {}, + vm: CompareViewModel = viewModel(), +) { + val state by vm.state.collectAsStateWithLifecycle() + + Column(Modifier.fillMaxSize().background(NsBackground)) { + // ── Top bar ── + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(start = 4.dp, top = 8.dp, end = 12.dp), + ) { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Zurück", tint = NsOnSurface) + } + Text( + "Musik-Match", + fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 22.sp, + color = NsOnSurface, modifier = Modifier.weight(1f), + ) + // Show "wechseln" once a partner is picked so you can go back to the list. + if (state.partner != null) { + Text( + "↺ Wechseln", + fontFamily = Outfit, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, + color = NsOnSurface2, + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .clickable { vm.clearPartner() } + .padding(horizontal = 8.dp, vertical = 6.dp), + ) + } + } + + when { + state.loading -> CenterBox { CircularProgressIndicator(color = NsAccent) } + !state.configured -> CenterMsg( + "Kein Scrobble-Dienst konfiguriert.\nTrage die Scrobble-URL in den Einstellungen ein.", + ) + state.error != null -> CenterMsg(state.error!!, NsError) + state.partner == null -> PartnerPicker(state.me, state.users, vm::choosePartner) + state.result != null -> CompareContent(state.result!!, coverUrl, onArtistClick, onAlbumClick) + else -> CenterBox { CircularProgressIndicator(color = NsAccent) } + } + } +} + +@Composable +private fun PartnerPicker( + me: String, + users: List, + onPick: (String) -> Unit, +) { + if (users.isEmpty()) { + CenterMsg("Noch niemand sonst hat Wiedergaben aufgezeichnet.\nSobald eine andere Person NovaSonic nutzt, könnt ihr euch hier vergleichen.") + return + } + Column( + Modifier.fillMaxSize().verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp).padding(bottom = 24.dp), + ) { + Text( + "Mit wem willst du dich vergleichen?", + fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2, + modifier = Modifier.padding(vertical = 6.dp), + ) + users.forEach { u -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + .padding(vertical = 5.dp) + .clip(RoundedCornerShape(12.dp)) + .background(NsSurface2) + .clickable { onPick(u.user) } + .padding(12.dp), + ) { + Box( + Modifier.size(40.dp).clip(CircleShape).background(NsAccent), + contentAlignment = Alignment.Center, + ) { + Text( + (u.user.firstOrNull()?.uppercase() ?: "?"), + fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 18.sp, color = Color.Black, + ) + } + Column(Modifier.weight(1f).padding(horizontal = 12.dp)) { + Text(u.user, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, + color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text("${u.plays} Plays", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2) + } + Text("›", fontFamily = Outfit, fontSize = 20.sp, color = NsOnSurface2) + } + } + } +} + +@Composable +private fun CompareContent( + d: CompareResult, + coverUrl: (String?) -> String?, + onArtistClick: (String) -> Unit, + onAlbumClick: (String) -> Unit, +) { + Column( + Modifier.fillMaxSize().verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp).padding(bottom = 24.dp), + ) { + ScoreRing(d) + Spacer(Modifier.height(20.dp)) + Totals(d) + Spacer(Modifier.height(22.dp)) + + SectionHeader("Gemeinsame Künstler") + if (d.sharedArtists.isEmpty()) { + EmptyLine("Keine gemeinsamen Künstler in diesem Zeitraum.") + } else { + val max = (d.sharedArtists.maxOfOrNull { maxOf(it.playsA, it.playsB) } ?: 1).coerceAtLeast(1) + d.sharedArtists.take(15).forEach { a -> + SharedArtistRow(a, max, a.artistId?.let { id -> { onArtistClick(id) } }) + } + } + Spacer(Modifier.height(22.dp)) + + if (d.sharedTracks.isNotEmpty()) { + SectionHeader("Gemeinsame Songs") + d.sharedTracks.take(12).forEach { t -> + SharedTrackRow(t, coverUrl(t.coverArt ?: t.albumId ?: t.songId), + t.albumId?.let { id -> { onAlbumClick(id) } }) + } + Spacer(Modifier.height(22.dp)) + } + + SectionHeader("Nur bei…") + Row(horizontalArrangement = Arrangement.spacedBy(18.dp)) { + UniqueColumn("Nur ${d.userA}", d.onlyA, onArtistClick, Modifier.weight(1f)) + UniqueColumn("Nur ${d.userB}", d.onlyB, onArtistClick, Modifier.weight(1f)) + } + } +} + +@Composable +private fun ScoreRing(d: CompareResult) { + val score = d.matchScore.coerceIn(0, 100) + val verdict = when { + score >= 80 -> "Seelenverwandte 🎯" + score >= 60 -> "Starkes Match 🔥" + score >= 40 -> "Solide Überschneidung 👍" + score >= 20 -> "Ein paar Gemeinsamkeiten 🙂" + else -> "Ziemlich unterschiedlich 🌗" + } + Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + Box(Modifier.size(132.dp), contentAlignment = Alignment.Center) { + val accent = NsAccent + val track = NsSurface2 + Canvas(Modifier.fillMaxSize()) { + val stroke = 14.dp.toPx() + val inset = stroke / 2 + val arcSize = Size(size.width - stroke, size.height - stroke) + drawArc( + color = track, startAngle = 0f, sweepAngle = 360f, useCenter = false, + topLeft = androidx.compose.ui.geometry.Offset(inset, inset), size = arcSize, + style = Stroke(width = stroke), + ) + drawArc( + color = accent, startAngle = -90f, sweepAngle = score * 3.6f, useCenter = false, + topLeft = androidx.compose.ui.geometry.Offset(inset, inset), size = arcSize, + style = Stroke(width = stroke), + ) + } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("$score%", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 30.sp, color = NsOnSurface) + Text("Match", fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2) + } + } + Spacer(Modifier.height(10.dp)) + Text(verdict, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, color = NsOnSurface) + Spacer(Modifier.height(4.dp)) + Text("${d.userA} ⟷ ${d.userB}", fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2) + } +} + +@Composable +private fun Totals(d: CompareResult) { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + TotalCard(d.userA, d.totalsA.plays, d.totalsA.artists, Modifier.weight(1f)) + TotalCard(d.userB, d.totalsB.plays, d.totalsB.artists, Modifier.weight(1f)) + } +} + +@Composable +private fun TotalCard(name: String, plays: Int, artists: Int, modifier: Modifier) { + Column(modifier.clip(RoundedCornerShape(12.dp)).background(NsSurface2).padding(14.dp)) { + Text(name, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, + color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis) + Spacer(Modifier.height(6.dp)) + Text("$plays Plays · $artists Künstler", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2) + } +} + +@Composable +private fun SharedArtistRow(a: SharedArtist, max: Int, onClick: (() -> Unit)?) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) + .padding(vertical = 7.dp), + ) { + Text("${a.playsA}", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 12.sp, + color = NsOnSurface2, textAlign = TextAlign.End, modifier = Modifier.width(30.dp)) + // left (A) bar grows toward the centre + Box(Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { + Box(Modifier.fillMaxWidth(a.playsA.toFloat() / max).height(6.dp) + .clip(RoundedCornerShape(3.dp)).background(NsAccent)) + } + Text( + a.name ?: "—", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, + color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center, + modifier = Modifier.weight(1.6f).padding(horizontal = 6.dp), + ) + // right (B) bar grows from the centre + Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { + Box(Modifier.fillMaxWidth(a.playsB.toFloat() / max).height(6.dp) + .clip(RoundedCornerShape(3.dp)).background(NsAccent.copy(alpha = 0.55f))) + } + Text("${a.playsB}", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 12.sp, + color = NsOnSurface2, textAlign = TextAlign.Start, modifier = Modifier.width(30.dp)) + } +} + +@Composable +private fun SharedTrackRow(t: SharedTrack, cover: String?, onClick: (() -> Unit)?) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) + .padding(vertical = 5.dp), + ) { + Box(Modifier.size(44.dp).clip(RoundedCornerShape(6.dp)).background(NsSurface2)) { + AsyncImage(model = cover, contentDescription = null, contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize()) + } + Column(Modifier.weight(1f).padding(horizontal = 12.dp)) { + Text(t.title ?: "—", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp, + color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (!t.artist.isNullOrBlank()) { + Text(t.artist, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2, + maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + Text("${t.playsA} · ${t.playsB}", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, + fontSize = 13.sp, color = NsOnSurface2) + } +} + +@Composable +private fun UniqueColumn(title: String, items: List, onArtistClick: (String) -> Unit, modifier: Modifier) { + Column(modifier) { + Text(title, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 12.sp, + color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(bottom = 6.dp)) + if (items.isEmpty()) { + Text("—", fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2, modifier = Modifier.padding(vertical = 4.dp)) + } else { + items.take(6).forEach { a -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + .then(if (a.artistId != null) Modifier.clickable { onArtistClick(a.artistId) } else Modifier) + .padding(vertical = 4.dp), + ) { + Text(a.name ?: "—", fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface, + maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + Text("${a.plays}", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 12.sp, + color = NsOnSurface2, modifier = Modifier.padding(start = 6.dp)) + } + } + } + } +} + +@Composable +private fun SectionHeader(t: String) { + Text(t.uppercase(), fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 12.sp, + letterSpacing = 0.6.sp, color = NsOnSurface2, modifier = Modifier.padding(bottom = 8.dp)) +} + +@Composable +private fun EmptyLine(msg: String) { + Text(msg, fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2, modifier = Modifier.padding(vertical = 8.dp)) +} + +@Composable +private fun CenterBox(content: @Composable BoxScope.() -> Unit) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center, content = content) +} + +@Composable +private fun CenterMsg(msg: String, color: Color = NsOnSurface2) { + Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) { + Text(msg, fontFamily = Outfit, fontSize = 14.sp, color = color, textAlign = TextAlign.Center) + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/SettingsScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/SettingsScreen.kt index ff1babf..bfd30e7 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/SettingsScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/SettingsScreen.kt @@ -41,7 +41,7 @@ private val SCROBBLE_OPTIONS = listOf( @OptIn(ExperimentalMaterial3Api::class) @Composable -fun SettingsScreen(onOpenStats: () -> Unit = {}) { +fun SettingsScreen(onOpenStats: () -> Unit = {}, onOpenCompare: () -> Unit = {}) { val vm: SettingsViewModel = viewModel() val state by vm.state.collectAsStateWithLifecycle() @@ -88,6 +88,14 @@ fun SettingsScreen(onOpenStats: () -> Unit = {}) { Text("📊 Meine Statistiken", color = NsOnSurface, fontFamily = Outfit, fontWeight = FontWeight.SemiBold) } + Spacer(Modifier.height(8.dp)) + OutlinedButton( + onClick = onOpenCompare, + modifier = Modifier.fillMaxWidth().height(48.dp), + ) { + Text("🎧 Musik-Match", color = NsOnSurface, fontFamily = Outfit, fontWeight = FontWeight.SemiBold) + } + // ── Save button (TOP) ──────────────────────────────────────────────── SaveButton(vm, state.isSaving, state.savedOk, state.error) diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/CompareViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/CompareViewModel.kt new file mode 100644 index 0000000..43c64b2 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/CompareViewModel.kt @@ -0,0 +1,68 @@ +package com.novasonic.app.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.novasonic.app.api.model.CompareResult +import com.novasonic.app.api.model.ScrobbleUser +import com.novasonic.app.app +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +data class CompareUiState( + val loading: Boolean = true, + val configured: Boolean = true, + val error: String? = null, + val me: String = "", + val users: List = emptyList(), // candidates (excludes me) + val partner: String? = null, // chosen userB; null → show picker + val result: CompareResult? = null, +) + +/** Head-to-Head "Musik-Match": pick a partner from the play-log, then compare. */ +class CompareViewModel(app: Application) : AndroidViewModel(app) { + + private val repo = app.app.repository + private val _state = MutableStateFlow(CompareUiState()) + val state: StateFlow = _state + + init { loadUsers() } + + fun loadUsers() { + viewModelScope.launch { + _state.value = CompareUiState(loading = true) + val settings = repo.appSettingsFlow().first() + if (settings.scrobbleUrl.isBlank()) { + _state.value = _state.value.copy(loading = false, configured = false) + return@launch + } + val resp = repo.fetchScrobbleUsers() + _state.value = if (resp == null) + _state.value.copy(loading = false, error = "Scrobble-Dienst nicht erreichbar") + else + _state.value.copy( + loading = false, configured = true, error = null, + me = resp.me, + users = resp.users.filter { it.user != resp.me }, + ) + } + } + + /** Back to the partner picker. */ + fun clearPartner() { + _state.value = _state.value.copy(partner = null, result = null, error = null) + } + + fun choosePartner(name: String) { + _state.value = _state.value.copy(partner = name, loading = true, error = null, result = null) + viewModelScope.launch { + val res = repo.fetchScrobbleCompare(name) + _state.value = if (res == null) + _state.value.copy(loading = false, error = "Scrobble-Dienst nicht erreichbar") + else + _state.value.copy(loading = false, result = res, error = null) + } + } +}