diff --git a/components/video-player/controls/TimeDisplay.tsx b/components/video-player/controls/TimeDisplay.tsx index f85af6343..f4ff5b092 100644 --- a/components/video-player/controls/TimeDisplay.tsx +++ b/components/video-player/controls/TimeDisplay.tsx @@ -20,6 +20,7 @@ export const TimeDisplay: FC = ({ const { t } = useTranslation(); const getFinishTime = () => { + if (!Number.isFinite(remainingTime)) return "—"; const now = new Date(); // remainingTime is in ms const finishTime = new Date(now.getTime() + remainingTime); diff --git a/components/video-player/controls/hooks/useVideoTime.ts b/components/video-player/controls/hooks/useVideoTime.ts index ad6800813..bba4dcd91 100644 --- a/components/video-player/controls/hooks/useVideoTime.ts +++ b/components/video-player/controls/hooks/useVideoTime.ts @@ -17,7 +17,10 @@ interface UseVideoTimeProps { */ export function useVideoTime({ progress, max, isSeeking }: UseVideoTimeProps) { const [currentTime, setCurrentTime] = useState(0); - const [remainingTime, setRemainingTime] = useState(Number.POSITIVE_INFINITY); + // Start at 0 (not Infinity) so the controls' first paint — before the first + // progress/max update — shows "0:00" instead of formatting Infinity into + // "Infinityh NaNm NaNs" and an Invalid Date for "ends at". + const [remainingTime, setRemainingTime] = useState(0); const lastCurrentTimeRef = useRef(0); const lastRemainingTimeRef = useRef(0); diff --git a/modules/exoplayer-player/android/src/main/java/expo/modules/exoplayerplayer/ExoPlayerView.kt b/modules/exoplayer-player/android/src/main/java/expo/modules/exoplayerplayer/ExoPlayerView.kt index d5aff953d..be7a0c906 100644 --- a/modules/exoplayer-player/android/src/main/java/expo/modules/exoplayerplayer/ExoPlayerView.kt +++ b/modules/exoplayer-player/android/src/main/java/expo/modules/exoplayerplayer/ExoPlayerView.kt @@ -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) { diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt index 6b41a621a..9a5ad6385 100644 --- a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt @@ -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( diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt index df74be18e..87eb4df2f 100644 --- a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt @@ -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 diff --git a/utils/time.ts b/utils/time.ts index 77e9b8ca6..c5193f0fe 100644 --- a/utils/time.ts +++ b/utils/time.ts @@ -41,7 +41,7 @@ export const formatTimeString = ( t: number | null | undefined, unit: "s" | "ms" | "tick" = "ms", ): string => { - if (t === null || t === undefined) return "0:00"; + if (t === null || t === undefined || !Number.isFinite(t)) return "0:00"; let seconds: number; switch (unit) {