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) {