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',