fix(android): Warteschlangen-Wiederherstellung robuster und nachvollziehbar (1.5)

Das Wiederherstellen schlug still fehl. Mehrere runCatching verschluckten jeden
Grund, sodass von aussen nur "passiert nichts" zu sehen war.

Zwei wahrscheinliche Ursachen adressiert:
- connect() laeuft in Application.onCreate(), wo der DataStore mit den
  Navidrome-Zugangsdaten unter Umstaenden noch nicht gelesen ist. Dann scheitert
  das Aufloesen ALLER Song-Ids und die Warteschlange bleibt leer. Es wird jetzt
  bis zu fuenfmal mit wachsendem Abstand versucht.
- Wischt man die App aus den letzten Apps, wurde nichts gesichert:
  onIsPlayingChanged feuert dabei nicht zuverlaessig. MusicService.onTaskRemoved
  sichert jetzt vor dem Beenden.

Dazu Protokollausgaben unter dem Tag NsQueue an jeder Stufe (gesichert,
gefunden, Id nicht aufloesbar, wiederhergestellt, aufgegeben), damit ein
weiterer Fehlschlag per logcat zu klaeren ist statt per Vermutung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-08 23:26:17 +02:00
1 parent 8da67e0975
commit 0b23a07039
3 files changed
+64 -17

No files matched your search

+2 -2
View File
@@ -14,8 +14,8 @@ android {
applicationId = "com.novasonic.app"
minSdk = 26
targetSdk = 35
versionCode = 7
versionName = "1.4"
versionCode = 8
versionName = "1.5"
// 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.
@@ -238,7 +238,14 @@ class MusicService : MediaLibraryService() {
override fun onGetSession(info: MediaSession.ControllerInfo) = session
override fun onTaskRemoved(rootIntent: Intent?) { player.stop(); stopSelf() }
override fun onTaskRemoved(rootIntent: Intent?) {
// Wischt jemand die App aus den letzten Apps, wird der Prozess gleich beendet — hier ist die
// letzte Gelegenheit, die Warteschlange festzuhalten. Ohne das ginge sie genau in dem Fall
// verloren, in dem man sie beim naechsten Oeffnen am ehesten erwartet.
runCatching { (application as? com.novasonic.app.NovaSonicApp)?.playerController?.saveQueueNow() }
player.stop()
stopSelf()
}
override fun onDestroy() {
scope.cancel()
@@ -37,6 +37,8 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
private val _state = MutableStateFlow(PlayerState())
val state: StateFlow<PlayerState> = _state.asStateFlow()
private val TAG_QUEUE = "NsQueue"
private val scope = CoroutineScope(
Dispatchers.Main + SupervisorJob() +
CoroutineExceptionHandler { _, t -> android.util.Log.e("PlayerController", "Uncaught coroutine exception", t) }
@@ -71,27 +73,65 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
val ids = queue.map { it.id }
if (ids.isEmpty() || ids.any { it.startsWith("abs:") }) return
val index = ctrl.currentMediaItemIndex
scope.launch { runCatching { repo.saveQueueState(ids, index) } }
scope.launch {
runCatching { repo.saveQueueState(ids, index) }
.onSuccess { android.util.Log.i(TAG_QUEUE, "gesichert: ${ids.size} Titel, Index $index") }
.onFailure { android.util.Log.w(TAG_QUEUE, "Sichern fehlgeschlagen", it) }
}
}
/** Von aussen aufrufbar (Dienst-Ende beim Wegwischen der App). */
fun saveQueueNow() = persistQueueState()
/**
* Stellt die zuletzt gehoerte Warteschlange wieder her — vorbereitet, aber NICHT abgespielt.
* Wer die App oeffnet, will nicht ueberfallen werden; ein Druck auf Play macht dort weiter,
* wo es aufgehoert hat. Laeuft schon etwas (etwa ueber Android Auto gestartet), passiert nichts.
* Wer die App oeffnet, will nicht ueberfallen werden; ein Druck auf Play macht dort weiter.
* Laeuft schon etwas (etwa ueber Android Auto gestartet), passiert nichts.
*
* Die Aufloesung der Song-Ids braucht die Navidrome-Zugangsdaten. connect() laeuft aber schon in
* Application.onCreate(), wo der DataStore unter Umstaenden noch nicht gelesen ist — dann
* scheitern alle Abfragen und die Warteschlange bliebe still leer. Deshalb wird es mehrfach mit
* wachsendem Abstand versucht, statt beim ersten Fehlschlag aufzugeben.
*/
fun restoreQueue() {
val ctrl = controller ?: return
if (ctrl.mediaItemCount > 0) return
scope.launch {
val zustand = runCatching { repo.readQueueState() }.getOrNull() ?: return@launch
// Songs einzeln nachladen; verschwundene (geloescht, umbenannt) werden uebersprungen.
val songs = zustand.ids.mapNotNull { runCatching { repo.getSong(it) }.getOrNull() }
if (songs.isEmpty()) return@launch
val index = zustand.index.coerceIn(0, songs.lastIndex)
queue = songs
ctrl.setMediaItems(songs.map { it.toMediaItem(repo) }, index, 0L)
ctrl.prepare()
syncState()
val zustand = runCatching { repo.readQueueState() }.getOrNull()
if (zustand == null) {
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))
}
android.util.Log.w(TAG_QUEUE, "aufgegeben — keine der ${zustand.ids.size} Ids war aufloesbar")
}
}