Files
NovaSonic/src/ui/stats.js
T
HoneyandClaude Opus 4.8 0c7132a7f2 feat(scrobble stats): genre ranking (Top-Genres) — web + android
Play-log now stores each play's genre (from the song tags; web scrobbleLog +
android PlayLogClient capture song.genre). The stats endpoint returns topGenres
(GROUP BY lower(genre), case-insensitive, blanks ignored); web + android render a
Top-Genres section. Schema migrates via ALTER TABLE. Old plays have no genre
(fills going forward, like cover art). SW cache -> ns-v72.

Android compileDebugKotlin + web build green; genre grouping + null-ignore unit-tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 01:35:00 +02:00

294 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ─── Listening statistics view ──────────────────────────────────────────────
// Opened from Settings ("Meine Statistiken"). Reads the self-hosted scrobble
// service (S.cfg.scrobbleUrl) → GET /scrobble/stats and renders totals, top
// artists/albums/tracks, a per-bucket play series, and a 24h listening clock.
// Self-contained: builds its own overlay DOM, so no index.html markup is needed.
import { S } from '../state.js';
import { esc, formatDur } from '../utils.js';
import { subsonicAuthParams, coverUrl } from '../api.js';
let _overlay = null;
// Time-travel navigator: browse all-time, a specific year, or a specific month.
let _view = 'all'; // all | year | month
let _year = new Date().getFullYear();
let _month = new Date().getMonth(); // 0-11
let _firstPlayedAt = null; // epoch secs of the user's earliest play → navigator lower bound
const _MONTHS = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
// Human listening time: "3h 12m" / "12m" / "45s"
function _fmtTime(sec) {
sec = Math.floor(sec || 0);
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
if (h) return `${h}h ${m}m`;
if (m) return `${m}m`;
return `${sec}s`;
}
function _tzOffsetSec() { return -new Date().getTimezoneOffset() * 60; }
// from/to (epoch seconds) + series bucket for the current navigator state.
function _range() {
const now = Math.floor(Date.now() / 1000);
if (_view === 'year') {
return {
from: Math.floor(new Date(_year, 0, 1).getTime() / 1000),
to: Math.floor(new Date(_year + 1, 0, 1).getTime() / 1000),
bucket: 'month',
};
}
if (_view === 'month') {
return {
from: Math.floor(new Date(_year, _month, 1).getTime() / 1000),
to: Math.floor(new Date(_year, _month + 1, 1).getTime() / 1000),
bucket: 'day',
};
}
return { from: 0, to: now + 1, bucket: 'month' }; // all time
}
function _periodLabel() {
if (_view === 'year') return String(_year);
if (_view === 'month') return `${_MONTHS[_month]} ${_year}`;
return 'Alles';
}
// True when the shown period is the current (real) one — used to disable stepping into the future.
function _atCurrent() {
const n = new Date();
if (_view === 'year') return _year >= n.getFullYear();
if (_view === 'month') return _year > n.getFullYear() || (_year === n.getFullYear() && _month >= n.getMonth());
return true;
}
// True when the shown period is at/before the user's first logged play — caps stepping backwards.
function _atStart() {
if (!_firstPlayedAt) return false;
const f = new Date(_firstPlayedAt * 1000);
if (_view === 'year') return _year <= f.getFullYear();
if (_view === 'month') return _year < f.getFullYear() || (_year === f.getFullYear() && _month <= f.getMonth());
return false;
}
function _step(dir) {
if (_view === 'year') { _year += dir; }
else if (_view === 'month') {
_month += dir;
if (_month < 0) { _month = 11; _year--; }
if (_month > 11) { _month = 0; _year++; }
}
}
export function openStats() {
if (!_overlay) _buildOverlay();
_overlay.classList.add('open'); // .modal-overlay.open = visible (matches app's modals)
document.body.style.overflow = 'hidden';
_load();
}
export function closeStats() {
if (_overlay) _overlay.classList.remove('open');
document.body.style.overflow = '';
}
function _buildOverlay() {
_overlay = document.createElement('div');
_overlay.className = 'modal-overlay';
_overlay.id = 'stats-overlay';
_overlay.addEventListener('click', e => { if (e.target === _overlay) closeStats(); });
_overlay.innerHTML = `
<div class="modal-card stats-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-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">Meine Statistiken</div>
<button class="hbtn" onclick="closeStats()" title="Schließen"
style="padding:6px 10px;color:var(--text2);font-size:18px;background:none;border:none;cursor:pointer">✕</button>
</div>
<div class="stats-tabs" id="stats-tabs" style="display:flex;gap:6px;padding:12px 18px 0"></div>
<div class="stats-body" id="stats-body" style="padding:16px 18px;overflow-y:auto;min-height:62vh"></div>
</div>`;
// Delegated navigation: click an artist / album / track row → open its page.
_overlay.querySelector('#stats-body').addEventListener('click', async e => {
const row = e.target.closest('[data-nav-type]');
if (!row) return;
const { navType, navId, navName, navArtist } = row.dataset;
closeStats();
const { navigate } = await import('../views/views.js');
if (navType === 'artist') navigate('artist-detail', { id: navId, name: navName });
else if (navType === 'album') navigate('album-detail', { id: navId, name: navName, artist: navArtist || '' });
});
document.body.appendChild(_overlay);
_renderTabs();
}
function _renderTabs() {
const tabs = _overlay.querySelector('#stats-tabs');
const scopes = [['all', 'Alles'], ['year', 'Jahr'], ['month', 'Monat']];
const chip = (k, l) => `
<button class="stats-tab" data-scope="${k}"
style="padding:7px 14px;border-radius:999px;font-family:var(--font);font-size:13px;font-weight:600;cursor:pointer;
border:1px solid var(--border);transition:all .15s;
background:${k === _view ? 'var(--accent)' : 'transparent'};
color:${k === _view ? '#fff' : 'var(--text2)'}">${l}</button>`;
const stepBtn = (dir, label, disabled) => `
<button data-step="${dir}" ${disabled ? 'disabled' : ''}
style="width:30px;height:30px;border-radius:8px;border:1px solid var(--border);background:transparent;
color:var(--text2);cursor:${disabled ? 'default' : 'pointer'};opacity:${disabled ? 0.35 : 1};
font-size:16px;line-height:1;display:flex;align-items:center;justify-content:center">${label}</button>`;
const stepper = _view === 'all' ? '' : `
<div style="display:flex;align-items:center;gap:8px;margin-left:auto">
${stepBtn(-1, '‹', _atStart())}
<div style="min-width:118px;text-align:center;font-weight:700;font-size:14px">${esc(_periodLabel())}</div>
${stepBtn(1, '›', _atCurrent())}
</div>`;
tabs.innerHTML = `<div style="display:flex;align-items:center;gap:6px;width:100%;flex-wrap:wrap">
${scopes.map(([k, l]) => chip(k, l)).join('')}${stepper}
</div>`;
tabs.querySelectorAll('[data-scope]').forEach(b =>
b.addEventListener('click', () => { _view = b.dataset.scope; _renderTabs(); _load(); }));
tabs.querySelectorAll('[data-step]').forEach(b =>
b.addEventListener('click', () => { if (!b.disabled) { _step(+b.dataset.step); _renderTabs(); _load(); } }));
}
async function _load() {
const body = _overlay.querySelector('#stats-body');
if (!S.cfg.scrobbleUrl) {
body.innerHTML = `<div class="stats-empty" style="text-align:center;color:var(--text2);padding:40px 0;line-height:1.6">
Kein Scrobble-Dienst konfiguriert.<br>Trage die <b>Scrobble-URL</b> in den Einstellungen ein,
damit deine Wiedergaben aufgezeichnet werden.</div>`;
return;
}
const auth = subsonicAuthParams();
if (!auth) { body.innerHTML = _errBox('Nicht angemeldet.'); return; }
// Only show "Lade…" on the very first open — on a period/tab switch keep the current content
// in place until the new data arrives, so the modal doesn't flash small→big (min-height also
// keeps its size stable). A per-request token guards against out-of-order responses.
if (!body.dataset.loaded) body.innerHTML = `<div style="text-align:center;color:var(--text2);padding:40px 0">Lade…</div>`;
const token = String(Date.now());
body.dataset.req = token;
const { from, to, bucket } = _range();
const qs = `${auth}&from=${from}&to=${to}&tz=${_tzOffsetSec()}&bucket=${bucket}&limit=10`;
try {
const res = await fetch(`${S.cfg.scrobbleUrl}/scrobble/stats?${qs}`);
if (body.dataset.req !== token) return; // a newer request superseded this one
if (!res.ok) { body.innerHTML = _errBox(`Fehler ${res.status}`); return; }
const d = await res.json();
if (body.dataset.req !== token) return;
if (d.firstPlayedAt) { _firstPlayedAt = d.firstPlayedAt; _renderTabs(); } // apply back-bound
_render(body, d);
body.dataset.loaded = '1';
} catch {
if (body.dataset.req === token) body.innerHTML = _errBox('Scrobble-Dienst nicht erreichbar.');
}
}
function _errBox(msg) {
return `<div style="text-align:center;color:var(--danger);padding:40px 0">${esc(msg)}</div>`;
}
function _render(body, d) {
if (!d.totalPlays) {
body.innerHTML = `<div style="text-align:center;color:var(--text2);padding:40px 0;line-height:1.6">
Noch keine Wiedergaben in diesem Zeitraum.<br>Spiel ein paar Songs — sie erscheinen hier automatisch.</div>`;
return;
}
body.innerHTML = [
_tiles(d),
_seriesChart(d.series, d.range.bucket),
_topList('Top-Künstler', d.topArtists, a => a.name, a => a.plays, a => a.artistId, true,
a => a.artistId ? { type: 'artist', id: a.artistId, name: a.name } : null),
_topList('Top-Songs', d.topTracks, t => `${t.title}${t.artist ? ' · ' + t.artist : ''}`, t => t.plays, t => t.coverArt || t.albumId || t.songId, false,
t => t.albumId ? { type: 'album', id: t.albumId, name: t.album, artist: t.artist } : null),
_topList('Top-Alben', d.topAlbums, a => `${a.name}${a.artist ? ' · ' + a.artist : ''}`, a => a.plays, a => a.coverArt || a.albumId, false,
a => a.albumId ? { type: 'album', id: a.albumId, name: a.name, artist: a.artist } : null),
_topList('Top-Genres', d.topGenres, g => g.name, g => g.plays),
_hourClock(d.byHour),
].join('');
}
function _tiles(d) {
const tile = (val, label) => `
<div style="flex:1;background:var(--surface2);border:1px solid var(--border);border-radius:12px;padding:14px 16px">
<div style="font-size:24px;font-weight:800;letter-spacing:-.5px">${val}</div>
<div style="font-size:12px;color:var(--text2);margin-top:2px">${label}</div>
</div>`;
return `<div style="display:flex;gap:10px;margin-bottom:18px">
${tile(d.totalPlays.toLocaleString('de-DE'), 'Plays')}
${tile(_fmtTime(d.totalSeconds), 'Hörzeit')}
${tile((d.topArtists?.length || 0) >= 10 ? '10+' : (d.topArtists?.length || 0), 'Künstler')}
</div>`;
}
// Simple vertical bar chart of plays per bucket.
function _seriesChart(series, bucket) {
if (!series?.length) return '';
const max = Math.max(...series.map(s => s.plays), 1);
const fmtLabel = b => bucket === 'month'
? b.slice(5) + '.' + b.slice(2, 4) // "08.25"
: bucket === 'year' ? b : b.slice(8) + '.' + b.slice(5, 7); // "03.08"
const bars = series.map(s => `
<div title="${esc(s.bucket)}: ${s.plays}" style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;min-width:0">
<div style="width:100%;display:flex;align-items:flex-end;height:90px">
<div style="width:100%;background:var(--accent);border-radius:4px 4px 0 0;height:${Math.round((s.plays / max) * 100)}%;min-height:2px"></div>
</div>
<div style="font-size:10px;color:var(--text2);white-space:nowrap;transform:rotate(-45deg);transform-origin:center;height:16px">${esc(fmtLabel(s.bucket))}</div>
</div>`).join('');
return `${_h('Verlauf')}
<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, idFn = null, circular = false, navFn = null) {
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 = idFn
? `<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>`
: '';
const nav = navFn ? navFn(it) : null;
const navAttrs = nav
? `data-nav-type="${nav.type}" data-nav-id="${esc(nav.id)}" data-nav-name="${esc(nav.name || '')}" data-nav-artist="${esc(nav.artist || '')}"`
: '';
return `<div ${navAttrs} style="display:flex;align-items:center;gap:10px;padding:6px 0${nav ? ';cursor:pointer' : ''}">
<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">
<div style="height:100%;background:var(--accent);width:${Math.round((val / max) * 100)}%"></div>
</div>
</div>
<div style="font-size:13px;color:var(--text2);font-weight:600;min-width:34px;text-align:right">${val}</div>
</div>`;
}).join('');
return `${_h(title)}<div style="margin-bottom:22px">${rows}</div>`;
}
function _hourClock(byHour) {
if (!byHour?.length) return '';
const max = Math.max(...byHour.map(h => h.plays), 1);
const bars = byHour.map(h => `
<div title="${h.hour}:00 — ${h.plays}" style="flex:1;display:flex;flex-direction:column;align-items:center;gap:3px;min-width:0">
<div style="width:100%;display:flex;align-items:flex-end;height:56px">
<div style="width:100%;background:var(--accent);opacity:${h.plays ? 1 : 0.15};border-radius:3px 3px 0 0;height:${Math.round((h.plays / max) * 100)}%;min-height:2px"></div>
</div>
${h.hour % 6 === 0 ? `<div style="font-size:9px;color:var(--text2)">${h.hour}</div>` : '<div style="height:11px"></div>'}
</div>`).join('');
return `${_h('Hör-Uhr (Stunde)')}
<div style="display:flex;gap:2px;align-items:flex-end;margin:6px 0 8px">${bars}</div>`;
}
function _h(t) {
return `<div style="font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.4px;color:var(--text2);margin:0 0 8px">${esc(t)}</div>`;
}