Compare commits

..

4 Commits

Author SHA1 Message Date
renovate[bot]
8557d38834 chore(deps): Update dependency react-native-gesture-handler to v3 2026-05-30 21:06:31 +00:00
Fredrik Burmester
ed7928b4d3 Merge remote-tracking branch 'origin/feat/tv-interface' into develop
Some checks failed
🌐 Translation Sync / sync-translations (push) Waiting to run
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-05-30 22:47:33 +02:00
lance chant
27dc7b5664 fix: android pip (#1605)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-05-30 22:45:19 +02:00
Alex
a205c75895 chore(mpv-player): Update to MPVKIT 0.41 (#1604) 2026-05-30 22:45:07 +02:00
11 changed files with 535 additions and 275 deletions

View File

@@ -144,8 +144,8 @@
[ [
"./plugins/withGitPod.js", "./plugins/withGitPod.js",
{ {
"podName": "MPVKit-GPL", "podName": "MPVKit",
"podspecUrl": "https://raw.githubusercontent.com/streamyfin/MPVKit/0.40.0-av/MPVKit-GPL.podspec" "podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec"
} }
] ]
], ],

View File

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

View File

@@ -68,14 +68,14 @@
"react-native-device-info": "^15.0.0", "react-native-device-info": "^15.0.0",
"react-native-draggable-flatlist": "^4.0.3", "react-native-draggable-flatlist": "^4.0.3",
"react-native-edge-to-edge": "^1.7.0", "react-native-edge-to-edge": "^1.7.0",
"react-native-gesture-handler": "~2.31.1", "react-native-gesture-handler": "~3.0.0",
"react-native-glass-effect-view": "^1.0.0", "react-native-glass-effect-view": "^1.0.0",
"react-native-google-cast": "^4.9.1", "react-native-google-cast": "^4.9.1",
"react-native-image-colors": "^2.4.0", "react-native-image-colors": "^2.4.0",
"react-native-ios-context-menu": "^3.2.1", "react-native-ios-context-menu": "^3.2.1",
"react-native-ios-utilities": "5.2.0", "react-native-ios-utilities": "5.2.0",
"react-native-mmkv": "4.1.1", "react-native-mmkv": "4.1.1",
"react-native-nitro-modules": "0.33.9", "react-native-nitro-modules": "0.33.1",
"react-native-pager-view": "8.0.1", "react-native-pager-view": "8.0.1",
"react-native-qrcode-svg": "^6.3.21", "react-native-qrcode-svg": "^6.3.21",
"react-native-reanimated": "4.3.1", "react-native-reanimated": "4.3.1",
@@ -292,8 +292,6 @@
"@douglowder/expo-av-route-picker-view": ["@douglowder/expo-av-route-picker-view@0.0.5", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-oT4wf8aYYNfLEuZEkwZIH7CtEHKnEHWnjs6/hNwbFGEC0FnfjjWBNrQEt4fo5/gkafqa2G5ILkxndMyBZvk5dg=="], "@douglowder/expo-av-route-picker-view": ["@douglowder/expo-av-route-picker-view@0.0.5", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-oT4wf8aYYNfLEuZEkwZIH7CtEHKnEHWnjs6/hNwbFGEC0FnfjjWBNrQEt4fo5/gkafqa2G5ILkxndMyBZvk5dg=="],
"@egjs/hammerjs": ["@egjs/hammerjs@2.0.17", "", { "dependencies": { "@types/hammerjs": "^2.0.36" } }, "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A=="],
"@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="], "@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="],
"@expo-google-fonts/material-symbols": ["@expo-google-fonts/material-symbols@0.4.38", "", {}, "sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A=="], "@expo-google-fonts/material-symbols": ["@expo-google-fonts/material-symbols@0.4.38", "", {}, "sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A=="],
@@ -588,8 +586,6 @@
"@types/emscripten": ["@types/emscripten@1.41.5", "", {}, "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q=="], "@types/emscripten": ["@types/emscripten@1.41.5", "", {}, "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q=="],
"@types/hammerjs": ["@types/hammerjs@2.0.46", "", {}, "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw=="],
"@types/hoist-non-react-statics": ["@types/hoist-non-react-statics@3.3.7", "", { "dependencies": { "hoist-non-react-statics": "^3.3.0" }, "peerDependencies": { "@types/react": "*" } }, "sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g=="], "@types/hoist-non-react-statics": ["@types/hoist-non-react-statics@3.3.7", "", { "dependencies": { "hoist-non-react-statics": "^3.3.0" }, "peerDependencies": { "@types/react": "*" } }, "sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g=="],
"@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="], "@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="],
@@ -1562,7 +1558,7 @@
"react-native-edge-to-edge": ["react-native-edge-to-edge@1.8.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-bhvsKqeX9PGkY9wBUk9vni/tJNJdKtLPbs/j3e/3CdV4JmUWfTXYYoL+4Hc8Wmej+5eJxkc8KOFa454ruFWBCA=="], "react-native-edge-to-edge": ["react-native-edge-to-edge@1.8.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-bhvsKqeX9PGkY9wBUk9vni/tJNJdKtLPbs/j3e/3CdV4JmUWfTXYYoL+4Hc8Wmej+5eJxkc8KOFa454ruFWBCA=="],
"react-native-gesture-handler": ["react-native-gesture-handler@2.31.2", "", { "dependencies": { "@egjs/hammerjs": "^2.0.17", "@types/react-test-renderer": "^19.1.0", "hoist-non-react-statics": "^3.3.0", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-rw5q74i2AfS7YGYdbxQDhOU7xqgY6WRM1132/CCm3erqjblhECZDZFHIm0tteHoC9ih24wogVBVVzcTBQtZ+5A=="], "react-native-gesture-handler": ["react-native-gesture-handler@3.0.0", "", { "dependencies": { "@types/react-test-renderer": "^19.1.0", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-6E8o9D2sHwhFGiU0c4aCweMdJwIbQeBV+dq3IQ3HcqKhVGzg7ccEycap6i0zGCtIYfs3V29Xd4OycwcRj5qxBQ=="],
"react-native-glass-effect-view": ["react-native-glass-effect-view@1.0.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-ABYG0oIiqbXsxe2R/cMhNgDn3YgwDLz/2TIN2XOxQopXC+MiGsG9C32VYQvO2sYehcu5JmI3h3EzwLwl6lJhhA=="], "react-native-glass-effect-view": ["react-native-glass-effect-view@1.0.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-ABYG0oIiqbXsxe2R/cMhNgDn3YgwDLz/2TIN2XOxQopXC+MiGsG9C32VYQvO2sYehcu5JmI3h3EzwLwl6lJhhA=="],
@@ -1578,7 +1574,7 @@
"react-native-mmkv": ["react-native-mmkv@4.1.1", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "*" } }, "sha512-nYFjM27l7zVhIiyAqWEFRagGASecb13JMIlzAuOeakRRz9GMJ49hCQntUBE2aeuZRE4u4ehSqTOomB0mTF56Ew=="], "react-native-mmkv": ["react-native-mmkv@4.1.1", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "*" } }, "sha512-nYFjM27l7zVhIiyAqWEFRagGASecb13JMIlzAuOeakRRz9GMJ49hCQntUBE2aeuZRE4u4ehSqTOomB0mTF56Ew=="],
"react-native-nitro-modules": ["react-native-nitro-modules@0.33.9", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-BM9C5mCGYYjrc8CDWZZ0anLWU/knH2xaEuFzvzogKTOW6fzgS6mmsCdM3ty+AhImJNSYwK19DLrHaqwnrrwEzw=="], "react-native-nitro-modules": ["react-native-nitro-modules@0.33.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-Kdo8qiqlkGAEs7fq29i0yiZs0Gf7ucmMiFsH8PH4uzsnSGEt2CQRBJGnQKKMl9vJYL8e7rzA0TZKRwO/L8G/Sg=="],
"react-native-pager-view": ["react-native-pager-view@8.0.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-pGOne2o0y0HOQLrlTLcGgOE48uJlqSZHRRwdW8nL6JJozMkPGJYi/G9e0EsJoWFpXYONjiDgr8IwxC4F6/r7Lg=="], "react-native-pager-view": ["react-native-pager-view@8.0.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-pGOne2o0y0HOQLrlTLcGgOE48uJlqSZHRRwdW8nL6JJozMkPGJYi/G9e0EsJoWFpXYONjiDgr8IwxC4F6/r7Lg=="],

View File

@@ -236,37 +236,43 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
} }
/** /**
* Attach surface and re-enable video output. * Attach surface and ensure video output is active.
* Based on Findroid's implementation. *
* 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) { fun attachSurface(surface: Surface) {
this.surface = surface this.surface = surface
Log.i(TAG, "[PiP] attachSurface — isRunning=$isRunning, vo=$voDriver, surface=${surface.hashCode()}")
if (isRunning) { if (isRunning) {
MPVLib.attachSurface(surface) MPVLib.attachSurface(surface)
// Re-enable video output after attaching surface (Findroid approach)
MPVLib.setOptionString("force-window", "yes") MPVLib.setOptionString("force-window", "yes")
MPVLib.setOptionString("vo", voDriver) // Read back vo to confirm it's still active
Log.i(TAG, "Surface attached, video output re-enabled (vo=$voDriver)") val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null }
Log.i(TAG, "[PiP] attachSurface — attached, activeVo=$activeVo")
} }
} }
/** /**
* Detach surface and disable video output. * Detach surface without killing the VO pipeline.
* Based on Findroid's implementation. *
* 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() { fun detachSurface() {
this.surface = null this.surface = null
Log.i(TAG, "[PiP] detachSurface — isRunning=$isRunning, vo=$voDriver")
if (isRunning) { 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() 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) { fun updateSurfaceSize(width: Int, height: Int) {
if (isRunning) { if (isRunning) {
MPVLib.setPropertyString("android-surface-size", "${width}x$height") 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 // 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.content.Context
import android.graphics.Color 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.util.Log
import android.view.Surface import android.view.Surface
import android.view.SurfaceHolder import android.view.TextureView
import android.view.SurfaceView import android.view.View
import android.widget.FrameLayout import android.view.ViewGroup
import expo.modules.kotlin.AppContext import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView import expo.modules.kotlin.views.ExpoView
@@ -28,10 +31,10 @@ data class VideoLoadConfig(
/** /**
* MpvPlayerView - ExpoView that hosts the MPV player. * 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), class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext),
MPVLayerRenderer.Delegate, SurfaceHolder.Callback { MPVLayerRenderer.Delegate, TextureView.SurfaceTextureListener {
companion object { companion object {
private const val TAG = "MpvPlayerView" private const val TAG = "MpvPlayerView"
@@ -43,8 +46,9 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
val onProgress by EventDispatcher() val onProgress by EventDispatcher()
val onError by EventDispatcher() val onError by EventDispatcher()
val onTracksReady by EventDispatcher() val onTracksReady by EventDispatcher()
val onPictureInPictureChange by EventDispatcher()
private var surfaceView: SurfaceView private var textureView: TextureView
private var renderer: MPVLayerRenderer? = null private var renderer: MPVLayerRenderer? = null
private var pipController: PiPController? = null private var pipController: PiPController? = null
@@ -56,23 +60,29 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
private var pendingConfig: VideoLoadConfig? = null private var pendingConfig: VideoLoadConfig? = null
private var rendererStarted: Boolean = false private var rendererStarted: Boolean = false
private var pendingSurface: Surface? = null 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 { init {
setBackgroundColor(Color.BLACK) setBackgroundColor(Color.BLACK)
// Create SurfaceView for video rendering // Create TextureView for video rendering (composites into app window for PiP support)
surfaceView = SurfaceView(context).apply { textureView = TextureView(context).apply {
layoutParams = FrameLayout.LayoutParams( layoutParams = ViewGroup.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
FrameLayout.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 // Initialize PiP controller with Expo's AppContext for proper activity access
pipController = PiPController(context, appContext) pipController = PiPController(context, appContext)
pipController?.setPlayerView(surfaceView) pipController?.setPlayerView(textureView)
pipController?.delegate = object : PiPController.Delegate { pipController?.delegate = object : PiPController.Delegate {
override fun onPlay() { override fun onPlay() {
play() play()
@@ -85,6 +95,23 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
override fun onSeekBy(seconds: Double) { override fun onSeekBy(seconds: Double) {
seekBy(seconds) 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 // Renderer is created lazily in loadVideo once we have the voDriver setting
@@ -102,13 +129,10 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
try { try {
renderer?.start(voDriver ?: "gpu-next") renderer?.start(voDriver ?: "gpu-next")
rendererStarted = true rendererStarted = true
Log.i(TAG, "Renderer started with vo=$voDriver")
// If surface was created before renderer started, attach it now
pendingSurface?.let { surface -> pendingSurface?.let { surface ->
renderer?.attachSurface(surface) renderer?.attachSurface(surface)
pendingSurface = null pendingSurface = null
Log.i(TAG, "Attached pending surface after renderer start")
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to start renderer: ${e.message}") Log.e(TAG, "Failed to start renderer: ${e.message}")
@@ -116,18 +140,18 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
} }
} }
// MARK: - SurfaceHolder.Callback // MARK: - TextureView.SurfaceTextureListener
override fun surfaceCreated(holder: SurfaceHolder) { override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
Log.i(TAG, "Surface created") this.surfaceTexture = surfaceTexture
val surface = Surface(surfaceTexture)
surfaceTexture.setDefaultBufferSize(width, height)
surfaceReady = true surfaceReady = true
if (rendererStarted) { if (rendererStarted) {
renderer?.attachSurface(holder.surface) renderer?.attachSurface(surface)
} else { } else {
// Renderer not started yet - store surface to attach after start pendingSurface = surface
pendingSurface = holder.surface
Log.i(TAG, "Surface created before renderer started, storing as pending")
} }
// If we have a pending load, execute it now // If we have a pending load, execute it now
@@ -138,16 +162,20 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
} }
} }
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
Log.i(TAG, "Surface changed: ${width}x${height}") surfaceTexture.setDefaultBufferSize(width, height)
// Update MPV with the new surface size (Findroid approach)
renderer?.updateSurfaceSize(width, height) renderer?.updateSurfaceSize(width, height)
} }
override fun surfaceDestroyed(holder: SurfaceHolder) { override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
Log.i(TAG, "Surface destroyed") this.surfaceTexture = null
surfaceReady = false surfaceReady = false
renderer?.detachSurface() 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 // MARK: - Video Loading
@@ -239,11 +267,60 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
// MARK: - Picture in Picture // MARK: - Picture in Picture
fun startPictureInPicture() { fun startPictureInPicture() {
Log.i(TAG, "startPictureInPicture called") isWaitingForPiPTransition = true
pipController?.startPictureInPicture() 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() { fun stopPictureInPicture() {
isWaitingForPiPTransition = false
pipHandler.removeCallbacksAndMessages(null)
pipController?.stopPictureInPicture() pipController?.stopPictureInPicture()
} }
@@ -368,7 +445,6 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
} }
override fun onPauseChanged(isPaused: Boolean) { override fun onPauseChanged(isPaused: Boolean) {
// Sync PiP playback rate
pipController?.setPlaybackRate(if (isPaused) 0.0 else 1.0) pipController?.setPlaybackRate(if (isPaused) 0.0 else 1.0)
onPlaybackStateChange(mapOf( onPlaybackStateChange(mapOf(
@@ -394,7 +470,6 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
} }
override fun onVideoDimensionsChanged(width: Int, height: Int) { override fun onVideoDimensionsChanged(width: Int, height: Int) {
// Update PiP controller with video dimensions for proper aspect ratio
pipController?.setVideoDimensions(width, height) pipController?.setVideoDimensions(width, height)
} }
@@ -405,9 +480,12 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
// MARK: - Cleanup // MARK: - Cleanup
fun cleanup() { fun cleanup() {
isWaitingForPiPTransition = false
pipHandler.removeCallbacksAndMessages(null)
pipController?.stopPictureInPicture() pipController?.stopPictureInPicture()
renderer?.stop() renderer?.stop()
surfaceView.holder.removeCallback(this) surfaceTexture = null
surfaceReady = false
} }
override fun onDetachedFromWindow() { override fun onDetachedFromWindow() {

View File

@@ -1,33 +1,42 @@
package expo.modules.mpvplayer package expo.modules.mpvplayer
import android.app.Activity import android.app.Activity
import android.app.Application
import android.app.PictureInPictureParams import android.app.PictureInPictureParams
import android.app.RemoteAction
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.drawable.Icon
import android.graphics.Rect import android.graphics.Rect
import android.os.Build import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log import android.util.Log
import android.util.Rational import android.util.Rational
import android.view.View import android.view.View
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import expo.modules.kotlin.AppContext 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) { class PiPController(private val context: Context, private val appContext: AppContext? = null) {
companion object { companion object {
private const val TAG = "PiPController" private const val TAG = "PiPController"
private const val DEFAULT_ASPECT_WIDTH = 16 private const val DEFAULT_ASPECT_WIDTH = 16
private const val DEFAULT_ASPECT_HEIGHT = 9 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 { interface Delegate {
fun onPlay() fun onPlay()
fun onPause() fun onPause()
fun onSeekBy(seconds: Double) fun onSeekBy(seconds: Double)
fun onPictureInPictureModeChanged(isInPiP: Boolean)
} }
var delegate: Delegate? = null var delegate: Delegate? = null
@@ -36,16 +45,18 @@ class PiPController(private val context: Context, private val appContext: AppCon
private var currentDuration: Double = 0.0 private var currentDuration: Double = 0.0
private var playbackRate: Double = 1.0 private var playbackRate: Double = 1.0
// Video dimensions for proper aspect ratio
private var videoWidth: Int = 0 private var videoWidth: Int = 0
private var videoHeight: Int = 0 private var videoHeight: Int = 0
// Reference to the player view for source rect
private var playerView: View? = null private var playerView: View? = null
/** // PiP state tracking
* Check if Picture-in-Picture is supported on this device 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 { fun isPictureInPictureSupported(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
@@ -54,9 +65,6 @@ class PiPController(private val context: Context, private val appContext: AppCon
} }
} }
/**
* Check if Picture-in-Picture is currently active
*/
fun isPictureInPictureActive(): Boolean { fun isPictureInPictureActive(): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = getActivity() val activity = getActivity()
@@ -65,14 +73,11 @@ class PiPController(private val context: Context, private val appContext: AppCon
return false return false
} }
/**
* Start Picture-in-Picture mode
*/
fun startPictureInPicture() { fun startPictureInPicture() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val activity = getActivity()
if (activity == null) { val activity = getActivity() ?: run {
Log.e(TAG, "Cannot start PiP: no activity found") Log.e(TAG, "Cannot start PiP: no activity")
return return
} }
@@ -83,50 +88,53 @@ class PiPController(private val context: Context, private val appContext: AppCon
try { try {
val params = buildPiPParams(forEntering = true) val params = buildPiPParams(forEntering = true)
activity.enterPictureInPictureMode(params) val result = activity.enterPictureInPictureMode(params)
Log.i(TAG, "Entered PiP mode")
if (!result) {
Log.e(TAG, "enterPictureInPictureMode rejected by system")
isInPiPMode = false
return
}
isInPiPMode = true
pipEntryNotified = true
delegate?.onPictureInPictureModeChanged(true)
registerLifecycleCallbacks()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to enter PiP: ${e.message}") Log.e(TAG, "Failed to enter PiP: ${e.message}")
} }
} else {
Log.w(TAG, "PiP requires Android O or higher")
}
} }
/**
* Stop Picture-in-Picture mode
*/
fun stopPictureInPicture() { fun stopPictureInPicture() {
// On Android, exiting PiP is typically done by the user isInPiPMode = false
// or by finishing the activity. We can request to move task to back. pipEntryNotified = false
unregisterLifecycleCallbacks()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = getActivity() val activity = getActivity()
if (activity?.isInPictureInPictureMode == true) { if (activity?.isInPictureInPictureMode == true) {
// Move task to back which will exit PiP
activity.moveTaskToBack(false) activity.moveTaskToBack(false)
} }
} }
} }
/** fun isCurrentlyInPiP(): Boolean = isInPiPMode
* 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 setCurrentTime(position: Double, duration: Double) { fun setCurrentTime(position: Double, duration: Double) {
currentPosition = position currentPosition = position
currentDuration = duration currentDuration = duration
} }
/**
* Set the playback rate (0.0 for paused, 1.0 for playing)
*/
fun setPlaybackRate(rate: Double) { fun setPlaybackRate(rate: Double) {
playbackRate = rate 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) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = getActivity() val activity = getActivity()
if (activity?.isInPictureInPictureMode == true) { if (activity != null) {
try { try {
activity.setPictureInPictureParams(buildPiPParams()) activity.setPictureInPictureParams(buildPiPParams())
} catch (e: Exception) { } catch (e: Exception) {
@@ -136,23 +144,14 @@ 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) { fun setVideoDimensions(width: Int, height: Int) {
if (width > 0 && height > 0) { if (width > 0 && height > 0) {
videoWidth = width videoWidth = width
videoHeight = height videoHeight = height
Log.i(TAG, "Video dimensions set: ${width}x${height}")
// Update PiP params if active
updatePiPParamsIfNeeded() updatePiPParamsIfNeeded()
} }
} }
/**
* Set the player view reference for source rect hint
*/
fun setPlayerView(view: View?) { fun setPlayerView(view: View?) {
playerView = view playerView = view
} }
@@ -170,22 +169,15 @@ 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) @RequiresApi(Build.VERSION_CODES.O)
private fun buildPiPParams(forEntering: Boolean = false): PictureInPictureParams { private fun buildPiPParams(forEntering: Boolean = false): PictureInPictureParams {
val view = playerView val view = playerView
val viewWidth = view?.width ?: 0 val viewWidth = view?.width ?: 0
val viewHeight = view?.height ?: 0 val viewHeight = view?.height ?: 0
// Display aspect ratio from view (exactly like Findroid)
val displayAspectRatio = Rational(viewWidth.coerceAtLeast(1), viewHeight.coerceAtLeast(1)) val displayAspectRatio = Rational(viewWidth.coerceAtLeast(1), viewHeight.coerceAtLeast(1))
// Video aspect ratio with 2.39:1 clamping (exactly like Findroid) // Video aspect ratio with 2.39:1 clamping
// Findroid: Rational(it.width.coerceAtMost((it.height * 2.39f).toInt()),
// it.height.coerceAtMost((it.width * 2.39f).toInt()))
val aspectRatio = if (videoWidth > 0 && videoHeight > 0) { val aspectRatio = if (videoWidth > 0 && videoHeight > 0) {
Rational( Rational(
videoWidth.coerceAtMost((videoHeight * 2.39f).toInt()), videoWidth.coerceAtMost((videoHeight * 2.39f).toInt()),
@@ -195,26 +187,13 @@ class PiPController(private val context: Context, private val appContext: AppCon
Rational(DEFAULT_ASPECT_WIDTH, DEFAULT_ASPECT_HEIGHT) 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) { val sourceRectHint = if (viewWidth > 0 && viewHeight > 0 && videoWidth > 0 && videoHeight > 0) {
if (displayAspectRatio < aspectRatio) { if (displayAspectRatio < aspectRatio) {
// Letterboxing - black bars top/bottom
val space = ((viewHeight - (viewWidth.toFloat() / aspectRatio.toFloat())) / 2).toInt() val space = ((viewHeight - (viewWidth.toFloat() / aspectRatio.toFloat())) / 2).toInt()
Rect( Rect(0, space, viewWidth, (viewWidth.toFloat() / aspectRatio.toFloat()).toInt() + space)
0,
space,
viewWidth,
(viewWidth.toFloat() / aspectRatio.toFloat()).toInt() + space
)
} else { } else {
// Pillarboxing - black bars left/right
val space = ((viewWidth - (viewHeight.toFloat() * aspectRatio.toFloat())) / 2).toInt() val space = ((viewWidth - (viewHeight.toFloat() * aspectRatio.toFloat())) / 2).toInt()
Rect( Rect(space, 0, (viewHeight.toFloat() * aspectRatio.toFloat()).toInt() + space, viewHeight)
space,
0,
(viewHeight.toFloat() * aspectRatio.toFloat()).toInt() + space,
viewHeight
)
} }
} else { } else {
null null
@@ -225,39 +204,217 @@ class PiPController(private val context: Context, private val appContext: AppCon
sourceRectHint?.let { builder.setSourceRectHint(it) } 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) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
builder.setAutoEnterEnabled(true) builder.setAutoEnterEnabled(forEntering || playbackRate > 0)
} }
return builder.build() return builder.build()
} }
private fun getActivity(): Activity? { private fun getActivity(): Activity? {
// First try Expo's AppContext (preferred in React Native)
appContext?.currentActivity?.let { return it } appContext?.currentActivity?.let { return it }
// Fallback: Try to get from context wrapper chain
var ctx = context var ctx = context
while (ctx is android.content.ContextWrapper) { while (ctx is android.content.ContextWrapper) {
if (ctx is Activity) { if (ctx is Activity) return ctx
return ctx
}
ctx = ctx.baseContext ctx = ctx.baseContext
} }
return null return null
} }
/** // MARK: - Lifecycle-based PiP Detection
* Handle PiP action (called from activity when user taps PiP controls)
*/ private fun registerLifecycleCallbacks() {
fun handlePiPAction(action: String) { if (lifecycleRegistered) return
when (action) {
"play" -> delegate?.onPlay() val app = context.applicationContext as? Application ?: run {
"pause" -> delegate?.onPause() Log.w(TAG, "Cannot access Application for lifecycle callbacks, falling back to polling")
"skip_forward" -> delegate?.onSeekBy(10.0) startFallbackPolling()
"skip_backward" -> delegate?.onSeekBy(-10.0) 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

@@ -1,32 +1,19 @@
Pod::Spec.new do |s| Pod::Spec.new do |s|
s.name = 'MpvPlayer' s.name = 'MpvPlayer'
s.version = '1.0.0' s.version = '1.0.0'
s.summary = 'MPVKit for Expo' s.summary = 'MPV-based video player for Streamyfin (Expo module)'
s.description = 'MPVKit for Expo' s.author = 'Streamyfin'
s.author = 'mpvkit' s.homepage = 'https://github.com/streamyfin/streamyfin'
s.homepage = 'https://github.com/mpvkit/MPVKit' s.platforms = { :ios => '15.1', :tvos => '15.1' }
s.platforms = { s.source = { git: '' }
:ios => '15.1',
:tvos => '15.1'
}
s.source = { git: 'https://github.com/mpvkit/MPVKit.git' }
s.static_framework = true s.static_framework = true
s.dependency 'ExpoModulesCore' s.dependency 'ExpoModulesCore'
s.dependency 'MPVKit-GPL' s.dependency 'MPVKit'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = { s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES', 'DEFINES_MODULE' => 'YES',
'VALID_ARCHS' => 'arm64', 'SWIFT_COMPILATION_MODE' => 'wholemodule'
'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386',
'DEBUG_INFORMATION_FORMAT' => 'dwarf',
'STRIP_INSTALLED_PRODUCT' => 'YES',
'DEPLOYMENT_POSTPROCESSING' => 'YES',
}
s.user_target_xcconfig = {
'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386'
} }
s.source_files = "*.{h,m,mm,swift,hpp,cpp}" s.source_files = "*.{h,m,mm,swift,hpp,cpp}"

View File

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

View File

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

View File

@@ -89,14 +89,14 @@
"react-native-device-info": "^15.0.0", "react-native-device-info": "^15.0.0",
"react-native-draggable-flatlist": "^4.0.3", "react-native-draggable-flatlist": "^4.0.3",
"react-native-edge-to-edge": "^1.7.0", "react-native-edge-to-edge": "^1.7.0",
"react-native-gesture-handler": "~2.31.1", "react-native-gesture-handler": "~3.0.0",
"react-native-glass-effect-view": "^1.0.0", "react-native-glass-effect-view": "^1.0.0",
"react-native-google-cast": "^4.9.1", "react-native-google-cast": "^4.9.1",
"react-native-image-colors": "^2.4.0", "react-native-image-colors": "^2.4.0",
"react-native-ios-context-menu": "^3.2.1", "react-native-ios-context-menu": "^3.2.1",
"react-native-ios-utilities": "5.2.0", "react-native-ios-utilities": "5.2.0",
"react-native-mmkv": "4.1.1", "react-native-mmkv": "4.1.1",
"react-native-nitro-modules": "0.33.9", "react-native-nitro-modules": "0.33.1",
"react-native-pager-view": "8.0.1", "react-native-pager-view": "8.0.1",
"react-native-qrcode-svg": "^6.3.21", "react-native-qrcode-svg": "^6.3.21",
"react-native-reanimated": "4.3.1", "react-native-reanimated": "4.3.1",