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:
HoneyandClaude Opus 4.8 committed 2026-08-08 21:50:27 +02:00
1 parent 86eb893f40
commit 3129333f9a
8 files changed
+91 -24

No files matched your search

@@ -5,6 +5,7 @@ import kotlinx.serialization.Serializable
// Mirrors the JSON from the scrobble service GET /scrobble/stats.
@Serializable
data class ScrobbleStats(
val firstPlayedAt: Long? = null,
val totalPlays: Int = 0,
val totalSeconds: Long = 0,
val topArtists: List<StatArtist> = emptyList(),
@@ -394,6 +394,8 @@ fun AppNavigation() {
StatsScreen(
coverUrl = { id -> coverUrl(id) },
onBack = { navController.popBackStack() },
onArtistClick = { id -> navController.navigate("artist/$id") },
onAlbumClick = { id -> navController.navigate("album/$id") },
)
}
@@ -80,6 +80,14 @@ fun SettingsScreen(onOpenStats: () -> Unit = {}) {
color = NsOnSurface,
)
// ── 📊 Statistics (TOP) ──────────────────────────────────────────────
OutlinedButton(
onClick = onOpenStats,
modifier = Modifier.fillMaxWidth().height(48.dp),
) {
Text("📊 Meine Statistiken", color = NsOnSurface, fontFamily = Outfit, fontWeight = FontWeight.SemiBold)
}
// ── Save button (TOP) ────────────────────────────────────────────────
SaveButton(vm, state.isSaving, state.savedOk, state.error)
@@ -228,15 +236,6 @@ fun SettingsScreen(onOpenStats: () -> Unit = {}) {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp), color = NsBorder)
OutlinedButton(
onClick = onOpenStats,
modifier = Modifier.fillMaxWidth().height(48.dp),
) {
Text("📊 Meine Statistiken", color = NsOnSurface, fontFamily = Outfit, fontWeight = FontWeight.SemiBold)
}
Spacer(Modifier.height(8.dp))
OutlinedButton(
onClick = { showLogoutDialog = true },
modifier = Modifier.fillMaxWidth().height(48.dp),
@@ -41,6 +41,8 @@ private val MONTHS = listOf(
fun StatsScreen(
coverUrl: (String?) -> String?,
onBack: () -> Unit,
onArtistClick: (String) -> Unit = {},
onAlbumClick: (String) -> Unit = {},
vm: StatsViewModel = viewModel(),
) {
val state by vm.state.collectAsStateWithLifecycle()
@@ -82,7 +84,7 @@ fun StatsScreen(
}
if (state.view != StatsView.ALL) {
Spacer(Modifier.weight(1f))
StepArrow("‹", enabled = true) { vm.step(-1) }
StepArrow("‹", enabled = !vm.atStart()) { vm.step(-1) }
Text(
if (state.view == StatsView.YEAR) state.year.toString() else "${MONTHS[state.month]} ${state.year}",
fontFamily = Outfit, fontSize = 14.sp, fontWeight = FontWeight.Bold, color = NsOnSurface,
@@ -101,13 +103,18 @@ fun StatsScreen(
(state.stats?.totalPlays ?: 0) == 0 -> CenterMessage(
"Noch keine Wiedergaben in diesem Zeitraum.\nSpiel ein paar Songs — sie erscheinen hier automatisch.",
)
else -> StatsContent(state.stats!!, coverUrl)
else -> StatsContent(state.stats!!, coverUrl, onArtistClick, onAlbumClick)
}
}
}
@Composable
private fun StatsContent(d: ScrobbleStats, coverUrl: (String?) -> String?) {
private fun StatsContent(
d: ScrobbleStats,
coverUrl: (String?) -> String?,
onArtistClick: (String) -> Unit,
onAlbumClick: (String) -> Unit,
) {
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp).padding(bottom = 24.dp),
@@ -128,7 +135,8 @@ private fun StatsContent(d: ScrobbleStats, coverUrl: (String?) -> String?) {
if (d.topArtists.isNotEmpty()) {
SectionHeader("Top-Künstler")
d.topArtists.forEachIndexed { i, a ->
TopRow(i + 1, coverUrl(a.artistId), a.name ?: "—", null, a.plays, circular = true)
TopRow(i + 1, coverUrl(a.artistId), a.name ?: "—", null, a.plays, circular = true,
onClick = a.artistId?.let { id -> { onArtistClick(id) } })
}
Spacer(Modifier.height(20.dp))
}
@@ -136,7 +144,8 @@ private fun StatsContent(d: ScrobbleStats, coverUrl: (String?) -> String?) {
if (d.topTracks.isNotEmpty()) {
SectionHeader("Top-Songs")
d.topTracks.forEachIndexed { i, t ->
TopRow(i + 1, coverUrl(t.coverArt ?: t.albumId ?: t.songId), t.title ?: "—", t.artist, t.plays)
TopRow(i + 1, coverUrl(t.coverArt ?: t.albumId ?: t.songId), t.title ?: "—", t.artist, t.plays,
onClick = t.albumId?.let { id -> { onAlbumClick(id) } })
}
Spacer(Modifier.height(20.dp))
}
@@ -144,7 +153,8 @@ private fun StatsContent(d: ScrobbleStats, coverUrl: (String?) -> String?) {
if (d.topAlbums.isNotEmpty()) {
SectionHeader("Top-Alben")
d.topAlbums.forEachIndexed { i, a ->
TopRow(i + 1, coverUrl(a.coverArt ?: a.albumId), a.name ?: "—", a.artist, a.plays)
TopRow(i + 1, coverUrl(a.coverArt ?: a.albumId), a.name ?: "—", a.artist, a.plays,
onClick = a.albumId?.let { id -> { onAlbumClick(id) } })
}
Spacer(Modifier.height(20.dp))
}
@@ -193,10 +203,13 @@ private fun SectionHeader(t: String) {
@Composable
private fun TopRow(
rank: Int, cover: String?, title: String, subtitle: String?, plays: Int, circular: Boolean = false,
onClick: (() -> Unit)? = null,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp),
modifier = Modifier.fillMaxWidth()
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.padding(vertical = 5.dp),
) {
Text(
rank.toString(), fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 13.sp,
@@ -21,6 +21,7 @@ data class StatsUiState(
val view: StatsView = StatsView.ALL,
val year: Int = Calendar.getInstance().get(Calendar.YEAR),
val month: Int = Calendar.getInstance().get(Calendar.MONTH), // 0-11
val firstPlayedAt: Long? = null, // earliest logged play → navigator lower bound
val stats: ScrobbleStats? = null,
)
@@ -67,6 +68,20 @@ class StatsViewModel(app: Application) : AndroidViewModel(app) {
}
}
/** True when the shown window is at/before the first logged play — caps stepping backwards. */
fun atStart(): Boolean {
val s = _state.value
val first = s.firstPlayedAt ?: return false
val c = Calendar.getInstance().apply { timeInMillis = first * 1000 }
val fy = c.get(Calendar.YEAR)
val fm = c.get(Calendar.MONTH)
return when (s.view) {
StatsView.YEAR -> s.year <= fy
StatsView.MONTH -> s.year < fy || (s.year == fy && s.month <= fm)
else -> false
}
}
private fun startOf(year: Int, month: Int, day: Int): Long {
val c = Calendar.getInstance().apply {
clear()
@@ -99,7 +114,11 @@ class StatsViewModel(app: Application) : AndroidViewModel(app) {
_state.value = if (stats == null)
_state.value.copy(loading = false, error = "Scrobble-Dienst nicht erreichbar")
else
_state.value.copy(loading = false, configured = true, stats = stats)
_state.value.copy(
loading = false, configured = true, stats = stats,
// Keep the earliest-play bound once known (a windowed query may return null for it).
firstPlayedAt = stats.firstPlayedAt ?: _state.value.firstPlayedAt,
)
}
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'ns-v69';
const CACHE = 'ns-v70';
const PRECACHE = ['/', 'index.html', 'manifest.json', 'icon.svg'];
const SKIP_PATHS = ['/rest/stream', '/rest/download', '/config.js'];
// Auth token params stripped from cache keys so artwork survives across sessions/token refreshes.
+5
View File
@@ -195,6 +195,10 @@ app.get('/scrobble/stats', async (req, res) => {
`SELECT COUNT(*) AS plays, COALESCE(SUM(duration), 0) AS seconds FROM plays WHERE ${WHERE}`
).get(win);
// Earliest play across ALL of the user's history (unfiltered by window) — lets the client cap the
// navigator so you can't step before logging began.
const firstPlayedAt = db.prepare('SELECT MIN(played_at) AS t FROM plays WHERE user = :user').get({ user }).t || null;
const topArtists = db.prepare(`
SELECT MAX(artist) AS name, MAX(artist_id) AS artistId, COUNT(*) AS plays, COALESCE(SUM(duration),0) AS seconds
FROM plays WHERE ${WHERE} AND artist IS NOT NULL AND artist <> ''
@@ -231,6 +235,7 @@ app.get('/scrobble/stats', async (req, res) => {
res.json({
user,
range: { from, to, tz, bucket },
firstPlayedAt,
totalPlays: totals.plays,
totalSeconds: totals.seconds,
topArtists, topAlbums, topTracks, series, byHour,
+34 -6
View File
@@ -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">