Compare commits

..

1 Commits

Author SHA1 Message Date
renovate[bot]
80e031a4c4 chore(deps): Update dependency sonner-native to v0.25.1 2026-05-30 20:46:18 +00:00
22 changed files with 322 additions and 744 deletions

View File

@@ -1,132 +0,0 @@
name: 🚀 Release (EAS Build + Submit)
# Cloud EAS build + auto-submit for iOS, tvOS and Android on merge to main.
# A manual approval gate (the `production` GitHub Environment) pauses the run
# before any build/submit starts. Configure required reviewers on that
# environment in repo Settings → Environments → production.
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
on:
push:
branches: [main]
workflow_dispatch:
jobs:
approve:
name: 🔐 Approve release
runs-on: ubuntu-24.04
environment: production
steps:
- name: ✅ Release approved
run: echo "Release approved for ${{ github.sha }}"
release:
name: 🚀 ${{ matrix.name }}
needs: approve
runs-on: ubuntu-24.04
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- name: 🍎 iOS
platform: ios
profile: production
- name: 📺 tvOS
platform: ios
profile: production_tv
- name: 🤖 Android
platform: android
profile: production
steps:
- name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache
- name: 📦 Install dependencies and reload submodules
run: |
bun install --frozen-lockfile
bun run submodule-reload
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
eas-cache: true
# tvOS uses local credentials (EAS can't manage tvOS provisioning
# remotely, including the TopShelf extension target). Restore the
# gitignored credentials.json + cert + profiles from secrets so the
# cloud build can sign with `credentialsSource: local`.
- name: 🔐 Restore tvOS signing credentials
if: matrix.profile == 'production_tv'
env:
EAS_CREDENTIALS_JSON: ${{ secrets.EAS_CREDENTIALS_JSON }}
TVOS_DIST_CERT_P12_BASE64: ${{ secrets.TVOS_DIST_CERT_P12_BASE64 }}
TVOS_APP_PROFILE_BASE64: ${{ secrets.TVOS_APP_PROFILE_BASE64 }}
TVOS_TOPSHELF_PROFILE_BASE64: ${{ secrets.TVOS_TOPSHELF_PROFILE_BASE64 }}
run: |
mkdir -p certs profiles
printf '%s' "$EAS_CREDENTIALS_JSON" > credentials.json
echo "$TVOS_DIST_CERT_P12_BASE64" | base64 -d > certs/distribution.p12
echo "$TVOS_APP_PROFILE_BASE64" | base64 -d > profiles/Streamyfin_tvOS_App_Store.mobileprovision
echo "$TVOS_TOPSHELF_PROFILE_BASE64" | base64 -d > profiles/Streamyfin_TopShelf_tvOS_App_Store.mobileprovision
# iOS + tvOS submit upload to App Store Connect with an ASC API key.
# EAS reads it from EXPO_ASC_API_KEY_PATH / EXPO_ASC_KEY_ID /
# EXPO_ASC_ISSUER_ID (set on the build step below). Write the .p8,
# tolerating either raw-PEM or base64-encoded secret content.
- name: 🔐 Restore App Store Connect API key
if: matrix.platform == 'ios'
env:
APPLE_KEY_CONTENT: ${{ secrets.APPLE_KEY_CONTENT }}
run: |
if printf '%s' "$APPLE_KEY_CONTENT" | grep -q "BEGIN PRIVATE KEY"; then
printf '%s' "$APPLE_KEY_CONTENT" > "$RUNNER_TEMP/asc_api_key.p8"
else
printf '%s' "$APPLE_KEY_CONTENT" | base64 -d > "$RUNNER_TEMP/asc_api_key.p8"
fi
# Android submit needs a Google Play service account JSON. eas.json's
# submit.production.android.serviceAccountKeyPath points at this file.
- name: 🔐 Restore Google Play service account
if: matrix.platform == 'android'
env:
GOOGLE_SERVICE_ACCOUNT_KEY: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_KEY }}
run: printf '%s' "$GOOGLE_SERVICE_ACCOUNT_KEY" > google-service-account.json
- name: 🚀 Build & submit (${{ matrix.name }})
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
# Consumed by eas submit for iOS/tvOS; ignored for Android.
EXPO_ASC_API_KEY_PATH: ${{ runner.temp }}/asc_api_key.p8
EXPO_ASC_KEY_ID: ${{ secrets.APPLE_KEY_ID }}
EXPO_ASC_ISSUER_ID: ${{ secrets.APPLE_KEY_ISSUER_ID }}
run: |
eas build \
--platform ${{ matrix.platform }} \
--profile ${{ matrix.profile }} \
--auto-submit \
--non-interactive

3
.gitignore vendored
View File

@@ -76,6 +76,3 @@ build/
.claude/ .claude/
.agents/skills/** .agents/skills/**
skills-lock.json skills-lock.json
# CI-injected Google Play service account key (written at build time)
google-service-account.json

View File

@@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "Streamyfin", "name": "Streamyfin",
"slug": "streamyfin", "slug": "streamyfin",
"version": "0.54.1", "version": "0.54.0",
"orientation": "default", "orientation": "default",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "streamyfin", "scheme": "streamyfin",
@@ -36,6 +36,7 @@
"appleTeamId": "MWD5K362T8" "appleTeamId": "MWD5K362T8"
}, },
"android": { "android": {
"versionCode": 93,
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/images/icon-android-plain.png", "foregroundImage": "./assets/images/icon-android-plain.png",
"monochromeImage": "./assets/images/icon-android-themed.png", "monochromeImage": "./assets/images/icon-android-themed.png",
@@ -143,8 +144,8 @@
[ [
"./plugins/withGitPod.js", "./plugins/withGitPod.js",
{ {
"podName": "MPVKit", "podName": "MPVKit-GPL",
"podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec" "podspecUrl": "https://raw.githubusercontent.com/streamyfin/MPVKit/0.40.0-av/MPVKit-GPL.podspec"
} }
] ]
], ],

View File

@@ -825,10 +825,12 @@ 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);
} }
@@ -846,9 +848,6 @@ 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?.();
}, []); }, []);
@@ -1254,7 +1253,6 @@ 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

@@ -1,7 +1,10 @@
diff --git a/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift b/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift diff --git a/node_modules/react-native-bottom-tabs/.bun-tag-b32ab1c60a5dfcf7 b/.bun-tag-b32ab1c60a5dfcf7
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/ios/BottomAccessoryProvider.swift b/ios/BottomAccessoryProvider.swift
index 539efee7156599e1fc795e11bf411b7dfaf12ec7..b2af39a2e6b014e9b1ae0a51b21115c19280df69 100644 index 539efee7156599e1fc795e11bf411b7dfaf12ec7..b2af39a2e6b014e9b1ae0a51b21115c19280df69 100644
--- a/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift --- a/ios/BottomAccessoryProvider.swift
+++ b/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift +++ b/ios/BottomAccessoryProvider.swift
@@ -8,7 +8,7 @@ import SwiftUI @@ -8,7 +8,7 @@ import SwiftUI
self.delegate = delegate self.delegate = delegate
} }
@@ -11,10 +14,10 @@ index 539efee7156599e1fc795e11bf411b7dfaf12ec7..b2af39a2e6b014e9b1ae0a51b21115c1
@available(iOS 26.0, *) @available(iOS 26.0, *)
public func emitPlacementChanged(_ placement: TabViewBottomAccessoryPlacement?) { public func emitPlacementChanged(_ placement: TabViewBottomAccessoryPlacement?) {
var placementValue = "none" var placementValue = "none"
diff --git a/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift b/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift diff --git a/ios/TabView/NewTabView.swift b/ios/TabView/NewTabView.swift
index 22c52cdf25ad0f7398d89197cb431ca8dc8e0f99..81411376e68803de8bd83515d42565cfa95daf2b 100644 index 22c52cdf25ad0f7398d89197cb431ca8dc8e0f99..81411376e68803de8bd83515d42565cfa95daf2b 100644
--- a/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift --- a/ios/TabView/NewTabView.swift
+++ b/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift +++ b/ios/TabView/NewTabView.swift
@@ -78,11 +78,11 @@ struct ConditionalBottomAccessoryModifier: ViewModifier { @@ -78,11 +78,11 @@ struct ConditionalBottomAccessoryModifier: ViewModifier {
} }
@@ -53,10 +56,10 @@ index 22c52cdf25ad0f7398d89197cb431ca8dc8e0f99..81411376e68803de8bd83515d42565cf
} }
#endif #endif
+ +
diff --git a/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift b/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift diff --git a/ios/TabViewImpl.swift b/ios/TabViewImpl.swift
index 72938be90540ea3a483d7db9a80fb74c04d31272..277278ffdd9268a96cb09869eb1d0c0d5e6ad300 100644 index 72938be90540ea3a483d7db9a80fb74c04d31272..277278ffdd9268a96cb09869eb1d0c0d5e6ad300 100644
--- a/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift --- a/ios/TabViewImpl.swift
+++ b/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift +++ b/ios/TabViewImpl.swift
@@ -281,7 +281,7 @@ extension View { @@ -281,7 +281,7 @@ extension View {
@ViewBuilder @ViewBuilder
@@ -66,10 +69,10 @@ index 72938be90540ea3a483d7db9a80fb74c04d31272..277278ffdd9268a96cb09869eb1d0c0d
if #available(iOS 26.0, macOS 26.0, *) { if #available(iOS 26.0, macOS 26.0, *) {
if let behavior { if let behavior {
self.tabBarMinimizeBehavior(behavior.convert()) self.tabBarMinimizeBehavior(behavior.convert())
diff --git a/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift b/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift diff --git a/ios/TabViewProps.swift b/ios/TabViewProps.swift
index 9cfb29a983b34d3f84fc7a678d19ef4ff30e0325..6a5854483e66200b71722bbac12e100742222bd3 100644 index 9cfb29a983b34d3f84fc7a678d19ef4ff30e0325..6a5854483e66200b71722bbac12e100742222bd3 100644
--- a/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift --- a/ios/TabViewProps.swift
+++ b/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift +++ b/ios/TabViewProps.swift
@@ -6,7 +6,7 @@ internal enum MinimizeBehavior: String { @@ -6,7 +6,7 @@ internal enum MinimizeBehavior: String {
case onScrollUp case onScrollUp
case onScrollDown case onScrollDown

View File

@@ -1,7 +1,7 @@
diff --git a/node_modules/react-native-ios-utilities/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift b/node_modules/react-native-ios-utilities/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift diff --git a/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift b/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
index 09be306d5aa39337c5114c2ad6ba7513218e0751..24ff8ee2c36fef8632a7e012514fd04db9bf89fd 100644 index 09be306d5aa39337c5114c2ad6ba7513218e0751..24ff8ee2c36fef8632a7e012514fd04db9bf89fd 100644
--- a/node_modules/react-native-ios-utilities/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift --- a/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
+++ b/node_modules/react-native-ios-utilities/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift +++ b/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
@@ -25,15 +25,14 @@ public extension RCTView { @@ -25,15 +25,14 @@ public extension RCTView {
return rootView.recursivelyFindSubview(whereType: targetType); return rootView.recursivelyFindSubview(whereType: targetType);
}; };

View File

@@ -1,7 +1,10 @@
diff --git a/node_modules/react-native-udp/react-native-udp.podspec b/node_modules/react-native-udp/react-native-udp.podspec diff --git a/node_modules/react-native-udp/.bun-tag-ea7df8754aa4db91 b/.bun-tag-ea7df8754aa4db91
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/react-native-udp.podspec b/react-native-udp.podspec
index 7450cc7d0862aadfb47d796929c801a3dc423a57..fa3e42c0152ef2d87536b8c2e484f64d525e35ec 100644 index 7450cc7d0862aadfb47d796929c801a3dc423a57..fa3e42c0152ef2d87536b8c2e484f64d525e35ec 100644
--- a/node_modules/react-native-udp/react-native-udp.podspec --- a/react-native-udp.podspec
+++ b/node_modules/react-native-udp/react-native-udp.podspec +++ b/react-native-udp.podspec
@@ -9,7 +9,8 @@ Pod::Spec.new do |s| @@ -9,7 +9,8 @@ Pod::Spec.new do |s|
s.homepage = package_json["homepage"] s.homepage = package_json["homepage"]
s.license = package_json["license"] s.license = package_json["license"]

View File

@@ -91,7 +91,7 @@
"react-native-volume-manager": "^2.0.8", "react-native-volume-manager": "^2.0.8",
"react-native-web": "^0.21.0", "react-native-web": "^0.21.0",
"react-native-worklets": "0.8.3", "react-native-worklets": "0.8.3",
"sonner-native": "0.21.2", "sonner-native": "0.25.1",
"tailwindcss": "3.3.2", "tailwindcss": "3.3.2",
"use-debounce": "^10.0.4", "use-debounce": "^10.0.4",
"zod": "4.4.3", "zod": "4.4.3",
@@ -114,6 +114,11 @@
}, },
}, },
}, },
"patchedDependencies": {
"react-native-ios-utilities@5.2.0": "bun-patches/react-native-ios-utilities@5.2.0.patch",
"react-native-udp@4.1.7": "bun-patches/react-native-udp@4.1.7.patch",
"react-native-bottom-tabs@1.2.0": "bun-patches/react-native-bottom-tabs@1.2.0.patch",
},
"packages": { "packages": {
"@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
@@ -1715,7 +1720,7 @@
"slugify": ["slugify@1.6.9", "", {}, "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg=="], "slugify": ["slugify@1.6.9", "", {}, "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg=="],
"sonner-native": ["sonner-native@0.21.2", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.16.1", "react-native-reanimated": ">=3.10.1", "react-native-safe-area-context": ">=4.10.5", "react-native-screens": ">=3.31.1", "react-native-svg": ">=15.6.0" } }, "sha512-LnGPmfgzrNIwcc+FvcLJqx8aH1dEHePRzvNR8aIR4kl9spySRkXK160GmQIazjfm6mSMlPqZwRa5eycvrzg/eQ=="], "sonner-native": ["sonner-native@0.25.1", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": "^2.28.0", "react-native-reanimated": "^4.1.1", "react-native-safe-area-context": "^5.6.0", "react-native-screens": "^4.16.0", "react-native-svg": "^15.12.1", "react-native-worklets": "^0.6.1 || ^0.7.0 || ^0.8.0" } }, "sha512-WyE5SjyRF8f845edmMw5hB89fkSrxpPRv6AjooVkN9SufF5n5jXSjIYa3+JI5UBthE0NeurbwHP3fzV3RehP0Q=="],
"source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="], "source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="],

View File

@@ -105,14 +105,14 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
maximumValue={max} maximumValue={max}
thumbWidth={0} thumbWidth={0}
onValueChange={handleValueChange} onValueChange={handleValueChange}
renderBubble={() => null}
renderThumb={() => null}
containerStyle={{ containerStyle={{
borderRadius: 50, borderRadius: 50,
}} }}
theme={{ theme={{
minimumTrackTintColor: "#FDFDFD", minimumTrackTintColor: "#FDFDFD",
maximumTrackTintColor: "#5A5A5A", maximumTrackTintColor: "#5A5A5A",
bubbleBackgroundColor: "transparent", // Hide the value bubble
bubbleTextColor: "transparent", // Hide the value text
}} }}
/> />
<Ionicons <Ionicons

View File

@@ -88,14 +88,14 @@ const BrightnessSlider = () => {
maximumValue={max} maximumValue={max}
thumbWidth={0} thumbWidth={0}
onValueChange={handleValueChange} onValueChange={handleValueChange}
renderBubble={() => null}
renderThumb={() => null}
containerStyle={{ containerStyle={{
borderRadius: 50, borderRadius: 50,
}} }}
theme={{ theme={{
minimumTrackTintColor: "#FDFDFD", minimumTrackTintColor: "#FDFDFD",
maximumTrackTintColor: "#5A5A5A", maximumTrackTintColor: "#5A5A5A",
bubbleBackgroundColor: "transparent", // Hide the value bubble
bubbleTextColor: "transparent", // Hide the value text
}} }}
/> />
<Ionicons <Ionicons

View File

@@ -1,7 +1,6 @@
{ {
"cli": { "cli": {
"version": ">= 9.1.0", "version": ">= 9.1.0"
"appVersionSource": "remote"
}, },
"build": { "build": {
"development": { "development": {
@@ -53,14 +52,14 @@
}, },
"production": { "production": {
"environment": "production", "environment": "production",
"autoIncrement": true, "channel": "0.54.0",
"android": { "android": {
"image": "latest" "image": "latest"
} }
}, },
"production-apk": { "production-apk": {
"environment": "production", "environment": "production",
"autoIncrement": true, "channel": "0.54.0",
"android": { "android": {
"buildType": "apk", "buildType": "apk",
"image": "latest" "image": "latest"
@@ -68,7 +67,7 @@
}, },
"production-apk-tv": { "production-apk-tv": {
"environment": "production", "environment": "production",
"autoIncrement": true, "channel": "0.54.0",
"android": { "android": {
"buildType": "apk", "buildType": "apk",
"image": "latest" "image": "latest"
@@ -79,7 +78,7 @@
}, },
"production_tv": { "production_tv": {
"environment": "production", "environment": "production",
"autoIncrement": true, "channel": "0.54.0",
"env": { "env": {
"EXPO_TV": "1" "EXPO_TV": "1"
}, },
@@ -89,22 +88,7 @@
} }
}, },
"submit": { "submit": {
"production": { "production": {},
"ios": { "production_tv": {}
"appleTeamId": "MWD5K362T8",
"ascAppId": "6593660679"
},
"android": {
"serviceAccountKeyPath": "./google-service-account.json",
"track": "internal",
"releaseStatus": "completed"
}
},
"production_tv": {
"ios": {
"appleTeamId": "MWD5K362T8",
"ascAppId": "6593660679"
}
}
} }
} }

View File

@@ -236,43 +236,37 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
} }
/** /**
* Attach surface and ensure video output is active. * Attach surface and re-enable video output.
* * 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")
// Read back vo to confirm it's still active MPVLib.setOptionString("vo", voDriver)
val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null } Log.i(TAG, "Surface attached, video output re-enabled (vo=$voDriver)")
Log.i(TAG, "[PiP] attachSurface — attached, activeVo=$activeVo")
} }
} }
/** /**
* Detach surface without killing the VO pipeline. * Detach surface and disable video output.
* * 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)")
} }
} }
@@ -283,24 +277,7 @@ 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, "[PiP] updateSurfaceSize ${width}x${height}") Log.i(TAG, "Surface size updated: ${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"))
} }
} }
@@ -715,7 +692,9 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
// dropped), so we (re)apply here for embedded and external alike. // dropped), so we (re)apply here for embedded and external alike.
// This is what makes a carried-over subtitle show up on the next // This is what makes a carried-over subtitle show up on the next
// episode without a manual re-selection. // episode without a manual re-selection.
initialAudioId?.let { if (it > 0) setAudioTrack(it) } if (initialAudioId != null && initialAudioId > 0) {
setAudioTrack(initialAudioId)
}
initialSubtitleId?.let { setSubtitleTrack(it) } ?: disableSubtitles() initialSubtitleId?.let { setSubtitleTrack(it) } ?: disableSubtitles()
if (!isReadyToSeek) { if (!isReadyToSeek) {

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", "onPictureInPictureChange") Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady")
} }
} }
} }

View File

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

View File

@@ -1,62 +1,51 @@
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
private var currentPosition: Double = 0.0 private var currentPosition: Double = 0.0
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 /**
private var isInPiPMode: Boolean = false * Check if Picture-in-Picture is supported on this device
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)
@@ -64,7 +53,10 @@ class PiPController(private val context: Context, private val appContext: AppCon
false false
} }
} }
/**
* 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()
@@ -72,69 +64,69 @@ 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) return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = getActivity()
val activity = getActivity() ?: run { if (activity == null) {
Log.e(TAG, "Cannot start PiP: no activity") Log.e(TAG, "Cannot start PiP: no activity found")
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 return
} }
isInPiPMode = true if (!isPictureInPictureSupported()) {
pipEntryNotified = true Log.e(TAG, "PiP not supported on this device")
delegate?.onPictureInPictureModeChanged(true) return
registerLifecycleCallbacks() }
} catch (e: Exception) {
Log.e(TAG, "Failed to enter PiP: ${e.message}") 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")
} }
} }
/**
* Stop Picture-in-Picture mode
*/
fun stopPictureInPicture() { fun stopPictureInPicture() {
isInPiPMode = false // On Android, exiting PiP is typically done by the user
pipEntryNotified = false // or by finishing the activity. We can request to move task to back.
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
if (rate > 0) { // Update PiP params to reflect play/pause state
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 != null) { if (activity?.isInPictureInPictureMode == true) {
try { try {
activity.setPictureInPictureParams(buildPiPParams()) activity.setPictureInPictureParams(buildPiPParams())
} catch (e: Exception) { } catch (e: Exception) {
@@ -143,19 +135,28 @@ 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
} }
private fun updatePiPParamsIfNeeded() { private fun updatePiPParamsIfNeeded() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = getActivity() val activity = getActivity()
@@ -168,16 +169,23 @@ 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 // 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()))
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()),
@@ -186,235 +194,70 @@ class PiPController(private val context: Context, private val appContext: AppCon
} else { } else {
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(0, space, viewWidth, (viewWidth.toFloat() / aspectRatio.toFloat()).toInt() + space) Rect(
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(space, 0, (viewHeight.toFloat() * aspectRatio.toFloat()).toInt() + space, viewHeight) Rect(
space,
0,
(viewHeight.toFloat() * aspectRatio.toFloat()).toInt() + space,
viewHeight
)
} }
} else { } else {
null null
} }
val builder = PictureInPictureParams.Builder() val builder = PictureInPictureParams.Builder()
.setAspectRatio(aspectRatio) .setAspectRatio(aspectRatio)
sourceRectHint?.let { builder.setSourceRectHint(it) } sourceRectHint?.let { builder.setSourceRectHint(it) }
ensurePiPReceiverRegistered() // On Android 12+, enable auto-enter (like Findroid)
builder.setActions(buildPiPActions())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
builder.setAutoEnterEnabled(forEntering || playbackRate > 0) builder.setAutoEnterEnabled(true)
} }
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) return ctx if (ctx is Activity) {
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() { */
if (lifecycleRegistered) return fun handlePiPAction(action: String) {
when (action) {
val app = context.applicationContext as? Application ?: run { "play" -> delegate?.onPlay()
Log.w(TAG, "Cannot access Application for lifecycle callbacks, falling back to polling") "pause" -> delegate?.onPause()
startFallbackPolling() "skip_forward" -> delegate?.onSeekBy(10.0)
return "skip_backward" -> delegate?.onSeekBy(-10.0)
} }
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,19 +1,32 @@
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 = 'MPV-based video player for Streamyfin (Expo module)' s.summary = 'MPVKit for Expo'
s.author = 'Streamyfin' s.description = 'MPVKit for Expo'
s.homepage = 'https://github.com/streamyfin/streamyfin' s.author = 'mpvkit'
s.platforms = { :ios => '15.1', :tvos => '15.1' } s.homepage = 'https://github.com/mpvkit/MPVKit'
s.source = { git: '' } s.platforms = {
: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' s.dependency 'MPVKit-GPL'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = { s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES', 'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule' 'VALID_ARCHS' => 'arm64',
'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,10 +25,6 @@ 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;
@@ -81,9 +77,6 @@ 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,8 +7,6 @@ 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);
@@ -42,24 +40,16 @@ 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 () => {
const result = await nativeRef.current?.isPictureInPictureSupported(); return await nativeRef.current?.isPictureInPictureSupported();
console.log(PIP_LOG, "isPictureInPictureSupported =", result);
return result;
}, },
isPictureInPictureActive: async () => { isPictureInPictureActive: async () => {
const result = await nativeRef.current?.isPictureInPictureActive(); return 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

@@ -112,7 +112,7 @@
"react-native-volume-manager": "^2.0.8", "react-native-volume-manager": "^2.0.8",
"react-native-web": "^0.21.0", "react-native-web": "^0.21.0",
"react-native-worklets": "0.8.3", "react-native-worklets": "0.8.3",
"sonner-native": "0.21.2", "sonner-native": "0.25.1",
"tailwindcss": "3.3.2", "tailwindcss": "3.3.2",
"use-debounce": "^10.0.4", "use-debounce": "^10.0.4",
"zod": "4.4.3" "zod": "4.4.3"
@@ -162,5 +162,10 @@
}, },
"trustedDependencies": [ "trustedDependencies": [
"unrs-resolver" "unrs-resolver"
] ],
"patchedDependencies": {
"react-native-udp@4.1.7": "bun-patches/react-native-udp@4.1.7.patch",
"react-native-bottom-tabs@1.2.0": "bun-patches/react-native-bottom-tabs@1.2.0.patch",
"react-native-ios-utilities@5.2.0": "bun-patches/react-native-ios-utilities@5.2.0.patch"
}
} }

View File

@@ -39,28 +39,6 @@ function buildPatch() {
" end", " end",
" end", " end",
"", "",
" # iOS 26 / Xcode 26: the APP target itself compiles ExpoModulesProvider.swift,",
" # which imports SwiftUI-based modules (ExpoUI, ExpoGlassEffect, GlassPoster, ExpoBlur, …).",
" # That emits a `-framework SwiftUICore` autolink into the app executable's OWN object",
" # files, so the pods-only flag above is not enough — the app's link still fails with",
" # `cannot link directly with 'SwiftUICore'`. Drop the autolink on the user app target",
" # too. Phone-only — tvOS has no SwiftUICore split and must stay untouched.",
" if ENV['EXPO_TV'] != '1'",
" installer.aggregate_targets.each do |agg|",
" next unless agg.user_project",
" agg.user_project.native_targets.each do |target|",
" target.build_configurations.each do |cfg|",
" existing = cfg.build_settings['OTHER_SWIFT_FLAGS'] || '$(inherited)'",
" existing = existing.join(' ') if existing.is_a?(Array)",
" unless existing.include?('-disable-autolink-framework -Xfrontend SwiftUICore')",
" cfg.build_settings['OTHER_SWIFT_FLAGS'] = existing + ' -Xfrontend -disable-autolink-framework -Xfrontend SwiftUICore'",
" end",
" end",
" end",
" agg.user_project.save",
" end",
" end",
"",
" # Safely patch RCTThirdPartyComponentsProvider.mm to avoid startup crash on unlinked Fabric components", " # Safely patch RCTThirdPartyComponentsProvider.mm to avoid startup crash on unlinked Fabric components",
' filepath = "#{installer.sandbox.root}/../build/generated/ios/ReactCodegen/RCTThirdPartyComponentsProvider.mm"', ' filepath = "#{installer.sandbox.root}/../build/generated/ios/ReactCodegen/RCTThirdPartyComponentsProvider.mm"',
" if File.exist?(filepath)", " if File.exist?(filepath)",

View File

@@ -53,7 +53,7 @@ const initialApi = (() => {
const id = getOrSetDeviceId(); const id = getOrSetDeviceId();
const deviceName = getDeviceNameSync(); const deviceName = getDeviceNameSync();
const jellyfinInstance = new Jellyfin({ const jellyfinInstance = new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.54.1" }, clientInfo: { name: "Streamyfin", version: "0.54.0" },
deviceInfo: { deviceInfo: {
name: deviceName, name: deviceName,
id, id,
@@ -128,7 +128,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const id = getOrSetDeviceId(); const id = getOrSetDeviceId();
const deviceName = getDeviceNameSync(); const deviceName = getDeviceNameSync();
return new Jellyfin({ return new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.54.1" }, clientInfo: { name: "Streamyfin", version: "0.54.0" },
deviceInfo: { deviceInfo: {
name: deviceName, name: deviceName,
id, id,
@@ -162,7 +162,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
return { return {
authorization: `MediaBrowser Client="Streamyfin", Device=${ authorization: `MediaBrowser Client="Streamyfin", Device=${
Platform.OS === "android" ? "Android" : "iOS" Platform.OS === "android" ? "Android" : "iOS"
}, DeviceId="${deviceId}", Version="0.54.1"`, }, DeviceId="${deviceId}", Version="0.54.0"`,
}; };
}, [deviceId]); }, [deviceId]);

View File

@@ -29,10 +29,6 @@
<string>$(MARKETING_VERSION)</string> <string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string> <string>$(CURRENT_PROJECT_VERSION)</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>NSExtension</key> <key>NSExtension</key>
<dict> <dict>
<key>NSExtensionPointIdentifier</key> <key>NSExtensionPointIdentifier</key>