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.
This commit is contained in:
austinried
2026-10-04 00:30:00 +00:00
parent 3bcf87b5b4
commit f7a5502240
3 changed files with 35 additions and 12 deletions
@@ -52,6 +52,7 @@ data class PlaybackState(
val hasPrevious: Boolean = false, val hasPrevious: Boolean = false,
val shuffle: Boolean = false, val shuffle: Boolean = false,
val repeat: RepeatMode = RepeatMode.Off, val repeat: RepeatMode = RepeatMode.Off,
val restored: Boolean = false,
) )
private sealed interface QueueUndo { private sealed interface QueueUndo {
@@ -127,6 +128,7 @@ class PlaybackController(
private var repeatMode = RepeatMode.Off private var repeatMode = RepeatMode.Off
private var endedHandled = false private var endedHandled = false
private var lastSavedPositionMs = 0L private var lastSavedPositionMs = 0L
private var restored = false
init { init {
scope.launch { scope.launch {
@@ -226,6 +228,7 @@ class PlaybackController(
windowEnd = -1 windowEnd = -1
lastEdit = null lastEdit = null
endedHandled = false endedHandled = false
restored = false
val previous = snapshot val previous = snapshot
val previousEntry = previous?.entries?.firstOrNull()?.entry val previousEntry = previous?.entries?.firstOrNull()?.entry
val same = previousEntry != null && previousEntry.kind == entry.kind && previousEntry.refId == entry.refId val same = previousEntry != null && previousEntry.kind == entry.kind && previousEntry.refId == entry.refId
@@ -601,6 +604,7 @@ class PlaybackController(
windowEnd = -1 windowEnd = -1
lastEdit = null lastEdit = null
endedHandled = false endedHandled = false
restored = false
val modes = queueRepository.modes() val modes = queueRepository.modes()
shuffleEnabled = !disableShuffle && modes.shuffle shuffleEnabled = !disableShuffle && modes.shuffle
repeatMode = modes.repeat.toRepeatMode() repeatMode = modes.repeat.toRepeatMode()
@@ -661,8 +665,8 @@ class PlaybackController(
this.queueSourceId = queueSourceId this.queueSourceId = queueSourceId
val position = queueRepository.cursor().coerceIn(0, snapshot.size - 1) val position = queueRepository.cursor().coerceIn(0, snapshot.size - 1)
lastSavedPositionMs = queueRepository.cursorPositionMs().coerceAtLeast(0) lastSavedPositionMs = queueRepository.cursorPositionMs().coerceAtLeast(0)
restored = true
loadWindow(position, autoplay = false, startPositionMs = lastSavedPositionMs) loadWindow(position, autoplay = false, startPositionMs = lastSavedPositionMs)
player?.prepare()
} }
private suspend fun stop() = startLock.withLock { stopLocked() } private suspend fun stop() = startLock.withLock { stopLocked() }
@@ -677,6 +681,7 @@ class PlaybackController(
windowStart = 0 windowStart = 0
windowEnd = -1 windowEnd = -1
lastEdit = null lastEdit = null
restored = false
player?.run { player?.run {
stop() stop()
clear() clear()
@@ -701,10 +706,8 @@ class PlaybackController(
updating = true updating = true
player.setWindow(window.map { it.item.toQueueItem() }, startIndex, startPositionMs) player.setWindow(window.map { it.item.toQueueItem() }, startIndex, startPositionMs)
updating = false updating = false
if (autoplay) {
player.prepare() player.prepare()
player.play() if (autoplay) player.play()
}
refresh(position) refresh(position)
} }
@@ -940,6 +943,7 @@ class PlaybackController(
hasPrevious = true, hasPrevious = true,
shuffle = shuffleEnabled, shuffle = shuffleEnabled,
repeat = repeatMode, repeat = repeatMode,
restored = restored,
) )
if (player.isPlaying) startPositionTicker() else stopPositionTicker() if (player.isPlaying) startPositionTicker() else stopPositionTicker()
} }
@@ -5,6 +5,7 @@ import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.animate import androidx.compose.animation.core.animate
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.snap import androidx.compose.animation.core.snap
@@ -233,12 +234,9 @@ private fun MainNavigation() {
val navBarInset = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() } val navBarInset = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() }
val keyboardUp = WindowInsets.imeAnimationTarget.getBottom(density) > 0 val keyboardUp = WindowInsets.imeAnimationTarget.getBottom(density) > 0
val showMiniPlayer = playerVisible && !keyboardUp val showMiniPlayer = playerVisible && !keyboardUp
var miniPlayerRevealed by remember { mutableStateOf(false) }
val firstReveal = !miniPlayerRevealed && showMiniPlayer
LaunchedEffect(showMiniPlayer) { if (showMiniPlayer) miniPlayerRevealed = true }
val bottomInset by animateDpAsState( val bottomInset by animateDpAsState(
targetValue = if (showMiniPlayer) 0.dp else navBarInset, 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", label = "libraryBottomInset",
) )
@@ -382,12 +380,17 @@ private fun MainNavigation() {
AnimatedVisibility( AnimatedVisibility(
visible = showMiniPlayer, visible = showMiniPlayer,
enter = enter =
if (firstReveal) { if (playback.restored) {
EnterTransition.None EnterTransition.None
} else { } else {
expandVertically(tween(MINI_PLAYER_ANIM_MS), expandFrom = Alignment.Bottom) 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 positionMs by playbackController.positionMs.collectAsStateWithLifecycle()
val miniArt = playbackController.coverArt(playback.item, thumbnail = true) val miniArt = playbackController.coverArt(playback.item, thumbnail = true)
@@ -160,6 +160,20 @@ class PlaybackControllerTest {
assertEquals(listOf("play"), handle.operations) 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 @Test
fun nextAdvancesTheCursor() { fun nextAdvancesTheCursor() {
seedAlbum(3, sourceId = 1) seedAlbum(3, sourceId = 1)
@@ -1067,7 +1081,9 @@ class PlaybackControllerTest {
assertEquals(33_000L, handle.positionMs) assertEquals(33_000L, handle.positionMs)
assertFalse(handle.playWhenReady) 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 @Test