// ─── 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;
let _period = 'all'; // all | year | 30d
const PERIODS = [
{ key: 'all', label: 'Alles', bucket: 'month' },
{ key: 'year', label: 'Dieses Jahr', bucket: 'month' },
{ key: '30d', label: 'Letzte 30 Tage', bucket: 'day' },
];
// 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; }
function _periodRange(key) {
const now = Math.floor(Date.now() / 1000);
if (key === 'year') {
const from = Math.floor(new Date(new Date().getFullYear(), 0, 1).getTime() / 1000);
return { from, to: now + 1 };
}
if (key === '30d') return { from: now - 30 * 86400, to: now + 1 };
return { from: 0, to: now + 1 }; // all time
}
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 = `
`;
document.body.appendChild(_overlay);
_renderTabs();
}
function _renderTabs() {
const tabs = _overlay.querySelector('#stats-tabs');
tabs.innerHTML = PERIODS.map(p => `
`).join('');
tabs.querySelectorAll('.stats-tab').forEach(b =>
b.addEventListener('click', () => { _period = b.dataset.period; _renderTabs(); _load(); }));
}
async function _load() {
const body = _overlay.querySelector('#stats-body');
if (!S.cfg.scrobbleUrl) {
body.innerHTML = `
Kein Scrobble-Dienst konfiguriert.
Trage die Scrobble-URL in den Einstellungen ein,
damit deine Wiedergaben aufgezeichnet werden.
`;
return;
}
const auth = subsonicAuthParams();
if (!auth) { body.innerHTML = _errBox('Nicht angemeldet.'); return; }
body.innerHTML = `Lade…
`;
const bucket = PERIODS.find(p => p.key === _period)?.bucket || 'month';
const { from, to } = _periodRange(_period);
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 (!res.ok) { body.innerHTML = _errBox(`Fehler ${res.status}`); return; }
const d = await res.json();
_render(body, d);
} catch {
body.innerHTML = _errBox('Scrobble-Dienst nicht erreichbar.');
}
}
function _errBox(msg) {
return `${esc(msg)}
`;
}
function _render(body, d) {
if (!d.totalPlays) {
body.innerHTML = `
Noch keine Wiedergaben in diesem Zeitraum.
Spiel ein paar Songs — sie erscheinen hier automatisch.
`;
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),
_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('');
}
function _tiles(d) {
const tile = (val, label) => `
`;
return `
${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')}
`;
}
// 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 => `
${esc(fmtLabel(s.bucket))}
`).join('');
return `${_h('Verlauf')}
${bars}
`;
}
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 = `
${cover ? `
})
` : ''}
`;
return ``;
}).join('');
return `${_h(title)}${rows}
`;
}
function _hourClock(byHour) {
if (!byHour?.length) return '';
const max = Math.max(...byHour.map(h => h.plays), 1);
const bars = byHour.map(h => `
${h.hour % 6 === 0 ? `
${h.hour}
` : '
'}
`).join('');
return `${_h('Hör-Uhr (Stunde)')}
${bars}
`;
}
function _h(t) {
return `${esc(t)}
`;
}