fix: android pip

Changed from surfaceView to textureView
Enabled pip controls to work too

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
This commit is contained in:
Lance Chant
2026-05-30 15:09:19 +02:00
parent 1cabbf087e
commit 3d0a8417ec
7 changed files with 518 additions and 241 deletions

View File

@@ -822,12 +822,10 @@ export default function DirectPlayerPage() {
],
);
/** PiP handler for MPV */
const _onPictureInPictureChange = useCallback(
(e: { nativeEvent: { isActive: boolean } }) => {
const { isActive } = e.nativeEvent;
setIsPipMode(isActive);
// Hide controls when entering PiP
if (isActive) {
_setShowControls(false);
}
@@ -845,6 +843,9 @@ export default function DirectPlayerPage() {
// Memoize video ref functions to prevent unnecessary re-renders
const startPictureInPicture = useCallback(async () => {
// Hide controls BEFORE entering PiP so the window captures a clean view
_setShowControls(false);
setIsPipMode(true);
return videoRef.current?.startPictureInPicture?.();
}, []);
@@ -1247,6 +1248,7 @@ export default function DirectPlayerPage() {
nowPlayingMetadata={nowPlayingMetadata}
onProgress={onProgress}
onPlaybackStateChange={onPlaybackStateChanged}
onPictureInPictureChange={_onPictureInPictureChange}
onLoad={() => setIsVideoLoaded(true)}
onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => {
console.error("Video Error:", e.nativeEvent);

View File

@@ -236,37 +236,43 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
}
/**
* Attach surface and re-enable video output.
* Based on Findroid's implementation.
* Attach surface and ensure video output is active.
*
* During PiP transitions, the surface is destroyed and recreated by Android.
* We keep the VO pipeline alive (not killed with vo=null) so that rendering
* resumes immediately when the new surface is attached — avoiding the black
* screen that occurs when the VO is fully re-initialized via setOptionString.
*/
fun attachSurface(surface: Surface) {
this.surface = surface
Log.i(TAG, "[PiP] attachSurface — isRunning=$isRunning, vo=$voDriver, surface=${surface.hashCode()}")
if (isRunning) {
MPVLib.attachSurface(surface)
// Re-enable video output after attaching surface (Findroid approach)
MPVLib.setOptionString("force-window", "yes")
MPVLib.setOptionString("vo", voDriver)
Log.i(TAG, "Surface attached, video output re-enabled (vo=$voDriver)")
// Read back vo to confirm it's still active
val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null }
Log.i(TAG, "[PiP] attachSurface — attached, activeVo=$activeVo")
}
}
/**
* Detach surface and disable video output.
* Based on Findroid's implementation.
* Detach surface without killing the VO pipeline.
*
* The previous approach (vo=null / force-window=no) destroyed the entire video
* output pipeline on every surface transition. During PiP mode, the rapid
* destroy/recreate cycle caused a black screen because setOptionString("vo", ...)
* did not properly re-initialize rendering into the new PiP surface.
*
* By keeping the VO alive, frames are simply dropped while no surface is
* attached, and rendering resumes immediately when the new surface arrives.
*/
fun detachSurface() {
this.surface = null
Log.i(TAG, "[PiP] detachSurface — isRunning=$isRunning, vo=$voDriver")
if (isRunning) {
try {
// Disable video output before detaching surface (Findroid approach)
MPVLib.setOptionString("vo", "null")
MPVLib.setOptionString("force-window", "no")
Log.i(TAG, "Video output disabled before surface detach")
} catch (e: Exception) {
Log.e(TAG, "Failed to disable video output: ${e.message}")
}
MPVLib.detachSurface()
val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null }
Log.i(TAG, "[PiP] detachSurface — detached, activeVo=$activeVo (should still be $voDriver)")
}
}
@@ -277,7 +283,24 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
fun updateSurfaceSize(width: Int, height: Int) {
if (isRunning) {
MPVLib.setPropertyString("android-surface-size", "${width}x$height")
Log.i(TAG, "Surface size updated: ${width}x$height")
Log.i(TAG, "[PiP] updateSurfaceSize ${width}x${height}")
} else {
Log.w(TAG, "[PiP] updateSurfaceSize — called but renderer not running")
}
}
/**
* 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.
*/
fun forceRedraw() {
if (!isRunning) return
val pos = cachedPosition
Log.i(TAG, "[PiP] forceRedraw — stepping frame then seeking to $pos")
MPVLib.command(arrayOf("frame-step"))
if (pos > 0) {
MPVLib.command(arrayOf("seek", pos.toString(), "absolute"))
}
}

View File

@@ -198,7 +198,7 @@ class MpvPlayerModule : Module() {
}
// Defines events that the view can send to JavaScript
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady")
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange")
}
}
}

View File

@@ -2,12 +2,15 @@ package expo.modules.mpvplayer
import android.content.Context
import android.graphics.Color
import android.os.Build
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.SurfaceHolder
import android.view.SurfaceView
import android.widget.FrameLayout
import android.view.TextureView
import android.view.View
import android.view.ViewGroup
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView
@@ -28,26 +31,27 @@ data class VideoLoadConfig(
/**
* MpvPlayerView - ExpoView that hosts the MPV player.
* This mirrors the iOS MpvPlayerView implementation.
* Uses TextureView for reliable Picture-in-Picture support.
*/
class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext),
MPVLayerRenderer.Delegate, SurfaceHolder.Callback {
class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext),
MPVLayerRenderer.Delegate, TextureView.SurfaceTextureListener {
companion object {
private const val TAG = "MpvPlayerView"
}
// Event dispatchers
val onLoad by EventDispatcher()
val onPlaybackStateChange by EventDispatcher()
val onProgress by EventDispatcher()
val onError by EventDispatcher()
val onTracksReady by EventDispatcher()
private var surfaceView: SurfaceView
val onPictureInPictureChange by EventDispatcher()
private var textureView: TextureView
private var renderer: MPVLayerRenderer? = null
private var pipController: PiPController? = null
private var currentUrl: String? = null
private var cachedPosition: Double = 0.0
private var cachedDuration: Double = 0.0
@@ -56,23 +60,29 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
private var pendingConfig: VideoLoadConfig? = null
private var rendererStarted: Boolean = false
private var pendingSurface: 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 SurfaceView for video rendering
surfaceView = SurfaceView(context).apply {
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
// Create TextureView for video rendering (composites into app window for PiP support)
textureView = TextureView(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
holder.addCallback(this@MpvPlayerView)
surfaceTextureListener = this@MpvPlayerView
}
addView(surfaceView)
addView(textureView)
// Initialize PiP controller with Expo's AppContext for proper activity access
pipController = PiPController(context, appContext)
pipController?.setPlayerView(surfaceView)
pipController?.setPlayerView(textureView)
pipController?.delegate = object : PiPController.Delegate {
override fun onPlay() {
play()
@@ -85,6 +95,23 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
override fun onSeekBy(seconds: Double) {
seekBy(seconds)
}
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
pipHandler.removeCallbacksAndMessages(null)
restoreFromPiP()
}
onPictureInPictureChange(mapOf("isActive" to isInPiP))
}
}
// Renderer is created lazily in loadVideo once we have the voDriver setting
@@ -102,32 +129,29 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
try {
renderer?.start(voDriver ?: "gpu-next")
rendererStarted = true
Log.i(TAG, "Renderer started with vo=$voDriver")
// If surface was created before renderer started, attach it now
pendingSurface?.let { surface ->
renderer?.attachSurface(surface)
pendingSurface = null
Log.i(TAG, "Attached pending surface after renderer start")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to start renderer: ${e.message}")
onError(mapOf("error" to "Failed to start renderer: ${e.message}"))
}
}
// MARK: - SurfaceHolder.Callback
override fun surfaceCreated(holder: SurfaceHolder) {
Log.i(TAG, "Surface created")
// MARK: - TextureView.SurfaceTextureListener
override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
this.surfaceTexture = surfaceTexture
val surface = Surface(surfaceTexture)
surfaceTexture.setDefaultBufferSize(width, height)
surfaceReady = true
if (rendererStarted) {
renderer?.attachSurface(holder.surface)
renderer?.attachSurface(surface)
} else {
// Renderer not started yet - store surface to attach after start
pendingSurface = holder.surface
Log.i(TAG, "Surface created before renderer started, storing as pending")
pendingSurface = surface
}
// If we have a pending load, execute it now
@@ -137,19 +161,23 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
pendingConfig = null
}
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
Log.i(TAG, "Surface changed: ${width}x${height}")
// Update MPV with the new surface size (Findroid approach)
override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
surfaceTexture.setDefaultBufferSize(width, height)
renderer?.updateSurfaceSize(width, height)
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
Log.i(TAG, "Surface destroyed")
override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
this.surfaceTexture = null
surfaceReady = false
renderer?.detachSurface()
return false // mpv manages the SurfaceTexture
}
override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) {
// Called every frame — no action needed, mpv drives rendering directly
}
// MARK: - Video Loading
fun loadVideo(config: VideoLoadConfig) {
@@ -169,10 +197,10 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
loadVideoInternal(config)
}
private fun loadVideoInternal(config: VideoLoadConfig) {
currentUrl = config.url
renderer?.load(
url = config.url,
headers = config.headers,
@@ -181,124 +209,173 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
initialSubtitleId = config.initialSubtitleId,
initialAudioId = config.initialAudioId
)
if (config.autoplay) {
play()
}
onLoad(mapOf("url" to config.url))
}
// Convenience method for simple loads
fun loadVideo(url: String, headers: Map<String, String>? = null) {
loadVideo(VideoLoadConfig(url = url, headers = headers))
}
// MARK: - Playback Controls
fun play() {
intendedPlayState = true
renderer?.play()
pipController?.setPlaybackRate(1.0)
}
fun pause() {
intendedPlayState = false
renderer?.pause()
pipController?.setPlaybackRate(0.0)
}
fun seekTo(position: Double) {
renderer?.seekTo(position)
}
fun seekBy(offset: Double) {
renderer?.seekBy(offset)
}
fun setSpeed(speed: Double) {
renderer?.setSpeed(speed)
}
fun getSpeed(): Double {
return renderer?.getSpeed() ?: 1.0
}
fun isPaused(): Boolean {
return renderer?.isPausedState ?: true
}
fun getCurrentPosition(): Double {
return cachedPosition
}
fun getDuration(): Double {
return cachedDuration
}
// MARK: - Picture in Picture
fun startPictureInPicture() {
Log.i(TAG, "startPictureInPicture called")
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()
}
fun isPictureInPictureSupported(): Boolean {
return pipController?.isPictureInPictureSupported() ?: false
}
fun isPictureInPictureActive(): Boolean {
return pipController?.isPictureInPictureActive() ?: false
}
// MARK: - Subtitle Controls
fun getSubtitleTracks(): List<Map<String, Any>> {
return renderer?.getSubtitleTracks() ?: emptyList()
}
fun setSubtitleTrack(trackId: Int) {
renderer?.setSubtitleTrack(trackId)
}
fun disableSubtitles() {
renderer?.disableSubtitles()
}
fun getCurrentSubtitleTrack(): Int {
return renderer?.getCurrentSubtitleTrack() ?: 0
}
fun addSubtitleFile(url: String, select: Boolean = true) {
renderer?.addSubtitleFile(url, select)
}
// MARK: - Subtitle Positioning
fun setSubtitlePosition(position: Int) {
renderer?.setSubtitlePosition(position)
}
fun setSubtitleScale(scale: Double) {
renderer?.setSubtitleScale(scale)
}
fun setSubtitleMarginY(margin: Int) {
renderer?.setSubtitleMarginY(margin)
}
fun setSubtitleAlignX(alignment: String) {
renderer?.setSubtitleAlignX(alignment)
}
fun setSubtitleAlignY(alignment: String) {
renderer?.setSubtitleAlignY(alignment)
}
fun setSubtitleFontSize(size: Int) {
renderer?.setSubtitleFontSize(size)
}
@@ -316,15 +393,15 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
}
// MARK: - Audio Track Controls
fun getAudioTracks(): List<Map<String, Any>> {
return renderer?.getAudioTracks() ?: emptyList()
}
fun setAudioTrack(trackId: Int) {
renderer?.setAudioTrack(trackId)
}
fun getCurrentAudioTrack(): Int {
return renderer?.getCurrentAudioTrack() ?: 0
}
@@ -349,16 +426,16 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
}
// MARK: - MPVLayerRenderer.Delegate
override fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double) {
cachedPosition = position
cachedDuration = duration
// Update PiP progress
if (pipController?.isPictureInPictureActive() == true) {
pipController?.setCurrentTime(position, duration)
}
onProgress(mapOf(
"position" to position,
"duration" to duration,
@@ -366,50 +443,51 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
"cacheSeconds" to cacheSeconds
))
}
override fun onPauseChanged(isPaused: Boolean) {
// Sync PiP playback rate
pipController?.setPlaybackRate(if (isPaused) 0.0 else 1.0)
onPlaybackStateChange(mapOf(
"isPaused" to isPaused,
"isPlaying" to !isPaused
))
}
override fun onLoadingChanged(isLoading: Boolean) {
onPlaybackStateChange(mapOf(
"isLoading" to isLoading
))
}
override fun onReadyToSeek() {
onPlaybackStateChange(mapOf(
"isReadyToSeek" to true
))
}
override fun onTracksReady() {
onTracksReady(emptyMap<String, Any>())
}
override fun onVideoDimensionsChanged(width: Int, height: Int) {
// Update PiP controller with video dimensions for proper aspect ratio
pipController?.setVideoDimensions(width, height)
}
override fun onError(message: String) {
onError(mapOf("error" to message))
}
// MARK: - Cleanup
fun cleanup() {
isWaitingForPiPTransition = false
pipHandler.removeCallbacksAndMessages(null)
pipController?.stopPictureInPicture()
renderer?.stop()
surfaceView.holder.removeCallback(this)
surfaceTexture = null
surfaceReady = false
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
cleanup()

View File

@@ -1,51 +1,62 @@
package expo.modules.mpvplayer
import android.app.Activity
import android.app.Application
import android.app.PictureInPictureParams
import android.app.RemoteAction
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.graphics.drawable.Icon
import android.graphics.Rect
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.util.Rational
import android.view.View
import androidx.annotation.RequiresApi
import expo.modules.kotlin.AppContext
/**
* Picture-in-Picture controller for Android.
* This mirrors the iOS PiPController implementation.
*/
class PiPController(private val context: Context, private val appContext: AppContext? = null) {
companion object {
private const val TAG = "PiPController"
private const val DEFAULT_ASPECT_WIDTH = 16
private const val DEFAULT_ASPECT_HEIGHT = 9
private const val ACTION_PIP_PLAY_PAUSE = "expo.modules.mpvplayer.PIP_PLAY_PAUSE"
private const val ACTION_PIP_SKIP_FORWARD = "expo.modules.mpvplayer.PIP_SKIP_FORWARD"
private const val ACTION_PIP_SKIP_BACKWARD = "expo.modules.mpvplayer.PIP_SKIP_BACKWARD"
}
interface Delegate {
fun onPlay()
fun onPause()
fun onSeekBy(seconds: Double)
fun onPictureInPictureModeChanged(isInPiP: Boolean)
}
var delegate: Delegate? = null
private var currentPosition: Double = 0.0
private var currentDuration: Double = 0.0
private var playbackRate: Double = 1.0
// Video dimensions for proper aspect ratio
private var videoWidth: Int = 0
private var videoHeight: Int = 0
// Reference to the player view for source rect
private var playerView: View? = null
/**
* Check if Picture-in-Picture is supported on this device
*/
// PiP state tracking
private var isInPiPMode: Boolean = false
private var pipEntryNotified: Boolean = false
private val pipHandler = Handler(Looper.getMainLooper())
private var lifecycleCallbacks: Application.ActivityLifecycleCallbacks? = null
private var lifecycleRegistered = false
private var pipBroadcastReceiver: BroadcastReceiver? = null
fun isPictureInPictureSupported(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
@@ -53,10 +64,7 @@ class PiPController(private val context: Context, private val appContext: AppCon
false
}
}
/**
* Check if Picture-in-Picture is currently active
*/
fun isPictureInPictureActive(): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = getActivity()
@@ -64,69 +72,69 @@ class PiPController(private val context: Context, private val appContext: AppCon
}
return false
}
/**
* Start Picture-in-Picture mode
*/
fun startPictureInPicture() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = getActivity()
if (activity == null) {
Log.e(TAG, "Cannot start PiP: no activity found")
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val activity = getActivity() ?: run {
Log.e(TAG, "Cannot start PiP: no activity")
return
}
if (!isPictureInPictureSupported()) {
Log.e(TAG, "PiP not supported on this device")
return
}
try {
val params = buildPiPParams(forEntering = true)
val result = activity.enterPictureInPictureMode(params)
if (!result) {
Log.e(TAG, "enterPictureInPictureMode rejected by system")
isInPiPMode = false
return
}
if (!isPictureInPictureSupported()) {
Log.e(TAG, "PiP not supported on this device")
return
}
try {
val params = buildPiPParams(forEntering = true)
activity.enterPictureInPictureMode(params)
Log.i(TAG, "Entered PiP mode")
} catch (e: Exception) {
Log.e(TAG, "Failed to enter PiP: ${e.message}")
}
} else {
Log.w(TAG, "PiP requires Android O or higher")
isInPiPMode = true
pipEntryNotified = true
delegate?.onPictureInPictureModeChanged(true)
registerLifecycleCallbacks()
} catch (e: Exception) {
Log.e(TAG, "Failed to enter PiP: ${e.message}")
}
}
/**
* Stop Picture-in-Picture mode
*/
fun stopPictureInPicture() {
// On Android, exiting PiP is typically done by the user
// or by finishing the activity. We can request to move task to back.
isInPiPMode = false
pipEntryNotified = false
unregisterLifecycleCallbacks()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = getActivity()
if (activity?.isInPictureInPictureMode == true) {
// Move task to back which will exit PiP
activity.moveTaskToBack(false)
}
}
}
/**
* Update the current playback position and duration
* Note: We don't update PiP params here as we're not using progress in PiP controls
*/
fun isCurrentlyInPiP(): Boolean = isInPiPMode
fun setCurrentTime(position: Double, duration: Double) {
currentPosition = position
currentDuration = duration
}
/**
* Set the playback rate (0.0 for paused, 1.0 for playing)
*/
fun setPlaybackRate(rate: Double) {
playbackRate = rate
// Update PiP params to reflect play/pause state
if (rate > 0) {
registerLifecycleCallbacks()
}
// Update PiP params so autoEnterEnabled and action icons track play/pause state
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = getActivity()
if (activity?.isInPictureInPictureMode == true) {
if (activity != null) {
try {
activity.setPictureInPictureParams(buildPiPParams())
} catch (e: Exception) {
@@ -135,28 +143,19 @@ class PiPController(private val context: Context, private val appContext: AppCon
}
}
}
/**
* Set the video dimensions for proper aspect ratio calculation
*/
fun setVideoDimensions(width: Int, height: Int) {
if (width > 0 && height > 0) {
videoWidth = width
videoHeight = height
Log.i(TAG, "Video dimensions set: ${width}x${height}")
// Update PiP params if active
updatePiPParamsIfNeeded()
}
}
/**
* Set the player view reference for source rect hint
*/
fun setPlayerView(view: View?) {
playerView = view
}
private fun updatePiPParamsIfNeeded() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = getActivity()
@@ -169,23 +168,16 @@ class PiPController(private val context: Context, private val appContext: AppCon
}
}
}
/**
* Build Picture-in-Picture params for the current player state.
* Calculates proper aspect ratio and source rect based on video and view dimensions.
*/
@RequiresApi(Build.VERSION_CODES.O)
private fun buildPiPParams(forEntering: Boolean = false): PictureInPictureParams {
val view = playerView
val viewWidth = view?.width ?: 0
val viewHeight = view?.height ?: 0
// Display aspect ratio from view (exactly like Findroid)
val displayAspectRatio = Rational(viewWidth.coerceAtLeast(1), viewHeight.coerceAtLeast(1))
// Video aspect ratio with 2.39:1 clamping (exactly like Findroid)
// Findroid: Rational(it.width.coerceAtMost((it.height * 2.39f).toInt()),
// it.height.coerceAtMost((it.width * 2.39f).toInt()))
// Video aspect ratio with 2.39:1 clamping
val aspectRatio = if (videoWidth > 0 && videoHeight > 0) {
Rational(
videoWidth.coerceAtMost((videoHeight * 2.39f).toInt()),
@@ -194,70 +186,235 @@ class PiPController(private val context: Context, private val appContext: AppCon
} else {
Rational(DEFAULT_ASPECT_WIDTH, DEFAULT_ASPECT_HEIGHT)
}
// Source rect hint calculation (exactly like Findroid)
val sourceRectHint = if (viewWidth > 0 && viewHeight > 0 && videoWidth > 0 && videoHeight > 0) {
if (displayAspectRatio < aspectRatio) {
// Letterboxing - black bars top/bottom
val space = ((viewHeight - (viewWidth.toFloat() / aspectRatio.toFloat())) / 2).toInt()
Rect(
0,
space,
viewWidth,
(viewWidth.toFloat() / aspectRatio.toFloat()).toInt() + space
)
Rect(0, space, viewWidth, (viewWidth.toFloat() / aspectRatio.toFloat()).toInt() + space)
} else {
// Pillarboxing - black bars left/right
val space = ((viewWidth - (viewHeight.toFloat() * aspectRatio.toFloat())) / 2).toInt()
Rect(
space,
0,
(viewHeight.toFloat() * aspectRatio.toFloat()).toInt() + space,
viewHeight
)
Rect(space, 0, (viewHeight.toFloat() * aspectRatio.toFloat()).toInt() + space, viewHeight)
}
} else {
null
}
val builder = PictureInPictureParams.Builder()
.setAspectRatio(aspectRatio)
sourceRectHint?.let { builder.setSourceRectHint(it) }
// On Android 12+, enable auto-enter (like Findroid)
ensurePiPReceiverRegistered()
builder.setActions(buildPiPActions())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
builder.setAutoEnterEnabled(true)
builder.setAutoEnterEnabled(forEntering || playbackRate > 0)
}
return builder.build()
}
private fun getActivity(): Activity? {
// First try Expo's AppContext (preferred in React Native)
appContext?.currentActivity?.let { return it }
// Fallback: Try to get from context wrapper chain
var ctx = context
while (ctx is android.content.ContextWrapper) {
if (ctx is Activity) {
return ctx
}
if (ctx is Activity) return ctx
ctx = ctx.baseContext
}
return null
}
/**
* Handle PiP action (called from activity when user taps PiP controls)
*/
fun handlePiPAction(action: String) {
when (action) {
"play" -> delegate?.onPlay()
"pause" -> delegate?.onPause()
"skip_forward" -> delegate?.onSeekBy(10.0)
"skip_backward" -> delegate?.onSeekBy(-10.0)
// MARK: - Lifecycle-based PiP Detection
private fun registerLifecycleCallbacks() {
if (lifecycleRegistered) return
val app = context.applicationContext as? Application ?: run {
Log.w(TAG, "Cannot access Application for lifecycle callbacks, falling back to polling")
startFallbackPolling()
return
}
lifecycleCallbacks = object : Application.ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
override fun onActivityStarted(activity: Activity) {}
override fun onActivityResumed(activity: Activity) {
if (!isInPiPMode) return
if (!activity.isInPictureInPictureMode) {
isInPiPMode = false
pipEntryNotified = false
delegate?.onPictureInPictureModeChanged(false)
}
}
override fun onActivityPaused(activity: Activity) {
// Proactively hide controls when user leaves while playing,
// before the PiP window captures the UI. onActivityStopped
// will restore if PiP didn't actually enter.
if (playbackRate > 0 && !isInPiPMode) {
isInPiPMode = true
pipEntryNotified = true
delegate?.onPictureInPictureModeChanged(true)
}
}
override fun onActivityStopped(activity: Activity) {
pipHandler.postDelayed({
val inPip = activity.isInPictureInPictureMode
if (inPip && !isInPiPMode) {
isInPiPMode = true
pipEntryNotified = true
delegate?.onPictureInPictureModeChanged(true)
return@postDelayed
}
if (!isInPiPMode) return@postDelayed
if (inPip) return@postDelayed
// Not in PiP after 1s — check again to avoid false positive during transition
pipHandler.postDelayed({
if (!isInPiPMode) return@postDelayed
if (!activity.isInPictureInPictureMode) {
isInPiPMode = false
pipEntryNotified = false
delegate?.onPictureInPictureModeChanged(false)
}
}, 1500)
}, 1000)
}
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
override fun onActivityDestroyed(activity: Activity) {
isInPiPMode = false
}
}
app.registerActivityLifecycleCallbacks(lifecycleCallbacks)
lifecycleRegistered = true
}
private fun unregisterLifecycleCallbacks() {
if (!lifecycleRegistered) return
lifecycleCallbacks?.let {
(context.applicationContext as? Application)
?.unregisterActivityLifecycleCallbacks(it)
}
lifecycleCallbacks = null
lifecycleRegistered = false
pipHandler.removeCallbacksAndMessages(null)
unregisterPiPBroadcastReceiver()
}
private fun startFallbackPolling() {
var falseReadCount = 0
pipHandler.removeCallbacksAndMessages(null)
pipHandler.postDelayed(object : Runnable {
override fun run() {
if (!isInPiPMode) return
var ctx = context
var activity: Activity? = null
while (ctx is android.content.ContextWrapper) {
if (ctx is Activity) { activity = ctx; break }
ctx = ctx.baseContext
}
val stillInPip = activity?.isInPictureInPictureMode == true
if (!stillInPip) {
falseReadCount++
if (falseReadCount >= 3) {
isInPiPMode = false
delegate?.onPictureInPictureModeChanged(false)
return
}
pipHandler.postDelayed(this, 500)
return
}
falseReadCount = 0
pipHandler.postDelayed(this, 1000)
}
}, 3000)
}
// MARK: - PiP Remote Actions
private fun ensurePiPReceiverRegistered() {
if (pipBroadcastReceiver != null) return
pipBroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
ACTION_PIP_PLAY_PAUSE -> {
if (playbackRate > 0) delegate?.onPause() else delegate?.onPlay()
}
ACTION_PIP_SKIP_FORWARD -> delegate?.onSeekBy(10.0)
ACTION_PIP_SKIP_BACKWARD -> delegate?.onSeekBy(-10.0)
}
}
}
val filter = IntentFilter().apply {
addAction(ACTION_PIP_PLAY_PAUSE)
addAction(ACTION_PIP_SKIP_FORWARD)
addAction(ACTION_PIP_SKIP_BACKWARD)
}
val registerFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Context.RECEIVER_EXPORTED
} else {
0
}
context.applicationContext.registerReceiver(pipBroadcastReceiver, filter, registerFlags)
}
private fun unregisterPiPBroadcastReceiver() {
pipBroadcastReceiver?.let {
try {
context.applicationContext.unregisterReceiver(it)
} catch (_: Exception) {}
}
pipBroadcastReceiver = null
}
private fun buildPiPActions(): List<RemoteAction> {
val isPlaying = playbackRate > 0
return listOf(
RemoteAction(
Icon.createWithResource(context, android.R.drawable.ic_media_rew),
"Rewind", "Skip backward 10 seconds",
createPiPPendingIntent(ACTION_PIP_SKIP_BACKWARD)
),
RemoteAction(
Icon.createWithResource(
context,
if (isPlaying) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play
),
if (isPlaying) "Pause" else "Play",
if (isPlaying) "Pause playback" else "Resume playback",
createPiPPendingIntent(ACTION_PIP_PLAY_PAUSE)
),
RemoteAction(
Icon.createWithResource(context, android.R.drawable.ic_media_ff),
"Fast Forward", "Skip forward 10 seconds",
createPiPPendingIntent(ACTION_PIP_SKIP_FORWARD)
)
)
}
private fun createPiPPendingIntent(action: String): android.app.PendingIntent {
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
android.app.PendingIntent.FLAG_IMMUTABLE
} else {
0
}
return android.app.PendingIntent.getBroadcast(
context.applicationContext, 0, Intent(action), flags
)
}
}

View File

@@ -25,6 +25,10 @@ export type OnErrorEventPayload = {
export type OnTracksReadyEventPayload = Record<string, never>;
export type OnPictureInPictureChangePayload = {
isActive: boolean;
};
export type NowPlayingMetadata = {
title?: string;
artist?: string;
@@ -77,6 +81,9 @@ export type MpvPlayerViewProps = {
onProgress?: (event: { nativeEvent: OnProgressEventPayload }) => void;
onError?: (event: { nativeEvent: OnErrorEventPayload }) => void;
onTracksReady?: (event: { nativeEvent: OnTracksReadyEventPayload }) => void;
onPictureInPictureChange?: (event: {
nativeEvent: OnPictureInPictureChangePayload;
}) => void;
};
export interface MpvPlayerViewRef {

View File

@@ -7,6 +7,8 @@ import { MpvPlayerViewProps, MpvPlayerViewRef } from "./MpvPlayer.types";
const NativeView: React.ComponentType<MpvPlayerViewProps & { ref?: any }> =
requireNativeView("MpvPlayer");
const PIP_LOG = "[PiP] MpvPlayerView.tsx:";
export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
function MpvPlayerView(props, ref) {
const nativeRef = useRef<any>(null);
@@ -40,16 +42,24 @@ export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
return await nativeRef.current?.getDuration();
},
startPictureInPicture: async () => {
console.log(PIP_LOG, "startPictureInPicture → native");
await nativeRef.current?.startPictureInPicture();
console.log(PIP_LOG, "startPictureInPicture ← native returned");
},
stopPictureInPicture: async () => {
console.log(PIP_LOG, "stopPictureInPicture → native");
await nativeRef.current?.stopPictureInPicture();
console.log(PIP_LOG, "stopPictureInPicture ← native returned");
},
isPictureInPictureSupported: async () => {
return await nativeRef.current?.isPictureInPictureSupported();
const result = await nativeRef.current?.isPictureInPictureSupported();
console.log(PIP_LOG, "isPictureInPictureSupported =", result);
return result;
},
isPictureInPictureActive: async () => {
return await nativeRef.current?.isPictureInPictureActive();
const result = await nativeRef.current?.isPictureInPictureActive();
console.log(PIP_LOG, "isPictureInPictureActive =", result);
return result;
},
getSubtitleTracks: async () => {
return await nativeRef.current?.getSubtitleTracks();