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
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user