diff --git a/android/app/src/main/kotlin/com/novasonic/app/api/model/ScrobbleModels.kt b/android/app/src/main/kotlin/com/novasonic/app/api/model/ScrobbleModels.kt index 7d2ab50..00a33f7 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/api/model/ScrobbleModels.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/api/model/ScrobbleModels.kt @@ -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 = emptyList(), diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt index 14b1d4a..cccc1c2 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt @@ -392,8 +392,10 @@ fun AppNavigation() { } composable("stats") { StatsScreen( - coverUrl = { id -> coverUrl(id) }, - onBack = { navController.popBackStack() }, + coverUrl = { id -> coverUrl(id) }, + onBack = { navController.popBackStack() }, + onArtistClick = { id -> navController.navigate("artist/$id") }, + onAlbumClick = { id -> navController.navigate("album/$id") }, ) } diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/SettingsScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/SettingsScreen.kt index 49e1dfd..ff1babf 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/SettingsScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/SettingsScreen.kt @@ -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), diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/StatsScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/StatsScreen.kt index b2accc7..12ed327 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/StatsScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/StatsScreen.kt @@ -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, diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/StatsViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/StatsViewModel.kt index 6d3c6b8..afc14bb 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/StatsViewModel.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/StatsViewModel.kt @@ -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, + ) } } } diff --git a/public/sw.js b/public/sw.js index 3e4e46b..a171699 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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. diff --git a/scrobble/scrobble-server.js b/scrobble/scrobble-server.js index 4e01121..7c4018a 100644 --- a/scrobble/scrobble-server.js +++ b/scrobble/scrobble-server.js @@ -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, diff --git a/src/ui/stats.js b/src/ui/stats.js index 5b5a894..e0ea290 100644 --- a/src/ui/stats.js +++ b/src/ui/stats.js @@ -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() {
`; + // 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}`; const stepper = _view === 'all' ? '' : `
- ${stepBtn(-1, 'β€Ή', false)} + ${stepBtn(-1, 'β€Ή', _atStart())}
${esc(_periodLabel())}
${stepBtn(1, 'β€Ί', _atCurrent())}
`; @@ -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) {
${bars}
`; } -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 = `
${cover ? `` : ''}
`; - return `
+ 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}