feat(scrobble stats): navigator lower bound, clickable rows, android button to top
- Navigator can't step before logging began: the endpoint returns firstPlayedAt (MIN(played_at) over all the user's plays, unfiltered by window); web + android disable the back arrow once the shown year/month is at/before that first play. - Top artists/tracks/albums are now clickable → open the artist / album page (web navigates and closes the overlay; android navigates via nav routes). Tracks open their album. - Android: moved the 'Meine Statistiken' button to the top of Settings (was at the bottom near logout). SW cache -> ns-v70. Android compileDebugKotlin + web build green; firstPlayedAt verified server-side. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
86eb893f40
commit
3129333f9a
8 files changed
+93
-26
No files matched your search
+34
-6
@@ -14,6 +14,7 @@ let _overlay = null;
|
||||
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'];
|
||||
@@ -64,6 +65,15 @@ function _atCurrent() {
|
||||
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') {
|
||||
@@ -101,6 +111,16 @@ function _buildOverlay() {
|
||||
<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"></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();
|
||||
}
|
||||
@@ -121,7 +141,7 @@ function _renderTabs() {
|
||||
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, '‹', false)}
|
||||
${stepBtn(-1, '‹', _atStart())}
|
||||
<div style="min-width:118px;text-align:center;font-weight:700;font-size:14px">${esc(_periodLabel())}</div>
|
||||
${stepBtn(1, '›', _atCurrent())}
|
||||
</div>`;
|
||||
@@ -153,6 +173,7 @@ async function _load() {
|
||||
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();
|
||||
if (d.firstPlayedAt) { _firstPlayedAt = d.firstPlayedAt; _renderTabs(); } // apply back-bound
|
||||
_render(body, d);
|
||||
} catch {
|
||||
body.innerHTML = _errBox('Scrobble-Dienst nicht erreichbar.');
|
||||
@@ -172,9 +193,12 @@ 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, a => a.artistId, true),
|
||||
_topList('Top-Songs', d.topTracks, t => `${t.title}${t.artist ? ' · ' + t.artist : ''}`, t => t.plays, t => t.coverArt || t.albumId || t.songId),
|
||||
_topList('Top-Alben', d.topAlbums, a => `${a.name}${a.artist ? ' · ' + a.artist : ''}`, a => a.plays, a => a.coverArt || a.albumId),
|
||||
_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),
|
||||
_hourClock(d.byHour),
|
||||
].join('');
|
||||
}
|
||||
@@ -210,7 +234,7 @@ 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, idFn = null, circular = false) {
|
||||
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';
|
||||
@@ -220,7 +244,11 @@ function _topList(title, items, labelFn, valFn, idFn = null, circular = false) {
|
||||
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">
|
||||
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">
|
||||
|
||||
Reference in new issue
Block a user