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:
1 parent
0b23a07039
commit
651f979401
6 files changed
+99
-53
No files matched your search
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user