perf(android): Warteschlange sofort wiederherstellen, SW-Cache automatisch versionieren

Android speicherte nur die Song-Ids und lud beim Start jeden Titel EINZELN und
nacheinander ueber das Netz nach. Das dauerte Sekunden, war unzuverlaessig und
ohne Verbindung unmoeglich. Gespeichert werden jetzt die vollstaendigen Songs als
JSON — Wiederherstellen ist ein lokaler Lesevorgang, wie im Web. Der Preis ist
ein veralteter Titel oder Cover bis zum naechsten Abspielen, was die deutlich
kleinere Unschoenheit ist.

Dazu sichert MusicService.onTaskRemoved die Warteschlange beim Wegwischen der
App: dort feuert onIsPlayingChanged nicht zuverlaessig, genau dieser Fall ging
vorher verloren.

Service Worker: die Cache-Version stand handgepflegt als ns-v78 in sw.js und
wurde beim Ausliefern vergessen — die Aktivierung loescht nur Caches mit
abweichendem Namen, also blieb der alte bestehen und Nutzer mussten den
Browsercache von Hand leeren. Ein Vite-Plugin setzt sie jetzt aus der Bauzeit,
die Quelldatei bleibt unveraendert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-09 00:01:30 +02:00
1 parent 0b23a07039
commit 651f979401
6 files changed
+99 -53

No files matched your search

+2 -2
View File
@@ -14,8 +14,8 @@ android {
applicationId = "com.novasonic.app"
minSdk = 26
targetSdk = 35
versionCode = 8
versionName = "1.5"
versionCode = 9
versionName = "1.6"
// Host der eigenen NovaSonic-Web-App — Ziel der geteilten Links. Einmal in
// gradle.properties setzen (novasonicHost=music.example.com); ohne Angabe bleibt der
// Platzhalter stehen und der App-Link greift schlicht nicht.
@@ -169,7 +169,8 @@ class MusicRepository(private val context: Context) {
suspend fun saveTheme(themeId: String) = prefs.saveTheme(themeId)
suspend fun saveQueueState(ids: List<String>, index: Int) = prefs.saveQueueState(ids, index)
suspend fun saveQueueState(songs: List<com.novasonic.app.api.model.Song>, index: Int) =
prefs.saveQueueState(songs, index)
suspend fun readQueueState() = prefs.readQueueState()
suspend fun saveLastSong(song: com.novasonic.app.api.model.Song) =
prefs.saveLastSong(song.id, song.title, song.artist ?: "", song.coverArt)
@@ -31,10 +31,11 @@ class PrefsStore(private val context: Context) {
val KEY_LAST_SONG_TITLE = stringPreferencesKey("last_song_title")
val KEY_LAST_SONG_ARTIST = stringPreferencesKey("last_song_artist")
val KEY_LAST_SONG_COVER = stringPreferencesKey("last_song_cover")
// Warteschlange fortsetzen: Song-Ids und Position in der Liste. Bewusst OHNE Sekunde im
// Titel — der zuletzt gehoerte Titel beginnt von vorn, das genuegt und spart staendiges
// Schreiben waehrend der Wiedergabe.
val KEY_QUEUE_IDS = stringPreferencesKey("queue_ids")
// Warteschlange fortsetzen. Gespeichert werden die VOLLSTAENDIGEN Songs als JSON, nicht nur
// Ids: mit Ids musste beim Start jeder Titel einzeln ueber das Netz nachgeladen werden, was
// Sekunden dauerte und ohne Verbindung gar nicht ging. So ist es ein lokaler Lesevorgang.
// Ohne Sekunde im Titel — der zuletzt gehoerte Titel beginnt von vorn.
val KEY_QUEUE_JSON = stringPreferencesKey("queue_json")
val KEY_QUEUE_INDEX = intPreferencesKey("queue_index")
}
@@ -144,27 +145,37 @@ class PrefsStore(private val context: Context) {
}
}
private val queueJson = kotlinx.serialization.json.Json { ignoreUnknownKeys = true }
/**
* Sichert die Warteschlange als Id-Liste samt Position. Nur Ids, keine vollstaendigen Songs:
* Titel und Cover koennen sich aendern (Metadaten-Bearbeitung), die Id ist der stabile Bezug.
* Sichert die Warteschlange als VOLLSTAENDIGE Songs, nicht nur als Ids. Mit Ids musste beim
* Start jeder Titel einzeln ueber das Netz nachgeladen werden — das dauerte Sekunden und ging
* ohne Verbindung gar nicht. So ist das Wiederherstellen ein rein lokaler Lesevorgang.
* Der Preis: zwischenzeitlich geaenderte Titel oder Cover zeigen bis zum naechsten Abspielen
* den alten Stand. Das ist die deutlich kleinere Unschoenheit.
*/
suspend fun saveQueueState(ids: List<String>, index: Int) {
suspend fun saveQueueState(songs: List<com.novasonic.app.api.model.Song>, index: Int) {
val kodiert = runCatching {
queueJson.encodeToString(kotlinx.serialization.builtins.ListSerializer(
com.novasonic.app.api.model.Song.serializer()), songs)
}.getOrNull() ?: return
context.dataStore.edit { prefs ->
prefs[KEY_QUEUE_IDS] = ids.joinToString(",")
prefs[KEY_QUEUE_JSON] = kodiert
prefs[KEY_QUEUE_INDEX] = index.coerceAtLeast(0)
}
}
data class QueueState(val ids: List<String>, val index: Int)
data class QueueState(val songs: List<com.novasonic.app.api.model.Song>, val index: Int)
suspend fun readQueueState(): QueueState? {
val prefs = context.dataStore.data.first()
val ids = prefs[KEY_QUEUE_IDS]?.split(',')?.filter { it.isNotBlank() }.orEmpty()
if (ids.isEmpty()) return null
return QueueState(
ids = ids,
index = (prefs[KEY_QUEUE_INDEX] ?: 0).coerceIn(0, ids.lastIndex),
)
val roh = prefs[KEY_QUEUE_JSON]?.takeIf { it.isNotBlank() } ?: return null
val songs = runCatching {
queueJson.decodeFromString(kotlinx.serialization.builtins.ListSerializer(
com.novasonic.app.api.model.Song.serializer()), roh)
}.getOrNull().orEmpty()
if (songs.isEmpty()) return null
return QueueState(songs, (prefs[KEY_QUEUE_INDEX] ?: 0).coerceIn(0, songs.lastIndex))
}
suspend fun clear() {
@@ -70,12 +70,12 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
*/
private fun persistQueueState() {
val ctrl = controller ?: return
val ids = queue.map { it.id }
if (ids.isEmpty() || ids.any { it.startsWith("abs:") }) return
val songs = queue
if (songs.isEmpty() || songs.any { it.id.startsWith("abs:") }) return
val index = ctrl.currentMediaItemIndex
scope.launch {
runCatching { repo.saveQueueState(ids, index) }
.onSuccess { android.util.Log.i(TAG_QUEUE, "gesichert: ${ids.size} Titel, Index $index") }
runCatching { repo.saveQueueState(songs, index) }
.onSuccess { android.util.Log.i(TAG_QUEUE, "gesichert: ${songs.size} Titel, Index $index") }
.onFailure { android.util.Log.w(TAG_QUEUE, "Sichern fehlgeschlagen", it) }
}
}
@@ -100,38 +100,25 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
android.util.Log.i(TAG_QUEUE, "nichts gesichert — nichts wiederherzustellen")
return@launch
}
android.util.Log.i(TAG_QUEUE, "gesichert gefunden: ${zustand.ids.size} Ids, Index ${zustand.index}")
repeat(5) { versuch ->
val ctrl = controller
if (ctrl == null) {
delay(500L * (versuch + 1)); return@repeat
}
if (ctrl.mediaItemCount > 0) {
android.util.Log.i(TAG_QUEUE, "Player hat bereits Titel — nicht angefasst")
return@launch
}
val songs = zustand.ids.mapNotNull { id ->
runCatching { repo.getSong(id) }
.onFailure { android.util.Log.w(TAG_QUEUE, "Id $id nicht aufloesbar: ${it.message}") }
.getOrNull()
}
if (songs.isNotEmpty()) {
val index = zustand.index.coerceIn(0, songs.lastIndex)
queue = songs
originalQueue = songs
runCatching {
ctrl.setMediaItems(songs.map { it.toMediaItem(repo) }, index, 0L)
ctrl.prepare()
}.onFailure { android.util.Log.w(TAG_QUEUE, "setMediaItems fehlgeschlagen", it) }
syncState()
android.util.Log.i(TAG_QUEUE, "wiederhergestellt: ${songs.size} Titel, Index $index")
return@launch
}
android.util.Log.i(TAG_QUEUE, "Versuch ${versuch + 1}: kein Titel aufloesbar, warte…")
delay(800L * (versuch + 1))
val ctrl = controller
if (ctrl == null) {
android.util.Log.w(TAG_QUEUE, "kein Controller — Wiederherstellen verworfen")
return@launch
}
android.util.Log.w(TAG_QUEUE, "aufgegeben — keine der ${zustand.ids.size} Ids war aufloesbar")
if (ctrl.mediaItemCount > 0) {
android.util.Log.i(TAG_QUEUE, "Player hat bereits Titel — nicht angefasst")
return@launch
}
val songs = zustand.songs
val index = zustand.index.coerceIn(0, songs.lastIndex)
queue = songs
originalQueue = songs
runCatching {
ctrl.setMediaItems(songs.map { it.toMediaItem(repo) }, index, 0L)
ctrl.prepare()
}.onFailure { android.util.Log.w(TAG_QUEUE, "setMediaItems fehlgeschlagen", it) }
syncState()
android.util.Log.i(TAG_QUEUE, "wiederhergestellt: ${songs.size} Titel, Index $index")
}
}