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>
294 lines
14 KiB
JavaScript
294 lines
14 KiB
JavaScript
// ─── 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>`;
|
||
}
|