mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-31 19:18:26 +01:00
Compare commits
11 Commits
renovate/p
...
renovate/x
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b8478512e | ||
|
|
fa1c3f3947 | ||
|
|
2761de5a74 | ||
|
|
feca1d7e9c | ||
|
|
6b6bfd1a89 | ||
|
|
d585b20f49 | ||
|
|
692ccfdb2c | ||
|
|
86e39c444c | ||
|
|
ed7928b4d3 | ||
|
|
27dc7b5664 | ||
|
|
a205c75895 |
8
.github/workflows/build-apps.yml
vendored
8
.github/workflows/build-apps.yml
vendored
@@ -219,7 +219,7 @@ jobs:
|
|||||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||||
xcode-version: "26.4"
|
xcode-version: "26.5"
|
||||||
|
|
||||||
- name: 🏗️ Setup EAS
|
- name: 🏗️ Setup EAS
|
||||||
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
||||||
@@ -284,7 +284,7 @@ jobs:
|
|||||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||||
xcode-version: "26.4"
|
xcode-version: "26.5"
|
||||||
|
|
||||||
- name: 🚀 Build iOS app
|
- name: 🚀 Build iOS app
|
||||||
env:
|
env:
|
||||||
@@ -344,7 +344,7 @@ jobs:
|
|||||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||||
xcode-version: "26.4"
|
xcode-version: "26.5"
|
||||||
|
|
||||||
- name: 🏗️ Setup EAS
|
- name: 🏗️ Setup EAS
|
||||||
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
||||||
@@ -412,7 +412,7 @@ jobs:
|
|||||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||||
xcode-version: "26.4"
|
xcode-version: "26.5"
|
||||||
|
|
||||||
- name: 🚀 Build iOS app
|
- name: 🚀 Build iOS app
|
||||||
env:
|
env:
|
||||||
|
|||||||
7
app.json
7
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.54.0",
|
"version": "0.54.1",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -36,7 +36,6 @@
|
|||||||
"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",
|
||||||
@@ -144,8 +143,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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
17
bun.lock
17
bun.lock
@@ -56,8 +56,8 @@
|
|||||||
"lodash": "4.18.1",
|
"lodash": "4.18.1",
|
||||||
"nativewind": "^2.0.11",
|
"nativewind": "^2.0.11",
|
||||||
"patch-package": "^8.0.0",
|
"patch-package": "^8.0.0",
|
||||||
"react": "19.2.6",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.6",
|
"react-dom": "19.2.3",
|
||||||
"react-i18next": "17.0.8",
|
"react-i18next": "17.0.8",
|
||||||
"react-native": "npm:react-native-tvos@0.85.3-0",
|
"react-native": "npm:react-native-tvos@0.85.3-0",
|
||||||
"react-native-awesome-slider": "^2.9.0",
|
"react-native-awesome-slider": "^2.9.0",
|
||||||
@@ -109,16 +109,11 @@
|
|||||||
"expo-doctor": "1.19.7",
|
"expo-doctor": "1.19.7",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"lint-staged": "17.0.5",
|
"lint-staged": "17.0.5",
|
||||||
"react-test-renderer": "19.2.6",
|
"react-test-renderer": "19.2.3",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"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=="],
|
||||||
|
|
||||||
@@ -1528,11 +1523,11 @@
|
|||||||
|
|
||||||
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||||
|
|
||||||
"react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="],
|
"react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="],
|
||||||
|
|
||||||
"react-devtools-core": ["react-devtools-core@6.1.5", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA=="],
|
"react-devtools-core": ["react-devtools-core@6.1.5", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA=="],
|
||||||
|
|
||||||
"react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="],
|
"react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
|
||||||
|
|
||||||
"react-fast-compare": ["react-fast-compare@3.2.2", "", {}, "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="],
|
"react-fast-compare": ["react-fast-compare@3.2.2", "", {}, "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="],
|
||||||
|
|
||||||
@@ -1618,7 +1613,7 @@
|
|||||||
|
|
||||||
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||||
|
|
||||||
"react-test-renderer": ["react-test-renderer@19.2.6", "", { "dependencies": { "react-is": "^19.2.6", "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-GbS6V23YduFTPiWJ5xICbKEjRcqx1Z90js/V5miqhz7qp/d6xSe9Dd6NjSQODFRdzdsqRMPW82E/sFpPRbY5Mw=="],
|
"react-test-renderer": ["react-test-renderer@19.2.3", "", { "dependencies": { "react-is": "^19.2.3", "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-TMR1LnSFiWZMJkCgNf5ATSvAheTT2NvKIwiVwdBPHxjBI7n/JbWd4gaZ16DVd9foAXdvDz+sB5yxZTwMjPRxpw=="],
|
||||||
|
|
||||||
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
|
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
25
eas.json
25
eas.json
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"cli": {
|
"cli": {
|
||||||
"version": ">= 9.1.0"
|
"version": ">= 9.1.0",
|
||||||
|
"appVersionSource": "remote"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"development": {
|
"development": {
|
||||||
@@ -52,14 +53,14 @@
|
|||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.54.0",
|
"autoIncrement": true,
|
||||||
"android": {
|
"android": {
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.54.0",
|
"autoIncrement": true,
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
@@ -67,7 +68,7 @@
|
|||||||
},
|
},
|
||||||
"production-apk-tv": {
|
"production-apk-tv": {
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.54.0",
|
"autoIncrement": true,
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
@@ -78,7 +79,7 @@
|
|||||||
},
|
},
|
||||||
"production_tv": {
|
"production_tv": {
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.54.0",
|
"autoIncrement": true,
|
||||||
"env": {
|
"env": {
|
||||||
"EXPO_TV": "1"
|
"EXPO_TV": "1"
|
||||||
},
|
},
|
||||||
@@ -88,7 +89,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"submit": {
|
"submit": {
|
||||||
"production": {},
|
"production": {
|
||||||
"production_tv": {}
|
"ios": {
|
||||||
|
"appleTeamId": "MWD5K362T8",
|
||||||
|
"ascAppId": "6593660679"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"production_tv": {
|
||||||
|
"ios": {
|
||||||
|
"appleTeamId": "MWD5K362T8",
|
||||||
|
"ascAppId": "6593660679"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -692,9 +715,7 @@ 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.
|
||||||
if (initialAudioId != null && initialAudioId > 0) {
|
initialAudioId?.let { if (it > 0) setAudioTrack(it) }
|
||||||
setAudioTrack(initialAudioId)
|
|
||||||
}
|
|
||||||
initialSubtitleId?.let { setSubtitleTrack(it) } ?: disableSubtitles()
|
initialSubtitleId?.let { setSubtitleTrack(it) } ?: disableSubtitles()
|
||||||
|
|
||||||
if (!isReadyToSeek) {
|
if (!isReadyToSeek) {
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
13
package.json
13
package.json
@@ -77,8 +77,8 @@
|
|||||||
"lodash": "4.18.1",
|
"lodash": "4.18.1",
|
||||||
"nativewind": "^2.0.11",
|
"nativewind": "^2.0.11",
|
||||||
"patch-package": "^8.0.0",
|
"patch-package": "^8.0.0",
|
||||||
"react": "19.2.6",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.6",
|
"react-dom": "19.2.3",
|
||||||
"react-i18next": "17.0.8",
|
"react-i18next": "17.0.8",
|
||||||
"react-native": "npm:react-native-tvos@0.85.3-0",
|
"react-native": "npm:react-native-tvos@0.85.3-0",
|
||||||
"react-native-awesome-slider": "^2.9.0",
|
"react-native-awesome-slider": "^2.9.0",
|
||||||
@@ -130,7 +130,7 @@
|
|||||||
"expo-doctor": "1.19.7",
|
"expo-doctor": "1.19.7",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"lint-staged": "17.0.5",
|
"lint-staged": "17.0.5",
|
||||||
"react-test-renderer": "19.2.6",
|
"react-test-renderer": "19.2.3",
|
||||||
"typescript": "5.9.3"
|
"typescript": "5.9.3"
|
||||||
},
|
},
|
||||||
"expo": {
|
"expo": {
|
||||||
@@ -162,10 +162,5 @@
|
|||||||
},
|
},
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
diff --git a/node_modules/react-native-bottom-tabs/.bun-tag-b32ab1c60a5dfcf7 b/.bun-tag-b32ab1c60a5dfcf7
|
diff --git a/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift b/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift
|
||||||
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/ios/BottomAccessoryProvider.swift
|
--- a/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift
|
||||||
+++ b/ios/BottomAccessoryProvider.swift
|
+++ b/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift
|
||||||
@@ -8,7 +8,7 @@ import SwiftUI
|
@@ -8,7 +8,7 @@ import SwiftUI
|
||||||
self.delegate = delegate
|
self.delegate = delegate
|
||||||
}
|
}
|
||||||
@@ -14,10 +11,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/ios/TabView/NewTabView.swift b/ios/TabView/NewTabView.swift
|
diff --git a/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift b/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift
|
||||||
index 22c52cdf25ad0f7398d89197cb431ca8dc8e0f99..81411376e68803de8bd83515d42565cfa95daf2b 100644
|
index 22c52cdf25ad0f7398d89197cb431ca8dc8e0f99..81411376e68803de8bd83515d42565cfa95daf2b 100644
|
||||||
--- a/ios/TabView/NewTabView.swift
|
--- a/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift
|
||||||
+++ b/ios/TabView/NewTabView.swift
|
+++ b/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift
|
||||||
@@ -78,11 +78,11 @@ struct ConditionalBottomAccessoryModifier: ViewModifier {
|
@@ -78,11 +78,11 @@ struct ConditionalBottomAccessoryModifier: ViewModifier {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,10 +53,10 @@ index 22c52cdf25ad0f7398d89197cb431ca8dc8e0f99..81411376e68803de8bd83515d42565cf
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
+
|
+
|
||||||
diff --git a/ios/TabViewImpl.swift b/ios/TabViewImpl.swift
|
diff --git a/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift b/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift
|
||||||
index 72938be90540ea3a483d7db9a80fb74c04d31272..277278ffdd9268a96cb09869eb1d0c0d5e6ad300 100644
|
index 72938be90540ea3a483d7db9a80fb74c04d31272..277278ffdd9268a96cb09869eb1d0c0d5e6ad300 100644
|
||||||
--- a/ios/TabViewImpl.swift
|
--- a/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift
|
||||||
+++ b/ios/TabViewImpl.swift
|
+++ b/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift
|
||||||
@@ -281,7 +281,7 @@ extension View {
|
@@ -281,7 +281,7 @@ extension View {
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -69,10 +66,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/ios/TabViewProps.swift b/ios/TabViewProps.swift
|
diff --git a/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift b/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift
|
||||||
index 9cfb29a983b34d3f84fc7a678d19ef4ff30e0325..6a5854483e66200b71722bbac12e100742222bd3 100644
|
index 9cfb29a983b34d3f84fc7a678d19ef4ff30e0325..6a5854483e66200b71722bbac12e100742222bd3 100644
|
||||||
--- a/ios/TabViewProps.swift
|
--- a/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift
|
||||||
+++ b/ios/TabViewProps.swift
|
+++ b/node_modules/react-native-bottom-tabs/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
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
diff --git a/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift b/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
|
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
|
||||||
index 09be306d5aa39337c5114c2ad6ba7513218e0751..24ff8ee2c36fef8632a7e012514fd04db9bf89fd 100644
|
index 09be306d5aa39337c5114c2ad6ba7513218e0751..24ff8ee2c36fef8632a7e012514fd04db9bf89fd 100644
|
||||||
--- a/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
|
--- a/node_modules/react-native-ios-utilities/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
|
||||||
+++ b/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
|
+++ b/node_modules/react-native-ios-utilities/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);
|
||||||
};
|
};
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
diff --git a/node_modules/react-native-udp/.bun-tag-ea7df8754aa4db91 b/.bun-tag-ea7df8754aa4db91
|
diff --git a/node_modules/react-native-udp/react-native-udp.podspec b/node_modules/react-native-udp/react-native-udp.podspec
|
||||||
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/react-native-udp.podspec
|
--- a/node_modules/react-native-udp/react-native-udp.podspec
|
||||||
+++ b/react-native-udp.podspec
|
+++ b/node_modules/react-native-udp/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"]
|
||||||
@@ -39,6 +39,28 @@ 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)",
|
||||||
|
|||||||
@@ -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.0" },
|
clientInfo: { name: "Streamyfin", version: "0.54.1" },
|
||||||
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.0" },
|
clientInfo: { name: "Streamyfin", version: "0.54.1" },
|
||||||
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.0"`,
|
}, DeviceId="${deviceId}", Version="0.54.1"`,
|
||||||
};
|
};
|
||||||
}, [deviceId]);
|
}, [deviceId]);
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,10 @@
|
|||||||
<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>
|
||||||
|
|||||||
Reference in New Issue
Block a user