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") } }