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:
1 parent
cc1ec1653f
commit
fff6f9e4da
11 files changed
+469
-11
No files matched your search
+12
-6
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user