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 new file mode 100644 index 0000000..3d48a65 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/api/model/ScrobbleModels.kt @@ -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 = emptyList(), + val topAlbums: List = emptyList(), + val topTracks: List = emptyList(), + val series: List = emptyList(), + val byHour: List = 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, +) diff --git a/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt b/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt index 89d5e12..1ed0b72 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt @@ -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() diff --git a/android/app/src/main/kotlin/com/novasonic/app/data/PlayLogClient.kt b/android/app/src/main/kotlin/com/novasonic/app/data/PlayLogClient.kt index 2b7313b..1f697ee 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/data/PlayLogClient.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/data/PlayLogClient.kt @@ -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(resp.bodyAsText()) + }.getOrNull() + } + private fun readQueue(): List = runCatching { if (!queueFile.exists()) 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 adbd746..14b1d4a 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 @@ -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}", 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 24e997b..49e1dfd 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 @@ -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), 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 new file mode 100644 index 0000000..13f0107 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/StatsScreen.kt @@ -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) { + 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) { + 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 +} 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 new file mode 100644 index 0000000..72bd29a --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/StatsViewModel.kt @@ -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 = _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 { + 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) + } + } +} diff --git a/docs/scrobble-plan.md b/docs/scrobble-plan.md index 6efa421..7e77207 100644 --- a/docs/scrobble-plan.md +++ b/docs/scrobble-plan.md @@ -214,7 +214,12 @@ volumes: ([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, 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. --- diff --git a/public/sw.js b/public/sw.js index 0c9f5ba..9f74144 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'ns-v66'; +const CACHE = 'ns-v67'; 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 40085c4..77111d1 100644 --- a/scrobble/scrobble-server.js +++ b/scrobble/scrobble-server.js @@ -202,7 +202,8 @@ app.get('/scrobble/stats', async (req, res) => { `).all(winL); 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} GROUP BY song_id ORDER BY plays DESC LIMIT :limit `).all(winL); diff --git a/src/ui/stats.js b/src/ui/stats.js index 2ea1d95..357347a 100644 --- a/src/ui/stats.js +++ b/src/ui/stats.js @@ -6,7 +6,7 @@ import { S } from '../state.js'; import { esc, formatDur } from '../utils.js'; -import { subsonicAuthParams } from '../api.js'; +import { subsonicAuthParams, coverUrl } from '../api.js'; let _overlay = null; let _period = 'all'; // all | year | 30d @@ -122,9 +122,9 @@ 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), - _topList('Top-Songs', d.topTracks, t => `${t.title}${t.artist ? ' · ' + t.artist : ''}`, t => t.plays), - _topList('Top-Alben', d.topAlbums, a => `${a.name}${a.artist ? ' · ' + a.artist : ''}`, 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, t => t.albumId || t.songId), + _topList('Top-Alben', d.topAlbums, a => `${a.name}${a.artist ? ' · ' + a.artist : ''}`, a => a.plays, a => a.albumId), _hourClock(d.byHour), ].join(''); } @@ -160,13 +160,19 @@ function _seriesChart(series, bucket) {
${bars}
`; } -function _topList(title, items, labelFn, valFn) { +function _topList(title, items, labelFn, valFn, idFn = null, circular = false) { if (!items?.length) return ''; const max = Math.max(...items.map(valFn), 1); + const radius = circular ? '50%' : '6px'; const rows = items.map((it, i) => { const val = valFn(it); + const cover = idFn ? coverUrl(idFn(it), 80) : null; + const thumb = `
+ ${cover ? `` : ''} +
`; return `
-
${i + 1}
+
${i + 1}
+ ${thumb}
${esc(labelFn(it))}