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:
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)) {
|
||||
|
||||
Reference in new issue
Block a user