diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index a384e94cf..937c32092 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -825,12 +825,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); } @@ -848,6 +846,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?.(); }, []); @@ -1253,6 +1254,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); 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 ff45438eb..753bfb28f 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 @@ -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")) } } diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt index 2e3f9a868..2d1cfddd5 100644 --- a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt @@ -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") } } } 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 066afe90b..4df7fe0b3 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,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? = 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> { 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> { 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()) } - + 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() 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 438ccaa1f..2a24440bf 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 @@ -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 { + 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 + ) } } - diff --git a/modules/mpv-player/src/MpvPlayer.types.ts b/modules/mpv-player/src/MpvPlayer.types.ts index 9de7ad60f..b6bd04711 100644 --- a/modules/mpv-player/src/MpvPlayer.types.ts +++ b/modules/mpv-player/src/MpvPlayer.types.ts @@ -25,6 +25,10 @@ export type OnErrorEventPayload = { export type OnTracksReadyEventPayload = Record; +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 { diff --git a/modules/mpv-player/src/MpvPlayerView.tsx b/modules/mpv-player/src/MpvPlayerView.tsx index cec13b0ff..1e1c80659 100644 --- a/modules/mpv-player/src/MpvPlayerView.tsx +++ b/modules/mpv-player/src/MpvPlayerView.tsx @@ -7,6 +7,8 @@ import { MpvPlayerViewProps, MpvPlayerViewRef } from "./MpvPlayer.types"; const NativeView: React.ComponentType = requireNativeView("MpvPlayer"); +const PIP_LOG = "[PiP] MpvPlayerView.tsx:"; + export default React.forwardRef( function MpvPlayerView(props, ref) { const nativeRef = useRef(null); @@ -40,16 +42,24 @@ export default React.forwardRef( 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();