feat(android): Head-to-Head "Musik-Match" screen (parity with web)
Android CompareScreen mirroring the web feature: partner picker fed by GET /scrobble/users, then GET /scrobble/compare?with= → a match-score ring (Canvas arc), per-user totals, mirrored shared-artist bars, shared tracks, and each side's unique top artists. Rows navigate to artist/album. Reuses the one PlayLogClient HttpClient (fetchUsers/fetchCompare). Opened from Settings "🎧 Musik-Match". Parity matrix updated (Android ✅). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
cbedbba6fc
commit
c933847201
8 files changed
+527
-3
No files matched your search
+1
-1
@@ -57,7 +57,7 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an
|
|||||||
| Timestamped play-log (own scrobble) | ✅ | ✅ |
|
| Timestamped play-log (own scrobble) | ✅ | ✅ |
|
||||||
| Listening statistics view | ✅ | ✅ |
|
| Listening statistics view | ✅ | ✅ |
|
||||||
| Stats cover art (artist/album) | ✅ | ✅ |
|
| Stats cover art (artist/album) | ✅ | ✅ |
|
||||||
| Musik-Match (Head-to-Head) | ✅ | 🔜 |
|
| Musik-Match (Head-to-Head) | ✅ | ✅ |
|
||||||
| Yearly Wrapped | 🔜 | 🔜 |
|
| Yearly Wrapped | 🔜 | 🔜 |
|
||||||
| Romaji / Japanese sort | ✅ | ❌ |
|
| Romaji / Japanese sort | ✅ | ❌ |
|
||||||
| Settings (Navidrome, ABS, etc.) | ✅ | ✅ |
|
| Settings (Navidrome, ABS, etc.) | ✅ | ✅ |
|
||||||
|
|||||||
@@ -63,3 +63,66 @@ data class StatHour(
|
|||||||
val hour: Int = 0,
|
val hour: Int = 0,
|
||||||
val plays: 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<ScrobbleUser> = 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<SharedArtist> = emptyList(),
|
||||||
|
val sharedTracks: List<SharedTrack> = emptyList(),
|
||||||
|
val onlyA: List<UniqueArtist> = emptyList(),
|
||||||
|
val onlyB: List<UniqueArtist> = 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,
|
||||||
|
)
|
||||||
@@ -62,6 +62,8 @@ class MusicRepository(private val context: Context) {
|
|||||||
suspend fun flushPlayLog() = playLog.flush()
|
suspend fun flushPlayLog() = playLog.flush()
|
||||||
suspend fun fetchScrobbleStats(from: Long, to: Long, tz: Int, bucket: String, limit: Int) =
|
suspend fun fetchScrobbleStats(from: Long, to: Long, tz: Int, bucket: String, limit: Int) =
|
||||||
playLog.fetchStats(from, to, tz, bucket, limit)
|
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 getArtistInfo(id: String) = api().getArtistInfo(id)
|
||||||
suspend fun getTopSongs(artistName: String) = api().getTopSongs(artistName)
|
suspend fun getTopSongs(artistName: String) = api().getTopSongs(artistName)
|
||||||
suspend fun getGenres() = api().getGenres()
|
suspend fun getGenres() = api().getGenres()
|
||||||
|
|||||||
@@ -135,6 +135,33 @@ class PlayLogClient(context: Context) {
|
|||||||
}.getOrNull()
|
}.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<com.novasonic.app.api.model.ScrobbleUsersResponse>(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<com.novasonic.app.api.model.CompareResult>(resp.bodyAsText())
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
private fun readQueue(): List<PlayEntry> =
|
private fun readQueue(): List<PlayEntry> =
|
||||||
runCatching {
|
runCatching {
|
||||||
if (!queueFile.exists()) emptyList()
|
if (!queueFile.exists()) emptyList()
|
||||||
|
|||||||
@@ -388,7 +388,10 @@ fun AppNavigation() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
composable(TopLevel.SETTINGS.route) {
|
composable(TopLevel.SETTINGS.route) {
|
||||||
SettingsScreen(onOpenStats = { navController.navigate("stats") })
|
SettingsScreen(
|
||||||
|
onOpenStats = { navController.navigate("stats") },
|
||||||
|
onOpenCompare = { navController.navigate("compare") },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
composable("stats") {
|
composable("stats") {
|
||||||
StatsScreen(
|
StatsScreen(
|
||||||
@@ -398,6 +401,14 @@ fun AppNavigation() {
|
|||||||
onAlbumClick = { id -> navController.navigate("album/$id") },
|
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(
|
composable(
|
||||||
"album/{albumId}",
|
"album/{albumId}",
|
||||||
|
|||||||
@@ -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<com.novasonic.app.api.model.ScrobbleUser>,
|
||||||
|
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<UniqueArtist>, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,7 +41,7 @@ private val SCROBBLE_OPTIONS = listOf(
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(onOpenStats: () -> Unit = {}) {
|
fun SettingsScreen(onOpenStats: () -> Unit = {}, onOpenCompare: () -> Unit = {}) {
|
||||||
val vm: SettingsViewModel = viewModel()
|
val vm: SettingsViewModel = viewModel()
|
||||||
val state by vm.state.collectAsStateWithLifecycle()
|
val state by vm.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
@@ -88,6 +88,14 @@ fun SettingsScreen(onOpenStats: () -> Unit = {}) {
|
|||||||
Text("📊 Meine Statistiken", color = NsOnSurface, fontFamily = Outfit, fontWeight = FontWeight.SemiBold)
|
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) ────────────────────────────────────────────────
|
// ── Save button (TOP) ────────────────────────────────────────────────
|
||||||
SaveButton(vm, state.isSaving, state.savedOk, state.error)
|
SaveButton(vm, state.isSaving, state.savedOk, state.error)
|
||||||
|
|
||||||
|
|||||||
@@ -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<ScrobbleUser> = 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<CompareUiState> = _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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user