feat(scrobble): Android stats screen + cover art in stats (web + android)

Phase 4 (part B): Android statistics screen consuming GET /scrobble/stats —
tiles (plays + listening time), day/month/year play series, top artists/tracks/
albums, 24h listening clock, period tabs; opened from a Settings button
("Meine Statistiken"). New StatsViewModel + StatsScreen + ScrobbleModels;
PlayLogClient.fetchStats() (tolerant JSON decode, null on any failure);
repo.fetchScrobbleStats(); nav route + SettingsScreen(onOpenStats).

Cover art everywhere: top artists show a circular artist image, tracks/albums
a square cover — resolved client-side from Navidrome via artistId/albumId/songId
(no coverArt knowledge in the scrobble service). Endpoint now also returns
albumId for top tracks so track covers resolve reliably. Web stats list gets the
same thumbnails. SW cache -> ns-v67.

Android compileDebugKotlin + web build green. Needs on-device runtime test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 4.8 committed 2026-08-07 18:41:27 +02:00
1 parent cc1ec1653f
commit fff6f9e4da
11 files changed
+469 -11

No files matched your search

@@ -0,0 +1,55 @@
package com.novasonic.app.api.model
import kotlinx.serialization.Serializable
// Mirrors the JSON from the scrobble service GET /scrobble/stats.
@Serializable
data class ScrobbleStats(
val totalPlays: Int = 0,
val totalSeconds: Long = 0,
val topArtists: List<StatArtist> = emptyList(),
val topAlbums: List<StatAlbum> = emptyList(),
val topTracks: List<StatTrack> = emptyList(),
val series: List<StatBucket> = emptyList(),
val byHour: List<StatHour> = emptyList(),
)
@Serializable
data class StatArtist(
val name: String? = null,
val artistId: String? = null,
val plays: Int = 0,
val seconds: Long = 0,
)
@Serializable
data class StatAlbum(
val name: String? = null,
val albumId: String? = null,
val artist: String? = null,
val plays: Int = 0,
)
@Serializable
data class StatTrack(
val title: String? = null,
val artist: String? = null,
val album: String? = null,
val albumId: String? = null,
val artistId: String? = null,
val songId: String? = null,
val plays: Int = 0,
)
@Serializable
data class StatBucket(
val bucket: String = "",
val plays: Int = 0,
val seconds: Long = 0,
)
@Serializable
data class StatHour(
val hour: Int = 0,
val plays: Int = 0,
)
@@ -60,6 +60,8 @@ class MusicRepository(private val context: Context) {
// Our own timestamped play-log (stats + Wrapped), in addition to the Navidrome scrobble above.
suspend fun logPlay(song: Song) = playLog.logPlay(song)
suspend fun flushPlayLog() = playLog.flush()
suspend fun fetchScrobbleStats(from: Long, to: Long, tz: Int, bucket: String, limit: Int) =
playLog.fetchStats(from, to, tz, bucket, limit)
suspend fun getArtistInfo(id: String) = api().getArtistInfo(id)
suspend fun getTopSongs(artistName: String) = api().getTopSongs(artistName)
suspend fun getGenres() = api().getGenres()
@@ -1,13 +1,16 @@
package com.novasonic.app.data
import android.content.Context
import com.novasonic.app.api.model.ScrobbleStats
import com.novasonic.app.api.model.Song
import io.ktor.client.HttpClient
import io.ktor.client.engine.android.Android
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.http.encodeURLParameter
@@ -113,6 +116,21 @@ class PlayLogClient(context: Context) {
}
}
/** Read aggregated stats for the current user. Returns null on any failure (unconfigured,
* unauthorized, network). Decodes with the tolerant json instance (ignoreUnknownKeys). */
suspend fun fetchStats(from: Long, to: Long, tz: Int, bucket: String, limit: Int): ScrobbleStats? {
val s = prefs.appSettings.first()
val base = s.scrobbleUrl.trimEnd('/')
if (base.isBlank() || s.username.isBlank() || s.password.isBlank()) return null
val auth = authParams(s.username, s.password)
val url = "$base/scrobble/stats?$auth&from=$from&to=$to&tz=$tz&bucket=$bucket&limit=$limit"
return runCatching {
val resp = http.get(url)
if (!resp.status.isSuccess()) return null
json.decodeFromString<ScrobbleStats>(resp.bodyAsText())
}.getOrNull()
}
private fun readQueue(): List<PlayEntry> =
runCatching {
if (!queueFile.exists()) emptyList()
@@ -387,7 +387,15 @@ fun AppNavigation() {
}
}
}
composable(TopLevel.SETTINGS.route) { SettingsScreen() }
composable(TopLevel.SETTINGS.route) {
SettingsScreen(onOpenStats = { navController.navigate("stats") })
}
composable("stats") {
StatsScreen(
coverUrl = { id -> coverUrl(id) },
onBack = { navController.popBackStack() },
)
}
composable(
"album/{albumId}",
@@ -41,7 +41,7 @@ private val SCROBBLE_OPTIONS = listOf(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen() {
fun SettingsScreen(onOpenStats: () -> Unit = {}) {
val vm: SettingsViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle()
@@ -228,6 +228,15 @@ fun SettingsScreen() {
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),
@@ -0,0 +1,280 @@
package com.novasonic.app.ui.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.novasonic.app.api.model.ScrobbleStats
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.StatsPeriod
import com.novasonic.app.viewmodel.StatsViewModel
@Composable
fun StatsScreen(
coverUrl: (String?) -> String?,
onBack: () -> Unit,
vm: StatsViewModel = viewModel(),
) {
val state by vm.state.collectAsStateWithLifecycle()
Column(Modifier.fillMaxSize().background(NsBackground)) {
// ── Top bar ──
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(start = 4.dp, top = 8.dp, end = 12.dp),
) {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Zurück", tint = NsOnSurface)
}
Text(
"Meine Statistiken",
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 22.sp,
color = NsOnSurface,
)
}
// ── Period chips ──
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 8.dp),
) {
StatsPeriod.entries.forEach { p ->
val active = p == state.period
Text(
p.label,
fontFamily = Outfit, fontSize = 13.sp, fontWeight = FontWeight.SemiBold,
color = if (active) Color.White else NsOnSurface2,
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(if (active) NsAccent else Color.Transparent)
.clickable { vm.setPeriod(p) }
.padding(horizontal = 14.dp, vertical = 7.dp),
)
}
}
when {
state.loading -> CenterBox { CircularProgressIndicator(color = NsAccent) }
!state.configured -> CenterMessage(
"Kein Scrobble-Dienst konfiguriert.\nTrage die Scrobble-URL in den Einstellungen ein.",
)
state.error != null -> CenterMessage(state.error!!, NsError)
(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)
}
}
}
@Composable
private fun StatsContent(d: ScrobbleStats, coverUrl: (String?) -> String?) {
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp).padding(bottom = 24.dp),
) {
// Tiles
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(bottom = 18.dp)) {
Tile(d.totalPlays.toString(), "Wiedergaben", Modifier.weight(1f))
Tile(fmtTime(d.totalSeconds), "Hörzeit", Modifier.weight(1f))
Tile(if (d.topArtists.size >= 10) "10+" else d.topArtists.size.toString(), "Künstler", Modifier.weight(1f))
}
if (d.series.isNotEmpty()) {
SectionHeader("Verlauf")
SeriesChart(d.series)
Spacer(Modifier.height(20.dp))
}
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)
}
Spacer(Modifier.height(20.dp))
}
if (d.topTracks.isNotEmpty()) {
SectionHeader("Top-Songs")
d.topTracks.forEachIndexed { i, t ->
TopRow(i + 1, coverUrl(t.albumId ?: t.songId), t.title ?: "—", t.artist, t.plays)
}
Spacer(Modifier.height(20.dp))
}
if (d.topAlbums.isNotEmpty()) {
SectionHeader("Top-Alben")
d.topAlbums.forEachIndexed { i, a ->
TopRow(i + 1, coverUrl(a.albumId), a.name ?: "—", a.artist, a.plays)
}
Spacer(Modifier.height(20.dp))
}
if (d.byHour.any { it.plays > 0 }) {
SectionHeader("Hör-Uhr (Stunde)")
HourClock(d.byHour.map { it.plays })
}
}
}
@Composable
private fun Tile(value: String, label: String, modifier: Modifier = Modifier) {
Column(
modifier
.clip(RoundedCornerShape(12.dp))
.background(NsSurface2)
.padding(14.dp),
) {
Text(value, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 22.sp, color = NsOnSurface, maxLines = 1)
Text(label, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2, modifier = Modifier.padding(top = 2.dp))
}
}
@Composable
private fun SectionHeader(t: String) {
Text(
t.uppercase(), fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 12.sp,
letterSpacing = 0.6.sp, color = NsOnSurface2, modifier = Modifier.padding(bottom = 8.dp),
)
}
@Composable
private fun TopRow(
rank: Int, cover: String?, title: String, subtitle: String?, plays: Int, circular: Boolean = false,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp),
) {
Text(
rank.toString(), fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 13.sp,
color = NsOnSurface2, modifier = Modifier.width(22.dp),
)
Box(
Modifier
.size(44.dp)
.clip(if (circular) CircleShape else RoundedCornerShape(6.dp))
.background(NsSurface2),
) {
AsyncImage(
model = cover, contentDescription = null, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
Text(title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (!subtitle.isNullOrBlank()) {
Text(subtitle, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
Text("$plays", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, color = NsOnSurface2)
}
}
@Composable
private fun SeriesChart(series: List<com.novasonic.app.api.model.StatBucket>) {
val max = (series.maxOfOrNull { it.plays } ?: 1).coerceAtLeast(1)
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.Bottom,
modifier = Modifier.fillMaxWidth().height(110.dp).horizontalScroll(rememberScrollState()),
) {
series.forEach { s ->
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.width(30.dp)) {
Box(
Modifier
.width(20.dp)
.height((6 + (84 * s.plays / max)).dp)
.clip(RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp))
.background(NsAccent),
)
Text(
seriesLabel(s.bucket), fontFamily = Outfit, fontSize = 9.sp, color = NsOnSurface2,
maxLines = 1, modifier = Modifier.padding(top = 4.dp),
)
}
}
}
}
@Composable
private fun HourClock(hours: List<Int>) {
val max = (hours.maxOrNull() ?: 1).coerceAtLeast(1)
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.Bottom,
modifier = Modifier.fillMaxWidth().height(72.dp),
) {
hours.forEachIndexed { h, plays ->
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.weight(1f)) {
Box(
Modifier
.fillMaxWidth()
.height((2 + (48 * plays / max)).dp)
.clip(RoundedCornerShape(topStart = 3.dp, topEnd = 3.dp))
.background(if (plays > 0) NsAccent else NsSurface2),
)
Text(
if (h % 6 == 0) h.toString() else "",
fontFamily = Outfit, fontSize = 9.sp, color = NsOnSurface2,
modifier = Modifier.padding(top = 3.dp),
)
}
}
}
}
@Composable
private fun CenterBox(content: @Composable BoxScope.() -> Unit) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center, content = content)
}
@Composable
private fun CenterMessage(msg: String, color: Color = NsOnSurface2) {
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
Text(msg, fontFamily = Outfit, fontSize = 14.sp, color = color)
}
}
// "3h 12m" / "12m" / "45s"
private fun fmtTime(sec: Long): String {
val h = sec / 3600
val m = (sec % 3600) / 60
return when {
h > 0 -> "${h}h ${m}m"
m > 0 -> "${m}m"
else -> "${sec}s"
}
}
// "2025-08" -> "08.25", "2025-08-03" -> "03.08", "2025" -> "2025"
private fun seriesLabel(b: String): String = when {
b.length == 7 -> b.substring(5) + "." + b.substring(2, 4)
b.length == 10 -> b.substring(8) + "." + b.substring(5, 7)
else -> b
}
@@ -0,0 +1,74 @@
package com.novasonic.app.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.model.ScrobbleStats
import com.novasonic.app.app
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import java.util.Calendar
import java.util.TimeZone
enum class StatsPeriod(val key: String, val label: String, val bucket: String) {
ALL("all", "Alles", "month"),
YEAR("year", "Dieses Jahr", "month"),
LAST30("30d", "Letzte 30 Tage", "day"),
}
data class StatsUiState(
val loading: Boolean = true,
val configured: Boolean = true,
val error: String? = null,
val period: StatsPeriod = StatsPeriod.ALL,
val stats: ScrobbleStats? = null,
)
class StatsViewModel(app: Application) : AndroidViewModel(app) {
private val repo = app.app.repository
private val _state = MutableStateFlow(StatsUiState())
val state: StateFlow<StatsUiState> = _state
init { load(StatsPeriod.ALL) }
fun setPeriod(p: StatsPeriod) {
if (p == _state.value.period && _state.value.stats != null) return
load(p)
}
private fun periodRange(p: StatsPeriod): Triple<Long, Long, String> {
val now = System.currentTimeMillis() / 1000
return when (p) {
StatsPeriod.ALL -> Triple(0L, now + 1, p.bucket)
StatsPeriod.LAST30 -> Triple(now - 30L * 86400, now + 1, p.bucket)
StatsPeriod.YEAR -> {
val c = Calendar.getInstance().apply {
set(Calendar.MONTH, 0); set(Calendar.DAY_OF_MONTH, 1)
set(Calendar.HOUR_OF_DAY, 0); set(Calendar.MINUTE, 0); set(Calendar.SECOND, 0)
}
Triple(c.timeInMillis / 1000, now + 1, p.bucket)
}
}
}
private fun load(period: StatsPeriod) {
viewModelScope.launch {
_state.value = _state.value.copy(loading = true, error = null, period = period)
val settings = repo.appSettingsFlow().first()
if (settings.scrobbleUrl.isBlank()) {
_state.value = _state.value.copy(loading = false, configured = false)
return@launch
}
val (from, to, bucket) = periodRange(period)
val tz = TimeZone.getDefault().getOffset(System.currentTimeMillis()) / 1000
val stats = repo.fetchScrobbleStats(from, to, tz, bucket, 10)
_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)
}
}
}