From f7a550224003807a7d88c445125d91c901f7d168 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Sat, 26 Sep 2026 19:50:48 +0000 Subject: [PATCH] fix the startup reveal and the first-play hitch for real The mini player's enter spec was decided from `firstReveal`, which was false at the first composition (the player is not visible yet) and flipped back to `expandVertically` in the same frame the visibility changed, so the expand animation always ran. PlaybackState now carries `restored`, set by restore() and cleared on any user playback, and the mini player uses it to appear in place for the whole restored session. The first play after a restart could still pay for a fresh buffer because a stream-quality reload rebuilds the window, and loadWindow only prepared when autoplay was set. It now always prepares, so a reload (or the restore) leaves the current track buffered. --- .../subtracks/playback/PlaybackController.kt | 14 +++++++++----- .../main/kotlin/com/subtracks/ui/AppRoot.kt | 15 +++++++++------ .../playback/PlaybackControllerTest.kt | 18 +++++++++++++++++- 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt index b6350f31..067062fc 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt @@ -52,6 +52,7 @@ data class PlaybackState( val hasPrevious: Boolean = false, val shuffle: Boolean = false, val repeat: RepeatMode = RepeatMode.Off, + val restored: Boolean = false, ) private sealed interface QueueUndo { @@ -127,6 +128,7 @@ class PlaybackController( private var repeatMode = RepeatMode.Off private var endedHandled = false private var lastSavedPositionMs = 0L + private var restored = false init { scope.launch { @@ -226,6 +228,7 @@ class PlaybackController( windowEnd = -1 lastEdit = null endedHandled = false + restored = false val previous = snapshot val previousEntry = previous?.entries?.firstOrNull()?.entry val same = previousEntry != null && previousEntry.kind == entry.kind && previousEntry.refId == entry.refId @@ -601,6 +604,7 @@ class PlaybackController( windowEnd = -1 lastEdit = null endedHandled = false + restored = false val modes = queueRepository.modes() shuffleEnabled = !disableShuffle && modes.shuffle repeatMode = modes.repeat.toRepeatMode() @@ -661,8 +665,8 @@ class PlaybackController( this.queueSourceId = queueSourceId val position = queueRepository.cursor().coerceIn(0, snapshot.size - 1) lastSavedPositionMs = queueRepository.cursorPositionMs().coerceAtLeast(0) + restored = true loadWindow(position, autoplay = false, startPositionMs = lastSavedPositionMs) - player?.prepare() } private suspend fun stop() = startLock.withLock { stopLocked() } @@ -677,6 +681,7 @@ class PlaybackController( windowStart = 0 windowEnd = -1 lastEdit = null + restored = false player?.run { stop() clear() @@ -701,10 +706,8 @@ class PlaybackController( updating = true player.setWindow(window.map { it.item.toQueueItem() }, startIndex, startPositionMs) updating = false - if (autoplay) { - player.prepare() - player.play() - } + player.prepare() + if (autoplay) player.play() refresh(position) } @@ -940,6 +943,7 @@ class PlaybackController( hasPrevious = true, shuffle = shuffleEnabled, repeat = repeatMode, + restored = restored, ) if (player.isPlaying) startPositionTicker() else stopPositionTicker() } diff --git a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt index 6a8a493a..6ce91a2a 100644 --- a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt +++ b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt @@ -5,6 +5,7 @@ import androidx.activity.OnBackPressedCallback import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.animate import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.snap @@ -233,12 +234,9 @@ private fun MainNavigation() { val navBarInset = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() } val keyboardUp = WindowInsets.imeAnimationTarget.getBottom(density) > 0 val showMiniPlayer = playerVisible && !keyboardUp - var miniPlayerRevealed by remember { mutableStateOf(false) } - val firstReveal = !miniPlayerRevealed && showMiniPlayer - LaunchedEffect(showMiniPlayer) { if (showMiniPlayer) miniPlayerRevealed = true } val bottomInset by animateDpAsState( targetValue = if (showMiniPlayer) 0.dp else navBarInset, - animationSpec = if (firstReveal) snap() else tween(MINI_PLAYER_ANIM_MS), + animationSpec = if (playback.restored) snap() else tween(MINI_PLAYER_ANIM_MS), label = "libraryBottomInset", ) @@ -382,12 +380,17 @@ private fun MainNavigation() { AnimatedVisibility( visible = showMiniPlayer, enter = - if (firstReveal) { + if (playback.restored) { EnterTransition.None } else { expandVertically(tween(MINI_PLAYER_ANIM_MS), expandFrom = Alignment.Bottom) }, - exit = shrinkVertically(tween(MINI_PLAYER_ANIM_MS), shrinkTowards = Alignment.Bottom), + exit = + if (playback.restored) { + ExitTransition.None + } else { + shrinkVertically(tween(MINI_PLAYER_ANIM_MS), shrinkTowards = Alignment.Bottom) + }, ) { val positionMs by playbackController.positionMs.collectAsStateWithLifecycle() val miniArt = playbackController.coverArt(playback.item, thumbnail = true) diff --git a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt index d596fbb7..bf886e3a 100644 --- a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt @@ -160,6 +160,20 @@ class PlaybackControllerTest { assertEquals(listOf("play"), handle.operations) } + @Test + fun restoringMarksTheStateUntilPlaybackStarts() { + seedAlbum(5, sourceId = 1) + runBlocking { queues.replace(listOf(queues.albumEntry(1, "al1"))) } + + controller.connect() + await { handle.items.isNotEmpty() } + + assertTrue(controller.state.value.restored) + + controller.playAlbum(1, "al1", 0) + await { !controller.state.value.restored } + } + @Test fun nextAdvancesTheCursor() { seedAlbum(3, sourceId = 1) @@ -1067,7 +1081,9 @@ class PlaybackControllerTest { assertEquals(33_000L, handle.positionMs) assertFalse(handle.playWhenReady) - assertTrue(handle.operations.drop(before).none { it.startsWith("prepare") || it.startsWith("play") }) + val reload = handle.operations.drop(before) + assertTrue("a reload must re-prepare, or the next play pays for it", reload.any { it == "prepare" }) + assertTrue(reload.none { it == "play" }) } @Test