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

+13
View File
@@ -72,6 +72,19 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an
## [2.4] — 2026-09-06 ## [2.4] — 2026-09-06
### Added ### 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 - **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 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 Version, direkt nach der Installation ist die Leiste also noch leer — einmal abspielen, App
+2 -2
View File
@@ -14,8 +14,8 @@ android {
applicationId = "com.novasonic.app" applicationId = "com.novasonic.app"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 8 versionCode = 9
versionName = "1.5" versionName = "1.6"
// Host der eigenen NovaSonic-Web-App — Ziel der geteilten Links. Einmal in // Host der eigenen NovaSonic-Web-App — Ziel der geteilten Links. Einmal in
// gradle.properties setzen (novasonicHost=music.example.com); ohne Angabe bleibt der // gradle.properties setzen (novasonicHost=music.example.com); ohne Angabe bleibt der
// Platzhalter stehen und der App-Link greift schlicht nicht. // 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 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 readQueueState() = prefs.readQueueState()
suspend fun saveLastSong(song: com.novasonic.app.api.model.Song) = suspend fun saveLastSong(song: com.novasonic.app.api.model.Song) =
prefs.saveLastSong(song.id, song.title, song.artist ?: "", song.coverArt) 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_TITLE = stringPreferencesKey("last_song_title")
val KEY_LAST_SONG_ARTIST = stringPreferencesKey("last_song_artist") val KEY_LAST_SONG_ARTIST = stringPreferencesKey("last_song_artist")
val KEY_LAST_SONG_COVER = stringPreferencesKey("last_song_cover") val KEY_LAST_SONG_COVER = stringPreferencesKey("last_song_cover")
// Warteschlange fortsetzen: Song-Ids und Position in der Liste. Bewusst OHNE Sekunde im // Warteschlange fortsetzen. Gespeichert werden die VOLLSTAENDIGEN Songs als JSON, nicht nur
// Titel — der zuletzt gehoerte Titel beginnt von vorn, das genuegt und spart staendiges // Ids: mit Ids musste beim Start jeder Titel einzeln ueber das Netz nachgeladen werden, was
// Schreiben waehrend der Wiedergabe. // Sekunden dauerte und ohne Verbindung gar nicht ging. So ist es ein lokaler Lesevorgang.
val KEY_QUEUE_IDS = stringPreferencesKey("queue_ids") // 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") 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: * Sichert die Warteschlange als VOLLSTAENDIGE Songs, nicht nur als Ids. Mit Ids musste beim
* Titel und Cover koennen sich aendern (Metadaten-Bearbeitung), die Id ist der stabile Bezug. * 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 -> context.dataStore.edit { prefs ->
prefs[KEY_QUEUE_IDS] = ids.joinToString(",") prefs[KEY_QUEUE_JSON] = kodiert
prefs[KEY_QUEUE_INDEX] = index.coerceAtLeast(0) 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? { suspend fun readQueueState(): QueueState? {
val prefs = context.dataStore.data.first() val prefs = context.dataStore.data.first()
val ids = prefs[KEY_QUEUE_IDS]?.split(',')?.filter { it.isNotBlank() }.orEmpty() val roh = prefs[KEY_QUEUE_JSON]?.takeIf { it.isNotBlank() } ?: return null
if (ids.isEmpty()) return null val songs = runCatching {
return QueueState( queueJson.decodeFromString(kotlinx.serialization.builtins.ListSerializer(
ids = ids, com.novasonic.app.api.model.Song.serializer()), roh)
index = (prefs[KEY_QUEUE_INDEX] ?: 0).coerceIn(0, ids.lastIndex), }.getOrNull().orEmpty()
) if (songs.isEmpty()) return null
return QueueState(songs, (prefs[KEY_QUEUE_INDEX] ?: 0).coerceIn(0, songs.lastIndex))
} }
suspend fun clear() { suspend fun clear() {
@@ -70,12 +70,12 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
*/ */
private fun persistQueueState() { private fun persistQueueState() {
val ctrl = controller ?: return val ctrl = controller ?: return
val ids = queue.map { it.id } val songs = queue
if (ids.isEmpty() || ids.any { it.startsWith("abs:") }) return if (songs.isEmpty() || songs.any { it.id.startsWith("abs:") }) return
val index = ctrl.currentMediaItemIndex val index = ctrl.currentMediaItemIndex
scope.launch { scope.launch {
runCatching { repo.saveQueueState(ids, index) } runCatching { repo.saveQueueState(songs, index) }
.onSuccess { android.util.Log.i(TAG_QUEUE, "gesichert: ${ids.size} Titel, Index $index") } .onSuccess { android.util.Log.i(TAG_QUEUE, "gesichert: ${songs.size} Titel, Index $index") }
.onFailure { android.util.Log.w(TAG_QUEUE, "Sichern fehlgeschlagen", it) } .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") android.util.Log.i(TAG_QUEUE, "nichts gesichert — nichts wiederherzustellen")
return@launch return@launch
} }
android.util.Log.i(TAG_QUEUE, "gesichert gefunden: ${zustand.ids.size} Ids, Index ${zustand.index}") val ctrl = controller
if (ctrl == null) {
repeat(5) { versuch -> android.util.Log.w(TAG_QUEUE, "kein Controller — Wiederherstellen verworfen")
val ctrl = controller return@launch
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))
} }
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")
} }
} }
+34
View File
@@ -1,6 +1,40 @@
import { defineConfig } from 'vite'; 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({ export default defineConfig({
plugins: [serviceWorkerVersion()],
build: { build: {
outDir: 'dist', outDir: 'dist',
assetsDir: 'assets', assetsDir: 'assets',