mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +01:00
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:
@@ -52,7 +52,6 @@ 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 {
|
||||||
@@ -128,7 +127,6 @@ 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 {
|
||||||
@@ -152,6 +150,9 @@ class PlaybackController(
|
|||||||
scope.cancel()
|
scope.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val _ready = MutableStateFlow(false)
|
||||||
|
val ready: StateFlow<Boolean> = _ready
|
||||||
|
|
||||||
fun connect() {
|
fun connect() {
|
||||||
if (player != null || connecting) return
|
if (player != null || connecting) return
|
||||||
connecting = true
|
connecting = true
|
||||||
@@ -170,12 +171,14 @@ class PlaybackController(
|
|||||||
shuffle != null -> startShuffled(shuffle)
|
shuffle != null -> startShuffled(shuffle)
|
||||||
else -> restore()
|
else -> restore()
|
||||||
}
|
}
|
||||||
|
_ready.value = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDisconnected = {
|
onDisconnected = {
|
||||||
player = null
|
player = null
|
||||||
connecting = false
|
connecting = false
|
||||||
scope.launch { stop() }
|
scope.launch { stop() }
|
||||||
|
_ready.value = true
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -228,7 +231,6 @@ 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
|
||||||
@@ -604,7 +606,6 @@ 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()
|
||||||
@@ -665,7 +666,6 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -681,7 +681,6 @@ class PlaybackController(
|
|||||||
windowStart = 0
|
windowStart = 0
|
||||||
windowEnd = -1
|
windowEnd = -1
|
||||||
lastEdit = null
|
lastEdit = null
|
||||||
restored = false
|
|
||||||
player?.run {
|
player?.run {
|
||||||
stop()
|
stop()
|
||||||
clear()
|
clear()
|
||||||
@@ -943,7 +942,6 @@ 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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,8 @@ import android.net.Uri
|
|||||||
import androidx.activity.OnBackPressedCallback
|
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.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.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.expandVertically
|
import androidx.compose.animation.expandVertically
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
@@ -143,9 +140,20 @@ internal fun focusPops(
|
|||||||
@Composable
|
@Composable
|
||||||
fun SubtracksRoot(root: RootViewModel = koinViewModel()) {
|
fun SubtracksRoot(root: RootViewModel = koinViewModel()) {
|
||||||
when (val hasSource = root.hasSource.collectAsStateWithLifecycle().value) {
|
when (val hasSource = root.hasSource.collectAsStateWithLifecycle().value) {
|
||||||
null -> LoadingState()
|
null -> {
|
||||||
false -> AddSourceRoute(onSaved = {}, onBack = null)
|
LoadingState()
|
||||||
true -> MainNavigation()
|
}
|
||||||
|
|
||||||
|
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() }
|
else -> repeat(pops) { navController.popBackStack() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LaunchedEffect(Unit) { playbackController.connect() }
|
|
||||||
LaunchedEffect(Unit) { if (nowPlayingOpen) nowPlayingProgress = 1f }
|
LaunchedEffect(Unit) { if (nowPlayingOpen) nowPlayingProgress = 1f }
|
||||||
|
|
||||||
val playerVisible = playback.item != null
|
val playerVisible = playback.item != null
|
||||||
@@ -236,7 +243,7 @@ private fun MainNavigation() {
|
|||||||
val showMiniPlayer = playerVisible && !keyboardUp
|
val showMiniPlayer = playerVisible && !keyboardUp
|
||||||
val bottomInset by animateDpAsState(
|
val bottomInset by animateDpAsState(
|
||||||
targetValue = if (showMiniPlayer) 0.dp else navBarInset,
|
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",
|
label = "libraryBottomInset",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -379,18 +386,8 @@ private fun MainNavigation() {
|
|||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = showMiniPlayer,
|
visible = showMiniPlayer,
|
||||||
enter =
|
enter = expandVertically(tween(MINI_PLAYER_ANIM_MS), expandFrom = Alignment.Bottom),
|
||||||
if (playback.restored) {
|
exit = shrinkVertically(tween(MINI_PLAYER_ANIM_MS), shrinkTowards = Alignment.Bottom),
|
||||||
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)
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
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)
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import okhttp3.OkHttpClient
|
|||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -161,17 +162,16 @@ class PlaybackControllerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun restoringMarksTheStateUntilPlaybackStarts() {
|
fun theReadyStateResolvesWithTheRestoredItemAlreadyInPlace() {
|
||||||
seedAlbum(5, sourceId = 1)
|
seedAlbum(5, sourceId = 1)
|
||||||
runBlocking { queues.replace(listOf(queues.albumEntry(1, "al1"))) }
|
runBlocking { queues.replace(listOf(queues.albumEntry(1, "al1"))) }
|
||||||
|
|
||||||
|
assertFalse(controller.ready.value)
|
||||||
|
|
||||||
controller.connect()
|
controller.connect()
|
||||||
await { handle.items.isNotEmpty() }
|
await { controller.ready.value }
|
||||||
|
|
||||||
assertTrue(controller.state.value.restored)
|
assertNotNull("the mini player must be drawable once ready", controller.state.value.item)
|
||||||
|
|
||||||
controller.playAlbum(1, "al1", 0)
|
|
||||||
await { !controller.state.value.restored }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user