From ffbbf46b4337b69be64e8d1d1ac309964bad6282 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Sat, 26 Sep 2026 19:58:52 +0000 Subject: [PATCH] gate the library on playback restore so the bottom bar lands in place The tab bar was laid out before playback was known, so the restored mini player arrived later and re-laid the whole bottom area: a couple of frames of bump, and the mini player "loading in" after the library. SubtracksRoot now waits for PlaybackController to finish connecting and restoring (a `ready` flag, set after restore or on disconnect) before it composes the library, so the first library frame already includes the mini player and nothing moves. With playback resolved up front, the `restored` flag and its special enter/inset specs are pointless, so they are reverted; playback the user starts animates as before. --- .../subtracks/playback/PlaybackController.kt | 12 +++--- .../main/kotlin/com/subtracks/ui/AppRoot.kt | 37 +++++++++---------- .../playback/PlaybackControllerTest.kt | 12 +++--- 3 files changed, 28 insertions(+), 33 deletions(-) diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt index 067062fc..d1251537 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt @@ -52,7 +52,6 @@ data class PlaybackState( val hasPrevious: Boolean = false, val shuffle: Boolean = false, val repeat: RepeatMode = RepeatMode.Off, - val restored: Boolean = false, ) private sealed interface QueueUndo { @@ -128,7 +127,6 @@ class PlaybackController( private var repeatMode = RepeatMode.Off private var endedHandled = false private var lastSavedPositionMs = 0L - private var restored = false init { scope.launch { @@ -152,6 +150,9 @@ class PlaybackController( scope.cancel() } + private val _ready = MutableStateFlow(false) + val ready: StateFlow = _ready + fun connect() { if (player != null || connecting) return connecting = true @@ -170,12 +171,14 @@ class PlaybackController( shuffle != null -> startShuffled(shuffle) else -> restore() } + _ready.value = true } }, onDisconnected = { player = null connecting = false scope.launch { stop() } + _ready.value = true }, ) } @@ -228,7 +231,6 @@ 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 @@ -604,7 +606,6 @@ class PlaybackController( windowEnd = -1 lastEdit = null endedHandled = false - restored = false val modes = queueRepository.modes() shuffleEnabled = !disableShuffle && modes.shuffle repeatMode = modes.repeat.toRepeatMode() @@ -665,7 +666,6 @@ 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) } @@ -681,7 +681,6 @@ class PlaybackController( windowStart = 0 windowEnd = -1 lastEdit = null - restored = false player?.run { stop() clear() @@ -943,7 +942,6 @@ 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 6ce91a2a..dcd7cf6c 100644 --- a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt +++ b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt @@ -4,11 +4,8 @@ import android.net.Uri 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 import androidx.compose.animation.core.tween import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn @@ -143,9 +140,20 @@ internal fun focusPops( @Composable fun SubtracksRoot(root: RootViewModel = koinViewModel()) { when (val hasSource = root.hasSource.collectAsStateWithLifecycle().value) { - null -> LoadingState() - false -> AddSourceRoute(onSaved = {}, onBack = null) - true -> MainNavigation() + null -> { + LoadingState() + } + + false -> { + AddSourceRoute(onSaved = {}, onBack = null) + } + + true -> { + val playbackController = koinInject() + LaunchedEffect(Unit) { playbackController.connect() } + val playbackReady by playbackController.ready.collectAsStateWithLifecycle() + if (playbackReady) MainNavigation() else LoadingState() + } } } @@ -219,7 +227,6 @@ private fun MainNavigation() { else -> repeat(pops) { navController.popBackStack() } } } - LaunchedEffect(Unit) { playbackController.connect() } LaunchedEffect(Unit) { if (nowPlayingOpen) nowPlayingProgress = 1f } val playerVisible = playback.item != null @@ -236,7 +243,7 @@ private fun MainNavigation() { val showMiniPlayer = playerVisible && !keyboardUp val bottomInset by animateDpAsState( targetValue = if (showMiniPlayer) 0.dp else navBarInset, - animationSpec = if (playback.restored) snap() else tween(MINI_PLAYER_ANIM_MS), + animationSpec = tween(MINI_PLAYER_ANIM_MS), label = "libraryBottomInset", ) @@ -379,18 +386,8 @@ private fun MainNavigation() { AnimatedVisibility( visible = showMiniPlayer, - enter = - if (playback.restored) { - EnterTransition.None - } else { - expandVertically(tween(MINI_PLAYER_ANIM_MS), expandFrom = Alignment.Bottom) - }, - exit = - if (playback.restored) { - ExitTransition.None - } else { - shrinkVertically(tween(MINI_PLAYER_ANIM_MS), shrinkTowards = Alignment.Bottom) - }, + enter = expandVertically(tween(MINI_PLAYER_ANIM_MS), expandFrom = Alignment.Bottom), + exit = 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 bf886e3a..43d945e5 100644 --- a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt @@ -28,6 +28,7 @@ import okhttp3.OkHttpClient import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -161,17 +162,16 @@ class PlaybackControllerTest { } @Test - fun restoringMarksTheStateUntilPlaybackStarts() { + fun theReadyStateResolvesWithTheRestoredItemAlreadyInPlace() { seedAlbum(5, sourceId = 1) runBlocking { queues.replace(listOf(queues.albumEntry(1, "al1"))) } + assertFalse(controller.ready.value) + controller.connect() - await { handle.items.isNotEmpty() } + await { controller.ready.value } - assertTrue(controller.state.value.restored) - - controller.playAlbum(1, "al1", 0) - await { !controller.state.value.restored } + assertNotNull("the mini player must be drawable once ready", controller.state.value.item) } @Test