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. // Our own timestamped play-log (stats + Wrapped), in addition to the Navidrome scrobble above.
suspend fun logPlay(song: Song) = playLog.logPlay(song) suspend fun logPlay(song: Song) = playLog.logPlay(song)
suspend fun flushPlayLog() = playLog.flush() 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 getArtistInfo(id: String) = api().getArtistInfo(id)
suspend fun getTopSongs(artistName: String) = api().getTopSongs(artistName) suspend fun getTopSongs(artistName: String) = api().getTopSongs(artistName)
suspend fun getGenres() = api().getGenres() suspend fun getGenres() = api().getGenres()
@@ -1,13 +1,16 @@
package com.novasonic.app.data package com.novasonic.app.data
import android.content.Context import android.content.Context
import com.novasonic.app.api.model.ScrobbleStats
import com.novasonic.app.api.model.Song import com.novasonic.app.api.model.Song
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.engine.android.Android import io.ktor.client.engine.android.Android
import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.request.get
import io.ktor.client.request.post import io.ktor.client.request.post
import io.ktor.client.request.setBody import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse 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.contentType import io.ktor.http.contentType
import io.ktor.http.encodeURLParameter 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> = private fun readQueue(): List<PlayEntry> =
runCatching { runCatching {
if (!queueFile.exists()) emptyList() 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( composable(
"album/{albumId}", "album/{albumId}",
@@ -41,7 +41,7 @@ private val SCROBBLE_OPTIONS = listOf(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SettingsScreen() { fun SettingsScreen(onOpenStats: () -> Unit = {}) {
val vm: SettingsViewModel = viewModel() val vm: SettingsViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle() val state by vm.state.collectAsStateWithLifecycle()
@@ -228,6 +228,15 @@ fun SettingsScreen() {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp), color = NsBorder) 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( OutlinedButton(
onClick = { showLogoutDialog = true }, onClick = { showLogoutDialog = true },
modifier = Modifier.fillMaxWidth().height(48.dp), 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)
}
}
}
+6 -1
View File
@@ -214,7 +214,12 @@ volumes:
([src/ui/stats.js](../src/ui/stats.js)) — Overlay aus den Einstellungen („📊 Meine Statistiken"), ([src/ui/stats.js](../src/ui/stats.js)) — Overlay aus den Einstellungen („📊 Meine Statistiken"),
Zeitraum-Tabs (Alles / Dieses Jahr / Letzte 30 Tage), Kacheln, Verlaufs-Balken, Top-Listen, Zeitraum-Tabs (Alles / Dieses Jahr / Letzte 30 Tage), Kacheln, Verlaufs-Balken, Top-Listen,
Hör-Uhr. Vanilla/inline, keine Chart-Lib. **Braucht Runtime-Test am Gerät.** Hör-Uhr. Vanilla/inline, keine Chart-Lib. **Braucht Runtime-Test am Gerät.**
4. **`/scrobble/wrapped`** + Story-Seite; Android-Stats-Screen. 4. 🔨 **In Arbeit**: **Android-Stats-Screen** ✅ (konsumiert `/scrobble/stats`; Kacheln, Verlaufs-
Balken, Top-Listen, Hör-Uhr; Zeitraum-Tabs; aus den Android-Einstellungen). **Cover + Artist-
Bilder** ✅ in Web **und** Android (client-seitig aus Navidrome via `coverUrl`/`coverArtUrl` über
`artistId`/`albumId`/`songId`; Endpoint liefert jetzt auch `albumId` bei Top-Tracks).
**Noch offen: Wrapped** (`/scrobble/wrapped` + Web-Story-Seite). Android + Web build grün;
**braucht Runtime-Test am Gerät.**
5. *(optional)* ListenBrainz-Relay. 5. *(optional)* ListenBrainz-Relay.
--- ---
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'ns-v66'; const CACHE = 'ns-v67';
const PRECACHE = ['/', 'index.html', 'manifest.json', 'icon.svg']; const PRECACHE = ['/', 'index.html', 'manifest.json', 'icon.svg'];
const SKIP_PATHS = ['/rest/stream', '/rest/download', '/config.js']; const SKIP_PATHS = ['/rest/stream', '/rest/download', '/config.js'];
// Auth token params stripped from cache keys so artwork survives across sessions/token refreshes. // Auth token params stripped from cache keys so artwork survives across sessions/token refreshes.
+2 -1
View File
@@ -202,7 +202,8 @@ app.get('/scrobble/stats', async (req, res) => {
`).all(winL); `).all(winL);
const topTracks = db.prepare(` const topTracks = db.prepare(`
SELECT MAX(title) AS title, MAX(artist) AS artist, MAX(album) AS album, song_id AS songId, COUNT(*) AS plays SELECT MAX(title) AS title, MAX(artist) AS artist, MAX(album) AS album,
MAX(album_id) AS albumId, MAX(artist_id) AS artistId, song_id AS songId, COUNT(*) AS plays
FROM plays WHERE ${WHERE} FROM plays WHERE ${WHERE}
GROUP BY song_id ORDER BY plays DESC LIMIT :limit GROUP BY song_id ORDER BY plays DESC LIMIT :limit
`).all(winL); `).all(winL);
+12 -6
View File
@@ -6,7 +6,7 @@
import { S } from '../state.js'; import { S } from '../state.js';
import { esc, formatDur } from '../utils.js'; import { esc, formatDur } from '../utils.js';
import { subsonicAuthParams } from '../api.js'; import { subsonicAuthParams, coverUrl } from '../api.js';
let _overlay = null; let _overlay = null;
let _period = 'all'; // all | year | 30d let _period = 'all'; // all | year | 30d
@@ -122,9 +122,9 @@ function _render(body, d) {
body.innerHTML = [ body.innerHTML = [
_tiles(d), _tiles(d),
_seriesChart(d.series, d.range.bucket), _seriesChart(d.series, d.range.bucket),
_topList('Top-Künstler', d.topArtists, a => a.name, a => a.plays), _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), _topList('Top-Songs', d.topTracks, t => `${t.title}${t.artist ? ' · ' + t.artist : ''}`, t => t.plays, t => t.albumId || t.songId),
_topList('Top-Alben', d.topAlbums, a => `${a.name}${a.artist ? ' · ' + a.artist : ''}`, a => a.plays), _topList('Top-Alben', d.topAlbums, a => `${a.name}${a.artist ? ' · ' + a.artist : ''}`, a => a.plays, a => a.albumId),
_hourClock(d.byHour), _hourClock(d.byHour),
].join(''); ].join('');
} }
@@ -160,13 +160,19 @@ 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>`; <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) { function _topList(title, items, labelFn, valFn, idFn = null, circular = false) {
if (!items?.length) return ''; if (!items?.length) return '';
const max = Math.max(...items.map(valFn), 1); const max = Math.max(...items.map(valFn), 1);
const radius = circular ? '50%' : '6px';
const rows = items.map((it, i) => { const rows = items.map((it, i) => {
const val = valFn(it); const val = valFn(it);
const cover = idFn ? coverUrl(idFn(it), 80) : null;
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"> return `<div style="display:flex;align-items:center;gap:10px;padding:6px 0">
<div style="width:20px;text-align:right;color:var(--text2);font-size:13px;font-weight:700">${i + 1}</div> <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="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="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:5px;background:var(--surface2);border-radius:3px;margin-top:4px;overflow:hidden">