mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-30 18:48:30 +01:00
Compare commits
4 Commits
chore/sdk-
...
feat/tv-in
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d0a8417ec | ||
|
|
1cabbf087e | ||
|
|
4cc11403f8 | ||
|
|
0ba3f44615 |
19
.github/renovate.json
vendored
19
.github/renovate.json
vendored
@@ -25,25 +25,6 @@
|
||||
"osvVulnerabilityAlerts": true,
|
||||
"configMigration": true,
|
||||
"separateMinorPatch": true,
|
||||
"customManagers": [
|
||||
{
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": ["/\\.ya?ml$/"],
|
||||
"matchStrings": [
|
||||
"# renovate: datasource=(?<datasource>\\S+) depName=(?<depName>\\S+)(?: versioning=(?<versioning>\\S+))?\\s+xcode-version:\\s*[\"']?(?<currentValue>[^\"'\\s]+)"
|
||||
],
|
||||
"versioningTemplate": "{{#if versioning}}{{{versioning}}}{{else}}loose{{/if}}"
|
||||
}
|
||||
],
|
||||
"customDatasources": {
|
||||
"xcode": {
|
||||
"defaultRegistryUrlTemplate": "https://xcodereleases.com/data.json",
|
||||
"format": "json",
|
||||
"transformTemplates": [
|
||||
"{ \"releases\": [$[version.release.release=true].{\"version\": version.number}] }"
|
||||
]
|
||||
}
|
||||
},
|
||||
"lockFileMaintenance": {
|
||||
"vulnerabilityAlerts": {
|
||||
"enabled": true,
|
||||
|
||||
12
.github/workflows/build-apps.yml
vendored
12
.github/workflows/build-apps.yml
vendored
@@ -218,8 +218,7 @@ jobs:
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.4"
|
||||
xcode-version: "26.2"
|
||||
|
||||
- name: 🏗️ Setup EAS
|
||||
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
||||
@@ -283,8 +282,7 @@ jobs:
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.4"
|
||||
xcode-version: "26.2"
|
||||
|
||||
- name: 🚀 Build iOS app
|
||||
env:
|
||||
@@ -343,8 +341,7 @@ jobs:
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.4"
|
||||
xcode-version: "26.2"
|
||||
|
||||
- name: 🏗️ Setup EAS
|
||||
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
||||
@@ -411,8 +408,7 @@ jobs:
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.4"
|
||||
xcode-version: "26.2"
|
||||
|
||||
- name: 🚀 Build iOS app
|
||||
env:
|
||||
|
||||
7
app.json
7
app.json
@@ -78,16 +78,15 @@
|
||||
"expo-build-properties",
|
||||
{
|
||||
"ios": {
|
||||
"deploymentTarget": "16.4",
|
||||
"useFrameworks": "static",
|
||||
"forceStaticLinking": ["ExpoUI", "GlassEffectView", "GlassPoster"]
|
||||
"deploymentTarget": "15.6",
|
||||
"useFrameworks": "static"
|
||||
},
|
||||
"android": {
|
||||
"buildArchs": ["arm64-v8a", "x86_64", "armeabi-v7a"],
|
||||
"compileSdkVersion": 36,
|
||||
"targetSdkVersion": 35,
|
||||
"buildToolsVersion": "35.0.0",
|
||||
"kotlinVersion": "2.1.20",
|
||||
"kotlinVersion": "2.0.21",
|
||||
"minSdkVersion": 26,
|
||||
"usesCleartextTraffic": true,
|
||||
"packagingOptions": {
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface MenuLink {
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export default function menuLinks() {
|
||||
export default function CustomLinksPage() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
const [menuLinks, setMenuLinks] = useState<MenuLink[]>([]);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Favorites } from "@/components/home/Favorites";
|
||||
import { Favorites as TVFavorites } from "@/components/home/Favorites.tv";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
|
||||
export default function favorites() {
|
||||
export default function FavoritesPage() {
|
||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -20,7 +20,7 @@ import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||
import { queueAtom } from "@/utils/atoms/queue";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
|
||||
export default function page() {
|
||||
export default function DownloadsPage() {
|
||||
const navigation = useNavigation();
|
||||
const { t } = useTranslation();
|
||||
const [_queue, _setQueue] = useAtom(queueAtom);
|
||||
|
||||
@@ -23,7 +23,7 @@ import { formatBitrate } from "@/utils/bitrate";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { formatTimeString } from "@/utils/time";
|
||||
|
||||
export default function page() {
|
||||
export default function SessionsPage() {
|
||||
const { sessions, isLoading } = useSessions({} as useSessionsProps);
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -72,7 +72,7 @@ const SessionCard = ({ session }: SessionCardProps) => {
|
||||
};
|
||||
|
||||
const getProgressPercentage = () => {
|
||||
if (!session.NowPlayingItem || !session.NowPlayingItem.RunTimeTicks) {
|
||||
if (!session.NowPlayingItem?.RunTimeTicks) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function page() {
|
||||
export default function AppearanceHideLibrariesPage() {
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const user = useAtomValue(userAtom);
|
||||
const api = useAtomValue(apiAtom);
|
||||
|
||||
@@ -11,7 +11,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function page() {
|
||||
export default function HideLibrariesPage() {
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const user = useAtomValue(userAtom);
|
||||
const api = useAtomValue(apiAtom);
|
||||
|
||||
@@ -4,7 +4,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function page() {
|
||||
export default function JellyseerrPluginPage() {
|
||||
const { pluginSettings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function page() {
|
||||
export default function KefinTweaksPage() {
|
||||
const { pluginSettings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function page() {
|
||||
export default function MarlinSearchPage() {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -17,7 +17,7 @@ import { ListItem } from "@/components/list/ListItem";
|
||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function page() {
|
||||
export default function StreamystatsPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from "@/utils/jellyseerr/server/models/Search";
|
||||
import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
|
||||
|
||||
export default function page() {
|
||||
export default function JellyseerrCompanyPage() {
|
||||
const local = useLocalSearchParams();
|
||||
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||
|
||||
export default function page() {
|
||||
export default function JellyseerrGenrePage() {
|
||||
const local = useLocalSearchParams();
|
||||
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
||||
|
||||
export default function page() {
|
||||
export default function JellyseerrPersonPage() {
|
||||
const local = useLocalSearchParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Slot, Stack, withLayoutContext } from "expo-router";
|
||||
import {
|
||||
createMaterialTopTabNavigator,
|
||||
MaterialTopTabNavigationEventMap,
|
||||
MaterialTopTabNavigationOptions,
|
||||
} from "expo-router/js-top-tabs";
|
||||
} from "@react-navigation/material-top-tabs";
|
||||
import type {
|
||||
ParamListBase,
|
||||
TabNavigationState,
|
||||
} from "expo-router/react-navigation";
|
||||
} from "@react-navigation/native";
|
||||
import { Slot, Stack, withLayoutContext } from "expo-router";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
const { Navigator } = createMaterialTopTabNavigator();
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
export default function page() {
|
||||
export default function LiveTvChannelsPage() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const _insets = useSafeAreaInsets();
|
||||
|
||||
@@ -17,7 +17,7 @@ const ITEMS_PER_PAGE = 20;
|
||||
|
||||
const MemoizedLiveTVGuideRow = React.memo(LiveTVGuideRow);
|
||||
|
||||
export default function page() {
|
||||
export default function LiveTvGuidePage() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
|
||||
export default function page() {
|
||||
export default function LiveTvRecordingsPage() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View className='flex items-center justify-center h-full -mt-12'>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Stack, useLocalSearchParams, withLayoutContext } from "expo-router";
|
||||
import {
|
||||
createMaterialTopTabNavigator,
|
||||
MaterialTopTabNavigationEventMap,
|
||||
MaterialTopTabNavigationOptions,
|
||||
} from "expo-router/js-top-tabs";
|
||||
} from "@react-navigation/material-top-tabs";
|
||||
import type {
|
||||
ParamListBase,
|
||||
TabNavigationState,
|
||||
} from "expo-router/react-navigation";
|
||||
} from "@react-navigation/native";
|
||||
import { Stack, useLocalSearchParams, withLayoutContext } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const { Navigator } = createMaterialTopTabNavigator();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useRoute } from "@react-navigation/native";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useRoute } from "expo-router/react-navigation";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { getArtistsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useRoute } from "@react-navigation/native";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useRoute } from "expo-router/react-navigation";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useNavigation, useRoute } from "@react-navigation/native";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useNavigation, useRoute } from "expo-router/react-navigation";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useLayoutEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useRoute } from "@react-navigation/native";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useRoute } from "expo-router/react-navigation";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -66,7 +66,7 @@ const exampleSearches = [
|
||||
"The Mandalorian",
|
||||
];
|
||||
|
||||
export default function search() {
|
||||
export default function SearchPage() {
|
||||
const params = useLocalSearchParams();
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
@@ -221,7 +221,7 @@ export default function search() {
|
||||
|
||||
const ids = response1.data.ids;
|
||||
|
||||
if (!ids || !ids.length) {
|
||||
if (!ids?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,11 @@ import {
|
||||
type NativeBottomTabNavigationEventMap,
|
||||
type NativeBottomTabNavigationOptions,
|
||||
} from "@bottom-tabs/react-navigation";
|
||||
import { withLayoutContext } from "expo-router";
|
||||
import type {
|
||||
ParamListBase,
|
||||
TabNavigationState,
|
||||
} from "expo-router/react-navigation";
|
||||
} from "@react-navigation/native";
|
||||
import { withLayoutContext } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
|
||||
@@ -63,7 +63,7 @@ import { writeToLog } from "@/utils/log";
|
||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||
import { generateDeviceProfile } from "../../../utils/profiles/native";
|
||||
|
||||
export default function page() {
|
||||
export default function DirectPlayerPage() {
|
||||
const videoRef = useRef<MpvPlayerViewRef>(null);
|
||||
const user = useAtomValue(userAtom);
|
||||
const api = useAtomValue(apiAtom);
|
||||
@@ -317,7 +317,7 @@ export default function page() {
|
||||
}
|
||||
|
||||
let result: Stream | null = null;
|
||||
if (offline && downloadedItem && downloadedItem.mediaSource) {
|
||||
if (offline && downloadedItem?.mediaSource) {
|
||||
const url = downloadedItem.videoFilePath;
|
||||
if (item) {
|
||||
result = {
|
||||
@@ -822,12 +822,10 @@ export default function page() {
|
||||
],
|
||||
);
|
||||
|
||||
/** PiP handler for MPV */
|
||||
const _onPictureInPictureChange = useCallback(
|
||||
(e: { nativeEvent: { isActive: boolean } }) => {
|
||||
const { isActive } = e.nativeEvent;
|
||||
setIsPipMode(isActive);
|
||||
// Hide controls when entering PiP
|
||||
if (isActive) {
|
||||
_setShowControls(false);
|
||||
}
|
||||
@@ -845,6 +843,9 @@ export default function page() {
|
||||
|
||||
// Memoize video ref functions to prevent unnecessary re-renders
|
||||
const startPictureInPicture = useCallback(async () => {
|
||||
// Hide controls BEFORE entering PiP so the window captures a clean view
|
||||
_setShowControls(false);
|
||||
setIsPipMode(true);
|
||||
return videoRef.current?.startPictureInPicture?.();
|
||||
}, []);
|
||||
|
||||
@@ -1247,6 +1248,7 @@ export default function page() {
|
||||
nowPlayingMetadata={nowPlayingMetadata}
|
||||
onProgress={onProgress}
|
||||
onPlaybackStateChange={onPlaybackStateChanged}
|
||||
onPictureInPictureChange={_onPictureInPictureChange}
|
||||
onLoad={() => setIsVideoLoaded(true)}
|
||||
onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => {
|
||||
console.error("Video Error:", e.nativeEvent);
|
||||
|
||||
@@ -1248,7 +1248,7 @@ const styles = StyleSheet.create({
|
||||
color: "#fff",
|
||||
},
|
||||
downloadingOverlay: {
|
||||
...StyleSheet.absoluteFill,
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: "rgba(0,0,0,0.5)",
|
||||
borderRadius: scaleSize(14),
|
||||
justifyContent: "center",
|
||||
|
||||
@@ -2,12 +2,12 @@ import "@/augmentations";
|
||||
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
import NetInfo from "@react-native-community/netinfo";
|
||||
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
||||
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
||||
import { onlineManager, QueryClient } from "@tanstack/react-query";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
import * as BackgroundTask from "expo-background-task";
|
||||
import * as Device from "expo-device";
|
||||
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
|
||||
import { Platform } from "react-native";
|
||||
import { GlobalModal } from "@/components/GlobalModal";
|
||||
import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.16/schema.json",
|
||||
"files": {
|
||||
"includes": [
|
||||
"**/*",
|
||||
|
||||
29
bun-patches/@react-native%2Fcodegen@0.83.6.patch
Normal file
29
bun-patches/@react-native%2Fcodegen@0.83.6.patch
Normal file
@@ -0,0 +1,29 @@
|
||||
diff --git a/lib/generators/modules/GenerateModuleObjCpp/index.js b/lib/generators/modules/GenerateModuleObjCpp/index.js
|
||||
index 927711514d2deaa3c795fb98e676e0a1f596eddc..0364d66204a76fccd3e06a0dc72bf801aa04a50d 100644
|
||||
--- a/lib/generators/modules/GenerateModuleObjCpp/index.js
|
||||
+++ b/lib/generators/modules/GenerateModuleObjCpp/index.js
|
||||
@@ -67,9 +67,12 @@ const HeaderFileTemplate = ({
|
||||
* must have a single output. More files => more genrule()s => slower builds.
|
||||
*/
|
||||
|
||||
-#ifndef __cplusplus
|
||||
-#error This file must be compiled as Obj-C++. If you are importing it, you must change your file extension to .mm.
|
||||
-#endif
|
||||
+// Patched: guard the Obj-C++ body with __cplusplus instead of hard-#error-ing.
|
||||
+// With use_frameworks! :static + New Arch, plain Obj-C .m TUs can trigger a
|
||||
+// Clang module build (via Swift-interop -Swift.h umbrellas) that pulls this
|
||||
+// header in Obj-C mode. Skipping the body (instead of erroring) lets the module
|
||||
+// build; .mm consumers still get the full Obj-C++ contents unchanged.
|
||||
+#if defined(__cplusplus)
|
||||
|
||||
// Avoid multiple includes of ${headerFileNameWithNoExt} symbols
|
||||
#ifndef ${headerFileNameWithNoExt}_H
|
||||
@@ -93,7 +96,7 @@ const HeaderFileTemplate = ({
|
||||
structInlineMethods +
|
||||
(assumeNonnull ? '\nNS_ASSUME_NONNULL_END\n' : '\n') +
|
||||
`#endif // ${headerFileNameWithNoExt}_H` +
|
||||
- '\n'
|
||||
+ '\n#endif // defined(__cplusplus)\n'
|
||||
);
|
||||
};
|
||||
const SourceFileTemplate = ({headerFileName, moduleImplementations}) => `/**
|
||||
@@ -1,28 +0,0 @@
|
||||
diff --git a/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift b/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
|
||||
index 09be306d5aa39337c5114c2ad6ba7513218e0751..24ff8ee2c36fef8632a7e012514fd04db9bf89fd 100644
|
||||
--- a/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
|
||||
+++ b/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
|
||||
@@ -25,15 +25,14 @@ public extension RCTView {
|
||||
return rootView.recursivelyFindSubview(whereType: targetType);
|
||||
};
|
||||
|
||||
- var closestParentReactContentView: RCTRootContentView? {
|
||||
- let targetType = RCTRootContentView.self;
|
||||
-
|
||||
- if let match = self.recursivelyFindParentView(whereType: targetType) {
|
||||
- return match;
|
||||
- };
|
||||
-
|
||||
- guard let rootView = self.rootViewForCurrentWindow else { return nil };
|
||||
- return rootView.recursivelyFindSubview(whereType: targetType);
|
||||
+ // PATCH (streamyfin): RCTRootContentView is a legacy paper class that the prebuilt
|
||||
+ // new-architecture React (RN 0.85) does not export, so any reference to it fails to
|
||||
+ // link (Undefined symbols: _OBJC_CLASS_$_RCTRootContentView). The app runs the new
|
||||
+ // architecture, where this content-view lookup is unused; short-circuit to nil.
|
||||
+ // Return type widened to RCTView? so the caller's `.reactTouchHandlers` (an RCTView
|
||||
+ // extension) still resolves.
|
||||
+ var closestParentReactContentView: RCTView? {
|
||||
+ return nil;
|
||||
};
|
||||
|
||||
var reactTouchHandlers: [RCTTouchHandler]? {
|
||||
191
bun-patches/react-native-screens@4.18.0.patch
Normal file
191
bun-patches/react-native-screens@4.18.0.patch
Normal file
@@ -0,0 +1,191 @@
|
||||
diff --git a/node_modules/react-native-screens/.bun-tag-10a3b0add1bd4de6 b/.bun-tag-10a3b0add1bd4de6
|
||||
new file mode 100644
|
||||
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
|
||||
diff --git a/node_modules/react-native-screens/.bun-tag-6a8504b742d5cfff b/.bun-tag-6a8504b742d5cfff
|
||||
new file mode 100644
|
||||
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
|
||||
diff --git a/node_modules/react-native-screens/.bun-tag-d28396854bc27a3d b/.bun-tag-d28396854bc27a3d
|
||||
new file mode 100644
|
||||
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
|
||||
diff --git a/ios/RNSScreenStack.mm b/ios/RNSScreenStack.mm
|
||||
index 51f021831aed26a4eed3c85014020423b7b3108b..2f621547932806b94ab1e75ecc73772facd209d0 100644
|
||||
--- a/ios/RNSScreenStack.mm
|
||||
+++ b/ios/RNSScreenStack.mm
|
||||
@@ -34,6 +34,11 @@
|
||||
#import "integrations/RNSDismissibleModalProtocol.h"
|
||||
#import "utils/UINavigationBar+RNSUtility.h"
|
||||
|
||||
+#if TARGET_OS_TV
|
||||
+#import <React/RCTTVNavigationEventNotification.h>
|
||||
+#import <React/RCTTVRemoteHandler.h>
|
||||
+#endif // TARGET_OS_TV
|
||||
+
|
||||
#ifdef RNS_GAMMA_ENABLED
|
||||
#import "RNSFrameCorrectionProvider.h"
|
||||
#import "Swift-Bridging.h"
|
||||
@@ -43,6 +48,12 @@
|
||||
namespace react = facebook::react;
|
||||
#endif // RCT_NEW_ARCH_ENABLED
|
||||
|
||||
+#if TARGET_OS_TV
|
||||
+@interface RNSNavigationController ()
|
||||
+@property (nonatomic, strong) UITapGestureRecognizer *rnscreens_menuGestureRecognizer;
|
||||
+@end
|
||||
+#endif // TARGET_OS_TV
|
||||
+
|
||||
@interface RNSScreenStackView () <
|
||||
UINavigationControllerDelegate,
|
||||
UIAdaptivePresentationControllerDelegate,
|
||||
@@ -62,6 +73,57 @@ namespace react = facebook::react;
|
||||
|
||||
@implementation RNSNavigationController
|
||||
|
||||
+#if TARGET_OS_TV
|
||||
+- (void)viewDidLoad
|
||||
+{
|
||||
+ [super viewDidLoad];
|
||||
+
|
||||
+ self.rnscreens_menuGestureRecognizer =
|
||||
+ [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(rnscreens_menuPressed:)];
|
||||
+ self.rnscreens_menuGestureRecognizer.allowedPressTypes = @[ @(UIPressTypeMenu) ];
|
||||
+
|
||||
+ [[NSNotificationCenter defaultCenter] addObserver:self
|
||||
+ selector:@selector(rnscreens_enableMenuGesture)
|
||||
+ name:RCTTVEnableMenuKeyNotification
|
||||
+ object:nil];
|
||||
+ [[NSNotificationCenter defaultCenter] addObserver:self
|
||||
+ selector:@selector(rnscreens_disableMenuGesture)
|
||||
+ name:RCTTVDisableMenuKeyNotification
|
||||
+ object:nil];
|
||||
+
|
||||
+ if ([RCTTVRemoteHandler useMenuKey]) {
|
||||
+ [self rnscreens_enableMenuGesture];
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+- (void)dealloc
|
||||
+{
|
||||
+ [[NSNotificationCenter defaultCenter] removeObserver:self];
|
||||
+}
|
||||
+
|
||||
+- (void)rnscreens_enableMenuGesture
|
||||
+{
|
||||
+ if (![self.view.gestureRecognizers containsObject:self.rnscreens_menuGestureRecognizer]) {
|
||||
+ [self.view addGestureRecognizer:self.rnscreens_menuGestureRecognizer];
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+- (void)rnscreens_disableMenuGesture
|
||||
+{
|
||||
+ if ([self.view.gestureRecognizers containsObject:self.rnscreens_menuGestureRecognizer]) {
|
||||
+ [self.view removeGestureRecognizer:self.rnscreens_menuGestureRecognizer];
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+- (void)rnscreens_menuPressed:(UIGestureRecognizer *)recognizer
|
||||
+{
|
||||
+ [[NSNotificationCenter defaultCenter] postNavigationPressEventWithType:RCTTVRemoteEventMenu
|
||||
+ keyAction:recognizer.eventKeyAction
|
||||
+ tag:nil
|
||||
+ target:nil];
|
||||
+}
|
||||
+#endif // TARGET_OS_TV
|
||||
+
|
||||
#if !TARGET_OS_TV
|
||||
- (UIViewController *)childViewControllerForStatusBarStyle
|
||||
{
|
||||
diff --git a/ios/gamma/split-view/RNSSplitViewAppearanceApplicator.swift b/ios/gamma/split-view/RNSSplitViewAppearanceApplicator.swift
|
||||
index 95c76ccf3528d3a8828e90b272a1d79b0828a139..f29d4df21440d23523ae7a2f6fe71c32154e3928 100644
|
||||
--- a/ios/gamma/split-view/RNSSplitViewAppearanceApplicator.swift
|
||||
+++ b/ios/gamma/split-view/RNSSplitViewAppearanceApplicator.swift
|
||||
@@ -79,11 +79,13 @@ class RNSSplitViewAppearanceApplicator {
|
||||
maxWidth: splitView.maximumSupplementaryColumnWidth)
|
||||
|
||||
#if compiler(>=6.2)
|
||||
+ #if !os(tvOS)
|
||||
if #available(iOS 26.0, *) {
|
||||
validateColumnConstraints(
|
||||
minWidth: splitView.minimumInspectorColumnWidth,
|
||||
maxWidth: splitView.maximumInspectorColumnWidth)
|
||||
}
|
||||
+ #endif
|
||||
#endif
|
||||
|
||||
// Step 2.2 - applying updates to columns
|
||||
@@ -126,6 +128,7 @@ class RNSSplitViewAppearanceApplicator {
|
||||
}
|
||||
|
||||
#if compiler(>=6.2)
|
||||
+ #if !os(tvOS)
|
||||
if #available(iOS 26.0, *) {
|
||||
if splitView.minimumSecondaryColumnWidth >= 0 {
|
||||
splitViewController.minimumSecondaryColumnWidth = splitView.minimumSecondaryColumnWidth
|
||||
@@ -159,6 +162,7 @@ class RNSSplitViewAppearanceApplicator {
|
||||
splitView.preferredInspectorColumnWidthOrFraction
|
||||
}
|
||||
}
|
||||
+ #endif
|
||||
#endif
|
||||
|
||||
// Step 2.3 - manipulating with inspector column
|
||||
diff --git a/ios/gamma/split-view/RNSSplitViewHostController.swift b/ios/gamma/split-view/RNSSplitViewHostController.swift
|
||||
index 0421e3ea92fc7bcdf57417b5ee3a62348fce34f5..cd878ab638d3c78a661e2df4c4c1b21011dfcf48 100644
|
||||
--- a/ios/gamma/split-view/RNSSplitViewHostController.swift
|
||||
+++ b/ios/gamma/split-view/RNSSplitViewHostController.swift
|
||||
@@ -386,7 +386,7 @@ extension RNSSplitViewHostController: RNSSplitViewNavigationControllerViewFrameO
|
||||
/// @param inspectors An array of inspector-type RNSSplitViewScreenComponentView subviews.
|
||||
///
|
||||
func maybeSetupInspector(_ inspectors: [RNSSplitViewScreenComponentView]) {
|
||||
-
|
||||
+ #if !os(tvOS)
|
||||
if #available(iOS 26.0, *) {
|
||||
let inspector = inspectors.first
|
||||
if inspector != nil {
|
||||
@@ -395,6 +395,7 @@ extension RNSSplitViewHostController: RNSSplitViewNavigationControllerViewFrameO
|
||||
setViewController(inspectorViewController, for: .inspector)
|
||||
}
|
||||
}
|
||||
+ #endif
|
||||
}
|
||||
|
||||
///
|
||||
@@ -404,9 +405,11 @@ extension RNSSplitViewHostController: RNSSplitViewNavigationControllerViewFrameO
|
||||
/// Uses the UISplitViewController's new API introduced in iOS 26 to show the inspector column.
|
||||
///
|
||||
func maybeShowInspector() {
|
||||
+ #if !os(tvOS)
|
||||
if #available(iOS 26.0, *) {
|
||||
show(.inspector)
|
||||
}
|
||||
+ #endif
|
||||
}
|
||||
|
||||
///
|
||||
@@ -416,9 +419,11 @@ extension RNSSplitViewHostController: RNSSplitViewNavigationControllerViewFrameO
|
||||
/// Uses the UISplitViewController's new API introduced in iOS 26 to hide the inspector column.
|
||||
///
|
||||
func maybeHideInspector() {
|
||||
+ #if !os(tvOS)
|
||||
if #available(iOS 26.0, *) {
|
||||
hide(.inspector)
|
||||
}
|
||||
+ #endif
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -444,6 +449,7 @@ extension RNSSplitViewHostController: UISplitViewControllerDelegate {
|
||||
public func splitViewController(
|
||||
_ svc: UISplitViewController, didHide column: UISplitViewController.Column
|
||||
) {
|
||||
+ #if !os(tvOS)
|
||||
if #available(iOS 26.0, *) {
|
||||
// TODO: we may consider removing this logic, because it could be handled by onViewDidDisappear on the column level
|
||||
// On the other hand, maybe dedicated event related to the inspector would be a better approach.
|
||||
@@ -461,6 +467,7 @@ extension RNSSplitViewHostController: UISplitViewControllerDelegate {
|
||||
}
|
||||
}
|
||||
}
|
||||
+ #endif
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -414,7 +414,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
]);
|
||||
|
||||
const derivedTargetWidth = useDerivedValue(() => {
|
||||
if (!item || !item.RunTimeTicks) return 0;
|
||||
if (!item?.RunTimeTicks) return 0;
|
||||
const userData = item.UserData;
|
||||
if (userData?.PlaybackPositionTicks) {
|
||||
return userData.PlaybackPositionTicks > 0
|
||||
|
||||
@@ -78,7 +78,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
};
|
||||
|
||||
const derivedTargetWidth = useDerivedValue(() => {
|
||||
if (!item || !item.RunTimeTicks) return 0;
|
||||
if (!item?.RunTimeTicks) return 0;
|
||||
const userData = item.UserData;
|
||||
if (userData?.PlaybackPositionTicks) {
|
||||
return userData.PlaybackPositionTicks > 0
|
||||
|
||||
@@ -116,7 +116,7 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
}, [process?.progress]);
|
||||
|
||||
// Return null after all hooks have been called
|
||||
if (!process || !process.item || !process.item.Id) {
|
||||
if (!process?.item?.Id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -351,7 +351,7 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
|
||||
|
||||
// Get subtitle for episodes
|
||||
const episodeSubtitle = useMemo(() => {
|
||||
if (!activeItem || activeItem.Type !== "Episode") return null;
|
||||
if (activeItem?.Type !== "Episode") return null;
|
||||
return `S${activeItem.ParentIndexNumber} E${activeItem.IndexNumber} · ${activeItem.Name}`;
|
||||
}, [activeItem]);
|
||||
|
||||
|
||||
@@ -180,4 +180,4 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
});
|
||||
|
||||
export { CARD_WIDTH, CARD_HEIGHT };
|
||||
export { CARD_HEIGHT, CARD_WIDTH };
|
||||
|
||||
@@ -155,7 +155,7 @@ export const TVLiveTVGuide: React.FC = () => {
|
||||
);
|
||||
|
||||
// Fetch programs for visible channels
|
||||
const { data: programsData, isLoading: isLoadingPrograms } = useQuery({
|
||||
const { data: programsData } = useQuery({
|
||||
queryKey: [
|
||||
"livetv",
|
||||
"tv-guide",
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useTVFocusAnimation } from "@/components/tv";
|
||||
import { useTVBackPress } from "@/hooks/useTVBackPress";
|
||||
import { scaleSize } from "@/utils/scaleSize";
|
||||
|
||||
interface TVPasswordEntryModalProps {
|
||||
@@ -201,6 +202,13 @@ export const TVPasswordEntryModal: React.FC<TVPasswordEntryModalProps> = ({
|
||||
setIsReady(false);
|
||||
}, [visible]);
|
||||
|
||||
// Close the modal on the TV remote back/menu button while it is open.
|
||||
useTVBackPress(() => {
|
||||
if (!visible) return false;
|
||||
onClose();
|
||||
return true;
|
||||
}, [visible, onClose]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!password) {
|
||||
setError(t("password.enter_password"));
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { Stack } from "expo-router";
|
||||
import type { ComponentProps } from "react";
|
||||
import type { ParamListBase, RouteProp } from "@react-navigation/native";
|
||||
import type { NativeStackNavigationOptions } from "@react-navigation/native-stack";
|
||||
import { Platform } from "react-native";
|
||||
import { HeaderBackButton } from "../common/HeaderBackButton";
|
||||
|
||||
type ICommonScreenOptions = ComponentProps<typeof Stack.Screen>["options"];
|
||||
type ICommonScreenOptions =
|
||||
| NativeStackNavigationOptions
|
||||
| ((prop: {
|
||||
route: RouteProp<ParamListBase, string>;
|
||||
navigation: any;
|
||||
}) => NativeStackNavigationOptions);
|
||||
|
||||
export const commonScreenOptions: ICommonScreenOptions = {
|
||||
title: "",
|
||||
|
||||
@@ -63,6 +63,7 @@ export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
|
||||
const typography = useScaledTVTypography();
|
||||
const { t } = useTranslation();
|
||||
const progress = useSharedValue(0);
|
||||
const cancelled = useSharedValue(false);
|
||||
const onFinishRef = useRef(onFinish);
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({
|
||||
@@ -120,13 +121,15 @@ export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
cancelled.value = false;
|
||||
|
||||
// Resume from current position
|
||||
const remainingDuration = (1 - progress.value) * 8000;
|
||||
progress.value = withTiming(
|
||||
1,
|
||||
{ duration: remainingDuration, easing: Easing.linear },
|
||||
(finished) => {
|
||||
if (finished) {
|
||||
if (finished && !cancelled.value) {
|
||||
runOnJS(onFinishRef.current)();
|
||||
}
|
||||
},
|
||||
@@ -134,9 +137,10 @@ export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
|
||||
|
||||
// Cancel animation on unmount to prevent onFinish from firing after exit
|
||||
return () => {
|
||||
cancelled.value = true;
|
||||
cancelAnimation(progress);
|
||||
};
|
||||
}, [show, isPlaying, progress]);
|
||||
}, [show, isPlaying, progress, cancelled]);
|
||||
|
||||
const progressStyle = useAnimatedStyle(() => ({
|
||||
width: `${progress.value * 100}%`,
|
||||
|
||||
@@ -263,7 +263,7 @@ const createStyles = (typography: ReturnType<typeof useScaledTVTypography>) =>
|
||||
color: "#fff",
|
||||
},
|
||||
downloadingOverlay: {
|
||||
...StyleSheet.absoluteFill,
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: "rgba(0,0,0,0.5)",
|
||||
borderRadius: scaleSize(14),
|
||||
justifyContent: "center",
|
||||
|
||||
@@ -59,6 +59,7 @@ import { useRemoteControl } from "./hooks/useRemoteControl";
|
||||
import { useVideoTime } from "./hooks/useVideoTime";
|
||||
import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay";
|
||||
import { TrickplayBubble } from "./TrickplayBubble";
|
||||
import type { Track } from "./types";
|
||||
import { useControlsTimeout } from "./useControlsTimeout";
|
||||
|
||||
interface Props {
|
||||
@@ -315,6 +316,31 @@ export const Controls: FC<Props> = ({
|
||||
[onSubtitleIndexChange],
|
||||
);
|
||||
|
||||
// Re-fetch subtitle streams from the server (e.g. after a server-side
|
||||
// download) and map them to the modal's Track shape. setTrack drives the
|
||||
// player through the same handler used for manual subtitle selection.
|
||||
const refreshSubtitleTracks = useCallback(async (): Promise<Track[]> => {
|
||||
try {
|
||||
const streams = (await onRefreshSubtitleTracks?.()) ?? [];
|
||||
// Skip streams without a real index: `?? -1` would alias them to the
|
||||
// "disable subtitles" sentinel and mis-route selection.
|
||||
return streams
|
||||
.filter((stream) => typeof stream.Index === "number")
|
||||
.map((stream) => {
|
||||
const index = stream.Index as number;
|
||||
return {
|
||||
name:
|
||||
stream.DisplayTitle ||
|
||||
`${stream.Language || "Unknown"} (${stream.Codec})`,
|
||||
index,
|
||||
setTrack: () => onSubtitleIndexChange?.(index),
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, [onRefreshSubtitleTracks, onSubtitleIndexChange]);
|
||||
|
||||
const {
|
||||
trickPlayUrl,
|
||||
calculateTrickplayUrl,
|
||||
@@ -491,6 +517,8 @@ export const Controls: FC<Props> = ({
|
||||
const goToNextItemRef = useRef<(opts?: { isAutoPlay?: boolean }) => void>(
|
||||
() => {},
|
||||
);
|
||||
const exitingRef = useRef(false);
|
||||
const [isExiting, setIsExiting] = useState(false);
|
||||
|
||||
const updateSeekBubbleTime = useCallback((ms: number) => {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
@@ -572,6 +600,9 @@ export const Controls: FC<Props> = ({
|
||||
disableTrack?.setTrack();
|
||||
},
|
||||
onLocalSubtitleDownloaded: handleLocalSubtitleDownloaded,
|
||||
refreshSubtitleTracks: onRefreshSubtitleTracks
|
||||
? refreshSubtitleTracks
|
||||
: undefined,
|
||||
});
|
||||
controlsInteractionRef.current();
|
||||
}, [
|
||||
@@ -581,6 +612,8 @@ export const Controls: FC<Props> = ({
|
||||
videoContextSubtitleTracks,
|
||||
subtitleIndex,
|
||||
handleLocalSubtitleDownloaded,
|
||||
onRefreshSubtitleTracks,
|
||||
refreshSubtitleTracks,
|
||||
]);
|
||||
|
||||
const handleToggleTechnicalInfo = useCallback(() => {
|
||||
@@ -929,6 +962,16 @@ export const Controls: FC<Props> = ({
|
||||
router.back();
|
||||
}, [router]);
|
||||
|
||||
const handleWillExit = useCallback(() => {
|
||||
exitingRef.current = true;
|
||||
setIsExiting(true);
|
||||
}, []);
|
||||
|
||||
const handleCancelExit = useCallback(() => {
|
||||
exitingRef.current = false;
|
||||
setIsExiting(false);
|
||||
}, []);
|
||||
|
||||
const { isSliding: isRemoteSliding } = useRemoteControl({
|
||||
showControls: showControls,
|
||||
toggleControls,
|
||||
@@ -945,6 +988,8 @@ export const Controls: FC<Props> = ({
|
||||
onVerticalDpad: handleVerticalDpad,
|
||||
onHideControls: hideControls,
|
||||
onBack: handleBack,
|
||||
onWillExit: handleWillExit,
|
||||
onCancelExit: handleCancelExit,
|
||||
videoTitle: item?.Name ?? undefined,
|
||||
});
|
||||
|
||||
@@ -1030,6 +1075,7 @@ export const Controls: FC<Props> = ({
|
||||
goToNextItemRef.current = goToNextItem;
|
||||
|
||||
const handleAutoPlayFinish = useCallback(() => {
|
||||
if (exitingRef.current) return;
|
||||
goToNextItem({ isAutoPlay: true });
|
||||
}, [goToNextItem]);
|
||||
|
||||
@@ -1104,7 +1150,7 @@ export const Controls: FC<Props> = ({
|
||||
nextItem={nextItem}
|
||||
api={api}
|
||||
show={isCountdownActive}
|
||||
isPlaying={isPlaying}
|
||||
isPlaying={isPlaying && !isExiting}
|
||||
onFinish={handleAutoPlayFinish}
|
||||
onPlayNext={handleNextItemButton}
|
||||
controlsVisible={showControls}
|
||||
@@ -1408,14 +1454,14 @@ export const Controls: FC<Props> = ({
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
controlsContainer: {
|
||||
...StyleSheet.absoluteFill,
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
darkOverlay: {
|
||||
...StyleSheet.absoluteFill,
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.4)",
|
||||
},
|
||||
focusStealingOverlay: {
|
||||
...StyleSheet.absoluteFill,
|
||||
...StyleSheet.absoluteFillObject,
|
||||
zIndex: 1,
|
||||
},
|
||||
bottomContainer: {
|
||||
|
||||
@@ -35,6 +35,10 @@ interface UseRemoteControlProps {
|
||||
onLongSeekStop?: () => void;
|
||||
/** Callback when up/down D-pad pressed (to show controls with play button focused) */
|
||||
onVerticalDpad?: () => void;
|
||||
/** Called before the exit confirmation Alert is shown (e.g., to pause countdown) */
|
||||
onWillExit?: () => void;
|
||||
/** Called when the user cancels the exit confirmation Alert */
|
||||
onCancelExit?: () => void;
|
||||
// Legacy props - kept for backwards compatibility with mobile Controls.tsx
|
||||
// These are ignored in the simplified implementation
|
||||
progress?: SharedValue<number>;
|
||||
@@ -72,6 +76,8 @@ export function useRemoteControl({
|
||||
onLongSeekRightStart,
|
||||
onLongSeekStop,
|
||||
onVerticalDpad,
|
||||
onWillExit,
|
||||
onCancelExit,
|
||||
}: UseRemoteControlProps) {
|
||||
// Keep these for backward compatibility with the component
|
||||
const remoteScrubProgress = useSharedValue<number | null>(null);
|
||||
@@ -85,13 +91,24 @@ export function useRemoteControl({
|
||||
const onHideControlsRef = useRef(onHideControls);
|
||||
const onBackRef = useRef(onBack);
|
||||
const videoTitleRef = useRef(videoTitle);
|
||||
const onWillExitRef = useRef(onWillExit);
|
||||
const onCancelExitRef = useRef(onCancelExit);
|
||||
|
||||
useEffect(() => {
|
||||
showControlsRef.current = showControls;
|
||||
onHideControlsRef.current = onHideControls;
|
||||
onBackRef.current = onBack;
|
||||
videoTitleRef.current = videoTitle;
|
||||
}, [showControls, onHideControls, onBack, videoTitle]);
|
||||
onWillExitRef.current = onWillExit;
|
||||
onCancelExitRef.current = onCancelExit;
|
||||
}, [
|
||||
showControls,
|
||||
onHideControls,
|
||||
onBack,
|
||||
videoTitle,
|
||||
onWillExit,
|
||||
onCancelExit,
|
||||
]);
|
||||
|
||||
// BackHandler owns player exit: Android TV sends hardware back here, and
|
||||
// react-native-tvos maps the Apple TV menu button to the same API.
|
||||
@@ -102,6 +119,9 @@ export function useRemoteControl({
|
||||
return true;
|
||||
}
|
||||
if (onBackRef.current) {
|
||||
// Signal Controls that exit is imminent (pauses countdown, sets guard)
|
||||
onWillExitRef.current?.();
|
||||
|
||||
// Controls are hidden, so confirm before leaving playback.
|
||||
Alert.alert(
|
||||
"Stop Playback",
|
||||
@@ -109,7 +129,11 @@ export function useRemoteControl({
|
||||
? `Stop playing "${videoTitleRef.current}"?`
|
||||
: "Are you sure you want to stop playback?",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Cancel",
|
||||
style: "cancel",
|
||||
onPress: () => onCancelExitRef.current?.(),
|
||||
},
|
||||
{ text: "Stop", style: "destructive", onPress: onBackRef.current },
|
||||
],
|
||||
);
|
||||
|
||||
@@ -28,4 +28,4 @@ type Track = {
|
||||
localPath?: string;
|
||||
};
|
||||
|
||||
export type { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle, Track };
|
||||
export type { EmbeddedSubtitle, ExternalSubtitle, Track, TranscodedSubtitle };
|
||||
|
||||
@@ -80,7 +80,7 @@ export const usePlaybackManager = ({
|
||||
const { data: adjacentItems } = useQuery({
|
||||
queryKey: ["adjacentItems", item?.Id, item?.SeriesId, isOffline],
|
||||
queryFn: async (): Promise<BaseItemDto[] | null> => {
|
||||
if (!item || !item.SeriesId) {
|
||||
if (!item?.SeriesId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ export const useSessions = ({
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["sessions"],
|
||||
queryFn: async () => {
|
||||
if (!api || !user || !user.Policy?.IsAdministrator) {
|
||||
if (!api || !user?.Policy?.IsAdministrator) {
|
||||
return [];
|
||||
}
|
||||
const response = await getSessionApi(api).getSessions({
|
||||
@@ -55,7 +55,7 @@ export const useAllSessions = ({
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["allSessions"],
|
||||
queryFn: async () => {
|
||||
if (!api || !user || !user.Policy?.IsAdministrator) {
|
||||
if (!api || !user?.Policy?.IsAdministrator) {
|
||||
return [];
|
||||
}
|
||||
const response = await getSessionApi(api).getSessions({
|
||||
|
||||
@@ -236,37 +236,43 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach surface and re-enable video output.
|
||||
* Based on Findroid's implementation.
|
||||
* Attach surface and ensure video output is active.
|
||||
*
|
||||
* During PiP transitions, the surface is destroyed and recreated by Android.
|
||||
* We keep the VO pipeline alive (not killed with vo=null) so that rendering
|
||||
* resumes immediately when the new surface is attached — avoiding the black
|
||||
* screen that occurs when the VO is fully re-initialized via setOptionString.
|
||||
*/
|
||||
fun attachSurface(surface: Surface) {
|
||||
this.surface = surface
|
||||
Log.i(TAG, "[PiP] attachSurface — isRunning=$isRunning, vo=$voDriver, surface=${surface.hashCode()}")
|
||||
if (isRunning) {
|
||||
MPVLib.attachSurface(surface)
|
||||
// Re-enable video output after attaching surface (Findroid approach)
|
||||
MPVLib.setOptionString("force-window", "yes")
|
||||
MPVLib.setOptionString("vo", voDriver)
|
||||
Log.i(TAG, "Surface attached, video output re-enabled (vo=$voDriver)")
|
||||
// Read back vo to confirm it's still active
|
||||
val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null }
|
||||
Log.i(TAG, "[PiP] attachSurface — attached, activeVo=$activeVo")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Detach surface and disable video output.
|
||||
* Based on Findroid's implementation.
|
||||
* Detach surface without killing the VO pipeline.
|
||||
*
|
||||
* The previous approach (vo=null / force-window=no) destroyed the entire video
|
||||
* output pipeline on every surface transition. During PiP mode, the rapid
|
||||
* destroy/recreate cycle caused a black screen because setOptionString("vo", ...)
|
||||
* did not properly re-initialize rendering into the new PiP surface.
|
||||
*
|
||||
* By keeping the VO alive, frames are simply dropped while no surface is
|
||||
* attached, and rendering resumes immediately when the new surface arrives.
|
||||
*/
|
||||
fun detachSurface() {
|
||||
this.surface = null
|
||||
Log.i(TAG, "[PiP] detachSurface — isRunning=$isRunning, vo=$voDriver")
|
||||
if (isRunning) {
|
||||
try {
|
||||
// Disable video output before detaching surface (Findroid approach)
|
||||
MPVLib.setOptionString("vo", "null")
|
||||
MPVLib.setOptionString("force-window", "no")
|
||||
Log.i(TAG, "Video output disabled before surface detach")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to disable video output: ${e.message}")
|
||||
}
|
||||
|
||||
MPVLib.detachSurface()
|
||||
val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null }
|
||||
Log.i(TAG, "[PiP] detachSurface — detached, activeVo=$activeVo (should still be $voDriver)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,7 +283,24 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
fun updateSurfaceSize(width: Int, height: Int) {
|
||||
if (isRunning) {
|
||||
MPVLib.setPropertyString("android-surface-size", "${width}x$height")
|
||||
Log.i(TAG, "Surface size updated: ${width}x$height")
|
||||
Log.i(TAG, "[PiP] updateSurfaceSize — ${width}x${height}")
|
||||
} else {
|
||||
Log.w(TAG, "[PiP] updateSurfaceSize — called but renderer not running")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force mpv to render a frame to the current surface.
|
||||
* Steps forward one frame then seeks back to the original position.
|
||||
* Used after PiP entry to work around mpv stopping pixel output.
|
||||
*/
|
||||
fun forceRedraw() {
|
||||
if (!isRunning) return
|
||||
val pos = cachedPosition
|
||||
Log.i(TAG, "[PiP] forceRedraw — stepping frame then seeking to $pos")
|
||||
MPVLib.command(arrayOf("frame-step"))
|
||||
if (pos > 0) {
|
||||
MPVLib.command(arrayOf("seek", pos.toString(), "absolute"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -198,7 +198,7 @@ class MpvPlayerModule : Module() {
|
||||
}
|
||||
|
||||
// Defines events that the view can send to JavaScript
|
||||
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady")
|
||||
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,15 @@ package expo.modules.mpvplayer
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import android.graphics.Rect
|
||||
import android.graphics.SurfaceTexture
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.Surface
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import android.widget.FrameLayout
|
||||
import android.view.TextureView
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import expo.modules.kotlin.AppContext
|
||||
import expo.modules.kotlin.viewevent.EventDispatcher
|
||||
import expo.modules.kotlin.views.ExpoView
|
||||
@@ -28,26 +31,27 @@ data class VideoLoadConfig(
|
||||
|
||||
/**
|
||||
* MpvPlayerView - ExpoView that hosts the MPV player.
|
||||
* This mirrors the iOS MpvPlayerView implementation.
|
||||
* Uses TextureView for reliable Picture-in-Picture support.
|
||||
*/
|
||||
class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext),
|
||||
MPVLayerRenderer.Delegate, SurfaceHolder.Callback {
|
||||
|
||||
class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext),
|
||||
MPVLayerRenderer.Delegate, TextureView.SurfaceTextureListener {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MpvPlayerView"
|
||||
}
|
||||
|
||||
|
||||
// Event dispatchers
|
||||
val onLoad by EventDispatcher()
|
||||
val onPlaybackStateChange by EventDispatcher()
|
||||
val onProgress by EventDispatcher()
|
||||
val onError by EventDispatcher()
|
||||
val onTracksReady by EventDispatcher()
|
||||
|
||||
private var surfaceView: SurfaceView
|
||||
val onPictureInPictureChange by EventDispatcher()
|
||||
|
||||
private var textureView: TextureView
|
||||
private var renderer: MPVLayerRenderer? = null
|
||||
private var pipController: PiPController? = null
|
||||
|
||||
|
||||
private var currentUrl: String? = null
|
||||
private var cachedPosition: Double = 0.0
|
||||
private var cachedDuration: Double = 0.0
|
||||
@@ -56,23 +60,29 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
private var pendingConfig: VideoLoadConfig? = null
|
||||
private var rendererStarted: Boolean = false
|
||||
private var pendingSurface: Surface? = null
|
||||
private var surfaceTexture: SurfaceTexture? = null
|
||||
|
||||
// PiP state tracking
|
||||
private var isWaitingForPiPTransition: Boolean = false
|
||||
private var isPiPSurfaceForced: Boolean = false
|
||||
private val pipHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
init {
|
||||
setBackgroundColor(Color.BLACK)
|
||||
|
||||
// Create SurfaceView for video rendering
|
||||
surfaceView = SurfaceView(context).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT
|
||||
// Create TextureView for video rendering (composites into app window for PiP support)
|
||||
textureView = TextureView(context).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
holder.addCallback(this@MpvPlayerView)
|
||||
surfaceTextureListener = this@MpvPlayerView
|
||||
}
|
||||
addView(surfaceView)
|
||||
addView(textureView)
|
||||
|
||||
// Initialize PiP controller with Expo's AppContext for proper activity access
|
||||
pipController = PiPController(context, appContext)
|
||||
pipController?.setPlayerView(surfaceView)
|
||||
pipController?.setPlayerView(textureView)
|
||||
pipController?.delegate = object : PiPController.Delegate {
|
||||
override fun onPlay() {
|
||||
play()
|
||||
@@ -85,6 +95,23 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
override fun onSeekBy(seconds: Double) {
|
||||
seekBy(seconds)
|
||||
}
|
||||
|
||||
override fun onPictureInPictureModeChanged(isInPiP: Boolean) {
|
||||
if (isInPiP) {
|
||||
if (!isWaitingForPiPTransition) {
|
||||
isWaitingForPiPTransition = true
|
||||
pipHandler.removeCallbacksAndMessages(null)
|
||||
for (delay in longArrayOf(500, 1000, 1500, 2000)) {
|
||||
pipHandler.postDelayed({ forcePiPBufferSize() }, delay)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
isWaitingForPiPTransition = false
|
||||
pipHandler.removeCallbacksAndMessages(null)
|
||||
restoreFromPiP()
|
||||
}
|
||||
onPictureInPictureChange(mapOf("isActive" to isInPiP))
|
||||
}
|
||||
}
|
||||
|
||||
// Renderer is created lazily in loadVideo once we have the voDriver setting
|
||||
@@ -102,32 +129,29 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
try {
|
||||
renderer?.start(voDriver ?: "gpu-next")
|
||||
rendererStarted = true
|
||||
Log.i(TAG, "Renderer started with vo=$voDriver")
|
||||
|
||||
// If surface was created before renderer started, attach it now
|
||||
pendingSurface?.let { surface ->
|
||||
renderer?.attachSurface(surface)
|
||||
pendingSurface = null
|
||||
Log.i(TAG, "Attached pending surface after renderer start")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start renderer: ${e.message}")
|
||||
onError(mapOf("error" to "Failed to start renderer: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SurfaceHolder.Callback
|
||||
|
||||
override fun surfaceCreated(holder: SurfaceHolder) {
|
||||
Log.i(TAG, "Surface created")
|
||||
|
||||
// MARK: - TextureView.SurfaceTextureListener
|
||||
|
||||
override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
|
||||
this.surfaceTexture = surfaceTexture
|
||||
val surface = Surface(surfaceTexture)
|
||||
surfaceTexture.setDefaultBufferSize(width, height)
|
||||
surfaceReady = true
|
||||
|
||||
if (rendererStarted) {
|
||||
renderer?.attachSurface(holder.surface)
|
||||
renderer?.attachSurface(surface)
|
||||
} else {
|
||||
// Renderer not started yet - store surface to attach after start
|
||||
pendingSurface = holder.surface
|
||||
Log.i(TAG, "Surface created before renderer started, storing as pending")
|
||||
pendingSurface = surface
|
||||
}
|
||||
|
||||
// If we have a pending load, execute it now
|
||||
@@ -137,19 +161,23 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
pendingConfig = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
|
||||
Log.i(TAG, "Surface changed: ${width}x${height}")
|
||||
// Update MPV with the new surface size (Findroid approach)
|
||||
|
||||
override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
|
||||
surfaceTexture.setDefaultBufferSize(width, height)
|
||||
renderer?.updateSurfaceSize(width, height)
|
||||
}
|
||||
|
||||
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||
Log.i(TAG, "Surface destroyed")
|
||||
|
||||
override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
|
||||
this.surfaceTexture = null
|
||||
surfaceReady = false
|
||||
renderer?.detachSurface()
|
||||
return false // mpv manages the SurfaceTexture
|
||||
}
|
||||
|
||||
|
||||
override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) {
|
||||
// Called every frame — no action needed, mpv drives rendering directly
|
||||
}
|
||||
|
||||
// MARK: - Video Loading
|
||||
|
||||
fun loadVideo(config: VideoLoadConfig) {
|
||||
@@ -169,10 +197,10 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
|
||||
loadVideoInternal(config)
|
||||
}
|
||||
|
||||
|
||||
private fun loadVideoInternal(config: VideoLoadConfig) {
|
||||
currentUrl = config.url
|
||||
|
||||
|
||||
renderer?.load(
|
||||
url = config.url,
|
||||
headers = config.headers,
|
||||
@@ -181,124 +209,173 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
initialSubtitleId = config.initialSubtitleId,
|
||||
initialAudioId = config.initialAudioId
|
||||
)
|
||||
|
||||
|
||||
if (config.autoplay) {
|
||||
play()
|
||||
}
|
||||
|
||||
|
||||
onLoad(mapOf("url" to config.url))
|
||||
}
|
||||
|
||||
|
||||
// Convenience method for simple loads
|
||||
fun loadVideo(url: String, headers: Map<String, String>? = null) {
|
||||
loadVideo(VideoLoadConfig(url = url, headers = headers))
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Playback Controls
|
||||
|
||||
|
||||
fun play() {
|
||||
intendedPlayState = true
|
||||
renderer?.play()
|
||||
pipController?.setPlaybackRate(1.0)
|
||||
}
|
||||
|
||||
|
||||
fun pause() {
|
||||
intendedPlayState = false
|
||||
renderer?.pause()
|
||||
pipController?.setPlaybackRate(0.0)
|
||||
}
|
||||
|
||||
|
||||
fun seekTo(position: Double) {
|
||||
renderer?.seekTo(position)
|
||||
}
|
||||
|
||||
|
||||
fun seekBy(offset: Double) {
|
||||
renderer?.seekBy(offset)
|
||||
}
|
||||
|
||||
|
||||
fun setSpeed(speed: Double) {
|
||||
renderer?.setSpeed(speed)
|
||||
}
|
||||
|
||||
|
||||
fun getSpeed(): Double {
|
||||
return renderer?.getSpeed() ?: 1.0
|
||||
}
|
||||
|
||||
|
||||
fun isPaused(): Boolean {
|
||||
return renderer?.isPausedState ?: true
|
||||
}
|
||||
|
||||
|
||||
fun getCurrentPosition(): Double {
|
||||
return cachedPosition
|
||||
}
|
||||
|
||||
|
||||
fun getDuration(): Double {
|
||||
return cachedDuration
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Picture in Picture
|
||||
|
||||
|
||||
fun startPictureInPicture() {
|
||||
Log.i(TAG, "startPictureInPicture called")
|
||||
isWaitingForPiPTransition = true
|
||||
pipController?.startPictureInPicture()
|
||||
|
||||
// Resize buffer to match PiP window after animation settles
|
||||
pipHandler.removeCallbacksAndMessages(null)
|
||||
for (delay in longArrayOf(500, 1000, 1500, 2000)) {
|
||||
pipHandler.postDelayed({ forcePiPBufferSize() }, delay)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Resize the SurfaceTexture buffer AND TextureView layout to match the PiP
|
||||
* visible rect so mpv renders at the PiP window's actual dimensions.
|
||||
*/
|
||||
private fun forcePiPBufferSize() {
|
||||
if (!isWaitingForPiPTransition || !surfaceReady) return
|
||||
|
||||
val rect = Rect()
|
||||
textureView.getGlobalVisibleRect(rect)
|
||||
val visW = rect.width()
|
||||
val visH = rect.height()
|
||||
val vw = textureView.width
|
||||
val vh = textureView.height
|
||||
|
||||
if (visW <= 0 || visH <= 0 || (vw == visW && vh == visH)) return
|
||||
|
||||
surfaceTexture?.setDefaultBufferSize(visW, visH)
|
||||
renderer?.updateSurfaceSize(visW, visH)
|
||||
|
||||
// Force TextureView layout to match PiP visible area.
|
||||
// layoutParams alone doesn't work during PiP because the parent
|
||||
// never re-lays out its children.
|
||||
textureView.measure(
|
||||
View.MeasureSpec.makeMeasureSpec(visW, View.MeasureSpec.EXACTLY),
|
||||
View.MeasureSpec.makeMeasureSpec(visH, View.MeasureSpec.EXACTLY)
|
||||
)
|
||||
textureView.layout(0, 0, visW, visH)
|
||||
isPiPSurfaceForced = true
|
||||
}
|
||||
|
||||
private fun restoreFromPiP() {
|
||||
if (!isPiPSurfaceForced) return
|
||||
isPiPSurfaceForced = false
|
||||
|
||||
val lp = textureView.layoutParams
|
||||
lp.width = ViewGroup.LayoutParams.MATCH_PARENT
|
||||
lp.height = ViewGroup.LayoutParams.MATCH_PARENT
|
||||
textureView.layoutParams = lp
|
||||
textureView.requestLayout()
|
||||
}
|
||||
|
||||
fun stopPictureInPicture() {
|
||||
isWaitingForPiPTransition = false
|
||||
pipHandler.removeCallbacksAndMessages(null)
|
||||
pipController?.stopPictureInPicture()
|
||||
}
|
||||
|
||||
|
||||
fun isPictureInPictureSupported(): Boolean {
|
||||
return pipController?.isPictureInPictureSupported() ?: false
|
||||
}
|
||||
|
||||
|
||||
fun isPictureInPictureActive(): Boolean {
|
||||
return pipController?.isPictureInPictureActive() ?: false
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Subtitle Controls
|
||||
|
||||
|
||||
fun getSubtitleTracks(): List<Map<String, Any>> {
|
||||
return renderer?.getSubtitleTracks() ?: emptyList()
|
||||
}
|
||||
|
||||
|
||||
fun setSubtitleTrack(trackId: Int) {
|
||||
renderer?.setSubtitleTrack(trackId)
|
||||
}
|
||||
|
||||
|
||||
fun disableSubtitles() {
|
||||
renderer?.disableSubtitles()
|
||||
}
|
||||
|
||||
|
||||
fun getCurrentSubtitleTrack(): Int {
|
||||
return renderer?.getCurrentSubtitleTrack() ?: 0
|
||||
}
|
||||
|
||||
|
||||
fun addSubtitleFile(url: String, select: Boolean = true) {
|
||||
renderer?.addSubtitleFile(url, select)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Subtitle Positioning
|
||||
|
||||
|
||||
fun setSubtitlePosition(position: Int) {
|
||||
renderer?.setSubtitlePosition(position)
|
||||
}
|
||||
|
||||
|
||||
fun setSubtitleScale(scale: Double) {
|
||||
renderer?.setSubtitleScale(scale)
|
||||
}
|
||||
|
||||
|
||||
fun setSubtitleMarginY(margin: Int) {
|
||||
renderer?.setSubtitleMarginY(margin)
|
||||
}
|
||||
|
||||
|
||||
fun setSubtitleAlignX(alignment: String) {
|
||||
renderer?.setSubtitleAlignX(alignment)
|
||||
}
|
||||
|
||||
|
||||
fun setSubtitleAlignY(alignment: String) {
|
||||
renderer?.setSubtitleAlignY(alignment)
|
||||
}
|
||||
|
||||
|
||||
fun setSubtitleFontSize(size: Int) {
|
||||
renderer?.setSubtitleFontSize(size)
|
||||
}
|
||||
@@ -316,15 +393,15 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
}
|
||||
|
||||
// MARK: - Audio Track Controls
|
||||
|
||||
|
||||
fun getAudioTracks(): List<Map<String, Any>> {
|
||||
return renderer?.getAudioTracks() ?: emptyList()
|
||||
}
|
||||
|
||||
|
||||
fun setAudioTrack(trackId: Int) {
|
||||
renderer?.setAudioTrack(trackId)
|
||||
}
|
||||
|
||||
|
||||
fun getCurrentAudioTrack(): Int {
|
||||
return renderer?.getCurrentAudioTrack() ?: 0
|
||||
}
|
||||
@@ -349,16 +426,16 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
}
|
||||
|
||||
// MARK: - MPVLayerRenderer.Delegate
|
||||
|
||||
|
||||
override fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double) {
|
||||
cachedPosition = position
|
||||
cachedDuration = duration
|
||||
|
||||
|
||||
// Update PiP progress
|
||||
if (pipController?.isPictureInPictureActive() == true) {
|
||||
pipController?.setCurrentTime(position, duration)
|
||||
}
|
||||
|
||||
|
||||
onProgress(mapOf(
|
||||
"position" to position,
|
||||
"duration" to duration,
|
||||
@@ -366,50 +443,51 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
"cacheSeconds" to cacheSeconds
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
override fun onPauseChanged(isPaused: Boolean) {
|
||||
// Sync PiP playback rate
|
||||
pipController?.setPlaybackRate(if (isPaused) 0.0 else 1.0)
|
||||
|
||||
|
||||
onPlaybackStateChange(mapOf(
|
||||
"isPaused" to isPaused,
|
||||
"isPlaying" to !isPaused
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
override fun onLoadingChanged(isLoading: Boolean) {
|
||||
onPlaybackStateChange(mapOf(
|
||||
"isLoading" to isLoading
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
override fun onReadyToSeek() {
|
||||
onPlaybackStateChange(mapOf(
|
||||
"isReadyToSeek" to true
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
override fun onTracksReady() {
|
||||
onTracksReady(emptyMap<String, Any>())
|
||||
}
|
||||
|
||||
|
||||
override fun onVideoDimensionsChanged(width: Int, height: Int) {
|
||||
// Update PiP controller with video dimensions for proper aspect ratio
|
||||
pipController?.setVideoDimensions(width, height)
|
||||
}
|
||||
|
||||
|
||||
override fun onError(message: String) {
|
||||
onError(mapOf("error" to message))
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Cleanup
|
||||
|
||||
|
||||
fun cleanup() {
|
||||
isWaitingForPiPTransition = false
|
||||
pipHandler.removeCallbacksAndMessages(null)
|
||||
pipController?.stopPictureInPicture()
|
||||
renderer?.stop()
|
||||
surfaceView.holder.removeCallback(this)
|
||||
surfaceTexture = null
|
||||
surfaceReady = false
|
||||
}
|
||||
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow()
|
||||
cleanup()
|
||||
|
||||
@@ -1,51 +1,62 @@
|
||||
package expo.modules.mpvplayer
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.app.PictureInPictureParams
|
||||
import android.app.RemoteAction
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.drawable.Icon
|
||||
import android.graphics.Rect
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.util.Rational
|
||||
import android.view.View
|
||||
import androidx.annotation.RequiresApi
|
||||
import expo.modules.kotlin.AppContext
|
||||
|
||||
/**
|
||||
* Picture-in-Picture controller for Android.
|
||||
* This mirrors the iOS PiPController implementation.
|
||||
*/
|
||||
class PiPController(private val context: Context, private val appContext: AppContext? = null) {
|
||||
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PiPController"
|
||||
private const val DEFAULT_ASPECT_WIDTH = 16
|
||||
private const val DEFAULT_ASPECT_HEIGHT = 9
|
||||
private const val ACTION_PIP_PLAY_PAUSE = "expo.modules.mpvplayer.PIP_PLAY_PAUSE"
|
||||
private const val ACTION_PIP_SKIP_FORWARD = "expo.modules.mpvplayer.PIP_SKIP_FORWARD"
|
||||
private const val ACTION_PIP_SKIP_BACKWARD = "expo.modules.mpvplayer.PIP_SKIP_BACKWARD"
|
||||
}
|
||||
|
||||
|
||||
interface Delegate {
|
||||
fun onPlay()
|
||||
fun onPause()
|
||||
fun onSeekBy(seconds: Double)
|
||||
fun onPictureInPictureModeChanged(isInPiP: Boolean)
|
||||
}
|
||||
|
||||
|
||||
var delegate: Delegate? = null
|
||||
|
||||
|
||||
private var currentPosition: Double = 0.0
|
||||
private var currentDuration: Double = 0.0
|
||||
private var playbackRate: Double = 1.0
|
||||
|
||||
// Video dimensions for proper aspect ratio
|
||||
|
||||
private var videoWidth: Int = 0
|
||||
private var videoHeight: Int = 0
|
||||
|
||||
// Reference to the player view for source rect
|
||||
private var playerView: View? = null
|
||||
|
||||
/**
|
||||
* Check if Picture-in-Picture is supported on this device
|
||||
*/
|
||||
|
||||
// PiP state tracking
|
||||
private var isInPiPMode: Boolean = false
|
||||
private var pipEntryNotified: Boolean = false
|
||||
private val pipHandler = Handler(Looper.getMainLooper())
|
||||
private var lifecycleCallbacks: Application.ActivityLifecycleCallbacks? = null
|
||||
private var lifecycleRegistered = false
|
||||
private var pipBroadcastReceiver: BroadcastReceiver? = null
|
||||
|
||||
fun isPictureInPictureSupported(): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
|
||||
@@ -53,10 +64,7 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Picture-in-Picture is currently active
|
||||
*/
|
||||
|
||||
fun isPictureInPictureActive(): Boolean {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val activity = getActivity()
|
||||
@@ -64,69 +72,69 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Start Picture-in-Picture mode
|
||||
*/
|
||||
|
||||
fun startPictureInPicture() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val activity = getActivity()
|
||||
if (activity == null) {
|
||||
Log.e(TAG, "Cannot start PiP: no activity found")
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
|
||||
val activity = getActivity() ?: run {
|
||||
Log.e(TAG, "Cannot start PiP: no activity")
|
||||
return
|
||||
}
|
||||
|
||||
if (!isPictureInPictureSupported()) {
|
||||
Log.e(TAG, "PiP not supported on this device")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val params = buildPiPParams(forEntering = true)
|
||||
val result = activity.enterPictureInPictureMode(params)
|
||||
|
||||
if (!result) {
|
||||
Log.e(TAG, "enterPictureInPictureMode rejected by system")
|
||||
isInPiPMode = false
|
||||
return
|
||||
}
|
||||
|
||||
if (!isPictureInPictureSupported()) {
|
||||
Log.e(TAG, "PiP not supported on this device")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val params = buildPiPParams(forEntering = true)
|
||||
activity.enterPictureInPictureMode(params)
|
||||
Log.i(TAG, "Entered PiP mode")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to enter PiP: ${e.message}")
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "PiP requires Android O or higher")
|
||||
|
||||
isInPiPMode = true
|
||||
pipEntryNotified = true
|
||||
delegate?.onPictureInPictureModeChanged(true)
|
||||
registerLifecycleCallbacks()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to enter PiP: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop Picture-in-Picture mode
|
||||
*/
|
||||
|
||||
fun stopPictureInPicture() {
|
||||
// On Android, exiting PiP is typically done by the user
|
||||
// or by finishing the activity. We can request to move task to back.
|
||||
isInPiPMode = false
|
||||
pipEntryNotified = false
|
||||
unregisterLifecycleCallbacks()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val activity = getActivity()
|
||||
if (activity?.isInPictureInPictureMode == true) {
|
||||
// Move task to back which will exit PiP
|
||||
activity.moveTaskToBack(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current playback position and duration
|
||||
* Note: We don't update PiP params here as we're not using progress in PiP controls
|
||||
*/
|
||||
|
||||
fun isCurrentlyInPiP(): Boolean = isInPiPMode
|
||||
|
||||
fun setCurrentTime(position: Double, duration: Double) {
|
||||
currentPosition = position
|
||||
currentDuration = duration
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the playback rate (0.0 for paused, 1.0 for playing)
|
||||
*/
|
||||
|
||||
fun setPlaybackRate(rate: Double) {
|
||||
playbackRate = rate
|
||||
|
||||
// Update PiP params to reflect play/pause state
|
||||
|
||||
if (rate > 0) {
|
||||
registerLifecycleCallbacks()
|
||||
}
|
||||
|
||||
// Update PiP params so autoEnterEnabled and action icons track play/pause state
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val activity = getActivity()
|
||||
if (activity?.isInPictureInPictureMode == true) {
|
||||
if (activity != null) {
|
||||
try {
|
||||
activity.setPictureInPictureParams(buildPiPParams())
|
||||
} catch (e: Exception) {
|
||||
@@ -135,28 +143,19 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the video dimensions for proper aspect ratio calculation
|
||||
*/
|
||||
|
||||
fun setVideoDimensions(width: Int, height: Int) {
|
||||
if (width > 0 && height > 0) {
|
||||
videoWidth = width
|
||||
videoHeight = height
|
||||
Log.i(TAG, "Video dimensions set: ${width}x${height}")
|
||||
|
||||
// Update PiP params if active
|
||||
updatePiPParamsIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the player view reference for source rect hint
|
||||
*/
|
||||
|
||||
fun setPlayerView(view: View?) {
|
||||
playerView = view
|
||||
}
|
||||
|
||||
|
||||
private fun updatePiPParamsIfNeeded() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val activity = getActivity()
|
||||
@@ -169,23 +168,16 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Picture-in-Picture params for the current player state.
|
||||
* Calculates proper aspect ratio and source rect based on video and view dimensions.
|
||||
*/
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun buildPiPParams(forEntering: Boolean = false): PictureInPictureParams {
|
||||
val view = playerView
|
||||
val viewWidth = view?.width ?: 0
|
||||
val viewHeight = view?.height ?: 0
|
||||
|
||||
// Display aspect ratio from view (exactly like Findroid)
|
||||
|
||||
val displayAspectRatio = Rational(viewWidth.coerceAtLeast(1), viewHeight.coerceAtLeast(1))
|
||||
|
||||
// Video aspect ratio with 2.39:1 clamping (exactly like Findroid)
|
||||
// Findroid: Rational(it.width.coerceAtMost((it.height * 2.39f).toInt()),
|
||||
// it.height.coerceAtMost((it.width * 2.39f).toInt()))
|
||||
|
||||
// Video aspect ratio with 2.39:1 clamping
|
||||
val aspectRatio = if (videoWidth > 0 && videoHeight > 0) {
|
||||
Rational(
|
||||
videoWidth.coerceAtMost((videoHeight * 2.39f).toInt()),
|
||||
@@ -194,70 +186,235 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
||||
} else {
|
||||
Rational(DEFAULT_ASPECT_WIDTH, DEFAULT_ASPECT_HEIGHT)
|
||||
}
|
||||
|
||||
// Source rect hint calculation (exactly like Findroid)
|
||||
|
||||
val sourceRectHint = if (viewWidth > 0 && viewHeight > 0 && videoWidth > 0 && videoHeight > 0) {
|
||||
if (displayAspectRatio < aspectRatio) {
|
||||
// Letterboxing - black bars top/bottom
|
||||
val space = ((viewHeight - (viewWidth.toFloat() / aspectRatio.toFloat())) / 2).toInt()
|
||||
Rect(
|
||||
0,
|
||||
space,
|
||||
viewWidth,
|
||||
(viewWidth.toFloat() / aspectRatio.toFloat()).toInt() + space
|
||||
)
|
||||
Rect(0, space, viewWidth, (viewWidth.toFloat() / aspectRatio.toFloat()).toInt() + space)
|
||||
} else {
|
||||
// Pillarboxing - black bars left/right
|
||||
val space = ((viewWidth - (viewHeight.toFloat() * aspectRatio.toFloat())) / 2).toInt()
|
||||
Rect(
|
||||
space,
|
||||
0,
|
||||
(viewHeight.toFloat() * aspectRatio.toFloat()).toInt() + space,
|
||||
viewHeight
|
||||
)
|
||||
Rect(space, 0, (viewHeight.toFloat() * aspectRatio.toFloat()).toInt() + space, viewHeight)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
|
||||
val builder = PictureInPictureParams.Builder()
|
||||
.setAspectRatio(aspectRatio)
|
||||
|
||||
|
||||
sourceRectHint?.let { builder.setSourceRectHint(it) }
|
||||
|
||||
// On Android 12+, enable auto-enter (like Findroid)
|
||||
|
||||
ensurePiPReceiverRegistered()
|
||||
builder.setActions(buildPiPActions())
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
builder.setAutoEnterEnabled(true)
|
||||
builder.setAutoEnterEnabled(forEntering || playbackRate > 0)
|
||||
}
|
||||
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
|
||||
private fun getActivity(): Activity? {
|
||||
// First try Expo's AppContext (preferred in React Native)
|
||||
appContext?.currentActivity?.let { return it }
|
||||
|
||||
// Fallback: Try to get from context wrapper chain
|
||||
|
||||
var ctx = context
|
||||
while (ctx is android.content.ContextWrapper) {
|
||||
if (ctx is Activity) {
|
||||
return ctx
|
||||
}
|
||||
if (ctx is Activity) return ctx
|
||||
ctx = ctx.baseContext
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle PiP action (called from activity when user taps PiP controls)
|
||||
*/
|
||||
fun handlePiPAction(action: String) {
|
||||
when (action) {
|
||||
"play" -> delegate?.onPlay()
|
||||
"pause" -> delegate?.onPause()
|
||||
"skip_forward" -> delegate?.onSeekBy(10.0)
|
||||
"skip_backward" -> delegate?.onSeekBy(-10.0)
|
||||
|
||||
// MARK: - Lifecycle-based PiP Detection
|
||||
|
||||
private fun registerLifecycleCallbacks() {
|
||||
if (lifecycleRegistered) return
|
||||
|
||||
val app = context.applicationContext as? Application ?: run {
|
||||
Log.w(TAG, "Cannot access Application for lifecycle callbacks, falling back to polling")
|
||||
startFallbackPolling()
|
||||
return
|
||||
}
|
||||
|
||||
lifecycleCallbacks = object : Application.ActivityLifecycleCallbacks {
|
||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
|
||||
override fun onActivityStarted(activity: Activity) {}
|
||||
|
||||
override fun onActivityResumed(activity: Activity) {
|
||||
if (!isInPiPMode) return
|
||||
if (!activity.isInPictureInPictureMode) {
|
||||
isInPiPMode = false
|
||||
pipEntryNotified = false
|
||||
delegate?.onPictureInPictureModeChanged(false)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityPaused(activity: Activity) {
|
||||
// Proactively hide controls when user leaves while playing,
|
||||
// before the PiP window captures the UI. onActivityStopped
|
||||
// will restore if PiP didn't actually enter.
|
||||
if (playbackRate > 0 && !isInPiPMode) {
|
||||
isInPiPMode = true
|
||||
pipEntryNotified = true
|
||||
delegate?.onPictureInPictureModeChanged(true)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityStopped(activity: Activity) {
|
||||
pipHandler.postDelayed({
|
||||
val inPip = activity.isInPictureInPictureMode
|
||||
|
||||
if (inPip && !isInPiPMode) {
|
||||
isInPiPMode = true
|
||||
pipEntryNotified = true
|
||||
delegate?.onPictureInPictureModeChanged(true)
|
||||
return@postDelayed
|
||||
}
|
||||
|
||||
if (!isInPiPMode) return@postDelayed
|
||||
if (inPip) return@postDelayed
|
||||
|
||||
// Not in PiP after 1s — check again to avoid false positive during transition
|
||||
pipHandler.postDelayed({
|
||||
if (!isInPiPMode) return@postDelayed
|
||||
if (!activity.isInPictureInPictureMode) {
|
||||
isInPiPMode = false
|
||||
pipEntryNotified = false
|
||||
delegate?.onPictureInPictureModeChanged(false)
|
||||
}
|
||||
}, 1500)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
|
||||
|
||||
override fun onActivityDestroyed(activity: Activity) {
|
||||
isInPiPMode = false
|
||||
}
|
||||
}
|
||||
|
||||
app.registerActivityLifecycleCallbacks(lifecycleCallbacks)
|
||||
lifecycleRegistered = true
|
||||
}
|
||||
|
||||
private fun unregisterLifecycleCallbacks() {
|
||||
if (!lifecycleRegistered) return
|
||||
lifecycleCallbacks?.let {
|
||||
(context.applicationContext as? Application)
|
||||
?.unregisterActivityLifecycleCallbacks(it)
|
||||
}
|
||||
lifecycleCallbacks = null
|
||||
lifecycleRegistered = false
|
||||
pipHandler.removeCallbacksAndMessages(null)
|
||||
unregisterPiPBroadcastReceiver()
|
||||
}
|
||||
|
||||
private fun startFallbackPolling() {
|
||||
var falseReadCount = 0
|
||||
pipHandler.removeCallbacksAndMessages(null)
|
||||
pipHandler.postDelayed(object : Runnable {
|
||||
override fun run() {
|
||||
if (!isInPiPMode) return
|
||||
|
||||
var ctx = context
|
||||
var activity: Activity? = null
|
||||
while (ctx is android.content.ContextWrapper) {
|
||||
if (ctx is Activity) { activity = ctx; break }
|
||||
ctx = ctx.baseContext
|
||||
}
|
||||
|
||||
val stillInPip = activity?.isInPictureInPictureMode == true
|
||||
|
||||
if (!stillInPip) {
|
||||
falseReadCount++
|
||||
if (falseReadCount >= 3) {
|
||||
isInPiPMode = false
|
||||
delegate?.onPictureInPictureModeChanged(false)
|
||||
return
|
||||
}
|
||||
pipHandler.postDelayed(this, 500)
|
||||
return
|
||||
}
|
||||
|
||||
falseReadCount = 0
|
||||
pipHandler.postDelayed(this, 1000)
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
// MARK: - PiP Remote Actions
|
||||
|
||||
private fun ensurePiPReceiverRegistered() {
|
||||
if (pipBroadcastReceiver != null) return
|
||||
|
||||
pipBroadcastReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
ACTION_PIP_PLAY_PAUSE -> {
|
||||
if (playbackRate > 0) delegate?.onPause() else delegate?.onPlay()
|
||||
}
|
||||
ACTION_PIP_SKIP_FORWARD -> delegate?.onSeekBy(10.0)
|
||||
ACTION_PIP_SKIP_BACKWARD -> delegate?.onSeekBy(-10.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(ACTION_PIP_PLAY_PAUSE)
|
||||
addAction(ACTION_PIP_SKIP_FORWARD)
|
||||
addAction(ACTION_PIP_SKIP_BACKWARD)
|
||||
}
|
||||
val registerFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
Context.RECEIVER_EXPORTED
|
||||
} else {
|
||||
0
|
||||
}
|
||||
context.applicationContext.registerReceiver(pipBroadcastReceiver, filter, registerFlags)
|
||||
}
|
||||
|
||||
private fun unregisterPiPBroadcastReceiver() {
|
||||
pipBroadcastReceiver?.let {
|
||||
try {
|
||||
context.applicationContext.unregisterReceiver(it)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
pipBroadcastReceiver = null
|
||||
}
|
||||
|
||||
private fun buildPiPActions(): List<RemoteAction> {
|
||||
val isPlaying = playbackRate > 0
|
||||
|
||||
return listOf(
|
||||
RemoteAction(
|
||||
Icon.createWithResource(context, android.R.drawable.ic_media_rew),
|
||||
"Rewind", "Skip backward 10 seconds",
|
||||
createPiPPendingIntent(ACTION_PIP_SKIP_BACKWARD)
|
||||
),
|
||||
RemoteAction(
|
||||
Icon.createWithResource(
|
||||
context,
|
||||
if (isPlaying) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play
|
||||
),
|
||||
if (isPlaying) "Pause" else "Play",
|
||||
if (isPlaying) "Pause playback" else "Resume playback",
|
||||
createPiPPendingIntent(ACTION_PIP_PLAY_PAUSE)
|
||||
),
|
||||
RemoteAction(
|
||||
Icon.createWithResource(context, android.R.drawable.ic_media_ff),
|
||||
"Fast Forward", "Skip forward 10 seconds",
|
||||
createPiPPendingIntent(ACTION_PIP_SKIP_FORWARD)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun createPiPPendingIntent(action: String): android.app.PendingIntent {
|
||||
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
android.app.PendingIntent.FLAG_IMMUTABLE
|
||||
} else {
|
||||
0
|
||||
}
|
||||
return android.app.PendingIntent.getBroadcast(
|
||||
context.applicationContext, 0, Intent(action), flags
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,10 @@ export type OnErrorEventPayload = {
|
||||
|
||||
export type OnTracksReadyEventPayload = Record<string, never>;
|
||||
|
||||
export type OnPictureInPictureChangePayload = {
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
export type NowPlayingMetadata = {
|
||||
title?: string;
|
||||
artist?: string;
|
||||
@@ -77,6 +81,9 @@ export type MpvPlayerViewProps = {
|
||||
onProgress?: (event: { nativeEvent: OnProgressEventPayload }) => void;
|
||||
onError?: (event: { nativeEvent: OnErrorEventPayload }) => void;
|
||||
onTracksReady?: (event: { nativeEvent: OnTracksReadyEventPayload }) => void;
|
||||
onPictureInPictureChange?: (event: {
|
||||
nativeEvent: OnPictureInPictureChangePayload;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
export interface MpvPlayerViewRef {
|
||||
|
||||
@@ -7,6 +7,8 @@ import { MpvPlayerViewProps, MpvPlayerViewRef } from "./MpvPlayer.types";
|
||||
const NativeView: React.ComponentType<MpvPlayerViewProps & { ref?: any }> =
|
||||
requireNativeView("MpvPlayer");
|
||||
|
||||
const PIP_LOG = "[PiP] MpvPlayerView.tsx:";
|
||||
|
||||
export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
|
||||
function MpvPlayerView(props, ref) {
|
||||
const nativeRef = useRef<any>(null);
|
||||
@@ -40,16 +42,24 @@ export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
|
||||
return await nativeRef.current?.getDuration();
|
||||
},
|
||||
startPictureInPicture: async () => {
|
||||
console.log(PIP_LOG, "startPictureInPicture → native");
|
||||
await nativeRef.current?.startPictureInPicture();
|
||||
console.log(PIP_LOG, "startPictureInPicture ← native returned");
|
||||
},
|
||||
stopPictureInPicture: async () => {
|
||||
console.log(PIP_LOG, "stopPictureInPicture → native");
|
||||
await nativeRef.current?.stopPictureInPicture();
|
||||
console.log(PIP_LOG, "stopPictureInPicture ← native returned");
|
||||
},
|
||||
isPictureInPictureSupported: async () => {
|
||||
return await nativeRef.current?.isPictureInPictureSupported();
|
||||
const result = await nativeRef.current?.isPictureInPictureSupported();
|
||||
console.log(PIP_LOG, "isPictureInPictureSupported =", result);
|
||||
return result;
|
||||
},
|
||||
isPictureInPictureActive: async () => {
|
||||
return await nativeRef.current?.isPictureInPictureActive();
|
||||
const result = await nativeRef.current?.isPictureInPictureActive();
|
||||
console.log(PIP_LOG, "isPictureInPictureActive =", result);
|
||||
return result;
|
||||
},
|
||||
getSubtitleTracks: async () => {
|
||||
return await nativeRef.current?.getSubtitleTracks();
|
||||
|
||||
98
package.json
98
package.json
@@ -28,59 +28,61 @@
|
||||
"dependencies": {
|
||||
"@bottom-tabs/react-navigation": "1.2.0",
|
||||
"@douglowder/expo-av-route-picker-view": "^0.0.5",
|
||||
"@expo/metro-runtime": "~56.0.13",
|
||||
"@expo/metro-runtime": "~55.0.11",
|
||||
"@expo/react-native-action-sheet": "^4.1.1",
|
||||
"@expo/ui": "~56.0.14",
|
||||
"@expo/ui": "~55.0.17",
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@gorhom/bottom-sheet": "5.2.8",
|
||||
"@jellyfin/sdk": "^0.13.0",
|
||||
"@react-native-community/netinfo": "^12.0.0",
|
||||
"@react-navigation/material-top-tabs": "7.4.28",
|
||||
"@react-navigation/native": "^7.2.5",
|
||||
"@react-navigation/native-stack": "~7.14.5",
|
||||
"@shopify/flash-list": "2.0.2",
|
||||
"@tanstack/query-sync-storage-persister": "^5.100.14",
|
||||
"@tanstack/react-pacer": "^0.19.1",
|
||||
"@tanstack/react-query": "5.100.14",
|
||||
"@tanstack/react-query-persist-client": "^5.100.14",
|
||||
"axios": "^1.7.9",
|
||||
"expo": "~56.0.6",
|
||||
"expo-application": "~56.0.3",
|
||||
"expo-asset": "~56.0.15",
|
||||
"expo-audio": "~56.0.11",
|
||||
"expo-background-task": "~56.0.15",
|
||||
"expo-blur": "~56.0.3",
|
||||
"expo-brightness": "~56.0.5",
|
||||
"expo-build-properties": "~56.0.15",
|
||||
"expo-camera": "~56.0.7",
|
||||
"expo-constants": "~56.0.16",
|
||||
"expo-crypto": "~56.0.4",
|
||||
"expo-dev-client": "~56.0.16",
|
||||
"expo-device": "~56.0.4",
|
||||
"expo-font": "~56.0.5",
|
||||
"expo-haptics": "~56.0.3",
|
||||
"expo-image": "~56.0.9",
|
||||
"expo-linear-gradient": "~56.0.4",
|
||||
"expo-linking": "~56.0.12",
|
||||
"expo-localization": "~56.0.6",
|
||||
"expo-location": "~56.0.14",
|
||||
"expo-notifications": "~56.0.14",
|
||||
"expo-router": "~56.2.7",
|
||||
"expo-screen-orientation": "~56.0.5",
|
||||
"expo-secure-store": "~56.0.4",
|
||||
"expo-sharing": "~56.0.14",
|
||||
"expo-splash-screen": "~56.0.10",
|
||||
"expo-status-bar": "~56.0.4",
|
||||
"expo-system-ui": "~56.0.5",
|
||||
"expo-task-manager": "~56.0.15",
|
||||
"expo-web-browser": "~56.0.5",
|
||||
"expo": "~55.0.26",
|
||||
"expo-application": "~55.0.15",
|
||||
"expo-asset": "~55.0.17",
|
||||
"expo-audio": "~55.0.0",
|
||||
"expo-background-task": "~55.0.18",
|
||||
"expo-blur": "~55.0.14",
|
||||
"expo-brightness": "~55.0.13",
|
||||
"expo-build-properties": "~55.0.14",
|
||||
"expo-camera": "~55.0.19",
|
||||
"expo-constants": "~55.0.16",
|
||||
"expo-crypto": "~55.0.15",
|
||||
"expo-dev-client": "~55.0.35",
|
||||
"expo-device": "~55.0.17",
|
||||
"expo-font": "~55.0.8",
|
||||
"expo-haptics": "~55.0.14",
|
||||
"expo-image": "~55.0.11",
|
||||
"expo-linear-gradient": "~55.0.14",
|
||||
"expo-linking": "~55.0.15",
|
||||
"expo-localization": "~55.0.15",
|
||||
"expo-location": "~55.1.10",
|
||||
"expo-notifications": "~55.0.23",
|
||||
"expo-router": "~55.0.16",
|
||||
"expo-screen-orientation": "~55.0.16",
|
||||
"expo-secure-store": "~55.0.14",
|
||||
"expo-sharing": "~55.0.20",
|
||||
"expo-splash-screen": "~55.0.21",
|
||||
"expo-status-bar": "~55.0.6",
|
||||
"expo-system-ui": "~55.0.18",
|
||||
"expo-task-manager": "~55.0.16",
|
||||
"expo-web-browser": "~55.0.16",
|
||||
"i18next": "^26.3.0",
|
||||
"jotai": "2.20.0",
|
||||
"lodash": "4.18.1",
|
||||
"nativewind": "^2.0.11",
|
||||
"patch-package": "^8.0.0",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-i18next": "17.0.8",
|
||||
"react-native": "npm:react-native-tvos@0.85.3-0",
|
||||
"react-native": "npm:react-native-tvos@0.83.6-0",
|
||||
"react-native-awesome-slider": "^2.9.0",
|
||||
"react-native-bottom-tabs": "1.2.0",
|
||||
"react-native-circular-progress": "^1.4.1",
|
||||
@@ -89,7 +91,7 @@
|
||||
"react-native-device-info": "^15.0.0",
|
||||
"react-native-draggable-flatlist": "^4.0.3",
|
||||
"react-native-edge-to-edge": "^1.7.0",
|
||||
"react-native-gesture-handler": "~2.31.1",
|
||||
"react-native-gesture-handler": "~2.30.0",
|
||||
"react-native-glass-effect-view": "^1.0.0",
|
||||
"react-native-google-cast": "^4.9.1",
|
||||
"react-native-image-colors": "^2.4.0",
|
||||
@@ -97,13 +99,13 @@
|
||||
"react-native-ios-utilities": "5.2.0",
|
||||
"react-native-mmkv": "4.1.1",
|
||||
"react-native-nitro-modules": "0.33.1",
|
||||
"react-native-pager-view": "8.0.1",
|
||||
"react-native-pager-view": "8.0.0",
|
||||
"react-native-qrcode-svg": "^6.3.21",
|
||||
"react-native-reanimated": "4.3.1",
|
||||
"react-native-reanimated": "4.2.1",
|
||||
"react-native-reanimated-carousel": "4.0.3",
|
||||
"react-native-safe-area-context": "~5.7.0",
|
||||
"react-native-screens": "4.25.2",
|
||||
"react-native-svg": "15.15.4",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.18.0",
|
||||
"react-native-svg": "15.15.3",
|
||||
"react-native-text-ticker": "^1.15.0",
|
||||
"react-native-track-player": "github:lovegaoshi/react-native-track-player#APM",
|
||||
"react-native-udp": "^4.1.7",
|
||||
@@ -111,19 +113,19 @@
|
||||
"react-native-uuid": "^2.0.3",
|
||||
"react-native-volume-manager": "^2.0.8",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-worklets": "0.8.3",
|
||||
"react-native-worklets": "0.7.4",
|
||||
"sonner-native": "0.21.2",
|
||||
"tailwindcss": "3.3.2",
|
||||
"use-debounce": "^10.0.4",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.29.7",
|
||||
"@biomejs/biome": "2.3.11",
|
||||
"@babel/core": "7.28.6",
|
||||
"@biomejs/biome": "2.4.16",
|
||||
"@react-native-community/cli": "20.1.3",
|
||||
"@react-native-tvos/config-tv": "0.1.6",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/lodash": "4.17.24",
|
||||
"@types/lodash": "4.17.23",
|
||||
"@types/react": "~19.2.10",
|
||||
"@types/react-test-renderer": "19.1.0",
|
||||
"cross-env": "10.1.0",
|
||||
@@ -146,7 +148,6 @@
|
||||
},
|
||||
"install": {
|
||||
"exclude": [
|
||||
"react-native",
|
||||
"react-native-screens"
|
||||
]
|
||||
}
|
||||
@@ -164,8 +165,9 @@
|
||||
"unrs-resolver"
|
||||
],
|
||||
"patchedDependencies": {
|
||||
"react-native-screens@4.18.0": "bun-patches/react-native-screens@4.18.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",
|
||||
"react-native-ios-utilities@5.2.0": "bun-patches/react-native-ios-utilities@5.2.0.patch"
|
||||
"@react-native/codegen@0.83.6": "bun-patches/@react-native%2Fcodegen@0.83.6.patch",
|
||||
"react-native-bottom-tabs@1.2.0": "bun-patches/react-native-bottom-tabs@1.2.0.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,17 +25,6 @@ function buildPatch() {
|
||||
" cfg.build_settings['HEADER_SEARCH_PATHS'] ||= '$(inherited)'",
|
||||
" cfg.build_settings['HEADER_SEARCH_PATHS'] << \" #{extra_hdrs.join(' ')}\"",
|
||||
" cfg.build_settings['CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES'] = 'YES'",
|
||||
" # iOS 26 / Xcode 26: SwiftUI was split into SwiftUI + SwiftUICore. The SwiftUI",
|
||||
" # pods (ExpoUI, glass-effect, glass-poster, …) emit a `-framework SwiftUICore`",
|
||||
" # autolink directive that, under use_frameworks :static, flows into the app",
|
||||
" # executable's link. The app isn't an allowed client of the private",
|
||||
" # SwiftUICore.tbd → `cannot link directly with 'SwiftUICore'`. Dropping that one",
|
||||
" # autolink at the Swift frontend lets the symbols resolve via SwiftUI's",
|
||||
" # re-export instead. Phone-only — tvOS links fine and must stay untouched.",
|
||||
" if ENV['EXPO_TV'] != '1'",
|
||||
" cfg.build_settings['OTHER_SWIFT_FLAGS'] ||= '$(inherited)'",
|
||||
" cfg.build_settings['OTHER_SWIFT_FLAGS'] << ' -Xfrontend -disable-autolink-framework -Xfrontend SwiftUICore'",
|
||||
" end",
|
||||
" end",
|
||||
" end",
|
||||
"",
|
||||
|
||||
@@ -122,7 +122,7 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
||||
|
||||
const handlePlayCommand = useCallback(
|
||||
(data: any) => {
|
||||
if (!data || !data.ItemIds || !data.ItemIds.length) {
|
||||
if (!data?.ItemIds?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
||||
}, [connectWebSocket]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!deviceId || !api || !api?.accessToken || !isNetworkConnected) {
|
||||
if (!deviceId || !api?.accessToken || !isNetworkConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -525,23 +525,10 @@ function displayBuildError(
|
||||
console.error(line);
|
||||
}
|
||||
console.error("--- End Build Errors ---\n");
|
||||
}
|
||||
|
||||
// Linker failures ("Undefined symbols for architecture …", the SwiftUICore
|
||||
// autolink rejection, "ld: …") don't carry an "error:" token, so the pattern
|
||||
// filter above drops the symbol name and "referenced from" context that
|
||||
// actually pinpoints the culprit. Surface that block explicitly.
|
||||
const stdoutLines = stdout.split("\n");
|
||||
const undefIdx = stdoutLines.findIndex((line: string) =>
|
||||
line.includes("Undefined symbols"),
|
||||
);
|
||||
if (undefIdx >= 0) {
|
||||
console.error("\n--- Linker error detail ---");
|
||||
console.error(stdoutLines.slice(undefIdx, undefIdx + 40).join("\n"));
|
||||
console.error("--- End linker error detail ---\n");
|
||||
} else if (errorLines.length === 0 && stdout.trim()) {
|
||||
} else if (stdout.trim()) {
|
||||
// No specific error patterns found, show last N lines of stdout
|
||||
const lastLines = stdoutLines.slice(-ERROR_OUTPUT_TAIL_LINES).join("\n");
|
||||
const lines = stdout.split("\n");
|
||||
const lastLines = lines.slice(-ERROR_OUTPUT_TAIL_LINES).join("\n");
|
||||
console.error(
|
||||
`\n--- Last ${ERROR_OUTPUT_TAIL_LINES} lines of build output ---`,
|
||||
);
|
||||
|
||||
@@ -156,4 +156,4 @@ class StreamRanker {
|
||||
}
|
||||
}
|
||||
|
||||
export { StreamRanker, SubtitleStreamRanker, AudioStreamRanker };
|
||||
export { AudioStreamRanker, StreamRanker, SubtitleStreamRanker };
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useFocusEffect } from "@react-navigation/core";
|
||||
import {
|
||||
type QueryKey,
|
||||
type UseQueryOptions,
|
||||
type UseQueryResult,
|
||||
useQuery,
|
||||
} from "@tanstack/react-query";
|
||||
import { useFocusEffect } from "expo-router/react-navigation";
|
||||
import { useCallback } from "react";
|
||||
|
||||
export function useReactNavigationQuery<
|
||||
|
||||
Reference in New Issue
Block a user