fix(android): back focus lost after tabbing out — re-assert overlay back callbacks on resume
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
17bb527e1d
commit
e69fbaf75e
3 files changed
+21
-5
No files matched your search
@@ -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`
|
- **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
|
### 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 + 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** — 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)
|
- **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)
|
||||||
|
|||||||
@@ -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**
|
its close action upward via `onOverlayBackChange`, and `AppNavigation` registers a **root-level**
|
||||||
`OnBackPressedCallback` for it (the same mechanism the NowPlaying overlay always used successfully).
|
`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.
|
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
|
- **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
|
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
|
leaves composition. `rememberSaveable` does **not** save you — it only survives for real nav
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import androidx.compose.ui.text.TextStyle
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation.*
|
import androidx.navigation.*
|
||||||
import androidx.navigation.compose.*
|
import androidx.navigation.compose.*
|
||||||
@@ -112,7 +114,15 @@ fun AppNavigation() {
|
|||||||
var exploreOverlayBack by remember { mutableStateOf<(() -> Unit)?>(null) }
|
var exploreOverlayBack by remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||||
|
|
||||||
val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
|
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 {}
|
if (!showNowPlaying || backDispatcher == null) return@DisposableEffect onDispose {}
|
||||||
val cb = object : OnBackPressedCallback(true) {
|
val cb = object : OnBackPressedCallback(true) {
|
||||||
override fun handleOnBackPressed() { showNowPlaying = false }
|
override fun handleOnBackPressed() { showNowPlaying = false }
|
||||||
@@ -120,7 +130,7 @@ fun AppNavigation() {
|
|||||||
backDispatcher.addCallback(cb)
|
backDispatcher.addCallback(cb)
|
||||||
onDispose { cb.remove() }
|
onDispose { cb.remove() }
|
||||||
}
|
}
|
||||||
DisposableEffect(showAbsPlayer, backDispatcher) {
|
DisposableEffect(showAbsPlayer, backDispatcher, currentBackEntry, resumeTick) {
|
||||||
if (!showAbsPlayer || backDispatcher == null) return@DisposableEffect onDispose {}
|
if (!showAbsPlayer || backDispatcher == null) return@DisposableEffect onDispose {}
|
||||||
val cb = object : OnBackPressedCallback(true) {
|
val cb = object : OnBackPressedCallback(true) {
|
||||||
override fun handleOnBackPressed() { showAbsPlayer = false }
|
override fun handleOnBackPressed() { showAbsPlayer = false }
|
||||||
@@ -128,7 +138,7 @@ fun AppNavigation() {
|
|||||||
backDispatcher.addCallback(cb)
|
backDispatcher.addCallback(cb)
|
||||||
onDispose { cb.remove() }
|
onDispose { cb.remove() }
|
||||||
}
|
}
|
||||||
DisposableEffect(selectedAbsItem, backDispatcher) {
|
DisposableEffect(selectedAbsItem, backDispatcher, currentBackEntry, resumeTick) {
|
||||||
if (selectedAbsItem == null || backDispatcher == null) return@DisposableEffect onDispose {}
|
if (selectedAbsItem == null || backDispatcher == null) return@DisposableEffect onDispose {}
|
||||||
val cb = object : OnBackPressedCallback(true) {
|
val cb = object : OnBackPressedCallback(true) {
|
||||||
override fun handleOnBackPressed() { selectedAbsItem = null }
|
override fun handleOnBackPressed() { selectedAbsItem = null }
|
||||||
@@ -148,7 +158,6 @@ fun AppNavigation() {
|
|||||||
|
|
||||||
var homeRefreshKey by remember { mutableIntStateOf(0) }
|
var homeRefreshKey by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
val currentBackEntry by navController.currentBackStackEntryAsState()
|
|
||||||
val currentRoute = currentBackEntry?.destination?.route
|
val currentRoute = currentBackEntry?.destination?.route
|
||||||
val isTopLevel = TopLevel.entries.any { it.route == currentRoute }
|
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
|
// 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
|
// — 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).
|
// 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
|
val action = exploreOverlayBack
|
||||||
if (action == null || backDispatcher == null) return@DisposableEffect onDispose {}
|
if (action == null || backDispatcher == null) return@DisposableEffect onDispose {}
|
||||||
val cb = object : OnBackPressedCallback(true) {
|
val cb = object : OnBackPressedCallback(true) {
|
||||||
|
|||||||
Reference in new issue
Block a user