From 32ead6d046291288a3929ff625daabd25da686b5 Mon Sep 17 00:00:00 2001 From: Lance Chant <13349722+lancechant@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:24:33 +0200 Subject: [PATCH] fix: changing pip to use surface view This restores back pip in surface view, now works as intended and removes other workarounds to get textureView working Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com> --- .../expo/modules/mpvplayer/MpvPlayerView.kt | 208 ++++++++---------- .../expo/modules/mpvplayer/PiPController.kt | 13 ++ 2 files changed, 108 insertions(+), 113 deletions(-) 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 7cb322d9..df74be18 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 @@ -2,14 +2,12 @@ package expo.modules.mpvplayer import android.content.Context import android.graphics.Color -import android.graphics.Rect -import android.graphics.SurfaceTexture import android.os.Handler import android.os.Looper import android.util.Log import android.view.Surface -import android.view.TextureView -import android.view.View +import android.view.SurfaceHolder +import android.view.SurfaceView import android.view.ViewGroup import expo.modules.kotlin.AppContext import expo.modules.kotlin.viewevent.EventDispatcher @@ -30,15 +28,26 @@ data class VideoLoadConfig( val cacheEnabled: String? = null, val cacheSeconds: Int? = null, val demuxerMaxBytes: Int? = null, - val demuxerMaxBackBytes: Int? = null + val demuxerMaxBackBytes: Int? = null, ) /** * MpvPlayerView - ExpoView that hosts the MPV player. - * Uses TextureView for reliable Picture-in-Picture support. + * + * Uses SurfaceView (not TextureView) so the surface routes directly to + * SurfaceFlinger (the OS compositor) rather than compositing into the + * app's window surface. This matches mpv-android's architecture and + * gives mpv a standalone surface. + * + * PiP black-screen mitigation: SurfaceView's surface is destroyed and + * recreated on PiP entry/exit, and the new surface's initial dimensions + * can be stale until the next layout pass. We push dimension updates to + * mpv via both SurfaceHolder.Callback.surfaceChanged AND an + * OnLayoutChangeListener, so the PiP transition (which fires layout + * passes on the view itself) reaches mpv promptly. */ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext), - MPVLayerRenderer.Delegate, TextureView.SurfaceTextureListener { + MPVLayerRenderer.Delegate, SurfaceHolder.Callback { companion object { private const val TAG = "MpvPlayerView" @@ -52,7 +61,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context val onTracksReady by EventDispatcher() val onPictureInPictureChange by EventDispatcher() - private var textureView: TextureView + private var surfaceView: SurfaceView private var renderer: MPVLayerRenderer? = null private var pipController: PiPController? = null @@ -63,31 +72,45 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context private var surfaceReady: Boolean = false private var pendingConfig: VideoLoadConfig? = null private var rendererStarted: Boolean = false - private var pendingSurface: Surface? = null private var activeSurface: Surface? = null - private var surfaceTexture: SurfaceTexture? = null // PiP state tracking - private var isWaitingForPiPTransition: Boolean = false - private var isPiPSurfaceForced: Boolean = false private val pipHandler = Handler(Looper.getMainLooper()) init { setBackgroundColor(Color.BLACK) - // Create TextureView for video rendering (composites into app window for PiP support) - textureView = TextureView(context).apply { + // SurfaceView for video rendering. Routes the surface directly to + // SurfaceFlinger (the OS compositor), giving mpv a standalone + // surface. TextureView composites into the app's window surface + // which is less efficient and breaks PiP transitions. + surfaceView = SurfaceView(context).apply { layoutParams = ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT ) - surfaceTextureListener = this@MpvPlayerView } - addView(textureView) + surfaceView.holder.addCallback(this@MpvPlayerView) + addView(surfaceView) + + // Push dimension updates to mpv on every view bounds change. This + // is the primary PiP black-screen fix: entering PiP fires a layout + // pass on the SurfaceView itself, and we proactively tell mpv the + // new size so it resizes its EGL swapchain before rendering. + surfaceView.addOnLayoutChangeListener { _, left, top, right, bottom, + oldLeft, oldTop, oldRight, oldBottom -> + val w = right - left + val h = bottom - top + val oldW = oldRight - oldLeft + val oldH = oldBottom - oldTop + if (w > 0 && h > 0 && (w != oldW || h != oldH)) { + renderer?.updateSurfaceSize(w, h) + } + } // Initialize PiP controller with Expo's AppContext for proper activity access pipController = PiPController(context, appContext) - pipController?.setPlayerView(textureView) + pipController?.setPlayerView(surfaceView) pipController?.delegate = object : PiPController.Delegate { override fun onPlay() { play() @@ -103,17 +126,17 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context override fun onPictureInPictureModeChanged(isInPiP: Boolean) { if (isInPiP) { - if (!isWaitingForPiPTransition) { - isWaitingForPiPTransition = true - pipHandler.removeCallbacksAndMessages(null) - for (delay in longArrayOf(500, 1000, 1500, 2000)) { - pipHandler.postDelayed({ forcePiPBufferSize() }, delay) - } - } - } else { - isWaitingForPiPTransition = false + // Post size syncs after the PiP layout settles. Two passes + // catch both the immediate surface re-attach and the + // post-animation layout pass. Replaces the old TextureView + // measure/layout polling hack (forcePiPBufferSize). pipHandler.removeCallbacksAndMessages(null) - restoreFromPiP() + pipHandler.postDelayed({ syncSurfaceSizeToView() }, 100) + pipHandler.postDelayed({ syncSurfaceSizeToView() }, 500) + } else { + // Restore from PiP: surface resized back to fullscreen. + pipHandler.removeCallbacksAndMessages(null) + pipHandler.postDelayed({ syncSurfaceSizeToView() }, 100) } onPictureInPictureChange(mapOf("isActive" to isInPiP)) } @@ -126,7 +149,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context /** * Start the renderer with the given VO driver. - * Called lazily on first loadVideo so the voDriver setting is available. + * Called lazily on first loadVideo so user settings are available. */ private fun ensureRendererStarted(voDriver: String?) { if (rendererStarted) return @@ -135,10 +158,14 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context renderer?.start(voDriver ?: "gpu-next") rendererStarted = true - pendingSurface?.let { surface -> + // If the surface is already alive (surfaceCreated fired before + // loadVideo), attach it now. With SurfaceView the surface is + // owned by the holder, so we read it from there directly rather + // than stashing it on the side. + surfaceView.holder.surface?.takeIf { it.isValid }?.let { surface -> activeSurface = surface renderer?.attachSurface(surface) - pendingSurface = null + syncSurfaceSizeToView() } } catch (e: Exception) { Log.e(TAG, "Failed to start renderer: ${e.message}") @@ -146,23 +173,20 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context } } - // MARK: - TextureView.SurfaceTextureListener + // MARK: - SurfaceHolder.Callback - override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) { - this.surfaceTexture = surfaceTexture - val surface = Surface(surfaceTexture) - surfaceTexture.setDefaultBufferSize(width, height) + override fun surfaceCreated(holder: SurfaceHolder) { + val surface = holder.surface surfaceReady = true if (rendererStarted) { - // Release the previous wrapper Surface before losing the only - // reference to it. cleanup() only runs on detach, so without this - // repeated PiP/background/resize cycles leak native surface objects. - activeSurface?.release() + // The previous Surface reference is holder-owned; do NOT release + // it (SurfaceView manages its lifecycle). Just track the new one. activeSurface = surface renderer?.attachSurface(surface) - } else { - pendingSurface = surface + // Push the actual view dimensions immediately so mpv doesn't + // render against stale full-screen geometry during PiP transitions. + syncSurfaceSizeToView() } // If we have a pending load, execute it now @@ -173,20 +197,36 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context } } - override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) { - surfaceTexture.setDefaultBufferSize(width, height) - renderer?.updateSurfaceSize(width, height) + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { + if (width > 0 && height > 0) { + renderer?.updateSurfaceSize(width, height) + } } - override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean { - this.surfaceTexture = null + override fun surfaceDestroyed(holder: SurfaceHolder) { surfaceReady = false renderer?.detachSurface() - return false // mpv manages the SurfaceTexture + // Do NOT issue mpv "stop" here. Playback continues against the + // demuxer; when surfaceCreated fires again (PiP entry/exit, app + // background/foreground), we re-attach and frames resume. This + // matches the keep-open=always setting in MPVLayerRenderer. + // + // Do NOT release activeSurface — SurfaceView owns it via the holder. + activeSurface = null } - override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) { - // Called every frame — no action needed, mpv drives rendering directly + /** + * Read the actual SurfaceView width/height and push them to mpv. + * The PiP transition can fire surfaceCreated before the view's layout + * has settled to PiP dimensions, so we re-sync after layout passes. + */ + private fun syncSurfaceSizeToView() { + if (!surfaceReady) return + val w = surfaceView.width + val h = surfaceView.height + if (w > 0 && h > 0) { + renderer?.updateSurfaceSize(w, h) + } } // MARK: - Video Loading @@ -275,7 +315,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context // Reset view-level state so a subsequent loadVideo() on the SAME view // instance re-creates the mpv handle and re-attaches the still-live - // TextureView surface. Without this, rendererStarted stays true and + // SurfaceView surface. Without this, rendererStarted stays true and // ensureRendererStarted() early-returns, so renderer.start() is never // called again — but stop() already nulled the renderer's mpv handle. // The next loadVideo() then runs loadVideoInternal() -> renderer.load() @@ -286,13 +326,12 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context // which call destroy() immediately before router.replace() to the // same route — Expo Router reuses the same MpvPlayerView instance, // so the next source load happens on this view without a remount. + // + // SurfaceView note: the surface is owned by the holder and survives + // across destroy()/loadVideo() on the same view instance. The next + // ensureRendererStarted() reads it from surfaceView.holder.surface. rendererStarted = false currentUrl = null - // Move the active surface back to pending so ensureRendererStarted() - // re-attaches it to the freshly created mpv instance on next load. - // The Surface itself is still valid — onSurfaceTextureDestroyed has - // not fired because the TextureView is not being unmounted. - activeSurface?.let { pendingSurface = it } activeSurface = null } @@ -327,59 +366,10 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context // MARK: - Picture in Picture fun startPictureInPicture() { - isWaitingForPiPTransition = true pipController?.startPictureInPicture() - - // Resize buffer to match PiP window after animation settles - pipHandler.removeCallbacksAndMessages(null) - for (delay in longArrayOf(500, 1000, 1500, 2000)) { - pipHandler.postDelayed({ forcePiPBufferSize() }, delay) - } - } - - /** - * Resize the SurfaceTexture buffer AND TextureView layout to match the PiP - * visible rect so mpv renders at the PiP window's actual dimensions. - */ - private fun forcePiPBufferSize() { - if (!isWaitingForPiPTransition || !surfaceReady) return - - val rect = Rect() - textureView.getGlobalVisibleRect(rect) - val visW = rect.width() - val visH = rect.height() - val vw = textureView.width - val vh = textureView.height - - if (visW <= 0 || visH <= 0 || (vw == visW && vh == visH)) return - - surfaceTexture?.setDefaultBufferSize(visW, visH) - renderer?.updateSurfaceSize(visW, visH) - - // Force TextureView layout to match PiP visible area. - // layoutParams alone doesn't work during PiP because the parent - // never re-lays out its children. - textureView.measure( - View.MeasureSpec.makeMeasureSpec(visW, View.MeasureSpec.EXACTLY), - View.MeasureSpec.makeMeasureSpec(visH, View.MeasureSpec.EXACTLY) - ) - textureView.layout(0, 0, visW, visH) - isPiPSurfaceForced = true - } - - private fun restoreFromPiP() { - if (!isPiPSurfaceForced) return - isPiPSurfaceForced = false - - val lp = textureView.layoutParams - lp.width = ViewGroup.LayoutParams.MATCH_PARENT - lp.height = ViewGroup.LayoutParams.MATCH_PARENT - textureView.layoutParams = lp - textureView.requestLayout() } fun stopPictureInPicture() { - isWaitingForPiPTransition = false pipHandler.removeCallbacksAndMessages(null) pipController?.stopPictureInPicture() } @@ -547,20 +537,12 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context * off the JS path. */ fun cleanup() { - isWaitingForPiPTransition = false pipHandler.removeCallbacksAndMessages(null) pipController?.stopPictureInPicture() renderer?.stop() renderer?.delegate = null - // Release the Surface that wraps the SurfaceTexture. These Surface - // objects are created in onSurfaceTextureAvailable and were never - // released; each playback session previously leaked one. The - // SurfaceTexture itself is owned by TextureView and released by it - // via onSurfaceTextureDestroyed, so we leave it alone. - pendingSurface?.release() - pendingSurface = null - activeSurface?.release() + // SurfaceView owns the Surface via its holder — do NOT release it. activeSurface = null surfaceReady = false currentUrl = null diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/PiPController.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/PiPController.kt index 2a24440b..2d1d694c 100644 --- a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/PiPController.kt +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/PiPController.kt @@ -106,11 +106,24 @@ class PiPController(private val context: Context, private val appContext: AppCon } fun stopPictureInPicture() { + // Clear playback rate FIRST so the param rebuild below computes + // setAutoEnterEnabled=false. Without this, the Activity retains the + // last-set auto-enter=true from when playback was active, and any + // home-button press from anywhere in the app triggers PiP — even + // after the player has unmounted. + playbackRate = 0.0 isInPiPMode = false pipEntryNotified = false unregisterLifecycleCallbacks() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val activity = getActivity() + // Re-push params with auto-enter disabled so the system stops + // considering this task eligible for auto-PiP on home press. + try { + activity?.setPictureInPictureParams(buildPiPParams()) + } catch (e: Exception) { + Log.e(TAG, "Failed to clear PiP auto-enter params: ${e.message}") + } if (activity?.isInPictureInPictureMode == true) { activity.moveTaskToBack(false) }