// ─── 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 = ` `; // 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) => ` `; const stepBtn = (dir, label, disabled) => ` `; const stepper = _view === 'all' ? '' : `
${stepBtn(-1, '‹', _atStart())}
${esc(_periodLabel())}
${stepBtn(1, '›', _atCurrent())}
`; tabs.innerHTML = `
${scopes.map(([k, l]) => chip(k, l)).join('')}${stepper}
`; 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 = `
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; } // 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 = `
Lade…
`; 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 `
${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, 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) => `
${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, 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 ? `
${cover ? `` : ''}
` : ''; 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 `
${i + 1}
${thumb}
${esc(labelFn(it))}
${val}
`; }).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)}
`; }