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:
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
+12
-6
@@ -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) {
|
||||
<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 '';
|
||||
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 = `<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">
|
||||
<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="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">
|
||||
|
||||
Reference in new issue
Block a user