player: keep the thumbnail until the first frame, not just STATE_READY

ExoPlayer reports STATE_READY a beat before it actually draws the first frame,
so the buffering thumbnail was removed into a black gap right before playback
("thumbnail, then black, then playing"). Hold the thumbnail overlay until
onRenderedFirstFrame fires, so it hands straight over to the picture.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jeena 2026-10-15 13:46:26 +00:00
parent fcb3be8e96
commit c3b5908f31

View file

@ -323,6 +323,10 @@ private fun PlayerSurface(
// 200 ms: quick flaps cancel the pending change and the overlay stays // 200 ms: quick flaps cancel the pending change and the overlay stays
// composed, so the animation never restarts mid-playback. // composed, so the animation never restarts mid-playback.
var bufferingPending by remember { mutableStateOf(true) } var bufferingPending by remember { mutableStateOf(true) }
// STATE_READY can fire before the first frame is actually drawn, leaving a black
// gap between the thumbnail and the picture. Keep the thumbnail until the first
// frame really renders (user report: "thumbnail, then black, then playing").
var firstFrame by remember { mutableStateOf(false) }
LaunchedEffect(bufferingPending) { LaunchedEffect(bufferingPending) {
kotlinx.coroutines.delay(200) kotlinx.coroutines.delay(200)
buffering = bufferingPending buffering = bufferingPending
@ -436,6 +440,10 @@ private fun PlayerSurface(
override fun onPlaybackStateChanged(playbackState: Int) { override fun onPlaybackStateChanged(playbackState: Int) {
bufferingPending = playbackState != Player.STATE_READY bufferingPending = playbackState != Player.STATE_READY
} }
override fun onRenderedFirstFrame() {
firstFrame = true
}
}) })
onDispose { onDispose {
viewModel.persistExit(shortUuid, (player.currentPosition / 1000).toInt()) viewModel.persistExit(shortUuid, (player.currentPosition / 1000).toInt())
@ -588,7 +596,7 @@ private fun PlayerSurface(
// ready to render — metadata resolution alone (the Loading state) is not // ready to render — metadata resolution alone (the Loading state) is not
// enough (issue #8). Fullscreen loading shows the video's own thumbnail // enough (issue #8). Fullscreen loading shows the video's own thumbnail
// dimmed under the circle loader (issue #19) instead of plain black. // dimmed under the circle loader (issue #19) instead of plain black.
if (buffering) { if (buffering || !firstFrame) {
Box( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()