Show loading indicator when video is buffering (#1441)

## Description
Shows a loading indicator and slightly dim the screen when playback is
buffering.

Showing the indicator is delayed in case the buffering doesn't take very
long.

Dev note: the changes to `MpvPlayer` need to be ported to
wholphin-extensions as well.

### Related issues
Closes #1411

### Testing
Emulator

## Screenshots

[buffering.webm](https://github.com/user-attachments/assets/dd848696-d6de-4f68-94e4-901bb5db1185)

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-05-24 10:11:59 -04:00 committed by GitHub
parent 903c45dfb8
commit 8177700e23
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 87 additions and 2 deletions

View file

@ -38,7 +38,9 @@ fun LoadingPage(
focusEnabled: Boolean = true, focusEnabled: Boolean = true,
) { ) {
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
if (focusEnabled) {
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
}
Box( Box(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
modifier = modifier =

View file

@ -1,14 +1,17 @@
package com.github.damontecres.wholphin.ui.playback package com.github.damontecres.wholphin.ui.playback
import android.view.Gravity import android.view.Gravity
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.annotation.Dimension import androidx.annotation.Dimension
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -72,6 +75,7 @@ import com.github.damontecres.wholphin.preferences.AssPlaybackMode
import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.skipBackOnResume import com.github.damontecres.wholphin.preferences.skipBackOnResume
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
@ -216,6 +220,18 @@ fun PlaybackPageContent(
} }
val presentationState = rememberPresentationState(player, false) val presentationState = rememberPresentationState(player, false)
val playbackState by rememberPlaybackState(player)
var showBuffering by remember { mutableStateOf(false) }
LaunchedEffect(playbackState) {
if (playbackState == PlaybackState.BUFFERING) {
// Delay before showing the loading indicator
// So if buffering is quick, the UI won't flash
delay(250)
showBuffering = true
} else {
showBuffering = false
}
}
val scaledModifier = val scaledModifier =
Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp) Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp)
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
@ -349,6 +365,21 @@ fun PlaybackPageContent(
) { ) {
LoadingPage(focusEnabled = false) LoadingPage(focusEnabled = false)
} }
} else {
AnimatedVisibility(
visible = showBuffering,
enter = fadeIn(tween(easing = LinearEasing)),
exit = fadeOut(tween(easing = LinearEasing)),
modifier = Modifier.matchParentSize(),
) {
LoadingPage(
focusEnabled = false,
modifier =
Modifier
.fillMaxSize()
.background(AppColors.TransparentBlack25),
)
}
} }
// If D-pad skipping, show the amount skipped in an animation // If D-pad skipping, show the amount skipped in an animation

View file

@ -0,0 +1,42 @@
package com.github.damontecres.wholphin.ui.playback
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.media3.common.Player
import androidx.media3.common.listenTo
/**
* Remembers the [Player]'s state as it changes. Useful for changing UI if the player is buffering.
*
* @see Player.State
* @see PlaybackState
*/
@Composable
fun rememberPlaybackState(player: Player): State<PlaybackState> {
val state = remember(player) { mutableStateOf(getPlaybackState(player.playbackState)) }
LaunchedEffect(player) {
player.listenTo(Player.EVENT_PLAYBACK_STATE_CHANGED) {
state.value = getPlaybackState(player.playbackState)
}
}
return state
}
private fun getPlaybackState(
@Player.State value: Int,
): PlaybackState = PlaybackState.entries.first { it.value == value }
/**
* Represents [Player.State] integers as an Enum
*/
enum class PlaybackState(
@param:Player.State val value: Int,
) {
IDLE(Player.STATE_IDLE),
BUFFERING(Player.STATE_BUFFERING),
READY(Player.STATE_READY),
ENDED(Player.STATE_ENDED),
}

View file

@ -620,6 +620,16 @@ class MpvPlayer(
} }
notifyListeners(EVENT_IS_PLAYING_CHANGED) { onIsPlayingChanged(!value) } notifyListeners(EVENT_IS_PLAYING_CHANGED) { onIsPlayingChanged(!value) }
} }
MPVProperty.PAUSED_FOR_CACHE -> {
Timber.v("paused-for-cache %s", value)
playbackState.update {
it.copy(
state = if (value) STATE_BUFFERING else it.state,
)
}
notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) { onPlaybackStateChanged(playbackState.load().state) }
}
} }
} }