From 6a7d9afdbc81c44d80d88f1d0d9c8cefa560b848 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 1 Jun 2026 23:29:36 -0400 Subject: [PATCH] fix(android): latch NowPlaying drag-dismiss to prevent back-stack underflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator reproduced black-screen-on-resume by drag-down dismissing the full player. Root cause: the NestedScrollConnection accumulator crossed the dismiss threshold, called navController.popBackStack(), reset accumulated to 0 — but the user's finger was still down and the pop transition was still running. The next frame's onPostScroll re-accumulated and re-fired onDismiss(), popping the screen BENEATH NowPlaying. When that left the back stack empty the NavHost had no destination to draw, producing a black window until the process was killed and the activity was cold-launched. Add a `dismissed` latch that survives until the connection is disposed (which only happens when NowPlayingScreen leaves the composition, i.e. the pop completes). After the latch sets we consume the remaining drag (return `available`) so the underlying scrollable doesn't paint over-scroll while the pop transitions. onPreFling also bails after dismissal so the fling can't restart the accumulator. --- .../minstrel/player/ui/NowPlayingScreen.kt | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt index 46a74516..3ceea516 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt @@ -189,16 +189,29 @@ private fun rememberDragDismissConnection( object : NestedScrollConnection { private var accumulated = 0f + // One-shot latch. popBackStack is async — between the first + // dismissal and the screen actually leaving the composition, + // the user's finger is still down and more onPostScroll + // frames arrive. Without this guard the accumulator rebuilds + // and onDismiss() fires a second time, popping the screen + // BENEATH NowPlaying. If that leaves the back stack empty + // the NavHost renders nothing → black screen on resume. + private var dismissed = false + override fun onPostScroll( consumed: Offset, available: Offset, source: NestedScrollSource, ): Offset { + // After dismissal, eat all remaining drag so the + // scrollable doesn't paint over-scroll deltas during the + // pop transition. + if (dismissed) return available if (source != NestedScrollSource.UserInput) return Offset.Zero return if (available.y > 0f) { accumulated += available.y if (accumulated >= thresholdPx) { - accumulated = 0f + dismissed = true onDismiss() } Offset(0f, available.y) @@ -209,6 +222,7 @@ private fun rememberDragDismissConnection( } override suspend fun onPreFling(available: Velocity): Velocity { + if (dismissed) return available accumulated = 0f return Velocity.Zero }