fix(player): resume replay, TV surface recovery, and finite time guards

ExoPlayer:
   - Pass the resume position into setMediaSource() instead of prepare()-ing
     from 0 and then seekTo()-ing, which replayed the first few seconds before
     jumping to the resume point.
   - Drop the redundant initial seek that mirrors the resume position (fired by
     the JS direct-player layer once tracks are ready). ExoPlayer re-buffers on
     every seek — even a no-op to the current position — so it stuttered startup.

   MPV (Android TV):
   - Recover the video pipeline when returning from the screensaver / app
     background while paused. TV uses zero-copy hwdec=mediacodec, which binds
     MediaCodec directly to the display surface; when the screensaver invalidates
     that surface the decoder is left bound to dead buffers and mpv disables the
     video track. Register TV-only activity-lifecycle callbacks and reload at the
     cached position on resume to recreate the decoder against the live surface
     (Android counterpart to iOS's performDecoderReset()).

   Video time controls:
   - Initialize remainingTime to 0 instead of Infinity and guard non-finite
     values, so the first paint shows "0:00" (and "—" for the end time) rather
     than "Infinityh NaNm NaNs" and an Invalid Date before the first progress
     update

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
This commit is contained in:
Lance Chant
2026-07-14 10:55:10 +02:00
parent c13da89307
commit 0fea901133
6 changed files with 228 additions and 21 deletions

View File

@@ -82,6 +82,13 @@ class ExoPlayerView(context: Context, appContext: AppContext) : ExpoView(context
private var pendingConfig: VideoLoadConfig? = null
private var tracksReadyFired: Boolean = false
// Resume position handed to setMediaSource() on load. seekTo() uses it to
// drop the redundant re-seek the JS layer fires once tracks are ready
// (direct-player seeks to the same resume position). ExoPlayer re-buffers
// on every seek — even a no-op to the current position — so without this
// the start stutters.
private var loadStartPositionMs: Long = 0L
// Side-loaded subtitle configurations accumulated across loadVideo and
// addSubtitleFile. Media3 doesn't expose the live SubtitleConfiguration
// list on a playing MediaItem, so we shadow it here to preserve prior
@@ -354,15 +361,20 @@ class ExoPlayerView(context: Context, appContext: AppContext) : ExpoView(context
val mediaSource = DefaultMediaSourceFactory(dataSourceFactory)
.createMediaSource(mediaItem)
p.setMediaSource(mediaSource)
p.prepare()
// Resolve the resume position once and hand it to setMediaSource() so
// ExoPlayer prepares from that position directly. Calling prepare()
// first (which buffers from 0) and seekTo() afterwards makes the
// opening seconds replay from the start before jumping to the resume
// point — the "repeats the first few seconds" symptom. C.TIME_UNSET
// falls back to the media's default start position (0).
val startMs = config.startPosition?.let { sp ->
if (sp > 0) (sp * 1000).toLong() else C.TIME_UNSET
} ?: C.TIME_UNSET
// Apply initial playback position
config.startPosition?.let { startPosSec ->
if (startPosSec > 0) {
p.seekTo((startPosSec * 1000).toLong())
}
}
loadStartPositionMs = if (startMs != C.TIME_UNSET) startMs else 0L
p.setMediaSource(mediaSource, startMs)
p.prepare()
if (config.autoplay) {
p.play()
@@ -427,6 +439,7 @@ class ExoPlayerView(context: Context, appContext: AppContext) : ExpoView(context
playerView.player = null
tracksReadyFired = false
currentUrl = null
loadStartPositionMs = 0L
sideLoadedSubs = emptyList()
subtitleTrackList = emptyList()
audioTrackList = emptyList()
@@ -438,7 +451,17 @@ class ExoPlayerView(context: Context, appContext: AppContext) : ExpoView(context
}
fun seekTo(positionSec: Double) {
player?.seekTo((positionSec * 1000).toLong())
val targetMs = (positionSec * 1000).toLong()
// Drop the redundant initial seek that mirrors the resume position we
// already applied via setMediaSource() — see loadInternal. Only the
// first seek after load is eligible, so a genuine seek to ~the start
// later on still works.
if (loadStartPositionMs > 0 && Math.abs(targetMs - loadStartPositionMs) < 1000L) {
loadStartPositionMs = 0L
return
}
loadStartPositionMs = 0L
player?.seekTo(targetMs)
}
fun seekBy(offsetSec: Double) {

View File

@@ -142,6 +142,13 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
*/
private var voDriver: String = "gpu-next"
/**
* True on Android TV form-factor devices. Drives both the hwdec selection
* in [start] and the TV-only resume-recovery gating in [MpvPlayerView].
* Computed once from the system UI mode.
*/
val isTv: Boolean = isTvDevice()
fun start(voDriver: String = "gpu-next") {
if (isRunning) return
@@ -377,18 +384,66 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
}
/**
* Force mpv to render a frame to the current surface.
* Steps forward one frame then seeks back to the original position.
* Used after PiP entry to work around mpv stopping pixel output.
* Restore video after a system-initiated surface loss (Android TV
* screensaver / app background while paused). Triggered from the host
* activity's onResume via [MpvPlayerView.runResumeRecovery].
*
* On TV, `hwdec=mediacodec` (zero-copy) binds MediaCodec directly to the
* display surface. When the screensaver invalidates that surface, the
* decoder is left bound to dead buffers and mpv auto-disables the video
* track (vid=no). Re-attaching the surface + cycling hwdec + re-selecting
* vid + seeking rebuilds the pipeline but leaves the video chain at EOF
* ("video=eof" in playback-restart) — the recreated MediaCodec produces no
* frames. Only a fresh `loadfile` deterministically recreates the decoder
* against the live surface, so we reload at the cached position.
*
* This is the Android counterpart to iOS's `performDecoderReset()`, which
* can get away with a `hwdec` cycle because VideoToolbox reinitializes
* cleanly; zero-copy MediaCodec bound to a lost surface does not.
*/
fun forceRedraw() {
if (!isRunning) return
val pos = cachedPosition
Log.i(TAG, "[PiP] forceRedraw — stepping frame then seeking to $pos")
mpv?.command(arrayOf("frame-step"))
if (pos > 0) {
mpv?.command(arrayOf("seek", pos.toString(), "absolute"))
fun recoverVideoOutput(surface: Surface?) {
if (!isRunning) {
Log.w(TAG, "[Recover] recoverVideoOutput — renderer not running, skipping")
return
}
val url = currentUrl ?: run {
Log.w(TAG, "[Recover] recoverVideoOutput — no URL loaded, skipping")
return
}
Log.i(
TAG,
"[Recover] reload recovery — pos=$cachedPosition, aid=${getCurrentAudioTrack()}, sid=${getCurrentSubtitleTrack()}"
)
// Re-attach the surface first (covers the case where the surface
// survived and surfaceCreated never fired). The reload below fully
// rebuilds the pipeline anyway, but this keeps the VO target current
// while the load is in flight.
surface?.takeIf { it.isValid }?.let { mpv?.attachSurface(it) }
// Preserve the user's current audio/subtitle selection across the
// reload — pass them INTO load() (not via the field: load() overwrites
// the initial IDs from its parameters). They're re-applied when
// FILE_LOADED fires. (0 / "no" means "off", which setSubtitleTrack /
// setAudioTrack handle correctly.)
val savedAid = getCurrentAudioTrack()
val savedSid = getCurrentSubtitleTrack()
// Full reload at the paused position. With keep-open=always +
// cache-pause-initial=yes, mpv seeks to the position and holds on the
// first decoded frame, so the paused frame reappears.
load(
url = url,
headers = currentHeaders,
startPosition = cachedPosition,
initialAudioId = savedAid,
initialSubtitleId = savedSid
)
// Hold the paused state explicitly — we only get here while paused,
// and load() doesn't touch the pause property.
pause()
}
fun load(

View File

@@ -1,7 +1,11 @@
package expo.modules.mpvplayer
import android.app.Activity
import android.app.Application
import android.content.Context
import android.content.ContextWrapper
import android.graphics.Color
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
@@ -51,6 +55,12 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
companion object {
private const val TAG = "MpvPlayerView"
// Grace window after onActivityResumed before running the resume
// recovery, so surfaceCreated (surface-destroyed case) has fired and
// the holder has a valid surface. If the surface survived the
// screensaver and surfaceCreated never fires, this still runs.
private const val RESUME_RECOVERY_DELAY_MS = 300L
}
// Event dispatchers
@@ -77,6 +87,15 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
// PiP state tracking
private val pipHandler = Handler(Looper.getMainLooper())
// Resume-recovery state: recreate the decoder when returning from the
// screensaver / app background while paused. See
// MPVLayerRenderer.recoverVideoOutput for why zero-copy hwdec=mediacodec
// needs this.
private var hostActivity: Activity? = null
private var lifecycleCallbacks: Application.ActivityLifecycleCallbacks? = null
private var lifecycleRegistered = false
private val recoverResumeRunnable = Runnable { runResumeRecovery() }
init {
setBackgroundColor(Color.BLACK)
@@ -145,6 +164,10 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
// Renderer is created lazily in loadVideo once we have the voDriver setting
renderer = MPVLayerRenderer(context)
renderer?.delegate = this
// Watch the host activity's lifecycle to recover the video pipeline
// when returning from the screensaver while paused.
registerLifecycleCallbacks()
}
/**
@@ -527,6 +550,107 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
onError(mapOf("error" to message))
}
// MARK: - Resume Recovery
/**
* Recreate the decoder when returning from the Android TV screensaver (or
* app background) while paused. Triggered from the host activity's
* onResume; the work is in [MPVLayerRenderer.recoverVideoOutput]. The
* playing case is skipped — the render thread re-primes the VO on surface
* reattach by itself — as is PiP (it owns its surface lifecycle).
*/
private fun runResumeRecovery() {
if (!rendererStarted) return
if (pipController?.isPictureInPictureActive() == true) return
if (intendedPlayState) return // playing self-heals
val surface = surfaceView.holder.surface?.takeIf { it.isValid }
Log.i(TAG, "[Recover] onResume recovery — paused, surfaceValid=${surface != null}")
renderer?.recoverVideoOutput(surface)
}
private fun registerLifecycleCallbacks() {
if (lifecycleRegistered) return
// Resume-recovery is TV-only. Only TV's zero-copy hwdec=mediacodec
// binds MediaCodec directly to the display surface, so only TV needs
// decoder recreation after a system-initiated surface loss (screensaver
// / app background while paused). Phones (mediacodec-copy) and the
// emulator self-heal via the surfaceCreated → attachSurface path, so we
// don't even register there — no callback overhead, no spurious resets.
if (renderer?.isTv != true) {
Log.i(TAG, "[Recover] skipping lifecycle registration (isTv=${renderer?.isTv})")
return
}
Log.i(TAG, "[Recover] registering lifecycle recovery (TV)")
val app = context.applicationContext as? Application ?: run {
Log.w(TAG, "Cannot register lifecycle callbacks: no Application")
return
}
lifecycleCallbacks = object : Application.ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
override fun onActivityStarted(activity: Activity) {}
override fun onActivityResumed(activity: Activity) {
val host = hostActivity ?: findActivity().also { hostActivity = it }
if (activity !== host) {
if (host == null) {
Log.i(TAG, "[Recover] onActivityResumed — host unresolved, activity=${activity.javaClass.simpleName}; skipping")
}
return
}
Log.i(
TAG,
"[Recover] onActivityResumed — host resumed, hasMedia=${currentUrl != null}, pip=${pipController?.isPictureInPictureActive()}, paused=${!intendedPlayState}"
)
// Only recover when there's loaded media, we're paused, and not
// in PiP. Playing self-heals; nothing loaded yet = nothing to
// recover.
if (currentUrl == null) return
if (pipController?.isPictureInPictureActive() == true) return
if (intendedPlayState) return
// Post past the resume/surfaceCreated race so the holder has a
// valid surface, then recreate the decoder against it.
pipHandler.removeCallbacks(recoverResumeRunnable)
pipHandler.postDelayed(recoverResumeRunnable, RESUME_RECOVERY_DELAY_MS)
}
override fun onActivityPaused(activity: Activity) {
if (activity === hostActivity) {
Log.i(TAG, "[Recover] onActivityPaused — host paused")
}
}
override fun onActivityStopped(activity: Activity) {
if (activity === hostActivity) {
Log.i(TAG, "[Recover] onActivityStopped — host stopped")
}
}
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
override fun onActivityDestroyed(activity: Activity) {}
}
app.registerActivityLifecycleCallbacks(lifecycleCallbacks)
lifecycleRegistered = true
}
private fun unregisterLifecycleCallbacks() {
pipHandler.removeCallbacks(recoverResumeRunnable)
if (!lifecycleRegistered) return
val app = context.applicationContext as? Application
lifecycleCallbacks?.let { app?.unregisterActivityLifecycleCallbacks(it) }
lifecycleCallbacks = null
lifecycleRegistered = false
}
private fun findActivity(): Activity? {
// Prefer Expo's currentActivity. The view's Context is a ReactContext
// whose base is the Application, not the Activity, so walking the
// context chain does not reliably reach the Activity. Mirrors
// PiPController.getActivity().
appContext.currentActivity?.let { return it }
var ctx: Context = context
while (ctx is ContextWrapper) {
if (ctx is Activity) return ctx
ctx = ctx.baseContext
}
return null
}
// MARK: - Cleanup
/**
@@ -538,6 +662,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
*/
fun cleanup() {
pipHandler.removeCallbacksAndMessages(null)
unregisterLifecycleCallbacks()
pipController?.stopPictureInPicture()
renderer?.stop()
renderer?.delegate = null