feat(scrobble): Android stats screen + cover art in stats (web + android)

Phase 4 (part B): Android statistics screen consuming GET /scrobble/stats —
tiles (plays + listening time), day/month/year play series, top artists/tracks/
albums, 24h listening clock, period tabs; opened from a Settings button
("Meine Statistiken"). New StatsViewModel + StatsScreen + ScrobbleModels;
PlayLogClient.fetchStats() (tolerant JSON decode, null on any failure);
repo.fetchScrobbleStats(); nav route + SettingsScreen(onOpenStats).

Cover art everywhere: top artists show a circular artist image, tracks/albums
a square cover — resolved client-side from Navidrome via artistId/albumId/songId
(no coverArt knowledge in the scrobble service). Endpoint now also returns
albumId for top tracks so track covers resolve reliably. Web stats list gets the
same thumbnails. SW cache -> ns-v67.

Android compileDebugKotlin + web build green. Needs on-device runtime test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 4.8 committed 2026-08-07 18:41:27 +02:00
1 parent cc1ec1653f
commit fff6f9e4da
11 files changed
+469 -11

No files matched your search

+12 -6
View File
@@ -6,7 +6,7 @@
import { S } from '../state.js';
import { esc, formatDur } from '../utils.js';
import { subsonicAuthParams } from '../api.js';
import { subsonicAuthParams, coverUrl } from '../api.js';
let _overlay = null;
let _period = 'all'; // all | year | 30d
@@ -122,9 +122,9 @@ function _render(body, d) {
body.innerHTML = [
_tiles(d),
_seriesChart(d.series, d.range.bucket),
_topList('Top-Künstler', d.topArtists, a => a.name, a => a.plays),
_topList('Top-Songs', d.topTracks, t => `${t.title}${t.artist ? ' · ' + t.artist : ''}`, t => t.plays),
_topList('Top-Alben', d.topAlbums, a => `${a.name}${a.artist ? ' · ' + a.artist : ''}`, a => a.plays),
_topList('Top-Künstler', d.topArtists, a => a.name, a => a.plays, a => a.artistId, true),
_topList('Top-Songs', d.topTracks, t => `${t.title}${t.artist ? ' · ' + t.artist : ''}`, t => t.plays, t => t.albumId || t.songId),
_topList('Top-Alben', d.topAlbums, a => `${a.name}${a.artist ? ' · ' + a.artist : ''}`, a => a.plays, a => a.albumId),
_hourClock(d.byHour),
].join('');
}
@@ -160,13 +160,19 @@ function _seriesChart(series, bucket) {
<div style="display:flex;gap:3px;align-items:flex-end;margin:6px 0 24px;overflow-x:auto;padding-bottom:8px">${bars}</div>`;
}
function _topList(title, items, labelFn, valFn) {
function _topList(title, items, labelFn, valFn, idFn = null, circular = false) {
if (!items?.length) return '';
const max = Math.max(...items.map(valFn), 1);
const radius = circular ? '50%' : '6px';
const rows = items.map((it, i) => {
const val = valFn(it);
const cover = idFn ? coverUrl(idFn(it), 80) : null;
const thumb = `<div style="width:40px;height:40px;border-radius:${radius};background:var(--surface2);flex-shrink:0;overflow:hidden">
${cover ? `<img src="${esc(cover)}" loading="lazy" style="width:100%;height:100%;object-fit:cover" onerror="this.style.display='none'">` : ''}
</div>`;
return `<div style="display:flex;align-items:center;gap:10px;padding:6px 0">
<div style="width:20px;text-align:right;color:var(--text2);font-size:13px;font-weight:700">${i + 1}</div>
<div style="width:18px;text-align:right;color:var(--text2);font-size:13px;font-weight:700">${i + 1}</div>
${thumb}
<div style="flex:1;min-width:0">
<div style="font-size:14px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(labelFn(it))}</div>
<div style="height:5px;background:var(--surface2);border-radius:3px;margin-top:4px;overflow:hidden">