From 651f9794013f5a1aad9b26fc734c1256ec1ec156 Mon Sep 17 00:00:00 2001 From: marc Date: Wed, 9 Sep 2026 00:01:30 +0200 Subject: [PATCH] perf(android): Warteschlange sofort wiederherstellen, SW-Cache automatisch versionieren MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 13 +++++ android/app/build.gradle.kts | 4 +- .../com/novasonic/app/data/MusicRepository.kt | 3 +- .../com/novasonic/app/data/PrefsStore.kt | 41 ++++++++----- .../novasonic/app/player/PlayerController.kt | 57 +++++++------------ vite.config.js | 34 +++++++++++ 6 files changed, 99 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b21eb3..886f37a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,19 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an ## [2.4] — 2026-09-06 ### Added +- **Android 1.6 (versionCode 9): Warteschlange ist sofort da** — bisher wurden nur die Song-Ids + gesichert und beim Start **einzeln nacheinander über das Netz** nachgeladen. Das dauerte Sekunden, + klappte mal und mal nicht und ohne Verbindung gar nicht. Jetzt liegen die vollständigen Songs als + JSON im Gerätespeicher — das Wiederherstellen ist ein rein lokaler Lesevorgang, wie im Web. Preis: + zwischenzeitlich geänderte Titel oder Cover zeigen bis zum nächsten Abspielen den alten Stand. + Zusätzlich sichert `MusicService.onTaskRemoved` die Warteschlange, wenn die App aus den letzten + Apps gewischt wird — dabei feuert `onIsPlayingChanged` nicht zuverlässig, genau dieser Fall ging + vorher verloren. Protokoll unter dem Tag `NsQueue` (`adb logcat -s NsQueue`). +- **Service-Worker-Cache versioniert sich beim Bauen selbst** — die Cache-Version stand handgepflegt + als `ns-v78` in `sw.js` und wurde beim Ausliefern zuverlässig vergessen. Da die Aktivierung nur + Caches mit *abweichendem* Namen löscht, blieb der alte bestehen und Nutzer mussten den Browsercache + von Hand leeren. Ein Vite-Plugin setzt die Version jetzt aus der Bauzeit (`ns-JJJJMMTThhmmss`), der + alte Cache räumt sich beim ersten Start selbst ab. Die Quelldatei bleibt unverändert. - **Android 1.4 (versionCode 7)** — erste Version mit Warteschlangen-Wiederherstellung und den Streitpunkten im Duell. Hinweis fürs Testen: der gespeicherte Zustand entsteht erst mit dieser Version, direkt nach der Installation ist die Leiste also noch leer — einmal abspielen, App diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 89a9e51..c024e85 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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. 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 060de14..b9217ee 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 @@ -169,7 +169,8 @@ class MusicRepository(private val context: Context) { suspend fun saveTheme(themeId: String) = prefs.saveTheme(themeId) - suspend fun saveQueueState(ids: List, index: Int) = prefs.saveQueueState(ids, index) + suspend fun saveQueueState(songs: List, 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) diff --git a/android/app/src/main/kotlin/com/novasonic/app/data/PrefsStore.kt b/android/app/src/main/kotlin/com/novasonic/app/data/PrefsStore.kt index 89fd19d..17b97dc 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/data/PrefsStore.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/data/PrefsStore.kt @@ -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, index: Int) { + suspend fun saveQueueState(songs: List, 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, val index: Int) + data class QueueState(val songs: List, 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() { diff --git a/android/app/src/main/kotlin/com/novasonic/app/player/PlayerController.kt b/android/app/src/main/kotlin/com/novasonic/app/player/PlayerController.kt index 0cd2a69..f720445 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/player/PlayerController.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/player/PlayerController.kt @@ -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") } } diff --git a/vite.config.js b/vite.config.js index 506258f..e7ea774 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,6 +1,40 @@ import { defineConfig } from 'vite'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * Setzt die Cache-Version des Service Workers beim Bauen. + * + * Vorher stand in sw.js ein handgepflegtes `ns-v78`. Wurde es beim Ausliefern vergessen — und das + * passiert zuverlaessig —, blieb der alte Cache bestehen: die Aktivierung loescht nur Caches, deren + * Name vom aktuellen abweicht. Nutzer sahen dann alte Staende und mussten den Browsercache von Hand + * leeren, was man niemandem erklaeren moechte. Die Version kommt jetzt aus der Bauzeit, ist also bei + * jedem Ausliefern neu, und der alte Cache raeumt sich beim ersten Start selbst ab. + */ +function serviceWorkerVersion() { + return { + name: 'sw-cache-version', + closeBundle() { + const pfad = join('dist', 'sw.js'); + try { + const inhalt = readFileSync(pfad, 'utf8'); + const version = `ns-${new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14)}`; + const neu = inhalt.replace(/^const CACHE = '[^']*';/m, `const CACHE = '${version}';`); + if (neu === inhalt) { + this.warn('sw.js: CACHE-Konstante nicht gefunden — Version nicht gesetzt'); + return; + } + writeFileSync(pfad, neu); + console.log(` Service-Worker-Cache: ${version}`); + } catch (e) { + this.warn(`sw.js konnte nicht versioniert werden: ${e.message}`); + } + }, + }; +} export default defineConfig({ + plugins: [serviceWorkerVersion()], build: { outDir: 'dist', assetsDir: 'assets',