feat(web+android): Musik-Match wird zum Duell mit Anzeigetafel

Statt eines Prozentrings ("wie aehnlich seid ihr") zeigt die Ansicht jetzt, wer
worin gewinnt: fuenf Kategorien mit je einem Sieger und ein Gesamtstand.
Hoerzeit, Vielfalt, Tiefe (Plays je Kuenstler), Nachteule (Anteil vor 5 Uhr) und
Entdeckerdrang (Anteil exklusiver Kuenstler) — bewusst Kennzahlen zur Hoerweise
statt zur Menge, sonst gewinnt immer, wer laenger dabei ist.

Am laufenden Dienst mit zwei gegensaetzlichen Nutzern geprueft: 60 Plays a 300 s
ergeben 5,0 h und Tiefe 12,0, jeder dritte Play nachts ergibt 33 %, 60 von 62
exklusiven Kuenstlern ergeben 97 % — Endstand 3:2. Das JSON-Feldschema wurde
gegen das Kotlin-Modell abgeglichen.

Dazu drei Fixes im selben Bereich:
- Die Web-Ansicht lebte nur von Inline-Stilen mit festen Flex-Verhaeltnissen und
  zerfiel am Desktop. Jetzt ein Raster mit eigenen Klassen, eine Spalte am Handy,
  zwei ab 780 px; das Fenster musste dafuer von 720 auf 940 px, sonst haette die
  zweite Spalte nie ausgeloest.
- Gemeinsame Songs wurden ueber song_id abgeglichen und fielen auseinander,
  sobald eine Datei umbenannt wurde. Jetzt normalisierter Titel+Kuenstler.
- Media-Session: die Metadaten wurden feldweise mutiert (gegen Kachel-Flackern
  unter Windows), ueber MPRIS kam davon nichts an — Plasma-Widgets zeigten
  dauerhaft den zuerst gespielten Titel. Bei echtem Titelwechsel jetzt ein neues
  MediaMetadata, sonst weiter in-place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-06 13:55:16 +02:00
1 parent 5a6cff8105
commit 91d0d5fd0b
7 files changed
+218 -81

No files matched your search

@@ -85,6 +85,7 @@ data class CompareResult(
val userA: String = "",
val userB: String = "",
val matchScore: Int = 0,
val battle: Battle? = null,
val totalsA: CompareTotals = CompareTotals(),
val totalsB: CompareTotals = CompareTotals(),
val sharedArtists: List<SharedArtist> = emptyList(),
@@ -93,6 +94,23 @@ data class CompareResult(
val onlyB: List<UniqueArtist> = emptyList(),
)
/** Anzeigetafel: je Kategorie ein Sieger ("A"/"B"/null bei Gleichstand) plus Gesamtstand. */
@Serializable
data class Battle(
val kategorien: List<BattleKategorie> = emptyList(),
val punkteA: Int = 0,
val punkteB: Int = 0,
)
@Serializable
data class BattleKategorie(
val key: String = "",
val label: String = "",
val textA: String = "",
val textB: String = "",
val sieger: String? = null,
)
@Serializable
data class CompareTotals(
val plays: Int = 0,
@@ -148,7 +148,7 @@ private fun CompareContent(
Modifier.fillMaxSize().verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp).padding(bottom = 24.dp),
) {
ScoreRing(d)
Scoreboard(d)
Spacer(Modifier.height(20.dp))
Totals(d)
Spacer(Modifier.height(22.dp))
@@ -182,46 +182,54 @@ private fun CompareContent(
}
@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 🌗"
private fun Scoreboard(d: CompareResult) {
val b = d.battle
if (b == null || b.kategorien.isEmpty()) return
val urteil = when {
b.punkteA == b.punkteB -> "Kopf-an-Kopf — kein Sieger."
b.punkteA > b.punkteB -> "${d.userA} gewinnt ${b.punkteA}:${b.punkteB}."
else -> "${d.userB} gewinnt ${b.punkteB}:${b.punkteA}."
}
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)
Column(Modifier.fillMaxWidth()) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Text(d.userA, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp,
color = NsOnSurface, maxLines = 1, modifier = Modifier.weight(1f, false))
Text(" ${b.punkteA} : ${b.punkteB} ", fontFamily = Outfit, fontWeight = FontWeight.Black,
fontSize = 30.sp, color = NsOnSurface)
Text(d.userB, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp,
color = NsOnSurface, maxLines = 1, modifier = Modifier.weight(1f, false))
}
Text(urteil, fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2,
modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 12.dp),
textAlign = TextAlign.Center)
// Eine Zeile je Kategorie: Wert · Bezeichnung · Wert. Der Sieger wird farblich
// hervorgehoben, der Verlierer zurueckgenommen — identisch zum Web.
b.kategorien.forEach { k ->
val gewinnA = k.sieger == "A"
val gewinnB = k.sieger == "B"
Row(
Modifier.fillMaxWidth().padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(k.textA, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp,
color = if (gewinnA) NsAccent else if (gewinnB) NsOnSurface3 else NsOnSurface,
textAlign = TextAlign.End, modifier = Modifier.weight(1f))
Text(k.label.uppercase(), fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 11.sp, color = NsOnSurface2, textAlign = TextAlign.Center,
maxLines = 1, modifier = Modifier.weight(1.3f))
Text(k.textB, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp,
color = if (gewinnB) NsAccent else if (gewinnA) NsOnSurface3 else NsOnSurface,
modifier = Modifier.weight(1f))
}
}
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)
Spacer(Modifier.height(6.dp))
}
}
@Composable
private fun Totals(d: CompareResult) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {