From e69fbaf75e7ace109e5f259db0e37f62edeecd04 Mon Sep 17 00:00:00 2001 From: Honey Date: Thu, 6 Aug 2026 10:00:04 +0200 Subject: [PATCH] =?UTF-8?q?fix(android):=20back=20focus=20lost=20after=20t?= =?UTF-8?q?abbing=20out=20=E2=80=94=20re-assert=20overlay=20back=20callbac?= =?UTF-8?q?ks=20on=20resume?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With NowPlaying (or an ABS player) open, backgrounding and resuming the app made the next back press pop the NavHost invisibly to Home before dismissing the overlay. Returning from the background re-registers the NavController's own OnBackPressedCallback on top of the dispatcher, stealing back-priority from the overlays. AppNavigation now bumps a resumeTick on Lifecycle.Event.ON_RESUME and keys every overlay DisposableEffect (NowPlaying, ABS player, ABS item, Erkunden overlay) on resumeTick + currentBackEntry, so each overlay re-adds its callback above the NavController after a resume or navigation. Needs on-device verification. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + CLAUDE.md | 6 ++++++ .../com/novasonic/app/ui/AppNavigation.kt | 19 ++++++++++++++----- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8ea1d1..e7f6126 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,7 @@ Big change: **the entire metadata/search stack moved from Apple's iTunes to Deez - **Backend** — Unit tests for the Deezer mapping/matching logic (`backend/test/deezer.test.js`, run with `npm test`), incl. per-track numbering, mixed-artist album-artist stability, explicit flags and the Japanese-title match guard. A live smoke test runs with `RUN_LIVE=1` ### Fixed +- **Android** — After **tabbing out and back into the app** with NowPlaying (or an ABS player) open, pressing back first popped the NavHost **invisibly to Home** and only *then* dismissed the overlay — the focus was lost. Returning from the background re-registers the NavController's own back callback on top of the dispatcher, stealing back-priority from the overlays. The overlay back-callbacks are now re-asserted on `ON_RESUME` (and on navigation), so back closes the overlay first again - **Web + Android** — A finished-download **notification led to the wrong song**: it resolved the album by name via `search3`, and when the exact artist match wasn't found (e.g. the new album isn't scanned yet), it fell back to a same-named album by a **different** artist (or the first result). Several artists share an album title ("I LIKE IT!"), so tapping the notification opened a completely different song. Now the artist must match when known (fuzzy for multi-artist/album-artist drift); otherwise it shows "wird evtl. gerade gescannt" instead of opening a wrong album - **Web** — Downloading a not-yet-owned album from an artist page searched YouTube for "**Unknown Artist** <album>" and failed. Deezer's `/artist/{id}/albums` omits the `artist` object, so the discography cards had an empty artist name → the modal fell back to the literal `"Unknown Artist"`. Fixed by backfilling the known artist name in `deezerLookupArtistAlbums` and defaulting the download modal to **Album** for album cards (was always **Track**) - **Web + Android** — Owned albums were wrongly shown as **"Unvollständig 1 / 0 Tracks"** on the artist page. Deezer's `/artist/{id}/albums` list omits `nb_tracks`, so the total read as 0 and every matched album looked incomplete. Web now resolves the real track count from the Deezer album endpoint for **matched (owned) albums only** and treats an unknown total as complete; Android treats an unknown total as complete (never a false partial) diff --git a/CLAUDE.md b/CLAUDE.md index b4e4196..dc7b524 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,6 +132,12 @@ Backend runs in Docker (see `docker-compose.yml`); on the deploy host it lives a its close action upward via `onOverlayBackChange`, and `AppNavigation` registers a **root-level** `OnBackPressedCallback` for it (the same mechanism the NowPlaying overlay always used successfully). Real nav routes (`album/{id}`, `artist/{id}`) are unaffected — `popBackStack()` is correct there. +- **Re-assert root-level back callbacks on `ON_RESUME`, not just on navigation.** Returning from the + background re-registers the NavController's own `OnBackPressedCallback` on **top** of the dispatcher, + which steals back-priority from the NowPlaying/ABS/Erkunden overlays — back then pops the NavHost + (invisibly, to Home) before dismissing the visible overlay. Fix in `AppNavigation`: a `resumeTick` + (`LifecycleEventEffect(ON_RESUME)`) plus `currentBackEntry` are keys on every overlay's + `DisposableEffect`, so each re-adds (bumps itself above the NavController) after a resume or a nav. - **Overlay state belongs in the ViewModel, not `remember`.** Anything an overlay shows (e.g. the artist's "all albums" section) is destroyed when the user drills into an album, because the overlay leaves composition. `rememberSaveable` does **not** save you — it only survives for real nav diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt index d432533..adbd746 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt @@ -23,6 +23,8 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.* import androidx.navigation.compose.* @@ -112,7 +114,15 @@ fun AppNavigation() { var exploreOverlayBack by remember { mutableStateOf<(() -> Unit)?>(null) } val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher - DisposableEffect(showNowPlaying, backDispatcher) { + val currentBackEntry by navController.currentBackStackEntryAsState() + // Returning from the background re-registers the NavController's OWN back callback on top of the + // dispatcher, which stole back-priority from the NowPlaying/ABS overlays: pressing back then popped + // the NavHost (invisibly, all the way to Home) before dismissing the overlay. Bump this tick on + // ON_RESUME (and re-key on navigation) so the overlay callbacks below re-add above it again. + var resumeTick by remember { mutableIntStateOf(0) } + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { resumeTick++ } + + DisposableEffect(showNowPlaying, backDispatcher, currentBackEntry, resumeTick) { if (!showNowPlaying || backDispatcher == null) return@DisposableEffect onDispose {} val cb = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { showNowPlaying = false } @@ -120,7 +130,7 @@ fun AppNavigation() { backDispatcher.addCallback(cb) onDispose { cb.remove() } } - DisposableEffect(showAbsPlayer, backDispatcher) { + DisposableEffect(showAbsPlayer, backDispatcher, currentBackEntry, resumeTick) { if (!showAbsPlayer || backDispatcher == null) return@DisposableEffect onDispose {} val cb = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { showAbsPlayer = false } @@ -128,7 +138,7 @@ fun AppNavigation() { backDispatcher.addCallback(cb) onDispose { cb.remove() } } - DisposableEffect(selectedAbsItem, backDispatcher) { + DisposableEffect(selectedAbsItem, backDispatcher, currentBackEntry, resumeTick) { if (selectedAbsItem == null || backDispatcher == null) return@DisposableEffect onDispose {} val cb = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { selectedAbsItem = null } @@ -148,7 +158,6 @@ fun AppNavigation() { var homeRefreshKey by remember { mutableIntStateOf(0) } - val currentBackEntry by navController.currentBackStackEntryAsState() val currentRoute = currentBackEntry?.destination?.route val isTopLevel = TopLevel.entries.any { it.route == currentRoute } @@ -156,7 +165,7 @@ fun AppNavigation() { // callback is re-added (bumped to the top of the OnBackPressedDispatcher) after every navigation // — otherwise, once the NavHost re-registers its own callback during an album drill-down or tab // switch, it could regain priority and pop Erkunden → Home on the next back ("sometimes" bug). - DisposableEffect(exploreOverlayBack, backDispatcher, currentBackEntry) { + DisposableEffect(exploreOverlayBack, backDispatcher, currentBackEntry, resumeTick) { val action = exploreOverlayBack if (action == null || backDispatcher == null) return@DisposableEffect onDispose {} val cb = object : OnBackPressedCallback(true) {