Video Playback Stuck on Initial Frames
Open
@rohitjoins is already working on this.
Since Mar 17, 2025.
bug
needs triage
- Dominant language
- Java
- Stars
- 3k
- Forks
- 955
- Avg merge
- 12d 14h
- Merged PRs (30d)
- 2
Description
Version
Media3 1.5.1
More version details
No response
Devices that reproduce the issue
Xiaomi 2310FPCA4G Android 15 (35)
Devices that do not reproduce the issue
All the other ones, as far as we know. We’ve tested the app on multiple Samsung devices, as well as the Pixel 8 and 9, and haven’t encountered any issues.
Reproducible in the demo app?
No
Reproduction steps
The user provided a video showing the issue, but we couldn’t reproduce it on our devices.
Expected result
The video should play smoothly from start to finish and loop indefinitely.
Actual result
The video gets stuck in the initial frames, moving slightly back and forth in terms of frames.
Media
https://github.com/user-attachments/assets/cc34e9f3-28e0-4e28-a420-a66bdc2bb9cb
code:
<?xml version="1.0" encoding="utf-8"?>
<androidx.media3.ui.PlayerView android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
app:use_controller="false"
app:resize_mode="zoom"
xmlns:android="http://schemas.android.com/apk/res/android"
app:surface_type="texture_view"
android:id="@+id/playerView"/>
// usage inside composable
val playerState = rememberVideoPlayerState(
mediaType = MediaType.File(uiState.videoFile!!),
repeatMode = ExoPlayer.REPEAT_MODE_ALL,
onPlayerReady = { isReady ->
if (isReady) {
animate = true
}
},
onPlayerError = { error ->
viewModel.onPlayerError(error)
playerFailed = true
}
)
TextureVideoPlayer(
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
alpha = videoAlpha
},
videoPlayerManager = playerState
)
@OptIn(UnstableApi::class)
@Composable
fun TextureVideoPlayer(
modifier: Modifier = Modifier,
videoPlayerManager: VideoPlayerState,
) {
var lifecycle by remember {
mutableStateOf(Lifecycle.Event.ON_CREATE)
}
OnLifecycleEvent { _, event ->
lifecycle = event
}
AndroidView(
modifier = modifier,
factory = { context ->
val view = LayoutInflater.from(context).inflate(R.layout.player_view, null, false)
val textView = view.findViewById<PlayerView>(R.id.playerView)
textView.player = videoPlayerManager.exoPlayer
textView
},
update = { view ->
when (lifecycle) {
Lifecycle.Event.ON_PAUSE -> {
view.onPause()
view.player?.pause()
}
Lifecycle.Event.ON_RESUME -> {
view.onResume()
}
else -> Unit
}
}
)
}
enum class PlaybackState {
IDLE,
BUFFERING,
READY,
PLAYING,
PAUSED,
ENDED,
ERROR
}
@Composable
fun rememberVideoPlayerState(
key: Any? = null,
mediaType: MediaType?,
repeatMode: Int = ExoPlayer.REPEAT_MODE_OFF,
autoPlay: Boolean = true,
initialVolume: Float = 1.0f,
updateProgressInterval: Long = 1000L,
onVideoEnded: () -> Unit = {},
onPlayerReady: (playWhenReady: Boolean) -> Unit = {},
onPlayerError: (error: PlaybackException) -> Unit = {},
onProgressUpdate: (position: Long, duration: Long) -> Unit = { _, _ -> }
): VideoPlayerState {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val videoPlayerState = rememberSaveable(
inputs = arrayOf(key),
saver = VideoPlayerState.saver(context, onVideoEnded, onPlayerReady, onPlayerError)
) {
VideoPlayerState(
context = context,
mediaType = mediaType,
repeatMode = repeatMode,
autoPlay = autoPlay,
initialVolume = initialVolume,
updateProgressInterval = updateProgressInterval,
onVideoEnded = onVideoEnded,
onPlayerReady = onPlayerReady,
onPlayerError = onPlayerError,
onProgressUpdate = onProgressUpdate
)
}
// Lifecycle handling
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_PAUSE -> videoPlayerState.pause()
Lifecycle.Event.ON_RESUME -> {
if (videoPlayerState.wasPlayingBeforePause) {
videoPlayerState.play()
}
}
Lifecycle.Event.ON_DESTROY -> videoPlayerState.release()
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
videoPlayerState.release()
}
}
return videoPlayerState
}
@Stable
class VideoPlayerState(
context: Context,
private val mediaType: MediaType?,
private val repeatMode: Int,
private val autoPlay: Boolean = true,
private val initialVolume: Float = 1.0f,
private val updateProgressInterval: Long = 1000L,
private val onVideoEnded: () -> Unit,
private val onPlayerReady: (playWhenReady: Boolean) -> Unit,
private val onPlayerError: (error: PlaybackException) -> Unit,
private val onProgressUpdate: (position: Long, duration: Long) -> Unit = { _, _ -> }
) {
// Internal ExoPlayer instance
private val _exoPlayer: ExoPlayer = ExoPlayer.Builder(context).build()
// Public getter for the ExoPlayer (read-only)
val exoPlayer: ExoPlayer
get() = _exoPlayer
// State tracking
var isPlayerReady by mutableStateOf(false)
private set
var wasPlayingBeforePause by mutableStateOf(false)
private set
var durationInMillis by mutableLongStateOf(0L)
private set
var currentPositionInMillis by mutableLongStateOf(0L)
private set
private val _playbackState = MutableStateFlow(PlaybackState.IDLE)
val playbackState: StateFlow<PlaybackState> = _playbackState.asStateFlow()
private val _error = MutableStateFlow<PlaybackException?>(null)
val error: StateFlow<PlaybackException?> = _error.asStateFlow()
private val listener = createListener()
private var currentVolume = initialVolume
private var durationSet = false
private var progressUpdateJob: Job? = null
private val coroutineScope = CoroutineScope(Dispatchers.Main)
init {
// Set initial volume
_exoPlayer.volume = initialVolume
// Initialize media based on type
when(mediaType) {
is MediaType.File -> setMediaFile(mediaType.file)
is MediaType.Raw -> setMediaRaw(mediaType.rawResourceId)
null -> { /* do nothing */ }
}
_exoPlayer.addListener(listener)
// Start progress tracking
startProgressTracking()
}
private fun init(mediaItem: MediaItem) {
_exoPlayer.setMediaItem(mediaItem)
_exoPlayer.prepare()
_exoPlayer.repeatMode = repeatMode
_exoPlayer.playWhenReady = autoPlay
}
private fun createListener(): Player.Listener {
return object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
when (playbackState) {
Player.STATE_IDLE -> {
isPlayerReady = false
_playbackState.value = PlaybackState.IDLE
}
Player.STATE_BUFFERING -> {
isPlayerReady = false
_playbackState.value = PlaybackState.BUFFERING
}
Player.STATE_READY -> {
if (!durationSet) {
durationInMillis = _exoPlayer.duration
durationSet = true
}
isPlayerReady = _exoPlayer.playWhenReady
_playbackState.value = if (_exoPlayer.playWhenReady) {
PlaybackState.PLAYING
} else {
PlaybackState.READY
}
onPlayerReady(_exoPlayer.playWhenReady)
}
Player.STATE_ENDED -> {
isPlayerReady = false
_playbackState.value = PlaybackState.ENDED
onVideoEnded()
}
}
}
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
if (isPlayerReady && _exoPlayer.playbackState == Player.STATE_READY) {
_playbackState.value = if (playWhenReady) {
PlaybackState.PLAYING
} else {
PlaybackState.PAUSED
}
}
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
if (isPlaying) {
_playbackState.value = PlaybackState.PLAYING
} else if (_exoPlayer.playbackState == Player.STATE_READY) {
_playbackState.value = PlaybackState.PAUSED
}
}
override fun onPlayerError(error: PlaybackException) {
_playbackState.value = PlaybackState.ERROR
_error.value = error
this@VideoPlayerState.onPlayerError(error)
}
override fun onIsLoadingChanged(isLoading: Boolean) {
if (isLoading) {
_playbackState.value = PlaybackState.BUFFERING
}
}
}
}
private fun setMediaFile(file: File) {
val correctedFile = file.takeIf { it.name.endsWith(".mp4") } ?: File(file.parent, "${file.name}.mp4")
val videoUri = Uri.fromFile(correctedFile)
val mediaItem = MediaItem.Builder()
.setUri(videoUri)
.setMimeType(MimeTypes.VIDEO_MP4)
.build()
init(mediaItem)
}
private fun setMediaRaw(@RawRes rawResourceId: Int) {
val videoUri = Uri
.Builder()
.scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
.path(rawResourceId.toString())
.build()
val mediaItem = MediaItem.fromUri(videoUri)
init(mediaItem)
}
private fun startProgressTracking() {
progressUpdateJob?.cancel()
progressUpdateJob = coroutineScope.launch {
while (isActive) {
if (_playbackState.value == PlaybackState.PLAYING) {
currentPositionInMillis = _exoPlayer.currentPosition
onProgressUpdate(currentPositionInMillis, durationInMillis)
}
delay(updateProgressInterval)
}
}
}
// Public playback control methods
fun play() {
_exoPlayer.play()
}
fun pause() {
wasPlayingBeforePause = _exoPlayer.isPlaying
_exoPlayer.pause()
}
fun togglePlayPause() {
if (_exoPlayer.isPlaying) {
pause()
} else {
play()
}
}
fun seekTo(positionMs: Long) {
_exoPlayer.seekTo(positionMs)
}
fun seekForward(offsetMs: Long = 10000) {
_exoPlayer.seekTo(_exoPlayer.currentPosition + offsetMs)
}
fun seekBackward(offsetMs: Long = 10000) {
_exoPlayer.seekTo((_exoPlayer.currentPosition - offsetMs).coerceAtLeast(0))
}
fun restart() {
_exoPlayer.seekTo(0)
_exoPlayer.play()
}
fun mute() {
currentVolume = _exoPlayer.volume
_exoPlayer.volume = 0f
}
fun unMute() {
_exoPlayer.volume = currentVolume
}
fun setVolume(volume: Float) {
val clampedVolume = volume.coerceIn(0f, 1f)
currentVolume = clampedVolume
if (_exoPlayer.volume > 0) { // Only update if not muted
_exoPlayer.volume = clampedVolume
}
}
fun release() {
progressUpdateJob?.cancel()
_exoPlayer.removeListener(listener)
_exoPlayer.release()
}
companion object {
fun saver(
context: Context,
onVideoEnded: () -> Unit,
onPlayerReady: (playWhenReady: Boolean) -> Unit,
onPlayerError: (error: PlaybackException) -> Unit
) = Saver<VideoPlayerState, Map<String, Any?>>(
save = { state ->
val mediaTypeData: Map<String, Any?> = when (val mt = state.mediaType) {
is MediaType.File -> mapOf(
"type" to "file",
"filePath" to mt.file.absolutePath
)
is MediaType.Raw -> mapOf(
"type" to "raw",
"rawResourceId" to mt.rawResourceId
)
null -> mapOf("type" to null)
}
mapOf(
"mediaType" to mediaTypeData,
"playbackPosition" to state._exoPlayer.currentPosition,
"isPlaying" to state._exoPlayer.isPlaying,
"repeatMode" to state._exoPlayer.repeatMode,
"volume" to state.currentVolume
)
},
restore = { map ->
val mediaTypeData = map["mediaType"] as Map<*, *>
// Handle null media type
val mediaType = when (mediaTypeData["type"]) {
"file" -> MediaType.File(File(mediaTypeData["filePath"] as String))
"raw" -> MediaType.Raw(mediaTypeData["rawResourceId"] as Int)
null -> null
else -> throw IllegalStateException("Unknown media type")
}
val playbackPosition = map["playbackPosition"] as Long
val isPlaying = map["isPlaying"] as Boolean
val repeatMode = map["repeatMode"] as Int
val volume = (map["volume"] as? Float) ?: 1.0f
val playerState = VideoPlayerState(
context = context,
mediaType = mediaType,
repeatMode = repeatMode,
autoPlay = isPlaying,
initialVolume = volume,
onVideoEnded = onVideoEnded,
onPlayerReady = onPlayerReady,
onPlayerError = onPlayerError
)
if (mediaType != null) {
playerState._exoPlayer.seekTo(playbackPosition)
playerState._exoPlayer.playWhenReady = isPlaying
}
playerState
}
)
}
}
Bug Report
- You will email the zip file produced by
adb bugreportto android-media-github@google.com after filing this issue.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.