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.
This commit is contained in:
austinried
2026-10-04 00:30:00 +00:00
parent f7a5502240
commit ffbbf46b43
3 changed files with 28 additions and 33 deletions
@@ -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<Boolean> = _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()
}
+17 -20
View File
@@ -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<PlaybackController>()
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)
@@ -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