From 0b23a07039eb677923a1f5dc9045956ca4e4621c Mon Sep 17 00:00:00 2001 From: marc Date: Tue, 8 Sep 2026 23:26:17 +0200 Subject: [PATCH] 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 --- android/app/build.gradle.kts | 4 +- .../com/novasonic/app/player/MusicService.kt | 9 ++- .../novasonic/app/player/PlayerController.kt | 68 +++++++++++++++---- 3 files changed, 64 insertions(+), 17 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 21f71c0..89a9e51 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 = 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. diff --git a/android/app/src/main/kotlin/com/novasonic/app/player/MusicService.kt b/android/app/src/main/kotlin/com/novasonic/app/player/MusicService.kt index c92ec53..2972597 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/player/MusicService.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/player/MusicService.kt @@ -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() 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 7d64f4b..0cd2a69 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 @@ -37,6 +37,8 @@ class PlayerController(private val context: Context, private val repo: MusicRepo private val _state = MutableStateFlow(PlayerState()) val state: StateFlow = _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") } }