androidx / androidx/media

Video Playback Stuck on Initial Frames

Open
#2,230 0 comments 0 reactions 1 assignee View on GitHub

@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 bugreport to android-media-github@google.com after filing this issue.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.