From 91d0d5fd0be6a7a419664622968e9690e5d3198a Mon Sep 17 00:00:00 2001 From: marc Date: Sun, 6 Sep 2026 13:55:16 +0200 Subject: [PATCH] feat(web+android): Musik-Match wird zum Duell mit Anzeigetafel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 17 ++++ .../novasonic/app/api/model/ScrobbleModels.kt | 18 +++++ .../novasonic/app/ui/screen/CompareScreen.kt | 80 ++++++++++--------- scrobble/scrobble-server.js | 53 +++++++++++- src/player/player.js | 21 ++--- src/ui/compare.js | 73 +++++++++-------- styles.css | 37 +++++++++ 7 files changed, 218 insertions(+), 81 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7e50ff..875b0ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an | Playlist create/edit | ✅ | ✅ | | Playlist cover upload | ✅ | ✅ | | Spotify / YouTube importer | ✅ | ✅ | +| Musik-Duell (Anzeigetafel) | ✅ | ✅ | | Teilen per Link | ✅ | ✅ | | Playlist aus Import anlegen | ✅ | ✅ | | Song von Festplatte löschen | ✅ | ✅ | @@ -133,6 +134,22 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an zurück; der Editor zeigt sie samt bearbeitetem Dateipfad an und bleibt offen. ### Added +- **Musik-Match wird zum Duell (Web + Android)** — statt eines Prozentrings zeigt die Ansicht jetzt + eine Anzeigetafel mit fünf Kategorien, die je einen Sieger haben: Hörzeit, Vielfalt, Tiefe + (Plays je Künstler), Nachteule (Anteil vor 5 Uhr) und Entdeckerdrang (Anteil exklusiver Künstler). + Bewusst Kennzahlen zur *Hörweise* statt zur schieren Menge, sonst gewinnt immer, wer länger dabei + ist. Unentschieden zählt für niemanden. +- **Vergleichsansicht im Web zerfiel am Desktop** — sie lebte ausschließlich von Inline-Stilen mit + festen Flex-Verhältnissen. Jetzt ein echtes Raster (eine Spalte am Handy, zwei ab 780 px) mit + eigenen CSS-Klassen; das Fenster ist von 720 auf 940 px verbreitert, sonst hätte die zweite Spalte + nie ausgelöst. +- **Gemeinsame Songs wurden über `song_id` abgeglichen** — hatten beide denselben Song aus + verschiedenen Dateien gehört oder wurde eine Datei zwischendurch umbenannt, galt er als nicht + gemeinsam. Jetzt derselbe normalisierte Schlüssel wie in der Statistik. +- **Titelwechsel erreichte den Linux-Desktop nicht** — die Media-Session-Metadaten wurden feldweise + mutiert, um unter Windows das Neuaufbauen der Medienkachel zu vermeiden. Über MPRIS kam davon + nichts an: Plasma-Widgets zeigten dauerhaft den zuerst gespielten Titel. Bei echtem Titelwechsel + wird jetzt ein neues `MediaMetadata` zugewiesen, sonst weiter in-place (Cover-Nachladen). - **Teilen per Link (Web + Android)** — ein Song oder Album lässt sich per `https://…?song=` an jemanden auf derselben Instanz schicken. Der Link trägt **nur die Id, keine Zugangsdaten**: der Empfänger sieht den Inhalt mit seinem eigenen Navidrome-Konto. Web teilt über 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 7e65bba..b9c23eb 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 @@ -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 = emptyList(), @@ -93,6 +94,23 @@ data class CompareResult( val onlyB: List = emptyList(), ) +/** Anzeigetafel: je Kategorie ein Sieger ("A"/"B"/null bei Gleichstand) plus Gesamtstand. */ +@Serializable +data class Battle( + val kategorien: List = 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, 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 index 9a614eb..49f602c 100644 --- 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 @@ -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)) { diff --git a/scrobble/scrobble-server.js b/scrobble/scrobble-server.js index c72fb1a..41afca4 100644 --- a/scrobble/scrobble-server.js +++ b/scrobble/scrobble-server.js @@ -302,6 +302,9 @@ app.get('/scrobble/users', async (req, res) => { res.json({ me, users: rows }); }); +// Gegenstueck zu normKey() in JS — fuer Abgleiche, die nicht in SQL stattfinden. +const _norm = (v) => (v || '').toLowerCase().replace(/[\t\s]+/g, ' ').trim(); + // GET /scrobble/compare?with=NAME&from= — Head-to-Head "Musik-Match" of the authed user (A) vs // another user (B). Taste similarity = cosine of the two artist-play vectors (robust, 0..100). // Also returns shared artists/tracks and each side's unique top artists. @@ -355,11 +358,15 @@ app.get('/scrobble/compare', async (req, res) => { .sort((x, y) => y.plays - x.plays).slice(0, 8) .map(r => ({ name: r.name, artistId: r.artistId, plays: r.plays })); + // Gemeinsame Titel ueber Titel+Kuenstler abgleichen, NICHT ueber song_id: haben beide denselben + // Song aus verschiedenen Dateien gehoert — oder wurde eine Datei zwischendurch umbenannt, wobei + // Navidrome eine neue id vergibt —, galt er sonst faelschlich als nicht gemeinsam. + const tKey = (r) => `${_norm(r.title)}\u0000${_norm(r.artist)}`; const aTracks = tracksOf(userA); - const bTrackMap = new Map(tracksOf(userB).map(r => [r.songId, r])); + const bTrackMap = new Map(tracksOf(userB).map(r => [tKey(r), r])); const sharedTracks = []; for (const ta of aTracks) { - const tb = bTrackMap.get(ta.songId); + const tb = bTrackMap.get(tKey(ta)); if (tb) sharedTracks.push({ songId: ta.songId, title: ta.title, artist: ta.artist, artistId: ta.artistId, albumId: ta.albumId, coverArt: ta.coverArt, playsA: ta.plays, playsB: tb.plays, @@ -367,9 +374,51 @@ app.get('/scrobble/compare', async (req, res) => { } sharedTracks.sort((x, y) => (y.playsA + y.playsB) - (x.playsA + x.playsB)); + // ── Anzeigetafel: Kategorien, die je einen Sieger haben ────────────────────── + // Bewusst Kennzahlen, die etwas ueber die HOERWEISE sagen statt ueber schiere Menge — sonst + // gewinnt immer, wer laenger dabei ist. Unentschieden ist erlaubt und zaehlt fuer niemanden. + const nachtAnteil = u => { + const r = db.prepare(` + SELECT COUNT(*) AS n, + SUM(CASE WHEN CAST(strftime('%H', played_at + :tz, 'unixepoch') AS INTEGER) < 5 + THEN 1 ELSE 0 END) AS nacht + FROM plays WHERE ${WHERE} + `).get({ user: u, from, to, tz: Math.trunc(Number(req.query.tz) || 0) }); + return r.n ? (r.nacht || 0) / r.n : 0; + }; + const tA = totalsOf(userA), tB = totalsOf(userB); + const exklusivAnteil = (mine, other) => + mine.length ? mine.filter(r => !other.has(r.k)).length / mine.length : 0; + const tiefe = (t, artists) => (artists ? t.plays / artists : 0); + + const kat = (schluessel, label, a, b, format, hoeherGewinnt = true) => { + const gleich = Math.abs(a - b) < 1e-9; + return { + key: schluessel, label, + a, b, + textA: format(a), textB: format(b), + sieger: gleich ? null : ((a > b) === hoeherGewinnt ? 'A' : 'B'), + }; + }; + const std = v => `${(v / 3600).toFixed(1)} h`; + const proz = v => `${Math.round(v * 100)} %`; + const zahl = v => v.toLocaleString('de-DE'); + const komma = v => v.toFixed(1); + + const kategorien = [ + kat('hoerzeit', 'Hörzeit', tA.seconds, tB.seconds, std), + kat('vielfalt', 'Vielfalt', aArtists.length, bArtists.length, zahl), + kat('tiefe', 'Tiefe', tiefe(tA, aArtists.length), tiefe(tB, bArtists.length), komma), + kat('nachteule', 'Nachteule', nachtAnteil(userA), nachtAnteil(userB), proz), + kat('entdecker', 'Entdeckerdrang', exklusivAnteil(aArtists, bMap), exklusivAnteil(bArtists, aMap), proz), + ]; + const punkteA = kategorien.filter(k => k.sieger === 'A').length; + const punkteB = kategorien.filter(k => k.sieger === 'B').length; + res.json({ userA, userB, range: { from, to }, matchScore, + battle: { kategorien, punkteA, punkteB }, totalsA: { ...totalsOf(userA), artists: aArtists.length }, totalsB: { ...totalsOf(userB), artists: bArtists.length }, sharedArtists: sharedArtists.slice(0, 30), diff --git a/src/player/player.js b/src/player/player.js index bc643ce..69c9411 100644 --- a/src/player/player.js +++ b/src/player/player.js @@ -1085,16 +1085,19 @@ export function updateMediaSession(s) { { src: _cachedCoverUrl(s.coverArt, 512), sizes:'512x512', type:'image/jpeg' }, { src: _cachedCoverUrl(s.coverArt, 1024), sizes:'1024x1024', type:'image/jpeg' }, ] : []; - // Mutate the existing metadata in place rather than assigning a new MediaMetadata — - // replacing the object makes Windows tear down & rebuild the media tile (visible - // flicker when skipping). Spotify-style: keep one object, just update its fields. - if (ms.metadata) { - ms.metadata.title = s.title; - ms.metadata.artist = s.artist || ''; - ms.metadata.album = s.album || ''; - ms.metadata.artwork = artwork; - } else { + // Feldweise mutieren statt ein neues MediaMetadata zuzuweisen vermeidet unter Windows das + // Neuaufbauen der Medienkachel (sichtbares Flackern beim Skippen). Auf Linux kam davon bei MPRIS + // aber nichts an: Desktop-Widgets zeigten dauerhaft den ZUERST gespielten Titel, weil nur die + // erste Zuweisung gemeldet wurde. Deshalb jetzt zweigeteilt — bei echtem Titelwechsel ein neues + // Objekt (garantiert gemeldet), sonst weiter in-place (z. B. wenn nur das Cover nachgeladen wird). + const wechsel = !ms.metadata + || ms.metadata.title !== s.title + || ms.metadata.artist !== (s.artist || '') + || ms.metadata.album !== (s.album || ''); + if (wechsel) { ms.metadata = new MediaMetadata({ title:s.title, artist:s.artist||'', album:s.album||'', artwork }); + } else { + ms.metadata.artwork = artwork; } // Action handlers are stable — set them once, not on every track change. if (_msHandlersSet) return; diff --git a/src/ui/compare.js b/src/ui/compare.js index 4b585a3..084f44c 100644 --- a/src/ui/compare.js +++ b/src/ui/compare.js @@ -35,7 +35,7 @@ function _buildOverlay() { _overlay.id = 'compare-overlay'; _overlay.addEventListener('click', e => { if (e.target === _overlay) closeCompare(); }); _overlay.innerHTML = ` -