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
@@ -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=<id>`
|
||||
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
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
+12
-9
@@ -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;
|
||||
|
||||
+39
-34
@@ -35,7 +35,7 @@ function _buildOverlay() {
|
||||
_overlay.id = 'compare-overlay';
|
||||
_overlay.addEventListener('click', e => { if (e.target === _overlay) closeCompare(); });
|
||||
_overlay.innerHTML = `
|
||||
<div class="modal-card" style="max-width:720px;width:calc(100vw - 32px);max-height:88vh;display:flex;flex-direction:column;background:var(--surface);border:1px solid var(--border2);border-radius:18px;box-shadow:0 40px 80px #0009;overflow:hidden">
|
||||
<div class="modal-card" style="max-width:940px;width:calc(100vw - 32px);max-height:88vh;display:flex;flex-direction:column;background:var(--surface);border:1px solid var(--border2);border-radius:18px;box-shadow:0 40px 80px #0009;overflow:hidden">
|
||||
<div class="modal-head" style="display:flex;align-items:center;gap:10px;padding:16px 18px;border-bottom:1px solid var(--border)">
|
||||
<span style="font-size:18px">🎧</span>
|
||||
<div style="font-size:17px;font-weight:800;letter-spacing:-.3px;flex:1">Musik-Match</div>
|
||||
@@ -126,35 +126,40 @@ async function _loadCompare() {
|
||||
}
|
||||
|
||||
function _renderCompare(body, d) {
|
||||
const score = Math.max(0, Math.min(100, d.matchScore || 0));
|
||||
const verdict =
|
||||
score >= 80 ? 'Seelenverwandte 🎯' :
|
||||
score >= 60 ? 'Starkes Match 🔥' :
|
||||
score >= 40 ? 'Solide Überschneidung 👍' :
|
||||
score >= 20 ? 'Ein paar Gemeinsamkeiten 🙂' :
|
||||
'Ziemlich unterschiedlich 🌗';
|
||||
const b = d.battle;
|
||||
const urteil = !b ? '' :
|
||||
b.punkteA === b.punkteB ? 'Kopf-an-Kopf — kein Sieger.' :
|
||||
b.punkteA > b.punkteB ? `${d.userA} gewinnt ${b.punkteA}:${b.punkteB}.` :
|
||||
`${d.userB} gewinnt ${b.punkteB}:${b.punkteA}.`;
|
||||
|
||||
body.innerHTML = [
|
||||
_scoreRing(d, score, verdict),
|
||||
_totals(d),
|
||||
_sharedArtists(d.sharedArtists),
|
||||
_sharedTracks(d.sharedTracks),
|
||||
_uniqueCols(d),
|
||||
].join('');
|
||||
body.innerHTML = `<div class="cmp-body">
|
||||
<div class="cmp-wide">${_scoreboard(d, b, urteil)}</div>
|
||||
<div class="cmp-wide">${_totals(d)}</div>
|
||||
<div>${_sharedArtists(d.sharedArtists)}</div>
|
||||
<div>${_sharedTracks(d.sharedTracks)}</div>
|
||||
<div class="cmp-wide">${_uniqueCols(d)}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _scoreRing(d, score, verdict) {
|
||||
const deg = Math.round(score * 3.6);
|
||||
return `<div style="display:flex;flex-direction:column;align-items:center;gap:10px;margin:6px 0 22px">
|
||||
<div style="width:132px;height:132px;border-radius:50%;background:conic-gradient(var(--accent) ${deg}deg, var(--surface2) ${deg}deg);display:flex;align-items:center;justify-content:center">
|
||||
<div style="width:104px;height:104px;border-radius:50%;background:var(--surface);display:flex;flex-direction:column;align-items:center;justify-content:center">
|
||||
<div style="font-size:32px;font-weight:800;letter-spacing:-1px;line-height:1">${score}<span style="font-size:16px">%</span></div>
|
||||
<div style="font-size:11px;color:var(--text2);margin-top:2px">Match</div>
|
||||
</div>
|
||||
// Anzeigetafel: pro Kategorie ein Sieger, unten der Gesamtstand. Ersetzt den frueheren
|
||||
// Prozentring — nicht "wie aehnlich seid ihr", sondern "wer gewinnt worin".
|
||||
function _scoreboard(d, b, urteil) {
|
||||
if (!b?.kategorien?.length) return '';
|
||||
const zeilen = b.kategorien.map(k => {
|
||||
const gewinnA = k.sieger === 'A', gewinnB = k.sieger === 'B';
|
||||
return `<div class="cmp-cat ${gewinnA ? 'win-a' : gewinnB ? 'win-b' : ''}">
|
||||
<div class="cmp-val a ${gewinnA ? 'win' : gewinnB ? 'lose' : ''}">${esc(k.textA)}</div>
|
||||
<div class="cmp-cat-label">${esc(k.label)}</div>
|
||||
<div class="cmp-val b ${gewinnB ? 'win' : gewinnA ? 'lose' : ''}">${esc(k.textB)}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
return `<div class="cmp-head">
|
||||
<div class="cmp-name">${esc(d.userA)}</div>
|
||||
<div class="cmp-score">${b.punkteA} : ${b.punkteB}</div>
|
||||
<div class="cmp-name">${esc(d.userB)}</div>
|
||||
</div>
|
||||
<div style="font-size:15px;font-weight:700">${esc(verdict)}</div>
|
||||
<div style="font-size:13px;color:var(--text2)"><b>${esc(d.userA)}</b> ⟷ <b>${esc(d.userB)}</b></div>
|
||||
</div>`;
|
||||
<div class="cmp-verdict">${esc(urteil)}</div>
|
||||
${zeilen}`;
|
||||
}
|
||||
|
||||
function _totals(d) {
|
||||
@@ -175,17 +180,17 @@ function _h(t) {
|
||||
function _sharedArtists(items) {
|
||||
if (!items?.length) return `${_h('Gemeinsame Künstler')}${_info('Keine gemeinsamen Künstler in diesem Zeitraum.')}`;
|
||||
const max = Math.max(...items.map(a => Math.max(a.playsA, a.playsB)), 1);
|
||||
const rows = items.slice(0, 15).map(a => {
|
||||
const rows = items.slice(0, 12).map(a => {
|
||||
const nav = a.artistId ? `data-nav-type="artist" data-nav-id="${esc(a.artistId)}" data-nav-name="${esc(a.name)}" style="cursor:pointer"` : '';
|
||||
return `<div ${nav} style="display:flex;align-items:center;gap:10px;padding:7px 0">
|
||||
<div style="width:34px;text-align:right;font-size:12px;color:var(--text2);font-weight:700">${a.playsA}</div>
|
||||
<div style="flex:1;display:flex;justify-content:flex-end"><div style="height:6px;background:var(--accent);border-radius:3px;width:${Math.round((a.playsA / max) * 100)}%"></div></div>
|
||||
<div style="flex:1.6;text-align:center;font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0">${esc(a.name)}</div>
|
||||
<div style="flex:1;display:flex;justify-content:flex-start"><div style="height:6px;background:var(--accent-dark, var(--accent));opacity:.7;border-radius:3px;width:${Math.round((a.playsB / max) * 100)}%"></div></div>
|
||||
<div style="width:34px;text-align:left;font-size:12px;color:var(--text2);font-weight:700">${a.playsB}</div>
|
||||
return `<div ${nav} class="cmp-row">
|
||||
<div class="cmp-num" style="text-align:right">${a.playsA}</div>
|
||||
<div style="display:flex;justify-content:flex-end"><div class="cmp-bar" style="width:${Math.round((a.playsA / max) * 100)}%"></div></div>
|
||||
<div class="cmp-row-name">${esc(a.name)}</div>
|
||||
<div><div class="cmp-bar" style="width:${Math.round((a.playsB / max) * 100)}%;opacity:.65"></div></div>
|
||||
<div class="cmp-num">${a.playsB}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
return `${_h('Gemeinsame Künstler')}<div style="margin-bottom:22px">${rows}</div>`;
|
||||
return `${_h('Gemeinsame Künstler')}<div>${rows}</div>`;
|
||||
}
|
||||
|
||||
function _sharedTracks(items) {
|
||||
|
||||
+37
@@ -1125,3 +1125,40 @@ button:focus{outline:none}
|
||||
.abs-vol-slider::-webkit-slider-thumb{-webkit-appearance:none;width:12px;height:12px;border-radius:50%;background:var(--accent);cursor:pointer}
|
||||
.abs-vol-slider::-moz-range-thumb{width:12px;height:12px;border-radius:50%;background:var(--accent);cursor:pointer;border:none}
|
||||
@media(max-width:768px){.abs-mini-bar{bottom:80px;height:var(--player-h)}}
|
||||
|
||||
/* ─── MUSIK-DUELL (Vergleich zweier Nutzer) ─────────────────────────────────
|
||||
Vorher lebte die Ansicht ausschliesslich von Inline-Stilen mit festen Flex-Verhaeltnissen.
|
||||
Am Handy ging das auf, am Desktop zerfiel es. Jetzt ein Raster: eine Spalte auf schmalen
|
||||
Schirmen, zwei ab 780 px — die Anzeigetafel bleibt in beiden Faellen ueber die volle Breite. */
|
||||
.cmp-body{display:grid;grid-template-columns:1fr;gap:26px;align-content:start}
|
||||
.cmp-wide{grid-column:1/-1}
|
||||
@media(min-width:780px){
|
||||
.cmp-body{grid-template-columns:1fr 1fr;gap:26px 30px}
|
||||
}
|
||||
.cmp-head{display:flex;align-items:center;justify-content:center;gap:16px;flex-wrap:wrap;margin-bottom:14px}
|
||||
.cmp-name{font-size:15px;font-weight:800;max-width:38vw;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.cmp-score{font-size:30px;font-weight:900;letter-spacing:-1px;font-variant-numeric:tabular-nums}
|
||||
.cmp-verdict{text-align:center;font-size:13px;color:var(--text2);margin-bottom:16px}
|
||||
/* Eine Kategoriezeile: Wert · Name · Wert. Feste Aussenspalten, damit die Zahlen untereinander
|
||||
stehen und die Mitte den Rest bekommt — genau das fehlte vorher. */
|
||||
.cmp-cat{display:grid;grid-template-columns:minmax(64px,1fr) auto minmax(64px,1fr);align-items:center;
|
||||
gap:10px;padding:9px 10px;border-radius:10px}
|
||||
.cmp-cat+.cmp-cat{margin-top:4px}
|
||||
.cmp-cat-label{font-size:12px;color:var(--text2);text-transform:uppercase;letter-spacing:.4px;
|
||||
font-weight:800;white-space:nowrap}
|
||||
.cmp-val{font-size:15px;font-weight:700;font-variant-numeric:tabular-nums}
|
||||
.cmp-val.a{text-align:right}
|
||||
.cmp-val.b{text-align:left}
|
||||
.cmp-val.win{color:var(--accent)}
|
||||
.cmp-val.lose{color:var(--text3)}
|
||||
.cmp-cat.win-a{background:linear-gradient(90deg,rgba(232,160,32,.13),transparent 70%)}
|
||||
.cmp-cat.win-b{background:linear-gradient(270deg,rgba(232,160,32,.13),transparent 70%)}
|
||||
/* Tauziehen-Zeile fuer gemeinsame Kuenstler */
|
||||
.cmp-row{display:grid;grid-template-columns:34px 1fr minmax(90px,1.4fr) 1fr 34px;align-items:center;
|
||||
gap:8px;padding:6px 0}
|
||||
.cmp-row-name{text-align:center;font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;
|
||||
text-overflow:ellipsis;min-width:0}
|
||||
.cmp-num{font-size:12px;color:var(--text2);font-weight:700;font-variant-numeric:tabular-nums}
|
||||
.cmp-bar{height:6px;border-radius:3px;background:var(--accent)}
|
||||
.cmp-track{display:flex;align-items:center;gap:10px;padding:6px 0;min-width:0}
|
||||
.cmp-track img{width:40px;height:40px;border-radius:6px;object-fit:cover;flex-shrink:0}
|
||||
Reference in new issue
Block a user