mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-15 17:02:58 +01:00
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:
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user