mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 23:59:08 +00:00
Compare commits
29 Commits
fix/github
...
mpv-player
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9725d499cd | ||
|
|
4f1567bfb3 | ||
|
|
2ead569fb7 | ||
|
|
d1fdea76e8 | ||
|
|
fd2d420320 | ||
|
|
b573a25203 | ||
|
|
074222050a | ||
|
|
7135be198a | ||
|
|
2648877eb8 | ||
|
|
bc78346760 | ||
|
|
c76d7eb877 | ||
|
|
36655bba43 | ||
|
|
cf269ba83e | ||
|
|
24d5fdefdf | ||
|
|
c05cef295e | ||
|
|
3c57829360 | ||
|
|
06349a4319 | ||
|
|
55ac9ae9d4 | ||
|
|
c8bdcc4df0 | ||
|
|
e7013edd84 | ||
|
|
991b45de06 | ||
|
|
97fe899cb0 | ||
|
|
86d7642dca | ||
|
|
631a5ef94e | ||
|
|
8b8b928837 | ||
|
|
56a3c62ed2 | ||
|
|
82683407da | ||
|
|
7b146e30bd | ||
|
|
5f48bec0f2 |
9
.github/ISSUE_TEMPLATE/issue_report.yml
vendored
9
.github/ISSUE_TEMPLATE/issue_report.yml
vendored
@@ -77,13 +77,8 @@ body:
|
|||||||
label: Streamyfin Version
|
label: Streamyfin Version
|
||||||
description: What version of Streamyfin are you running?
|
description: What version of Streamyfin are you running?
|
||||||
options:
|
options:
|
||||||
|
- 0.47.1
|
||||||
- 0.30.2
|
- 0.30.2
|
||||||
- 0.29.0
|
|
||||||
- 0.28.0
|
|
||||||
- 0.27.0
|
|
||||||
- 0.26.1
|
|
||||||
- 0.26.0
|
|
||||||
- 0.25.0
|
|
||||||
- older
|
- older
|
||||||
- TestFlight/Development build
|
- TestFlight/Development build
|
||||||
validations:
|
validations:
|
||||||
@@ -116,4 +111,4 @@ body:
|
|||||||
id: additional-info
|
id: additional-info
|
||||||
attributes:
|
attributes:
|
||||||
label: Additional information
|
label: Additional information
|
||||||
description: Any additional context that might help us understand and reproduce the issue.
|
description: Any additional context that might help us understand and reproduce the issue.
|
||||||
|
|||||||
2
.github/workflows/build-apps.yml
vendored
2
.github/workflows/build-apps.yml
vendored
@@ -192,7 +192,7 @@ jobs:
|
|||||||
run: bun run prebuild
|
run: bun run prebuild
|
||||||
|
|
||||||
- name: 🔧 Setup Xcode
|
- name: 🔧 Setup Xcode
|
||||||
uses: maxim-lobanov/setup-xcode@v1
|
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
|
||||||
with:
|
with:
|
||||||
xcode-version: "26.0.1"
|
xcode-version: "26.0.1"
|
||||||
|
|
||||||
|
|||||||
4
.github/workflows/ci-codeql.yml
vendored
4
.github/workflows/ci-codeql.yml
vendored
@@ -25,10 +25,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
with:
|
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
|
||||||
show-progress: false
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: 🏁 Initialize CodeQL
|
- name: 🏁 Initialize CodeQL
|
||||||
uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -19,7 +19,7 @@ web-build/
|
|||||||
/androidtv
|
/androidtv
|
||||||
|
|
||||||
# Module-specific Builds
|
# Module-specific Builds
|
||||||
modules/vlc-player/android/build
|
modules/mpv-player/android/build
|
||||||
modules/player/android
|
modules/player/android
|
||||||
modules/hls-downloader/android/build
|
modules/hls-downloader/android/build
|
||||||
|
|
||||||
|
|||||||
15
app.json
15
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.47.1",
|
"version": "0.48.0",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"versionCode": 84,
|
"versionCode": 85,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/icon-android-plain.png",
|
"foregroundImage": "./assets/images/icon-android-plain.png",
|
||||||
"monochromeImage": "./assets/images/icon-android-themed.png",
|
"monochromeImage": "./assets/images/icon-android-themed.png",
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
"targetSdkVersion": 35,
|
"targetSdkVersion": 35,
|
||||||
"buildToolsVersion": "35.0.0",
|
"buildToolsVersion": "35.0.0",
|
||||||
"kotlinVersion": "2.0.21",
|
"kotlinVersion": "2.0.21",
|
||||||
"minSdkVersion": 24,
|
"minSdkVersion": 26,
|
||||||
"usesCleartextTraffic": true,
|
"usesCleartextTraffic": true,
|
||||||
"packagingOptions": {
|
"packagingOptions": {
|
||||||
"jniLibs": {
|
"jniLibs": {
|
||||||
@@ -133,7 +133,14 @@
|
|||||||
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
||||||
["./plugins/withAndroidManifest.js"],
|
["./plugins/withAndroidManifest.js"],
|
||||||
["./plugins/withTrustLocalCerts.js"],
|
["./plugins/withTrustLocalCerts.js"],
|
||||||
["./plugins/withGradleProperties.js"]
|
["./plugins/withGradleProperties.js"],
|
||||||
|
[
|
||||||
|
"./plugins/withGitPod.js",
|
||||||
|
{
|
||||||
|
"podName": "MPVKit-GPL",
|
||||||
|
"podspecUrl": "https://raw.githubusercontent.com/Alexk2309/MPVKit/0.40.0-av/MPVKit-GPL.podspec"
|
||||||
|
}
|
||||||
|
]
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ const Page: React.FC = () => {
|
|||||||
const { offline } = useLocalSearchParams() as { offline?: string };
|
const { offline } = useLocalSearchParams() as { offline?: string };
|
||||||
const isOffline = offline === "true";
|
const isOffline = offline === "true";
|
||||||
|
|
||||||
const { data: item, isError } = useItemQuery(id, isOffline);
|
// Fetch item with all fields including MediaSources
|
||||||
|
const { data: item, isError } = useItemQuery(id, isOffline, undefined, []);
|
||||||
|
|
||||||
const opacity = useSharedValue(1);
|
const opacity = useSharedValue(1);
|
||||||
const animatedStyle = useAnimatedStyle(() => {
|
const animatedStyle = useAnimatedStyle(() => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { GenreTags } from "@/components/GenreTags";
|
import { GenreTags } from "@/components/GenreTags";
|
||||||
@@ -33,8 +34,16 @@ import {
|
|||||||
type IssueType,
|
type IssueType,
|
||||||
IssueTypeName,
|
IssueTypeName,
|
||||||
} from "@/utils/jellyseerr/server/constants/issue";
|
} from "@/utils/jellyseerr/server/constants/issue";
|
||||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
import {
|
||||||
|
MediaRequestStatus,
|
||||||
|
MediaType,
|
||||||
|
} from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
||||||
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||||
|
import {
|
||||||
|
hasPermission,
|
||||||
|
Permission,
|
||||||
|
} from "@/utils/jellyseerr/server/lib/permissions";
|
||||||
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||||
import type {
|
import type {
|
||||||
MovieResult,
|
MovieResult,
|
||||||
@@ -58,7 +67,7 @@ const Page: React.FC = () => {
|
|||||||
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
|
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
|
||||||
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
||||||
|
|
||||||
const [issueType, setIssueType] = useState<IssueType>();
|
const [issueType, setIssueType] = useState<IssueType>();
|
||||||
const [issueMessage, setIssueMessage] = useState<string>();
|
const [issueMessage, setIssueMessage] = useState<string>();
|
||||||
@@ -91,6 +100,46 @@ const Page: React.FC = () => {
|
|||||||
const [canRequest, hasAdvancedRequestPermission] =
|
const [canRequest, hasAdvancedRequestPermission] =
|
||||||
useJellyseerrCanRequest(details);
|
useJellyseerrCanRequest(details);
|
||||||
|
|
||||||
|
const canManageRequests = useMemo(() => {
|
||||||
|
if (!jellyseerrUser) return false;
|
||||||
|
return hasPermission(
|
||||||
|
Permission.MANAGE_REQUESTS,
|
||||||
|
jellyseerrUser.permissions,
|
||||||
|
);
|
||||||
|
}, [jellyseerrUser]);
|
||||||
|
|
||||||
|
const pendingRequest = useMemo(() => {
|
||||||
|
return details?.mediaInfo?.requests?.find(
|
||||||
|
(r: MediaRequest) => r.status === MediaRequestStatus.PENDING,
|
||||||
|
);
|
||||||
|
}, [details]);
|
||||||
|
|
||||||
|
const handleApproveRequest = useCallback(async () => {
|
||||||
|
if (!pendingRequest?.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await jellyseerrApi?.approveRequest(pendingRequest.id);
|
||||||
|
toast.success(t("jellyseerr.toasts.request_approved"));
|
||||||
|
refetch();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t("jellyseerr.toasts.failed_to_approve_request"));
|
||||||
|
console.error("Failed to approve request:", error);
|
||||||
|
}
|
||||||
|
}, [jellyseerrApi, pendingRequest, refetch, t]);
|
||||||
|
|
||||||
|
const handleDeclineRequest = useCallback(async () => {
|
||||||
|
if (!pendingRequest?.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await jellyseerrApi?.declineRequest(pendingRequest.id);
|
||||||
|
toast.success(t("jellyseerr.toasts.request_declined"));
|
||||||
|
refetch();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t("jellyseerr.toasts.failed_to_decline_request"));
|
||||||
|
console.error("Failed to decline request:", error);
|
||||||
|
}
|
||||||
|
}, [jellyseerrApi, pendingRequest, refetch, t]);
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
(props: BottomSheetBackdropProps) => (
|
(props: BottomSheetBackdropProps) => (
|
||||||
<BottomSheetBackdrop
|
<BottomSheetBackdrop
|
||||||
@@ -334,6 +383,60 @@ const Page: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
{canManageRequests && pendingRequest && (
|
||||||
|
<View className='flex flex-col space-y-2 mt-4'>
|
||||||
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
|
<Ionicons name='person-outline' size={16} color='#9CA3AF' />
|
||||||
|
<Text className='text-sm text-neutral-400'>
|
||||||
|
{t("jellyseerr.requested_by", {
|
||||||
|
user:
|
||||||
|
pendingRequest.requestedBy?.displayName ||
|
||||||
|
pendingRequest.requestedBy?.username ||
|
||||||
|
pendingRequest.requestedBy?.jellyfinUsername ||
|
||||||
|
t("jellyseerr.unknown_user"),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className='flex flex-row space-x-2'>
|
||||||
|
<Button
|
||||||
|
className='flex-1 bg-green-600/50 border-green-400 ring-green-400 text-green-100'
|
||||||
|
color='transparent'
|
||||||
|
onPress={handleApproveRequest}
|
||||||
|
iconLeft={
|
||||||
|
<Ionicons
|
||||||
|
name='checkmark-outline'
|
||||||
|
size={20}
|
||||||
|
color='white'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className='text-sm'>{t("jellyseerr.approve")}</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className='flex-1 bg-red-600/50 border-red-400 ring-red-400 text-red-100'
|
||||||
|
color='transparent'
|
||||||
|
onPress={handleDeclineRequest}
|
||||||
|
iconLeft={
|
||||||
|
<Ionicons
|
||||||
|
name='close-outline'
|
||||||
|
size={20}
|
||||||
|
color='white'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className='text-sm'>{t("jellyseerr.decline")}</Text>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
<OverviewText text={result.overview} className='mt-4' />
|
<OverviewText text={result.overview} className='mt-4' />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,33 @@
|
|||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { AppState } from "react-native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
|
|
||||||
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
|
const { settings } = useSettings();
|
||||||
|
const { lockOrientation, unlockOrientation } = useOrientation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (settings?.defaultVideoOrientation) {
|
||||||
|
lockOrientation(settings.defaultVideoOrientation);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-apply orientation lock when app returns to foreground (iOS resets it)
|
||||||
|
const subscription = AppState.addEventListener("change", (nextAppState) => {
|
||||||
|
if (nextAppState === "active" && settings?.defaultVideoOrientation) {
|
||||||
|
lockOrientation(settings.defaultVideoOrientation);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscription.remove();
|
||||||
|
unlockOrientation();
|
||||||
|
};
|
||||||
|
}, [settings?.defaultVideoOrientation, lockOrientation, unlockOrientation]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SystemBars hidden />
|
<SystemBars hidden />
|
||||||
|
|||||||
@@ -22,34 +22,34 @@ import { BITRATES } from "@/components/BitrateSelector";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { Controls } from "@/components/video-player/controls/Controls";
|
import { Controls } from "@/components/video-player/controls/Controls";
|
||||||
import {
|
import { PlayerProvider } from "@/components/video-player/controls/contexts/PlayerContext";
|
||||||
OUTLINE_THICKNESS,
|
import { VideoProvider } from "@/components/video-player/controls/contexts/VideoContext";
|
||||||
OutlineThickness,
|
|
||||||
VLC_COLORS,
|
|
||||||
VLCColor,
|
|
||||||
} from "@/constants/SubtitleConstants";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
|
||||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||||
import { VlcPlayerView } from "@/modules";
|
import {
|
||||||
import type {
|
MpvPlayerView,
|
||||||
PlaybackStatePayload,
|
type MpvPlayerViewRef,
|
||||||
ProgressUpdatePayload,
|
type OnPlaybackStateChangePayload,
|
||||||
VlcPlayerViewRef,
|
type OnProgressEventPayload,
|
||||||
} from "@/modules/VlcPlayer.types";
|
type VideoSource,
|
||||||
|
} from "@/modules";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
|
import {
|
||||||
|
getMpvAudioId,
|
||||||
|
getMpvSubtitleId,
|
||||||
|
} from "@/utils/jellyfin/subtitleUtils";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import { generateDeviceProfile } from "@/utils/profiles/native";
|
import { generateDeviceProfile } from "@/utils/profiles/native";
|
||||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const videoRef = useRef<VlcPlayerViewRef>(null);
|
const videoRef = useRef<MpvPlayerViewRef>(null);
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -57,7 +57,7 @@ export default function page() {
|
|||||||
|
|
||||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||||
const [showControls, _setShowControls] = useState(true);
|
const [showControls, _setShowControls] = useState(true);
|
||||||
const [isPipMode, setIsPipMode] = useState(false);
|
const [isPipMode, _setIsPipMode] = useState(false);
|
||||||
const [aspectRatio, setAspectRatio] = useState<
|
const [aspectRatio, setAspectRatio] = useState<
|
||||||
"default" | "16:9" | "4:3" | "1:1" | "21:9"
|
"default" | "16:9" | "4:3" | "1:1" | "21:9"
|
||||||
>("default");
|
>("default");
|
||||||
@@ -68,6 +68,7 @@ export default function page() {
|
|||||||
const [isMuted, setIsMuted] = useState(false);
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
const [isBuffering, setIsBuffering] = useState(true);
|
const [isBuffering, setIsBuffering] = useState(true);
|
||||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
||||||
|
const [tracksReady, setTracksReady] = useState(false);
|
||||||
|
|
||||||
const progress = useSharedValue(0);
|
const progress = useSharedValue(0);
|
||||||
const isSeeking = useSharedValue(false);
|
const isSeeking = useSharedValue(false);
|
||||||
@@ -110,7 +111,6 @@ export default function page() {
|
|||||||
playbackPosition?: string;
|
playbackPosition?: string;
|
||||||
}>();
|
}>();
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const { lockOrientation, unlockOrientation } = useOrientation();
|
|
||||||
|
|
||||||
const offline = offlineStr === "true";
|
const offline = offlineStr === "true";
|
||||||
const playbackManager = usePlaybackManager();
|
const playbackManager = usePlaybackManager();
|
||||||
@@ -173,16 +173,6 @@ export default function page() {
|
|||||||
}
|
}
|
||||||
}, [itemId, offline, api, user?.Id]);
|
}, [itemId, offline, api, user?.Id]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (settings?.defaultVideoOrientation) {
|
|
||||||
lockOrientation(settings.defaultVideoOrientation);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unlockOrientation();
|
|
||||||
};
|
|
||||||
}, [settings?.defaultVideoOrientation]);
|
|
||||||
|
|
||||||
interface Stream {
|
interface Stream {
|
||||||
mediaSource: MediaSourceInfo;
|
mediaSource: MediaSourceInfo;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -229,8 +219,6 @@ export default function page() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const native = generateDeviceProfile();
|
|
||||||
const transcoding = generateDeviceProfile({ transcode: true });
|
|
||||||
const res = await getStreamUrl({
|
const res = await getStreamUrl({
|
||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
@@ -240,7 +228,7 @@ export default function page() {
|
|||||||
maxStreamingBitrate: bitrateValue,
|
maxStreamingBitrate: bitrateValue,
|
||||||
mediaSourceId: mediaSourceId,
|
mediaSourceId: mediaSourceId,
|
||||||
subtitleStreamIndex: subtitleIndex,
|
subtitleStreamIndex: subtitleIndex,
|
||||||
deviceProfile: bitrateValue ? transcoding : native,
|
deviceProfile: generateDeviceProfile(),
|
||||||
});
|
});
|
||||||
if (!res) return;
|
if (!res) return;
|
||||||
const { mediaSource, sessionId, url } = res;
|
const { mediaSource, sessionId, url } = res;
|
||||||
@@ -324,7 +312,8 @@ export default function page() {
|
|||||||
});
|
});
|
||||||
reportPlaybackStopped();
|
reportPlaybackStopped();
|
||||||
setIsPlaybackStopped(true);
|
setIsPlaybackStopped(true);
|
||||||
videoRef.current?.stop();
|
// MPV doesn't have a stop method, use pause instead
|
||||||
|
videoRef.current?.pause();
|
||||||
revalidateProgressCache();
|
revalidateProgressCache();
|
||||||
}, [videoRef, reportPlaybackStopped, progress]);
|
}, [videoRef, reportPlaybackStopped, progress]);
|
||||||
|
|
||||||
@@ -337,6 +326,7 @@ export default function page() {
|
|||||||
|
|
||||||
const currentPlayStateInfo = useCallback(() => {
|
const currentPlayStateInfo = useCallback(() => {
|
||||||
if (!stream || !item?.Id) return;
|
if (!stream || !item?.Id) return;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
itemId: item.Id,
|
itemId: item.Id,
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||||
@@ -379,10 +369,13 @@ export default function page() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const onProgress = useCallback(
|
const onProgress = useCallback(
|
||||||
async (data: ProgressUpdatePayload) => {
|
async (data: { nativeEvent: OnProgressEventPayload }) => {
|
||||||
if (isSeeking.get() || isPlaybackStopped) return;
|
if (isSeeking.get() || isPlaybackStopped) return;
|
||||||
|
|
||||||
const { currentTime } = data.nativeEvent;
|
const { position } = data.nativeEvent;
|
||||||
|
// MPV reports position in seconds, convert to ms
|
||||||
|
const currentTime = position * 1000;
|
||||||
|
|
||||||
if (isBuffering) {
|
if (isBuffering) {
|
||||||
setIsBuffering(false);
|
setIsBuffering(false);
|
||||||
}
|
}
|
||||||
@@ -428,6 +421,46 @@ export default function page() {
|
|||||||
return ticksToSeconds(getInitialPlaybackTicks());
|
return ticksToSeconds(getInitialPlaybackTicks());
|
||||||
}, [getInitialPlaybackTicks]);
|
}, [getInitialPlaybackTicks]);
|
||||||
|
|
||||||
|
/** Build video source config for the native player */
|
||||||
|
const videoSource = useMemo<VideoSource | undefined>(() => {
|
||||||
|
if (!stream?.url) return undefined;
|
||||||
|
|
||||||
|
const mediaSource = stream.mediaSource;
|
||||||
|
const isTranscoding = Boolean(mediaSource?.TranscodingUrl);
|
||||||
|
|
||||||
|
// Get external subtitle URLs
|
||||||
|
const externalSubs = mediaSource?.MediaStreams?.filter(
|
||||||
|
(s) =>
|
||||||
|
s.Type === "Subtitle" &&
|
||||||
|
s.DeliveryMethod === "External" &&
|
||||||
|
s.DeliveryUrl,
|
||||||
|
).map((s) => `${api?.basePath}${s.DeliveryUrl}`);
|
||||||
|
|
||||||
|
// Calculate MPV track IDs for initial selection
|
||||||
|
const initialSubtitleId = getMpvSubtitleId(
|
||||||
|
mediaSource,
|
||||||
|
subtitleIndex,
|
||||||
|
isTranscoding,
|
||||||
|
);
|
||||||
|
const initialAudioId = getMpvAudioId(mediaSource, audioIndex);
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: stream.url,
|
||||||
|
startPosition,
|
||||||
|
autoplay: true,
|
||||||
|
externalSubtitles: externalSubs,
|
||||||
|
initialSubtitleId,
|
||||||
|
initialAudioId,
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
stream?.url,
|
||||||
|
stream?.mediaSource,
|
||||||
|
startPosition,
|
||||||
|
api?.basePath,
|
||||||
|
subtitleIndex,
|
||||||
|
audioIndex,
|
||||||
|
]);
|
||||||
|
|
||||||
const volumeUpCb = useCallback(async () => {
|
const volumeUpCb = useCallback(async () => {
|
||||||
if (Platform.isTV) return;
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
@@ -508,10 +541,12 @@ export default function page() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const onPlaybackStateChanged = useCallback(
|
const onPlaybackStateChanged = useCallback(
|
||||||
async (e: PlaybackStatePayload) => {
|
async (e: { nativeEvent: OnPlaybackStateChangePayload }) => {
|
||||||
const { state, isBuffering, isPlaying } = e.nativeEvent;
|
const { isPaused, isPlaying: playing, isLoading } = e.nativeEvent;
|
||||||
if (state === "Playing") {
|
|
||||||
|
if (playing) {
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
|
setIsBuffering(false);
|
||||||
if (item?.Id) {
|
if (item?.Id) {
|
||||||
playbackManager.reportPlaybackProgress(
|
playbackManager.reportPlaybackProgress(
|
||||||
currentPlayStateInfo() as PlaybackProgressInfo,
|
currentPlayStateInfo() as PlaybackProgressInfo,
|
||||||
@@ -521,7 +556,7 @@ export default function page() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state === "Paused") {
|
if (isPaused) {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
if (item?.Id) {
|
if (item?.Id) {
|
||||||
playbackManager.reportPlaybackProgress(
|
playbackManager.reportPlaybackProgress(
|
||||||
@@ -532,87 +567,13 @@ export default function page() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPlaying) {
|
if (isLoading) {
|
||||||
setIsPlaying(true);
|
|
||||||
setIsBuffering(false);
|
|
||||||
} else if (isBuffering) {
|
|
||||||
setIsBuffering(true);
|
setIsBuffering(true);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[playbackManager, item?.Id, progress],
|
[playbackManager, item?.Id, progress],
|
||||||
);
|
);
|
||||||
|
|
||||||
const allAudio =
|
|
||||||
stream?.mediaSource.MediaStreams?.filter(
|
|
||||||
(audio) => audio.Type === "Audio",
|
|
||||||
) || [];
|
|
||||||
|
|
||||||
// Move all the external subtitles last, because vlc places them last.
|
|
||||||
const allSubs =
|
|
||||||
stream?.mediaSource.MediaStreams?.filter(
|
|
||||||
(sub) => sub.Type === "Subtitle",
|
|
||||||
).sort((a, b) => Number(a.IsExternal) - Number(b.IsExternal)) || [];
|
|
||||||
|
|
||||||
const externalSubtitles = allSubs
|
|
||||||
.filter((sub: any) => sub.DeliveryMethod === "External")
|
|
||||||
.map((sub: any) => ({
|
|
||||||
name: sub.DisplayTitle,
|
|
||||||
DeliveryUrl: offline ? sub.DeliveryUrl : api?.basePath + sub.DeliveryUrl,
|
|
||||||
}));
|
|
||||||
/** The text based subtitle tracks */
|
|
||||||
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
|
|
||||||
/** The user chosen subtitle track from the server */
|
|
||||||
const chosenSubtitleTrack = allSubs.find(
|
|
||||||
(sub) => sub.Index === subtitleIndex,
|
|
||||||
);
|
|
||||||
/** The user chosen audio track from the server */
|
|
||||||
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
|
|
||||||
/** Whether the stream we're playing is not transcoding*/
|
|
||||||
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
|
|
||||||
/** The initial options to pass to the VLC Player */
|
|
||||||
const initOptions = [``];
|
|
||||||
if (
|
|
||||||
chosenSubtitleTrack &&
|
|
||||||
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
|
|
||||||
) {
|
|
||||||
// If not transcoding, we can the index as normal.
|
|
||||||
// If transcoding, we need to reverse the text based subtitles, because VLC reverses the HLS subtitles.
|
|
||||||
const finalIndex = notTranscoding
|
|
||||||
? allSubs.indexOf(chosenSubtitleTrack)
|
|
||||||
: [...textSubs].reverse().indexOf(chosenSubtitleTrack);
|
|
||||||
initOptions.push(`--sub-track=${finalIndex}`);
|
|
||||||
|
|
||||||
// Add VLC subtitle styling options from settings
|
|
||||||
const textColor = (settings.vlcTextColor ?? "White") as VLCColor;
|
|
||||||
const backgroundColor = (settings.vlcBackgroundColor ??
|
|
||||||
"Black") as VLCColor;
|
|
||||||
const outlineColor = (settings.vlcOutlineColor ?? "Black") as VLCColor;
|
|
||||||
const outlineThickness = (settings.vlcOutlineThickness ??
|
|
||||||
"Normal") as OutlineThickness;
|
|
||||||
const backgroundOpacity = settings.vlcBackgroundOpacity ?? 128;
|
|
||||||
const outlineOpacity = settings.vlcOutlineOpacity ?? 255;
|
|
||||||
const isBold = settings.vlcIsBold ?? false;
|
|
||||||
// Add subtitle styling options
|
|
||||||
initOptions.push(`--freetype-color=${VLC_COLORS[textColor]}`);
|
|
||||||
initOptions.push(`--freetype-background-opacity=${backgroundOpacity}`);
|
|
||||||
initOptions.push(
|
|
||||||
`--freetype-background-color=${VLC_COLORS[backgroundColor]}`,
|
|
||||||
);
|
|
||||||
initOptions.push(`--freetype-outline-opacity=${outlineOpacity}`);
|
|
||||||
initOptions.push(`--freetype-outline-color=${VLC_COLORS[outlineColor]}`);
|
|
||||||
initOptions.push(
|
|
||||||
`--freetype-outline-thickness=${OUTLINE_THICKNESS[outlineThickness]}`,
|
|
||||||
);
|
|
||||||
initOptions.push(`--sub-text-scale=${settings.subtitleSize}`);
|
|
||||||
initOptions.push("--sub-margin=40");
|
|
||||||
if (isBold) {
|
|
||||||
initOptions.push("--freetype-bold");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (notTranscoding && chosenAudioTrack) {
|
|
||||||
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
|
||||||
// Add useEffect to handle mounting
|
// Add useEffect to handle mounting
|
||||||
@@ -625,6 +586,7 @@ export default function page() {
|
|||||||
const startPictureInPicture = useCallback(async () => {
|
const startPictureInPicture = useCallback(async () => {
|
||||||
return videoRef.current?.startPictureInPicture?.();
|
return videoRef.current?.startPictureInPicture?.();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const play = useCallback(() => {
|
const play = useCallback(() => {
|
||||||
videoRef.current?.play?.();
|
videoRef.current?.play?.();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -634,46 +596,40 @@ export default function page() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const seek = useCallback((position: number) => {
|
const seek = useCallback((position: number) => {
|
||||||
videoRef.current?.seekTo?.(position);
|
// MPV expects seconds, convert from ms
|
||||||
}, []);
|
videoRef.current?.seekTo?.(position / 1000);
|
||||||
const getAudioTracks = useCallback(async () => {
|
|
||||||
return videoRef.current?.getAudioTracks?.() || null;
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getSubtitleTracks = useCallback(async () => {
|
// Apply MPV subtitle settings when video loads
|
||||||
return videoRef.current?.getSubtitleTracks?.() || null;
|
useEffect(() => {
|
||||||
}, []);
|
if (!isVideoLoaded || !videoRef.current) return;
|
||||||
|
|
||||||
const setSubtitleTrack = useCallback((index: number) => {
|
const applySubtitleSettings = async () => {
|
||||||
videoRef.current?.setSubtitleTrack?.(index);
|
if (settings.mpvSubtitleScale !== undefined) {
|
||||||
}, []);
|
await videoRef.current?.setSubtitleScale(settings.mpvSubtitleScale);
|
||||||
|
}
|
||||||
|
if (settings.mpvSubtitleMarginY !== undefined) {
|
||||||
|
await videoRef.current?.setSubtitleMarginY(settings.mpvSubtitleMarginY);
|
||||||
|
}
|
||||||
|
if (settings.mpvSubtitleAlignX !== undefined) {
|
||||||
|
await videoRef.current?.setSubtitleAlignX(settings.mpvSubtitleAlignX);
|
||||||
|
}
|
||||||
|
if (settings.mpvSubtitleAlignY !== undefined) {
|
||||||
|
await videoRef.current?.setSubtitleAlignY(settings.mpvSubtitleAlignY);
|
||||||
|
}
|
||||||
|
if (settings.mpvSubtitleFontSize !== undefined) {
|
||||||
|
await videoRef.current?.setSubtitleFontSize(
|
||||||
|
settings.mpvSubtitleFontSize,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Apply subtitle size from general settings
|
||||||
|
if (settings.subtitleSize) {
|
||||||
|
await videoRef.current?.setSubtitleFontSize(settings.subtitleSize);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const setSubtitleURL = useCallback((url: string, _customName?: string) => {
|
applySubtitleSettings();
|
||||||
// Note: VlcPlayer type only expects url parameter
|
}, [isVideoLoaded, settings]);
|
||||||
videoRef.current?.setSubtitleURL?.(url);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setAudioTrack = useCallback((index: number) => {
|
|
||||||
videoRef.current?.setAudioTrack?.(index);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setVideoAspectRatio = useCallback(
|
|
||||||
async (aspectRatio: string | null) => {
|
|
||||||
return (
|
|
||||||
videoRef.current?.setVideoAspectRatio?.(aspectRatio) ||
|
|
||||||
Promise.resolve()
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const setVideoScaleFactor = useCallback(async (scaleFactor: number) => {
|
|
||||||
return (
|
|
||||||
videoRef.current?.setVideoScaleFactor?.(scaleFactor) || Promise.resolve()
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
console.log("Debug: component render"); // Uncomment to debug re-renders
|
|
||||||
|
|
||||||
// Show error UI first, before checking loading/missing‐data
|
// Show error UI first, before checking loading/missing‐data
|
||||||
if (itemStatus.isError || streamStatus.isError) {
|
if (itemStatus.isError || streamStatus.isError) {
|
||||||
@@ -684,7 +640,7 @@ export default function page() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then show loader while either side is still fetching or data isn’t present
|
// Then show loader while either side is still fetching or data isn't present
|
||||||
if (itemStatus.isLoading || streamStatus.isLoading || !item || !stream) {
|
if (itemStatus.isLoading || streamStatus.isLoading || !item || !stream) {
|
||||||
// …loader UI…
|
// …loader UI…
|
||||||
return (
|
return (
|
||||||
@@ -702,90 +658,80 @@ export default function page() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<PlayerProvider
|
||||||
style={{
|
playerRef={videoRef}
|
||||||
flex: 1,
|
item={item}
|
||||||
backgroundColor: "black",
|
mediaSource={stream?.mediaSource}
|
||||||
height: "100%",
|
isVideoLoaded={isVideoLoaded}
|
||||||
width: "100%",
|
tracksReady={tracksReady}
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<View
|
<VideoProvider>
|
||||||
style={{
|
<View
|
||||||
display: "flex",
|
style={{
|
||||||
width: "100%",
|
flex: 1,
|
||||||
height: "100%",
|
backgroundColor: "black",
|
||||||
position: "relative",
|
height: "100%",
|
||||||
flexDirection: "column",
|
width: "100%",
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<VlcPlayerView
|
|
||||||
ref={videoRef}
|
|
||||||
source={{
|
|
||||||
uri: stream?.url || "",
|
|
||||||
autoplay: true,
|
|
||||||
isNetwork: !offline,
|
|
||||||
startPosition,
|
|
||||||
externalSubtitles,
|
|
||||||
initOptions,
|
|
||||||
}}
|
}}
|
||||||
style={{ width: "100%", height: "100%" }}
|
>
|
||||||
onVideoProgress={onProgress}
|
<View
|
||||||
progressUpdateInterval={1000}
|
style={{
|
||||||
onVideoStateChange={onPlaybackStateChanged}
|
display: "flex",
|
||||||
onVideoLoadEnd={() => {
|
width: "100%",
|
||||||
setIsVideoLoaded(true);
|
height: "100%",
|
||||||
}}
|
position: "relative",
|
||||||
onVideoError={(e) => {
|
flexDirection: "column",
|
||||||
console.error("Video Error:", e.nativeEvent);
|
justifyContent: "center",
|
||||||
Alert.alert(
|
}}
|
||||||
t("player.error"),
|
>
|
||||||
t("player.an_error_occured_while_playing_the_video"),
|
<MpvPlayerView
|
||||||
);
|
ref={videoRef}
|
||||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
source={videoSource}
|
||||||
}}
|
style={{ width: "100%", height: "100%" }}
|
||||||
onPipStarted={(e) => {
|
onProgress={onProgress}
|
||||||
setIsPipMode(e.nativeEvent.pipStarted);
|
onPlaybackStateChange={onPlaybackStateChanged}
|
||||||
}}
|
onLoad={() => setIsVideoLoaded(true)}
|
||||||
/>
|
onError={(e) => {
|
||||||
</View>
|
console.error("Video Error:", e.nativeEvent);
|
||||||
{isMounted === true && item && !isPipMode && (
|
Alert.alert(
|
||||||
<Controls
|
t("player.error"),
|
||||||
mediaSource={stream?.mediaSource}
|
t("player.an_error_occured_while_playing_the_video"),
|
||||||
item={item}
|
);
|
||||||
videoRef={videoRef}
|
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||||
togglePlay={togglePlay}
|
}}
|
||||||
isPlaying={isPlaying}
|
onTracksReady={() => {
|
||||||
isSeeking={isSeeking}
|
setTracksReady(true);
|
||||||
progress={progress}
|
}}
|
||||||
cacheProgress={cacheProgress}
|
/>
|
||||||
isBuffering={isBuffering}
|
</View>
|
||||||
showControls={showControls}
|
{isMounted === true && item && !isPipMode && (
|
||||||
setShowControls={setShowControls}
|
<Controls
|
||||||
isVideoLoaded={isVideoLoaded}
|
mediaSource={stream?.mediaSource}
|
||||||
startPictureInPicture={startPictureInPicture}
|
item={item}
|
||||||
play={play}
|
togglePlay={togglePlay}
|
||||||
pause={pause}
|
isPlaying={isPlaying}
|
||||||
seek={seek}
|
isSeeking={isSeeking}
|
||||||
enableTrickplay={true}
|
progress={progress}
|
||||||
getAudioTracks={getAudioTracks}
|
cacheProgress={cacheProgress}
|
||||||
getSubtitleTracks={getSubtitleTracks}
|
isBuffering={isBuffering}
|
||||||
offline={offline}
|
showControls={showControls}
|
||||||
setSubtitleTrack={setSubtitleTrack}
|
setShowControls={setShowControls}
|
||||||
setSubtitleURL={setSubtitleURL}
|
startPictureInPicture={startPictureInPicture}
|
||||||
setAudioTrack={setAudioTrack}
|
play={play}
|
||||||
setVideoAspectRatio={setVideoAspectRatio}
|
pause={pause}
|
||||||
setVideoScaleFactor={setVideoScaleFactor}
|
seek={seek}
|
||||||
aspectRatio={aspectRatio}
|
enableTrickplay={true}
|
||||||
scaleFactor={scaleFactor}
|
offline={offline}
|
||||||
setAspectRatio={setAspectRatio}
|
aspectRatio={aspectRatio}
|
||||||
setScaleFactor={setScaleFactor}
|
scaleFactor={scaleFactor}
|
||||||
isVlc
|
setAspectRatio={setAspectRatio}
|
||||||
api={api}
|
setScaleFactor={setScaleFactor}
|
||||||
downloadedFiles={downloadedFiles}
|
api={api}
|
||||||
/>
|
downloadedFiles={downloadedFiles}
|
||||||
)}
|
/>
|
||||||
</View>
|
)}
|
||||||
|
</View>
|
||||||
|
</VideoProvider>
|
||||||
|
</PlayerProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
166
bun.lock
166
bun.lock
@@ -50,7 +50,7 @@
|
|||||||
"patch-package": "^8.0.0",
|
"patch-package": "^8.0.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-i18next": "^15.4.0",
|
"react-i18next": "16.0.0",
|
||||||
"react-native": "npm:react-native-tvos@0.81.5-1",
|
"react-native": "npm:react-native-tvos@0.81.5-1",
|
||||||
"react-native-awesome-slider": "^2.9.0",
|
"react-native-awesome-slider": "^2.9.0",
|
||||||
"react-native-bottom-tabs": "^1.0.2",
|
"react-native-bottom-tabs": "^1.0.2",
|
||||||
@@ -85,20 +85,20 @@
|
|||||||
"zod": "^4.1.3",
|
"zod": "^4.1.3",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.28.5",
|
"@babel/core": "7.28.5",
|
||||||
"@biomejs/biome": "^2.3.5",
|
"@biomejs/biome": "2.3.5",
|
||||||
"@react-native-community/cli": "^20.0.2",
|
"@react-native-community/cli": "20.0.2",
|
||||||
"@react-native-tvos/config-tv": "^0.1.4",
|
"@react-native-tvos/config-tv": "0.1.4",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "29.5.14",
|
||||||
"@types/lodash": "^4.17.20",
|
"@types/lodash": "4.17.20",
|
||||||
"@types/react": "~19.1.10",
|
"@types/react": "~19.1.10",
|
||||||
"@types/react-test-renderer": "^19.0.0",
|
"@types/react-test-renderer": "19.1.0",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "10.1.0",
|
||||||
"expo-doctor": "^1.17.11",
|
"expo-doctor": "1.17.11",
|
||||||
"husky": "^9.1.7",
|
"husky": "9.1.7",
|
||||||
"lint-staged": "^16.2.6",
|
"lint-staged": "16.2.6",
|
||||||
"react-test-renderer": "19.1.1",
|
"react-test-renderer": "19.1.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "5.9.3",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -405,19 +405,13 @@
|
|||||||
|
|
||||||
"@jest/create-cache-key-function": ["@jest/create-cache-key-function@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3" } }, "sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA=="],
|
"@jest/create-cache-key-function": ["@jest/create-cache-key-function@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3" } }, "sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA=="],
|
||||||
|
|
||||||
"@jest/diff-sequences": ["@jest/diff-sequences@30.0.1", "", {}, "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw=="],
|
|
||||||
|
|
||||||
"@jest/environment": ["@jest/environment@29.7.0", "", { "dependencies": { "@jest/fake-timers": "^29.7.0", "@jest/types": "^29.6.3", "@types/node": "*", "jest-mock": "^29.7.0" } }, "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw=="],
|
"@jest/environment": ["@jest/environment@29.7.0", "", { "dependencies": { "@jest/fake-timers": "^29.7.0", "@jest/types": "^29.6.3", "@types/node": "*", "jest-mock": "^29.7.0" } }, "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw=="],
|
||||||
|
|
||||||
"@jest/expect-utils": ["@jest/expect-utils@30.2.0", "", { "dependencies": { "@jest/get-type": "30.1.0" } }, "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA=="],
|
"@jest/expect-utils": ["@jest/expect-utils@29.7.0", "", { "dependencies": { "jest-get-type": "^29.6.3" } }, "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA=="],
|
||||||
|
|
||||||
"@jest/fake-timers": ["@jest/fake-timers@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@sinonjs/fake-timers": "^10.0.2", "@types/node": "*", "jest-message-util": "^29.7.0", "jest-mock": "^29.7.0", "jest-util": "^29.7.0" } }, "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ=="],
|
"@jest/fake-timers": ["@jest/fake-timers@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@sinonjs/fake-timers": "^10.0.2", "@types/node": "*", "jest-message-util": "^29.7.0", "jest-mock": "^29.7.0", "jest-util": "^29.7.0" } }, "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ=="],
|
||||||
|
|
||||||
"@jest/get-type": ["@jest/get-type@30.1.0", "", {}, "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA=="],
|
"@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="],
|
||||||
|
|
||||||
"@jest/pattern": ["@jest/pattern@30.0.1", "", { "dependencies": { "@types/node": "*", "jest-regex-util": "30.0.1" } }, "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA=="],
|
|
||||||
|
|
||||||
"@jest/schemas": ["@jest/schemas@30.0.5", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA=="],
|
|
||||||
|
|
||||||
"@jest/transform": ["@jest/transform@29.7.0", "", { "dependencies": { "@babel/core": "^7.11.6", "@jest/types": "^29.6.3", "@jridgewell/trace-mapping": "^0.3.18", "babel-plugin-istanbul": "^6.1.1", "chalk": "^4.0.0", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.9", "jest-haste-map": "^29.7.0", "jest-regex-util": "^29.6.3", "jest-util": "^29.7.0", "micromatch": "^4.0.4", "pirates": "^4.0.4", "slash": "^3.0.0", "write-file-atomic": "^4.0.2" } }, "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw=="],
|
"@jest/transform": ["@jest/transform@29.7.0", "", { "dependencies": { "@babel/core": "^7.11.6", "@jest/types": "^29.6.3", "@jridgewell/trace-mapping": "^0.3.18", "babel-plugin-istanbul": "^6.1.1", "chalk": "^4.0.0", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.9", "jest-haste-map": "^29.7.0", "jest-regex-util": "^29.6.3", "jest-util": "^29.7.0", "micromatch": "^4.0.4", "pirates": "^4.0.4", "slash": "^3.0.0", "write-file-atomic": "^4.0.2" } }, "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw=="],
|
||||||
|
|
||||||
@@ -577,7 +571,7 @@
|
|||||||
|
|
||||||
"@sideway/pinpoint": ["@sideway/pinpoint@2.0.0", "", {}, "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ=="],
|
"@sideway/pinpoint": ["@sideway/pinpoint@2.0.0", "", {}, "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ=="],
|
||||||
|
|
||||||
"@sinclair/typebox": ["@sinclair/typebox@0.34.41", "", {}, "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g=="],
|
"@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="],
|
||||||
|
|
||||||
"@sinonjs/commons": ["@sinonjs/commons@3.0.1", "", { "dependencies": { "type-detect": "4.0.8" } }, "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ=="],
|
"@sinonjs/commons": ["@sinonjs/commons@3.0.1", "", { "dependencies": { "type-detect": "4.0.8" } }, "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ=="],
|
||||||
|
|
||||||
@@ -609,7 +603,7 @@
|
|||||||
|
|
||||||
"@types/istanbul-reports": ["@types/istanbul-reports@3.0.4", "", { "dependencies": { "@types/istanbul-lib-report": "*" } }, "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ=="],
|
"@types/istanbul-reports": ["@types/istanbul-reports@3.0.4", "", { "dependencies": { "@types/istanbul-lib-report": "*" } }, "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ=="],
|
||||||
|
|
||||||
"@types/jest": ["@types/jest@30.0.0", "", { "dependencies": { "expect": "^30.0.0", "pretty-format": "^30.0.0" } }, "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA=="],
|
"@types/jest": ["@types/jest@29.5.14", "", { "dependencies": { "expect": "^29.0.0", "pretty-format": "^29.0.0" } }, "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ=="],
|
||||||
|
|
||||||
"@types/lodash": ["@types/lodash@4.17.20", "", {}, "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA=="],
|
"@types/lodash": ["@types/lodash@4.17.20", "", {}, "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA=="],
|
||||||
|
|
||||||
@@ -903,6 +897,8 @@
|
|||||||
|
|
||||||
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
|
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
|
||||||
|
|
||||||
|
"diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="],
|
||||||
|
|
||||||
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
|
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
|
||||||
|
|
||||||
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
|
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
|
||||||
@@ -975,7 +971,7 @@
|
|||||||
|
|
||||||
"exif-parser": ["exif-parser@0.1.12", "", {}, "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="],
|
"exif-parser": ["exif-parser@0.1.12", "", {}, "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="],
|
||||||
|
|
||||||
"expect": ["expect@30.2.0", "", { "dependencies": { "@jest/expect-utils": "30.2.0", "@jest/get-type": "30.1.0", "jest-matcher-utils": "30.2.0", "jest-message-util": "30.2.0", "jest-mock": "30.2.0", "jest-util": "30.2.0" } }, "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw=="],
|
"expect": ["expect@29.7.0", "", { "dependencies": { "@jest/expect-utils": "^29.7.0", "jest-get-type": "^29.6.3", "jest-matcher-utils": "^29.7.0", "jest-message-util": "^29.7.0", "jest-util": "^29.7.0" } }, "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw=="],
|
||||||
|
|
||||||
"expo": ["expo@54.0.23", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "54.0.16", "@expo/config": "~12.0.10", "@expo/config-plugins": "~54.0.2", "@expo/devtools": "0.1.7", "@expo/fingerprint": "0.15.3", "@expo/metro": "~54.1.0", "@expo/metro-config": "54.0.9", "@expo/vector-icons": "^15.0.3", "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~54.0.7", "expo-asset": "~12.0.9", "expo-constants": "~18.0.10", "expo-file-system": "~19.0.17", "expo-font": "~14.0.9", "expo-keep-awake": "~15.0.7", "expo-modules-autolinking": "3.0.21", "expo-modules-core": "3.0.25", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-without-unicode": "8.0.0-3" }, "peerDependencies": { "@expo/dom-webview": "*", "@expo/metro-runtime": "*", "react": "*", "react-native": "*", "react-native-webview": "*" }, "optionalPeers": ["@expo/dom-webview", "@expo/metro-runtime", "react-native-webview"], "bin": { "expo": "bin/cli", "fingerprint": "bin/fingerprint", "expo-modules-autolinking": "bin/autolinking" } }, "sha512-b4uQoiRwQ6nwqsT2709RS15CWYNGF3eJtyr1KyLw9WuMAK7u4jjofkhRiO0+3o1C2NbV+WooyYTOZGubQQMBaQ=="],
|
"expo": ["expo@54.0.23", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "54.0.16", "@expo/config": "~12.0.10", "@expo/config-plugins": "~54.0.2", "@expo/devtools": "0.1.7", "@expo/fingerprint": "0.15.3", "@expo/metro": "~54.1.0", "@expo/metro-config": "54.0.9", "@expo/vector-icons": "^15.0.3", "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~54.0.7", "expo-asset": "~12.0.9", "expo-constants": "~18.0.10", "expo-file-system": "~19.0.17", "expo-font": "~14.0.9", "expo-keep-awake": "~15.0.7", "expo-modules-autolinking": "3.0.21", "expo-modules-core": "3.0.25", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-without-unicode": "8.0.0-3" }, "peerDependencies": { "@expo/dom-webview": "*", "@expo/metro-runtime": "*", "react": "*", "react-native": "*", "react-native-webview": "*" }, "optionalPeers": ["@expo/dom-webview", "@expo/metro-runtime", "react-native-webview"], "bin": { "expo": "bin/cli", "fingerprint": "bin/fingerprint", "expo-modules-autolinking": "bin/autolinking" } }, "sha512-b4uQoiRwQ6nwqsT2709RS15CWYNGF3eJtyr1KyLw9WuMAK7u4jjofkhRiO0+3o1C2NbV+WooyYTOZGubQQMBaQ=="],
|
||||||
|
|
||||||
@@ -1247,7 +1243,7 @@
|
|||||||
|
|
||||||
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
|
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
|
||||||
|
|
||||||
"jest-diff": ["jest-diff@30.2.0", "", { "dependencies": { "@jest/diff-sequences": "30.0.1", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "pretty-format": "30.2.0" } }, "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A=="],
|
"jest-diff": ["jest-diff@29.7.0", "", { "dependencies": { "chalk": "^4.0.0", "diff-sequences": "^29.6.3", "jest-get-type": "^29.6.3", "pretty-format": "^29.7.0" } }, "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw=="],
|
||||||
|
|
||||||
"jest-environment-node": ["jest-environment-node@29.7.0", "", { "dependencies": { "@jest/environment": "^29.7.0", "@jest/fake-timers": "^29.7.0", "@jest/types": "^29.6.3", "@types/node": "*", "jest-mock": "^29.7.0", "jest-util": "^29.7.0" } }, "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw=="],
|
"jest-environment-node": ["jest-environment-node@29.7.0", "", { "dependencies": { "@jest/environment": "^29.7.0", "@jest/fake-timers": "^29.7.0", "@jest/types": "^29.6.3", "@types/node": "*", "jest-mock": "^29.7.0", "jest-util": "^29.7.0" } }, "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw=="],
|
||||||
|
|
||||||
@@ -1255,15 +1251,15 @@
|
|||||||
|
|
||||||
"jest-haste-map": ["jest-haste-map@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/graceful-fs": "^4.1.3", "@types/node": "*", "anymatch": "^3.0.3", "fb-watchman": "^2.0.0", "graceful-fs": "^4.2.9", "jest-regex-util": "^29.6.3", "jest-util": "^29.7.0", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "walker": "^1.0.8" }, "optionalDependencies": { "fsevents": "^2.3.2" } }, "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA=="],
|
"jest-haste-map": ["jest-haste-map@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/graceful-fs": "^4.1.3", "@types/node": "*", "anymatch": "^3.0.3", "fb-watchman": "^2.0.0", "graceful-fs": "^4.2.9", "jest-regex-util": "^29.6.3", "jest-util": "^29.7.0", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "walker": "^1.0.8" }, "optionalDependencies": { "fsevents": "^2.3.2" } }, "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA=="],
|
||||||
|
|
||||||
"jest-matcher-utils": ["jest-matcher-utils@30.2.0", "", { "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "jest-diff": "30.2.0", "pretty-format": "30.2.0" } }, "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg=="],
|
"jest-matcher-utils": ["jest-matcher-utils@29.7.0", "", { "dependencies": { "chalk": "^4.0.0", "jest-diff": "^29.7.0", "jest-get-type": "^29.6.3", "pretty-format": "^29.7.0" } }, "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g=="],
|
||||||
|
|
||||||
"jest-message-util": ["jest-message-util@30.2.0", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.2.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "micromatch": "^4.0.8", "pretty-format": "30.2.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw=="],
|
"jest-message-util": ["jest-message-util@29.7.0", "", { "dependencies": { "@babel/code-frame": "^7.12.13", "@jest/types": "^29.6.3", "@types/stack-utils": "^2.0.0", "chalk": "^4.0.0", "graceful-fs": "^4.2.9", "micromatch": "^4.0.4", "pretty-format": "^29.7.0", "slash": "^3.0.0", "stack-utils": "^2.0.3" } }, "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w=="],
|
||||||
|
|
||||||
"jest-mock": ["jest-mock@30.2.0", "", { "dependencies": { "@jest/types": "30.2.0", "@types/node": "*", "jest-util": "30.2.0" } }, "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw=="],
|
"jest-mock": ["jest-mock@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "jest-util": "^29.7.0" } }, "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw=="],
|
||||||
|
|
||||||
"jest-regex-util": ["jest-regex-util@29.6.3", "", {}, "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg=="],
|
"jest-regex-util": ["jest-regex-util@29.6.3", "", {}, "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg=="],
|
||||||
|
|
||||||
"jest-util": ["jest-util@30.2.0", "", { "dependencies": { "@jest/types": "30.2.0", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", "picomatch": "^4.0.2" } }, "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA=="],
|
"jest-util": ["jest-util@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "chalk": "^4.0.0", "ci-info": "^3.2.0", "graceful-fs": "^4.2.9", "picomatch": "^2.2.3" } }, "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA=="],
|
||||||
|
|
||||||
"jest-validate": ["jest-validate@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "camelcase": "^6.2.0", "chalk": "^4.0.0", "jest-get-type": "^29.6.3", "leven": "^3.1.0", "pretty-format": "^29.7.0" } }, "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw=="],
|
"jest-validate": ["jest-validate@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "camelcase": "^6.2.0", "chalk": "^4.0.0", "jest-get-type": "^29.6.3", "leven": "^3.1.0", "pretty-format": "^29.7.0" } }, "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw=="],
|
||||||
|
|
||||||
@@ -1565,7 +1561,7 @@
|
|||||||
|
|
||||||
"pretty-bytes": ["pretty-bytes@5.6.0", "", {}, "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg=="],
|
"pretty-bytes": ["pretty-bytes@5.6.0", "", {}, "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg=="],
|
||||||
|
|
||||||
"pretty-format": ["pretty-format@30.2.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA=="],
|
"pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="],
|
||||||
|
|
||||||
"proc-log": ["proc-log@4.2.0", "", {}, "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA=="],
|
"proc-log": ["proc-log@4.2.0", "", {}, "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA=="],
|
||||||
|
|
||||||
@@ -1609,7 +1605,7 @@
|
|||||||
|
|
||||||
"react-freeze": ["react-freeze@1.0.4", "", { "peerDependencies": { "react": ">=17.0.0" } }, "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA=="],
|
"react-freeze": ["react-freeze@1.0.4", "", { "peerDependencies": { "react": ">=17.0.0" } }, "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA=="],
|
||||||
|
|
||||||
"react-i18next": ["react-i18next@15.7.4", "", { "dependencies": { "@babel/runtime": "^7.27.6", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { "i18next": ">= 23.4.0", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw=="],
|
"react-i18next": ["react-i18next@16.0.0", "", { "dependencies": { "@babel/runtime": "^7.27.6", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { "i18next": ">= 25.5.2", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-JQ+dFfLnFSKJQt7W01lJHWRC0SX7eDPobI+MSTJ3/gP39xH2g33AuTE7iddAfXYHamJdAeMGM0VFboPaD3G68Q=="],
|
||||||
|
|
||||||
"react-is": ["react-is@19.2.0", "", {}, "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA=="],
|
"react-is": ["react-is@19.2.0", "", {}, "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA=="],
|
||||||
|
|
||||||
@@ -2023,8 +2019,6 @@
|
|||||||
|
|
||||||
"@expo/cli/picomatch": ["picomatch@3.0.1", "", {}, "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag=="],
|
"@expo/cli/picomatch": ["picomatch@3.0.1", "", {}, "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag=="],
|
||||||
|
|
||||||
"@expo/cli/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="],
|
|
||||||
|
|
||||||
"@expo/cli/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
|
"@expo/cli/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
|
||||||
|
|
||||||
"@expo/cli/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
"@expo/cli/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||||
@@ -2079,8 +2073,6 @@
|
|||||||
|
|
||||||
"@expo/metro-config/postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="],
|
"@expo/metro-config/postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="],
|
||||||
|
|
||||||
"@expo/metro-runtime/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="],
|
|
||||||
|
|
||||||
"@expo/package-manager/ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="],
|
"@expo/package-manager/ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="],
|
||||||
|
|
||||||
"@expo/prebuild-config/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
|
"@expo/prebuild-config/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
|
||||||
@@ -2099,22 +2091,8 @@
|
|||||||
|
|
||||||
"@istanbuljs/load-nyc-config/js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="],
|
"@istanbuljs/load-nyc-config/js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="],
|
||||||
|
|
||||||
"@jest/environment/jest-mock": ["jest-mock@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "jest-util": "^29.7.0" } }, "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw=="],
|
|
||||||
|
|
||||||
"@jest/fake-timers/jest-message-util": ["jest-message-util@29.7.0", "", { "dependencies": { "@babel/code-frame": "^7.12.13", "@jest/types": "^29.6.3", "@types/stack-utils": "^2.0.0", "chalk": "^4.0.0", "graceful-fs": "^4.2.9", "micromatch": "^4.0.4", "pretty-format": "^29.7.0", "slash": "^3.0.0", "stack-utils": "^2.0.3" } }, "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w=="],
|
|
||||||
|
|
||||||
"@jest/fake-timers/jest-mock": ["jest-mock@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "jest-util": "^29.7.0" } }, "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw=="],
|
|
||||||
|
|
||||||
"@jest/fake-timers/jest-util": ["jest-util@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "chalk": "^4.0.0", "ci-info": "^3.2.0", "graceful-fs": "^4.2.9", "picomatch": "^2.2.3" } }, "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA=="],
|
|
||||||
|
|
||||||
"@jest/pattern/jest-regex-util": ["jest-regex-util@30.0.1", "", {}, "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA=="],
|
|
||||||
|
|
||||||
"@jest/transform/jest-util": ["jest-util@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "chalk": "^4.0.0", "ci-info": "^3.2.0", "graceful-fs": "^4.2.9", "picomatch": "^2.2.3" } }, "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA=="],
|
|
||||||
|
|
||||||
"@jest/transform/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
|
"@jest/transform/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
|
||||||
|
|
||||||
"@jest/types/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="],
|
|
||||||
|
|
||||||
"@jimp/png/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="],
|
"@jimp/png/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="],
|
||||||
|
|
||||||
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||||
@@ -2129,8 +2107,6 @@
|
|||||||
|
|
||||||
"@react-native-community/cli-server-api/open": ["open@6.4.0", "", { "dependencies": { "is-wsl": "^1.1.0" } }, "sha512-IFenVPgF70fSm1keSd2iDBIDIBZkroLeuffXq+wKTzTJlBpesFWojV9lb8mzOfaAzM1sr7HQHuO0vtV0zYekGg=="],
|
"@react-native-community/cli-server-api/open": ["open@6.4.0", "", { "dependencies": { "is-wsl": "^1.1.0" } }, "sha512-IFenVPgF70fSm1keSd2iDBIDIBZkroLeuffXq+wKTzTJlBpesFWojV9lb8mzOfaAzM1sr7HQHuO0vtV0zYekGg=="],
|
||||||
|
|
||||||
"@react-native-community/cli-server-api/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="],
|
|
||||||
|
|
||||||
"@react-native-community/cli-tools/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
"@react-native-community/cli-tools/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||||
|
|
||||||
"@react-native/community-cli-plugin/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
"@react-native/community-cli-plugin/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||||
@@ -2175,8 +2151,6 @@
|
|||||||
|
|
||||||
"error-ex/is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="],
|
"error-ex/is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="],
|
||||||
|
|
||||||
"expo/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="],
|
|
||||||
|
|
||||||
"expo-build-properties/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
|
"expo-build-properties/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
|
||||||
|
|
||||||
"expo-modules-autolinking/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="],
|
"expo-modules-autolinking/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="],
|
||||||
@@ -2209,28 +2183,8 @@
|
|||||||
|
|
||||||
"import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
"import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||||
|
|
||||||
"jest-environment-node/jest-mock": ["jest-mock@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "jest-util": "^29.7.0" } }, "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw=="],
|
|
||||||
|
|
||||||
"jest-environment-node/jest-util": ["jest-util@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "chalk": "^4.0.0", "ci-info": "^3.2.0", "graceful-fs": "^4.2.9", "picomatch": "^2.2.3" } }, "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA=="],
|
|
||||||
|
|
||||||
"jest-haste-map/jest-util": ["jest-util@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "chalk": "^4.0.0", "ci-info": "^3.2.0", "graceful-fs": "^4.2.9", "picomatch": "^2.2.3" } }, "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA=="],
|
|
||||||
|
|
||||||
"jest-message-util/@jest/types": ["@jest/types@30.2.0", "", { "dependencies": { "@jest/pattern": "30.0.1", "@jest/schemas": "30.0.5", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", "@types/node": "*", "@types/yargs": "^17.0.33", "chalk": "^4.1.2" } }, "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg=="],
|
|
||||||
|
|
||||||
"jest-message-util/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
|
"jest-message-util/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
|
||||||
|
|
||||||
"jest-mock/@jest/types": ["@jest/types@30.2.0", "", { "dependencies": { "@jest/pattern": "30.0.1", "@jest/schemas": "30.0.5", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", "@types/node": "*", "@types/yargs": "^17.0.33", "chalk": "^4.1.2" } }, "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg=="],
|
|
||||||
|
|
||||||
"jest-util/@jest/types": ["@jest/types@30.2.0", "", { "dependencies": { "@jest/pattern": "30.0.1", "@jest/schemas": "30.0.5", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", "@types/node": "*", "@types/yargs": "^17.0.33", "chalk": "^4.1.2" } }, "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg=="],
|
|
||||||
|
|
||||||
"jest-util/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="],
|
|
||||||
|
|
||||||
"jest-util/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
|
||||||
|
|
||||||
"jest-validate/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="],
|
|
||||||
|
|
||||||
"jest-worker/jest-util": ["jest-util@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "chalk": "^4.0.0", "ci-info": "^3.2.0", "graceful-fs": "^4.2.9", "picomatch": "^2.2.3" } }, "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA=="],
|
|
||||||
|
|
||||||
"jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
|
"jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
|
||||||
|
|
||||||
"lighthouse-logger/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
"lighthouse-logger/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||||
@@ -2289,8 +2243,6 @@
|
|||||||
|
|
||||||
"react-native/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
|
"react-native/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
|
||||||
|
|
||||||
"react-native/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="],
|
|
||||||
|
|
||||||
"react-native/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
"react-native/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||||
|
|
||||||
"react-native-reanimated/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
"react-native-reanimated/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||||
@@ -2359,18 +2311,6 @@
|
|||||||
|
|
||||||
"@expo/cli/ora/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="],
|
"@expo/cli/ora/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="],
|
||||||
|
|
||||||
"@expo/cli/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="],
|
|
||||||
|
|
||||||
"@expo/cli/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
|
|
||||||
|
|
||||||
"@expo/cli/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
|
||||||
|
|
||||||
"@expo/metro-runtime/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="],
|
|
||||||
|
|
||||||
"@expo/metro-runtime/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
|
|
||||||
|
|
||||||
"@expo/metro-runtime/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
|
||||||
|
|
||||||
"@expo/metro/metro-source-map/metro-symbolicate": ["metro-symbolicate@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.83.2", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-KoU9BLwxxED6n33KYuQQuc5bXkIxF3fSwlc3ouxrrdLWwhu64muYZNQrukkWzhVKRNFIXW7X2iM8JXpi2heIPw=="],
|
"@expo/metro/metro-source-map/metro-symbolicate": ["metro-symbolicate@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.83.2", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-KoU9BLwxxED6n33KYuQQuc5bXkIxF3fSwlc3ouxrrdLWwhu64muYZNQrukkWzhVKRNFIXW7X2iM8JXpi2heIPw=="],
|
||||||
|
|
||||||
"@expo/metro/metro-source-map/ob1": ["ob1@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-XlK3w4M+dwd1g1gvHzVbxiXEbUllRONEgcF2uEO0zm4nxa0eKlh41c6N65q1xbiDOeKKda1tvNOAD33fNjyvCg=="],
|
"@expo/metro/metro-source-map/ob1": ["ob1@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-XlK3w4M+dwd1g1gvHzVbxiXEbUllRONEgcF2uEO0zm4nxa0eKlh41c6N65q1xbiDOeKKda1tvNOAD33fNjyvCg=="],
|
||||||
@@ -2393,22 +2333,8 @@
|
|||||||
|
|
||||||
"@istanbuljs/load-nyc-config/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
"@istanbuljs/load-nyc-config/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||||
|
|
||||||
"@jest/environment/jest-mock/jest-util": ["jest-util@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "chalk": "^4.0.0", "ci-info": "^3.2.0", "graceful-fs": "^4.2.9", "picomatch": "^2.2.3" } }, "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA=="],
|
|
||||||
|
|
||||||
"@jest/fake-timers/jest-message-util/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="],
|
|
||||||
|
|
||||||
"@jest/fake-timers/jest-message-util/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
|
|
||||||
|
|
||||||
"@jest/types/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="],
|
|
||||||
|
|
||||||
"@react-native-community/cli-server-api/open/is-wsl": ["is-wsl@1.1.0", "", {}, "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw=="],
|
"@react-native-community/cli-server-api/open/is-wsl": ["is-wsl@1.1.0", "", {}, "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw=="],
|
||||||
|
|
||||||
"@react-native-community/cli-server-api/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="],
|
|
||||||
|
|
||||||
"@react-native-community/cli-server-api/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
|
|
||||||
|
|
||||||
"@react-native-community/cli-server-api/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
|
||||||
|
|
||||||
"@react-navigation/bottom-tabs/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
"@react-navigation/bottom-tabs/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||||
|
|
||||||
"@react-navigation/bottom-tabs/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
|
"@react-navigation/bottom-tabs/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
|
||||||
@@ -2441,22 +2367,10 @@
|
|||||||
|
|
||||||
"connect/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
"connect/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||||
|
|
||||||
"expo/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="],
|
|
||||||
|
|
||||||
"expo/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
|
|
||||||
|
|
||||||
"expo/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
|
||||||
|
|
||||||
"finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
"finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||||
|
|
||||||
"glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
"glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||||
|
|
||||||
"jest-validate/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="],
|
|
||||||
|
|
||||||
"jest-validate/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
|
|
||||||
|
|
||||||
"jest-validate/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
|
||||||
|
|
||||||
"lighthouse-logger/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
"lighthouse-logger/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||||
|
|
||||||
"log-update/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
|
"log-update/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
|
||||||
@@ -2487,12 +2401,6 @@
|
|||||||
|
|
||||||
"patch-package/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
|
"patch-package/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
|
||||||
|
|
||||||
"react-native/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="],
|
|
||||||
|
|
||||||
"react-native/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
|
|
||||||
|
|
||||||
"react-native/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
|
||||||
|
|
||||||
"readable-web-to-node-stream/readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
|
"readable-web-to-node-stream/readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
|
||||||
|
|
||||||
"send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
"send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||||
@@ -2525,10 +2433,6 @@
|
|||||||
|
|
||||||
"@expo/cli/ora/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="],
|
"@expo/cli/ora/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="],
|
||||||
|
|
||||||
"@expo/cli/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="],
|
|
||||||
|
|
||||||
"@expo/metro-runtime/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="],
|
|
||||||
|
|
||||||
"@expo/package-manager/ora/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
|
"@expo/package-manager/ora/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
|
||||||
|
|
||||||
"@expo/package-manager/ora/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
|
"@expo/package-manager/ora/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
|
||||||
@@ -2541,14 +2445,6 @@
|
|||||||
|
|
||||||
"@istanbuljs/load-nyc-config/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
"@istanbuljs/load-nyc-config/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
||||||
|
|
||||||
"@jest/fake-timers/jest-message-util/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="],
|
|
||||||
|
|
||||||
"@jest/fake-timers/jest-message-util/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
|
|
||||||
|
|
||||||
"@jest/fake-timers/jest-message-util/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
|
||||||
|
|
||||||
"@react-native-community/cli-server-api/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="],
|
|
||||||
|
|
||||||
"@react-navigation/bottom-tabs/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
"@react-navigation/bottom-tabs/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||||
|
|
||||||
"@react-navigation/bottom-tabs/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
"@react-navigation/bottom-tabs/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||||
@@ -2569,10 +2465,6 @@
|
|||||||
|
|
||||||
"cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
"cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||||
|
|
||||||
"expo/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="],
|
|
||||||
|
|
||||||
"jest-validate/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="],
|
|
||||||
|
|
||||||
"log-update/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
|
"log-update/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
|
||||||
|
|
||||||
"log-update/cli-cursor/restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
"log-update/cli-cursor/restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||||
@@ -2583,8 +2475,6 @@
|
|||||||
|
|
||||||
"logkitty/yargs/yargs-parser/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
|
"logkitty/yargs/yargs-parser/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
|
||||||
|
|
||||||
"react-native/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="],
|
|
||||||
|
|
||||||
"serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
"serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||||
|
|
||||||
"@babel/highlight/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
|
"@babel/highlight/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
|
||||||
@@ -2603,8 +2493,6 @@
|
|||||||
|
|
||||||
"@istanbuljs/load-nyc-config/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
|
"@istanbuljs/load-nyc-config/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
|
||||||
|
|
||||||
"@jest/fake-timers/jest-message-util/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="],
|
|
||||||
|
|
||||||
"ansi-fragments/slice-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
|
"ansi-fragments/slice-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
|
||||||
|
|
||||||
"logkitty/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
"logkitty/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type React from "react";
|
|||||||
import {
|
import {
|
||||||
type PropsWithChildren,
|
type PropsWithChildren,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
useMemo,
|
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
@@ -18,6 +17,58 @@ import {
|
|||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
|
|
||||||
|
const getColorClasses = (
|
||||||
|
color: "purple" | "red" | "black" | "transparent" | "white",
|
||||||
|
variant: "solid" | "border",
|
||||||
|
focused: boolean,
|
||||||
|
): string => {
|
||||||
|
if (variant === "border") {
|
||||||
|
switch (color) {
|
||||||
|
case "purple":
|
||||||
|
return focused
|
||||||
|
? "bg-transparent border-2 border-purple-400"
|
||||||
|
: "bg-transparent border-2 border-purple-600";
|
||||||
|
case "red":
|
||||||
|
return focused
|
||||||
|
? "bg-transparent border-2 border-red-400"
|
||||||
|
: "bg-transparent border-2 border-red-600";
|
||||||
|
case "black":
|
||||||
|
return focused
|
||||||
|
? "bg-transparent border-2 border-neutral-700"
|
||||||
|
: "bg-transparent border-2 border-neutral-900";
|
||||||
|
case "white":
|
||||||
|
return focused
|
||||||
|
? "bg-transparent border-2 border-gray-100"
|
||||||
|
: "bg-transparent border-2 border-white";
|
||||||
|
case "transparent":
|
||||||
|
return focused
|
||||||
|
? "bg-transparent border-2 border-gray-400"
|
||||||
|
: "bg-transparent border-2 border-gray-600";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switch (color) {
|
||||||
|
case "purple":
|
||||||
|
return focused
|
||||||
|
? "bg-purple-500 border-2 border-white"
|
||||||
|
: "bg-purple-600 border border-purple-700";
|
||||||
|
case "red":
|
||||||
|
return "bg-red-600";
|
||||||
|
case "black":
|
||||||
|
return "bg-neutral-900";
|
||||||
|
case "white":
|
||||||
|
return focused
|
||||||
|
? "bg-gray-100 border-2 border-gray-300"
|
||||||
|
: "bg-white border border-gray-200";
|
||||||
|
case "transparent":
|
||||||
|
return "bg-transparent";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
extends React.ComponentProps<typeof TouchableOpacity> {
|
extends React.ComponentProps<typeof TouchableOpacity> {
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
@@ -26,7 +77,8 @@ export interface ButtonProps
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
children?: string | ReactNode;
|
children?: string | ReactNode;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
color?: "purple" | "red" | "black" | "transparent";
|
color?: "purple" | "red" | "black" | "transparent" | "white";
|
||||||
|
variant?: "solid" | "border";
|
||||||
iconRight?: ReactNode;
|
iconRight?: ReactNode;
|
||||||
iconLeft?: ReactNode;
|
iconLeft?: ReactNode;
|
||||||
justify?: "center" | "between";
|
justify?: "center" | "between";
|
||||||
@@ -39,6 +91,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
loading = false,
|
loading = false,
|
||||||
color = "purple",
|
color = "purple",
|
||||||
|
variant = "solid",
|
||||||
iconRight,
|
iconRight,
|
||||||
iconLeft,
|
iconLeft,
|
||||||
children,
|
children,
|
||||||
@@ -56,23 +109,13 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
}).start();
|
}).start();
|
||||||
|
|
||||||
const colorClasses = useMemo(() => {
|
const colorClasses = getColorClasses(color, variant, focused);
|
||||||
switch (color) {
|
|
||||||
case "purple":
|
|
||||||
return focused
|
|
||||||
? "bg-purple-500 border-2 border-white"
|
|
||||||
: "bg-purple-600 border border-purple-700";
|
|
||||||
case "red":
|
|
||||||
return "bg-red-600";
|
|
||||||
case "black":
|
|
||||||
return "bg-neutral-900";
|
|
||||||
case "transparent":
|
|
||||||
return "bg-transparent";
|
|
||||||
}
|
|
||||||
}, [color, focused]);
|
|
||||||
|
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
|
const textColorClass =
|
||||||
|
color === "white" && variant === "solid" ? "text-black" : "text-white";
|
||||||
|
|
||||||
return Platform.isTV ? (
|
return Platform.isTV ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
className='w-full'
|
className='w-full'
|
||||||
@@ -98,10 +141,12 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
className={`rounded-2xl py-5 items-center justify-center
|
className={`rounded-2xl py-5 items-center justify-center
|
||||||
${focused ? "bg-purple-500 border-2 border-white" : "bg-purple-600 border border-purple-700"}
|
${colorClasses}
|
||||||
${className}`}
|
${className}`}
|
||||||
>
|
>
|
||||||
<Text className='text-white text-xl font-bold'>{children}</Text>
|
<Text className={`${textColorClass} text-xl font-bold`}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -135,7 +180,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
{iconLeft ? iconLeft : <View className='w-4' />}
|
{iconLeft ? iconLeft : <View className='w-4' />}
|
||||||
<Text
|
<Text
|
||||||
className={`
|
className={`
|
||||||
text-white font-bold text-base
|
${textColorClass} font-bold text-base
|
||||||
${disabled ? "text-gray-300" : ""}
|
${disabled ? "text-gray-300" : ""}
|
||||||
${textClassName}
|
${textClassName}
|
||||||
${iconRight ? "mr-2" : ""}
|
${iconRight ? "mr-2" : ""}
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import { Image } from "expo-image";
|
|||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { type Bitrate } from "@/components/BitrateSelector";
|
import { type Bitrate } from "@/components/BitrateSelector";
|
||||||
import { ItemImage } from "@/components/common/ItemImage";
|
import { ItemImage } from "@/components/common/ItemImage";
|
||||||
import { DownloadSingleItem } from "@/components/DownloadItem";
|
import { DownloadSingleItem } from "@/components/DownloadItem";
|
||||||
|
import { MediaSourceButton } from "@/components/MediaSourceButton";
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null;
|
// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null;
|
||||||
@@ -29,13 +29,10 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import { AddToFavorites } from "./AddToFavorites";
|
import { AddToFavorites } from "./AddToFavorites";
|
||||||
import { BitrateSheet } from "./BitRateSheet";
|
|
||||||
import { ItemHeader } from "./ItemHeader";
|
import { ItemHeader } from "./ItemHeader";
|
||||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||||
import { MediaSourceSheet } from "./MediaSourceSheet";
|
|
||||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||||
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
|
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
|
||||||
import { TrackSheet } from "./TrackSheet";
|
|
||||||
|
|
||||||
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
||||||
|
|
||||||
@@ -59,7 +56,6 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const itemColors = useImageColorsReturn({ item });
|
const itemColors = useImageColorsReturn({ item });
|
||||||
|
|
||||||
@@ -75,7 +71,13 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
defaultBitrate,
|
defaultBitrate,
|
||||||
defaultMediaSource,
|
defaultMediaSource,
|
||||||
defaultSubtitleIndex,
|
defaultSubtitleIndex,
|
||||||
} = useDefaultPlaySettings(item!, settings);
|
} = useDefaultPlaySettings(item, settings);
|
||||||
|
|
||||||
|
console.log("defaultMediaSource", {
|
||||||
|
defaultAudioIndex,
|
||||||
|
defaultBitrate,
|
||||||
|
defaultSubtitleIndex,
|
||||||
|
});
|
||||||
|
|
||||||
const logoUrl = useMemo(
|
const logoUrl = useMemo(
|
||||||
() => (item ? getLogoImageUrlById({ api, item }) : null),
|
() => (item ? getLogoImageUrlById({ api, item }) : null),
|
||||||
@@ -90,7 +92,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedOptions(() => ({
|
setSelectedOptions(() => ({
|
||||||
bitrate: defaultBitrate,
|
bitrate: defaultBitrate,
|
||||||
mediaSource: defaultMediaSource,
|
mediaSource: defaultMediaSource ?? undefined,
|
||||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||||
audioIndex: defaultAudioIndex,
|
audioIndex: defaultAudioIndex,
|
||||||
}));
|
}));
|
||||||
@@ -102,7 +104,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!Platform.isTV) {
|
if (!Platform.isTV && item) {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () =>
|
headerRight: () =>
|
||||||
item &&
|
item &&
|
||||||
@@ -143,7 +145,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
)),
|
)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [item, navigation, user]);
|
}, [item, navigation, user, item]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (item) {
|
if (item) {
|
||||||
@@ -201,76 +203,27 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<View className='flex flex-col bg-transparent shrink'>
|
<View className='flex flex-col bg-transparent shrink'>
|
||||||
<View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'>
|
<View className='flex flex-col px-4 w-full pt-2 mb-2 shrink'>
|
||||||
<ItemHeader item={item} className='mb-2' />
|
<ItemHeader item={item} className='mb-2' />
|
||||||
{item.Type !== "Program" && !Platform.isTV && !isOffline && (
|
|
||||||
<View className='flex flex-row items-center justify-start w-full h-16 mb-2'>
|
<View className='flex flex-row px-0 mb-2 justify-between space-x-2'>
|
||||||
<BitrateSheet
|
<PlayButton
|
||||||
className='mr-1'
|
selectedOptions={selectedOptions}
|
||||||
onChange={(val) =>
|
item={item}
|
||||||
setSelectedOptions(
|
isOffline={isOffline}
|
||||||
(prev) => prev && { ...prev, bitrate: val },
|
colors={itemColors}
|
||||||
)
|
/>
|
||||||
}
|
<View className='w-1' />
|
||||||
selected={selectedOptions.bitrate}
|
{!isOffline && (
|
||||||
/>
|
<MediaSourceButton
|
||||||
<MediaSourceSheet
|
selectedOptions={selectedOptions}
|
||||||
className='mr-1'
|
setSelectedOptions={setSelectedOptions}
|
||||||
item={item}
|
item={item}
|
||||||
onChange={(val) =>
|
colors={itemColors}
|
||||||
setSelectedOptions(
|
|
||||||
(prev) =>
|
|
||||||
prev && {
|
|
||||||
...prev,
|
|
||||||
mediaSource: val,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
selected={selectedOptions.mediaSource}
|
|
||||||
/>
|
/>
|
||||||
<TrackSheet
|
)}
|
||||||
className='mr-1'
|
</View>
|
||||||
streamType='Audio'
|
|
||||||
title={t("item_card.audio")}
|
|
||||||
source={selectedOptions.mediaSource}
|
|
||||||
onChange={(val) => {
|
|
||||||
setSelectedOptions(
|
|
||||||
(prev) =>
|
|
||||||
prev && {
|
|
||||||
...prev,
|
|
||||||
audioIndex: val,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
selected={selectedOptions.audioIndex}
|
|
||||||
/>
|
|
||||||
<TrackSheet
|
|
||||||
source={selectedOptions.mediaSource}
|
|
||||||
streamType='Subtitle'
|
|
||||||
title={t("item_card.subtitles")}
|
|
||||||
onChange={(val) =>
|
|
||||||
setSelectedOptions(
|
|
||||||
(prev) =>
|
|
||||||
prev && {
|
|
||||||
...prev,
|
|
||||||
subtitleIndex: val,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
selected={selectedOptions.subtitleIndex}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<PlayButton
|
|
||||||
className='grow'
|
|
||||||
selectedOptions={selectedOptions}
|
|
||||||
item={item}
|
|
||||||
isOffline={isOffline}
|
|
||||||
colors={itemColors}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{item.Type === "Episode" && (
|
{item.Type === "Episode" && (
|
||||||
<SeasonEpisodesCarousel
|
<SeasonEpisodesCarousel
|
||||||
item={item}
|
item={item}
|
||||||
@@ -279,9 +232,12 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isOffline && (
|
{!isOffline &&
|
||||||
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
selectedOptions.mediaSource?.MediaStreams &&
|
||||||
)}
|
selectedOptions.mediaSource.MediaStreams.length > 0 && (
|
||||||
|
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
||||||
|
)}
|
||||||
|
|
||||||
<OverviewText text={item.Overview} className='px-4 mb-4' />
|
<OverviewText text={item.Overview} className='px-4 mb-4' />
|
||||||
|
|
||||||
{item.Type !== "Program" && (
|
{item.Type !== "Program" && (
|
||||||
|
|||||||
198
components/MediaSourceButton.tsx
Normal file
198
components/MediaSourceButton.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type {
|
||||||
|
BaseItemDto,
|
||||||
|
MediaSourceInfo,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||||
|
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||||
|
import { BITRATES } from "./BitRateSheet";
|
||||||
|
import type { SelectedOptions } from "./ItemContent";
|
||||||
|
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||||
|
|
||||||
|
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
|
||||||
|
item?: BaseItemDto | null;
|
||||||
|
selectedOptions: SelectedOptions;
|
||||||
|
setSelectedOptions: React.Dispatch<
|
||||||
|
React.SetStateAction<SelectedOptions | undefined>
|
||||||
|
>;
|
||||||
|
colors?: ThemeColors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MediaSourceButton: React.FC<Props> = ({
|
||||||
|
item,
|
||||||
|
selectedOptions,
|
||||||
|
setSelectedOptions,
|
||||||
|
colors,
|
||||||
|
}: Props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const effectiveColors = colors || {
|
||||||
|
primary: "#7c3aed",
|
||||||
|
text: "#000000",
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const firstMediaSource = item?.MediaSources?.[0];
|
||||||
|
if (!firstMediaSource) return;
|
||||||
|
setSelectedOptions((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
mediaSource: firstMediaSource,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [item, setSelectedOptions]);
|
||||||
|
|
||||||
|
const getMediaSourceDisplayName = useCallback((source: MediaSourceInfo) => {
|
||||||
|
const videoStream = source.MediaStreams?.find((x) => x.Type === "Video");
|
||||||
|
if (source.Name) return source.Name;
|
||||||
|
if (videoStream?.DisplayTitle) return videoStream.DisplayTitle;
|
||||||
|
return `Source ${source.Id}`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const audioStreams = useMemo(
|
||||||
|
() =>
|
||||||
|
selectedOptions.mediaSource?.MediaStreams?.filter(
|
||||||
|
(x) => x.Type === "Audio",
|
||||||
|
) || [],
|
||||||
|
[selectedOptions.mediaSource],
|
||||||
|
);
|
||||||
|
|
||||||
|
const subtitleStreams = useMemo(
|
||||||
|
() =>
|
||||||
|
selectedOptions.mediaSource?.MediaStreams?.filter(
|
||||||
|
(x) => x.Type === "Subtitle",
|
||||||
|
) || [],
|
||||||
|
[selectedOptions.mediaSource],
|
||||||
|
);
|
||||||
|
|
||||||
|
const optionGroups: OptionGroup[] = useMemo(() => {
|
||||||
|
const groups: OptionGroup[] = [];
|
||||||
|
|
||||||
|
// Bitrate group
|
||||||
|
groups.push({
|
||||||
|
title: t("item_card.quality"),
|
||||||
|
options: BITRATES.map((bitrate) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: bitrate.key,
|
||||||
|
value: bitrate,
|
||||||
|
selected: bitrate.value === selectedOptions.bitrate?.value,
|
||||||
|
onPress: () =>
|
||||||
|
setSelectedOptions((prev) => prev && { ...prev, bitrate }),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Media Source group (only if multiple sources)
|
||||||
|
if (item?.MediaSources && item.MediaSources.length > 1) {
|
||||||
|
groups.push({
|
||||||
|
title: t("item_card.video"),
|
||||||
|
options: item.MediaSources.map((source) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: getMediaSourceDisplayName(source),
|
||||||
|
value: source,
|
||||||
|
selected: source.Id === selectedOptions.mediaSource?.Id,
|
||||||
|
onPress: () =>
|
||||||
|
setSelectedOptions(
|
||||||
|
(prev) => prev && { ...prev, mediaSource: source },
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio track group
|
||||||
|
if (audioStreams.length > 0) {
|
||||||
|
console.log("Audio comparison:", {
|
||||||
|
selectedAudioIndex: selectedOptions.audioIndex,
|
||||||
|
streamIndices: audioStreams.map((s) => s.Index),
|
||||||
|
});
|
||||||
|
|
||||||
|
groups.push({
|
||||||
|
title: t("item_card.audio"),
|
||||||
|
options: audioStreams.map((stream) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: stream.DisplayTitle || `${t("common.track")} ${stream.Index}`,
|
||||||
|
value: stream.Index,
|
||||||
|
selected: stream.Index === selectedOptions.audioIndex,
|
||||||
|
onPress: () =>
|
||||||
|
setSelectedOptions(
|
||||||
|
(prev) => prev && { ...prev, audioIndex: stream.Index ?? 0 },
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subtitle track group (with None option)
|
||||||
|
if (subtitleStreams.length > 0) {
|
||||||
|
const noneOption = {
|
||||||
|
type: "radio" as const,
|
||||||
|
label: t("common.none"),
|
||||||
|
value: -1,
|
||||||
|
selected: selectedOptions.subtitleIndex === -1,
|
||||||
|
onPress: () =>
|
||||||
|
setSelectedOptions((prev) => prev && { ...prev, subtitleIndex: -1 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const subtitleOptions = subtitleStreams.map((stream) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: stream.DisplayTitle || `${t("common.track")} ${stream.Index}`,
|
||||||
|
value: stream.Index,
|
||||||
|
selected: stream.Index === selectedOptions.subtitleIndex,
|
||||||
|
onPress: () =>
|
||||||
|
setSelectedOptions(
|
||||||
|
(prev) => prev && { ...prev, subtitleIndex: stream.Index ?? -1 },
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
groups.push({
|
||||||
|
title: t("item_card.subtitles"),
|
||||||
|
options: [noneOption, ...subtitleOptions],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}, [
|
||||||
|
item,
|
||||||
|
selectedOptions,
|
||||||
|
audioStreams,
|
||||||
|
subtitleStreams,
|
||||||
|
getMediaSourceDisplayName,
|
||||||
|
t,
|
||||||
|
setSelectedOptions,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const trigger = (
|
||||||
|
<TouchableOpacity
|
||||||
|
disabled={!item}
|
||||||
|
onPress={() => setOpen(true)}
|
||||||
|
className='relative'
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{ backgroundColor: effectiveColors.primary, opacity: 0.7 }}
|
||||||
|
className='absolute w-12 h-12 rounded-full'
|
||||||
|
/>
|
||||||
|
<View className='w-12 h-12 rounded-full z-10 items-center justify-center'>
|
||||||
|
{!item ? (
|
||||||
|
<ActivityIndicator size='small' color={effectiveColors.text} />
|
||||||
|
) : (
|
||||||
|
<Ionicons name='list' size={24} color={effectiveColors.text} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PlatformDropdown
|
||||||
|
groups={optionGroups}
|
||||||
|
trigger={trigger}
|
||||||
|
title={t("item_card.media_options")}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
bottomSheetConfig={{
|
||||||
|
enablePanDownToClose: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -184,7 +184,7 @@ const PlatformDropdownComponent = ({
|
|||||||
expoUIConfig,
|
expoUIConfig,
|
||||||
bottomSheetConfig,
|
bottomSheetConfig,
|
||||||
}: PlatformDropdownProps) => {
|
}: PlatformDropdownProps) => {
|
||||||
const { showModal, hideModal } = useGlobalModal();
|
const { showModal, hideModal, isVisible } = useGlobalModal();
|
||||||
|
|
||||||
// Handle controlled open state for Android
|
// Handle controlled open state for Android
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -207,6 +207,14 @@ const PlatformDropdownComponent = ({
|
|||||||
}
|
}
|
||||||
}, [controlledOpen]);
|
}, [controlledOpen]);
|
||||||
|
|
||||||
|
// Watch for modal dismissal on Android (e.g., swipe down, backdrop tap)
|
||||||
|
// and sync the controlled open state
|
||||||
|
useEffect(() => {
|
||||||
|
if (Platform.OS === "android" && controlledOpen === true && !isVisible) {
|
||||||
|
controlledOnOpenChange?.(false);
|
||||||
|
}
|
||||||
|
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
||||||
|
|
||||||
if (Platform.OS === "ios") {
|
if (Platform.OS === "ios") {
|
||||||
return (
|
return (
|
||||||
<Host style={expoUIConfig?.hostStyle}>
|
<Host style={expoUIConfig?.hostStyle}>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||||
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
|
import { BottomSheetView } from "@gorhom/bottom-sheet";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, TouchableOpacity, View } from "react-native";
|
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
||||||
import CastContext, {
|
import CastContext, {
|
||||||
CastButton,
|
CastButton,
|
||||||
PlayServicesState,
|
PlayServicesState,
|
||||||
@@ -24,6 +25,8 @@ import Animated, {
|
|||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||||
|
import { getDownloadedItemById } from "@/providers/Downloads/database";
|
||||||
|
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
@@ -33,6 +36,8 @@ import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
|||||||
import { chromecast } from "@/utils/profiles/chromecast";
|
import { chromecast } from "@/utils/profiles/chromecast";
|
||||||
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
|
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
|
import { Button } from "./Button";
|
||||||
|
import { Text } from "./common/Text";
|
||||||
import type { SelectedOptions } from "./ItemContent";
|
import type { SelectedOptions } from "./ItemContent";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
|
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
|
||||||
@@ -55,6 +60,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
const client = useRemoteMediaClient();
|
const client = useRemoteMediaClient();
|
||||||
const mediaStatus = useMediaStatus();
|
const mediaStatus = useMediaStatus();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { showModal, hideModal } = useGlobalModal();
|
||||||
|
|
||||||
const [globalColorAtom] = useAtom(itemThemeColorAtom);
|
const [globalColorAtom] = useAtom(itemThemeColorAtom);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
@@ -84,12 +90,9 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
[router, isOffline],
|
[router, isOffline],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onPress = useCallback(async () => {
|
const handleNormalPlayFlow = useCallback(async () => {
|
||||||
console.log("onPress");
|
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
lightHapticFeedback();
|
|
||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
itemId: item.Id!,
|
itemId: item.Id!,
|
||||||
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
||||||
@@ -271,6 +274,118 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
showActionSheetWithOptions,
|
showActionSheetWithOptions,
|
||||||
mediaStatus,
|
mediaStatus,
|
||||||
selectedOptions,
|
selectedOptions,
|
||||||
|
goToPlayer,
|
||||||
|
isOffline,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const onPress = useCallback(async () => {
|
||||||
|
console.log("onPress");
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
lightHapticFeedback();
|
||||||
|
|
||||||
|
// Check if item is downloaded
|
||||||
|
const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined;
|
||||||
|
|
||||||
|
if (downloadedItem) {
|
||||||
|
if (Platform.OS === "android") {
|
||||||
|
// Show bottom sheet for Android
|
||||||
|
showModal(
|
||||||
|
<BottomSheetView>
|
||||||
|
<View className='px-4 mt-4 mb-12'>
|
||||||
|
<View className='pb-6'>
|
||||||
|
<Text className='text-2xl font-bold mb-2'>
|
||||||
|
{t("player.downloaded_file_title")}
|
||||||
|
</Text>
|
||||||
|
<Text className='opacity-70 text-base'>
|
||||||
|
{t("player.downloaded_file_message")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className='space-y-3'>
|
||||||
|
<Button
|
||||||
|
onPress={() => {
|
||||||
|
hideModal();
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId: item.Id!,
|
||||||
|
offline: "true",
|
||||||
|
playbackPosition:
|
||||||
|
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||||
|
});
|
||||||
|
goToPlayer(queryParams.toString());
|
||||||
|
}}
|
||||||
|
color='purple'
|
||||||
|
>
|
||||||
|
{Platform.OS === "android"
|
||||||
|
? "Play downloaded file"
|
||||||
|
: t("player.downloaded_file_yes")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onPress={() => {
|
||||||
|
hideModal();
|
||||||
|
handleNormalPlayFlow();
|
||||||
|
}}
|
||||||
|
color='white'
|
||||||
|
variant='border'
|
||||||
|
>
|
||||||
|
{Platform.OS === "android"
|
||||||
|
? "Stream file"
|
||||||
|
: t("player.downloaded_file_no")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</BottomSheetView>,
|
||||||
|
{
|
||||||
|
snapPoints: ["35%"],
|
||||||
|
enablePanDownToClose: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Show alert for iOS
|
||||||
|
Alert.alert(
|
||||||
|
t("player.downloaded_file_title"),
|
||||||
|
t("player.downloaded_file_message"),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: t("player.downloaded_file_yes"),
|
||||||
|
onPress: () => {
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId: item.Id!,
|
||||||
|
offline: "true",
|
||||||
|
playbackPosition:
|
||||||
|
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||||
|
});
|
||||||
|
goToPlayer(queryParams.toString());
|
||||||
|
},
|
||||||
|
isPreferred: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: t("player.downloaded_file_no"),
|
||||||
|
onPress: () => {
|
||||||
|
handleNormalPlayFlow();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: t("player.downloaded_file_cancel"),
|
||||||
|
style: "cancel",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not downloaded, proceed with normal flow
|
||||||
|
handleNormalPlayFlow();
|
||||||
|
}, [
|
||||||
|
item,
|
||||||
|
lightHapticFeedback,
|
||||||
|
handleNormalPlayFlow,
|
||||||
|
goToPlayer,
|
||||||
|
t,
|
||||||
|
showModal,
|
||||||
|
hideModal,
|
||||||
|
effectiveColors,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const derivedTargetWidth = useDerivedValue(() => {
|
const derivedTargetWidth = useDerivedValue(() => {
|
||||||
@@ -358,55 +473,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
[startColor.value.text, endColor.value.text],
|
[startColor.value.text, endColor.value.text],
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
/**
|
|
||||||
* *********************
|
|
||||||
*/
|
|
||||||
|
|
||||||
// if (Platform.OS === "ios")
|
|
||||||
// return (
|
|
||||||
// <Host
|
|
||||||
// style={{
|
|
||||||
// height: 50,
|
|
||||||
// flex: 1,
|
|
||||||
// flexShrink: 0,
|
|
||||||
// }}
|
|
||||||
// >
|
|
||||||
// <Button
|
|
||||||
// variant='glassProminent'
|
|
||||||
// onPress={onPress}
|
|
||||||
// color={effectiveColors.primary}
|
|
||||||
// modifiers={[fixedSize()]}
|
|
||||||
// >
|
|
||||||
// <View className='flex flex-row items-center space-x-2 h-full w-full justify-center -mb-3.5 '>
|
|
||||||
// <Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
|
||||||
// {runtimeTicksToMinutes(
|
|
||||||
// (item?.RunTimeTicks || 0) -
|
|
||||||
// (item?.UserData?.PlaybackPositionTicks || 0),
|
|
||||||
// )}
|
|
||||||
// {(item?.UserData?.PlaybackPositionTicks || 0) > 0 && " left"}
|
|
||||||
// </Animated.Text>
|
|
||||||
// <Animated.Text style={animatedTextStyle}>
|
|
||||||
// <Ionicons name='play-circle' size={24} />
|
|
||||||
// </Animated.Text>
|
|
||||||
// {client && (
|
|
||||||
// <Animated.Text style={animatedTextStyle}>
|
|
||||||
// <Feather name='cast' size={22} />
|
|
||||||
// <CastButton tintColor='transparent' />
|
|
||||||
// </Animated.Text>
|
|
||||||
// )}
|
|
||||||
// {!client && settings?.openInVLC && (
|
|
||||||
// <Animated.Text style={animatedTextStyle}>
|
|
||||||
// <MaterialCommunityIcons
|
|
||||||
// name='vlc'
|
|
||||||
// size={18}
|
|
||||||
// color={animatedTextStyle.color}
|
|
||||||
// />
|
|
||||||
// </Animated.Text>
|
|
||||||
// )}
|
|
||||||
// </View>
|
|
||||||
// </Button>
|
|
||||||
// </Host>
|
|
||||||
// );
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -414,7 +480,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
accessibilityLabel='Play button'
|
accessibilityLabel='Play button'
|
||||||
accessibilityHint='Tap to play the media'
|
accessibilityHint='Tap to play the media'
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={"relative"}
|
className={"relative flex-1"}
|
||||||
>
|
>
|
||||||
<View className='absolute w-full h-full top-0 left-0 rounded-full z-10 overflow-hidden'>
|
<View className='absolute w-full h-full top-0 left-0 rounded-full z-10 overflow-hidden'>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
@@ -457,15 +523,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
<CastButton tintColor='transparent' />
|
<CastButton tintColor='transparent' />
|
||||||
</Animated.Text>
|
</Animated.Text>
|
||||||
)}
|
)}
|
||||||
{!client && settings?.openInVLC && (
|
|
||||||
<Animated.Text style={animatedTextStyle}>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name='vlc'
|
|
||||||
size={18}
|
|
||||||
color={animatedTextStyle.color}
|
|
||||||
/>
|
|
||||||
</Animated.Text>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
@@ -17,7 +17,6 @@ import Animated, {
|
|||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
import type { Button } from "./Button";
|
import type { Button } from "./Button";
|
||||||
import type { SelectedOptions } from "./ItemContent";
|
import type { SelectedOptions } from "./ItemContent";
|
||||||
@@ -50,7 +49,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
const startColor = useSharedValue(effectiveColors);
|
const startColor = useSharedValue(effectiveColors);
|
||||||
const widthProgress = useSharedValue(0);
|
const widthProgress = useSharedValue(0);
|
||||||
const colorChangeProgress = useSharedValue(0);
|
const colorChangeProgress = useSharedValue(0);
|
||||||
const { settings } = useSettings();
|
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
const goToPlayer = useCallback(
|
const goToPlayer = useCallback(
|
||||||
@@ -207,15 +205,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
<Animated.Text style={animatedTextStyle}>
|
<Animated.Text style={animatedTextStyle}>
|
||||||
<Ionicons name='play-circle' size={24} />
|
<Ionicons name='play-circle' size={24} />
|
||||||
</Animated.Text>
|
</Animated.Text>
|
||||||
{settings?.openInVLC && (
|
|
||||||
<Animated.Text style={animatedTextStyle}>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name='vlc'
|
|
||||||
size={18}
|
|
||||||
color={animatedTextStyle.color}
|
|
||||||
/>
|
|
||||||
</Animated.Text>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
type QueryKey,
|
type QueryKey,
|
||||||
useInfiniteQuery,
|
useInfiniteQuery,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
@@ -64,6 +65,11 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
// Flatten all pages into a single array
|
// Flatten all pages into a single array
|
||||||
const allItems = data?.pages.flat() || [];
|
const allItems = data?.pages.flat() || [];
|
||||||
|
|
||||||
|
const snapOffsets = useMemo(() => {
|
||||||
|
const itemWidth = orientation === "horizontal" ? 184 : 120; // w-44 (176px) + mr-2 (8px) or w-28 (112px) + mr-2 (8px)
|
||||||
|
return allItems.map((_, index) => index * itemWidth);
|
||||||
|
}, [allItems, orientation]);
|
||||||
|
|
||||||
if (hideIfEmpty === true && allItems.length === 0 && !isLoading) return null;
|
if (hideIfEmpty === true && allItems.length === 0 && !isLoading) return null;
|
||||||
if (disabled || !title) return null;
|
if (disabled || !title) return null;
|
||||||
|
|
||||||
@@ -126,6 +132,8 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
scrollEventThrottle={16}
|
scrollEventThrottle={16}
|
||||||
|
snapToOffsets={snapOffsets}
|
||||||
|
decelerationRate='fast'
|
||||||
>
|
>
|
||||||
<View className='px-4 flex flex-row'>
|
<View className='px-4 flex flex-row'>
|
||||||
{allItems.map((item) => (
|
{allItems.map((item) => (
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
useQuery,
|
useQuery,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import { useInView } from "@/hooks/useInView";
|
import { useInView } from "@/hooks/useInView";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
@@ -67,6 +67,12 @@ export const MediaListSection: React.FC<Props> = ({
|
|||||||
[api, user?.Id, collection?.Id],
|
[api, user?.Id, collection?.Id],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const snapOffsets = useMemo(() => {
|
||||||
|
const itemWidth = 120; // w-28 (112px) + mr-2 (8px)
|
||||||
|
// Generate offsets for a reasonable number of items
|
||||||
|
return Array.from({ length: 50 }, (_, index) => index * itemWidth);
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (!collection) return null;
|
if (!collection) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -92,6 +98,8 @@ export const MediaListSection: React.FC<Props> = ({
|
|||||||
)}
|
)}
|
||||||
queryFn={fetchItems}
|
queryFn={fetchItems}
|
||||||
queryKey={["media-list", collection.Id!]}
|
queryKey={["media-list", collection.Id!]}
|
||||||
|
snapToOffsets={snapOffsets}
|
||||||
|
decelerationRate='fast'
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
|||||||
const scrollRef = useRef<HorizontalScrollRef>(null);
|
const scrollRef = useRef<HorizontalScrollRef>(null);
|
||||||
|
|
||||||
const scrollToIndex = (index: number) => {
|
const scrollToIndex = (index: number) => {
|
||||||
scrollRef.current?.scrollToIndex(index, 16);
|
scrollRef.current?.scrollToIndex(index, -16);
|
||||||
};
|
};
|
||||||
|
|
||||||
const seasonId = useMemo(() => {
|
const seasonId = useMemo(() => {
|
||||||
@@ -87,6 +87,11 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
|||||||
}
|
}
|
||||||
}, [episodes, item]);
|
}, [episodes, item]);
|
||||||
|
|
||||||
|
const snapOffsets = useMemo(() => {
|
||||||
|
const itemWidth = 184; // w-44 (176px) + mr-2 (8px)
|
||||||
|
return episodes?.map((_, index) => index * itemWidth) || [];
|
||||||
|
}, [episodes]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HorizontalScroll
|
<HorizontalScroll
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
@@ -109,6 +114,8 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
|||||||
<ItemCardText item={_item} />
|
<ItemCardText item={_item} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
|
snapToOffsets={snapOffsets}
|
||||||
|
decelerationRate='fast'
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,12 +5,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Platform, View, type ViewProps } from "react-native";
|
import { Platform, View, type ViewProps } from "react-native";
|
||||||
import { Switch } from "react-native-gesture-handler";
|
import { Switch } from "react-native-gesture-handler";
|
||||||
import { Stepper } from "@/components/inputs/Stepper";
|
import { Stepper } from "@/components/inputs/Stepper";
|
||||||
import {
|
|
||||||
OUTLINE_THICKNESS,
|
|
||||||
type OutlineThickness,
|
|
||||||
VLC_COLORS,
|
|
||||||
type VLCColor,
|
|
||||||
} from "@/constants/SubtitleConstants";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
@@ -92,84 +86,6 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
];
|
];
|
||||||
}, [settings?.subtitleMode, t, updateSettings]);
|
}, [settings?.subtitleMode, t, updateSettings]);
|
||||||
|
|
||||||
const textColorOptionGroups = useMemo(() => {
|
|
||||||
const colors = Object.keys(VLC_COLORS) as VLCColor[];
|
|
||||||
const options = colors.map((color) => ({
|
|
||||||
type: "radio" as const,
|
|
||||||
label: t(`home.settings.subtitles.colors.${color}`),
|
|
||||||
value: color,
|
|
||||||
selected: (settings?.vlcTextColor || "White") === color,
|
|
||||||
onPress: () => updateSettings({ vlcTextColor: color }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return [{ options }];
|
|
||||||
}, [settings?.vlcTextColor, t, updateSettings]);
|
|
||||||
|
|
||||||
const backgroundColorOptionGroups = useMemo(() => {
|
|
||||||
const colors = Object.keys(VLC_COLORS) as VLCColor[];
|
|
||||||
const options = colors.map((color) => ({
|
|
||||||
type: "radio" as const,
|
|
||||||
label: t(`home.settings.subtitles.colors.${color}`),
|
|
||||||
value: color,
|
|
||||||
selected: (settings?.vlcBackgroundColor || "Black") === color,
|
|
||||||
onPress: () => updateSettings({ vlcBackgroundColor: color }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return [{ options }];
|
|
||||||
}, [settings?.vlcBackgroundColor, t, updateSettings]);
|
|
||||||
|
|
||||||
const outlineColorOptionGroups = useMemo(() => {
|
|
||||||
const colors = Object.keys(VLC_COLORS) as VLCColor[];
|
|
||||||
const options = colors.map((color) => ({
|
|
||||||
type: "radio" as const,
|
|
||||||
label: t(`home.settings.subtitles.colors.${color}`),
|
|
||||||
value: color,
|
|
||||||
selected: (settings?.vlcOutlineColor || "Black") === color,
|
|
||||||
onPress: () => updateSettings({ vlcOutlineColor: color }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return [{ options }];
|
|
||||||
}, [settings?.vlcOutlineColor, t, updateSettings]);
|
|
||||||
|
|
||||||
const outlineThicknessOptionGroups = useMemo(() => {
|
|
||||||
const thicknesses = Object.keys(OUTLINE_THICKNESS) as OutlineThickness[];
|
|
||||||
const options = thicknesses.map((thickness) => ({
|
|
||||||
type: "radio" as const,
|
|
||||||
label: t(`home.settings.subtitles.thickness.${thickness}`),
|
|
||||||
value: thickness,
|
|
||||||
selected: (settings?.vlcOutlineThickness || "Normal") === thickness,
|
|
||||||
onPress: () => updateSettings({ vlcOutlineThickness: thickness }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return [{ options }];
|
|
||||||
}, [settings?.vlcOutlineThickness, t, updateSettings]);
|
|
||||||
|
|
||||||
const backgroundOpacityOptionGroups = useMemo(() => {
|
|
||||||
const opacities = [0, 32, 64, 96, 128, 160, 192, 224, 255];
|
|
||||||
const options = opacities.map((opacity) => ({
|
|
||||||
type: "radio" as const,
|
|
||||||
label: `${Math.round((opacity / 255) * 100)}%`,
|
|
||||||
value: opacity,
|
|
||||||
selected: (settings?.vlcBackgroundOpacity ?? 128) === opacity,
|
|
||||||
onPress: () => updateSettings({ vlcBackgroundOpacity: opacity }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return [{ options }];
|
|
||||||
}, [settings?.vlcBackgroundOpacity, updateSettings]);
|
|
||||||
|
|
||||||
const outlineOpacityOptionGroups = useMemo(() => {
|
|
||||||
const opacities = [0, 32, 64, 96, 128, 160, 192, 224, 255];
|
|
||||||
const options = opacities.map((opacity) => ({
|
|
||||||
type: "radio" as const,
|
|
||||||
label: `${Math.round((opacity / 255) * 100)}%`,
|
|
||||||
value: opacity,
|
|
||||||
selected: (settings?.vlcOutlineOpacity ?? 255) === opacity,
|
|
||||||
onPress: () => updateSettings({ vlcOutlineOpacity: opacity }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return [{ options }];
|
|
||||||
}, [settings?.vlcOutlineOpacity, updateSettings]);
|
|
||||||
|
|
||||||
if (isTv) return null;
|
if (isTv) return null;
|
||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
@@ -252,124 +168,6 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
onUpdate={(subtitleSize) => updateSettings({ subtitleSize })}
|
onUpdate={(subtitleSize) => updateSettings({ subtitleSize })}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem title={t("home.settings.subtitles.text_color")}>
|
|
||||||
<PlatformDropdown
|
|
||||||
groups={textColorOptionGroups}
|
|
||||||
trigger={
|
|
||||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
|
||||||
{t(
|
|
||||||
`home.settings.subtitles.colors.${settings?.vlcTextColor || "White"}`,
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
title={t("home.settings.subtitles.text_color")}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem title={t("home.settings.subtitles.background_color")}>
|
|
||||||
<PlatformDropdown
|
|
||||||
groups={backgroundColorOptionGroups}
|
|
||||||
trigger={
|
|
||||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
|
||||||
{t(
|
|
||||||
`home.settings.subtitles.colors.${settings?.vlcBackgroundColor || "Black"}`,
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
title={t("home.settings.subtitles.background_color")}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem title={t("home.settings.subtitles.outline_color")}>
|
|
||||||
<PlatformDropdown
|
|
||||||
groups={outlineColorOptionGroups}
|
|
||||||
trigger={
|
|
||||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
|
||||||
{t(
|
|
||||||
`home.settings.subtitles.colors.${settings?.vlcOutlineColor || "Black"}`,
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
title={t("home.settings.subtitles.outline_color")}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem title={t("home.settings.subtitles.outline_thickness")}>
|
|
||||||
<PlatformDropdown
|
|
||||||
groups={outlineThicknessOptionGroups}
|
|
||||||
trigger={
|
|
||||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
|
||||||
{t(
|
|
||||||
`home.settings.subtitles.thickness.${settings?.vlcOutlineThickness || "Normal"}`,
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
title={t("home.settings.subtitles.outline_thickness")}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem title={t("home.settings.subtitles.background_opacity")}>
|
|
||||||
<PlatformDropdown
|
|
||||||
groups={backgroundOpacityOptionGroups}
|
|
||||||
trigger={
|
|
||||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>{`${Math.round(((settings?.vlcBackgroundOpacity ?? 128) / 255) * 100)}%`}</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
title={t("home.settings.subtitles.background_opacity")}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem title={t("home.settings.subtitles.outline_opacity")}>
|
|
||||||
<PlatformDropdown
|
|
||||||
groups={outlineOpacityOptionGroups}
|
|
||||||
trigger={
|
|
||||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>{`${Math.round(((settings?.vlcOutlineOpacity ?? 255) / 255) * 100)}%`}</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
title={t("home.settings.subtitles.outline_opacity")}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem title={t("home.settings.subtitles.bold_text")}>
|
|
||||||
<Switch
|
|
||||||
value={settings?.vlcIsBold ?? false}
|
|
||||||
onValueChange={(value) => updateSettings({ vlcIsBold: value })}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ interface BottomControlsProps {
|
|||||||
showRemoteBubble: boolean;
|
showRemoteBubble: boolean;
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
remainingTime: number;
|
remainingTime: number;
|
||||||
isVlc: boolean;
|
|
||||||
showSkipButton: boolean;
|
showSkipButton: boolean;
|
||||||
showSkipCreditButton: boolean;
|
showSkipCreditButton: boolean;
|
||||||
skipIntro: () => void;
|
skipIntro: () => void;
|
||||||
@@ -66,7 +65,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
showRemoteBubble,
|
showRemoteBubble,
|
||||||
currentTime,
|
currentTime,
|
||||||
remainingTime,
|
remainingTime,
|
||||||
isVlc,
|
|
||||||
showSkipButton,
|
showSkipButton,
|
||||||
showSkipCreditButton,
|
showSkipCreditButton,
|
||||||
skipIntro,
|
skipIntro,
|
||||||
@@ -145,13 +143,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
settings.autoPlayEpisodeCount <
|
settings.autoPlayEpisodeCount <
|
||||||
settings.maxAutoPlayEpisodeCount.value) && (
|
settings.maxAutoPlayEpisodeCount.value) && (
|
||||||
<NextEpisodeCountDownButton
|
<NextEpisodeCountDownButton
|
||||||
show={
|
show={!nextItem ? false : remainingTime < 10000}
|
||||||
!nextItem
|
|
||||||
? false
|
|
||||||
: isVlc
|
|
||||||
? remainingTime < 10000
|
|
||||||
: remainingTime < 10
|
|
||||||
}
|
|
||||||
onFinish={handleNextEpisodeAutoPlay}
|
onFinish={handleNextEpisodeAutoPlay}
|
||||||
onPress={handleNextEpisodeManual}
|
onPress={handleNextEpisodeManual}
|
||||||
/>
|
/>
|
||||||
@@ -208,7 +200,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
<TimeDisplay
|
<TimeDisplay
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
remainingTime={remainingTime}
|
remainingTime={remainingTime}
|
||||||
isVlc={isVlc}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { useLocalSearchParams, useRouter } from "expo-router";
|
|||||||
import {
|
import {
|
||||||
type Dispatch,
|
type Dispatch,
|
||||||
type FC,
|
type FC,
|
||||||
type MutableRefObject,
|
|
||||||
type SetStateAction,
|
type SetStateAction,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -28,7 +27,6 @@ import { useHaptic } from "@/hooks/useHaptic";
|
|||||||
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||||
import type { TrackInfo, VlcPlayerViewRef } from "@/modules/VlcPlayer.types";
|
|
||||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
@@ -36,7 +34,6 @@ import { ticksToMs } from "@/utils/time";
|
|||||||
import { BottomControls } from "./BottomControls";
|
import { BottomControls } from "./BottomControls";
|
||||||
import { CenterControls } from "./CenterControls";
|
import { CenterControls } from "./CenterControls";
|
||||||
import { CONTROLS_CONSTANTS } from "./constants";
|
import { CONTROLS_CONSTANTS } from "./constants";
|
||||||
import { ControlProvider } from "./contexts/ControlContext";
|
|
||||||
import { EpisodeList } from "./EpisodeList";
|
import { EpisodeList } from "./EpisodeList";
|
||||||
import { GestureOverlay } from "./GestureOverlay";
|
import { GestureOverlay } from "./GestureOverlay";
|
||||||
import { HeaderControls } from "./HeaderControls";
|
import { HeaderControls } from "./HeaderControls";
|
||||||
@@ -50,36 +47,27 @@ import { type AspectRatio } from "./VideoScalingModeSelector";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
videoRef: MutableRefObject<VlcPlayerViewRef | null>;
|
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
isSeeking: SharedValue<boolean>;
|
isSeeking: SharedValue<boolean>;
|
||||||
cacheProgress: SharedValue<number>;
|
cacheProgress: SharedValue<number>;
|
||||||
progress: SharedValue<number>;
|
progress: SharedValue<number>;
|
||||||
isBuffering: boolean;
|
isBuffering: boolean;
|
||||||
showControls: boolean;
|
showControls: boolean;
|
||||||
|
|
||||||
enableTrickplay?: boolean;
|
enableTrickplay?: boolean;
|
||||||
togglePlay: () => void;
|
togglePlay: () => void;
|
||||||
setShowControls: (shown: boolean) => void;
|
setShowControls: (shown: boolean) => void;
|
||||||
offline?: boolean;
|
offline?: boolean;
|
||||||
isVideoLoaded?: boolean;
|
|
||||||
mediaSource?: MediaSourceInfo | null;
|
mediaSource?: MediaSourceInfo | null;
|
||||||
seek: (ticks: number) => void;
|
seek: (ticks: number) => void;
|
||||||
startPictureInPicture?: () => Promise<void>;
|
startPictureInPicture?: () => Promise<void>;
|
||||||
play: () => void;
|
play: () => void;
|
||||||
pause: () => void;
|
pause: () => void;
|
||||||
getAudioTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
|
|
||||||
getSubtitleTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
|
|
||||||
setSubtitleURL?: (url: string, customName: string) => void;
|
|
||||||
setSubtitleTrack?: (index: number) => void;
|
|
||||||
setAudioTrack?: (index: number) => void;
|
|
||||||
setVideoAspectRatio?: (aspectRatio: string | null) => Promise<void>;
|
setVideoAspectRatio?: (aspectRatio: string | null) => Promise<void>;
|
||||||
setVideoScaleFactor?: (scaleFactor: number) => Promise<void>;
|
setVideoScaleFactor?: (scaleFactor: number) => Promise<void>;
|
||||||
aspectRatio?: AspectRatio;
|
aspectRatio?: AspectRatio;
|
||||||
scaleFactor?: ScaleFactor;
|
scaleFactor?: ScaleFactor;
|
||||||
setAspectRatio?: Dispatch<SetStateAction<AspectRatio>>;
|
setAspectRatio?: Dispatch<SetStateAction<AspectRatio>>;
|
||||||
setScaleFactor?: Dispatch<SetStateAction<ScaleFactor>>;
|
setScaleFactor?: Dispatch<SetStateAction<ScaleFactor>>;
|
||||||
isVlc?: boolean;
|
|
||||||
api?: Api | null;
|
api?: Api | null;
|
||||||
downloadedFiles?: DownloadedItem[];
|
downloadedFiles?: DownloadedItem[];
|
||||||
}
|
}
|
||||||
@@ -99,12 +87,6 @@ export const Controls: FC<Props> = ({
|
|||||||
showControls,
|
showControls,
|
||||||
setShowControls,
|
setShowControls,
|
||||||
mediaSource,
|
mediaSource,
|
||||||
isVideoLoaded,
|
|
||||||
getAudioTracks,
|
|
||||||
getSubtitleTracks,
|
|
||||||
setSubtitleURL,
|
|
||||||
setSubtitleTrack,
|
|
||||||
setAudioTrack,
|
|
||||||
setVideoAspectRatio,
|
setVideoAspectRatio,
|
||||||
setVideoScaleFactor,
|
setVideoScaleFactor,
|
||||||
aspectRatio = "default",
|
aspectRatio = "default",
|
||||||
@@ -112,7 +94,6 @@ export const Controls: FC<Props> = ({
|
|||||||
setAspectRatio,
|
setAspectRatio,
|
||||||
setScaleFactor,
|
setScaleFactor,
|
||||||
offline = false,
|
offline = false,
|
||||||
isVlc = false,
|
|
||||||
api = null,
|
api = null,
|
||||||
downloadedFiles = undefined,
|
downloadedFiles = undefined,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -137,7 +118,7 @@ export const Controls: FC<Props> = ({
|
|||||||
} = useTrickplay(item);
|
} = useTrickplay(item);
|
||||||
|
|
||||||
const min = useSharedValue(0);
|
const min = useSharedValue(0);
|
||||||
const max = useSharedValue(item.RunTimeTicks || 0);
|
const max = useSharedValue(ticksToMs(item.RunTimeTicks || 0));
|
||||||
|
|
||||||
// Animation values for controls
|
// Animation values for controls
|
||||||
const controlsOpacity = useSharedValue(showControls ? 1 : 0);
|
const controlsOpacity = useSharedValue(showControls ? 1 : 0);
|
||||||
@@ -194,17 +175,13 @@ export const Controls: FC<Props> = ({
|
|||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Initialize progress values
|
// Initialize progress values - MPV uses milliseconds
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (item) {
|
if (item) {
|
||||||
progress.value = isVlc
|
progress.value = ticksToMs(item?.UserData?.PlaybackPositionTicks);
|
||||||
? ticksToMs(item?.UserData?.PlaybackPositionTicks)
|
max.value = ticksToMs(item.RunTimeTicks || 0);
|
||||||
: item?.UserData?.PlaybackPositionTicks || 0;
|
|
||||||
max.value = isVlc
|
|
||||||
? ticksToMs(item.RunTimeTicks || 0)
|
|
||||||
: item.RunTimeTicks || 0;
|
|
||||||
}
|
}
|
||||||
}, [item, isVlc, progress, max]);
|
}, [item, progress, max]);
|
||||||
|
|
||||||
// Navigation hooks
|
// Navigation hooks
|
||||||
const {
|
const {
|
||||||
@@ -215,7 +192,6 @@ export const Controls: FC<Props> = ({
|
|||||||
} = useVideoNavigation({
|
} = useVideoNavigation({
|
||||||
progress,
|
progress,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
isVlc,
|
|
||||||
seek,
|
seek,
|
||||||
play,
|
play,
|
||||||
});
|
});
|
||||||
@@ -225,7 +201,6 @@ export const Controls: FC<Props> = ({
|
|||||||
progress,
|
progress,
|
||||||
max,
|
max,
|
||||||
isSeeking,
|
isSeeking,
|
||||||
isVlc,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleControls = useCallback(() => {
|
const toggleControls = useCallback(() => {
|
||||||
@@ -248,7 +223,6 @@ export const Controls: FC<Props> = ({
|
|||||||
progress,
|
progress,
|
||||||
min,
|
min,
|
||||||
max,
|
max,
|
||||||
isVlc,
|
|
||||||
showControls,
|
showControls,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
seek,
|
seek,
|
||||||
@@ -273,7 +247,6 @@ export const Controls: FC<Props> = ({
|
|||||||
progress,
|
progress,
|
||||||
isSeeking,
|
isSeeking,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
isVlc,
|
|
||||||
seek,
|
seek,
|
||||||
play,
|
play,
|
||||||
pause,
|
pause,
|
||||||
@@ -302,9 +275,8 @@ export const Controls: FC<Props> = ({
|
|||||||
: current.actual;
|
: current.actual;
|
||||||
} else {
|
} else {
|
||||||
// When not scrubbing, only update if progress changed significantly (1 second)
|
// When not scrubbing, only update if progress changed significantly (1 second)
|
||||||
const progressUnit = isVlc
|
// MPV uses milliseconds
|
||||||
? CONTROLS_CONSTANTS.PROGRESS_UNIT_MS
|
const progressUnit = CONTROLS_CONSTANTS.PROGRESS_UNIT_MS;
|
||||||
: CONTROLS_CONSTANTS.PROGRESS_UNIT_TICKS;
|
|
||||||
const progressDiff = Math.abs(current.actual - effectiveProgress.value);
|
const progressDiff = Math.abs(current.actual - effectiveProgress.value);
|
||||||
if (progressDiff >= progressUnit) {
|
if (progressDiff >= progressUnit) {
|
||||||
effectiveProgress.value = current.actual;
|
effectiveProgress.value = current.actual;
|
||||||
@@ -325,7 +297,6 @@ export const Controls: FC<Props> = ({
|
|||||||
currentTime,
|
currentTime,
|
||||||
seek,
|
seek,
|
||||||
play,
|
play,
|
||||||
isVlc,
|
|
||||||
offline,
|
offline,
|
||||||
api,
|
api,
|
||||||
downloadedFiles,
|
downloadedFiles,
|
||||||
@@ -336,7 +307,6 @@ export const Controls: FC<Props> = ({
|
|||||||
currentTime,
|
currentTime,
|
||||||
seek,
|
seek,
|
||||||
play,
|
play,
|
||||||
isVlc,
|
|
||||||
offline,
|
offline,
|
||||||
api,
|
api,
|
||||||
downloadedFiles,
|
downloadedFiles,
|
||||||
@@ -359,12 +329,10 @@ export const Controls: FC<Props> = ({
|
|||||||
mediaSource: newMediaSource,
|
mediaSource: newMediaSource,
|
||||||
audioIndex: defaultAudioIndex,
|
audioIndex: defaultAudioIndex,
|
||||||
subtitleIndex: defaultSubtitleIndex,
|
subtitleIndex: defaultSubtitleIndex,
|
||||||
} = getDefaultPlaySettings(
|
} = getDefaultPlaySettings(item, settings, {
|
||||||
item,
|
indexes: previousIndexes,
|
||||||
settings,
|
source: mediaSource ?? undefined,
|
||||||
previousIndexes,
|
});
|
||||||
mediaSource ?? undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
...(offline && { offline: "true" }),
|
...(offline && { offline: "true" }),
|
||||||
@@ -479,11 +447,7 @@ export const Controls: FC<Props> = ({
|
|||||||
}, [isPlaying, togglePlay]);
|
}, [isPlaying, togglePlay]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ControlProvider
|
<>
|
||||||
item={item}
|
|
||||||
mediaSource={mediaSource}
|
|
||||||
isVideoLoaded={isVideoLoaded}
|
|
||||||
>
|
|
||||||
{episodeView ? (
|
{episodeView ? (
|
||||||
<EpisodeList
|
<EpisodeList
|
||||||
item={item}
|
item={item}
|
||||||
@@ -515,11 +479,6 @@ export const Controls: FC<Props> = ({
|
|||||||
goToNextItem={goToNextItem}
|
goToNextItem={goToNextItem}
|
||||||
previousItem={previousItem}
|
previousItem={previousItem}
|
||||||
nextItem={nextItem}
|
nextItem={nextItem}
|
||||||
getAudioTracks={getAudioTracks}
|
|
||||||
getSubtitleTracks={getSubtitleTracks}
|
|
||||||
setAudioTrack={setAudioTrack}
|
|
||||||
setSubtitleTrack={setSubtitleTrack}
|
|
||||||
setSubtitleURL={setSubtitleURL}
|
|
||||||
aspectRatio={aspectRatio}
|
aspectRatio={aspectRatio}
|
||||||
scaleFactor={scaleFactor}
|
scaleFactor={scaleFactor}
|
||||||
setAspectRatio={setAspectRatio}
|
setAspectRatio={setAspectRatio}
|
||||||
@@ -554,7 +513,6 @@ export const Controls: FC<Props> = ({
|
|||||||
showRemoteBubble={showRemoteBubble}
|
showRemoteBubble={showRemoteBubble}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
remainingTime={remainingTime}
|
remainingTime={remainingTime}
|
||||||
isVlc={isVlc}
|
|
||||||
showSkipButton={showSkipButton}
|
showSkipButton={showSkipButton}
|
||||||
showSkipCreditButton={showSkipCreditButton}
|
showSkipCreditButton={showSkipCreditButton}
|
||||||
skipIntro={skipIntro}
|
skipIntro={skipIntro}
|
||||||
@@ -582,6 +540,6 @@ export const Controls: FC<Props> = ({
|
|||||||
{settings.maxAutoPlayEpisodeCount.value !== -1 && (
|
{settings.maxAutoPlayEpisodeCount.value !== -1 && (
|
||||||
<ContinueWatchingOverlay goToNextItem={handleContinueWatching} />
|
<ContinueWatchingOverlay goToNextItem={handleContinueWatching} />
|
||||||
)}
|
)}
|
||||||
</ControlProvider>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,9 +13,8 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useSettings, VideoPlayer } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { ICON_SIZES } from "./constants";
|
import { ICON_SIZES } from "./constants";
|
||||||
import { VideoProvider } from "./contexts/VideoContext";
|
|
||||||
import DropdownView from "./dropdown/DropdownView";
|
import DropdownView from "./dropdown/DropdownView";
|
||||||
import { type ScaleFactor, ScaleFactorSelector } from "./ScaleFactorSelector";
|
import { type ScaleFactor, ScaleFactorSelector } from "./ScaleFactorSelector";
|
||||||
import {
|
import {
|
||||||
@@ -34,11 +33,6 @@ interface HeaderControlsProps {
|
|||||||
goToNextItem: (options: { isAutoPlay?: boolean }) => void;
|
goToNextItem: (options: { isAutoPlay?: boolean }) => void;
|
||||||
previousItem?: BaseItemDto | null;
|
previousItem?: BaseItemDto | null;
|
||||||
nextItem?: BaseItemDto | null;
|
nextItem?: BaseItemDto | null;
|
||||||
getAudioTracks?: (() => Promise<any[] | null>) | (() => any[]);
|
|
||||||
getSubtitleTracks?: (() => Promise<any[] | null>) | (() => any[]);
|
|
||||||
setAudioTrack?: (index: number) => void;
|
|
||||||
setSubtitleTrack?: (index: number) => void;
|
|
||||||
setSubtitleURL?: (url: string, customName: string) => void;
|
|
||||||
aspectRatio?: AspectRatio;
|
aspectRatio?: AspectRatio;
|
||||||
scaleFactor?: ScaleFactor;
|
scaleFactor?: ScaleFactor;
|
||||||
setAspectRatio?: Dispatch<SetStateAction<AspectRatio>>;
|
setAspectRatio?: Dispatch<SetStateAction<AspectRatio>>;
|
||||||
@@ -58,11 +52,6 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
|||||||
goToNextItem,
|
goToNextItem,
|
||||||
previousItem,
|
previousItem,
|
||||||
nextItem,
|
nextItem,
|
||||||
getAudioTracks,
|
|
||||||
getSubtitleTracks,
|
|
||||||
setAudioTrack,
|
|
||||||
setSubtitleTrack,
|
|
||||||
setSubtitleURL,
|
|
||||||
aspectRatio = "default",
|
aspectRatio = "default",
|
||||||
scaleFactor = 1.0,
|
scaleFactor = 1.0,
|
||||||
setAspectRatio,
|
setAspectRatio,
|
||||||
@@ -113,35 +102,25 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
|||||||
>
|
>
|
||||||
<View className='mr-auto' pointerEvents='box-none'>
|
<View className='mr-auto' pointerEvents='box-none'>
|
||||||
{!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && (
|
{!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && (
|
||||||
<VideoProvider
|
<View pointerEvents='auto'>
|
||||||
getAudioTracks={getAudioTracks}
|
<DropdownView />
|
||||||
getSubtitleTracks={getSubtitleTracks}
|
</View>
|
||||||
setAudioTrack={setAudioTrack}
|
|
||||||
setSubtitleTrack={setSubtitleTrack}
|
|
||||||
setSubtitleURL={setSubtitleURL}
|
|
||||||
>
|
|
||||||
<View pointerEvents='auto'>
|
|
||||||
<DropdownView />
|
|
||||||
</View>
|
|
||||||
</VideoProvider>
|
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className='flex flex-row items-center space-x-2'>
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
{!Platform.isTV &&
|
{!Platform.isTV && startPictureInPicture && (
|
||||||
(settings.defaultPlayer === VideoPlayer.VLC_4 ||
|
<TouchableOpacity
|
||||||
Platform.OS === "android") && (
|
onPress={startPictureInPicture}
|
||||||
<TouchableOpacity
|
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||||
onPress={startPictureInPicture}
|
>
|
||||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
<MaterialIcons
|
||||||
>
|
name='picture-in-picture'
|
||||||
<MaterialIcons
|
size={ICON_SIZES.HEADER}
|
||||||
name='picture-in-picture'
|
color='white'
|
||||||
size={ICON_SIZES.HEADER}
|
/>
|
||||||
color='white'
|
</TouchableOpacity>
|
||||||
/>
|
)}
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
{item?.Type === "Episode" && (
|
{item?.Type === "Episode" && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={switchOnEpisodeMode}
|
onPress={switchOnEpisodeMode}
|
||||||
|
|||||||
@@ -13,12 +13,6 @@ const SkipButton: React.FC<SkipButtonProps> = ({
|
|||||||
buttonText,
|
buttonText,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
console.log(`[SKIP_BUTTON] Render:`, {
|
|
||||||
buttonText,
|
|
||||||
showButton,
|
|
||||||
className: showButton ? "flex" : "hidden",
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className={showButton ? "flex" : "hidden"} {...props}>
|
<View className={showButton ? "flex" : "hidden"} {...props}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
|||||||
@@ -6,18 +6,20 @@ import { formatTimeString } from "@/utils/time";
|
|||||||
interface TimeDisplayProps {
|
interface TimeDisplayProps {
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
remainingTime: number;
|
remainingTime: number;
|
||||||
isVlc: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays current time and remaining time.
|
||||||
|
* MPV player uses milliseconds for time values.
|
||||||
|
*/
|
||||||
export const TimeDisplay: FC<TimeDisplayProps> = ({
|
export const TimeDisplay: FC<TimeDisplayProps> = ({
|
||||||
currentTime,
|
currentTime,
|
||||||
remainingTime,
|
remainingTime,
|
||||||
isVlc,
|
|
||||||
}) => {
|
}) => {
|
||||||
const getFinishTime = () => {
|
const getFinishTime = () => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const remainingMs = isVlc ? remainingTime : remainingTime * 1000;
|
// remainingTime is in ms
|
||||||
const finishTime = new Date(now.getTime() + remainingMs);
|
const finishTime = new Date(now.getTime() + remainingTime);
|
||||||
return finishTime.toLocaleTimeString([], {
|
return finishTime.toLocaleTimeString([], {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
@@ -28,11 +30,11 @@ export const TimeDisplay: FC<TimeDisplayProps> = ({
|
|||||||
return (
|
return (
|
||||||
<View className='flex flex-row items-center justify-between mt-2'>
|
<View className='flex flex-row items-center justify-between mt-2'>
|
||||||
<Text className='text-[12px] text-neutral-400'>
|
<Text className='text-[12px] text-neutral-400'>
|
||||||
{formatTimeString(currentTime, isVlc ? "ms" : "s")}
|
{formatTimeString(currentTime, "ms")}
|
||||||
</Text>
|
</Text>
|
||||||
<View className='flex flex-col items-end'>
|
<View className='flex flex-col items-end'>
|
||||||
<Text className='text-[12px] text-neutral-400'>
|
<Text className='text-[12px] text-neutral-400'>
|
||||||
-{formatTimeString(remainingTime, isVlc ? "ms" : "s")}
|
-{formatTimeString(remainingTime, "ms")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className='text-[10px] text-neutral-500 opacity-70'>
|
<Text className='text-[10px] text-neutral-500 opacity-70'>
|
||||||
ends at {getFinishTime()}
|
ends at {getFinishTime()}
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
import type {
|
|
||||||
BaseItemDto,
|
|
||||||
MediaSourceInfo,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import type React from "react";
|
|
||||||
import { createContext, type ReactNode, useContext } from "react";
|
|
||||||
|
|
||||||
interface ControlContextProps {
|
|
||||||
item: BaseItemDto;
|
|
||||||
mediaSource: MediaSourceInfo | null | undefined;
|
|
||||||
isVideoLoaded: boolean | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ControlContext = createContext<ControlContextProps | undefined>(
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
interface ControlProviderProps {
|
|
||||||
children: ReactNode;
|
|
||||||
item: BaseItemDto;
|
|
||||||
mediaSource: MediaSourceInfo | null | undefined;
|
|
||||||
isVideoLoaded: boolean | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ControlProvider: React.FC<ControlProviderProps> = ({
|
|
||||||
children,
|
|
||||||
item,
|
|
||||||
mediaSource,
|
|
||||||
isVideoLoaded,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<ControlContext.Provider value={{ item, mediaSource, isVideoLoaded }}>
|
|
||||||
{children}
|
|
||||||
</ControlContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useControlContext = () => {
|
|
||||||
const context = useContext(ControlContext);
|
|
||||||
if (context === undefined) {
|
|
||||||
throw new Error("useControlContext must be used within a ControlProvider");
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
107
components/video-player/controls/contexts/PlayerContext.tsx
Normal file
107
components/video-player/controls/contexts/PlayerContext.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import type {
|
||||||
|
BaseItemDto,
|
||||||
|
MediaSourceInfo,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
type MutableRefObject,
|
||||||
|
type ReactNode,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
} from "react";
|
||||||
|
import type { MpvPlayerViewRef } from "@/modules";
|
||||||
|
|
||||||
|
interface PlayerContextProps {
|
||||||
|
playerRef: MutableRefObject<MpvPlayerViewRef | null>;
|
||||||
|
item: BaseItemDto;
|
||||||
|
mediaSource: MediaSourceInfo | null | undefined;
|
||||||
|
isVideoLoaded: boolean;
|
||||||
|
tracksReady: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlayerContext = createContext<PlayerContextProps | undefined>(undefined);
|
||||||
|
|
||||||
|
interface PlayerProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
playerRef: MutableRefObject<MpvPlayerViewRef | null>;
|
||||||
|
item: BaseItemDto;
|
||||||
|
mediaSource: MediaSourceInfo | null | undefined;
|
||||||
|
isVideoLoaded: boolean;
|
||||||
|
tracksReady: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PlayerProvider: React.FC<PlayerProviderProps> = ({
|
||||||
|
children,
|
||||||
|
playerRef,
|
||||||
|
item,
|
||||||
|
mediaSource,
|
||||||
|
isVideoLoaded,
|
||||||
|
tracksReady,
|
||||||
|
}) => {
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({ playerRef, item, mediaSource, isVideoLoaded, tracksReady }),
|
||||||
|
[playerRef, item, mediaSource, isVideoLoaded, tracksReady],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PlayerContext.Provider value={value}>{children}</PlayerContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Core context hook
|
||||||
|
export const usePlayerContext = () => {
|
||||||
|
const context = useContext(PlayerContext);
|
||||||
|
if (!context)
|
||||||
|
throw new Error("usePlayerContext must be used within PlayerProvider");
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Player controls hook
|
||||||
|
export const usePlayerControls = () => {
|
||||||
|
const { playerRef } = usePlayerContext();
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Subtitle controls
|
||||||
|
getSubtitleTracks: async () => {
|
||||||
|
return playerRef.current?.getSubtitleTracks() ?? null;
|
||||||
|
},
|
||||||
|
setSubtitleTrack: (trackId: number) => {
|
||||||
|
playerRef.current?.setSubtitleTrack(trackId);
|
||||||
|
},
|
||||||
|
disableSubtitles: () => {
|
||||||
|
playerRef.current?.disableSubtitles();
|
||||||
|
},
|
||||||
|
addSubtitleFile: (url: string, select = true) => {
|
||||||
|
playerRef.current?.addSubtitleFile(url, select);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Audio controls
|
||||||
|
getAudioTracks: async () => {
|
||||||
|
return playerRef.current?.getAudioTracks() ?? null;
|
||||||
|
},
|
||||||
|
setAudioTrack: (trackId: number) => {
|
||||||
|
playerRef.current?.setAudioTrack(trackId);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Playback controls
|
||||||
|
play: () => playerRef.current?.play(),
|
||||||
|
pause: () => playerRef.current?.pause(),
|
||||||
|
seekTo: (position: number) => playerRef.current?.seekTo(position),
|
||||||
|
seekBy: (offset: number) => playerRef.current?.seekBy(offset),
|
||||||
|
setSpeed: (speed: number) => playerRef.current?.setSpeed(speed),
|
||||||
|
|
||||||
|
// Subtitle positioning
|
||||||
|
setSubtitleScale: (scale: number) =>
|
||||||
|
playerRef.current?.setSubtitleScale(scale),
|
||||||
|
setSubtitlePosition: (position: number) =>
|
||||||
|
playerRef.current?.setSubtitlePosition(position),
|
||||||
|
setSubtitleMarginY: (margin: number) =>
|
||||||
|
playerRef.current?.setSubtitleMarginY(margin),
|
||||||
|
setSubtitleFontSize: (size: number) =>
|
||||||
|
playerRef.current?.setSubtitleFontSize(size),
|
||||||
|
|
||||||
|
// PiP
|
||||||
|
startPictureInPicture: () => playerRef.current?.startPictureInPicture(),
|
||||||
|
stopPictureInPicture: () => playerRef.current?.stopPictureInPicture(),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,4 +1,69 @@
|
|||||||
import { SubtitleDeliveryMethod } from "@jellyfin/sdk/lib/generated-client";
|
/**
|
||||||
|
* VideoContext.tsx
|
||||||
|
*
|
||||||
|
* Manages subtitle and audio track state for the video player UI.
|
||||||
|
*
|
||||||
|
* ============================================================================
|
||||||
|
* INDEX TYPES
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* We track two different indices for each track:
|
||||||
|
*
|
||||||
|
* 1. SERVER INDEX (sub.Index / track.index)
|
||||||
|
* - Jellyfin's server-side stream index
|
||||||
|
* - Used to report playback state to Jellyfin server
|
||||||
|
* - Allows Jellyfin to remember user's last selected tracks
|
||||||
|
* - Passed via router params (subtitleIndex, audioIndex)
|
||||||
|
* - Value of -1 means disabled/none
|
||||||
|
*
|
||||||
|
* 2. MPV INDEX (track.mpvIndex)
|
||||||
|
* - MPV's internal track ID for the loaded track
|
||||||
|
* - Used to actually switch tracks in the player
|
||||||
|
* - Only assigned to tracks that are loaded into MPV
|
||||||
|
* - Value of -1 means track is not in MPV (e.g., burned-in image sub)
|
||||||
|
*
|
||||||
|
* ============================================================================
|
||||||
|
* SUBTITLE DELIVERY METHODS
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* Jellyfin provides subtitles via different delivery methods:
|
||||||
|
* - Embed: Subtitle is embedded in the container (MKV, MP4, etc.)
|
||||||
|
* - Hls: Subtitle is delivered via HLS segments (during transcoding)
|
||||||
|
* - External: Subtitle is delivered as a separate file URL
|
||||||
|
* - Encode: Subtitle is burned into the video (image-based subs during transcode)
|
||||||
|
*
|
||||||
|
* Jellyfin also provides `IsTextSubtitleStream` boolean:
|
||||||
|
* - true: Text-based subtitle (SRT, ASS, VTT, etc.)
|
||||||
|
* - false: Image-based subtitle (PGS, VOBSUB, DVDSUB, etc.)
|
||||||
|
*
|
||||||
|
* ============================================================================
|
||||||
|
* SUBTITLE TYPES AND HOW THEY'RE HANDLED
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* 1. TEXT-BASED SUBTITLES (IsTextSubtitleStream = true)
|
||||||
|
* - Direct Play: Loaded into MPV (embedded or via sub-add for external)
|
||||||
|
* - Transcoding: Delivered via HLS, loaded into MPV
|
||||||
|
* - Action: Use playerControls.setSubtitleTrack(mpvId)
|
||||||
|
*
|
||||||
|
* 2. IMAGE-BASED SUBTITLES (IsTextSubtitleStream = false)
|
||||||
|
* - Direct Play: Embedded ones are in MPV, external ones are filtered out
|
||||||
|
* - Transcoding: BURNED INTO VIDEO by Jellyfin (not in MPV track list)
|
||||||
|
* - Action: When transcoding, use replacePlayer() to request burn-in
|
||||||
|
*
|
||||||
|
* ============================================================================
|
||||||
|
* MPV INDEX CALCULATION
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* We iterate through Jellyfin's subtitle list and assign MPV indices only to
|
||||||
|
* subtitles that are actually loaded into MPV:
|
||||||
|
*
|
||||||
|
* - isSubtitleInMpv = true: Subtitle is in MPV's track list, increment index
|
||||||
|
* - isSubtitleInMpv = false: Subtitle is NOT in MPV (e.g., image sub during
|
||||||
|
* transcode), do NOT increment index
|
||||||
|
*
|
||||||
|
* The order of subtitles in Jellyfin's MediaStreams matches the order in MPV.
|
||||||
|
*/
|
||||||
|
|
||||||
import { router, useLocalSearchParams } from "expo-router";
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import {
|
import {
|
||||||
@@ -9,52 +74,29 @@ import {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import type { TrackInfo } from "@/modules/VlcPlayer.types";
|
import type { AudioTrack, SubtitleTrack } from "@/modules";
|
||||||
|
import {
|
||||||
|
isImageBasedSubtitle,
|
||||||
|
isSubtitleInMpv,
|
||||||
|
} from "@/utils/jellyfin/subtitleUtils";
|
||||||
import type { Track } from "../types";
|
import type { Track } from "../types";
|
||||||
import { useControlContext } from "./ControlContext";
|
import { usePlayerContext, usePlayerControls } from "./PlayerContext";
|
||||||
|
|
||||||
interface VideoContextProps {
|
interface VideoContextProps {
|
||||||
audioTracks: Track[] | null;
|
|
||||||
subtitleTracks: Track[] | null;
|
subtitleTracks: Track[] | null;
|
||||||
setAudioTrack: ((index: number) => void) | undefined;
|
audioTracks: Track[] | null;
|
||||||
setSubtitleTrack: ((index: number) => void) | undefined;
|
|
||||||
setSubtitleURL: ((url: string, customName: string) => void) | undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const VideoContext = createContext<VideoContextProps | undefined>(undefined);
|
const VideoContext = createContext<VideoContextProps | undefined>(undefined);
|
||||||
|
|
||||||
interface VideoProviderProps {
|
export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
children: ReactNode;
|
|
||||||
getAudioTracks:
|
|
||||||
| (() => Promise<TrackInfo[] | null>)
|
|
||||||
| (() => TrackInfo[])
|
|
||||||
| undefined;
|
|
||||||
getSubtitleTracks:
|
|
||||||
| (() => Promise<TrackInfo[] | null>)
|
|
||||||
| (() => TrackInfo[])
|
|
||||||
| undefined;
|
|
||||||
setAudioTrack: ((index: number) => void) | undefined;
|
|
||||||
setSubtitleTrack: ((index: number) => void) | undefined;
|
|
||||||
setSubtitleURL: ((url: string, customName: string) => void) | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const VideoProvider: React.FC<VideoProviderProps> = ({
|
|
||||||
children,
|
children,
|
||||||
getSubtitleTracks,
|
|
||||||
getAudioTracks,
|
|
||||||
setSubtitleTrack,
|
|
||||||
setSubtitleURL,
|
|
||||||
setAudioTrack,
|
|
||||||
}) => {
|
}) => {
|
||||||
const [audioTracks, setAudioTracks] = useState<Track[] | null>(null);
|
|
||||||
const [subtitleTracks, setSubtitleTracks] = useState<Track[] | null>(null);
|
const [subtitleTracks, setSubtitleTracks] = useState<Track[] | null>(null);
|
||||||
|
const [audioTracks, setAudioTracks] = useState<Track[] | null>(null);
|
||||||
|
|
||||||
const ControlContext = useControlContext();
|
const { tracksReady, mediaSource } = usePlayerContext();
|
||||||
const isVideoLoaded = ControlContext?.isVideoLoaded;
|
const playerControls = usePlayerControls();
|
||||||
const mediaSource = ControlContext?.mediaSource;
|
|
||||||
|
|
||||||
const allSubs =
|
|
||||||
mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") || [];
|
|
||||||
|
|
||||||
const { itemId, audioIndex, bitrateValue, subtitleIndex, playbackPosition } =
|
const { itemId, audioIndex, bitrateValue, subtitleIndex, playbackPosition } =
|
||||||
useLocalSearchParams<{
|
useLocalSearchParams<{
|
||||||
@@ -66,173 +108,143 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
|||||||
playbackPosition: string;
|
playbackPosition: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const onTextBasedSubtitle = useMemo(() => {
|
const allSubs =
|
||||||
return (
|
mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") || [];
|
||||||
allSubs.find(
|
const allAudio =
|
||||||
(s) =>
|
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];
|
||||||
s.Index?.toString() === subtitleIndex &&
|
|
||||||
(s.DeliveryMethod === SubtitleDeliveryMethod.Embed ||
|
const isTranscoding = Boolean(mediaSource?.TranscodingUrl);
|
||||||
s.DeliveryMethod === SubtitleDeliveryMethod.Hls ||
|
|
||||||
s.DeliveryMethod === SubtitleDeliveryMethod.External),
|
/**
|
||||||
) || subtitleIndex === "-1"
|
* Check if the currently selected subtitle is image-based.
|
||||||
|
* Used to determine if we need to refresh the player when changing subs.
|
||||||
|
*/
|
||||||
|
const isCurrentSubImageBased = useMemo(() => {
|
||||||
|
if (subtitleIndex === "-1") return false;
|
||||||
|
const currentSub = allSubs.find(
|
||||||
|
(s) => s.Index?.toString() === subtitleIndex,
|
||||||
);
|
);
|
||||||
|
return currentSub ? isImageBasedSubtitle(currentSub) : false;
|
||||||
}, [allSubs, subtitleIndex]);
|
}, [allSubs, subtitleIndex]);
|
||||||
|
|
||||||
const setPlayerParams = ({
|
/**
|
||||||
chosenAudioIndex = audioIndex,
|
* Refresh the player with new parameters.
|
||||||
chosenSubtitleIndex = subtitleIndex,
|
* This triggers Jellyfin to re-process the stream (e.g., burn in image subs).
|
||||||
}: {
|
*/
|
||||||
chosenAudioIndex?: string;
|
const replacePlayer = (params: {
|
||||||
chosenSubtitleIndex?: string;
|
audioIndex?: string;
|
||||||
|
subtitleIndex?: string;
|
||||||
}) => {
|
}) => {
|
||||||
console.log("chosenSubtitleIndex", chosenSubtitleIndex);
|
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
itemId: itemId ?? "",
|
itemId: itemId ?? "",
|
||||||
audioIndex: chosenAudioIndex,
|
audioIndex: params.audioIndex ?? audioIndex,
|
||||||
subtitleIndex: chosenSubtitleIndex,
|
subtitleIndex: params.subtitleIndex ?? subtitleIndex,
|
||||||
mediaSourceId: mediaSource?.Id ?? "",
|
mediaSourceId: mediaSource?.Id ?? "",
|
||||||
bitrateValue: bitrateValue,
|
bitrateValue: bitrateValue,
|
||||||
playbackPosition: playbackPosition,
|
playbackPosition: playbackPosition,
|
||||||
}).toString();
|
}).toString();
|
||||||
|
|
||||||
router.replace(`player/direct-player?${queryParams}` as any);
|
router.replace(`player/direct-player?${queryParams}` as any);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setTrackParams = (
|
// Fetch tracks when ready
|
||||||
type: "audio" | "subtitle",
|
|
||||||
index: number,
|
|
||||||
serverIndex: number,
|
|
||||||
) => {
|
|
||||||
const setTrack = type === "audio" ? setAudioTrack : setSubtitleTrack;
|
|
||||||
const paramKey = type === "audio" ? "audioIndex" : "subtitleIndex";
|
|
||||||
|
|
||||||
// If we're transcoding and we're going from a image based subtitle
|
|
||||||
// to a text based subtitle, we need to change the player params.
|
|
||||||
|
|
||||||
const shouldChangePlayerParams =
|
|
||||||
type === "subtitle" &&
|
|
||||||
mediaSource?.TranscodingUrl &&
|
|
||||||
!onTextBasedSubtitle;
|
|
||||||
|
|
||||||
console.log("Set player params", index, serverIndex);
|
|
||||||
if (shouldChangePlayerParams) {
|
|
||||||
setPlayerParams({
|
|
||||||
chosenSubtitleIndex: serverIndex.toString(),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setTrack?.(serverIndex);
|
|
||||||
router.setParams({
|
|
||||||
[paramKey]: serverIndex.toString(),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!tracksReady) return;
|
||||||
|
|
||||||
const fetchTracks = async () => {
|
const fetchTracks = async () => {
|
||||||
if (getSubtitleTracks) {
|
const [subtitleData, audioData] = await Promise.all([
|
||||||
let subtitleData = await getSubtitleTracks();
|
playerControls.getSubtitleTracks().catch(() => null),
|
||||||
// Only FOR VLC 3, If we're transcoding, we need to reverse the subtitle data, because VLC reverses the HLS subtitles.
|
playerControls.getAudioTracks().catch(() => null),
|
||||||
if (
|
]);
|
||||||
mediaSource?.TranscodingUrl &&
|
|
||||||
subtitleData &&
|
|
||||||
subtitleData.length > 1
|
|
||||||
) {
|
|
||||||
subtitleData = [subtitleData[0], ...subtitleData.slice(1).reverse()];
|
|
||||||
}
|
|
||||||
|
|
||||||
let embedSubIndex = 1;
|
// Process subtitles - map Jellyfin indices to MPV track IDs
|
||||||
const processedSubs: Track[] = allSubs?.map((sub) => {
|
let mpvIndex = 0; // MPV track index counter (only incremented for subs in MPV)
|
||||||
/** A boolean value determining if we should increment the embedSubIndex, currently only Embed and Hls subtitles are automatically added into VLC Player */
|
|
||||||
const shouldIncrement =
|
|
||||||
sub.DeliveryMethod === SubtitleDeliveryMethod.Embed ||
|
|
||||||
sub.DeliveryMethod === SubtitleDeliveryMethod.Hls ||
|
|
||||||
sub.DeliveryMethod === SubtitleDeliveryMethod.External;
|
|
||||||
/** The index of subtitle inside VLC Player Itself */
|
|
||||||
const vlcIndex = subtitleData?.at(embedSubIndex)?.index ?? -1;
|
|
||||||
if (shouldIncrement) embedSubIndex++;
|
|
||||||
return {
|
|
||||||
name: sub.DisplayTitle || "Undefined Subtitle",
|
|
||||||
index: sub.Index ?? -1,
|
|
||||||
setTrack: () =>
|
|
||||||
shouldIncrement
|
|
||||||
? setTrackParams("subtitle", vlcIndex, sub.Index ?? -1)
|
|
||||||
: setPlayerParams({
|
|
||||||
chosenSubtitleIndex: sub.Index?.toString(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Step 3: Restore the original order
|
const subs: Track[] = allSubs.map((sub) => {
|
||||||
const subtitles: Track[] = processedSubs.sort(
|
const inMpv = isSubtitleInMpv(sub, isTranscoding);
|
||||||
(a, b) => a.index - b.index,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add a "Disable Subtitles" option
|
// Get MPV track ID: only if this sub is actually in MPV's track list
|
||||||
subtitles.unshift({
|
const mpvId = inMpv
|
||||||
name: "Disable",
|
? ((subtitleData as SubtitleTrack[])?.[mpvIndex++]?.id ?? -1)
|
||||||
index: -1,
|
: -1;
|
||||||
setTrack: () =>
|
|
||||||
!mediaSource?.TranscodingUrl || onTextBasedSubtitle
|
return {
|
||||||
? setTrackParams("subtitle", -1, -1)
|
name: sub.DisplayTitle || "Unknown",
|
||||||
: setPlayerParams({ chosenSubtitleIndex: "-1" }),
|
index: sub.Index ?? -1, // Jellyfin server-side index
|
||||||
});
|
mpvIndex: mpvId, // MPV track ID (-1 if not in MPV)
|
||||||
setSubtitleTracks(subtitles);
|
setTrack: () => {
|
||||||
}
|
// Case 1: Transcoding + switching to/from image-based sub
|
||||||
if (getAudioTracks) {
|
// Need to refresh player so Jellyfin burns in the new sub
|
||||||
const audioData = await getAudioTracks();
|
if (
|
||||||
const allAudio =
|
isTranscoding &&
|
||||||
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];
|
(isImageBasedSubtitle(sub) || isCurrentSubImageBased)
|
||||||
const audioTracks: Track[] = allAudio?.map((audio, idx) => {
|
) {
|
||||||
if (!mediaSource?.TranscodingUrl) {
|
replacePlayer({ subtitleIndex: String(sub.Index) });
|
||||||
const vlcIndex = audioData?.at(idx + 1)?.index ?? -1;
|
return;
|
||||||
return {
|
}
|
||||||
name: audio.DisplayTitle ?? "Undefined Audio",
|
|
||||||
index: audio.Index ?? -1,
|
// Case 2: Subtitle is in MPV - just switch tracks
|
||||||
setTrack: () =>
|
if (inMpv && mpvId !== -1) {
|
||||||
setTrackParams("audio", vlcIndex, audio.Index ?? -1),
|
playerControls.setSubtitleTrack(mpvId);
|
||||||
};
|
router.setParams({ subtitleIndex: String(sub.Index) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 3: Fallback - refresh player
|
||||||
|
replacePlayer({ subtitleIndex: String(sub.Index) });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add "Disable" option at the beginning
|
||||||
|
subs.unshift({
|
||||||
|
name: "Disable",
|
||||||
|
index: -1,
|
||||||
|
setTrack: () => {
|
||||||
|
// If currently using image-based sub during transcode, need to refresh
|
||||||
|
if (isTranscoding && isCurrentSubImageBased) {
|
||||||
|
replacePlayer({ subtitleIndex: "-1" });
|
||||||
|
} else {
|
||||||
|
playerControls.setSubtitleTrack(-1);
|
||||||
|
router.setParams({ subtitleIndex: "-1" });
|
||||||
}
|
}
|
||||||
return {
|
},
|
||||||
name: audio.DisplayTitle ?? "Undefined Audio",
|
});
|
||||||
index: audio.Index ?? -1,
|
|
||||||
setTrack: () =>
|
|
||||||
setPlayerParams({ chosenAudioIndex: audio.Index?.toString() }),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add a "Disable Audio" option if its not transcoding.
|
// Process audio tracks
|
||||||
if (!mediaSource?.TranscodingUrl) {
|
const audio: Track[] = allAudio.map((a, idx) => ({
|
||||||
audioTracks.unshift({
|
name: a.DisplayTitle || "Unknown",
|
||||||
name: "Disable",
|
index: a.Index ?? -1,
|
||||||
index: -1,
|
setTrack: () => {
|
||||||
setTrack: () => setTrackParams("audio", -1, -1),
|
// Transcoding: need full player refresh to change audio stream
|
||||||
});
|
if (isTranscoding) {
|
||||||
}
|
replacePlayer({ audioIndex: String(a.Index) });
|
||||||
setAudioTracks(audioTracks);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Direct play: just switch audio track in MPV
|
||||||
|
const mpvId = (audioData as AudioTrack[])?.[idx]?.id ?? idx + 1;
|
||||||
|
playerControls.setAudioTrack(mpvId);
|
||||||
|
router.setParams({ audioIndex: String(a.Index) });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
setSubtitleTracks(subs.sort((a, b) => a.index - b.index));
|
||||||
|
setAudioTracks(audio);
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchTracks();
|
fetchTracks();
|
||||||
}, [isVideoLoaded, getAudioTracks, getSubtitleTracks]);
|
}, [tracksReady, mediaSource]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VideoContext.Provider
|
<VideoContext.Provider value={{ subtitleTracks, audioTracks }}>
|
||||||
value={{
|
|
||||||
audioTracks,
|
|
||||||
subtitleTracks,
|
|
||||||
setSubtitleTrack,
|
|
||||||
setSubtitleURL,
|
|
||||||
setAudioTrack,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</VideoContext.Provider>
|
</VideoContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useVideoContext = () => {
|
export const useVideoContext = () => {
|
||||||
const context = useContext(VideoContext);
|
const ctx = useContext(VideoContext);
|
||||||
if (context === undefined) {
|
if (!ctx)
|
||||||
throw new Error("useVideoContext must be used within a VideoProvider");
|
throw new Error("useVideoContext must be used within VideoProvider");
|
||||||
}
|
return ctx;
|
||||||
return context;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,17 +7,12 @@ import {
|
|||||||
type OptionGroup,
|
type OptionGroup,
|
||||||
PlatformDropdown,
|
PlatformDropdown,
|
||||||
} from "@/components/PlatformDropdown";
|
} from "@/components/PlatformDropdown";
|
||||||
import { useControlContext } from "../contexts/ControlContext";
|
import { usePlayerContext } from "../contexts/PlayerContext";
|
||||||
import { useVideoContext } from "../contexts/VideoContext";
|
import { useVideoContext } from "../contexts/VideoContext";
|
||||||
|
|
||||||
const DropdownView = () => {
|
const DropdownView = () => {
|
||||||
const videoContext = useVideoContext();
|
const { subtitleTracks, audioTracks } = useVideoContext();
|
||||||
const { subtitleTracks, audioTracks } = videoContext;
|
const { item, mediaSource } = usePlayerContext();
|
||||||
const ControlContext = useControlContext();
|
|
||||||
const [item, mediaSource] = [
|
|
||||||
ControlContext?.item,
|
|
||||||
ControlContext?.mediaSource,
|
|
||||||
];
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition, offline } =
|
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition, offline } =
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const useGestureDetection = ({
|
|||||||
onVerticalDragEnd,
|
onVerticalDragEnd,
|
||||||
onTap,
|
onTap,
|
||||||
screenWidth = 400,
|
screenWidth = 400,
|
||||||
|
screenHeight = 800,
|
||||||
}: SwipeGestureOptions = {}) => {
|
}: SwipeGestureOptions = {}) => {
|
||||||
const touchStartTime = useRef(0);
|
const touchStartTime = useRef(0);
|
||||||
const touchStartPosition = useRef({ x: 0, y: 0 });
|
const touchStartPosition = useRef({ x: 0, y: 0 });
|
||||||
@@ -36,25 +37,47 @@ export const useGestureDetection = ({
|
|||||||
const dragSide = useRef<"left" | "right" | null>(null);
|
const dragSide = useRef<"left" | "right" | null>(null);
|
||||||
const hasMovedEnough = useRef(false);
|
const hasMovedEnough = useRef(false);
|
||||||
const gestureType = useRef<"none" | "horizontal" | "vertical">("none");
|
const gestureType = useRef<"none" | "horizontal" | "vertical">("none");
|
||||||
|
const shouldIgnoreTouch = useRef(false);
|
||||||
|
|
||||||
const handleTouchStart = useCallback((event: GestureResponderEvent) => {
|
const handleTouchStart = useCallback(
|
||||||
touchStartTime.current = Date.now();
|
(event: GestureResponderEvent) => {
|
||||||
touchStartPosition.current = {
|
const startY = event.nativeEvent.pageY;
|
||||||
x: event.nativeEvent.pageX,
|
|
||||||
y: event.nativeEvent.pageY,
|
// Define exclusion zones (15% from top and bottom)
|
||||||
};
|
const topExclusionZone = screenHeight * 0.15;
|
||||||
lastTouchPosition.current = {
|
const bottomExclusionZone = screenHeight * 0.85;
|
||||||
x: event.nativeEvent.pageX,
|
|
||||||
y: event.nativeEvent.pageY,
|
// Check if touch started in exclusion zones
|
||||||
};
|
if (startY < topExclusionZone || startY > bottomExclusionZone) {
|
||||||
isDragging.current = false;
|
shouldIgnoreTouch.current = true;
|
||||||
dragSide.current = null;
|
return;
|
||||||
hasMovedEnough.current = false;
|
}
|
||||||
gestureType.current = "none";
|
|
||||||
}, []);
|
shouldIgnoreTouch.current = false;
|
||||||
|
touchStartTime.current = Date.now();
|
||||||
|
touchStartPosition.current = {
|
||||||
|
x: event.nativeEvent.pageX,
|
||||||
|
y: startY,
|
||||||
|
};
|
||||||
|
lastTouchPosition.current = {
|
||||||
|
x: event.nativeEvent.pageX,
|
||||||
|
y: startY,
|
||||||
|
};
|
||||||
|
isDragging.current = false;
|
||||||
|
dragSide.current = null;
|
||||||
|
hasMovedEnough.current = false;
|
||||||
|
gestureType.current = "none";
|
||||||
|
},
|
||||||
|
[screenHeight],
|
||||||
|
);
|
||||||
|
|
||||||
const handleTouchMove = useCallback(
|
const handleTouchMove = useCallback(
|
||||||
(event: GestureResponderEvent) => {
|
(event: GestureResponderEvent) => {
|
||||||
|
// Ignore touch if it started in exclusion zone
|
||||||
|
if (shouldIgnoreTouch.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const currentPosition = {
|
const currentPosition = {
|
||||||
x: event.nativeEvent.pageX,
|
x: event.nativeEvent.pageX,
|
||||||
y: event.nativeEvent.pageY,
|
y: event.nativeEvent.pageY,
|
||||||
@@ -106,6 +129,12 @@ export const useGestureDetection = ({
|
|||||||
|
|
||||||
const handleTouchEnd = useCallback(
|
const handleTouchEnd = useCallback(
|
||||||
(event: GestureResponderEvent) => {
|
(event: GestureResponderEvent) => {
|
||||||
|
// Ignore touch if it started in exclusion zone
|
||||||
|
if (shouldIgnoreTouch.current) {
|
||||||
|
shouldIgnoreTouch.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const touchEndTime = Date.now();
|
const touchEndTime = Date.now();
|
||||||
const touchEndPosition = {
|
const touchEndPosition = {
|
||||||
x: event.nativeEvent.pageX,
|
x: event.nativeEvent.pageX,
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ interface UseRemoteControlProps {
|
|||||||
progress: SharedValue<number>;
|
progress: SharedValue<number>;
|
||||||
min: SharedValue<number>;
|
min: SharedValue<number>;
|
||||||
max: SharedValue<number>;
|
max: SharedValue<number>;
|
||||||
isVlc: boolean;
|
|
||||||
showControls: boolean;
|
showControls: boolean;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
seek: (value: number) => void;
|
seek: (value: number) => void;
|
||||||
@@ -34,11 +33,14 @@ interface UseRemoteControlProps {
|
|||||||
handleSeekBackward: (seconds: number) => void;
|
handleSeekBackward: (seconds: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to manage TV remote control interactions.
|
||||||
|
* MPV player uses milliseconds for time values.
|
||||||
|
*/
|
||||||
export function useRemoteControl({
|
export function useRemoteControl({
|
||||||
progress,
|
progress,
|
||||||
min,
|
min,
|
||||||
max,
|
max,
|
||||||
isVlc,
|
|
||||||
showControls,
|
showControls,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
seek,
|
seek,
|
||||||
@@ -61,21 +63,18 @@ export function useRemoteControl({
|
|||||||
const longPressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
const longPressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const SCRUB_INTERVAL = isVlc
|
// MPV uses ms
|
||||||
? CONTROLS_CONSTANTS.SCRUB_INTERVAL_MS
|
const SCRUB_INTERVAL = CONTROLS_CONSTANTS.SCRUB_INTERVAL_MS;
|
||||||
: CONTROLS_CONSTANTS.SCRUB_INTERVAL_TICKS;
|
|
||||||
|
|
||||||
const updateTime = useCallback(
|
const updateTime = useCallback((progressValue: number) => {
|
||||||
(progressValue: number) => {
|
// Convert ms to ticks for calculation
|
||||||
const progressInTicks = isVlc ? msToTicks(progressValue) : progressValue;
|
const progressInTicks = msToTicks(progressValue);
|
||||||
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
|
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
|
||||||
const hours = Math.floor(progressInSeconds / 3600);
|
const hours = Math.floor(progressInSeconds / 3600);
|
||||||
const minutes = Math.floor((progressInSeconds % 3600) / 60);
|
const minutes = Math.floor((progressInSeconds % 3600) / 60);
|
||||||
const seconds = progressInSeconds % 60;
|
const seconds = progressInSeconds % 60;
|
||||||
setTime({ hours, minutes, seconds });
|
setTime({ hours, minutes, seconds });
|
||||||
},
|
}, []);
|
||||||
[isVlc],
|
|
||||||
);
|
|
||||||
|
|
||||||
// TV remote control handling (no-op on non-TV platforms)
|
// TV remote control handling (no-op on non-TV platforms)
|
||||||
useTVEventHandler((evt) => {
|
useTVEventHandler((evt) => {
|
||||||
@@ -102,7 +101,8 @@ export function useRemoteControl({
|
|||||||
Math.min(max.value, base + direction * SCRUB_INTERVAL),
|
Math.min(max.value, base + direction * SCRUB_INTERVAL),
|
||||||
);
|
);
|
||||||
remoteScrubProgress.value = updated;
|
remoteScrubProgress.value = updated;
|
||||||
const progressInTicks = isVlc ? msToTicks(updated) : updated;
|
// Convert ms to ticks for trickplay
|
||||||
|
const progressInTicks = msToTicks(updated);
|
||||||
calculateTrickplayUrl(progressInTicks);
|
calculateTrickplayUrl(progressInTicks);
|
||||||
updateTime(updated);
|
updateTime(updated);
|
||||||
break;
|
break;
|
||||||
@@ -111,9 +111,8 @@ export function useRemoteControl({
|
|||||||
if (isRemoteScrubbing.value && remoteScrubProgress.value != null) {
|
if (isRemoteScrubbing.value && remoteScrubProgress.value != null) {
|
||||||
progress.value = remoteScrubProgress.value;
|
progress.value = remoteScrubProgress.value;
|
||||||
|
|
||||||
const seekTarget = isVlc
|
// MPV uses ms, seek expects ms
|
||||||
? Math.max(0, remoteScrubProgress.value)
|
const seekTarget = Math.max(0, remoteScrubProgress.value);
|
||||||
: Math.max(0, ticksToSeconds(remoteScrubProgress.value));
|
|
||||||
|
|
||||||
seek(seekTarget);
|
seek(seekTarget);
|
||||||
if (isPlaying) play();
|
if (isPlaying) play();
|
||||||
|
|||||||
@@ -3,20 +3,22 @@ import type { SharedValue } from "react-native-reanimated";
|
|||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import { secondsToMs, ticksToSeconds } from "@/utils/time";
|
import { secondsToMs } from "@/utils/time";
|
||||||
|
|
||||||
interface UseVideoNavigationProps {
|
interface UseVideoNavigationProps {
|
||||||
progress: SharedValue<number>;
|
progress: SharedValue<number>;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
isVlc: boolean;
|
|
||||||
seek: (value: number) => void;
|
seek: (value: number) => void;
|
||||||
play: () => void;
|
play: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to manage video navigation (seeking forward/backward).
|
||||||
|
* MPV player uses milliseconds for time values.
|
||||||
|
*/
|
||||||
export function useVideoNavigation({
|
export function useVideoNavigation({
|
||||||
progress,
|
progress,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
isVlc,
|
|
||||||
seek,
|
seek,
|
||||||
play,
|
play,
|
||||||
}: UseVideoNavigationProps) {
|
}: UseVideoNavigationProps) {
|
||||||
@@ -30,16 +32,15 @@ export function useVideoNavigation({
|
|||||||
try {
|
try {
|
||||||
const curr = progress.value;
|
const curr = progress.value;
|
||||||
if (curr !== undefined) {
|
if (curr !== undefined) {
|
||||||
const newTime = isVlc
|
// MPV uses ms
|
||||||
? Math.max(0, curr - secondsToMs(seconds))
|
const newTime = Math.max(0, curr - secondsToMs(seconds));
|
||||||
: Math.max(0, ticksToSeconds(curr) - seconds);
|
|
||||||
seek(newTime);
|
seek(newTime);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
writeToLog("ERROR", "Error seeking video backwards", error);
|
writeToLog("ERROR", "Error seeking video backwards", error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isPlaying, isVlc, seek, progress],
|
[isPlaying, seek, progress],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSeekForward = useCallback(
|
const handleSeekForward = useCallback(
|
||||||
@@ -48,16 +49,15 @@ export function useVideoNavigation({
|
|||||||
try {
|
try {
|
||||||
const curr = progress.value;
|
const curr = progress.value;
|
||||||
if (curr !== undefined) {
|
if (curr !== undefined) {
|
||||||
const newTime = isVlc
|
// MPV uses ms
|
||||||
? curr + secondsToMs(seconds)
|
const newTime = curr + secondsToMs(seconds);
|
||||||
: ticksToSeconds(curr) + seconds;
|
|
||||||
seek(Math.max(0, newTime));
|
seek(Math.max(0, newTime));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
writeToLog("ERROR", "Error seeking video forwards", error);
|
writeToLog("ERROR", "Error seeking video forwards", error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isPlaying, isVlc, seek, progress],
|
[isPlaying, seek, progress],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSkipBackward = useCallback(async () => {
|
const handleSkipBackward = useCallback(async () => {
|
||||||
@@ -69,9 +69,11 @@ export function useVideoNavigation({
|
|||||||
try {
|
try {
|
||||||
const curr = progress.value;
|
const curr = progress.value;
|
||||||
if (curr !== undefined) {
|
if (curr !== undefined) {
|
||||||
const newTime = isVlc
|
// MPV uses ms
|
||||||
? Math.max(0, curr - secondsToMs(settings.rewindSkipTime))
|
const newTime = Math.max(
|
||||||
: Math.max(0, ticksToSeconds(curr) - settings.rewindSkipTime);
|
0,
|
||||||
|
curr - secondsToMs(settings.rewindSkipTime),
|
||||||
|
);
|
||||||
seek(newTime);
|
seek(newTime);
|
||||||
if (wasPlayingRef.current) {
|
if (wasPlayingRef.current) {
|
||||||
play();
|
play();
|
||||||
@@ -80,7 +82,7 @@ export function useVideoNavigation({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
writeToLog("ERROR", "Error seeking video backwards", error);
|
writeToLog("ERROR", "Error seeking video backwards", error);
|
||||||
}
|
}
|
||||||
}, [settings, isPlaying, isVlc, play, seek, progress, lightHapticFeedback]);
|
}, [settings, isPlaying, play, seek, progress, lightHapticFeedback]);
|
||||||
|
|
||||||
const handleSkipForward = useCallback(async () => {
|
const handleSkipForward = useCallback(async () => {
|
||||||
if (!settings?.forwardSkipTime) {
|
if (!settings?.forwardSkipTime) {
|
||||||
@@ -91,9 +93,8 @@ export function useVideoNavigation({
|
|||||||
try {
|
try {
|
||||||
const curr = progress.value;
|
const curr = progress.value;
|
||||||
if (curr !== undefined) {
|
if (curr !== undefined) {
|
||||||
const newTime = isVlc
|
// MPV uses ms
|
||||||
? curr + secondsToMs(settings.forwardSkipTime)
|
const newTime = curr + secondsToMs(settings.forwardSkipTime);
|
||||||
: ticksToSeconds(curr) + settings.forwardSkipTime;
|
|
||||||
seek(Math.max(0, newTime));
|
seek(Math.max(0, newTime));
|
||||||
if (wasPlayingRef.current) {
|
if (wasPlayingRef.current) {
|
||||||
play();
|
play();
|
||||||
@@ -102,7 +103,7 @@ export function useVideoNavigation({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
writeToLog("ERROR", "Error seeking video forwards", error);
|
writeToLog("ERROR", "Error seeking video forwards", error);
|
||||||
}
|
}
|
||||||
}, [settings, isPlaying, isVlc, play, seek, progress, lightHapticFeedback]);
|
}, [settings, isPlaying, play, seek, progress, lightHapticFeedback]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handleSeekBackward,
|
handleSeekBackward,
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ interface UseVideoSliderProps {
|
|||||||
progress: SharedValue<number>;
|
progress: SharedValue<number>;
|
||||||
isSeeking: SharedValue<boolean>;
|
isSeeking: SharedValue<boolean>;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
isVlc: boolean;
|
|
||||||
seek: (value: number) => void;
|
seek: (value: number) => void;
|
||||||
play: () => void;
|
play: () => void;
|
||||||
pause: () => void;
|
pause: () => void;
|
||||||
@@ -16,11 +15,14 @@ interface UseVideoSliderProps {
|
|||||||
showControls: boolean;
|
showControls: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to manage video slider interactions.
|
||||||
|
* MPV player uses milliseconds for time values.
|
||||||
|
*/
|
||||||
export function useVideoSlider({
|
export function useVideoSlider({
|
||||||
progress,
|
progress,
|
||||||
isSeeking,
|
isSeeking,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
isVlc,
|
|
||||||
seek,
|
seek,
|
||||||
play,
|
play,
|
||||||
pause,
|
pause,
|
||||||
@@ -62,21 +64,20 @@ export function useVideoSlider({
|
|||||||
setIsSliding(false);
|
setIsSliding(false);
|
||||||
isSeeking.value = false;
|
isSeeking.value = false;
|
||||||
progress.value = value;
|
progress.value = value;
|
||||||
const seekValue = Math.max(
|
// MPV uses ms, seek expects ms
|
||||||
0,
|
const seekValue = Math.max(0, Math.floor(value));
|
||||||
Math.floor(isVlc ? value : ticksToSeconds(value)),
|
|
||||||
);
|
|
||||||
seek(seekValue);
|
seek(seekValue);
|
||||||
if (wasPlayingRef.current) {
|
if (wasPlayingRef.current) {
|
||||||
play();
|
play();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isVlc, seek, play, progress, isSeeking],
|
[seek, play, progress, isSeeking],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSliderChange = useCallback(
|
const handleSliderChange = useCallback(
|
||||||
debounce((value: number) => {
|
debounce((value: number) => {
|
||||||
const progressInTicks = isVlc ? msToTicks(value) : value;
|
// Convert ms to ticks for trickplay
|
||||||
|
const progressInTicks = msToTicks(value);
|
||||||
calculateTrickplayUrl(progressInTicks);
|
calculateTrickplayUrl(progressInTicks);
|
||||||
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
|
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
|
||||||
const hours = Math.floor(progressInSeconds / 3600);
|
const hours = Math.floor(progressInSeconds / 3600);
|
||||||
@@ -84,7 +85,7 @@ export function useVideoSlider({
|
|||||||
const seconds = progressInSeconds % 60;
|
const seconds = progressInSeconds % 60;
|
||||||
setTime({ hours, minutes, seconds });
|
setTime({ hours, minutes, seconds });
|
||||||
}, CONTROLS_CONSTANTS.SLIDER_DEBOUNCE_MS),
|
}, CONTROLS_CONSTANTS.SLIDER_DEBOUNCE_MS),
|
||||||
[isVlc, calculateTrickplayUrl],
|
[calculateTrickplayUrl],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -4,21 +4,18 @@ import {
|
|||||||
type SharedValue,
|
type SharedValue,
|
||||||
useAnimatedReaction,
|
useAnimatedReaction,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { ticksToSeconds } from "@/utils/time";
|
|
||||||
|
|
||||||
interface UseVideoTimeProps {
|
interface UseVideoTimeProps {
|
||||||
progress: SharedValue<number>;
|
progress: SharedValue<number>;
|
||||||
max: SharedValue<number>;
|
max: SharedValue<number>;
|
||||||
isSeeking: SharedValue<boolean>;
|
isSeeking: SharedValue<boolean>;
|
||||||
isVlc: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useVideoTime({
|
/**
|
||||||
progress,
|
* Hook to manage video time display.
|
||||||
max,
|
* MPV player uses milliseconds for time values.
|
||||||
isSeeking,
|
*/
|
||||||
isVlc,
|
export function useVideoTime({ progress, max, isSeeking }: UseVideoTimeProps) {
|
||||||
}: UseVideoTimeProps) {
|
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
const [remainingTime, setRemainingTime] = useState(Number.POSITIVE_INFINITY);
|
const [remainingTime, setRemainingTime] = useState(Number.POSITIVE_INFINITY);
|
||||||
|
|
||||||
@@ -27,19 +24,16 @@ export function useVideoTime({
|
|||||||
|
|
||||||
const updateTimes = useCallback(
|
const updateTimes = useCallback(
|
||||||
(currentProgress: number, maxValue: number) => {
|
(currentProgress: number, maxValue: number) => {
|
||||||
const current = isVlc ? currentProgress : ticksToSeconds(currentProgress);
|
// MPV uses milliseconds
|
||||||
const remaining = isVlc
|
const current = currentProgress;
|
||||||
? maxValue - currentProgress
|
const remaining = maxValue - currentProgress;
|
||||||
: ticksToSeconds(maxValue - currentProgress);
|
|
||||||
|
|
||||||
// Only update state if the displayed time actually changed (avoid sub-second updates)
|
// Only update state if the displayed time actually changed (avoid sub-second updates)
|
||||||
const currentSeconds = Math.floor(current / (isVlc ? 1000 : 1));
|
const currentSeconds = Math.floor(current / 1000);
|
||||||
const remainingSeconds = Math.floor(remaining / (isVlc ? 1000 : 1));
|
const remainingSeconds = Math.floor(remaining / 1000);
|
||||||
const lastCurrentSeconds = Math.floor(
|
const lastCurrentSeconds = Math.floor(lastCurrentTimeRef.current / 1000);
|
||||||
lastCurrentTimeRef.current / (isVlc ? 1000 : 1),
|
|
||||||
);
|
|
||||||
const lastRemainingSeconds = Math.floor(
|
const lastRemainingSeconds = Math.floor(
|
||||||
lastRemainingTimeRef.current / (isVlc ? 1000 : 1),
|
lastRemainingTimeRef.current / 1000,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -52,7 +46,7 @@ export function useVideoTime({
|
|||||||
lastRemainingTimeRef.current = remaining;
|
lastRemainingTimeRef.current = remaining;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isVlc],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
useAnimatedReaction(
|
useAnimatedReaction(
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
import type React from "react";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import type { TrackInfo, VlcPlayerViewRef } from "@/modules/VlcPlayer.types";
|
|
||||||
import { Text } from "../common/Text";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
|
||||||
playerRef: React.RefObject<VlcPlayerViewRef>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const VideoDebugInfo: React.FC<Props> = ({ playerRef, ...props }) => {
|
|
||||||
const [audioTracks, setAudioTracks] = useState<TrackInfo[] | null>(null);
|
|
||||||
const [subtitleTracks, setSubtitleTracks] = useState<TrackInfo[] | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchTracks = async () => {
|
|
||||||
if (playerRef.current) {
|
|
||||||
const audio = await playerRef.current.getAudioTracks();
|
|
||||||
const subtitles = await playerRef.current.getSubtitleTracks();
|
|
||||||
setAudioTracks(audio);
|
|
||||||
setSubtitleTracks(subtitles);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchTracks();
|
|
||||||
}, [playerRef]);
|
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: insets.top,
|
|
||||||
left: insets.left + 8,
|
|
||||||
zIndex: 100,
|
|
||||||
}}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<Text className='font-bold'>{t("player.playback_state")}</Text>
|
|
||||||
<Text className='font-bold mt-2.5'>{t("player.audio_tracks")}</Text>
|
|
||||||
{audioTracks?.map((track, index) => (
|
|
||||||
<Text key={index}>
|
|
||||||
{track.name} ({t("player.index")} {track.index})
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
<Text className='font-bold mt-2.5'>{t("player.subtitles_tracks")}</Text>
|
|
||||||
{subtitleTracks?.map((track, index) => (
|
|
||||||
<Text key={index}>
|
|
||||||
{track.name} ({t("player.index")} {track.index})
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
<TouchableOpacity
|
|
||||||
className='mt-2.5 bg-blue-500 p-2 rounded'
|
|
||||||
onPress={() => {
|
|
||||||
if (playerRef.current) {
|
|
||||||
playerRef.current.getAudioTracks().then(setAudioTracks);
|
|
||||||
playerRef.current.getSubtitleTracks().then(setSubtitleTracks);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text className='text-white text-center'>
|
|
||||||
{t("player.refresh_tracks")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
export type VLCColor =
|
|
||||||
| "Black"
|
|
||||||
| "Gray"
|
|
||||||
| "Silver"
|
|
||||||
| "White"
|
|
||||||
| "Maroon"
|
|
||||||
| "Red"
|
|
||||||
| "Fuchsia"
|
|
||||||
| "Yellow"
|
|
||||||
| "Olive"
|
|
||||||
| "Green"
|
|
||||||
| "Teal"
|
|
||||||
| "Lime"
|
|
||||||
| "Purple"
|
|
||||||
| "Navy"
|
|
||||||
| "Blue"
|
|
||||||
| "Aqua";
|
|
||||||
|
|
||||||
export type OutlineThickness = "None" | "Thin" | "Normal" | "Thick";
|
|
||||||
|
|
||||||
export const VLC_COLORS: Record<VLCColor, number> = {
|
|
||||||
Black: 0,
|
|
||||||
Gray: 8421504,
|
|
||||||
Silver: 12632256,
|
|
||||||
White: 16777215,
|
|
||||||
Maroon: 8388608,
|
|
||||||
Red: 16711680,
|
|
||||||
Fuchsia: 16711935,
|
|
||||||
Yellow: 16776960,
|
|
||||||
Olive: 8421376,
|
|
||||||
Green: 32768,
|
|
||||||
Teal: 32896,
|
|
||||||
Lime: 65280,
|
|
||||||
Purple: 8388736,
|
|
||||||
Navy: 128,
|
|
||||||
Blue: 255,
|
|
||||||
Aqua: 65535,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const OUTLINE_THICKNESS: Record<OutlineThickness, number> = {
|
|
||||||
None: 0,
|
|
||||||
Thin: 2,
|
|
||||||
Normal: 4,
|
|
||||||
Thick: 6,
|
|
||||||
};
|
|
||||||
6
eas.json
6
eas.json
@@ -45,14 +45,14 @@
|
|||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.47.1",
|
"channel": "0.48.0",
|
||||||
"android": {
|
"android": {
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.47.1",
|
"channel": "0.48.0",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
},
|
},
|
||||||
"production-apk-tv": {
|
"production-apk-tv": {
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.47.1",
|
"channel": "0.48.0",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ import { useSegments } from "@/utils/segments";
|
|||||||
import { msToSeconds, secondsToMs } from "@/utils/time";
|
import { msToSeconds, secondsToMs } from "@/utils/time";
|
||||||
import { useHaptic } from "./useHaptic";
|
import { useHaptic } from "./useHaptic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook to handle skipping credits in a media player.
|
||||||
|
* MPV player uses milliseconds for time.
|
||||||
|
*/
|
||||||
export const useCreditSkipper = (
|
export const useCreditSkipper = (
|
||||||
itemId: string,
|
itemId: string,
|
||||||
currentTime: number,
|
currentTime: number,
|
||||||
seek: (time: number) => void,
|
seek: (ms: number) => void,
|
||||||
play: () => void,
|
play: () => void,
|
||||||
isVlc = false,
|
|
||||||
isOffline = false,
|
isOffline = false,
|
||||||
api: Api | null = null,
|
api: Api | null = null,
|
||||||
downloadedFiles: DownloadedItem[] | undefined = undefined,
|
downloadedFiles: DownloadedItem[] | undefined = undefined,
|
||||||
@@ -18,16 +21,11 @@ export const useCreditSkipper = (
|
|||||||
const [showSkipCreditButton, setShowSkipCreditButton] = useState(false);
|
const [showSkipCreditButton, setShowSkipCreditButton] = useState(false);
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
if (isVlc) {
|
// Convert ms to seconds for comparison with timestamps
|
||||||
currentTime = msToSeconds(currentTime);
|
const currentTimeSeconds = msToSeconds(currentTime);
|
||||||
}
|
|
||||||
|
|
||||||
const wrappedSeek = (seconds: number) => {
|
const wrappedSeek = (seconds: number) => {
|
||||||
if (isVlc) {
|
seek(secondsToMs(seconds));
|
||||||
seek(secondsToMs(seconds));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
seek(seconds);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data: segments } = useSegments(
|
const { data: segments } = useSegments(
|
||||||
@@ -41,11 +39,11 @@ export const useCreditSkipper = (
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (creditTimestamps) {
|
if (creditTimestamps) {
|
||||||
setShowSkipCreditButton(
|
setShowSkipCreditButton(
|
||||||
currentTime > creditTimestamps.startTime &&
|
currentTimeSeconds > creditTimestamps.startTime &&
|
||||||
currentTime < creditTimestamps.endTime,
|
currentTimeSeconds < creditTimestamps.endTime,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [creditTimestamps, currentTime]);
|
}, [creditTimestamps, currentTimeSeconds]);
|
||||||
|
|
||||||
const skipCredit = useCallback(() => {
|
const skipCredit = useCallback(() => {
|
||||||
if (!creditTimestamps) return;
|
if (!creditTimestamps) return;
|
||||||
|
|||||||
@@ -1,51 +1,29 @@
|
|||||||
import { type BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import { type BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { BITRATES } from "@/components/BitrateSelector";
|
|
||||||
import type { Settings } from "@/utils/atoms/settings";
|
import type { Settings } from "@/utils/atoms/settings";
|
||||||
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
|
|
||||||
// Used only for initial play settings.
|
/**
|
||||||
const useDefaultPlaySettings = (
|
* React hook wrapper for getDefaultPlaySettings.
|
||||||
item: BaseItemDto,
|
* Used in UI components for initial playback (no previous track state).
|
||||||
settings: Settings | null,
|
*/
|
||||||
) => {
|
const useDefaultPlaySettings = (item: BaseItemDto, settings: Settings | null) =>
|
||||||
const playSettings = useMemo(() => {
|
useMemo(() => {
|
||||||
// 1. Get first media source
|
const { mediaSource, audioIndex, subtitleIndex, bitrate } =
|
||||||
const mediaSource = item.MediaSources?.[0];
|
getDefaultPlaySettings(item, settings);
|
||||||
|
|
||||||
// 2. Get default or preferred audio
|
console.log("defaultPlaySettings", {
|
||||||
const defaultAudioIndex = mediaSource?.DefaultAudioStreamIndex;
|
audioIndex,
|
||||||
const preferedAudioIndex = mediaSource?.MediaStreams?.find(
|
subtitleIndex,
|
||||||
(x) =>
|
bitrate,
|
||||||
x.Type === "Audio" &&
|
});
|
||||||
x.Language ===
|
|
||||||
settings?.defaultAudioLanguage?.ThreeLetterISOLanguageName,
|
|
||||||
)?.Index;
|
|
||||||
|
|
||||||
const firstAudioIndex = mediaSource?.MediaStreams?.find(
|
|
||||||
(x) => x.Type === "Audio",
|
|
||||||
)?.Index;
|
|
||||||
|
|
||||||
// 4. Get default bitrate from settings or fallback to max
|
|
||||||
let bitrate = settings?.defaultBitrate ?? BITRATES[0];
|
|
||||||
// value undefined seems to get lost in settings. This is just a failsafe
|
|
||||||
if (bitrate.key === BITRATES[0].key) {
|
|
||||||
bitrate = BITRATES[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
defaultAudioIndex:
|
defaultMediaSource: mediaSource,
|
||||||
preferedAudioIndex ?? defaultAudioIndex ?? firstAudioIndex ?? undefined,
|
defaultAudioIndex: audioIndex,
|
||||||
defaultSubtitleIndex: mediaSource?.DefaultSubtitleStreamIndex ?? -1,
|
defaultSubtitleIndex: subtitleIndex,
|
||||||
defaultMediaSource: mediaSource ?? undefined,
|
defaultBitrate: bitrate,
|
||||||
defaultBitrate: bitrate ?? undefined,
|
|
||||||
};
|
};
|
||||||
}, [
|
}, [item, settings]);
|
||||||
item.MediaSources,
|
|
||||||
settings?.defaultAudioLanguage,
|
|
||||||
settings?.defaultSubtitleLanguage,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return playSettings;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useDefaultPlaySettings;
|
export default useDefaultPlaySettings;
|
||||||
|
|||||||
@@ -7,31 +7,26 @@ import { useHaptic } from "./useHaptic";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom hook to handle skipping intros in a media player.
|
* Custom hook to handle skipping intros in a media player.
|
||||||
|
* MPV player uses milliseconds for time.
|
||||||
*
|
*
|
||||||
* @param {number} currentTime - The current playback time in seconds.
|
* @param {number} currentTime - The current playback time in milliseconds.
|
||||||
*/
|
*/
|
||||||
export const useIntroSkipper = (
|
export const useIntroSkipper = (
|
||||||
itemId: string,
|
itemId: string,
|
||||||
currentTime: number,
|
currentTime: number,
|
||||||
seek: (ticks: number) => void,
|
seek: (ms: number) => void,
|
||||||
play: () => void,
|
play: () => void,
|
||||||
isVlc = false,
|
|
||||||
isOffline = false,
|
isOffline = false,
|
||||||
api: Api | null = null,
|
api: Api | null = null,
|
||||||
downloadedFiles: DownloadedItem[] | undefined = undefined,
|
downloadedFiles: DownloadedItem[] | undefined = undefined,
|
||||||
) => {
|
) => {
|
||||||
const [showSkipButton, setShowSkipButton] = useState(false);
|
const [showSkipButton, setShowSkipButton] = useState(false);
|
||||||
if (isVlc) {
|
// Convert ms to seconds for comparison with timestamps
|
||||||
currentTime = msToSeconds(currentTime);
|
const currentTimeSeconds = msToSeconds(currentTime);
|
||||||
}
|
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
const wrappedSeek = (seconds: number) => {
|
const wrappedSeek = (seconds: number) => {
|
||||||
if (isVlc) {
|
seek(secondsToMs(seconds));
|
||||||
seek(secondsToMs(seconds));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
seek(seconds);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data: segments } = useSegments(
|
const { data: segments } = useSegments(
|
||||||
@@ -43,49 +38,22 @@ export const useIntroSkipper = (
|
|||||||
const introTimestamps = segments?.introSegments?.[0];
|
const introTimestamps = segments?.introSegments?.[0];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log(`[INTRO_SKIPPER] Hook state:`, {
|
|
||||||
itemId,
|
|
||||||
currentTime,
|
|
||||||
hasSegments: !!segments,
|
|
||||||
segments: segments,
|
|
||||||
introSegmentsCount: segments?.introSegments?.length || 0,
|
|
||||||
introSegments: segments?.introSegments,
|
|
||||||
hasIntroTimestamps: !!introTimestamps,
|
|
||||||
introTimestamps,
|
|
||||||
isVlc,
|
|
||||||
isOffline,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (introTimestamps) {
|
if (introTimestamps) {
|
||||||
const shouldShow =
|
const shouldShow =
|
||||||
currentTime > introTimestamps.startTime &&
|
currentTimeSeconds > introTimestamps.startTime &&
|
||||||
currentTime < introTimestamps.endTime;
|
currentTimeSeconds < introTimestamps.endTime;
|
||||||
|
|
||||||
console.log(`[INTRO_SKIPPER] Button visibility check:`, {
|
|
||||||
currentTime,
|
|
||||||
introStart: introTimestamps.startTime,
|
|
||||||
introEnd: introTimestamps.endTime,
|
|
||||||
afterStart: currentTime > introTimestamps.startTime,
|
|
||||||
beforeEnd: currentTime < introTimestamps.endTime,
|
|
||||||
shouldShow,
|
|
||||||
});
|
|
||||||
|
|
||||||
setShowSkipButton(shouldShow);
|
setShowSkipButton(shouldShow);
|
||||||
} else {
|
} else {
|
||||||
if (showSkipButton) {
|
if (showSkipButton) {
|
||||||
console.log(`[INTRO_SKIPPER] No intro timestamps, hiding button`);
|
|
||||||
setShowSkipButton(false);
|
setShowSkipButton(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [introTimestamps, currentTime, showSkipButton]);
|
}, [introTimestamps, currentTimeSeconds, showSkipButton]);
|
||||||
|
|
||||||
const skipIntro = useCallback(() => {
|
const skipIntro = useCallback(() => {
|
||||||
if (!introTimestamps) return;
|
if (!introTimestamps) return;
|
||||||
try {
|
try {
|
||||||
console.log(
|
|
||||||
`[INTRO_SKIPPER] Skipping intro to:`,
|
|
||||||
introTimestamps.endTime,
|
|
||||||
);
|
|
||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
wrappedSeek(introTimestamps.endTime);
|
wrappedSeek(introTimestamps.endTime);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -96,7 +64,5 @@ export const useIntroSkipper = (
|
|||||||
}
|
}
|
||||||
}, [introTimestamps, lightHapticFeedback, wrappedSeek, play]);
|
}, [introTimestamps, lightHapticFeedback, wrappedSeek, play]);
|
||||||
|
|
||||||
console.log(`[INTRO_SKIPPER] Returning state:`, { showSkipButton });
|
|
||||||
|
|
||||||
return { showSkipButton, skipIntro };
|
return { showSkipButton, skipIntro };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,28 +1,54 @@
|
|||||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
import { ItemFields } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
export const useItemQuery = (itemId: string, isOffline: boolean) => {
|
// Helper to exclude specific fields
|
||||||
|
export const excludeFields = (fieldsToExclude: ItemFields[]) => {
|
||||||
|
return Object.values(ItemFields).filter(
|
||||||
|
(field) => !fieldsToExclude.includes(field),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useItemQuery = (
|
||||||
|
itemId: string | undefined,
|
||||||
|
isOffline?: boolean,
|
||||||
|
fields?: ItemFields[],
|
||||||
|
excludeFields?: ItemFields[],
|
||||||
|
) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const { getDownloadedItemById } = useDownload();
|
const { getDownloadedItemById } = useDownload();
|
||||||
|
|
||||||
|
// Calculate final fields: use excludeFields if provided, otherwise use fields
|
||||||
|
const finalFields = excludeFields
|
||||||
|
? Object.values(ItemFields).filter(
|
||||||
|
(field) => !excludeFields.includes(field),
|
||||||
|
)
|
||||||
|
: fields;
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["item", itemId],
|
queryKey: ["item", itemId, finalFields],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
|
if (!itemId) throw new Error("Item ID is required");
|
||||||
|
|
||||||
if (isOffline) {
|
if (isOffline) {
|
||||||
return getDownloadedItemById(itemId)?.item;
|
return getDownloadedItemById(itemId)?.item;
|
||||||
}
|
}
|
||||||
if (!api || !user || !itemId) return null;
|
|
||||||
const res = await getUserLibraryApi(api).getItem({
|
if (!api || !user) return null;
|
||||||
itemId: itemId,
|
|
||||||
userId: user?.Id,
|
const response = await getItemsApi(api).getItems({
|
||||||
|
ids: [itemId],
|
||||||
|
userId: user.Id,
|
||||||
|
...(finalFields && { fields: finalFields }),
|
||||||
});
|
});
|
||||||
return res.data;
|
|
||||||
|
return response.data.Items?.[0];
|
||||||
},
|
},
|
||||||
staleTime: 0,
|
enabled: !!itemId,
|
||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: true,
|
||||||
refetchOnReconnect: true,
|
refetchOnReconnect: true,
|
||||||
|
|||||||
@@ -244,6 +244,22 @@ export class JellyseerrApi {
|
|||||||
.then(({ data }) => data);
|
.then(({ data }) => data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async approveRequest(requestId: number): Promise<MediaRequest> {
|
||||||
|
return this.axios
|
||||||
|
?.post<MediaRequest>(
|
||||||
|
`${Endpoints.API_V1 + Endpoints.REQUEST}/${requestId}/approve`,
|
||||||
|
)
|
||||||
|
.then(({ data }) => data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async declineRequest(requestId: number): Promise<MediaRequest> {
|
||||||
|
return this.axios
|
||||||
|
?.post<MediaRequest>(
|
||||||
|
`${Endpoints.API_V1 + Endpoints.REQUEST}/${requestId}/decline`,
|
||||||
|
)
|
||||||
|
.then(({ data }) => data);
|
||||||
|
}
|
||||||
|
|
||||||
async requests(
|
async requests(
|
||||||
params = {
|
params = {
|
||||||
filter: "all",
|
filter: "all",
|
||||||
@@ -512,7 +528,7 @@ export const useJellyseerr = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const jellyseerrRegion = useMemo(
|
const jellyseerrRegion = useMemo(
|
||||||
() => jellyseerrUser?.settings?.discoverRegion || "US",
|
() => jellyseerrUser?.settings?.region || "US",
|
||||||
[jellyseerrUser],
|
[jellyseerrUser],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { OrientationChangeEvent } from "expo-screen-orientation";
|
import type { OrientationChangeEvent } from "expo-screen-orientation";
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import {
|
import {
|
||||||
addOrientationChangeListener,
|
addOrientationChangeListener,
|
||||||
@@ -53,27 +53,28 @@ export const useOrientation = () => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const lockOrientation = async (
|
const lockOrientation = useCallback(
|
||||||
lock: (typeof OrientationLock)[keyof typeof OrientationLock],
|
async (lock: (typeof OrientationLock)[keyof typeof OrientationLock]) => {
|
||||||
) => {
|
if (Platform.isTV) return;
|
||||||
if (Platform.isTV) return;
|
|
||||||
|
|
||||||
if (lock === OrientationLock.DEFAULT) {
|
if (lock === OrientationLock.DEFAULT) {
|
||||||
await unlockAsync();
|
await unlockAsync();
|
||||||
} else {
|
} else {
|
||||||
await lockAsync(lock);
|
await lockAsync(lock);
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const unlockOrientationFn = async () => {
|
const unlockOrientation = useCallback(async () => {
|
||||||
if (Platform.isTV) return;
|
if (Platform.isTV) return;
|
||||||
await unlockAsync();
|
await unlockAsync();
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
orientation,
|
orientation,
|
||||||
setOrientation,
|
setOrientation,
|
||||||
lockOrientation,
|
lockOrientation,
|
||||||
unlockOrientation: unlockOrientationFn,
|
unlockOrientation,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
import { ViewStyle } from "react-native";
|
|
||||||
|
|
||||||
export type PlaybackStatePayload = {
|
|
||||||
nativeEvent: {
|
|
||||||
target: number;
|
|
||||||
state: "Opening" | "Buffering" | "Playing" | "Paused" | "Error";
|
|
||||||
currentTime: number;
|
|
||||||
duration: number;
|
|
||||||
isBuffering: boolean;
|
|
||||||
isPlaying: boolean;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ProgressUpdatePayload = {
|
|
||||||
nativeEvent: {
|
|
||||||
currentTime: number;
|
|
||||||
duration: number;
|
|
||||||
isPlaying: boolean;
|
|
||||||
isBuffering: boolean;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type VideoLoadStartPayload = {
|
|
||||||
nativeEvent: {
|
|
||||||
target: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PipStartedPayload = {
|
|
||||||
nativeEvent: {
|
|
||||||
pipStarted: boolean;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type VideoStateChangePayload = PlaybackStatePayload;
|
|
||||||
|
|
||||||
export type VideoProgressPayload = ProgressUpdatePayload;
|
|
||||||
|
|
||||||
export type VlcPlayerSource = {
|
|
||||||
uri: string;
|
|
||||||
type?: string;
|
|
||||||
isNetwork?: boolean;
|
|
||||||
autoplay?: boolean;
|
|
||||||
startPosition?: number;
|
|
||||||
externalSubtitles?: { name: string; DeliveryUrl: string }[];
|
|
||||||
initOptions?: any[];
|
|
||||||
mediaOptions?: { [key: string]: any };
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TrackInfo = {
|
|
||||||
name: string;
|
|
||||||
index: number;
|
|
||||||
language?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ChapterInfo = {
|
|
||||||
name: string;
|
|
||||||
timeOffset: number;
|
|
||||||
duration: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type VlcPlayerViewProps = {
|
|
||||||
source: VlcPlayerSource;
|
|
||||||
style?: ViewStyle | ViewStyle[];
|
|
||||||
progressUpdateInterval?: number;
|
|
||||||
paused?: boolean;
|
|
||||||
muted?: boolean;
|
|
||||||
volume?: number;
|
|
||||||
videoAspectRatio?: string;
|
|
||||||
onVideoProgress?: (event: ProgressUpdatePayload) => void;
|
|
||||||
onVideoStateChange?: (event: PlaybackStatePayload) => void;
|
|
||||||
onVideoLoadStart?: (event: VideoLoadStartPayload) => void;
|
|
||||||
onVideoLoadEnd?: (event: VideoLoadStartPayload) => void;
|
|
||||||
onVideoError?: (event: PlaybackStatePayload) => void;
|
|
||||||
onPipStarted?: (event: PipStartedPayload) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface VlcPlayerViewRef {
|
|
||||||
startPictureInPicture: () => Promise<void>;
|
|
||||||
play: () => Promise<void>;
|
|
||||||
pause: () => Promise<void>;
|
|
||||||
stop: () => Promise<void>;
|
|
||||||
seekTo: (time: number) => Promise<void>;
|
|
||||||
setAudioTrack: (trackIndex: number) => Promise<void>;
|
|
||||||
getAudioTracks: () => Promise<TrackInfo[] | null>;
|
|
||||||
setSubtitleTrack: (trackIndex: number) => Promise<void>;
|
|
||||||
getSubtitleTracks: () => Promise<TrackInfo[] | null>;
|
|
||||||
setSubtitleDelay: (delay: number) => Promise<void>;
|
|
||||||
setAudioDelay: (delay: number) => Promise<void>;
|
|
||||||
takeSnapshot: (path: string, width: number, height: number) => Promise<void>;
|
|
||||||
setRate: (rate: number) => Promise<void>;
|
|
||||||
nextChapter: () => Promise<void>;
|
|
||||||
previousChapter: () => Promise<void>;
|
|
||||||
getChapters: () => Promise<ChapterInfo[] | null>;
|
|
||||||
setVideoCropGeometry: (cropGeometry: string | null) => Promise<void>;
|
|
||||||
getVideoCropGeometry: () => Promise<string | null>;
|
|
||||||
setSubtitleURL: (url: string) => Promise<void>;
|
|
||||||
setVideoAspectRatio: (aspectRatio: string | null) => Promise<void>;
|
|
||||||
setVideoScaleFactor: (scaleFactor: number) => Promise<void>;
|
|
||||||
}
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
import { requireNativeViewManager } from "expo-modules-core";
|
|
||||||
import * as React from "react";
|
|
||||||
import { ViewStyle } from "react-native";
|
|
||||||
import type {
|
|
||||||
VlcPlayerSource,
|
|
||||||
VlcPlayerViewProps,
|
|
||||||
VlcPlayerViewRef,
|
|
||||||
} from "./VlcPlayer.types";
|
|
||||||
|
|
||||||
interface NativeViewRef extends VlcPlayerViewRef {
|
|
||||||
setNativeProps?: (props: Partial<VlcPlayerViewProps>) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const VLCViewManager = requireNativeViewManager("VlcPlayer");
|
|
||||||
|
|
||||||
// Create a forwarded ref version of the native view
|
|
||||||
const NativeView = React.forwardRef<NativeViewRef, VlcPlayerViewProps>(
|
|
||||||
(props, ref) => {
|
|
||||||
return <VLCViewManager {...props} ref={ref} />;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
|
|
||||||
(props, ref) => {
|
|
||||||
const nativeRef = React.useRef<NativeViewRef>(null);
|
|
||||||
|
|
||||||
React.useImperativeHandle(ref, () => ({
|
|
||||||
startPictureInPicture: async () => {
|
|
||||||
await nativeRef.current?.startPictureInPicture();
|
|
||||||
},
|
|
||||||
play: async () => {
|
|
||||||
await nativeRef.current?.play();
|
|
||||||
},
|
|
||||||
pause: async () => {
|
|
||||||
await nativeRef.current?.pause();
|
|
||||||
},
|
|
||||||
stop: async () => {
|
|
||||||
await nativeRef.current?.stop();
|
|
||||||
},
|
|
||||||
seekTo: async (time: number) => {
|
|
||||||
await nativeRef.current?.seekTo(time);
|
|
||||||
},
|
|
||||||
setAudioTrack: async (trackIndex: number) => {
|
|
||||||
await nativeRef.current?.setAudioTrack(trackIndex);
|
|
||||||
},
|
|
||||||
getAudioTracks: async () => {
|
|
||||||
const tracks = await nativeRef.current?.getAudioTracks();
|
|
||||||
return tracks ?? null;
|
|
||||||
},
|
|
||||||
setSubtitleTrack: async (trackIndex: number) => {
|
|
||||||
await nativeRef.current?.setSubtitleTrack(trackIndex);
|
|
||||||
},
|
|
||||||
getSubtitleTracks: async () => {
|
|
||||||
const tracks = await nativeRef.current?.getSubtitleTracks();
|
|
||||||
return tracks ?? null;
|
|
||||||
},
|
|
||||||
setSubtitleDelay: async (delay: number) => {
|
|
||||||
await nativeRef.current?.setSubtitleDelay(delay);
|
|
||||||
},
|
|
||||||
setAudioDelay: async (delay: number) => {
|
|
||||||
await nativeRef.current?.setAudioDelay(delay);
|
|
||||||
},
|
|
||||||
takeSnapshot: async (path: string, width: number, height: number) => {
|
|
||||||
await nativeRef.current?.takeSnapshot(path, width, height);
|
|
||||||
},
|
|
||||||
setRate: async (rate: number) => {
|
|
||||||
await nativeRef.current?.setRate(rate);
|
|
||||||
},
|
|
||||||
nextChapter: async () => {
|
|
||||||
await nativeRef.current?.nextChapter();
|
|
||||||
},
|
|
||||||
previousChapter: async () => {
|
|
||||||
await nativeRef.current?.previousChapter();
|
|
||||||
},
|
|
||||||
getChapters: async () => {
|
|
||||||
const chapters = await nativeRef.current?.getChapters();
|
|
||||||
return chapters ?? null;
|
|
||||||
},
|
|
||||||
setVideoCropGeometry: async (geometry: string | null) => {
|
|
||||||
await nativeRef.current?.setVideoCropGeometry(geometry);
|
|
||||||
},
|
|
||||||
getVideoCropGeometry: async () => {
|
|
||||||
const geometry = await nativeRef.current?.getVideoCropGeometry();
|
|
||||||
return geometry ?? null;
|
|
||||||
},
|
|
||||||
setSubtitleURL: async (url: string) => {
|
|
||||||
await nativeRef.current?.setSubtitleURL(url);
|
|
||||||
},
|
|
||||||
setVideoAspectRatio: async (aspectRatio: string | null) => {
|
|
||||||
await nativeRef.current?.setVideoAspectRatio(aspectRatio);
|
|
||||||
},
|
|
||||||
setVideoScaleFactor: async (scaleFactor: number) => {
|
|
||||||
await nativeRef.current?.setVideoScaleFactor(scaleFactor);
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const {
|
|
||||||
source,
|
|
||||||
style,
|
|
||||||
progressUpdateInterval = 500,
|
|
||||||
paused,
|
|
||||||
muted,
|
|
||||||
volume,
|
|
||||||
videoAspectRatio,
|
|
||||||
onVideoLoadStart,
|
|
||||||
onVideoStateChange,
|
|
||||||
onVideoProgress,
|
|
||||||
onVideoLoadEnd,
|
|
||||||
onVideoError,
|
|
||||||
onPipStarted,
|
|
||||||
...otherProps
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
const processedSource: VlcPlayerSource =
|
|
||||||
typeof source === "string"
|
|
||||||
? ({ uri: source } as unknown as VlcPlayerSource)
|
|
||||||
: source;
|
|
||||||
|
|
||||||
if (processedSource.startPosition !== undefined) {
|
|
||||||
processedSource.startPosition = Math.floor(processedSource.startPosition);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NativeView
|
|
||||||
{...otherProps}
|
|
||||||
ref={nativeRef}
|
|
||||||
source={processedSource}
|
|
||||||
style={[{ width: "100%", height: "100%" }, style as ViewStyle]}
|
|
||||||
progressUpdateInterval={progressUpdateInterval}
|
|
||||||
paused={paused}
|
|
||||||
muted={muted}
|
|
||||||
volume={volume}
|
|
||||||
videoAspectRatio={videoAspectRatio}
|
|
||||||
onVideoLoadStart={onVideoLoadStart}
|
|
||||||
onVideoLoadEnd={onVideoLoadEnd}
|
|
||||||
onVideoStateChange={onVideoStateChange}
|
|
||||||
onVideoProgress={onVideoProgress}
|
|
||||||
onVideoError={onVideoError}
|
|
||||||
onPipStarted={onPipStarted}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export default VlcPlayerView;
|
|
||||||
@@ -1,17 +1,4 @@
|
|||||||
import type {
|
// Background Downloader
|
||||||
ChapterInfo,
|
|
||||||
PlaybackStatePayload,
|
|
||||||
ProgressUpdatePayload,
|
|
||||||
TrackInfo,
|
|
||||||
VideoLoadStartPayload,
|
|
||||||
VideoProgressPayload,
|
|
||||||
VideoStateChangePayload,
|
|
||||||
VlcPlayerSource,
|
|
||||||
VlcPlayerViewProps,
|
|
||||||
VlcPlayerViewRef,
|
|
||||||
} from "./VlcPlayer.types";
|
|
||||||
import VlcPlayerView from "./VlcPlayerView";
|
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
ActiveDownload,
|
ActiveDownload,
|
||||||
DownloadCompleteEvent,
|
DownloadCompleteEvent,
|
||||||
@@ -19,23 +6,21 @@ export type {
|
|||||||
DownloadProgressEvent,
|
DownloadProgressEvent,
|
||||||
DownloadStartedEvent,
|
DownloadStartedEvent,
|
||||||
} from "./background-downloader";
|
} from "./background-downloader";
|
||||||
// Background Downloader
|
|
||||||
export { default as BackgroundDownloader } from "./background-downloader";
|
export { default as BackgroundDownloader } from "./background-downloader";
|
||||||
|
// Type aliases for backward compatibility during migration
|
||||||
// Component
|
// These map old VLC type names to new MPV equivalents
|
||||||
export { VlcPlayerView };
|
|
||||||
|
|
||||||
// Component Types
|
|
||||||
export type { VlcPlayerViewProps, VlcPlayerViewRef };
|
|
||||||
|
|
||||||
// Media Types
|
|
||||||
export type { ChapterInfo, TrackInfo, VlcPlayerSource };
|
|
||||||
|
|
||||||
// Playback Events (alphabetically sorted)
|
|
||||||
export type {
|
export type {
|
||||||
PlaybackStatePayload,
|
AudioTrack,
|
||||||
ProgressUpdatePayload,
|
MpvPlayerViewProps,
|
||||||
VideoLoadStartPayload,
|
MpvPlayerViewRef,
|
||||||
VideoProgressPayload,
|
OnErrorEventPayload,
|
||||||
VideoStateChangePayload,
|
OnLoadEventPayload,
|
||||||
};
|
OnPlaybackStateChangePayload,
|
||||||
|
OnProgressEventPayload,
|
||||||
|
OnTracksReadyEventPayload,
|
||||||
|
SubtitleTrack,
|
||||||
|
SubtitleTrack as TrackInfo,
|
||||||
|
VideoSource,
|
||||||
|
} from "./mpv-player";
|
||||||
|
// MPV Player - Main exports
|
||||||
|
export { MpvPlayerView } from "./mpv-player";
|
||||||
|
|||||||
57
modules/mpv-player/android/build.gradle
Normal file
57
modules/mpv-player/android/build.gradle
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
apply plugin: 'com.android.library'
|
||||||
|
|
||||||
|
group = 'expo.modules.mpvplayer'
|
||||||
|
version = '0.7.6'
|
||||||
|
|
||||||
|
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
||||||
|
apply from: expoModulesCorePlugin
|
||||||
|
applyKotlinExpoModulesCorePlugin()
|
||||||
|
useCoreDependencies()
|
||||||
|
useExpoPublishing()
|
||||||
|
|
||||||
|
// If you want to use the managed Android SDK versions from expo-modules-core, set this to true.
|
||||||
|
// The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code.
|
||||||
|
// Most of the time, you may like to manage the Android SDK versions yourself.
|
||||||
|
def useManagedAndroidSdkVersions = false
|
||||||
|
if (useManagedAndroidSdkVersions) {
|
||||||
|
useDefaultAndroidSdkVersions()
|
||||||
|
} else {
|
||||||
|
buildscript {
|
||||||
|
// Simple helper that allows the root project to override versions declared by this library.
|
||||||
|
ext.safeExtGet = { prop, fallback ->
|
||||||
|
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
project.android {
|
||||||
|
compileSdkVersion safeExtGet("compileSdkVersion", 36)
|
||||||
|
defaultConfig {
|
||||||
|
minSdkVersion safeExtGet("minSdkVersion", 26)
|
||||||
|
targetSdkVersion safeExtGet("targetSdkVersion", 36)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace "expo.modules.mpvplayer"
|
||||||
|
defaultConfig {
|
||||||
|
versionCode 1
|
||||||
|
versionName "0.7.6"
|
||||||
|
ndk {
|
||||||
|
// Architectures supported by mpv-android
|
||||||
|
abiFilters 'arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lintOptions {
|
||||||
|
abortOnError false
|
||||||
|
}
|
||||||
|
sourceSets {
|
||||||
|
main {
|
||||||
|
jniLibs.srcDirs = ['libs']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// libmpv from Maven Central
|
||||||
|
implementation 'dev.jdtech.mpv:libmpv:0.5.1'
|
||||||
|
}
|
||||||
9
modules/mpv-player/android/src/main/AndroidManifest.xml
Normal file
9
modules/mpv-player/android/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- Required for network streaming -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
<!-- Picture-in-Picture feature -->
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.software.picture_in_picture"
|
||||||
|
android:required="false" />
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,543 @@
|
|||||||
|
package expo.modules.mpvplayer
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.Surface
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MPV renderer that wraps libmpv for video playback.
|
||||||
|
* This mirrors the iOS MPVLayerRenderer implementation.
|
||||||
|
*/
|
||||||
|
class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "MPVLayerRenderer"
|
||||||
|
|
||||||
|
// Property observation format types
|
||||||
|
const val MPV_FORMAT_NONE = 0
|
||||||
|
const val MPV_FORMAT_STRING = 1
|
||||||
|
const val MPV_FORMAT_OSD_STRING = 2
|
||||||
|
const val MPV_FORMAT_FLAG = 3
|
||||||
|
const val MPV_FORMAT_INT64 = 4
|
||||||
|
const val MPV_FORMAT_DOUBLE = 5
|
||||||
|
const val MPV_FORMAT_NODE = 6
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Delegate {
|
||||||
|
fun onPositionChanged(position: Double, duration: Double)
|
||||||
|
fun onPauseChanged(isPaused: Boolean)
|
||||||
|
fun onLoadingChanged(isLoading: Boolean)
|
||||||
|
fun onReadyToSeek()
|
||||||
|
fun onTracksReady()
|
||||||
|
fun onError(message: String)
|
||||||
|
fun onVideoDimensionsChanged(width: Int, height: Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
var delegate: Delegate? = null
|
||||||
|
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
|
private var surface: Surface? = null
|
||||||
|
private var isRunning = false
|
||||||
|
private var isStopping = false
|
||||||
|
|
||||||
|
// Cached state
|
||||||
|
private var cachedPosition: Double = 0.0
|
||||||
|
private var cachedDuration: Double = 0.0
|
||||||
|
private var _isPaused: Boolean = true
|
||||||
|
private var _isLoading: Boolean = false
|
||||||
|
private var _playbackSpeed: Double = 1.0
|
||||||
|
private var isReadyToSeek: Boolean = false
|
||||||
|
|
||||||
|
// Video dimensions
|
||||||
|
private var _videoWidth: Int = 0
|
||||||
|
private var _videoHeight: Int = 0
|
||||||
|
|
||||||
|
val videoWidth: Int
|
||||||
|
get() = _videoWidth
|
||||||
|
|
||||||
|
val videoHeight: Int
|
||||||
|
get() = _videoHeight
|
||||||
|
|
||||||
|
// Current video config
|
||||||
|
private var currentUrl: String? = null
|
||||||
|
private var currentHeaders: Map<String, String>? = null
|
||||||
|
private var pendingExternalSubtitles: List<String> = emptyList()
|
||||||
|
private var initialSubtitleId: Int? = null
|
||||||
|
private var initialAudioId: Int? = null
|
||||||
|
|
||||||
|
val isPausedState: Boolean
|
||||||
|
get() = _isPaused
|
||||||
|
|
||||||
|
val currentPosition: Double
|
||||||
|
get() = cachedPosition
|
||||||
|
|
||||||
|
val duration: Double
|
||||||
|
get() = cachedDuration
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
if (isRunning) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
MPVLib.create(context)
|
||||||
|
MPVLib.addObserver(this)
|
||||||
|
|
||||||
|
// Configure mpv options before initialization (based on Findroid)
|
||||||
|
MPVLib.setOptionString("vo", "gpu")
|
||||||
|
MPVLib.setOptionString("gpu-context", "android")
|
||||||
|
MPVLib.setOptionString("opengl-es", "yes")
|
||||||
|
|
||||||
|
// Hardware video decoding
|
||||||
|
MPVLib.setOptionString("hwdec", "mediacodec-copy")
|
||||||
|
MPVLib.setOptionString("hwdec-codecs", "h264,hevc,mpeg4,mpeg2video,vp8,vp9,av1")
|
||||||
|
|
||||||
|
// Cache settings for better network streaming
|
||||||
|
MPVLib.setOptionString("cache", "yes")
|
||||||
|
MPVLib.setOptionString("cache-pause-initial", "yes")
|
||||||
|
MPVLib.setOptionString("demuxer-max-bytes", "150MiB")
|
||||||
|
MPVLib.setOptionString("demuxer-max-back-bytes", "75MiB")
|
||||||
|
MPVLib.setOptionString("demuxer-readahead-secs", "20")
|
||||||
|
|
||||||
|
// Seeking optimization - faster seeking at the cost of less precision
|
||||||
|
// Use keyframe seeking by default (much faster for network streams)
|
||||||
|
MPVLib.setOptionString("hr-seek", "no")
|
||||||
|
// Drop frames during seeking for faster response
|
||||||
|
MPVLib.setOptionString("hr-seek-framedrop", "yes")
|
||||||
|
|
||||||
|
// Subtitle settings
|
||||||
|
MPVLib.setOptionString("sub-scale-with-window", "yes")
|
||||||
|
MPVLib.setOptionString("sub-use-margins", "no")
|
||||||
|
MPVLib.setOptionString("subs-match-os-language", "yes")
|
||||||
|
MPVLib.setOptionString("subs-fallback", "yes")
|
||||||
|
|
||||||
|
// Important: Start with force-window=no, will be set to yes when surface is attached
|
||||||
|
MPVLib.setOptionString("force-window", "no")
|
||||||
|
MPVLib.setOptionString("keep-open", "always")
|
||||||
|
|
||||||
|
MPVLib.initialize()
|
||||||
|
|
||||||
|
// Observe properties
|
||||||
|
observeProperties()
|
||||||
|
|
||||||
|
isRunning = true
|
||||||
|
Log.i(TAG, "MPV renderer started")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to start MPV renderer: ${e.message}")
|
||||||
|
delegate?.onError("Failed to start renderer: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
if (isStopping) return
|
||||||
|
if (!isRunning) return
|
||||||
|
|
||||||
|
isStopping = true
|
||||||
|
isRunning = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
MPVLib.removeObserver(this)
|
||||||
|
MPVLib.detachSurface()
|
||||||
|
MPVLib.destroy()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error stopping MPV: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
isStopping = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach surface and re-enable video output.
|
||||||
|
* Based on Findroid's implementation.
|
||||||
|
*/
|
||||||
|
fun attachSurface(surface: Surface) {
|
||||||
|
this.surface = surface
|
||||||
|
if (isRunning) {
|
||||||
|
MPVLib.attachSurface(surface)
|
||||||
|
// Re-enable video output after attaching surface (Findroid approach)
|
||||||
|
MPVLib.setOptionString("force-window", "yes")
|
||||||
|
MPVLib.setOptionString("vo", "gpu")
|
||||||
|
Log.i(TAG, "Surface attached, video output re-enabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detach surface and disable video output.
|
||||||
|
* Based on Findroid's implementation.
|
||||||
|
*/
|
||||||
|
fun detachSurface() {
|
||||||
|
this.surface = null
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the surface size. Called from surfaceChanged.
|
||||||
|
* Based on Findroid's implementation.
|
||||||
|
*/
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load(
|
||||||
|
url: String,
|
||||||
|
headers: Map<String, String>? = null,
|
||||||
|
startPosition: Double? = null,
|
||||||
|
externalSubtitles: List<String>? = null,
|
||||||
|
initialSubtitleId: Int? = null,
|
||||||
|
initialAudioId: Int? = null
|
||||||
|
) {
|
||||||
|
currentUrl = url
|
||||||
|
currentHeaders = headers
|
||||||
|
pendingExternalSubtitles = externalSubtitles ?: emptyList()
|
||||||
|
this.initialSubtitleId = initialSubtitleId
|
||||||
|
this.initialAudioId = initialAudioId
|
||||||
|
|
||||||
|
_isLoading = true
|
||||||
|
isReadyToSeek = false
|
||||||
|
mainHandler.post { delegate?.onLoadingChanged(true) }
|
||||||
|
|
||||||
|
// Stop previous playback
|
||||||
|
MPVLib.command(arrayOf("stop"))
|
||||||
|
|
||||||
|
// Set HTTP headers if provided
|
||||||
|
updateHttpHeaders(headers)
|
||||||
|
|
||||||
|
// Set start position
|
||||||
|
if (startPosition != null && startPosition > 0) {
|
||||||
|
MPVLib.setPropertyString("start", String.format("%.2f", startPosition))
|
||||||
|
} else {
|
||||||
|
MPVLib.setPropertyString("start", "0")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set initial audio track if specified
|
||||||
|
if (initialAudioId != null && initialAudioId > 0) {
|
||||||
|
setAudioTrack(initialAudioId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set initial subtitle track if no external subs
|
||||||
|
if (pendingExternalSubtitles.isEmpty()) {
|
||||||
|
if (initialSubtitleId != null) {
|
||||||
|
setSubtitleTrack(initialSubtitleId)
|
||||||
|
} else {
|
||||||
|
disableSubtitles()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
disableSubtitles()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the file
|
||||||
|
MPVLib.command(arrayOf("loadfile", url, "replace"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reloadCurrentItem() {
|
||||||
|
currentUrl?.let { url ->
|
||||||
|
load(url, currentHeaders)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateHttpHeaders(headers: Map<String, String>?) {
|
||||||
|
if (headers.isNullOrEmpty()) {
|
||||||
|
// Clear headers
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val headerString = headers.entries.joinToString("\r\n") { "${it.key}: ${it.value}" }
|
||||||
|
MPVLib.setPropertyString("http-header-fields", headerString)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeProperties() {
|
||||||
|
MPVLib.observeProperty("duration", MPV_FORMAT_DOUBLE)
|
||||||
|
MPVLib.observeProperty("time-pos", MPV_FORMAT_DOUBLE)
|
||||||
|
MPVLib.observeProperty("pause", MPV_FORMAT_FLAG)
|
||||||
|
MPVLib.observeProperty("track-list/count", MPV_FORMAT_INT64)
|
||||||
|
MPVLib.observeProperty("paused-for-cache", MPV_FORMAT_FLAG)
|
||||||
|
// Video dimensions for PiP aspect ratio
|
||||||
|
MPVLib.observeProperty("video-params/w", MPV_FORMAT_INT64)
|
||||||
|
MPVLib.observeProperty("video-params/h", MPV_FORMAT_INT64)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Playback Controls
|
||||||
|
|
||||||
|
fun play() {
|
||||||
|
MPVLib.setPropertyBoolean("pause", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pause() {
|
||||||
|
MPVLib.setPropertyBoolean("pause", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun togglePause() {
|
||||||
|
if (_isPaused) play() else pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun seekTo(seconds: Double) {
|
||||||
|
val clamped = maxOf(0.0, seconds)
|
||||||
|
cachedPosition = clamped
|
||||||
|
MPVLib.command(arrayOf("seek", clamped.toString(), "absolute"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun seekBy(seconds: Double) {
|
||||||
|
val newPosition = maxOf(0.0, cachedPosition + seconds)
|
||||||
|
cachedPosition = newPosition
|
||||||
|
MPVLib.command(arrayOf("seek", seconds.toString(), "relative"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSpeed(speed: Double) {
|
||||||
|
_playbackSpeed = speed
|
||||||
|
MPVLib.setPropertyDouble("speed", speed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSpeed(): Double {
|
||||||
|
return MPVLib.getPropertyDouble("speed") ?: _playbackSpeed
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Subtitle Controls
|
||||||
|
|
||||||
|
fun getSubtitleTracks(): List<Map<String, Any>> {
|
||||||
|
val tracks = mutableListOf<Map<String, Any>>()
|
||||||
|
|
||||||
|
val trackCount = MPVLib.getPropertyInt("track-list/count") ?: 0
|
||||||
|
|
||||||
|
for (i in 0 until trackCount) {
|
||||||
|
val trackType = MPVLib.getPropertyString("track-list/$i/type") ?: continue
|
||||||
|
if (trackType != "sub") continue
|
||||||
|
|
||||||
|
val trackId = MPVLib.getPropertyInt("track-list/$i/id") ?: continue
|
||||||
|
val track = mutableMapOf<String, Any>("id" to trackId)
|
||||||
|
|
||||||
|
MPVLib.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
|
||||||
|
MPVLib.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
|
||||||
|
|
||||||
|
val selected = MPVLib.getPropertyBoolean("track-list/$i/selected") ?: false
|
||||||
|
track["selected"] = selected
|
||||||
|
|
||||||
|
tracks.add(track)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tracks
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSubtitleTrack(trackId: Int) {
|
||||||
|
Log.i(TAG, "setSubtitleTrack: setting sid to $trackId")
|
||||||
|
if (trackId < 0) {
|
||||||
|
MPVLib.setPropertyString("sid", "no")
|
||||||
|
} else {
|
||||||
|
MPVLib.setPropertyInt("sid", trackId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disableSubtitles() {
|
||||||
|
MPVLib.setPropertyString("sid", "no")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCurrentSubtitleTrack(): Int {
|
||||||
|
return MPVLib.getPropertyInt("sid") ?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addSubtitleFile(url: String, select: Boolean = true) {
|
||||||
|
val flag = if (select) "select" else "cached"
|
||||||
|
MPVLib.command(arrayOf("sub-add", url, flag))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Subtitle Positioning
|
||||||
|
|
||||||
|
fun setSubtitlePosition(position: Int) {
|
||||||
|
MPVLib.setPropertyInt("sub-pos", position)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSubtitleScale(scale: Double) {
|
||||||
|
MPVLib.setPropertyDouble("sub-scale", scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSubtitleMarginY(margin: Int) {
|
||||||
|
MPVLib.setPropertyInt("sub-margin-y", margin)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSubtitleAlignX(alignment: String) {
|
||||||
|
MPVLib.setPropertyString("sub-align-x", alignment)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSubtitleAlignY(alignment: String) {
|
||||||
|
MPVLib.setPropertyString("sub-align-y", alignment)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSubtitleFontSize(size: Int) {
|
||||||
|
MPVLib.setPropertyInt("sub-font-size", size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Audio Track Controls
|
||||||
|
|
||||||
|
fun getAudioTracks(): List<Map<String, Any>> {
|
||||||
|
val tracks = mutableListOf<Map<String, Any>>()
|
||||||
|
|
||||||
|
val trackCount = MPVLib.getPropertyInt("track-list/count") ?: 0
|
||||||
|
|
||||||
|
for (i in 0 until trackCount) {
|
||||||
|
val trackType = MPVLib.getPropertyString("track-list/$i/type") ?: continue
|
||||||
|
if (trackType != "audio") continue
|
||||||
|
|
||||||
|
val trackId = MPVLib.getPropertyInt("track-list/$i/id") ?: continue
|
||||||
|
val track = mutableMapOf<String, Any>("id" to trackId)
|
||||||
|
|
||||||
|
MPVLib.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
|
||||||
|
MPVLib.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
|
||||||
|
MPVLib.getPropertyString("track-list/$i/codec")?.let { track["codec"] = it }
|
||||||
|
|
||||||
|
val channels = MPVLib.getPropertyInt("track-list/$i/audio-channels")
|
||||||
|
if (channels != null && channels > 0) {
|
||||||
|
track["channels"] = channels
|
||||||
|
}
|
||||||
|
|
||||||
|
val selected = MPVLib.getPropertyBoolean("track-list/$i/selected") ?: false
|
||||||
|
track["selected"] = selected
|
||||||
|
|
||||||
|
tracks.add(track)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tracks
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAudioTrack(trackId: Int) {
|
||||||
|
Log.i(TAG, "setAudioTrack: setting aid to $trackId")
|
||||||
|
MPVLib.setPropertyInt("aid", trackId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCurrentAudioTrack(): Int {
|
||||||
|
return MPVLib.getPropertyInt("aid") ?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - MPVLib.EventObserver
|
||||||
|
|
||||||
|
override fun eventProperty(property: String) {
|
||||||
|
// Property changed but no value provided
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun eventProperty(property: String, value: Long) {
|
||||||
|
when (property) {
|
||||||
|
"track-list/count" -> {
|
||||||
|
if (value > 0) {
|
||||||
|
Log.i(TAG, "Track list updated: $value tracks available")
|
||||||
|
mainHandler.post { delegate?.onTracksReady() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"video-params/w" -> {
|
||||||
|
val width = value.toInt()
|
||||||
|
if (width > 0 && width != _videoWidth) {
|
||||||
|
_videoWidth = width
|
||||||
|
notifyVideoDimensionsIfReady()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"video-params/h" -> {
|
||||||
|
val height = value.toInt()
|
||||||
|
if (height > 0 && height != _videoHeight) {
|
||||||
|
_videoHeight = height
|
||||||
|
notifyVideoDimensionsIfReady()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notifyVideoDimensionsIfReady() {
|
||||||
|
if (_videoWidth > 0 && _videoHeight > 0) {
|
||||||
|
Log.i(TAG, "Video dimensions: ${_videoWidth}x${_videoHeight}")
|
||||||
|
mainHandler.post { delegate?.onVideoDimensionsChanged(_videoWidth, _videoHeight) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun eventProperty(property: String, value: Boolean) {
|
||||||
|
when (property) {
|
||||||
|
"pause" -> {
|
||||||
|
if (value != _isPaused) {
|
||||||
|
_isPaused = value
|
||||||
|
mainHandler.post { delegate?.onPauseChanged(value) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"paused-for-cache" -> {
|
||||||
|
if (value != _isLoading) {
|
||||||
|
_isLoading = value
|
||||||
|
mainHandler.post { delegate?.onLoadingChanged(value) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun eventProperty(property: String, value: String) {
|
||||||
|
// Handle string properties if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun eventProperty(property: String, value: Double) {
|
||||||
|
when (property) {
|
||||||
|
"duration" -> {
|
||||||
|
cachedDuration = value
|
||||||
|
mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration) }
|
||||||
|
}
|
||||||
|
"time-pos" -> {
|
||||||
|
cachedPosition = value
|
||||||
|
mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun event(eventId: Int) {
|
||||||
|
when (eventId) {
|
||||||
|
MPVLib.MPV_EVENT_FILE_LOADED -> {
|
||||||
|
// Add external subtitles now that file is loaded
|
||||||
|
if (pendingExternalSubtitles.isNotEmpty()) {
|
||||||
|
for (subUrl in pendingExternalSubtitles) {
|
||||||
|
MPVLib.command(arrayOf("sub-add", subUrl))
|
||||||
|
}
|
||||||
|
pendingExternalSubtitles = emptyList()
|
||||||
|
|
||||||
|
// Set subtitle after external subs are added
|
||||||
|
initialSubtitleId?.let { setSubtitleTrack(it) } ?: disableSubtitles()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isReadyToSeek) {
|
||||||
|
isReadyToSeek = true
|
||||||
|
mainHandler.post { delegate?.onReadyToSeek() }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_isLoading) {
|
||||||
|
_isLoading = false
|
||||||
|
mainHandler.post { delegate?.onLoadingChanged(false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MPVLib.MPV_EVENT_SEEK -> {
|
||||||
|
// Seek started - show loading indicator
|
||||||
|
if (!_isLoading) {
|
||||||
|
_isLoading = true
|
||||||
|
mainHandler.post { delegate?.onLoadingChanged(true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MPVLib.MPV_EVENT_PLAYBACK_RESTART -> {
|
||||||
|
// Video playback has started/restarted (including after seek)
|
||||||
|
if (_isLoading) {
|
||||||
|
_isLoading = false
|
||||||
|
mainHandler.post { delegate?.onLoadingChanged(false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MPVLib.MPV_EVENT_END_FILE -> {
|
||||||
|
Log.i(TAG, "Playback ended")
|
||||||
|
}
|
||||||
|
MPVLib.MPV_EVENT_SHUTDOWN -> {
|
||||||
|
Log.w(TAG, "MPV shutdown")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
package expo.modules.mpvplayer
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.Surface
|
||||||
|
import dev.jdtech.mpv.MPVLib as LibMPV
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper around the dev.jdtech.mpv.MPVLib class.
|
||||||
|
* This provides a consistent interface for the rest of the app.
|
||||||
|
*/
|
||||||
|
object MPVLib {
|
||||||
|
private const val TAG = "MPVLib"
|
||||||
|
|
||||||
|
private var initialized = false
|
||||||
|
|
||||||
|
// Event observer interface
|
||||||
|
interface EventObserver {
|
||||||
|
fun eventProperty(property: String)
|
||||||
|
fun eventProperty(property: String, value: Long)
|
||||||
|
fun eventProperty(property: String, value: Boolean)
|
||||||
|
fun eventProperty(property: String, value: String)
|
||||||
|
fun eventProperty(property: String, value: Double)
|
||||||
|
fun event(eventId: Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val observers = mutableListOf<EventObserver>()
|
||||||
|
|
||||||
|
// Library event observer that forwards to our observers
|
||||||
|
private val libObserver = object : LibMPV.EventObserver {
|
||||||
|
override fun eventProperty(property: String) {
|
||||||
|
synchronized(observers) {
|
||||||
|
for (observer in observers) {
|
||||||
|
observer.eventProperty(property)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun eventProperty(property: String, value: Long) {
|
||||||
|
synchronized(observers) {
|
||||||
|
for (observer in observers) {
|
||||||
|
observer.eventProperty(property, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun eventProperty(property: String, value: Boolean) {
|
||||||
|
synchronized(observers) {
|
||||||
|
for (observer in observers) {
|
||||||
|
observer.eventProperty(property, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun eventProperty(property: String, value: String) {
|
||||||
|
synchronized(observers) {
|
||||||
|
for (observer in observers) {
|
||||||
|
observer.eventProperty(property, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun eventProperty(property: String, value: Double) {
|
||||||
|
synchronized(observers) {
|
||||||
|
for (observer in observers) {
|
||||||
|
observer.eventProperty(property, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun event(eventId: Int) {
|
||||||
|
synchronized(observers) {
|
||||||
|
for (observer in observers) {
|
||||||
|
observer.event(eventId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addObserver(observer: EventObserver) {
|
||||||
|
synchronized(observers) {
|
||||||
|
observers.add(observer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeObserver(observer: EventObserver) {
|
||||||
|
synchronized(observers) {
|
||||||
|
observers.remove(observer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MPV Event IDs
|
||||||
|
const val MPV_EVENT_NONE = 0
|
||||||
|
const val MPV_EVENT_SHUTDOWN = 1
|
||||||
|
const val MPV_EVENT_LOG_MESSAGE = 2
|
||||||
|
const val MPV_EVENT_GET_PROPERTY_REPLY = 3
|
||||||
|
const val MPV_EVENT_SET_PROPERTY_REPLY = 4
|
||||||
|
const val MPV_EVENT_COMMAND_REPLY = 5
|
||||||
|
const val MPV_EVENT_START_FILE = 6
|
||||||
|
const val MPV_EVENT_END_FILE = 7
|
||||||
|
const val MPV_EVENT_FILE_LOADED = 8
|
||||||
|
const val MPV_EVENT_IDLE = 11
|
||||||
|
const val MPV_EVENT_TICK = 14
|
||||||
|
const val MPV_EVENT_CLIENT_MESSAGE = 16
|
||||||
|
const val MPV_EVENT_VIDEO_RECONFIG = 17
|
||||||
|
const val MPV_EVENT_AUDIO_RECONFIG = 18
|
||||||
|
const val MPV_EVENT_SEEK = 20
|
||||||
|
const val MPV_EVENT_PLAYBACK_RESTART = 21
|
||||||
|
const val MPV_EVENT_PROPERTY_CHANGE = 22
|
||||||
|
const val MPV_EVENT_QUEUE_OVERFLOW = 24
|
||||||
|
|
||||||
|
// End file reason
|
||||||
|
const val MPV_END_FILE_REASON_EOF = 0
|
||||||
|
const val MPV_END_FILE_REASON_STOP = 2
|
||||||
|
const val MPV_END_FILE_REASON_QUIT = 3
|
||||||
|
const val MPV_END_FILE_REASON_ERROR = 4
|
||||||
|
const val MPV_END_FILE_REASON_REDIRECT = 5
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and initialize the MPV library
|
||||||
|
*/
|
||||||
|
fun create(context: Context, configDir: String? = null) {
|
||||||
|
if (initialized) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
LibMPV.create(context)
|
||||||
|
LibMPV.addObserver(libObserver)
|
||||||
|
initialized = true
|
||||||
|
Log.i(TAG, "libmpv created successfully")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to create libmpv: ${e.message}")
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun initialize() {
|
||||||
|
LibMPV.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun destroy() {
|
||||||
|
if (!initialized) return
|
||||||
|
try {
|
||||||
|
LibMPV.removeObserver(libObserver)
|
||||||
|
LibMPV.destroy()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error destroying mpv: ${e.message}")
|
||||||
|
}
|
||||||
|
initialized = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isInitialized(): Boolean = initialized
|
||||||
|
|
||||||
|
fun attachSurface(surface: Surface) {
|
||||||
|
LibMPV.attachSurface(surface)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun detachSurface() {
|
||||||
|
LibMPV.detachSurface()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun command(cmd: Array<String?>) {
|
||||||
|
LibMPV.command(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setOptionString(name: String, value: String): Int {
|
||||||
|
return LibMPV.setOptionString(name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPropertyInt(name: String): Int? {
|
||||||
|
return try {
|
||||||
|
LibMPV.getPropertyInt(name)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPropertyDouble(name: String): Double? {
|
||||||
|
return try {
|
||||||
|
LibMPV.getPropertyDouble(name)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPropertyBoolean(name: String): Boolean? {
|
||||||
|
return try {
|
||||||
|
LibMPV.getPropertyBoolean(name)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPropertyString(name: String): String? {
|
||||||
|
return try {
|
||||||
|
LibMPV.getPropertyString(name)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPropertyInt(name: String, value: Int) {
|
||||||
|
LibMPV.setPropertyInt(name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPropertyDouble(name: String, value: Double) {
|
||||||
|
LibMPV.setPropertyDouble(name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPropertyBoolean(name: String, value: Boolean) {
|
||||||
|
LibMPV.setPropertyBoolean(name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPropertyString(name: String, value: String) {
|
||||||
|
LibMPV.setPropertyString(name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun observeProperty(name: String, format: Int) {
|
||||||
|
LibMPV.observeProperty(name, format)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package expo.modules.mpvplayer
|
||||||
|
|
||||||
|
import expo.modules.kotlin.modules.Module
|
||||||
|
import expo.modules.kotlin.modules.ModuleDefinition
|
||||||
|
|
||||||
|
class MpvPlayerModule : Module() {
|
||||||
|
override fun definition() = ModuleDefinition {
|
||||||
|
Name("MpvPlayer")
|
||||||
|
|
||||||
|
// Defines event names that the module can send to JavaScript.
|
||||||
|
Events("onChange")
|
||||||
|
|
||||||
|
// Defines a JavaScript synchronous function that runs the native code on the JavaScript thread.
|
||||||
|
Function("hello") {
|
||||||
|
"Hello from MPV Player! 👋"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defines a JavaScript function that always returns a Promise and whose native code
|
||||||
|
// is by default dispatched on the different thread than the JavaScript runtime runs on.
|
||||||
|
AsyncFunction("setValueAsync") { value: String ->
|
||||||
|
sendEvent("onChange", mapOf("value" to value))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enables the module to be used as a native view.
|
||||||
|
View(MpvPlayerView::class) {
|
||||||
|
// All video load options are passed via a single "source" prop
|
||||||
|
Prop("source") { view: MpvPlayerView, source: Map<String, Any?>? ->
|
||||||
|
if (source == null) return@Prop
|
||||||
|
|
||||||
|
val urlString = source["url"] as? String ?: return@Prop
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val config = VideoLoadConfig(
|
||||||
|
url = urlString,
|
||||||
|
headers = source["headers"] as? Map<String, String>,
|
||||||
|
externalSubtitles = source["externalSubtitles"] as? List<String>,
|
||||||
|
startPosition = (source["startPosition"] as? Number)?.toDouble(),
|
||||||
|
autoplay = (source["autoplay"] as? Boolean) ?: true,
|
||||||
|
initialSubtitleId = (source["initialSubtitleId"] as? Number)?.toInt(),
|
||||||
|
initialAudioId = (source["initialAudioId"] as? Number)?.toInt()
|
||||||
|
)
|
||||||
|
|
||||||
|
view.loadVideo(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Async function to play video
|
||||||
|
AsyncFunction("play") { view: MpvPlayerView ->
|
||||||
|
view.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Async function to pause video
|
||||||
|
AsyncFunction("pause") { view: MpvPlayerView ->
|
||||||
|
view.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Async function to seek to position
|
||||||
|
AsyncFunction("seekTo") { view: MpvPlayerView, position: Double ->
|
||||||
|
view.seekTo(position)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Async function to seek by offset
|
||||||
|
AsyncFunction("seekBy") { view: MpvPlayerView, offset: Double ->
|
||||||
|
view.seekBy(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Async function to set playback speed
|
||||||
|
AsyncFunction("setSpeed") { view: MpvPlayerView, speed: Double ->
|
||||||
|
view.setSpeed(speed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to get current speed
|
||||||
|
AsyncFunction("getSpeed") { view: MpvPlayerView ->
|
||||||
|
view.getSpeed()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to check if paused
|
||||||
|
AsyncFunction("isPaused") { view: MpvPlayerView ->
|
||||||
|
view.isPaused()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to get current position
|
||||||
|
AsyncFunction("getCurrentPosition") { view: MpvPlayerView ->
|
||||||
|
view.getCurrentPosition()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to get duration
|
||||||
|
AsyncFunction("getDuration") { view: MpvPlayerView ->
|
||||||
|
view.getDuration()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Picture in Picture functions
|
||||||
|
AsyncFunction("startPictureInPicture") { view: MpvPlayerView ->
|
||||||
|
view.startPictureInPicture()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("stopPictureInPicture") { view: MpvPlayerView ->
|
||||||
|
view.stopPictureInPicture()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("isPictureInPictureSupported") { view: MpvPlayerView ->
|
||||||
|
view.isPictureInPictureSupported()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("isPictureInPictureActive") { view: MpvPlayerView ->
|
||||||
|
view.isPictureInPictureActive()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subtitle functions
|
||||||
|
AsyncFunction("getSubtitleTracks") { view: MpvPlayerView ->
|
||||||
|
view.getSubtitleTracks()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleTrack") { view: MpvPlayerView, trackId: Int ->
|
||||||
|
view.setSubtitleTrack(trackId)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("disableSubtitles") { view: MpvPlayerView ->
|
||||||
|
view.disableSubtitles()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("getCurrentSubtitleTrack") { view: MpvPlayerView ->
|
||||||
|
view.getCurrentSubtitleTrack()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("addSubtitleFile") { view: MpvPlayerView, url: String, select: Boolean ->
|
||||||
|
view.addSubtitleFile(url, select)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subtitle positioning functions
|
||||||
|
AsyncFunction("setSubtitlePosition") { view: MpvPlayerView, position: Int ->
|
||||||
|
view.setSubtitlePosition(position)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleScale") { view: MpvPlayerView, scale: Double ->
|
||||||
|
view.setSubtitleScale(scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleMarginY") { view: MpvPlayerView, margin: Int ->
|
||||||
|
view.setSubtitleMarginY(margin)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleAlignX") { view: MpvPlayerView, alignment: String ->
|
||||||
|
view.setSubtitleAlignX(alignment)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleAlignY") { view: MpvPlayerView, alignment: String ->
|
||||||
|
view.setSubtitleAlignY(alignment)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleFontSize") { view: MpvPlayerView, size: Int ->
|
||||||
|
view.setSubtitleFontSize(size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio track functions
|
||||||
|
AsyncFunction("getAudioTracks") { view: MpvPlayerView ->
|
||||||
|
view.getAudioTracks()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setAudioTrack") { view: MpvPlayerView, trackId: Int ->
|
||||||
|
view.setAudioTrack(trackId)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("getCurrentAudioTrack") { view: MpvPlayerView ->
|
||||||
|
view.getCurrentAudioTrack()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defines events that the view can send to JavaScript
|
||||||
|
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
package expo.modules.mpvplayer
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.SurfaceHolder
|
||||||
|
import android.view.SurfaceView
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import expo.modules.kotlin.AppContext
|
||||||
|
import expo.modules.kotlin.viewevent.EventDispatcher
|
||||||
|
import expo.modules.kotlin.views.ExpoView
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for loading a video
|
||||||
|
*/
|
||||||
|
data class VideoLoadConfig(
|
||||||
|
val url: String,
|
||||||
|
val headers: Map<String, String>? = null,
|
||||||
|
val externalSubtitles: List<String>? = null,
|
||||||
|
val startPosition: Double? = null,
|
||||||
|
val autoplay: Boolean = true,
|
||||||
|
val initialSubtitleId: Int? = null,
|
||||||
|
val initialAudioId: Int? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MpvPlayerView - ExpoView that hosts the MPV player.
|
||||||
|
* This mirrors the iOS MpvPlayerView implementation.
|
||||||
|
*/
|
||||||
|
class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext),
|
||||||
|
MPVLayerRenderer.Delegate, SurfaceHolder.Callback {
|
||||||
|
|
||||||
|
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
|
||||||
|
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
|
||||||
|
private var intendedPlayState: Boolean = false
|
||||||
|
private var surfaceReady: Boolean = false
|
||||||
|
private var pendingConfig: VideoLoadConfig? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
setBackgroundColor(Color.BLACK)
|
||||||
|
|
||||||
|
// Create SurfaceView for video rendering
|
||||||
|
surfaceView = SurfaceView(context).apply {
|
||||||
|
layoutParams = FrameLayout.LayoutParams(
|
||||||
|
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||||
|
FrameLayout.LayoutParams.MATCH_PARENT
|
||||||
|
)
|
||||||
|
holder.addCallback(this@MpvPlayerView)
|
||||||
|
}
|
||||||
|
addView(surfaceView)
|
||||||
|
|
||||||
|
// Initialize renderer
|
||||||
|
renderer = MPVLayerRenderer(context)
|
||||||
|
renderer?.delegate = this
|
||||||
|
|
||||||
|
// Initialize PiP controller with Expo's AppContext for proper activity access
|
||||||
|
pipController = PiPController(context, appContext)
|
||||||
|
pipController?.setPlayerView(surfaceView)
|
||||||
|
pipController?.delegate = object : PiPController.Delegate {
|
||||||
|
override fun onPlay() {
|
||||||
|
play()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSeekBy(seconds: Double) {
|
||||||
|
seekBy(seconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the renderer
|
||||||
|
try {
|
||||||
|
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")
|
||||||
|
surfaceReady = true
|
||||||
|
renderer?.attachSurface(holder.surface)
|
||||||
|
|
||||||
|
// If we have a pending load, execute it now
|
||||||
|
pendingConfig?.let { config ->
|
||||||
|
loadVideoInternal(config)
|
||||||
|
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)
|
||||||
|
renderer?.updateSurfaceSize(width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||||
|
Log.i(TAG, "Surface destroyed")
|
||||||
|
surfaceReady = false
|
||||||
|
renderer?.detachSurface()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Video Loading
|
||||||
|
|
||||||
|
fun loadVideo(config: VideoLoadConfig) {
|
||||||
|
// Skip reload if same URL is already playing
|
||||||
|
if (currentUrl == config.url) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!surfaceReady) {
|
||||||
|
// Surface not ready, store config and load when ready
|
||||||
|
pendingConfig = config
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loadVideoInternal(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadVideoInternal(config: VideoLoadConfig) {
|
||||||
|
currentUrl = config.url
|
||||||
|
|
||||||
|
renderer?.load(
|
||||||
|
url = config.url,
|
||||||
|
headers = config.headers,
|
||||||
|
startPosition = config.startPosition,
|
||||||
|
externalSubtitles = config.externalSubtitles,
|
||||||
|
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")
|
||||||
|
pipController?.startPictureInPicture()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopPictureInPicture() {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - MPVLayerRenderer.Delegate
|
||||||
|
|
||||||
|
override fun onPositionChanged(position: Double, duration: Double) {
|
||||||
|
cachedPosition = position
|
||||||
|
cachedDuration = duration
|
||||||
|
|
||||||
|
// Update PiP progress
|
||||||
|
if (pipController?.isPictureInPictureActive() == true) {
|
||||||
|
pipController?.setCurrentTime(position, duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress(mapOf(
|
||||||
|
"position" to position,
|
||||||
|
"duration" to duration,
|
||||||
|
"progress" to if (duration > 0) position / duration else 0.0
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
pipController?.stopPictureInPicture()
|
||||||
|
renderer?.stop()
|
||||||
|
surfaceView.holder.removeCallback(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetachedFromWindow() {
|
||||||
|
super.onDetachedFromWindow()
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
package expo.modules.mpvplayer
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.app.PictureInPictureParams
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.graphics.Rect
|
||||||
|
import android.os.Build
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Delegate {
|
||||||
|
fun onPlay()
|
||||||
|
fun onPause()
|
||||||
|
fun onSeekBy(seconds: Double)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
fun isPictureInPictureSupported(): Boolean {
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Picture-in-Picture is currently active
|
||||||
|
*/
|
||||||
|
fun isPictureInPictureActive(): Boolean {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val activity = getActivity()
|
||||||
|
return activity?.isInPictureInPictureMode ?: false
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
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 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 (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val activity = getActivity()
|
||||||
|
if (activity?.isInPictureInPictureMode == true) {
|
||||||
|
try {
|
||||||
|
activity.setPictureInPictureParams(buildPiPParams())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to update PiP params: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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()
|
||||||
|
if (activity?.isInPictureInPictureMode == true) {
|
||||||
|
try {
|
||||||
|
activity.setPictureInPictureParams(buildPiPParams())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to update PiP params: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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()))
|
||||||
|
val aspectRatio = if (videoWidth > 0 && videoHeight > 0) {
|
||||||
|
Rational(
|
||||||
|
videoWidth.coerceAtMost((videoHeight * 2.39f).toInt()),
|
||||||
|
videoHeight.coerceAtMost((videoWidth * 2.39f).toInt())
|
||||||
|
)
|
||||||
|
} 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
|
||||||
|
)
|
||||||
|
} 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
val builder = PictureInPictureParams.Builder()
|
||||||
|
.setAspectRatio(aspectRatio)
|
||||||
|
|
||||||
|
sourceRectHint?.let { builder.setSourceRectHint(it) }
|
||||||
|
|
||||||
|
// On Android 12+, enable auto-enter (like Findroid)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
builder.setAutoEnterEnabled(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
9
modules/mpv-player/expo-module.config.json
Normal file
9
modules/mpv-player/expo-module.config.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"platforms": ["apple", "android", "web"],
|
||||||
|
"apple": {
|
||||||
|
"modules": ["MpvPlayerModule"]
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"modules": ["expo.modules.mpvplayer.MpvPlayerModule"]
|
||||||
|
}
|
||||||
|
}
|
||||||
6
modules/mpv-player/index.ts
Normal file
6
modules/mpv-player/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// Reexport the native module. On web, it will be resolved to MpvPlayerModule.web.ts
|
||||||
|
// and on native platforms to MpvPlayerModule.ts
|
||||||
|
|
||||||
|
export * from "./src/MpvPlayer.types";
|
||||||
|
export { default } from "./src/MpvPlayerModule";
|
||||||
|
export { default as MpvPlayerView } from "./src/MpvPlayerView";
|
||||||
154
modules/mpv-player/ios/Logger.swift
Normal file
154
modules/mpv-player/ios/Logger.swift
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
class Logger {
|
||||||
|
static let shared = Logger()
|
||||||
|
|
||||||
|
struct LogEntry {
|
||||||
|
let message: String
|
||||||
|
let type: String
|
||||||
|
let timestamp: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
private let queue = DispatchQueue(label: "mpvkit.logger", attributes: .concurrent)
|
||||||
|
private var logs: [LogEntry] = []
|
||||||
|
private let logFileURL: URL
|
||||||
|
|
||||||
|
private let maxFileSize = 1024 * 512
|
||||||
|
private let maxLogEntries = 1000
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
let tmpDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||||
|
logFileURL = tmpDir.appendingPathComponent("logs.txt")
|
||||||
|
}
|
||||||
|
|
||||||
|
func log(_ message: String, type: String = "General") {
|
||||||
|
let entry = LogEntry(message: message, type: type, timestamp: Date())
|
||||||
|
|
||||||
|
queue.async(flags: .barrier) {
|
||||||
|
self.logs.append(entry)
|
||||||
|
|
||||||
|
if self.logs.count > self.maxLogEntries {
|
||||||
|
self.logs.removeFirst(self.logs.count - self.maxLogEntries)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.saveLogToFile(entry)
|
||||||
|
self.debugLog(entry)
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
NotificationCenter.default.post(name: NSNotification.Name("LoggerNotification"), object: nil,
|
||||||
|
userInfo: [
|
||||||
|
"message": message,
|
||||||
|
"type": type,
|
||||||
|
"timestamp": entry.timestamp
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getLogs() -> String {
|
||||||
|
var result = ""
|
||||||
|
queue.sync {
|
||||||
|
let dateFormatter = DateFormatter()
|
||||||
|
dateFormatter.dateFormat = "dd-MM HH:mm:ss"
|
||||||
|
result = logs.map { "[\(dateFormatter.string(from: $0.timestamp))] [\($0.type)] \($0.message)" }
|
||||||
|
.joined(separator: "\n----\n")
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func getLogsAsync() async -> String {
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
|
queue.async {
|
||||||
|
let dateFormatter = DateFormatter()
|
||||||
|
dateFormatter.dateFormat = "dd-MM HH:mm:ss"
|
||||||
|
let result = self.logs.map { "[\(dateFormatter.string(from: $0.timestamp))] [\($0.type)] \($0.message)" }
|
||||||
|
.joined(separator: "\n----\n")
|
||||||
|
continuation.resume(returning: result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearLogs() {
|
||||||
|
queue.async(flags: .barrier) {
|
||||||
|
self.logs.removeAll()
|
||||||
|
try? FileManager.default.removeItem(at: self.logFileURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearLogsAsync() async {
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
queue.async(flags: .barrier) {
|
||||||
|
self.logs.removeAll()
|
||||||
|
try? FileManager.default.removeItem(at: self.logFileURL)
|
||||||
|
continuation.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveLogToFile(_ log: LogEntry) {
|
||||||
|
let dateFormatter = DateFormatter()
|
||||||
|
dateFormatter.dateFormat = "dd-MM HH:mm:ss"
|
||||||
|
|
||||||
|
let logString = "[\(dateFormatter.string(from: log.timestamp))] [\(log.type)] \(log.message)\n---\n"
|
||||||
|
|
||||||
|
guard let data = logString.data(using: .utf8) else {
|
||||||
|
print("Failed to encode log string to UTF-8")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
if FileManager.default.fileExists(atPath: logFileURL.path) {
|
||||||
|
let attributes = try FileManager.default.attributesOfItem(atPath: logFileURL.path)
|
||||||
|
let fileSize = attributes[.size] as? UInt64 ?? 0
|
||||||
|
|
||||||
|
if fileSize + UInt64(data.count) > maxFileSize {
|
||||||
|
self.truncateLogFile()
|
||||||
|
}
|
||||||
|
|
||||||
|
if let handle = try? FileHandle(forWritingTo: logFileURL) {
|
||||||
|
handle.seekToEndOfFile()
|
||||||
|
handle.write(data)
|
||||||
|
handle.closeFile()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try data.write(to: logFileURL)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("Error managing log file: \(error)")
|
||||||
|
try? data.write(to: logFileURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func truncateLogFile() {
|
||||||
|
do {
|
||||||
|
guard let content = try? String(contentsOf: logFileURL, encoding: .utf8),
|
||||||
|
!content.isEmpty else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries = content.components(separatedBy: "\n---\n")
|
||||||
|
guard entries.count > 10 else { return }
|
||||||
|
|
||||||
|
let keepCount = entries.count / 2
|
||||||
|
let truncatedEntries = Array(entries.suffix(keepCount))
|
||||||
|
let truncatedContent = truncatedEntries.joined(separator: "\n---\n")
|
||||||
|
|
||||||
|
if let truncatedData = truncatedContent.data(using: .utf8) {
|
||||||
|
try truncatedData.write(to: logFileURL)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("Error truncating log file: \(error)")
|
||||||
|
try? FileManager.default.removeItem(at: logFileURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func debugLog(_ entry: LogEntry) {
|
||||||
|
#if DEBUG
|
||||||
|
let dateFormatter = DateFormatter()
|
||||||
|
dateFormatter.dateFormat = "dd-MM HH:mm:ss"
|
||||||
|
let formattedMessage = "[\(dateFormatter.string(from: entry.timestamp))] [\(entry.type)] \(entry.message)"
|
||||||
|
print(formattedMessage)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
746
modules/mpv-player/ios/MPVLayerRenderer.swift
Normal file
746
modules/mpv-player/ios/MPVLayerRenderer.swift
Normal file
@@ -0,0 +1,746 @@
|
|||||||
|
import UIKit
|
||||||
|
import MPVKit
|
||||||
|
import CoreMedia
|
||||||
|
import CoreVideo
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
protocol MPVLayerRendererDelegate: AnyObject {
|
||||||
|
func renderer(_ renderer: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double)
|
||||||
|
func renderer(_ renderer: MPVLayerRenderer, didChangePause isPaused: Bool)
|
||||||
|
func renderer(_ renderer: MPVLayerRenderer, didChangeLoading isLoading: Bool)
|
||||||
|
func renderer(_ renderer: MPVLayerRenderer, didBecomeReadyToSeek: Bool)
|
||||||
|
func renderer(_ renderer: MPVLayerRenderer, didBecomeTracksReady: Bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MPV player using vo_avfoundation for video output.
|
||||||
|
/// This renders video directly to AVSampleBufferDisplayLayer for PiP support.
|
||||||
|
final class MPVLayerRenderer {
|
||||||
|
enum RendererError: Error {
|
||||||
|
case mpvCreationFailed
|
||||||
|
case mpvInitialization(Int32)
|
||||||
|
}
|
||||||
|
|
||||||
|
private let displayLayer: AVSampleBufferDisplayLayer
|
||||||
|
private let queue = DispatchQueue(label: "mpv.avfoundation", qos: .userInitiated)
|
||||||
|
private let stateQueue = DispatchQueue(label: "mpv.avfoundation.state", attributes: .concurrent)
|
||||||
|
|
||||||
|
private var mpv: OpaquePointer?
|
||||||
|
|
||||||
|
private var currentPreset: PlayerPreset?
|
||||||
|
private var currentURL: URL?
|
||||||
|
private var currentHeaders: [String: String]?
|
||||||
|
private var pendingExternalSubtitles: [String] = []
|
||||||
|
private var initialSubtitleId: Int?
|
||||||
|
private var initialAudioId: Int?
|
||||||
|
|
||||||
|
private var isRunning = false
|
||||||
|
private var isStopping = false
|
||||||
|
|
||||||
|
weak var delegate: MPVLayerRendererDelegate?
|
||||||
|
|
||||||
|
// Thread-safe state for playback
|
||||||
|
private var _cachedDuration: Double = 0
|
||||||
|
private var _cachedPosition: Double = 0
|
||||||
|
private var _isPaused: Bool = true
|
||||||
|
private var _playbackSpeed: Double = 1.0
|
||||||
|
private var _isLoading: Bool = false
|
||||||
|
private var _isReadyToSeek: Bool = false
|
||||||
|
|
||||||
|
// Thread-safe accessors
|
||||||
|
private var cachedDuration: Double {
|
||||||
|
get { stateQueue.sync { _cachedDuration } }
|
||||||
|
set { stateQueue.async(flags: .barrier) { self._cachedDuration = newValue } }
|
||||||
|
}
|
||||||
|
private var cachedPosition: Double {
|
||||||
|
get { stateQueue.sync { _cachedPosition } }
|
||||||
|
set { stateQueue.async(flags: .barrier) { self._cachedPosition = newValue } }
|
||||||
|
}
|
||||||
|
private var isPaused: Bool {
|
||||||
|
get { stateQueue.sync { _isPaused } }
|
||||||
|
set { stateQueue.async(flags: .barrier) { self._isPaused = newValue } }
|
||||||
|
}
|
||||||
|
private var playbackSpeed: Double {
|
||||||
|
get { stateQueue.sync { _playbackSpeed } }
|
||||||
|
set { stateQueue.async(flags: .barrier) { self._playbackSpeed = newValue } }
|
||||||
|
}
|
||||||
|
private var isLoading: Bool {
|
||||||
|
get { stateQueue.sync { _isLoading } }
|
||||||
|
set { stateQueue.async(flags: .barrier) { self._isLoading = newValue } }
|
||||||
|
}
|
||||||
|
private var isReadyToSeek: Bool {
|
||||||
|
get { stateQueue.sync { _isReadyToSeek } }
|
||||||
|
set { stateQueue.async(flags: .barrier) { self._isReadyToSeek = newValue } }
|
||||||
|
}
|
||||||
|
|
||||||
|
var isPausedState: Bool {
|
||||||
|
return isPaused
|
||||||
|
}
|
||||||
|
|
||||||
|
init(displayLayer: AVSampleBufferDisplayLayer) {
|
||||||
|
self.displayLayer = displayLayer
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() throws {
|
||||||
|
guard !isRunning else { return }
|
||||||
|
guard let handle = mpv_create() else {
|
||||||
|
throw RendererError.mpvCreationFailed
|
||||||
|
}
|
||||||
|
mpv = handle
|
||||||
|
|
||||||
|
// Logging
|
||||||
|
#if DEBUG
|
||||||
|
checkError(mpv_request_log_messages(handle, "warn"))
|
||||||
|
#else
|
||||||
|
checkError(mpv_request_log_messages(handle, "no"))
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Pass the AVSampleBufferDisplayLayer to mpv via --wid
|
||||||
|
// The vo_avfoundation driver expects this
|
||||||
|
var displayLayerPtr = Int64(Int(bitPattern: Unmanaged.passUnretained(displayLayer).toOpaque()))
|
||||||
|
checkError(mpv_set_option(handle, "wid", MPV_FORMAT_INT64, &displayLayerPtr))
|
||||||
|
|
||||||
|
// Use AVFoundation video output - required for PiP support
|
||||||
|
checkError(mpv_set_option_string(handle, "vo", "avfoundation"))
|
||||||
|
|
||||||
|
// Enable composite OSD mode - renders subtitles directly onto video frames using GPU
|
||||||
|
// This is better for PiP as subtitles are baked into the video
|
||||||
|
checkError(mpv_set_option_string(handle, "avfoundation-composite-osd", "yes"))
|
||||||
|
|
||||||
|
// Hardware decoding with VideoToolbox - REQUIRED for vo_avfoundation
|
||||||
|
// vo_avfoundation ONLY accepts IMGFMT_VIDEOTOOLBOX frames
|
||||||
|
checkError(mpv_set_option_string(handle, "hwdec", "videotoolbox"))
|
||||||
|
checkError(mpv_set_option_string(handle, "hwdec-codecs", "all"))
|
||||||
|
checkError(mpv_set_option_string(handle, "hwdec-software-fallback", "no"))
|
||||||
|
|
||||||
|
// Seeking optimization - faster seeking at the cost of less precision
|
||||||
|
// Use keyframe seeking by default (much faster for network streams)
|
||||||
|
checkError(mpv_set_option_string(handle, "hr-seek", "no"))
|
||||||
|
// Drop frames during seeking for faster response
|
||||||
|
checkError(mpv_set_option_string(handle, "hr-seek-framedrop", "yes"))
|
||||||
|
|
||||||
|
// Demuxer cache settings for better network streaming
|
||||||
|
checkError(mpv_set_option_string(handle, "cache", "yes"))
|
||||||
|
checkError(mpv_set_option_string(handle, "demuxer-max-bytes", "150MiB"))
|
||||||
|
checkError(mpv_set_option_string(handle, "demuxer-max-back-bytes", "75MiB"))
|
||||||
|
checkError(mpv_set_option_string(handle, "demuxer-readahead-secs", "20"))
|
||||||
|
|
||||||
|
// Subtitle and audio settings
|
||||||
|
checkError(mpv_set_option_string(mpv, "subs-match-os-language", "yes"))
|
||||||
|
checkError(mpv_set_option_string(mpv, "subs-fallback", "yes"))
|
||||||
|
|
||||||
|
// Initialize mpv
|
||||||
|
let initStatus = mpv_initialize(handle)
|
||||||
|
guard initStatus >= 0 else {
|
||||||
|
throw RendererError.mpvInitialization(initStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observe properties
|
||||||
|
observeProperties()
|
||||||
|
|
||||||
|
// Setup wakeup callback
|
||||||
|
mpv_set_wakeup_callback(handle, { ctx in
|
||||||
|
guard let ctx = ctx else { return }
|
||||||
|
let instance = Unmanaged<MPVLayerRenderer>.fromOpaque(ctx).takeUnretainedValue()
|
||||||
|
instance.processEvents()
|
||||||
|
}, Unmanaged.passUnretained(self).toOpaque())
|
||||||
|
|
||||||
|
isRunning = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
if isStopping { return }
|
||||||
|
if !isRunning, mpv == nil { return }
|
||||||
|
isRunning = false
|
||||||
|
isStopping = true
|
||||||
|
|
||||||
|
queue.sync { [weak self] in
|
||||||
|
guard let self, let handle = self.mpv else { return }
|
||||||
|
|
||||||
|
mpv_set_wakeup_callback(handle, nil, nil)
|
||||||
|
mpv_terminate_destroy(handle)
|
||||||
|
self.mpv = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
if #available(iOS 18.0, *) {
|
||||||
|
self.displayLayer.sampleBufferRenderer.flush(removingDisplayedImage: true, completionHandler: nil)
|
||||||
|
} else {
|
||||||
|
self.displayLayer.flushAndRemoveImage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isStopping = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func load(
|
||||||
|
url: URL,
|
||||||
|
with preset: PlayerPreset,
|
||||||
|
headers: [String: String]? = nil,
|
||||||
|
startPosition: Double? = nil,
|
||||||
|
externalSubtitles: [String]? = nil,
|
||||||
|
initialSubtitleId: Int? = nil,
|
||||||
|
initialAudioId: Int? = nil
|
||||||
|
) {
|
||||||
|
currentPreset = preset
|
||||||
|
currentURL = url
|
||||||
|
currentHeaders = headers
|
||||||
|
pendingExternalSubtitles = externalSubtitles ?? []
|
||||||
|
self.initialSubtitleId = initialSubtitleId
|
||||||
|
self.initialAudioId = initialAudioId
|
||||||
|
|
||||||
|
queue.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.isLoading = true
|
||||||
|
self.isReadyToSeek = false
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.delegate?.renderer(self, didChangeLoading: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let handle = self.mpv else { return }
|
||||||
|
|
||||||
|
self.apply(commands: preset.commands, on: handle)
|
||||||
|
// Stop previous playback before loading new file
|
||||||
|
self.command(handle, ["stop"])
|
||||||
|
self.updateHTTPHeaders(headers)
|
||||||
|
|
||||||
|
// Set start position
|
||||||
|
if let startPos = startPosition, startPos > 0 {
|
||||||
|
self.setProperty(name: "start", value: String(format: "%.2f", startPos))
|
||||||
|
} else {
|
||||||
|
self.setProperty(name: "start", value: "0")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set initial audio track if specified
|
||||||
|
if let audioId = self.initialAudioId, audioId > 0 {
|
||||||
|
self.setAudioTrack(audioId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set initial subtitle track if no external subs
|
||||||
|
if self.pendingExternalSubtitles.isEmpty {
|
||||||
|
if let subId = self.initialSubtitleId {
|
||||||
|
self.setSubtitleTrack(subId)
|
||||||
|
} else {
|
||||||
|
self.disableSubtitles()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.disableSubtitles()
|
||||||
|
}
|
||||||
|
|
||||||
|
let target = url.isFileURL ? url.path : url.absoluteString
|
||||||
|
self.command(handle, ["loadfile", target, "replace"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reloadCurrentItem() {
|
||||||
|
guard let url = currentURL, let preset = currentPreset else { return }
|
||||||
|
load(url: url, with: preset, headers: currentHeaders)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyPreset(_ preset: PlayerPreset) {
|
||||||
|
currentPreset = preset
|
||||||
|
guard let handle = mpv else { return }
|
||||||
|
queue.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.apply(commands: preset.commands, on: handle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Property Helpers
|
||||||
|
|
||||||
|
private func setOption(name: String, value: String) {
|
||||||
|
guard let handle = mpv else { return }
|
||||||
|
checkError(mpv_set_option_string(handle, name, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setProperty(name: String, value: String) {
|
||||||
|
guard let handle = mpv else { return }
|
||||||
|
let status = mpv_set_property_string(handle, name, value)
|
||||||
|
if status < 0 {
|
||||||
|
Logger.shared.log("Failed to set property \(name)=\(value) (\(status))", type: "Warn")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearProperty(name: String) {
|
||||||
|
guard let handle = mpv else { return }
|
||||||
|
let status = mpv_set_property(handle, name, MPV_FORMAT_NONE, nil)
|
||||||
|
if status < 0 {
|
||||||
|
Logger.shared.log("Failed to clear property \(name) (\(status))", type: "Warn")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateHTTPHeaders(_ headers: [String: String]?) {
|
||||||
|
guard let headers, !headers.isEmpty else {
|
||||||
|
clearProperty(name: "http-header-fields")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let headerString = headers
|
||||||
|
.map { key, value in "\(key): \(value)" }
|
||||||
|
.joined(separator: "\r\n")
|
||||||
|
setProperty(name: "http-header-fields", value: headerString)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func observeProperties() {
|
||||||
|
guard let handle = mpv else { return }
|
||||||
|
let properties: [(String, mpv_format)] = [
|
||||||
|
("duration", MPV_FORMAT_DOUBLE),
|
||||||
|
("time-pos", MPV_FORMAT_DOUBLE),
|
||||||
|
("pause", MPV_FORMAT_FLAG),
|
||||||
|
("track-list/count", MPV_FORMAT_INT64),
|
||||||
|
("paused-for-cache", MPV_FORMAT_FLAG)
|
||||||
|
]
|
||||||
|
|
||||||
|
for (name, format) in properties {
|
||||||
|
mpv_observe_property(handle, 0, name, format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func apply(commands: [[String]], on handle: OpaquePointer) {
|
||||||
|
for command in commands {
|
||||||
|
guard !command.isEmpty else { continue }
|
||||||
|
self.command(handle, command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func command(_ handle: OpaquePointer, _ args: [String]) {
|
||||||
|
guard !args.isEmpty else { return }
|
||||||
|
_ = withCStringArray(args) { pointer in
|
||||||
|
mpv_command_async(handle, 0, pointer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func commandSync(_ handle: OpaquePointer, _ args: [String]) -> Int32 {
|
||||||
|
guard !args.isEmpty else { return -1 }
|
||||||
|
return withCStringArray(args) { pointer in
|
||||||
|
mpv_command(handle, pointer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func checkError(_ status: CInt) {
|
||||||
|
if status < 0 {
|
||||||
|
Logger.shared.log("MPV API error: \(String(cString: mpv_error_string(status)))", type: "Error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Event Handling
|
||||||
|
|
||||||
|
private func processEvents() {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
|
||||||
|
while self.mpv != nil && !self.isStopping {
|
||||||
|
guard let handle = self.mpv,
|
||||||
|
let eventPointer = mpv_wait_event(handle, 0) else { return }
|
||||||
|
let event = eventPointer.pointee
|
||||||
|
if event.event_id == MPV_EVENT_NONE { break }
|
||||||
|
self.handleEvent(event)
|
||||||
|
if event.event_id == MPV_EVENT_SHUTDOWN { break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleEvent(_ event: mpv_event) {
|
||||||
|
switch event.event_id {
|
||||||
|
case MPV_EVENT_FILE_LOADED:
|
||||||
|
// Add external subtitles now that the file is loaded
|
||||||
|
let hadExternalSubs = !pendingExternalSubtitles.isEmpty
|
||||||
|
if hadExternalSubs, let handle = mpv {
|
||||||
|
for subUrl in pendingExternalSubtitles {
|
||||||
|
command(handle, ["sub-add", subUrl])
|
||||||
|
}
|
||||||
|
pendingExternalSubtitles = []
|
||||||
|
|
||||||
|
// Set subtitle after external subs are added
|
||||||
|
if let subId = initialSubtitleId {
|
||||||
|
setSubtitleTrack(subId)
|
||||||
|
} else {
|
||||||
|
disableSubtitles()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isReadyToSeek {
|
||||||
|
isReadyToSeek = true
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.delegate?.renderer(self, didBecomeReadyToSeek: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify loading ended
|
||||||
|
if isLoading {
|
||||||
|
isLoading = false
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.delegate?.renderer(self, didChangeLoading: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case MPV_EVENT_SEEK:
|
||||||
|
// Seek started - show loading indicator
|
||||||
|
if !isLoading {
|
||||||
|
isLoading = true
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.delegate?.renderer(self, didChangeLoading: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case MPV_EVENT_PLAYBACK_RESTART:
|
||||||
|
// Video playback has started/restarted (including after seek)
|
||||||
|
if isLoading {
|
||||||
|
isLoading = false
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.delegate?.renderer(self, didChangeLoading: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case MPV_EVENT_PROPERTY_CHANGE:
|
||||||
|
if let property = event.data?.assumingMemoryBound(to: mpv_event_property.self).pointee.name {
|
||||||
|
let name = String(cString: property)
|
||||||
|
refreshProperty(named: name, event: event)
|
||||||
|
}
|
||||||
|
|
||||||
|
case MPV_EVENT_SHUTDOWN:
|
||||||
|
Logger.shared.log("mpv shutdown", type: "Warn")
|
||||||
|
|
||||||
|
case MPV_EVENT_LOG_MESSAGE:
|
||||||
|
if let logMessagePointer = event.data?.assumingMemoryBound(to: mpv_event_log_message.self) {
|
||||||
|
let component = String(cString: logMessagePointer.pointee.prefix)
|
||||||
|
let text = String(cString: logMessagePointer.pointee.text)
|
||||||
|
let lower = text.lowercased()
|
||||||
|
if lower.contains("error") {
|
||||||
|
Logger.shared.log("mpv[\(component)] \(text)", type: "Error")
|
||||||
|
} else if lower.contains("warn") || lower.contains("warning") {
|
||||||
|
Logger.shared.log("mpv[\(component)] \(text)", type: "Warn")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshProperty(named name: String, event: mpv_event) {
|
||||||
|
guard let handle = mpv else { return }
|
||||||
|
|
||||||
|
switch name {
|
||||||
|
case "duration":
|
||||||
|
var value = Double(0)
|
||||||
|
let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_DOUBLE, value: &value)
|
||||||
|
if status >= 0 {
|
||||||
|
cachedDuration = value
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.delegate?.renderer(self, didUpdatePosition: self.cachedPosition, duration: self.cachedDuration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "time-pos":
|
||||||
|
var value = Double(0)
|
||||||
|
let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_DOUBLE, value: &value)
|
||||||
|
if status >= 0 {
|
||||||
|
cachedPosition = value
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.delegate?.renderer(self, didUpdatePosition: self.cachedPosition, duration: self.cachedDuration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "pause":
|
||||||
|
var flag: Int32 = 0
|
||||||
|
let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_FLAG, value: &flag)
|
||||||
|
if status >= 0 {
|
||||||
|
let newPaused = flag != 0
|
||||||
|
if newPaused != isPaused {
|
||||||
|
isPaused = newPaused
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.delegate?.renderer(self, didChangePause: self.isPaused)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "paused-for-cache":
|
||||||
|
var flag: Int32 = 0
|
||||||
|
let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_FLAG, value: &flag)
|
||||||
|
if status >= 0 {
|
||||||
|
let buffering = flag != 0
|
||||||
|
if buffering != isLoading {
|
||||||
|
isLoading = buffering
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.delegate?.renderer(self, didChangeLoading: buffering)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "track-list/count":
|
||||||
|
var trackCount: Int64 = 0
|
||||||
|
let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_INT64, value: &trackCount)
|
||||||
|
if status >= 0 && trackCount > 0 {
|
||||||
|
Logger.shared.log("Track list updated: \(trackCount) tracks available", type: "Info")
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.delegate?.renderer(self, didBecomeTracksReady: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getStringProperty(handle: OpaquePointer, name: String) -> String? {
|
||||||
|
var result: String?
|
||||||
|
if let cString = mpv_get_property_string(handle, name) {
|
||||||
|
result = String(cString: cString)
|
||||||
|
mpv_free(cString)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
private func getProperty<T>(handle: OpaquePointer, name: String, format: mpv_format, value: inout T) -> Int32 {
|
||||||
|
return withUnsafeMutablePointer(to: &value) { mutablePointer in
|
||||||
|
return mpv_get_property(handle, name, format, mutablePointer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@inline(__always)
|
||||||
|
private func withCStringArray<R>(_ args: [String], body: (UnsafeMutablePointer<UnsafePointer<CChar>?>?) -> R) -> R {
|
||||||
|
var cStrings = [UnsafeMutablePointer<CChar>?]()
|
||||||
|
cStrings.reserveCapacity(args.count + 1)
|
||||||
|
for s in args {
|
||||||
|
cStrings.append(strdup(s))
|
||||||
|
}
|
||||||
|
cStrings.append(nil)
|
||||||
|
defer {
|
||||||
|
for ptr in cStrings where ptr != nil {
|
||||||
|
free(ptr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cStrings.withUnsafeMutableBufferPointer { buffer in
|
||||||
|
return buffer.baseAddress!.withMemoryRebound(to: UnsafePointer<CChar>?.self, capacity: buffer.count) { rebound in
|
||||||
|
return body(UnsafeMutablePointer(mutating: rebound))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Playback Controls
|
||||||
|
|
||||||
|
func play() {
|
||||||
|
setProperty(name: "pause", value: "no")
|
||||||
|
}
|
||||||
|
|
||||||
|
func pausePlayback() {
|
||||||
|
setProperty(name: "pause", value: "yes")
|
||||||
|
}
|
||||||
|
|
||||||
|
func togglePause() {
|
||||||
|
if isPaused { play() } else { pausePlayback() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func seek(to seconds: Double) {
|
||||||
|
guard let handle = mpv else { return }
|
||||||
|
let clamped = max(0, seconds)
|
||||||
|
cachedPosition = clamped
|
||||||
|
commandSync(handle, ["seek", String(clamped), "absolute"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func seek(by seconds: Double) {
|
||||||
|
guard let handle = mpv else { return }
|
||||||
|
let newPosition = max(0, cachedPosition + seconds)
|
||||||
|
cachedPosition = newPosition
|
||||||
|
commandSync(handle, ["seek", String(seconds), "relative"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sync timebase - no-op for vo_avfoundation (mpv handles timing)
|
||||||
|
func syncTimebase() {
|
||||||
|
// vo_avfoundation manages its own timebase
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSpeed(_ speed: Double) {
|
||||||
|
playbackSpeed = speed
|
||||||
|
setProperty(name: "speed", value: String(speed))
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSpeed() -> Double {
|
||||||
|
guard let handle = mpv else { return 1.0 }
|
||||||
|
var speed: Double = 1.0
|
||||||
|
getProperty(handle: handle, name: "speed", format: MPV_FORMAT_DOUBLE, value: &speed)
|
||||||
|
return speed
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Subtitle Controls
|
||||||
|
|
||||||
|
func getSubtitleTracks() -> [[String: Any]] {
|
||||||
|
guard let handle = mpv else {
|
||||||
|
Logger.shared.log("getSubtitleTracks: mpv handle is nil", type: "Warn")
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
var tracks: [[String: Any]] = []
|
||||||
|
|
||||||
|
var trackCount: Int64 = 0
|
||||||
|
getProperty(handle: handle, name: "track-list/count", format: MPV_FORMAT_INT64, value: &trackCount)
|
||||||
|
|
||||||
|
for i in 0..<trackCount {
|
||||||
|
guard let trackType = getStringProperty(handle: handle, name: "track-list/\(i)/type"),
|
||||||
|
trackType == "sub" else { continue }
|
||||||
|
|
||||||
|
var trackId: Int64 = 0
|
||||||
|
getProperty(handle: handle, name: "track-list/\(i)/id", format: MPV_FORMAT_INT64, value: &trackId)
|
||||||
|
|
||||||
|
var track: [String: Any] = ["id": Int(trackId)]
|
||||||
|
|
||||||
|
if let title = getStringProperty(handle: handle, name: "track-list/\(i)/title") {
|
||||||
|
track["title"] = title
|
||||||
|
}
|
||||||
|
|
||||||
|
if let lang = getStringProperty(handle: handle, name: "track-list/\(i)/lang") {
|
||||||
|
track["lang"] = lang
|
||||||
|
}
|
||||||
|
|
||||||
|
var selected: Int32 = 0
|
||||||
|
getProperty(handle: handle, name: "track-list/\(i)/selected", format: MPV_FORMAT_FLAG, value: &selected)
|
||||||
|
track["selected"] = selected != 0
|
||||||
|
|
||||||
|
Logger.shared.log("getSubtitleTracks: found sub track id=\(trackId), title=\(track["title"] ?? "none"), lang=\(track["lang"] ?? "none")", type: "Info")
|
||||||
|
tracks.append(track)
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.shared.log("getSubtitleTracks: returning \(tracks.count) subtitle tracks", type: "Info")
|
||||||
|
return tracks
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSubtitleTrack(_ trackId: Int) {
|
||||||
|
Logger.shared.log("setSubtitleTrack: setting sid to \(trackId)", type: "Info")
|
||||||
|
guard mpv != nil else {
|
||||||
|
Logger.shared.log("setSubtitleTrack: mpv handle is nil!", type: "Error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if trackId < 0 {
|
||||||
|
setProperty(name: "sid", value: "no")
|
||||||
|
} else {
|
||||||
|
setProperty(name: "sid", value: String(trackId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func disableSubtitles() {
|
||||||
|
setProperty(name: "sid", value: "no")
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCurrentSubtitleTrack() -> Int {
|
||||||
|
guard let handle = mpv else { return 0 }
|
||||||
|
var sid: Int64 = 0
|
||||||
|
getProperty(handle: handle, name: "sid", format: MPV_FORMAT_INT64, value: &sid)
|
||||||
|
return Int(sid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addSubtitleFile(url: String, select: Bool = true) {
|
||||||
|
guard let handle = mpv else { return }
|
||||||
|
let flag = select ? "select" : "cached"
|
||||||
|
commandSync(handle, ["sub-add", url, flag])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Subtitle Positioning
|
||||||
|
|
||||||
|
func setSubtitlePosition(_ position: Int) {
|
||||||
|
setProperty(name: "sub-pos", value: String(position))
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSubtitleScale(_ scale: Double) {
|
||||||
|
setProperty(name: "sub-scale", value: String(scale))
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSubtitleMarginY(_ margin: Int) {
|
||||||
|
setProperty(name: "sub-margin-y", value: String(margin))
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSubtitleAlignX(_ alignment: String) {
|
||||||
|
setProperty(name: "sub-align-x", value: alignment)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSubtitleAlignY(_ alignment: String) {
|
||||||
|
setProperty(name: "sub-align-y", value: alignment)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSubtitleFontSize(_ size: Int) {
|
||||||
|
setProperty(name: "sub-font-size", value: String(size))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Audio Track Controls
|
||||||
|
|
||||||
|
func getAudioTracks() -> [[String: Any]] {
|
||||||
|
guard let handle = mpv else {
|
||||||
|
Logger.shared.log("getAudioTracks: mpv handle is nil", type: "Warn")
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
var tracks: [[String: Any]] = []
|
||||||
|
|
||||||
|
var trackCount: Int64 = 0
|
||||||
|
getProperty(handle: handle, name: "track-list/count", format: MPV_FORMAT_INT64, value: &trackCount)
|
||||||
|
|
||||||
|
for i in 0..<trackCount {
|
||||||
|
guard let trackType = getStringProperty(handle: handle, name: "track-list/\(i)/type"),
|
||||||
|
trackType == "audio" else { continue }
|
||||||
|
|
||||||
|
var trackId: Int64 = 0
|
||||||
|
getProperty(handle: handle, name: "track-list/\(i)/id", format: MPV_FORMAT_INT64, value: &trackId)
|
||||||
|
|
||||||
|
var track: [String: Any] = ["id": Int(trackId)]
|
||||||
|
|
||||||
|
if let title = getStringProperty(handle: handle, name: "track-list/\(i)/title") {
|
||||||
|
track["title"] = title
|
||||||
|
}
|
||||||
|
|
||||||
|
if let lang = getStringProperty(handle: handle, name: "track-list/\(i)/lang") {
|
||||||
|
track["lang"] = lang
|
||||||
|
}
|
||||||
|
|
||||||
|
if let codec = getStringProperty(handle: handle, name: "track-list/\(i)/codec") {
|
||||||
|
track["codec"] = codec
|
||||||
|
}
|
||||||
|
|
||||||
|
var channels: Int64 = 0
|
||||||
|
getProperty(handle: handle, name: "track-list/\(i)/audio-channels", format: MPV_FORMAT_INT64, value: &channels)
|
||||||
|
if channels > 0 {
|
||||||
|
track["channels"] = Int(channels)
|
||||||
|
}
|
||||||
|
|
||||||
|
var selected: Int32 = 0
|
||||||
|
getProperty(handle: handle, name: "track-list/\(i)/selected", format: MPV_FORMAT_FLAG, value: &selected)
|
||||||
|
track["selected"] = selected != 0
|
||||||
|
|
||||||
|
Logger.shared.log("getAudioTracks: found audio track id=\(trackId), title=\(track["title"] ?? "none"), lang=\(track["lang"] ?? "none")", type: "Info")
|
||||||
|
tracks.append(track)
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.shared.log("getAudioTracks: returning \(tracks.count) audio tracks", type: "Info")
|
||||||
|
return tracks
|
||||||
|
}
|
||||||
|
|
||||||
|
func setAudioTrack(_ trackId: Int) {
|
||||||
|
guard mpv != nil else {
|
||||||
|
Logger.shared.log("setAudioTrack: mpv handle is nil", type: "Warn")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Logger.shared.log("setAudioTrack: setting aid to \(trackId)", type: "Info")
|
||||||
|
setProperty(name: "aid", value: String(trackId))
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCurrentAudioTrack() -> Int {
|
||||||
|
guard let handle = mpv else { return 0 }
|
||||||
|
var aid: Int64 = 0
|
||||||
|
getProperty(handle: handle, name: "aid", format: MPV_FORMAT_INT64, value: &aid)
|
||||||
|
return Int(aid)
|
||||||
|
}
|
||||||
|
}
|
||||||
28
modules/mpv-player/ios/MpvPlayer.podspec
Normal file
28
modules/mpv-player/ios/MpvPlayer.podspec
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
Pod::Spec.new do |s|
|
||||||
|
s.name = 'MpvPlayer'
|
||||||
|
s.version = '1.0.0'
|
||||||
|
s.summary = 'MPVKit for Expo'
|
||||||
|
s.description = 'MPVKit for Expo'
|
||||||
|
s.author = 'mpvkit'
|
||||||
|
s.homepage = 'https://github.com/mpvkit/MPVKit'
|
||||||
|
s.platforms = {
|
||||||
|
:ios => '15.1',
|
||||||
|
:tvos => '15.1'
|
||||||
|
}
|
||||||
|
s.source = { git: 'https://github.com/mpvkit/MPVKit.git' }
|
||||||
|
s.static_framework = true
|
||||||
|
|
||||||
|
s.dependency 'ExpoModulesCore'
|
||||||
|
s.dependency 'MPVKit-GPL'
|
||||||
|
|
||||||
|
# Swift/Objective-C compatibility
|
||||||
|
s.pod_target_xcconfig = {
|
||||||
|
'DEFINES_MODULE' => 'YES',
|
||||||
|
# Strip debug symbols to avoid DWARF errors from MPVKit
|
||||||
|
'DEBUG_INFORMATION_FORMAT' => 'dwarf',
|
||||||
|
'STRIP_INSTALLED_PRODUCT' => 'YES',
|
||||||
|
'DEPLOYMENT_POSTPROCESSING' => 'YES',
|
||||||
|
}
|
||||||
|
|
||||||
|
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
|
||||||
|
end
|
||||||
171
modules/mpv-player/ios/MpvPlayerModule.swift
Normal file
171
modules/mpv-player/ios/MpvPlayerModule.swift
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import ExpoModulesCore
|
||||||
|
|
||||||
|
public class MpvPlayerModule: Module {
|
||||||
|
public func definition() -> ModuleDefinition {
|
||||||
|
Name("MpvPlayer")
|
||||||
|
|
||||||
|
// Defines event names that the module can send to JavaScript.
|
||||||
|
Events("onChange")
|
||||||
|
|
||||||
|
// Defines a JavaScript synchronous function that runs the native code on the JavaScript thread.
|
||||||
|
Function("hello") {
|
||||||
|
return "Hello from MPV Player! 👋"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defines a JavaScript function that always returns a Promise and whose native code
|
||||||
|
// is by default dispatched on the different thread than the JavaScript runtime runs on.
|
||||||
|
AsyncFunction("setValueAsync") { (value: String) in
|
||||||
|
// Send an event to JavaScript.
|
||||||
|
self.sendEvent("onChange", [
|
||||||
|
"value": value
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enables the module to be used as a native view. Definition components that are accepted as part of the
|
||||||
|
// view definition: Prop, Events.
|
||||||
|
View(MpvPlayerView.self) {
|
||||||
|
// All video load options are passed via a single "source" prop
|
||||||
|
Prop("source") { (view: MpvPlayerView, source: [String: Any]?) in
|
||||||
|
guard let source = source,
|
||||||
|
let urlString = source["url"] as? String,
|
||||||
|
let videoURL = URL(string: urlString) else { return }
|
||||||
|
|
||||||
|
let config = VideoLoadConfig(
|
||||||
|
url: videoURL,
|
||||||
|
headers: source["headers"] as? [String: String],
|
||||||
|
externalSubtitles: source["externalSubtitles"] as? [String],
|
||||||
|
startPosition: source["startPosition"] as? Double,
|
||||||
|
autoplay: (source["autoplay"] as? Bool) ?? true,
|
||||||
|
initialSubtitleId: source["initialSubtitleId"] as? Int,
|
||||||
|
initialAudioId: source["initialAudioId"] as? Int
|
||||||
|
)
|
||||||
|
|
||||||
|
view.loadVideo(config: config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Async function to play video
|
||||||
|
AsyncFunction("play") { (view: MpvPlayerView) in
|
||||||
|
view.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Async function to pause video
|
||||||
|
AsyncFunction("pause") { (view: MpvPlayerView) in
|
||||||
|
view.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Async function to seek to position
|
||||||
|
AsyncFunction("seekTo") { (view: MpvPlayerView, position: Double) in
|
||||||
|
view.seekTo(position: position)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Async function to seek by offset
|
||||||
|
AsyncFunction("seekBy") { (view: MpvPlayerView, offset: Double) in
|
||||||
|
view.seekBy(offset: offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Async function to set playback speed
|
||||||
|
AsyncFunction("setSpeed") { (view: MpvPlayerView, speed: Double) in
|
||||||
|
view.setSpeed(speed: speed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to get current speed
|
||||||
|
AsyncFunction("getSpeed") { (view: MpvPlayerView) -> Double in
|
||||||
|
return view.getSpeed()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to check if paused
|
||||||
|
AsyncFunction("isPaused") { (view: MpvPlayerView) -> Bool in
|
||||||
|
return view.isPaused()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to get current position
|
||||||
|
AsyncFunction("getCurrentPosition") { (view: MpvPlayerView) -> Double in
|
||||||
|
return view.getCurrentPosition()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to get duration
|
||||||
|
AsyncFunction("getDuration") { (view: MpvPlayerView) -> Double in
|
||||||
|
return view.getDuration()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Picture in Picture functions
|
||||||
|
AsyncFunction("startPictureInPicture") { (view: MpvPlayerView) in
|
||||||
|
view.startPictureInPicture()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("stopPictureInPicture") { (view: MpvPlayerView) in
|
||||||
|
view.stopPictureInPicture()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("isPictureInPictureSupported") { (view: MpvPlayerView) -> Bool in
|
||||||
|
return view.isPictureInPictureSupported()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("isPictureInPictureActive") { (view: MpvPlayerView) -> Bool in
|
||||||
|
return view.isPictureInPictureActive()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subtitle functions
|
||||||
|
AsyncFunction("getSubtitleTracks") { (view: MpvPlayerView) -> [[String: Any]] in
|
||||||
|
return view.getSubtitleTracks()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleTrack") { (view: MpvPlayerView, trackId: Int) in
|
||||||
|
view.setSubtitleTrack(trackId)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("disableSubtitles") { (view: MpvPlayerView) in
|
||||||
|
view.disableSubtitles()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("getCurrentSubtitleTrack") { (view: MpvPlayerView) -> Int in
|
||||||
|
return view.getCurrentSubtitleTrack()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("addSubtitleFile") { (view: MpvPlayerView, url: String, select: Bool) in
|
||||||
|
view.addSubtitleFile(url: url, select: select)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subtitle positioning functions
|
||||||
|
AsyncFunction("setSubtitlePosition") { (view: MpvPlayerView, position: Int) in
|
||||||
|
view.setSubtitlePosition(position)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleScale") { (view: MpvPlayerView, scale: Double) in
|
||||||
|
view.setSubtitleScale(scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleMarginY") { (view: MpvPlayerView, margin: Int) in
|
||||||
|
view.setSubtitleMarginY(margin)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleAlignX") { (view: MpvPlayerView, alignment: String) in
|
||||||
|
view.setSubtitleAlignX(alignment)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleAlignY") { (view: MpvPlayerView, alignment: String) in
|
||||||
|
view.setSubtitleAlignY(alignment)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleFontSize") { (view: MpvPlayerView, size: Int) in
|
||||||
|
view.setSubtitleFontSize(size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio track functions
|
||||||
|
AsyncFunction("getAudioTracks") { (view: MpvPlayerView) -> [[String: Any]] in
|
||||||
|
return view.getAudioTracks()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setAudioTrack") { (view: MpvPlayerView, trackId: Int) in
|
||||||
|
view.setAudioTrack(trackId)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("getCurrentAudioTrack") { (view: MpvPlayerView) -> Int in
|
||||||
|
return view.getCurrentAudioTrack()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defines events that the view can send to JavaScript
|
||||||
|
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
411
modules/mpv-player/ios/MpvPlayerView.swift
Normal file
411
modules/mpv-player/ios/MpvPlayerView.swift
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import CoreMedia
|
||||||
|
import ExpoModulesCore
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// Configuration for loading a video
|
||||||
|
struct VideoLoadConfig {
|
||||||
|
let url: URL
|
||||||
|
var headers: [String: String]?
|
||||||
|
var externalSubtitles: [String]?
|
||||||
|
var startPosition: Double?
|
||||||
|
var autoplay: Bool
|
||||||
|
/// MPV subtitle track ID to select on start (1-based, -1 to disable, nil to use default)
|
||||||
|
var initialSubtitleId: Int?
|
||||||
|
/// MPV audio track ID to select on start (1-based, nil to use default)
|
||||||
|
var initialAudioId: Int?
|
||||||
|
|
||||||
|
init(
|
||||||
|
url: URL,
|
||||||
|
headers: [String: String]? = nil,
|
||||||
|
externalSubtitles: [String]? = nil,
|
||||||
|
startPosition: Double? = nil,
|
||||||
|
autoplay: Bool = true,
|
||||||
|
initialSubtitleId: Int? = nil,
|
||||||
|
initialAudioId: Int? = nil
|
||||||
|
) {
|
||||||
|
self.url = url
|
||||||
|
self.headers = headers
|
||||||
|
self.externalSubtitles = externalSubtitles
|
||||||
|
self.startPosition = startPosition
|
||||||
|
self.autoplay = autoplay
|
||||||
|
self.initialSubtitleId = initialSubtitleId
|
||||||
|
self.initialAudioId = initialAudioId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This view will be used as a native component. Make sure to inherit from `ExpoView`
|
||||||
|
// to apply the proper styling (e.g. border radius and shadows).
|
||||||
|
class MpvPlayerView: ExpoView {
|
||||||
|
private let displayLayer = AVSampleBufferDisplayLayer()
|
||||||
|
private var renderer: MPVLayerRenderer?
|
||||||
|
private var videoContainer: UIView!
|
||||||
|
private var pipController: PiPController?
|
||||||
|
|
||||||
|
let onLoad = EventDispatcher()
|
||||||
|
let onPlaybackStateChange = EventDispatcher()
|
||||||
|
let onProgress = EventDispatcher()
|
||||||
|
let onError = EventDispatcher()
|
||||||
|
let onTracksReady = EventDispatcher()
|
||||||
|
|
||||||
|
private var currentURL: URL?
|
||||||
|
private var cachedPosition: Double = 0
|
||||||
|
private var cachedDuration: Double = 0
|
||||||
|
private var intendedPlayState: Bool = false // For PiP - ignores transient states during seek
|
||||||
|
|
||||||
|
required init(appContext: AppContext? = nil) {
|
||||||
|
super.init(appContext: appContext)
|
||||||
|
setupView()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupView() {
|
||||||
|
clipsToBounds = true
|
||||||
|
backgroundColor = .black
|
||||||
|
|
||||||
|
videoContainer = UIView()
|
||||||
|
videoContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
videoContainer.backgroundColor = .black
|
||||||
|
videoContainer.clipsToBounds = true
|
||||||
|
addSubview(videoContainer)
|
||||||
|
|
||||||
|
displayLayer.frame = bounds
|
||||||
|
displayLayer.videoGravity = .resizeAspect
|
||||||
|
if #available(iOS 17.0, *) {
|
||||||
|
displayLayer.wantsExtendedDynamicRangeContent = true
|
||||||
|
}
|
||||||
|
displayLayer.backgroundColor = UIColor.black.cgColor
|
||||||
|
videoContainer.layer.addSublayer(displayLayer)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
videoContainer.topAnchor.constraint(equalTo: topAnchor),
|
||||||
|
videoContainer.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
|
videoContainer.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
|
videoContainer.bottomAnchor.constraint(equalTo: bottomAnchor)
|
||||||
|
])
|
||||||
|
|
||||||
|
renderer = MPVLayerRenderer(displayLayer: displayLayer)
|
||||||
|
renderer?.delegate = self
|
||||||
|
|
||||||
|
// Setup PiP
|
||||||
|
pipController = PiPController(sampleBufferDisplayLayer: displayLayer)
|
||||||
|
pipController?.delegate = self
|
||||||
|
|
||||||
|
do {
|
||||||
|
try renderer?.start()
|
||||||
|
} catch {
|
||||||
|
onError(["error": "Failed to start renderer: \(error.localizedDescription)"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
CATransaction.begin()
|
||||||
|
CATransaction.setDisableActions(true)
|
||||||
|
displayLayer.frame = videoContainer.bounds
|
||||||
|
displayLayer.isHidden = false
|
||||||
|
displayLayer.opacity = 1.0
|
||||||
|
CATransaction.commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadVideo(config: VideoLoadConfig) {
|
||||||
|
// Skip reload if same URL is already playing
|
||||||
|
if currentURL == config.url {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
currentURL = config.url
|
||||||
|
|
||||||
|
let preset = PlayerPreset(
|
||||||
|
id: .sdrRec709,
|
||||||
|
title: "Default",
|
||||||
|
summary: "Default playback preset",
|
||||||
|
stream: nil,
|
||||||
|
commands: []
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pass everything to the renderer - it handles start position and external subs
|
||||||
|
renderer?.load(
|
||||||
|
url: config.url,
|
||||||
|
with: preset,
|
||||||
|
headers: config.headers,
|
||||||
|
startPosition: config.startPosition,
|
||||||
|
externalSubtitles: config.externalSubtitles,
|
||||||
|
initialSubtitleId: config.initialSubtitleId,
|
||||||
|
initialAudioId: config.initialAudioId
|
||||||
|
)
|
||||||
|
|
||||||
|
if config.autoplay {
|
||||||
|
play()
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoad(["url": config.url.absoluteString])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience method for simple loads
|
||||||
|
func loadVideo(url: URL, headers: [String: String]? = nil) {
|
||||||
|
loadVideo(config: VideoLoadConfig(url: url, headers: headers))
|
||||||
|
}
|
||||||
|
|
||||||
|
func play() {
|
||||||
|
intendedPlayState = true
|
||||||
|
renderer?.play()
|
||||||
|
pipController?.setPlaybackRate(1.0)
|
||||||
|
pipController?.updatePlaybackState()
|
||||||
|
}
|
||||||
|
|
||||||
|
func pause() {
|
||||||
|
intendedPlayState = false
|
||||||
|
renderer?.pausePlayback()
|
||||||
|
pipController?.setPlaybackRate(0.0)
|
||||||
|
pipController?.updatePlaybackState()
|
||||||
|
}
|
||||||
|
|
||||||
|
func seekTo(position: Double) {
|
||||||
|
renderer?.seek(to: position)
|
||||||
|
}
|
||||||
|
|
||||||
|
func seekBy(offset: Double) {
|
||||||
|
renderer?.seek(by: offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSpeed(speed: Double) {
|
||||||
|
renderer?.setSpeed(speed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSpeed() -> Double {
|
||||||
|
return renderer?.getSpeed() ?? 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPaused() -> Bool {
|
||||||
|
return renderer?.isPausedState ?? true
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCurrentPosition() -> Double {
|
||||||
|
return cachedPosition
|
||||||
|
}
|
||||||
|
|
||||||
|
func getDuration() -> Double {
|
||||||
|
return cachedDuration
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Picture in Picture
|
||||||
|
|
||||||
|
func startPictureInPicture() {
|
||||||
|
print("🎬 MpvPlayerView: startPictureInPicture called")
|
||||||
|
print("🎬 Duration: \(getDuration()), IsPlaying: \(!isPaused())")
|
||||||
|
pipController?.startPictureInPicture()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopPictureInPicture() {
|
||||||
|
pipController?.stopPictureInPicture()
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPictureInPictureSupported() -> Bool {
|
||||||
|
return pipController?.isPictureInPictureSupported ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPictureInPictureActive() -> Bool {
|
||||||
|
return pipController?.isPictureInPictureActive ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Subtitle Controls
|
||||||
|
|
||||||
|
func getSubtitleTracks() -> [[String: Any]] {
|
||||||
|
return renderer?.getSubtitleTracks() ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSubtitleTrack(_ trackId: Int) {
|
||||||
|
renderer?.setSubtitleTrack(trackId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func disableSubtitles() {
|
||||||
|
renderer?.disableSubtitles()
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCurrentSubtitleTrack() -> Int {
|
||||||
|
return renderer?.getCurrentSubtitleTrack() ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func addSubtitleFile(url: String, select: Bool = true) {
|
||||||
|
renderer?.addSubtitleFile(url: url, select: select)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Audio Track Controls
|
||||||
|
|
||||||
|
func getAudioTracks() -> [[String: Any]] {
|
||||||
|
return renderer?.getAudioTracks() ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
func setAudioTrack(_ trackId: Int) {
|
||||||
|
renderer?.setAudioTrack(trackId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCurrentAudioTrack() -> Int {
|
||||||
|
return renderer?.getCurrentAudioTrack() ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Subtitle Positioning
|
||||||
|
|
||||||
|
func setSubtitlePosition(_ position: Int) {
|
||||||
|
renderer?.setSubtitlePosition(position)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSubtitleScale(_ scale: Double) {
|
||||||
|
renderer?.setSubtitleScale(scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSubtitleMarginY(_ margin: Int) {
|
||||||
|
renderer?.setSubtitleMarginY(margin)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSubtitleAlignX(_ alignment: String) {
|
||||||
|
renderer?.setSubtitleAlignX(alignment)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSubtitleAlignY(_ alignment: String) {
|
||||||
|
renderer?.setSubtitleAlignY(alignment)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSubtitleFontSize(_ size: Int) {
|
||||||
|
renderer?.setSubtitleFontSize(size)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
pipController?.stopPictureInPicture()
|
||||||
|
renderer?.stop()
|
||||||
|
displayLayer.removeFromSuperlayer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - MPVLayerRendererDelegate
|
||||||
|
|
||||||
|
extension MpvPlayerView: MPVLayerRendererDelegate {
|
||||||
|
func renderer(_: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double) {
|
||||||
|
cachedPosition = position
|
||||||
|
cachedDuration = duration
|
||||||
|
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
// Update PiP current time for progress bar
|
||||||
|
if self.pipController?.isPictureInPictureActive == true {
|
||||||
|
self.pipController?.setCurrentTimeFromSeconds(position, duration: duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.onProgress([
|
||||||
|
"position": position,
|
||||||
|
"duration": duration,
|
||||||
|
"progress": duration > 0 ? position / duration : 0,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderer(_: MPVLayerRenderer, didChangePause isPaused: Bool) {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
// Don't update intendedPlayState here - it's only set by user actions (play/pause)
|
||||||
|
// This prevents PiP UI flicker during seeking
|
||||||
|
|
||||||
|
// Sync timebase rate with actual playback state
|
||||||
|
self.pipController?.setPlaybackRate(isPaused ? 0.0 : 1.0)
|
||||||
|
|
||||||
|
self.onPlaybackStateChange([
|
||||||
|
"isPaused": isPaused,
|
||||||
|
"isPlaying": !isPaused,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderer(_: MPVLayerRenderer, didChangeLoading isLoading: Bool) {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.onPlaybackStateChange([
|
||||||
|
"isLoading": isLoading,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderer(_: MPVLayerRenderer, didBecomeReadyToSeek: Bool) {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.onPlaybackStateChange([
|
||||||
|
"isReadyToSeek": didBecomeReadyToSeek,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderer(_: MPVLayerRenderer, didBecomeTracksReady: Bool) {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.onTracksReady([:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - PiPControllerDelegate
|
||||||
|
|
||||||
|
extension MpvPlayerView: PiPControllerDelegate {
|
||||||
|
func pipController(_ controller: PiPController, willStartPictureInPicture: Bool) {
|
||||||
|
print("PiP will start")
|
||||||
|
// Sync timebase before PiP starts for smooth transition
|
||||||
|
renderer?.syncTimebase()
|
||||||
|
// Set current time for PiP progress bar
|
||||||
|
pipController?.setCurrentTimeFromSeconds(cachedPosition, duration: cachedDuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pipController(_ controller: PiPController, didStartPictureInPicture: Bool) {
|
||||||
|
print("PiP did start: \(didStartPictureInPicture)")
|
||||||
|
// Ensure current time is synced when PiP starts
|
||||||
|
pipController?.setCurrentTimeFromSeconds(cachedPosition, duration: cachedDuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pipController(_ controller: PiPController, willStopPictureInPicture: Bool) {
|
||||||
|
print("PiP will stop")
|
||||||
|
// Sync timebase before returning from PiP
|
||||||
|
renderer?.syncTimebase()
|
||||||
|
}
|
||||||
|
|
||||||
|
func pipController(_ controller: PiPController, didStopPictureInPicture: Bool) {
|
||||||
|
print("PiP did stop")
|
||||||
|
// Ensure timebase is synced after PiP ends
|
||||||
|
renderer?.syncTimebase()
|
||||||
|
pipController?.updatePlaybackState()
|
||||||
|
}
|
||||||
|
|
||||||
|
func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void) {
|
||||||
|
print("PiP restore user interface")
|
||||||
|
completionHandler(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pipControllerPlay(_ controller: PiPController) {
|
||||||
|
print("PiP play requested")
|
||||||
|
intendedPlayState = true
|
||||||
|
renderer?.play()
|
||||||
|
pipController?.setPlaybackRate(1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pipControllerPause(_ controller: PiPController) {
|
||||||
|
print("PiP pause requested")
|
||||||
|
intendedPlayState = false
|
||||||
|
renderer?.pausePlayback()
|
||||||
|
pipController?.setPlaybackRate(0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pipController(_ controller: PiPController, skipByInterval interval: CMTime) {
|
||||||
|
let seconds = CMTimeGetSeconds(interval)
|
||||||
|
print("PiP skip by interval: \(seconds)")
|
||||||
|
let target = max(0, cachedPosition + seconds)
|
||||||
|
seekTo(position: target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pipControllerIsPlaying(_ controller: PiPController) -> Bool {
|
||||||
|
// Use intended state to ignore transient pauses during seeking
|
||||||
|
return intendedPlayState
|
||||||
|
}
|
||||||
|
|
||||||
|
func pipControllerDuration(_ controller: PiPController) -> Double {
|
||||||
|
return getDuration()
|
||||||
|
}
|
||||||
|
|
||||||
|
func pipControllerCurrentPosition(_ controller: PiPController) -> Double {
|
||||||
|
return getCurrentPosition()
|
||||||
|
}
|
||||||
|
}
|
||||||
233
modules/mpv-player/ios/PiPController.swift
Normal file
233
modules/mpv-player/ios/PiPController.swift
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import AVKit
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
protocol PiPControllerDelegate: AnyObject {
|
||||||
|
func pipController(_ controller: PiPController, willStartPictureInPicture: Bool)
|
||||||
|
func pipController(_ controller: PiPController, didStartPictureInPicture: Bool)
|
||||||
|
func pipController(_ controller: PiPController, willStopPictureInPicture: Bool)
|
||||||
|
func pipController(_ controller: PiPController, didStopPictureInPicture: Bool)
|
||||||
|
func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void)
|
||||||
|
func pipControllerPlay(_ controller: PiPController)
|
||||||
|
func pipControllerPause(_ controller: PiPController)
|
||||||
|
func pipController(_ controller: PiPController, skipByInterval interval: CMTime)
|
||||||
|
func pipControllerIsPlaying(_ controller: PiPController) -> Bool
|
||||||
|
func pipControllerDuration(_ controller: PiPController) -> Double
|
||||||
|
func pipControllerCurrentPosition(_ controller: PiPController) -> Double
|
||||||
|
}
|
||||||
|
|
||||||
|
final class PiPController: NSObject {
|
||||||
|
private var pipController: AVPictureInPictureController?
|
||||||
|
private weak var sampleBufferDisplayLayer: AVSampleBufferDisplayLayer?
|
||||||
|
|
||||||
|
weak var delegate: PiPControllerDelegate?
|
||||||
|
|
||||||
|
// Timebase for PiP progress tracking
|
||||||
|
private var timebase: CMTimebase?
|
||||||
|
|
||||||
|
// Track current time for PiP progress
|
||||||
|
private var currentTime: CMTime = .zero
|
||||||
|
private var currentDuration: Double = 0
|
||||||
|
|
||||||
|
var isPictureInPictureSupported: Bool {
|
||||||
|
return AVPictureInPictureController.isPictureInPictureSupported()
|
||||||
|
}
|
||||||
|
|
||||||
|
var isPictureInPictureActive: Bool {
|
||||||
|
return pipController?.isPictureInPictureActive ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
var isPictureInPicturePossible: Bool {
|
||||||
|
return pipController?.isPictureInPicturePossible ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
init(sampleBufferDisplayLayer: AVSampleBufferDisplayLayer) {
|
||||||
|
self.sampleBufferDisplayLayer = sampleBufferDisplayLayer
|
||||||
|
super.init()
|
||||||
|
setupTimebase()
|
||||||
|
setupPictureInPicture()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupTimebase() {
|
||||||
|
// Create a timebase for tracking playback time
|
||||||
|
var newTimebase: CMTimebase?
|
||||||
|
let status = CMTimebaseCreateWithSourceClock(
|
||||||
|
allocator: kCFAllocatorDefault,
|
||||||
|
sourceClock: CMClockGetHostTimeClock(),
|
||||||
|
timebaseOut: &newTimebase
|
||||||
|
)
|
||||||
|
|
||||||
|
if status == noErr, let tb = newTimebase {
|
||||||
|
timebase = tb
|
||||||
|
CMTimebaseSetTime(tb, time: .zero)
|
||||||
|
CMTimebaseSetRate(tb, rate: 0) // Start paused
|
||||||
|
|
||||||
|
// Set the control timebase on the display layer
|
||||||
|
sampleBufferDisplayLayer?.controlTimebase = tb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupPictureInPicture() {
|
||||||
|
guard isPictureInPictureSupported,
|
||||||
|
let displayLayer = sampleBufferDisplayLayer else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let contentSource = AVPictureInPictureController.ContentSource(
|
||||||
|
sampleBufferDisplayLayer: displayLayer,
|
||||||
|
playbackDelegate: self
|
||||||
|
)
|
||||||
|
|
||||||
|
pipController = AVPictureInPictureController(contentSource: contentSource)
|
||||||
|
pipController?.delegate = self
|
||||||
|
pipController?.requiresLinearPlayback = false
|
||||||
|
#if !os(tvOS)
|
||||||
|
pipController?.canStartPictureInPictureAutomaticallyFromInline = true
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
func startPictureInPicture() {
|
||||||
|
guard let pipController = pipController,
|
||||||
|
pipController.isPictureInPicturePossible else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pipController.startPictureInPicture()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopPictureInPicture() {
|
||||||
|
pipController?.stopPictureInPicture()
|
||||||
|
}
|
||||||
|
|
||||||
|
func invalidate() {
|
||||||
|
if Thread.isMainThread {
|
||||||
|
pipController?.invalidatePlaybackState()
|
||||||
|
} else {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.pipController?.invalidatePlaybackState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updatePlaybackState() {
|
||||||
|
// Only invalidate when PiP is active to avoid "no context menu visible" warnings
|
||||||
|
guard isPictureInPictureActive else { return }
|
||||||
|
|
||||||
|
if Thread.isMainThread {
|
||||||
|
pipController?.invalidatePlaybackState()
|
||||||
|
} else {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.pipController?.invalidatePlaybackState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the current playback time for PiP progress display
|
||||||
|
func setCurrentTime(_ time: CMTime) {
|
||||||
|
currentTime = time
|
||||||
|
|
||||||
|
// Update the timebase to reflect current position
|
||||||
|
if let tb = timebase {
|
||||||
|
CMTimebaseSetTime(tb, time: time)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only invalidate when PiP is active to avoid unnecessary updates
|
||||||
|
if isPictureInPictureActive {
|
||||||
|
updatePlaybackState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the current playback time from seconds
|
||||||
|
func setCurrentTimeFromSeconds(_ seconds: Double, duration: Double) {
|
||||||
|
guard seconds >= 0 else { return }
|
||||||
|
currentDuration = duration
|
||||||
|
let time = CMTime(seconds: seconds, preferredTimescale: 1000)
|
||||||
|
setCurrentTime(time)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the playback rate on the timebase (1.0 = playing, 0.0 = paused)
|
||||||
|
func setPlaybackRate(_ rate: Float) {
|
||||||
|
if let tb = timebase {
|
||||||
|
CMTimebaseSetRate(tb, rate: Float64(rate))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AVPictureInPictureControllerDelegate
|
||||||
|
|
||||||
|
extension PiPController: AVPictureInPictureControllerDelegate {
|
||||||
|
func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||||||
|
delegate?.pipController(self, willStartPictureInPicture: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||||||
|
delegate?.pipController(self, didStartPictureInPicture: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, failedToStartPictureInPictureWithError error: Error) {
|
||||||
|
print("Failed to start PiP: \(error)")
|
||||||
|
delegate?.pipController(self, didStartPictureInPicture: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pictureInPictureControllerWillStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||||||
|
delegate?.pipController(self, willStopPictureInPicture: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||||||
|
delegate?.pipController(self, didStopPictureInPicture: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) {
|
||||||
|
delegate?.pipController(self, restoreUserInterfaceForPictureInPictureStop: completionHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AVPictureInPictureSampleBufferPlaybackDelegate
|
||||||
|
|
||||||
|
extension PiPController: AVPictureInPictureSampleBufferPlaybackDelegate {
|
||||||
|
|
||||||
|
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, setPlaying playing: Bool) {
|
||||||
|
if playing {
|
||||||
|
delegate?.pipControllerPlay(self)
|
||||||
|
} else {
|
||||||
|
delegate?.pipControllerPause(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, didTransitionToRenderSize newRenderSize: CMVideoDimensions) {
|
||||||
|
}
|
||||||
|
|
||||||
|
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, skipByInterval skipInterval: CMTime, completion completionHandler: @escaping () -> Void) {
|
||||||
|
delegate?.pipController(self, skipByInterval: skipInterval)
|
||||||
|
completionHandler()
|
||||||
|
}
|
||||||
|
|
||||||
|
var isPlaying: Bool {
|
||||||
|
return delegate?.pipControllerIsPlaying(self) ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
var timeRangeForPlayback: CMTimeRange {
|
||||||
|
let duration = delegate?.pipControllerDuration(self) ?? 0
|
||||||
|
if duration > 0 {
|
||||||
|
let cmDuration = CMTime(seconds: duration, preferredTimescale: 1000)
|
||||||
|
return CMTimeRange(start: .zero, duration: cmDuration)
|
||||||
|
}
|
||||||
|
return CMTimeRange(start: .zero, duration: .positiveInfinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pictureInPictureControllerTimeRangeForPlayback(_ pictureInPictureController: AVPictureInPictureController) -> CMTimeRange {
|
||||||
|
return timeRangeForPlayback
|
||||||
|
}
|
||||||
|
|
||||||
|
func pictureInPictureControllerIsPlaybackPaused(_ pictureInPictureController: AVPictureInPictureController) -> Bool {
|
||||||
|
return !isPlaying
|
||||||
|
}
|
||||||
|
|
||||||
|
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, setPlaying playing: Bool, completion: @escaping () -> Void) {
|
||||||
|
if playing {
|
||||||
|
delegate?.pipControllerPlay(self)
|
||||||
|
} else {
|
||||||
|
delegate?.pipControllerPause(self)
|
||||||
|
}
|
||||||
|
completion()
|
||||||
|
}
|
||||||
|
}
|
||||||
40
modules/mpv-player/ios/PlayerPreset.swift
Normal file
40
modules/mpv-player/ios/PlayerPreset.swift
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct PlayerPreset: Identifiable, Hashable {
|
||||||
|
enum Identifier: String, CaseIterable {
|
||||||
|
case sdrRec709
|
||||||
|
case hdr10
|
||||||
|
case dolbyVisionP5
|
||||||
|
case dolbyVisionP8
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Stream: Hashable {
|
||||||
|
enum Source: Hashable {
|
||||||
|
case remote(URL)
|
||||||
|
case bundled(resource: String, withExtension: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
let source: Source
|
||||||
|
let note: String
|
||||||
|
|
||||||
|
func resolveURL() -> URL? {
|
||||||
|
switch source {
|
||||||
|
case .remote(let url):
|
||||||
|
return url
|
||||||
|
case .bundled(let resource, let ext):
|
||||||
|
return Bundle.main.url(forResource: resource, withExtension: ext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let id: Identifier
|
||||||
|
let title: String
|
||||||
|
let summary: String
|
||||||
|
let stream: Stream?
|
||||||
|
let commands: [[String]]
|
||||||
|
|
||||||
|
static var presets: [PlayerPreset] {
|
||||||
|
let list: [PlayerPreset] = []
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
}
|
||||||
72
modules/mpv-player/ios/SampleBufferDisplayView.swift
Normal file
72
modules/mpv-player/ios/SampleBufferDisplayView.swift
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import UIKit
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
final class SampleBufferDisplayView: UIView {
|
||||||
|
override class var layerClass: AnyClass { AVSampleBufferDisplayLayer.self }
|
||||||
|
|
||||||
|
var displayLayer: AVSampleBufferDisplayLayer {
|
||||||
|
return layer as! AVSampleBufferDisplayLayer
|
||||||
|
}
|
||||||
|
|
||||||
|
private(set) var pipController: PiPController?
|
||||||
|
|
||||||
|
weak var pipDelegate: PiPControllerDelegate? {
|
||||||
|
didSet {
|
||||||
|
pipController?.delegate = pipDelegate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
commonInit()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
super.init(coder: coder)
|
||||||
|
commonInit()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func commonInit() {
|
||||||
|
backgroundColor = .black
|
||||||
|
displayLayer.videoGravity = .resizeAspect
|
||||||
|
#if !os(tvOS)
|
||||||
|
#if compiler(>=6.0)
|
||||||
|
if #available(iOS 26.0, *) {
|
||||||
|
displayLayer.preferredDynamicRange = .automatic
|
||||||
|
} else if #available(iOS 17.0, *) {
|
||||||
|
displayLayer.wantsExtendedDynamicRangeContent = true
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
if #available(iOS 17.0, *) {
|
||||||
|
displayLayer.wantsExtendedDynamicRangeContent = true
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
setupPictureInPicture()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupPictureInPicture() {
|
||||||
|
pipController = PiPController(sampleBufferDisplayLayer: displayLayer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - PiP Control Methods
|
||||||
|
|
||||||
|
func startPictureInPicture() {
|
||||||
|
pipController?.startPictureInPicture()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopPictureInPicture() {
|
||||||
|
pipController?.stopPictureInPicture()
|
||||||
|
}
|
||||||
|
|
||||||
|
var isPictureInPictureSupported: Bool {
|
||||||
|
return pipController?.isPictureInPictureSupported ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
var isPictureInPictureActive: Bool {
|
||||||
|
return pipController?.isPictureInPictureActive ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
var isPictureInPicturePossible: Bool {
|
||||||
|
return pipController?.isPictureInPicturePossible ?? false
|
||||||
|
}
|
||||||
|
}
|
||||||
105
modules/mpv-player/src/MpvPlayer.types.ts
Normal file
105
modules/mpv-player/src/MpvPlayer.types.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import type { StyleProp, ViewStyle } from "react-native";
|
||||||
|
|
||||||
|
export type OnLoadEventPayload = {
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OnPlaybackStateChangePayload = {
|
||||||
|
isPaused?: boolean;
|
||||||
|
isPlaying?: boolean;
|
||||||
|
isLoading?: boolean;
|
||||||
|
isReadyToSeek?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OnProgressEventPayload = {
|
||||||
|
position: number;
|
||||||
|
duration: number;
|
||||||
|
progress: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OnErrorEventPayload = {
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OnTracksReadyEventPayload = Record<string, never>;
|
||||||
|
|
||||||
|
export type MpvPlayerModuleEvents = {
|
||||||
|
onChange: (params: ChangeEventPayload) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChangeEventPayload = {
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type VideoSource = {
|
||||||
|
url: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
externalSubtitles?: string[];
|
||||||
|
startPosition?: number;
|
||||||
|
autoplay?: boolean;
|
||||||
|
/** MPV subtitle track ID to select on start (1-based, -1 to disable) */
|
||||||
|
initialSubtitleId?: number;
|
||||||
|
/** MPV audio track ID to select on start (1-based) */
|
||||||
|
initialAudioId?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MpvPlayerViewProps = {
|
||||||
|
source?: VideoSource;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
onLoad?: (event: { nativeEvent: OnLoadEventPayload }) => void;
|
||||||
|
onPlaybackStateChange?: (event: {
|
||||||
|
nativeEvent: OnPlaybackStateChangePayload;
|
||||||
|
}) => void;
|
||||||
|
onProgress?: (event: { nativeEvent: OnProgressEventPayload }) => void;
|
||||||
|
onError?: (event: { nativeEvent: OnErrorEventPayload }) => void;
|
||||||
|
onTracksReady?: (event: { nativeEvent: OnTracksReadyEventPayload }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface MpvPlayerViewRef {
|
||||||
|
play: () => Promise<void>;
|
||||||
|
pause: () => Promise<void>;
|
||||||
|
seekTo: (position: number) => Promise<void>;
|
||||||
|
seekBy: (offset: number) => Promise<void>;
|
||||||
|
setSpeed: (speed: number) => Promise<void>;
|
||||||
|
getSpeed: () => Promise<number>;
|
||||||
|
isPaused: () => Promise<boolean>;
|
||||||
|
getCurrentPosition: () => Promise<number>;
|
||||||
|
getDuration: () => Promise<number>;
|
||||||
|
startPictureInPicture: () => Promise<void>;
|
||||||
|
stopPictureInPicture: () => Promise<void>;
|
||||||
|
isPictureInPictureSupported: () => Promise<boolean>;
|
||||||
|
isPictureInPictureActive: () => Promise<boolean>;
|
||||||
|
// Subtitle controls
|
||||||
|
getSubtitleTracks: () => Promise<SubtitleTrack[]>;
|
||||||
|
setSubtitleTrack: (trackId: number) => Promise<void>;
|
||||||
|
disableSubtitles: () => Promise<void>;
|
||||||
|
getCurrentSubtitleTrack: () => Promise<number>;
|
||||||
|
addSubtitleFile: (url: string, select?: boolean) => Promise<void>;
|
||||||
|
// Subtitle positioning
|
||||||
|
setSubtitlePosition: (position: number) => Promise<void>;
|
||||||
|
setSubtitleScale: (scale: number) => Promise<void>;
|
||||||
|
setSubtitleMarginY: (margin: number) => Promise<void>;
|
||||||
|
setSubtitleAlignX: (alignment: "left" | "center" | "right") => Promise<void>;
|
||||||
|
setSubtitleAlignY: (alignment: "top" | "center" | "bottom") => Promise<void>;
|
||||||
|
setSubtitleFontSize: (size: number) => Promise<void>;
|
||||||
|
// Audio controls
|
||||||
|
getAudioTracks: () => Promise<AudioTrack[]>;
|
||||||
|
setAudioTrack: (trackId: number) => Promise<void>;
|
||||||
|
getCurrentAudioTrack: () => Promise<number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SubtitleTrack = {
|
||||||
|
id: number;
|
||||||
|
title?: string;
|
||||||
|
lang?: string;
|
||||||
|
selected?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AudioTrack = {
|
||||||
|
id: number;
|
||||||
|
title?: string;
|
||||||
|
lang?: string;
|
||||||
|
codec?: string;
|
||||||
|
channels?: number;
|
||||||
|
selected?: boolean;
|
||||||
|
};
|
||||||
11
modules/mpv-player/src/MpvPlayerModule.ts
Normal file
11
modules/mpv-player/src/MpvPlayerModule.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { NativeModule, requireNativeModule } from "expo";
|
||||||
|
|
||||||
|
import { MpvPlayerModuleEvents } from "./MpvPlayer.types";
|
||||||
|
|
||||||
|
declare class MpvPlayerModule extends NativeModule<MpvPlayerModuleEvents> {
|
||||||
|
hello(): string;
|
||||||
|
setValueAsync(value: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This call loads the native module object from the JSI.
|
||||||
|
export default requireNativeModule<MpvPlayerModule>("MpvPlayer");
|
||||||
19
modules/mpv-player/src/MpvPlayerModule.web.ts
Normal file
19
modules/mpv-player/src/MpvPlayerModule.web.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { NativeModule, registerWebModule } from "expo";
|
||||||
|
|
||||||
|
import { ChangeEventPayload } from "./MpvPlayer.types";
|
||||||
|
|
||||||
|
type MpvPlayerModuleEvents = {
|
||||||
|
onChange: (params: ChangeEventPayload) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
class MpvPlayerModule extends NativeModule<MpvPlayerModuleEvents> {
|
||||||
|
PI = Math.PI;
|
||||||
|
async setValueAsync(value: string): Promise<void> {
|
||||||
|
this.emit("onChange", { value });
|
||||||
|
}
|
||||||
|
hello() {
|
||||||
|
return "Hello world! 👋";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default registerWebModule(MpvPlayerModule, "MpvPlayerModule");
|
||||||
101
modules/mpv-player/src/MpvPlayerView.tsx
Normal file
101
modules/mpv-player/src/MpvPlayerView.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { requireNativeView } from "expo";
|
||||||
|
import * as React from "react";
|
||||||
|
import { useImperativeHandle, useRef } from "react";
|
||||||
|
|
||||||
|
import { MpvPlayerViewProps, MpvPlayerViewRef } from "./MpvPlayer.types";
|
||||||
|
|
||||||
|
const NativeView: React.ComponentType<MpvPlayerViewProps & { ref?: any }> =
|
||||||
|
requireNativeView("MpvPlayer");
|
||||||
|
|
||||||
|
export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
|
||||||
|
function MpvPlayerView(props, ref) {
|
||||||
|
const nativeRef = useRef<any>(null);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
play: async () => {
|
||||||
|
await nativeRef.current?.play();
|
||||||
|
},
|
||||||
|
pause: async () => {
|
||||||
|
await nativeRef.current?.pause();
|
||||||
|
},
|
||||||
|
seekTo: async (position: number) => {
|
||||||
|
await nativeRef.current?.seekTo(position);
|
||||||
|
},
|
||||||
|
seekBy: async (offset: number) => {
|
||||||
|
await nativeRef.current?.seekBy(offset);
|
||||||
|
},
|
||||||
|
setSpeed: async (speed: number) => {
|
||||||
|
await nativeRef.current?.setSpeed(speed);
|
||||||
|
},
|
||||||
|
getSpeed: async () => {
|
||||||
|
return await nativeRef.current?.getSpeed();
|
||||||
|
},
|
||||||
|
isPaused: async () => {
|
||||||
|
return await nativeRef.current?.isPaused();
|
||||||
|
},
|
||||||
|
getCurrentPosition: async () => {
|
||||||
|
return await nativeRef.current?.getCurrentPosition();
|
||||||
|
},
|
||||||
|
getDuration: async () => {
|
||||||
|
return await nativeRef.current?.getDuration();
|
||||||
|
},
|
||||||
|
startPictureInPicture: async () => {
|
||||||
|
await nativeRef.current?.startPictureInPicture();
|
||||||
|
},
|
||||||
|
stopPictureInPicture: async () => {
|
||||||
|
await nativeRef.current?.stopPictureInPicture();
|
||||||
|
},
|
||||||
|
isPictureInPictureSupported: async () => {
|
||||||
|
return await nativeRef.current?.isPictureInPictureSupported();
|
||||||
|
},
|
||||||
|
isPictureInPictureActive: async () => {
|
||||||
|
return await nativeRef.current?.isPictureInPictureActive();
|
||||||
|
},
|
||||||
|
getSubtitleTracks: async () => {
|
||||||
|
return await nativeRef.current?.getSubtitleTracks();
|
||||||
|
},
|
||||||
|
setSubtitleTrack: async (trackId: number) => {
|
||||||
|
await nativeRef.current?.setSubtitleTrack(trackId);
|
||||||
|
},
|
||||||
|
disableSubtitles: async () => {
|
||||||
|
await nativeRef.current?.disableSubtitles();
|
||||||
|
},
|
||||||
|
getCurrentSubtitleTrack: async () => {
|
||||||
|
return await nativeRef.current?.getCurrentSubtitleTrack();
|
||||||
|
},
|
||||||
|
addSubtitleFile: async (url: string, select = true) => {
|
||||||
|
await nativeRef.current?.addSubtitleFile(url, select);
|
||||||
|
},
|
||||||
|
setSubtitlePosition: async (position: number) => {
|
||||||
|
await nativeRef.current?.setSubtitlePosition(position);
|
||||||
|
},
|
||||||
|
setSubtitleScale: async (scale: number) => {
|
||||||
|
await nativeRef.current?.setSubtitleScale(scale);
|
||||||
|
},
|
||||||
|
setSubtitleMarginY: async (margin: number) => {
|
||||||
|
await nativeRef.current?.setSubtitleMarginY(margin);
|
||||||
|
},
|
||||||
|
setSubtitleAlignX: async (alignment: "left" | "center" | "right") => {
|
||||||
|
await nativeRef.current?.setSubtitleAlignX(alignment);
|
||||||
|
},
|
||||||
|
setSubtitleAlignY: async (alignment: "top" | "center" | "bottom") => {
|
||||||
|
await nativeRef.current?.setSubtitleAlignY(alignment);
|
||||||
|
},
|
||||||
|
setSubtitleFontSize: async (size: number) => {
|
||||||
|
await nativeRef.current?.setSubtitleFontSize(size);
|
||||||
|
},
|
||||||
|
// Audio controls
|
||||||
|
getAudioTracks: async () => {
|
||||||
|
return await nativeRef.current?.getAudioTracks();
|
||||||
|
},
|
||||||
|
setAudioTrack: async (trackId: number) => {
|
||||||
|
await nativeRef.current?.setAudioTrack(trackId);
|
||||||
|
},
|
||||||
|
getCurrentAudioTrack: async () => {
|
||||||
|
return await nativeRef.current?.getCurrentAudioTrack();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return <NativeView ref={nativeRef} {...props} />;
|
||||||
|
},
|
||||||
|
);
|
||||||
14
modules/mpv-player/src/MpvPlayerView.web.tsx
Normal file
14
modules/mpv-player/src/MpvPlayerView.web.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { MpvPlayerViewProps } from "./MpvPlayer.types";
|
||||||
|
|
||||||
|
export default function MpvPlayerView(props: MpvPlayerViewProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<iframe
|
||||||
|
title='MPV Player'
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
src={props.url}
|
||||||
|
onLoad={() => props.onLoad({ nativeEvent: { url: props.url } })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
modules/mpv-player/src/index.ts
Normal file
3
modules/mpv-player/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./MpvPlayer.types";
|
||||||
|
export { default as MpvPlayerModule } from "./MpvPlayerModule";
|
||||||
|
export { default as MpvPlayerView } from "./MpvPlayerView";
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"platforms": ["ios", "tvos"],
|
|
||||||
"ios": {
|
|
||||||
"modules": ["VlcPlayer4Module"],
|
|
||||||
"appDelegateSubscribers": ["AppLifecycleDelegate"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import ExpoModulesCore
|
|
||||||
|
|
||||||
protocol SimpleAppLifecycleListener {
|
|
||||||
func applicationDidEnterBackground() -> Void
|
|
||||||
func applicationDidEnterForeground() -> Void
|
|
||||||
}
|
|
||||||
|
|
||||||
public class AppLifecycleDelegate: ExpoAppDelegateSubscriber {
|
|
||||||
public func applicationDidBecomeActive(_ application: UIApplication) {
|
|
||||||
// The app has become active.
|
|
||||||
}
|
|
||||||
|
|
||||||
public func applicationWillResignActive(_ application: UIApplication) {
|
|
||||||
// The app is about to become inactive.
|
|
||||||
}
|
|
||||||
|
|
||||||
public func applicationDidEnterBackground(_ application: UIApplication) {
|
|
||||||
VLCManager.shared.listeners.forEach { listener in
|
|
||||||
listener.applicationDidEnterBackground()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func applicationWillEnterForeground(_ application: UIApplication) {
|
|
||||||
VLCManager.shared.listeners.forEach { listener in
|
|
||||||
listener.applicationDidEnterForeground()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func applicationWillTerminate(_ application: UIApplication) {
|
|
||||||
// The app is about to terminate.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
class VLCManager {
|
|
||||||
static let shared = VLCManager()
|
|
||||||
var listeners: [SimpleAppLifecycleListener] = []
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
Pod::Spec.new do |s|
|
|
||||||
s.name = 'VlcPlayer4'
|
|
||||||
s.version = '4.0.0a10'
|
|
||||||
s.summary = 'A sample project summary'
|
|
||||||
s.description = 'A sample project description'
|
|
||||||
s.author = ''
|
|
||||||
s.homepage = 'https://docs.expo.dev/modules/'
|
|
||||||
s.platforms = { :ios => '13.4', :tvos => '16' }
|
|
||||||
s.source = { git: '' }
|
|
||||||
s.static_framework = true
|
|
||||||
|
|
||||||
s.dependency 'ExpoModulesCore'
|
|
||||||
s.ios.dependency 'VLCKit', s.version
|
|
||||||
s.tvos.dependency 'VLCKit', s.version
|
|
||||||
|
|
||||||
# Swift/Objective-C compatibility
|
|
||||||
s.pod_target_xcconfig = {
|
|
||||||
'DEFINES_MODULE' => 'YES',
|
|
||||||
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
|
||||||
}
|
|
||||||
s.source_files = "*.{h,m,mm,swift,hpp,cpp}"
|
|
||||||
end
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import ExpoModulesCore
|
|
||||||
|
|
||||||
public class VlcPlayer4Module: Module {
|
|
||||||
public func definition() -> ModuleDefinition {
|
|
||||||
Name("VlcPlayer4")
|
|
||||||
View(VlcPlayer4View.self) {
|
|
||||||
Prop("source") { (view: VlcPlayer4View, source: [String: Any]) in
|
|
||||||
view.setSource(source)
|
|
||||||
}
|
|
||||||
|
|
||||||
Prop("paused") { (view: VlcPlayer4View, paused: Bool) in
|
|
||||||
if paused {
|
|
||||||
view.pause()
|
|
||||||
} else {
|
|
||||||
view.play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Events(
|
|
||||||
"onPlaybackStateChanged",
|
|
||||||
"onVideoStateChange",
|
|
||||||
"onVideoLoadStart",
|
|
||||||
"onVideoLoadEnd",
|
|
||||||
"onVideoProgress",
|
|
||||||
"onVideoError",
|
|
||||||
"onPipStarted"
|
|
||||||
)
|
|
||||||
|
|
||||||
AsyncFunction("startPictureInPicture") { (view: VlcPlayer4View) in
|
|
||||||
view.startPictureInPicture()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("play") { (view: VlcPlayer4View) in
|
|
||||||
view.play()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("pause") { (view: VlcPlayer4View) in
|
|
||||||
view.pause()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("stop") { (view: VlcPlayer4View) in
|
|
||||||
view.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("seekTo") { (view: VlcPlayer4View, time: Int32) in
|
|
||||||
view.seekTo(time)
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("setAudioTrack") { (view: VlcPlayer4View, trackIndex: Int) in
|
|
||||||
view.setAudioTrack(trackIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("getAudioTracks") { (view: VlcPlayer4View) -> [[String: Any]]? in
|
|
||||||
return view.getAudioTracks()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("setSubtitleTrack") { (view: VlcPlayer4View, trackIndex: Int) in
|
|
||||||
view.setSubtitleTrack(trackIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("getSubtitleTracks") { (view: VlcPlayer4View) -> [[String: Any]]? in
|
|
||||||
return view.getSubtitleTracks()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("setSubtitleURL") {
|
|
||||||
(view: VlcPlayer4View, url: String, name: String) in
|
|
||||||
view.setSubtitleURL(url, name: name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,507 +0,0 @@
|
|||||||
import ExpoModulesCore
|
|
||||||
import UIKit
|
|
||||||
import VLCKit
|
|
||||||
import os
|
|
||||||
|
|
||||||
public class VLCPlayerView: UIView {
|
|
||||||
func setupView(parent: UIView) {
|
|
||||||
self.backgroundColor = .black
|
|
||||||
self.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
self.leadingAnchor.constraint(equalTo: parent.leadingAnchor),
|
|
||||||
self.trailingAnchor.constraint(equalTo: parent.trailingAnchor),
|
|
||||||
self.topAnchor.constraint(equalTo: parent.topAnchor),
|
|
||||||
self.bottomAnchor.constraint(equalTo: parent.bottomAnchor),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
public override func layoutSubviews() {
|
|
||||||
super.layoutSubviews()
|
|
||||||
|
|
||||||
for subview in subviews {
|
|
||||||
subview.frame = bounds
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class VLCPlayerWrapper: NSObject {
|
|
||||||
private var lastProgressCall = Date().timeIntervalSince1970
|
|
||||||
public var player: VLCMediaPlayer = VLCMediaPlayer()
|
|
||||||
private var updatePlayerState: (() -> Void)?
|
|
||||||
private var updateVideoProgress: (() -> Void)?
|
|
||||||
private var playerView: VLCPlayerView = VLCPlayerView()
|
|
||||||
public weak var pipController: VLCPictureInPictureWindowControlling?
|
|
||||||
|
|
||||||
override public init() {
|
|
||||||
super.init()
|
|
||||||
player.delegate = self
|
|
||||||
player.drawable = self
|
|
||||||
player.scaleFactor = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
public func setup(
|
|
||||||
parent: UIView,
|
|
||||||
updatePlayerState: (() -> Void)?,
|
|
||||||
updateVideoProgress: (() -> Void)?
|
|
||||||
) {
|
|
||||||
self.updatePlayerState = updatePlayerState
|
|
||||||
self.updateVideoProgress = updateVideoProgress
|
|
||||||
|
|
||||||
player.delegate = self
|
|
||||||
parent.addSubview(playerView)
|
|
||||||
playerView.setupView(parent: parent)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func getPlayerView() -> UIView {
|
|
||||||
return playerView
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - VLCPictureInPictureDrawable
|
|
||||||
extension VLCPlayerWrapper: VLCPictureInPictureDrawable {
|
|
||||||
public func mediaController() -> (any VLCPictureInPictureMediaControlling)! {
|
|
||||||
return self
|
|
||||||
}
|
|
||||||
|
|
||||||
public func pictureInPictureReady() -> (((any VLCPictureInPictureWindowControlling)?) -> Void)!
|
|
||||||
{
|
|
||||||
return { [weak self] controller in
|
|
||||||
self?.pipController = controller
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - VLCPictureInPictureMediaControlling
|
|
||||||
extension VLCPlayerWrapper: VLCPictureInPictureMediaControlling {
|
|
||||||
func mediaTime() -> Int64 {
|
|
||||||
return player.time.value?.int64Value ?? 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func mediaLength() -> Int64 {
|
|
||||||
return player.media?.length.value?.int64Value ?? 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func play() {
|
|
||||||
player.play()
|
|
||||||
}
|
|
||||||
|
|
||||||
func pause() {
|
|
||||||
player.pause()
|
|
||||||
}
|
|
||||||
|
|
||||||
func seek(by offset: Int64, completion: @escaping () -> Void) {
|
|
||||||
player.jump(withOffset: Int32(offset), completion: completion)
|
|
||||||
}
|
|
||||||
|
|
||||||
func isMediaSeekable() -> Bool {
|
|
||||||
return player.isSeekable
|
|
||||||
}
|
|
||||||
|
|
||||||
func isMediaPlaying() -> Bool {
|
|
||||||
return player.isPlaying
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - VLCDrawable
|
|
||||||
extension VLCPlayerWrapper: VLCDrawable {
|
|
||||||
public func addSubview(_ view: UIView) {
|
|
||||||
playerView.addSubview(view)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func bounds() -> CGRect {
|
|
||||||
return playerView.bounds
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - VLCMediaPlayerDelegate
|
|
||||||
extension VLCPlayerWrapper: VLCMediaPlayerDelegate {
|
|
||||||
func mediaPlayerTimeChanged(_ aNotification: Notification) {
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
let timeNow = Date().timeIntervalSince1970
|
|
||||||
if timeNow - self.lastProgressCall >= 1 {
|
|
||||||
self.lastProgressCall = timeNow
|
|
||||||
self.updateVideoProgress?()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mediaPlayerStateChanged(_ state: VLCMediaPlayerState) {
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
self.updatePlayerState?()
|
|
||||||
|
|
||||||
guard let pipController = self.pipController else { return }
|
|
||||||
pipController.invalidatePlaybackState()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class VlcPlayer4View: ExpoView {
|
|
||||||
let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "VlcPlayer4View")
|
|
||||||
|
|
||||||
private var vlc: VLCPlayerWrapper = VLCPlayerWrapper()
|
|
||||||
private var progressUpdateInterval: TimeInterval = 1.0 // Update interval set to 1 second
|
|
||||||
private var isPaused: Bool = false
|
|
||||||
private var customSubtitles: [(internalName: String, originalName: String)] = []
|
|
||||||
private var startPosition: Int32 = 0
|
|
||||||
private var externalTrack: [String: String]?
|
|
||||||
private var isStopping: Bool = false // Define isStopping here
|
|
||||||
private var externalSubtitles: [[String: String]]?
|
|
||||||
var hasSource = false
|
|
||||||
var initialSeekPerformed = false
|
|
||||||
// A flag variable determinging if we should perform the initial seek. Its either transcoding or offline playback. that makes
|
|
||||||
var shouldPerformInitialSeek: Bool = false
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Initialization
|
|
||||||
required init(appContext: AppContext? = nil) {
|
|
||||||
super.init(appContext: appContext)
|
|
||||||
setupVLC()
|
|
||||||
setupNotifications()
|
|
||||||
VLCManager.shared.listeners.append(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Setup
|
|
||||||
private func setupVLC() {
|
|
||||||
vlc.setup(
|
|
||||||
parent: self,
|
|
||||||
updatePlayerState: updatePlayerState,
|
|
||||||
updateVideoProgress: updateVideoProgress
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Workaround: When playing an HLS video for the first time, seeking to a specific time immediately can cause a crash.
|
|
||||||
// To avoid this, we wait until the video has started playing before performing the initial seek.
|
|
||||||
func performInitialSeek() {
|
|
||||||
guard !initialSeekPerformed,
|
|
||||||
startPosition > 0,
|
|
||||||
shouldPerformInitialSeek,
|
|
||||||
vlc.player.isSeekable else { return }
|
|
||||||
|
|
||||||
initialSeekPerformed = true
|
|
||||||
logger.debug("First time update, performing initial seek to \(self.startPosition) seconds")
|
|
||||||
vlc.player.time = VLCTime(int: startPosition * 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setupNotifications() {
|
|
||||||
NotificationCenter.default.addObserver(
|
|
||||||
self, selector: #selector(applicationWillResignActive),
|
|
||||||
name: UIApplication.willResignActiveNotification, object: nil)
|
|
||||||
NotificationCenter.default.addObserver(
|
|
||||||
self, selector: #selector(applicationDidBecomeActive),
|
|
||||||
name: UIApplication.didBecomeActiveNotification, object: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Public Methods
|
|
||||||
func startPictureInPicture() {
|
|
||||||
self.vlc.pipController?.stateChangeEventHandler = { (isStarted: Bool) in
|
|
||||||
self.onPipStarted?(["pipStarted": isStarted])
|
|
||||||
}
|
|
||||||
self.vlc.pipController?.startPictureInPicture()
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func play() {
|
|
||||||
self.vlc.player.play()
|
|
||||||
self.isPaused = false
|
|
||||||
logger.debug("Play")
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func pause() {
|
|
||||||
self.vlc.player.pause()
|
|
||||||
self.isPaused = true
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func seekTo(_ time: Int32) {
|
|
||||||
let wasPlaying = vlc.player.isPlaying
|
|
||||||
if wasPlaying {
|
|
||||||
self.pause()
|
|
||||||
}
|
|
||||||
|
|
||||||
if let duration = vlc.player.media?.length.intValue {
|
|
||||||
logger.debug("Seeking to time: \(time) Video Duration \(duration)")
|
|
||||||
|
|
||||||
// If the specified time is greater than the duration, seek to the end
|
|
||||||
let seekTime = time > duration ? duration - 1000 : time
|
|
||||||
vlc.player.time = VLCTime(int: seekTime)
|
|
||||||
self.updatePlayerState()
|
|
||||||
|
|
||||||
// Let mediaPlayerStateChanged handle play state change
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
||||||
if wasPlaying {
|
|
||||||
self.play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.error("Unable to retrieve video duration")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func setSource(_ source: [String: Any]) {
|
|
||||||
logger.debug("Setting source...")
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
if self.hasSource {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:]
|
|
||||||
self.externalTrack = source["externalTrack"] as? [String: String]
|
|
||||||
let initOptions: [String] = source["initOptions"] as? [String] ?? []
|
|
||||||
self.startPosition = source["startPosition"] as? Int32 ?? 0
|
|
||||||
self.externalSubtitles = source["externalSubtitles"] as? [[String: String]]
|
|
||||||
|
|
||||||
for item in initOptions {
|
|
||||||
let option = item.components(separatedBy: "=")
|
|
||||||
mediaOptions.updateValue(
|
|
||||||
option[1], forKey: option[0].replacingOccurrences(of: "--", with: ""))
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let uri = source["uri"] as? String, !uri.isEmpty else {
|
|
||||||
logger.error("Invalid or empty URI")
|
|
||||||
self.onVideoError?(["error": "Invalid or empty URI"])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let autoplay = source["autoplay"] as? Bool ?? false
|
|
||||||
let isNetwork = source["isNetwork"] as? Bool ?? false
|
|
||||||
|
|
||||||
// Set shouldPeformIntial based on isTranscoding and is not a network stream
|
|
||||||
self.shouldPerformInitialSeek = uri.contains("m3u8") || !isNetwork
|
|
||||||
self.onVideoLoadStart?(["target": self.reactTag ?? NSNull()])
|
|
||||||
|
|
||||||
let media: VLCMedia!
|
|
||||||
if isNetwork {
|
|
||||||
logger.debug("Loading network file: \(uri)")
|
|
||||||
media = VLCMedia(url: URL(string: uri)!)
|
|
||||||
} else {
|
|
||||||
logger.debug("Loading local file: \(uri)")
|
|
||||||
if uri.starts(with: "file://"), let url = URL(string: uri) {
|
|
||||||
media = VLCMedia(url: url)
|
|
||||||
} else {
|
|
||||||
media = VLCMedia(path: uri)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug("Media options: \(mediaOptions)")
|
|
||||||
media.addOptions(mediaOptions)
|
|
||||||
|
|
||||||
self.vlc.player.media = media
|
|
||||||
self.setInitialExternalSubtitles()
|
|
||||||
self.hasSource = true
|
|
||||||
if autoplay {
|
|
||||||
logger.info("Playing...")
|
|
||||||
// The Video is not transcoding so it its safe to seek to the start position.
|
|
||||||
if !self.shouldPerformInitialSeek {
|
|
||||||
self.vlc.player.time = VLCTime(number: NSNumber(value: self.startPosition * 1000))
|
|
||||||
}
|
|
||||||
self.play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func setAudioTrack(_ trackIndex: Int) {
|
|
||||||
print("Setting audio track: \(trackIndex)")
|
|
||||||
let track = self.vlc.player.audioTracks[trackIndex]
|
|
||||||
track.isSelectedExclusively = true
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func getAudioTracks() -> [[String: Any]]? {
|
|
||||||
return vlc.player.audioTracks.enumerated().map {
|
|
||||||
return ["name": $1.trackName, "index": $0]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func setSubtitleTrack(_ trackIndex: Int) {
|
|
||||||
logger.debug("Attempting to set subtitle track to index: \(trackIndex)")
|
|
||||||
if trackIndex == -1 {
|
|
||||||
logger.debug("Disabling all subtitles")
|
|
||||||
for track in self.vlc.player.textTracks {
|
|
||||||
track.isSelected = false
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let track = self.vlc.player.textTracks[trackIndex]
|
|
||||||
track.isSelectedExclusively = true;
|
|
||||||
logger.debug("Current subtitle track index after setting: \(track.trackName)")
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func setSubtitleURL(_ subtitleURL: String, name: String) {
|
|
||||||
guard let url = URL(string: subtitleURL) else {
|
|
||||||
logger.error("Invalid subtitle URL")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let result = self.vlc.player.addPlaybackSlave(url, type: .subtitle, enforce: false)
|
|
||||||
if result == 0 {
|
|
||||||
let internalName = "Track \(self.customSubtitles.count)"
|
|
||||||
self.customSubtitles.append((internalName: internalName, originalName: name))
|
|
||||||
logger.debug("Subtitle added with result: \(result) \(internalName)")
|
|
||||||
} else {
|
|
||||||
logger.debug("Failed to add subtitle")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func getSubtitleTracks() -> [[String: Any]]? {
|
|
||||||
if self.vlc.player.textTracks.count == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug("Number of subtitle tracks: \(self.vlc.player.textTracks.count)")
|
|
||||||
|
|
||||||
let tracks = self.vlc.player.textTracks.enumerated().map { (index, track) in
|
|
||||||
if let customSubtitle = customSubtitles.first(where: {
|
|
||||||
$0.internalName == track.trackName
|
|
||||||
}) {
|
|
||||||
return ["name": customSubtitle.originalName, "index": index]
|
|
||||||
} else {
|
|
||||||
return ["name": track.trackName, "index": index]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug("Subtitle tracks: \(tracks)")
|
|
||||||
return tracks
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func stop(completion: (() -> Void)? = nil) {
|
|
||||||
logger.debug("Stopping media...")
|
|
||||||
guard !isStopping else {
|
|
||||||
completion?()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
isStopping = true
|
|
||||||
|
|
||||||
// If we're not on the main thread, dispatch to main thread
|
|
||||||
if !Thread.isMainThread {
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
self?.performStop(completion: completion)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
performStop(completion: completion)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Methods
|
|
||||||
|
|
||||||
@objc private func applicationWillResignActive() {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func applicationDidBecomeActive() {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setInitialExternalSubtitles() {
|
|
||||||
if let externalSubtitles = self.externalSubtitles {
|
|
||||||
for subtitle in externalSubtitles {
|
|
||||||
if let subtitleName = subtitle["name"],
|
|
||||||
let subtitleURL = subtitle["DeliveryUrl"]
|
|
||||||
{
|
|
||||||
print("Setting external subtitle: \(subtitleName) \(subtitleURL)")
|
|
||||||
self.setSubtitleURL(subtitleURL, name: subtitleName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func performStop(completion: (() -> Void)? = nil) {
|
|
||||||
// Stop the media player
|
|
||||||
vlc.player.stop()
|
|
||||||
|
|
||||||
// Remove observer
|
|
||||||
NotificationCenter.default.removeObserver(self)
|
|
||||||
|
|
||||||
// Clear the video view
|
|
||||||
vlc.getPlayerView().removeFromSuperview()
|
|
||||||
|
|
||||||
isStopping = false
|
|
||||||
completion?()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateVideoProgress() {
|
|
||||||
guard self.vlc.player.media != nil else { return }
|
|
||||||
|
|
||||||
let currentTimeMs = self.vlc.player.time.intValue
|
|
||||||
let durationMs = self.vlc.player.media?.length.intValue ?? 0
|
|
||||||
|
|
||||||
logger.debug("Current time: \(currentTimeMs)")
|
|
||||||
self.onVideoProgress?([
|
|
||||||
"currentTime": currentTimeMs,
|
|
||||||
"duration": durationMs,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updatePlayerState() {
|
|
||||||
let player = self.vlc.player
|
|
||||||
if player.isPlaying {
|
|
||||||
performInitialSeek()
|
|
||||||
}
|
|
||||||
self.onVideoStateChange?([
|
|
||||||
"target": self.reactTag ?? NSNull(),
|
|
||||||
"currentTime": player.time.intValue,
|
|
||||||
"duration": player.media?.length.intValue ?? 0,
|
|
||||||
"error": false,
|
|
||||||
"isPlaying": player.isPlaying,
|
|
||||||
"isBuffering": !player.isPlaying && player.state == VLCMediaPlayerState.buffering,
|
|
||||||
"state": player.state.description,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Expo Events
|
|
||||||
@objc var onPlaybackStateChanged: RCTDirectEventBlock?
|
|
||||||
@objc var onVideoLoadStart: RCTDirectEventBlock?
|
|
||||||
@objc var onVideoStateChange: RCTDirectEventBlock?
|
|
||||||
@objc var onVideoProgress: RCTDirectEventBlock?
|
|
||||||
@objc var onVideoLoadEnd: RCTDirectEventBlock?
|
|
||||||
@objc var onVideoError: RCTDirectEventBlock?
|
|
||||||
@objc var onPipStarted: RCTDirectEventBlock?
|
|
||||||
|
|
||||||
// MARK: - Deinitialization
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
logger.debug("Deinitialization")
|
|
||||||
performStop()
|
|
||||||
VLCManager.shared.listeners.removeAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - SimpleAppLifecycleListener
|
|
||||||
extension VlcPlayer4View: SimpleAppLifecycleListener {
|
|
||||||
func applicationDidEnterBackground() {
|
|
||||||
logger.debug("Entering background")
|
|
||||||
}
|
|
||||||
|
|
||||||
func applicationDidEnterForeground() {
|
|
||||||
logger.debug("Entering foreground, is player visible? \(self.vlc.getPlayerView().superview != nil)")
|
|
||||||
if !self.vlc.getPlayerView().isDescendant(of: self) {
|
|
||||||
logger.debug("Player view is missing. Adding back as subview")
|
|
||||||
self.addSubview(self.vlc.getPlayerView())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Current solution to fixing black screen when re-entering application
|
|
||||||
if let videoTrack = self.vlc.player.videoTracks.first(where: { $0.isSelected == true }),
|
|
||||||
!self.vlc.isMediaPlaying()
|
|
||||||
{
|
|
||||||
videoTrack.isSelected = false
|
|
||||||
videoTrack.isSelectedExclusively = true
|
|
||||||
self.vlc.player.play()
|
|
||||||
self.vlc.player.pause()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension VLCMediaPlayerState {
|
|
||||||
var description: String {
|
|
||||||
switch self {
|
|
||||||
case .opening: return "Opening"
|
|
||||||
case .buffering: return "Buffering"
|
|
||||||
case .playing: return "Playing"
|
|
||||||
case .paused: return "Paused"
|
|
||||||
case .stopped: return "Stopped"
|
|
||||||
case .error: return "Error"
|
|
||||||
case .stopping: return "Stopping"
|
|
||||||
@unknown default: return "Unknown"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { requireNativeModule } from "expo-modules-core";
|
|
||||||
|
|
||||||
// It loads the native module object from the JSI or falls back to
|
|
||||||
// the bridge module (from NativeModulesProxy) if the remote debugger is on.
|
|
||||||
export default requireNativeModule("VlcPlayer4");
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
plugins {
|
|
||||||
id 'com.android.library'
|
|
||||||
id 'kotlin-android'
|
|
||||||
id 'kotlin-kapt'
|
|
||||||
}
|
|
||||||
|
|
||||||
group = 'expo.modules.vlcplayer'
|
|
||||||
version = '0.6.0'
|
|
||||||
|
|
||||||
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
|
||||||
def kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25'
|
|
||||||
|
|
||||||
apply from: expoModulesCorePlugin
|
|
||||||
|
|
||||||
applyKotlinExpoModulesCorePlugin()
|
|
||||||
useDefaultAndroidSdkVersions()
|
|
||||||
useCoreDependencies()
|
|
||||||
useExpoPublishing()
|
|
||||||
|
|
||||||
android {
|
|
||||||
namespace "expo.modules.vlcplayer"
|
|
||||||
|
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility JavaVersion.VERSION_17
|
|
||||||
targetCompatibility JavaVersion.VERSION_17
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = "17"
|
|
||||||
}
|
|
||||||
|
|
||||||
lintOptions {
|
|
||||||
abortOnError false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation 'org.videolan.android:libvlc-all:3.6.0'
|
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
|
|
||||||
kotlinOptions {
|
|
||||||
freeCompilerArgs += ["-Xshow-kotlin-compiler-errors"]
|
|
||||||
jvmTarget = "17"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
<manifest>
|
|
||||||
</manifest>
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
package expo.modules.vlcplayer
|
|
||||||
|
|
||||||
import expo.modules.core.interfaces.ReactActivityLifecycleListener
|
|
||||||
|
|
||||||
// TODO: Creating a separate package class and adding this as a lifecycle listener did not work...
|
|
||||||
// https://docs.expo.dev/modules/android-lifecycle-listeners/
|
|
||||||
object VLCManager: ReactActivityLifecycleListener {
|
|
||||||
val listeners: MutableList<ReactActivityLifecycleListener> = mutableListOf()
|
|
||||||
// override fun onCreate(activity: Activity?, savedInstanceState: Bundle?) {
|
|
||||||
// listeners.forEach {
|
|
||||||
// it.onCreate(activity, savedInstanceState)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// override fun onResume(activity: Activity?) {
|
|
||||||
// listeners.forEach {
|
|
||||||
// it.onResume(activity)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// override fun onPause(activity: Activity?) {
|
|
||||||
// listeners.forEach {
|
|
||||||
// it.onPause(activity)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// override fun onUserLeaveHint(activity: Activity?) {
|
|
||||||
// listeners.forEach {
|
|
||||||
// it.onUserLeaveHint(activity)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// override fun onDestroy(activity: Activity?) {
|
|
||||||
// listeners.forEach {
|
|
||||||
// it.onDestroy(activity)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
package expo.modules.vlcplayer
|
|
||||||
|
|
||||||
import androidx.core.os.bundleOf
|
|
||||||
import expo.modules.kotlin.modules.Module
|
|
||||||
import expo.modules.kotlin.modules.ModuleDefinition
|
|
||||||
|
|
||||||
class VlcPlayerModule : Module() {
|
|
||||||
override fun definition() = ModuleDefinition {
|
|
||||||
Name("VlcPlayer")
|
|
||||||
|
|
||||||
OnActivityEntersForeground {
|
|
||||||
VLCManager.listeners.forEach {
|
|
||||||
it.onResume(appContext.currentActivity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
OnActivityEntersBackground {
|
|
||||||
VLCManager.listeners.forEach {
|
|
||||||
it.onPause(appContext.currentActivity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
View(VlcPlayerView::class) {
|
|
||||||
Prop("source") { view: VlcPlayerView, source: Map<String, Any> ->
|
|
||||||
view.setSource(source)
|
|
||||||
}
|
|
||||||
|
|
||||||
Prop("paused") { view: VlcPlayerView, paused: Boolean ->
|
|
||||||
if (paused) {
|
|
||||||
view.pause()
|
|
||||||
} else {
|
|
||||||
view.play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Events(
|
|
||||||
"onPlaybackStateChanged",
|
|
||||||
"onVideoStateChange",
|
|
||||||
"onVideoLoadStart",
|
|
||||||
"onVideoLoadEnd",
|
|
||||||
"onVideoProgress",
|
|
||||||
"onVideoError",
|
|
||||||
"onPipStarted"
|
|
||||||
)
|
|
||||||
|
|
||||||
AsyncFunction("startPictureInPicture") { view: VlcPlayerView ->
|
|
||||||
view.startPictureInPicture()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("play") { view: VlcPlayerView ->
|
|
||||||
view.play()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("pause") { view: VlcPlayerView ->
|
|
||||||
view.pause()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("stop") { view: VlcPlayerView ->
|
|
||||||
view.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("seekTo") { view: VlcPlayerView, time: Int ->
|
|
||||||
view.seekTo(time)
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("setAudioTrack") { view: VlcPlayerView, trackIndex: Int ->
|
|
||||||
view.setAudioTrack(trackIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("getAudioTracks") { view: VlcPlayerView ->
|
|
||||||
view.getAudioTracks()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("setSubtitleTrack") { view: VlcPlayerView, trackIndex: Int ->
|
|
||||||
view.setSubtitleTrack(trackIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("getSubtitleTracks") { view: VlcPlayerView ->
|
|
||||||
view.getSubtitleTracks()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("setSubtitleURL") { view: VlcPlayerView, url: String, name: String ->
|
|
||||||
view.setSubtitleURL(url, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("setVideoAspectRatio") { view: VlcPlayerView, aspectRatio: String? ->
|
|
||||||
view.setVideoAspectRatio(aspectRatio)
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("setVideoScaleFactor") { view: VlcPlayerView, scaleFactor: Float ->
|
|
||||||
view.setVideoScaleFactor(scaleFactor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,482 +0,0 @@
|
|||||||
package expo.modules.vlcplayer
|
|
||||||
|
|
||||||
import android.R
|
|
||||||
import android.app.Activity
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.app.PendingIntent.FLAG_IMMUTABLE
|
|
||||||
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
|
||||||
import android.app.PictureInPictureParams
|
|
||||||
import android.app.RemoteAction
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.ContextWrapper
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.IntentFilter
|
|
||||||
import android.graphics.drawable.Icon
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import android.util.Log
|
|
||||||
import android.view.View
|
|
||||||
import androidx.annotation.RequiresApi
|
|
||||||
import androidx.core.app.PictureInPictureModeChangedInfo
|
|
||||||
import androidx.core.view.isVisible
|
|
||||||
import androidx.lifecycle.Lifecycle
|
|
||||||
import androidx.lifecycle.LifecycleObserver
|
|
||||||
import androidx.lifecycle.OnLifecycleEvent
|
|
||||||
import expo.modules.core.interfaces.ReactActivityLifecycleListener
|
|
||||||
import expo.modules.core.logging.LogHandlers
|
|
||||||
import expo.modules.core.logging.Logger
|
|
||||||
import expo.modules.kotlin.AppContext
|
|
||||||
import expo.modules.kotlin.viewevent.EventDispatcher
|
|
||||||
import expo.modules.kotlin.views.ExpoView
|
|
||||||
import org.videolan.libvlc.LibVLC
|
|
||||||
import org.videolan.libvlc.Media
|
|
||||||
import org.videolan.libvlc.MediaPlayer
|
|
||||||
import org.videolan.libvlc.interfaces.IMedia
|
|
||||||
import org.videolan.libvlc.util.VLCVideoLayout
|
|
||||||
|
|
||||||
|
|
||||||
class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext), LifecycleObserver, MediaPlayer.EventListener, ReactActivityLifecycleListener {
|
|
||||||
private val log = Logger(listOf(LogHandlers.createOSLogHandler(this::class.simpleName!!)))
|
|
||||||
private val PIP_PLAY_PAUSE_ACTION = "PIP_PLAY_PAUSE_ACTION"
|
|
||||||
private val PIP_REWIND_ACTION = "PIP_REWIND_ACTION"
|
|
||||||
private val PIP_FORWARD_ACTION = "PIP_FORWARD_ACTION"
|
|
||||||
|
|
||||||
private var libVLC: LibVLC? = null
|
|
||||||
private var mediaPlayer: MediaPlayer? = null
|
|
||||||
private lateinit var videoLayout: VLCVideoLayout
|
|
||||||
private var isPaused: Boolean = false
|
|
||||||
private var lastReportedState: Int? = null
|
|
||||||
private var lastReportedIsPlaying: Boolean? = null
|
|
||||||
private var media : Media? = null
|
|
||||||
private var timeLeft: Long? = null
|
|
||||||
|
|
||||||
private val onVideoProgress by EventDispatcher()
|
|
||||||
private val onVideoStateChange by EventDispatcher()
|
|
||||||
private val onVideoLoadEnd by EventDispatcher()
|
|
||||||
private val onPipStarted by EventDispatcher()
|
|
||||||
|
|
||||||
private var startPosition: Int? = 0
|
|
||||||
private var isMediaReady: Boolean = false
|
|
||||||
private var externalTrack: Map<String, String>? = null
|
|
||||||
private var externalSubtitles: List<Map<String, String>>? = null
|
|
||||||
var hasSource: Boolean = false
|
|
||||||
|
|
||||||
private val handler = Handler(Looper.getMainLooper())
|
|
||||||
private val updateInterval = 1000L // 1 second
|
|
||||||
private val updateProgressRunnable = object : Runnable {
|
|
||||||
override fun run() {
|
|
||||||
updateVideoProgress()
|
|
||||||
handler.postDelayed(this, updateInterval)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private val currentActivity get() = context.findActivity()
|
|
||||||
private val actions: MutableList<RemoteAction> = mutableListOf()
|
|
||||||
private val remoteActionFilter = IntentFilter()
|
|
||||||
private val playPauseIntent: Intent = Intent(PIP_PLAY_PAUSE_ACTION).setPackage(context.packageName)
|
|
||||||
private val forwardIntent: Intent = Intent(PIP_FORWARD_ACTION).setPackage(context.packageName)
|
|
||||||
private val rewindIntent: Intent = Intent(PIP_REWIND_ACTION).setPackage(context.packageName)
|
|
||||||
private var actionReceiver: BroadcastReceiver = object : BroadcastReceiver() {
|
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
|
||||||
when (intent?.action) {
|
|
||||||
PIP_PLAY_PAUSE_ACTION -> {
|
|
||||||
if (isPaused) play() else pause()
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
setupPipActions()
|
|
||||||
currentActivity.setPictureInPictureParams(getPipParams()!!)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
PIP_FORWARD_ACTION -> seekTo((mediaPlayer?.time?.toInt() ?: 0) + 15_000)
|
|
||||||
PIP_REWIND_ACTION -> seekTo((mediaPlayer?.time?.toInt() ?: 0) - 15_000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var pipChangeListener: (PictureInPictureModeChangedInfo) -> Unit = { info ->
|
|
||||||
if (!info.isInPictureInPictureMode && mediaPlayer?.isPlaying == true) {
|
|
||||||
log.debug("Exiting PiP")
|
|
||||||
timeLeft = mediaPlayer?.time
|
|
||||||
pause()
|
|
||||||
|
|
||||||
// Setting the media after reattaching the view allows for a fast video view render
|
|
||||||
if (mediaPlayer?.vlcVout?.areViewsAttached() == false) {
|
|
||||||
mediaPlayer?.attachViews(videoLayout, null, false, false)
|
|
||||||
mediaPlayer?.media = media
|
|
||||||
mediaPlayer?.play()
|
|
||||||
timeLeft?.let { mediaPlayer?.time = it }
|
|
||||||
mediaPlayer?.pause()
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onPipStarted(mapOf(
|
|
||||||
"pipStarted" to info.isInPictureInPictureMode
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
|
||||||
VLCManager.listeners.add(this)
|
|
||||||
setupView()
|
|
||||||
setupPiP()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setupView() {
|
|
||||||
log.debug("Setting up view")
|
|
||||||
setBackgroundColor(android.graphics.Color.WHITE)
|
|
||||||
videoLayout = VLCVideoLayout(context).apply {
|
|
||||||
layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
|
|
||||||
}
|
|
||||||
videoLayout.keepScreenOn = true
|
|
||||||
addView(videoLayout)
|
|
||||||
log.debug("View setup complete")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setupPiP() {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
remoteActionFilter.addAction(PIP_PLAY_PAUSE_ACTION)
|
|
||||||
remoteActionFilter.addAction(PIP_FORWARD_ACTION)
|
|
||||||
remoteActionFilter.addAction(PIP_REWIND_ACTION)
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
currentActivity.registerReceiver(
|
|
||||||
actionReceiver,
|
|
||||||
remoteActionFilter,
|
|
||||||
Context.RECEIVER_NOT_EXPORTED
|
|
||||||
)
|
|
||||||
}
|
|
||||||
setupPipActions()
|
|
||||||
currentActivity.apply {
|
|
||||||
setPictureInPictureParams(getPipParams()!!)
|
|
||||||
addOnPictureInPictureModeChangedListener(pipChangeListener)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
|
||||||
private fun setupPipActions() {
|
|
||||||
actions.clear()
|
|
||||||
actions.addAll(
|
|
||||||
listOf(
|
|
||||||
RemoteAction(
|
|
||||||
Icon.createWithResource(context, R.drawable.ic_media_rew),
|
|
||||||
"Rewind",
|
|
||||||
"Rewind Video",
|
|
||||||
PendingIntent.getBroadcast(
|
|
||||||
context,
|
|
||||||
0,
|
|
||||||
rewindIntent,
|
|
||||||
FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE
|
|
||||||
)
|
|
||||||
),
|
|
||||||
RemoteAction(
|
|
||||||
if (isPaused) Icon.createWithResource(context, R.drawable.ic_media_play)
|
|
||||||
else Icon.createWithResource(context, R.drawable.ic_media_pause),
|
|
||||||
"Play",
|
|
||||||
"Play Video",
|
|
||||||
PendingIntent.getBroadcast(
|
|
||||||
context,
|
|
||||||
if (isPaused) 0 else 1,
|
|
||||||
playPauseIntent,
|
|
||||||
FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE
|
|
||||||
)
|
|
||||||
),
|
|
||||||
RemoteAction(
|
|
||||||
Icon.createWithResource(context, R.drawable.ic_media_ff),
|
|
||||||
"Skip",
|
|
||||||
"Skip Forward",
|
|
||||||
PendingIntent.getBroadcast(
|
|
||||||
context,
|
|
||||||
0,
|
|
||||||
forwardIntent,
|
|
||||||
FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getPipParams(): PictureInPictureParams? {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
var builder = PictureInPictureParams.Builder()
|
|
||||||
.setActions(actions)
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
||||||
builder = builder.setAutoEnterEnabled(true)
|
|
||||||
}
|
|
||||||
return builder.build()
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setSource(source: Map<String, Any>) {
|
|
||||||
log.debug("setting source $source")
|
|
||||||
if (hasSource) {
|
|
||||||
log.debug("Source already set. Ignoring.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val mediaOptions = source["mediaOptions"] as? Map<String, Any> ?: emptyMap()
|
|
||||||
val autoplay = source["autoplay"] as? Boolean ?: false
|
|
||||||
val isNetwork = source["isNetwork"] as? Boolean ?: false
|
|
||||||
externalTrack = source["externalTrack"] as? Map<String, String>
|
|
||||||
externalSubtitles = source["externalSubtitles"] as? List<Map<String, String>>
|
|
||||||
startPosition = (source["startPosition"] as? Double)?.toInt() ?: 0
|
|
||||||
|
|
||||||
val initOptions = source["initOptions"] as? MutableList<String> ?: mutableListOf()
|
|
||||||
initOptions.add("--start-time=$startPosition")
|
|
||||||
|
|
||||||
|
|
||||||
val uri = source["uri"] as? String
|
|
||||||
|
|
||||||
// Handle video load start event
|
|
||||||
// onVideoLoadStart?.invoke(mapOf("target" to reactTag ?: "null"))
|
|
||||||
|
|
||||||
libVLC = LibVLC(context, initOptions)
|
|
||||||
mediaPlayer = MediaPlayer(libVLC)
|
|
||||||
mediaPlayer?.attachViews(videoLayout, null, false, false)
|
|
||||||
mediaPlayer?.setEventListener(this)
|
|
||||||
|
|
||||||
log.debug("Loading network file: $uri")
|
|
||||||
media = Media(libVLC, Uri.parse(uri))
|
|
||||||
mediaPlayer?.media = media
|
|
||||||
|
|
||||||
log.debug("Debug: Media options: $mediaOptions")
|
|
||||||
// media.addOptions(mediaOptions)
|
|
||||||
|
|
||||||
// Set initial external subtitles immediately like iOS
|
|
||||||
setInitialExternalSubtitles()
|
|
||||||
|
|
||||||
hasSource = true
|
|
||||||
|
|
||||||
if (autoplay) {
|
|
||||||
log.debug("Playing...")
|
|
||||||
play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun startPictureInPicture() {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
currentActivity.enterPictureInPictureMode(getPipParams()!!)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun play() {
|
|
||||||
mediaPlayer?.play()
|
|
||||||
isPaused = false
|
|
||||||
handler.post(updateProgressRunnable) // Start updating progress
|
|
||||||
}
|
|
||||||
|
|
||||||
fun pause() {
|
|
||||||
mediaPlayer?.pause()
|
|
||||||
isPaused = true
|
|
||||||
handler.removeCallbacks(updateProgressRunnable) // Stop updating progress
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stop() {
|
|
||||||
mediaPlayer?.stop()
|
|
||||||
handler.removeCallbacks(updateProgressRunnable) // Stop updating progress
|
|
||||||
}
|
|
||||||
|
|
||||||
fun seekTo(time: Int) {
|
|
||||||
mediaPlayer?.let { player ->
|
|
||||||
val wasPlaying = player.isPlaying
|
|
||||||
if (wasPlaying) {
|
|
||||||
player.pause()
|
|
||||||
}
|
|
||||||
|
|
||||||
val duration = player.length.toInt()
|
|
||||||
val seekTime = if (time > duration) duration - 1000 else time
|
|
||||||
player.time = seekTime.toLong()
|
|
||||||
|
|
||||||
if (wasPlaying) {
|
|
||||||
player.play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setAudioTrack(trackIndex: Int) {
|
|
||||||
mediaPlayer?.setAudioTrack(trackIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getAudioTracks(): List<Map<String, Any>>? {
|
|
||||||
log.debug("getAudioTracks ${mediaPlayer?.audioTracks}")
|
|
||||||
val trackDescriptions = mediaPlayer?.audioTracks ?: return null
|
|
||||||
|
|
||||||
return trackDescriptions.map { trackDescription ->
|
|
||||||
mapOf("name" to trackDescription.name, "index" to trackDescription.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setSubtitleTrack(trackIndex: Int) {
|
|
||||||
mediaPlayer?.setSpuTrack(trackIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
// fun getSubtitleTracks(): List<Map<String, Any>>? {
|
|
||||||
// return mediaPlayer?.getSpuTracks()?.map { trackDescription ->
|
|
||||||
// mapOf("name" to trackDescription.name, "index" to trackDescription.id)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
fun getSubtitleTracks(): List<Map<String, Any>>? {
|
|
||||||
val subtitleTracks = mediaPlayer?.spuTracks?.map { trackDescription ->
|
|
||||||
mapOf("name" to trackDescription.name, "index" to trackDescription.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debug statement to print the result
|
|
||||||
log.debug("Subtitle Tracks: $subtitleTracks")
|
|
||||||
|
|
||||||
return subtitleTracks
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setSubtitleURL(subtitleURL: String, name: String) {
|
|
||||||
log.debug("Setting subtitle URL: $subtitleURL, name: $name")
|
|
||||||
mediaPlayer?.addSlave(IMedia.Slave.Type.Subtitle, Uri.parse(subtitleURL), true)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setVideoAspectRatio(aspectRatio: String?) {
|
|
||||||
log.debug("Setting video aspect ratio: $aspectRatio")
|
|
||||||
mediaPlayer?.aspectRatio = aspectRatio
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setVideoScaleFactor(scaleFactor: Float) {
|
|
||||||
log.debug("Setting video scale factor: $scaleFactor")
|
|
||||||
mediaPlayer?.scale = scaleFactor
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setInitialExternalSubtitles() {
|
|
||||||
externalSubtitles?.let { subtitles ->
|
|
||||||
for (subtitle in subtitles) {
|
|
||||||
val subtitleName = subtitle["name"]
|
|
||||||
val subtitleURL = subtitle["DeliveryUrl"]
|
|
||||||
if (!subtitleName.isNullOrEmpty() && !subtitleURL.isNullOrEmpty()) {
|
|
||||||
log.debug("Setting external subtitle: $subtitleName $subtitleURL")
|
|
||||||
setSubtitleURL(subtitleURL, subtitleName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDetachedFromWindow() {
|
|
||||||
log.debug("onDetachedFromWindow")
|
|
||||||
super.onDetachedFromWindow()
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
||||||
currentActivity.setPictureInPictureParams(
|
|
||||||
PictureInPictureParams.Builder()
|
|
||||||
.setAutoEnterEnabled(false)
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
currentActivity.unregisterReceiver(actionReceiver)
|
|
||||||
}
|
|
||||||
currentActivity.removeOnPictureInPictureModeChangedListener(pipChangeListener)
|
|
||||||
VLCManager.listeners.clear()
|
|
||||||
|
|
||||||
mediaPlayer?.stop()
|
|
||||||
handler.removeCallbacks(updateProgressRunnable) // Stop updating progress
|
|
||||||
|
|
||||||
media?.release()
|
|
||||||
mediaPlayer?.release()
|
|
||||||
libVLC?.release()
|
|
||||||
mediaPlayer = null
|
|
||||||
media = null
|
|
||||||
libVLC = null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onEvent(event: MediaPlayer.Event) {
|
|
||||||
keepScreenOn = event.type == MediaPlayer.Event.Playing || event.type == MediaPlayer.Event.Buffering
|
|
||||||
when (event.type) {
|
|
||||||
MediaPlayer.Event.Playing,
|
|
||||||
MediaPlayer.Event.Paused,
|
|
||||||
MediaPlayer.Event.Stopped,
|
|
||||||
MediaPlayer.Event.Buffering,
|
|
||||||
MediaPlayer.Event.EndReached,
|
|
||||||
MediaPlayer.Event.EncounteredError -> updatePlayerState(event)
|
|
||||||
MediaPlayer.Event.TimeChanged -> {
|
|
||||||
// Do nothing here, as we are updating progress every 1 second
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updatePlayerState(event: MediaPlayer.Event) {
|
|
||||||
val player = mediaPlayer ?: return
|
|
||||||
val currentState = event.type
|
|
||||||
|
|
||||||
val stateInfo = mutableMapOf<String, Any>(
|
|
||||||
"target" to "null", // Replace with actual target if needed
|
|
||||||
"currentTime" to player.time.toInt(),
|
|
||||||
"duration" to (player.media?.duration?.toInt() ?: 0),
|
|
||||||
"error" to false,
|
|
||||||
"isPlaying" to (currentState == MediaPlayer.Event.Playing),
|
|
||||||
"isBuffering" to (!player.isPlaying && currentState == MediaPlayer.Event.Buffering)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Todo: make enum - string to prevent this when statement from becoming exhaustive
|
|
||||||
when (currentState) {
|
|
||||||
MediaPlayer.Event.Playing ->
|
|
||||||
stateInfo["state"] = "Playing"
|
|
||||||
MediaPlayer.Event.Paused ->
|
|
||||||
stateInfo["state"] = "Paused"
|
|
||||||
MediaPlayer.Event.Buffering ->
|
|
||||||
stateInfo["state"] = "Buffering"
|
|
||||||
MediaPlayer.Event.EncounteredError -> {
|
|
||||||
stateInfo["state"] = "Error"
|
|
||||||
onVideoLoadEnd(stateInfo);
|
|
||||||
}
|
|
||||||
MediaPlayer.Event.Opening ->
|
|
||||||
stateInfo["state"] = "Opening"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastReportedState != currentState || lastReportedIsPlaying != player.isPlaying) {
|
|
||||||
lastReportedState = currentState
|
|
||||||
lastReportedIsPlaying = player.isPlaying
|
|
||||||
onVideoStateChange(stateInfo)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private fun updateVideoProgress() {
|
|
||||||
val player = mediaPlayer ?: return
|
|
||||||
|
|
||||||
val currentTimeMs = player.time.toInt()
|
|
||||||
val durationMs = player.media?.duration?.toInt() ?: 0
|
|
||||||
if (currentTimeMs >= 0 && currentTimeMs < durationMs) {
|
|
||||||
// Set subtitle URL if available
|
|
||||||
if (player.isPlaying && !isMediaReady) {
|
|
||||||
isMediaReady = true
|
|
||||||
externalTrack?.let {
|
|
||||||
val name = it["name"]
|
|
||||||
val deliveryUrl = it["DeliveryUrl"] ?: ""
|
|
||||||
if (!name.isNullOrEmpty() && !deliveryUrl.isNullOrEmpty()) {
|
|
||||||
setSubtitleURL(deliveryUrl, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onVideoProgress(mapOf(
|
|
||||||
"currentTime" to currentTimeMs,
|
|
||||||
"duration" to durationMs
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPause(activity: Activity?) {
|
|
||||||
log.debug("Pausing activity...")
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
override fun onResume(activity: Activity?) {
|
|
||||||
log.debug("Resuming activity...")
|
|
||||||
if (isPaused) play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun Context.findActivity(): androidx.activity.ComponentActivity {
|
|
||||||
var context = this
|
|
||||||
while (context is ContextWrapper) {
|
|
||||||
if (context is androidx.activity.ComponentActivity) return context
|
|
||||||
context = context.baseContext
|
|
||||||
}
|
|
||||||
throw IllegalStateException("Failed to find ComponentActivity")
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"platforms": ["ios", "tvos", "android", "web"],
|
|
||||||
"ios": {
|
|
||||||
"modules": ["VlcPlayerModule"]
|
|
||||||
},
|
|
||||||
"android": {
|
|
||||||
"modules": ["expo.modules.vlcplayer.VlcPlayerModule"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
Pod::Spec.new do |s|
|
|
||||||
s.name = 'VlcPlayer'
|
|
||||||
s.version = '3.6.1b1'
|
|
||||||
s.summary = 'A sample project summary'
|
|
||||||
s.description = 'A sample project description'
|
|
||||||
s.author = ''
|
|
||||||
s.homepage = 'https://docs.expo.dev/modules/'
|
|
||||||
s.platforms = { :ios => '13.4', :tvos => '13.4' }
|
|
||||||
s.source = { git: '' }
|
|
||||||
s.static_framework = true
|
|
||||||
|
|
||||||
s.dependency 'ExpoModulesCore'
|
|
||||||
s.ios.dependency 'MobileVLCKit', s.version
|
|
||||||
s.tvos.dependency 'TVVLCKit', s.version
|
|
||||||
|
|
||||||
# Swift/Objective-C compatibility
|
|
||||||
s.pod_target_xcconfig = {
|
|
||||||
'DEFINES_MODULE' => 'YES',
|
|
||||||
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
|
||||||
}
|
|
||||||
|
|
||||||
s.source_files = "*.{h,m,mm,swift,hpp,cpp}"
|
|
||||||
end
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
import ExpoModulesCore
|
|
||||||
|
|
||||||
public class VlcPlayerModule: Module {
|
|
||||||
public func definition() -> ModuleDefinition {
|
|
||||||
Name("VlcPlayer")
|
|
||||||
View(VlcPlayerView.self) {
|
|
||||||
Prop("source") { (view: VlcPlayerView, source: [String: Any]) in
|
|
||||||
view.setSource(source)
|
|
||||||
}
|
|
||||||
|
|
||||||
Prop("paused") { (view: VlcPlayerView, paused: Bool) in
|
|
||||||
if paused {
|
|
||||||
view.pause()
|
|
||||||
} else {
|
|
||||||
view.play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Events(
|
|
||||||
"onPlaybackStateChanged",
|
|
||||||
"onVideoStateChange",
|
|
||||||
"onVideoLoadStart",
|
|
||||||
"onVideoLoadEnd",
|
|
||||||
"onVideoProgress",
|
|
||||||
"onVideoError",
|
|
||||||
"onPipStarted"
|
|
||||||
)
|
|
||||||
|
|
||||||
AsyncFunction("startPictureInPicture") { (view: VlcPlayerView) in
|
|
||||||
view.startPictureInPicture()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("play") { (view: VlcPlayerView) in
|
|
||||||
view.play()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("pause") { (view: VlcPlayerView) in
|
|
||||||
view.pause()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("stop") { (view: VlcPlayerView) in
|
|
||||||
view.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("seekTo") { (view: VlcPlayerView, time: Int32) in
|
|
||||||
view.seekTo(time)
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("setAudioTrack") { (view: VlcPlayerView, trackIndex: Int) in
|
|
||||||
view.setAudioTrack(trackIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("getAudioTracks") { (view: VlcPlayerView) -> [[String: Any]]? in
|
|
||||||
return view.getAudioTracks()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("setSubtitleURL") { (view: VlcPlayerView, url: String, name: String) in
|
|
||||||
view.setSubtitleURL(url, name: name)
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("setSubtitleTrack") { (view: VlcPlayerView, trackIndex: Int) in
|
|
||||||
view.setSubtitleTrack(trackIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("setVideoAspectRatio") { (view: VlcPlayerView, aspectRatio: String?) in
|
|
||||||
view.setVideoAspectRatio(aspectRatio)
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("setVideoScaleFactor") { (view: VlcPlayerView, scaleFactor: Float) in
|
|
||||||
view.setVideoScaleFactor(scaleFactor)
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("getSubtitleTracks") { (view: VlcPlayerView) -> [[String: Any]]? in
|
|
||||||
return view.getSubtitleTracks()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,424 +0,0 @@
|
|||||||
import ExpoModulesCore
|
|
||||||
|
|
||||||
#if os(tvOS)
|
|
||||||
import TVVLCKit
|
|
||||||
#else
|
|
||||||
import MobileVLCKit
|
|
||||||
#endif
|
|
||||||
|
|
||||||
class VlcPlayerView: ExpoView {
|
|
||||||
private var mediaPlayer: VLCMediaPlayer?
|
|
||||||
private var videoView: UIView?
|
|
||||||
private var progressUpdateInterval: TimeInterval = 1.0 // Update interval set to 1 second
|
|
||||||
private var isPaused: Bool = false
|
|
||||||
private var currentGeometryCString: [CChar]?
|
|
||||||
private var lastReportedState: VLCMediaPlayerState?
|
|
||||||
private var lastReportedIsPlaying: Bool?
|
|
||||||
private var customSubtitles: [(internalName: String, originalName: String)] = []
|
|
||||||
private var startPosition: Int32 = 0
|
|
||||||
private var externalSubtitles: [[String: String]]?
|
|
||||||
private var externalTrack: [String: String]?
|
|
||||||
private var progressTimer: DispatchSourceTimer?
|
|
||||||
private var isStopping: Bool = false // Define isStopping here
|
|
||||||
private var lastProgressCall = Date().timeIntervalSince1970
|
|
||||||
var hasSource = false
|
|
||||||
var isTranscoding = false
|
|
||||||
private var initialSeekPerformed: Bool = false
|
|
||||||
|
|
||||||
// MARK: - Initialization
|
|
||||||
|
|
||||||
required init(appContext: AppContext? = nil) {
|
|
||||||
super.init(appContext: appContext)
|
|
||||||
setupView()
|
|
||||||
setupNotifications()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Setup
|
|
||||||
|
|
||||||
private func setupView() {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.backgroundColor = .black
|
|
||||||
self.videoView = UIView()
|
|
||||||
self.videoView?.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
|
|
||||||
if let videoView = self.videoView {
|
|
||||||
self.addSubview(videoView)
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
videoView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
|
|
||||||
videoView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
|
|
||||||
videoView.topAnchor.constraint(equalTo: self.topAnchor),
|
|
||||||
videoView.bottomAnchor.constraint(equalTo: self.bottomAnchor),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setupNotifications() {
|
|
||||||
NotificationCenter.default.addObserver(
|
|
||||||
self, selector: #selector(applicationWillResignActive),
|
|
||||||
name: UIApplication.willResignActiveNotification, object: nil)
|
|
||||||
NotificationCenter.default.addObserver(
|
|
||||||
self, selector: #selector(applicationDidBecomeActive),
|
|
||||||
name: UIApplication.didBecomeActiveNotification, object: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Public Methods
|
|
||||||
func startPictureInPicture() {}
|
|
||||||
|
|
||||||
@objc func play() {
|
|
||||||
self.mediaPlayer?.play()
|
|
||||||
self.isPaused = false
|
|
||||||
print("Play")
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func pause() {
|
|
||||||
self.mediaPlayer?.pause()
|
|
||||||
self.isPaused = true
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func seekTo(_ time: Int32) {
|
|
||||||
guard let player = self.mediaPlayer else { return }
|
|
||||||
|
|
||||||
let wasPlaying = player.isPlaying
|
|
||||||
if wasPlaying {
|
|
||||||
self.pause()
|
|
||||||
}
|
|
||||||
|
|
||||||
if let duration = player.media?.length.intValue {
|
|
||||||
print("Seeking to time: \(time) Video Duration \(duration)")
|
|
||||||
|
|
||||||
// If the specified time is greater than the duration, seek to the end
|
|
||||||
let seekTime = time > duration ? duration - 1000 : time
|
|
||||||
player.time = VLCTime(int: seekTime)
|
|
||||||
if wasPlaying {
|
|
||||||
self.play()
|
|
||||||
}
|
|
||||||
self.updatePlayerState()
|
|
||||||
} else {
|
|
||||||
print("Error: Unable to retrieve video duration")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func setSource(_ source: [String: Any]) {
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
if self.hasSource {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:]
|
|
||||||
self.externalTrack = source["externalTrack"] as? [String: String]
|
|
||||||
var initOptions = source["initOptions"] as? [Any] ?? []
|
|
||||||
self.startPosition = source["startPosition"] as? Int32 ?? 0
|
|
||||||
self.externalSubtitles = source["externalSubtitles"] as? [[String: String]]
|
|
||||||
|
|
||||||
guard let uri = source["uri"] as? String, !uri.isEmpty else {
|
|
||||||
print("Error: Invalid or empty URI")
|
|
||||||
self.onVideoError?(["error": "Invalid or empty URI"])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
self.isTranscoding = uri.contains("m3u8")
|
|
||||||
|
|
||||||
if !self.isTranscoding, self.startPosition > 0 {
|
|
||||||
initOptions.append("--start-time=\(self.startPosition)")
|
|
||||||
}
|
|
||||||
|
|
||||||
let autoplay = source["autoplay"] as? Bool ?? false
|
|
||||||
let isNetwork = source["isNetwork"] as? Bool ?? false
|
|
||||||
|
|
||||||
self.onVideoLoadStart?(["target": self.reactTag ?? NSNull()])
|
|
||||||
self.mediaPlayer = VLCMediaPlayer(options: initOptions)
|
|
||||||
self.mediaPlayer?.delegate = self
|
|
||||||
self.mediaPlayer?.drawable = self.videoView
|
|
||||||
self.mediaPlayer?.scaleFactor = 0
|
|
||||||
self.initialSeekPerformed = false
|
|
||||||
|
|
||||||
let media: VLCMedia
|
|
||||||
if isNetwork {
|
|
||||||
print("Loading network file: \(uri)")
|
|
||||||
media = VLCMedia(url: URL(string: uri)!)
|
|
||||||
} else {
|
|
||||||
print("Loading local file: \(uri)")
|
|
||||||
if uri.starts(with: "file://"), let url = URL(string: uri) {
|
|
||||||
media = VLCMedia(url: url)
|
|
||||||
} else {
|
|
||||||
media = VLCMedia(path: uri)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
print("Debug: Media options: \(mediaOptions)")
|
|
||||||
media.addOptions(mediaOptions)
|
|
||||||
|
|
||||||
self.mediaPlayer?.media = media
|
|
||||||
self.setInitialExternalSubtitles()
|
|
||||||
self.hasSource = true
|
|
||||||
if autoplay {
|
|
||||||
print("Playing...")
|
|
||||||
self.play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func setAudioTrack(_ trackIndex: Int) {
|
|
||||||
self.mediaPlayer?.currentAudioTrackIndex = Int32(trackIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func getAudioTracks() -> [[String: Any]]? {
|
|
||||||
guard let trackNames = mediaPlayer?.audioTrackNames,
|
|
||||||
let trackIndexes = mediaPlayer?.audioTrackIndexes
|
|
||||||
else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return zip(trackNames, trackIndexes).map { name, index in
|
|
||||||
return ["name": name, "index": index]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func setSubtitleTrack(_ trackIndex: Int) {
|
|
||||||
print("Debug: Attempting to set subtitle track to index: \(trackIndex)")
|
|
||||||
self.mediaPlayer?.currentVideoSubTitleIndex = Int32(trackIndex)
|
|
||||||
print(
|
|
||||||
"Debug: Current subtitle track index after setting: \(self.mediaPlayer?.currentVideoSubTitleIndex ?? -1)"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func setSubtitleURL(_ subtitleURL: String, name: String) {
|
|
||||||
guard let url = URL(string: subtitleURL) else {
|
|
||||||
print("Error: Invalid subtitle URL")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = self.mediaPlayer?.addPlaybackSlave(url, type: .subtitle, enforce: false)
|
|
||||||
if let result = result {
|
|
||||||
let internalName = "Track \(self.customSubtitles.count)"
|
|
||||||
print("Subtitle added with result: \(result) \(internalName)")
|
|
||||||
self.customSubtitles.append((internalName: internalName, originalName: name))
|
|
||||||
} else {
|
|
||||||
print("Failed to add subtitle")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setInitialExternalSubtitles() {
|
|
||||||
if let externalSubtitles = self.externalSubtitles {
|
|
||||||
for subtitle in externalSubtitles {
|
|
||||||
if let subtitleName = subtitle["name"],
|
|
||||||
let subtitleURL = subtitle["DeliveryUrl"]
|
|
||||||
{
|
|
||||||
print("Setting external subtitle: \(subtitleName) \(subtitleURL)")
|
|
||||||
self.setSubtitleURL(subtitleURL, name: subtitleName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func getSubtitleTracks() -> [[String: Any]]? {
|
|
||||||
guard let mediaPlayer = self.mediaPlayer else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let count = mediaPlayer.numberOfSubtitlesTracks
|
|
||||||
print("Debug: Number of subtitle tracks: \(count)")
|
|
||||||
|
|
||||||
guard count > 0 else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var tracks: [[String: Any]] = []
|
|
||||||
|
|
||||||
if let names = mediaPlayer.videoSubTitlesNames as? [String],
|
|
||||||
let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber]
|
|
||||||
{
|
|
||||||
for (index, name) in zip(indexes, names) {
|
|
||||||
if let customSubtitle = customSubtitles.first(where: { $0.internalName == name }) {
|
|
||||||
tracks.append(["name": customSubtitle.originalName, "index": index.intValue])
|
|
||||||
} else {
|
|
||||||
tracks.append(["name": name, "index": index.intValue])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
print("Debug: Subtitle tracks: \(tracks)")
|
|
||||||
return tracks
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func setVideoAspectRatio(_ aspectRatio: String?) {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
if let aspectRatio = aspectRatio {
|
|
||||||
// Convert String to C string for VLC
|
|
||||||
let cString = strdup(aspectRatio)
|
|
||||||
self.mediaPlayer?.videoAspectRatio = cString
|
|
||||||
} else {
|
|
||||||
// Reset to default (let VLC determine aspect ratio)
|
|
||||||
self.mediaPlayer?.videoAspectRatio = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func setVideoScaleFactor(_ scaleFactor: Float) {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.mediaPlayer?.scaleFactor = scaleFactor
|
|
||||||
print("Set video scale factor: \(scaleFactor)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func stop(completion: (() -> Void)? = nil) {
|
|
||||||
guard !isStopping else {
|
|
||||||
completion?()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
isStopping = true
|
|
||||||
|
|
||||||
// If we're not on the main thread, dispatch to main thread
|
|
||||||
if !Thread.isMainThread {
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
self?.performStop(completion: completion)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
performStop(completion: completion)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Methods
|
|
||||||
|
|
||||||
@objc private func applicationWillResignActive() {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func applicationDidBecomeActive() {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private func performStop(completion: (() -> Void)? = nil) {
|
|
||||||
// Stop the media player
|
|
||||||
mediaPlayer?.stop()
|
|
||||||
|
|
||||||
// Remove observer
|
|
||||||
NotificationCenter.default.removeObserver(self)
|
|
||||||
|
|
||||||
// Clear the video view
|
|
||||||
videoView?.removeFromSuperview()
|
|
||||||
videoView = nil
|
|
||||||
|
|
||||||
// Release the media player
|
|
||||||
mediaPlayer?.delegate = nil
|
|
||||||
mediaPlayer = nil
|
|
||||||
|
|
||||||
isStopping = false
|
|
||||||
completion?()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateVideoProgress() {
|
|
||||||
guard let player = self.mediaPlayer else { return }
|
|
||||||
|
|
||||||
let currentTimeMs = player.time.intValue
|
|
||||||
let durationMs = player.media?.length.intValue ?? 0
|
|
||||||
|
|
||||||
|
|
||||||
print("Debug: Current time: \(currentTimeMs)")
|
|
||||||
if currentTimeMs >= 0 && currentTimeMs < durationMs {
|
|
||||||
if self.isTranscoding, !self.initialSeekPerformed, self.startPosition > 0 {
|
|
||||||
player.time = VLCTime(int: self.startPosition * 1000)
|
|
||||||
self.initialSeekPerformed = true
|
|
||||||
}
|
|
||||||
self.onVideoProgress?([
|
|
||||||
"currentTime": currentTimeMs,
|
|
||||||
"duration": durationMs,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Expo Events
|
|
||||||
|
|
||||||
@objc var onPlaybackStateChanged: RCTDirectEventBlock?
|
|
||||||
@objc var onVideoLoadStart: RCTDirectEventBlock?
|
|
||||||
@objc var onVideoStateChange: RCTDirectEventBlock?
|
|
||||||
@objc var onVideoProgress: RCTDirectEventBlock?
|
|
||||||
@objc var onVideoLoadEnd: RCTDirectEventBlock?
|
|
||||||
@objc var onVideoError: RCTDirectEventBlock?
|
|
||||||
@objc var onPipStarted: RCTDirectEventBlock?
|
|
||||||
|
|
||||||
// MARK: - Deinitialization
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
performStop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension VlcPlayerView: VLCMediaPlayerDelegate {
|
|
||||||
func mediaPlayerTimeChanged(_ aNotification: Notification) {
|
|
||||||
// self?.updateVideoProgress()
|
|
||||||
let timeNow = Date().timeIntervalSince1970
|
|
||||||
if timeNow - lastProgressCall >= 1 {
|
|
||||||
lastProgressCall = timeNow
|
|
||||||
updateVideoProgress()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mediaPlayerStateChanged(_ aNotification: Notification) {
|
|
||||||
self.updatePlayerState()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updatePlayerState() {
|
|
||||||
guard let player = self.mediaPlayer else { return }
|
|
||||||
let currentState = player.state
|
|
||||||
|
|
||||||
var stateInfo: [String: Any] = [
|
|
||||||
"target": self.reactTag ?? NSNull(),
|
|
||||||
"currentTime": player.time.intValue,
|
|
||||||
"duration": player.media?.length.intValue ?? 0,
|
|
||||||
"error": false,
|
|
||||||
]
|
|
||||||
|
|
||||||
if player.isPlaying {
|
|
||||||
stateInfo["isPlaying"] = true
|
|
||||||
stateInfo["isBuffering"] = false
|
|
||||||
stateInfo["state"] = "Playing"
|
|
||||||
} else {
|
|
||||||
stateInfo["isPlaying"] = false
|
|
||||||
stateInfo["state"] = "Paused"
|
|
||||||
}
|
|
||||||
|
|
||||||
if player.state == VLCMediaPlayerState.buffering {
|
|
||||||
stateInfo["isBuffering"] = true
|
|
||||||
stateInfo["state"] = "Buffering"
|
|
||||||
} else if player.state == VLCMediaPlayerState.error {
|
|
||||||
print("player.state ~ error")
|
|
||||||
stateInfo["state"] = "Error"
|
|
||||||
self.onVideoLoadEnd?(stateInfo)
|
|
||||||
} else if player.state == VLCMediaPlayerState.opening {
|
|
||||||
print("player.state ~ opening")
|
|
||||||
stateInfo["state"] = "Opening"
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.lastReportedState != currentState
|
|
||||||
|| self.lastReportedIsPlaying != player.isPlaying
|
|
||||||
{
|
|
||||||
self.lastReportedState = currentState
|
|
||||||
self.lastReportedIsPlaying = player.isPlaying
|
|
||||||
self.onVideoStateChange?(stateInfo)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension VlcPlayerView: VLCMediaDelegate {
|
|
||||||
// Implement VLCMediaDelegate methods if needed
|
|
||||||
}
|
|
||||||
|
|
||||||
extension VLCMediaPlayerState {
|
|
||||||
var description: String {
|
|
||||||
switch self {
|
|
||||||
case .opening: return "Opening"
|
|
||||||
case .buffering: return "Buffering"
|
|
||||||
case .playing: return "Playing"
|
|
||||||
case .paused: return "Paused"
|
|
||||||
case .stopped: return "Stopped"
|
|
||||||
case .ended: return "Ended"
|
|
||||||
case .error: return "Error"
|
|
||||||
case .esAdded: return "ESAdded"
|
|
||||||
@unknown default: return "Unknown"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { requireNativeModule } from "expo-modules-core";
|
|
||||||
|
|
||||||
// It loads the native module object from the JSI or falls back to
|
|
||||||
// the bridge module (from NativeModulesProxy) if the remote debugger is on.
|
|
||||||
export default requireNativeModule("VlcPlayer");
|
|
||||||
26
package.json
26
package.json
@@ -68,7 +68,7 @@
|
|||||||
"patch-package": "^8.0.0",
|
"patch-package": "^8.0.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-i18next": "^15.4.0",
|
"react-i18next": "16.0.0",
|
||||||
"react-native": "npm:react-native-tvos@0.81.5-1",
|
"react-native": "npm:react-native-tvos@0.81.5-1",
|
||||||
"react-native-awesome-slider": "^2.9.0",
|
"react-native-awesome-slider": "^2.9.0",
|
||||||
"react-native-bottom-tabs": "^1.0.2",
|
"react-native-bottom-tabs": "^1.0.2",
|
||||||
@@ -103,20 +103,20 @@
|
|||||||
"zod": "^4.1.3"
|
"zod": "^4.1.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.28.5",
|
"@babel/core": "7.28.5",
|
||||||
"@biomejs/biome": "^2.3.5",
|
"@biomejs/biome": "2.3.5",
|
||||||
"@react-native-community/cli": "^20.0.2",
|
"@react-native-community/cli": "20.0.2",
|
||||||
"@react-native-tvos/config-tv": "^0.1.4",
|
"@react-native-tvos/config-tv": "0.1.4",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "29.5.14",
|
||||||
"@types/lodash": "^4.17.20",
|
"@types/lodash": "4.17.20",
|
||||||
"@types/react": "~19.1.10",
|
"@types/react": "~19.1.10",
|
||||||
"@types/react-test-renderer": "^19.0.0",
|
"@types/react-test-renderer": "19.1.0",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "10.1.0",
|
||||||
"expo-doctor": "^1.17.11",
|
"expo-doctor": "1.17.11",
|
||||||
"husky": "^9.1.7",
|
"husky": "9.1.7",
|
||||||
"lint-staged": "^16.2.6",
|
"lint-staged": "16.2.6",
|
||||||
"react-test-renderer": "19.1.1",
|
"react-test-renderer": "19.1.1",
|
||||||
"typescript": "~5.9.3"
|
"typescript": "5.9.3"
|
||||||
},
|
},
|
||||||
"expo": {
|
"expo": {
|
||||||
"doctor": {
|
"doctor": {
|
||||||
|
|||||||
24
plugins/withGitPod.js
Normal file
24
plugins/withGitPod.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
const { withPodfile } = require("@expo/config-plugins");
|
||||||
|
|
||||||
|
const withGitPod = (config, { podName, podspecUrl }) => {
|
||||||
|
return withPodfile(config, (config) => {
|
||||||
|
const podfile = config.modResults.contents;
|
||||||
|
|
||||||
|
const podLine = ` pod '${podName}', :podspec => '${podspecUrl}'`;
|
||||||
|
|
||||||
|
// Check if already added
|
||||||
|
if (podfile.includes(podLine)) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert after "use_expo_modules!"
|
||||||
|
config.modResults.contents = podfile.replace(
|
||||||
|
"use_expo_modules!",
|
||||||
|
`use_expo_modules!\n${podLine}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = withGitPod;
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
const { withAppDelegate, withXcodeProject } = require("expo/config-plugins");
|
|
||||||
const fs = require("node:fs");
|
|
||||||
const path = require("node:path");
|
|
||||||
|
|
||||||
/** @param {import("expo/config-plugins").ExpoConfig} config */
|
|
||||||
function withRNBackgroundDownloader(config) {
|
|
||||||
/* 1️⃣ Add handleEventsForBackgroundURLSession to AppDelegate.swift */
|
|
||||||
config = withAppDelegate(config, (mod) => {
|
|
||||||
const tag = "handleEventsForBackgroundURLSession";
|
|
||||||
if (!mod.modResults.contents.includes(tag)) {
|
|
||||||
mod.modResults.contents = mod.modResults.contents.replace(
|
|
||||||
/\}\s*$/, // insert before final }
|
|
||||||
`
|
|
||||||
func application(
|
|
||||||
_ application: UIApplication,
|
|
||||||
handleEventsForBackgroundURLSession identifier: String,
|
|
||||||
completionHandler: @escaping () -> Void
|
|
||||||
) {
|
|
||||||
RNBackgroundDownloader.setCompletionHandlerWithIdentifier(identifier, completionHandler: completionHandler)
|
|
||||||
}
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return mod;
|
|
||||||
});
|
|
||||||
|
|
||||||
/* 2️⃣ Ensure bridging header exists & is attached to *every* app target */
|
|
||||||
config = withXcodeProject(config, (mod) => {
|
|
||||||
const project = mod.modResults;
|
|
||||||
const projectName = config.name || "App";
|
|
||||||
// Fix: Go up one more directory to get to ios/, not ios/ProjectName.xcodeproj/
|
|
||||||
const iosDir = path.dirname(path.dirname(project.filepath));
|
|
||||||
const headerRel = `${projectName}/${projectName}-Bridging-Header.h`;
|
|
||||||
const headerAbs = path.join(iosDir, headerRel);
|
|
||||||
|
|
||||||
// create / append import if missing
|
|
||||||
let headerText = "";
|
|
||||||
try {
|
|
||||||
headerText = fs.readFileSync(headerAbs, "utf8");
|
|
||||||
} catch (error) {
|
|
||||||
if (error.code !== "ENOENT") {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!headerText.includes("RNBackgroundDownloader.h")) {
|
|
||||||
fs.mkdirSync(path.dirname(headerAbs), { recursive: true });
|
|
||||||
fs.appendFileSync(headerAbs, '#import "RNBackgroundDownloader.h"\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expo 53's xcode‑js doesn't expose pbxTargets().
|
|
||||||
// Setting the property once at the project level is sufficient.
|
|
||||||
["Debug", "Release"].forEach((cfg) => {
|
|
||||||
// Use the detected projectName to set the bridging header path instead of a hardcoded value
|
|
||||||
const bridgingHeaderPath = `${projectName}/${projectName}-Bridging-Header.h`;
|
|
||||||
project.updateBuildProperty(
|
|
||||||
"SWIFT_OBJC_BRIDGING_HEADER",
|
|
||||||
bridgingHeaderPath,
|
|
||||||
cfg,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return mod;
|
|
||||||
});
|
|
||||||
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = withRNBackgroundDownloader;
|
|
||||||
@@ -64,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setJellyfin(
|
setJellyfin(
|
||||||
() =>
|
() =>
|
||||||
new Jellyfin({
|
new Jellyfin({
|
||||||
clientInfo: { name: "Streamyfin", version: "0.47.1" },
|
clientInfo: { name: "Streamyfin", version: "0.48.0" },
|
||||||
deviceInfo: {
|
deviceInfo: {
|
||||||
name: deviceName,
|
name: deviceName,
|
||||||
id,
|
id,
|
||||||
@@ -87,7 +87,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
return {
|
return {
|
||||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||||
Platform.OS === "android" ? "Android" : "iOS"
|
Platform.OS === "android" ? "Android" : "iOS"
|
||||||
}, DeviceId="${deviceId}", Version="0.47.1"`,
|
}, DeviceId="${deviceId}", Version="0.48.0"`,
|
||||||
};
|
};
|
||||||
}, [deviceId]);
|
}, [deviceId]);
|
||||||
|
|
||||||
|
|||||||
@@ -13,30 +13,41 @@ const disableForTV = (_moduleName) =>
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
module.exports = {
|
const dependencies = {
|
||||||
dependencies: {
|
"react-native-volume-manager": !isTV
|
||||||
"react-native-volume-manager": !isTV
|
? {
|
||||||
? {
|
platforms: {
|
||||||
platforms: {
|
// leaving this blank seems to enable auto-linking which is what we want for mobile
|
||||||
// leaving this blank seems to enable auto-linking which is what we want for mobile
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
platforms: {
|
|
||||||
android: null,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"expo-notifications": disableForTV("expo-notifications"),
|
}
|
||||||
"react-native-image-colors": disableForTV("react-native-image-colors"),
|
: {
|
||||||
"expo-sharing": disableForTV("expo-sharing"),
|
platforms: {
|
||||||
"expo-haptics": disableForTV("expo-haptics"),
|
android: null,
|
||||||
"expo-brightness": disableForTV("expo-brightness"),
|
},
|
||||||
"expo-sensors": disableForTV("expo-sensors"),
|
},
|
||||||
"expo-screen-orientation": disableForTV("expo-screen-orientation"),
|
"expo-notifications": disableForTV("expo-notifications"),
|
||||||
"react-native-ios-context-menu": disableForTV(
|
"react-native-image-colors": disableForTV("react-native-image-colors"),
|
||||||
"react-native-ios-context-menu",
|
"expo-sharing": disableForTV("expo-sharing"),
|
||||||
),
|
"expo-haptics": disableForTV("expo-haptics"),
|
||||||
"react-native-ios-utilities": disableForTV("react-native-ios-utilities"),
|
"expo-brightness": disableForTV("expo-brightness"),
|
||||||
"react-native-pager-view": disableForTV("react-native-pager-view"),
|
"expo-sensors": disableForTV("expo-sensors"),
|
||||||
|
"expo-screen-orientation": disableForTV("expo-screen-orientation"),
|
||||||
|
"react-native-ios-context-menu": disableForTV(
|
||||||
|
"react-native-ios-context-menu",
|
||||||
|
),
|
||||||
|
"react-native-ios-utilities": disableForTV("react-native-ios-utilities"),
|
||||||
|
"react-native-pager-view": disableForTV("react-native-pager-view"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter out undefined values
|
||||||
|
const cleanDependencies = Object.fromEntries(
|
||||||
|
Object.entries(dependencies).filter(([_, value]) => value !== undefined),
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
dependencies: cleanDependencies,
|
||||||
|
project: {
|
||||||
|
ios: {},
|
||||||
|
android: {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -337,7 +337,8 @@
|
|||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
"subtitle": "Subtitle",
|
"subtitle": "Subtitle",
|
||||||
"play": "Play",
|
"play": "Play",
|
||||||
"none": "None"
|
"none": "None",
|
||||||
|
"track": "Track"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Search...",
|
"search": "Search...",
|
||||||
@@ -428,7 +429,12 @@
|
|||||||
"playback_state": "Playback State:",
|
"playback_state": "Playback State:",
|
||||||
"index": "Index:",
|
"index": "Index:",
|
||||||
"continue_watching": "Continue Watching",
|
"continue_watching": "Continue Watching",
|
||||||
"go_back": "Go Back"
|
"go_back": "Go Back",
|
||||||
|
"downloaded_file_title": "You have this file downloaded",
|
||||||
|
"downloaded_file_message": "Do you want to play the downloaded file?",
|
||||||
|
"downloaded_file_yes": "Yes",
|
||||||
|
"downloaded_file_no": "No",
|
||||||
|
"downloaded_file_cancel": "Cancel"
|
||||||
},
|
},
|
||||||
"item_card": {
|
"item_card": {
|
||||||
"next_up": "Next Up",
|
"next_up": "Next Up",
|
||||||
@@ -444,6 +450,7 @@
|
|||||||
"no_similar_items_found": "No Similar Items Found",
|
"no_similar_items_found": "No Similar Items Found",
|
||||||
"video": "Video",
|
"video": "Video",
|
||||||
"more_details": "More Details",
|
"more_details": "More Details",
|
||||||
|
"media_options": "Media Options",
|
||||||
"quality": "Quality",
|
"quality": "Quality",
|
||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
"subtitles": "Subtitle",
|
"subtitles": "Subtitle",
|
||||||
@@ -512,6 +519,10 @@
|
|||||||
"number_episodes": "{{episode_number}} Episodes",
|
"number_episodes": "{{episode_number}} Episodes",
|
||||||
"born": "Born",
|
"born": "Born",
|
||||||
"appearances": "Appearances",
|
"appearances": "Appearances",
|
||||||
|
"approve": "Approve",
|
||||||
|
"decline": "Decline",
|
||||||
|
"requested_by": "Requested by {{user}}",
|
||||||
|
"unknown_user": "Unknown User",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"jellyseer_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0",
|
"jellyseer_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0",
|
||||||
"jellyseerr_test_failed": "Seerr test failed. Please try again.",
|
"jellyseerr_test_failed": "Seerr test failed. Please try again.",
|
||||||
@@ -519,7 +530,11 @@
|
|||||||
"issue_submitted": "Issue Submitted!",
|
"issue_submitted": "Issue Submitted!",
|
||||||
"requested_item": "Requested {{item}}!",
|
"requested_item": "Requested {{item}}!",
|
||||||
"you_dont_have_permission_to_request": "You don't have permission to request!",
|
"you_dont_have_permission_to_request": "You don't have permission to request!",
|
||||||
"something_went_wrong_requesting_media": "Something went wrong requesting media!"
|
"something_went_wrong_requesting_media": "Something went wrong requesting media!",
|
||||||
|
"request_approved": "Request Approved!",
|
||||||
|
"request_declined": "Request Declined!",
|
||||||
|
"failed_to_approve_request": "Failed to Approve Request",
|
||||||
|
"failed_to_decline_request": "Failed to Decline Request"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
|
|||||||
@@ -130,10 +130,9 @@ export type HomeSectionLatestResolver = {
|
|||||||
includeItemTypes?: Array<BaseItemKind>;
|
includeItemTypes?: Array<BaseItemKind>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Video player enum - currently only MPV is supported
|
||||||
export enum VideoPlayer {
|
export enum VideoPlayer {
|
||||||
// NATIVE, //todo: changes will make this a lot more easier to implement if we want. delete if not wanted
|
MPV = 0,
|
||||||
VLC_3 = 0,
|
|
||||||
VLC_4 = 1,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Settings = {
|
export type Settings = {
|
||||||
@@ -143,7 +142,6 @@ export type Settings = {
|
|||||||
preferedLanguage?: string;
|
preferedLanguage?: string;
|
||||||
searchEngine: "Marlin" | "Jellyfin";
|
searchEngine: "Marlin" | "Jellyfin";
|
||||||
marlinServerUrl?: string;
|
marlinServerUrl?: string;
|
||||||
openInVLC?: boolean;
|
|
||||||
downloadQuality?: DownloadOption;
|
downloadQuality?: DownloadOption;
|
||||||
defaultBitrate?: Bitrate;
|
defaultBitrate?: Bitrate;
|
||||||
libraryOptions: LibraryOptions;
|
libraryOptions: LibraryOptions;
|
||||||
@@ -164,16 +162,14 @@ export type Settings = {
|
|||||||
jellyseerrServerUrl?: string;
|
jellyseerrServerUrl?: string;
|
||||||
hiddenLibraries?: string[];
|
hiddenLibraries?: string[];
|
||||||
enableH265ForChromecast: boolean;
|
enableH265ForChromecast: boolean;
|
||||||
defaultPlayer: VideoPlayer;
|
|
||||||
maxAutoPlayEpisodeCount: MaxAutoPlayEpisodeCount;
|
maxAutoPlayEpisodeCount: MaxAutoPlayEpisodeCount;
|
||||||
autoPlayEpisodeCount: number;
|
autoPlayEpisodeCount: number;
|
||||||
vlcTextColor?: string;
|
// MPV subtitle settings
|
||||||
vlcBackgroundColor?: string;
|
mpvSubtitleScale?: number;
|
||||||
vlcOutlineColor?: string;
|
mpvSubtitleMarginY?: number;
|
||||||
vlcOutlineThickness?: string;
|
mpvSubtitleAlignX?: "left" | "center" | "right";
|
||||||
vlcBackgroundOpacity?: number;
|
mpvSubtitleAlignY?: "top" | "center" | "bottom";
|
||||||
vlcOutlineOpacity?: number;
|
mpvSubtitleFontSize?: number;
|
||||||
vlcIsBold?: boolean;
|
|
||||||
// Gesture controls
|
// Gesture controls
|
||||||
enableHorizontalSwipeSkip: boolean;
|
enableHorizontalSwipeSkip: boolean;
|
||||||
enableLeftSideBrightnessSwipe: boolean;
|
enableLeftSideBrightnessSwipe: boolean;
|
||||||
@@ -201,7 +197,6 @@ export const defaultValues: Settings = {
|
|||||||
preferedLanguage: undefined,
|
preferedLanguage: undefined,
|
||||||
searchEngine: "Jellyfin",
|
searchEngine: "Jellyfin",
|
||||||
marlinServerUrl: "",
|
marlinServerUrl: "",
|
||||||
openInVLC: false,
|
|
||||||
downloadQuality: DownloadOptions[0],
|
downloadQuality: DownloadOptions[0],
|
||||||
defaultBitrate: BITRATES[0],
|
defaultBitrate: BITRATES[0],
|
||||||
libraryOptions: {
|
libraryOptions: {
|
||||||
@@ -228,16 +223,14 @@ export const defaultValues: Settings = {
|
|||||||
jellyseerrServerUrl: undefined,
|
jellyseerrServerUrl: undefined,
|
||||||
hiddenLibraries: [],
|
hiddenLibraries: [],
|
||||||
enableH265ForChromecast: false,
|
enableH265ForChromecast: false,
|
||||||
defaultPlayer: VideoPlayer.VLC_3, // ios-only setting. does not matter what this is for android
|
|
||||||
maxAutoPlayEpisodeCount: { key: "3", value: 3 },
|
maxAutoPlayEpisodeCount: { key: "3", value: 3 },
|
||||||
autoPlayEpisodeCount: 0,
|
autoPlayEpisodeCount: 0,
|
||||||
vlcTextColor: undefined,
|
// MPV subtitle defaults
|
||||||
vlcBackgroundColor: undefined,
|
mpvSubtitleScale: undefined,
|
||||||
vlcOutlineColor: undefined,
|
mpvSubtitleMarginY: undefined,
|
||||||
vlcOutlineThickness: undefined,
|
mpvSubtitleAlignX: undefined,
|
||||||
vlcBackgroundOpacity: undefined,
|
mpvSubtitleAlignY: undefined,
|
||||||
vlcOutlineOpacity: undefined,
|
mpvSubtitleFontSize: undefined,
|
||||||
vlcIsBold: undefined,
|
|
||||||
// Gesture controls
|
// Gesture controls
|
||||||
enableHorizontalSwipeSkip: true,
|
enableHorizontalSwipeSkip: true,
|
||||||
enableLeftSideBrightnessSwipe: true,
|
enableLeftSideBrightnessSwipe: true,
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
// utils/getDefaultPlaySettings.ts
|
/**
|
||||||
|
* getDefaultPlaySettings.ts
|
||||||
|
*
|
||||||
|
* Determines default audio/subtitle tracks and bitrate for playback.
|
||||||
|
*
|
||||||
|
* Two use cases:
|
||||||
|
* 1. INITIAL PLAY: No previous state, uses media defaults + user language preferences
|
||||||
|
* 2. SEQUENTIAL PLAY: Has previous state (e.g., next episode), uses StreamRanker
|
||||||
|
* to find matching tracks in the new media
|
||||||
|
*/
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
@@ -12,86 +21,83 @@ import {
|
|||||||
SubtitleStreamRanker,
|
SubtitleStreamRanker,
|
||||||
} from "../streamRanker";
|
} from "../streamRanker";
|
||||||
|
|
||||||
interface PlaySettings {
|
export interface PlaySettings {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
bitrate: (typeof BITRATES)[0];
|
bitrate: (typeof BITRATES)[0];
|
||||||
mediaSource?: MediaSourceInfo | null;
|
mediaSource?: MediaSourceInfo | null;
|
||||||
audioIndex?: number | undefined;
|
|
||||||
subtitleIndex?: number | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface previousIndexes {
|
|
||||||
audioIndex?: number;
|
audioIndex?: number;
|
||||||
subtitleIndex?: number;
|
subtitleIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TrackOptions {
|
export interface PreviousIndexes {
|
||||||
DefaultAudioStreamIndex: number | undefined;
|
audioIndex?: number;
|
||||||
DefaultSubtitleStreamIndex: number | undefined;
|
subtitleIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Used getting default values for the next player.
|
/**
|
||||||
|
* Get default play settings for an item.
|
||||||
|
*
|
||||||
|
* @param item - The media item to play
|
||||||
|
* @param settings - User settings (language preferences, bitrate, etc.)
|
||||||
|
* @param previous - Optional previous track selections to carry over (for sequential play)
|
||||||
|
*/
|
||||||
export function getDefaultPlaySettings(
|
export function getDefaultPlaySettings(
|
||||||
item: BaseItemDto,
|
item: BaseItemDto,
|
||||||
settings: Settings,
|
settings: Settings | null,
|
||||||
previousIndexes?: previousIndexes,
|
previous?: { indexes?: PreviousIndexes; source?: MediaSourceInfo },
|
||||||
previousSource?: MediaSourceInfo,
|
|
||||||
): PlaySettings {
|
): PlaySettings {
|
||||||
if (item.Type === "Program") {
|
const bitrate = settings?.defaultBitrate ?? BITRATES[0];
|
||||||
return {
|
|
||||||
item,
|
|
||||||
bitrate: BITRATES[0],
|
|
||||||
mediaSource: undefined,
|
|
||||||
audioIndex: undefined,
|
|
||||||
subtitleIndex: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Get first media source
|
// Live TV programs don't have media sources
|
||||||
|
if (item.Type === "Program") {
|
||||||
|
return { item, bitrate };
|
||||||
|
}
|
||||||
|
|
||||||
const mediaSource = item.MediaSources?.[0];
|
const mediaSource = item.MediaSources?.[0];
|
||||||
|
const streams = mediaSource?.MediaStreams ?? [];
|
||||||
|
|
||||||
// We prefer the previous track over the default track.
|
// Start with media source defaults
|
||||||
const trackOptions: TrackOptions = {
|
let audioIndex = mediaSource?.DefaultAudioStreamIndex;
|
||||||
DefaultAudioStreamIndex: mediaSource?.DefaultAudioStreamIndex ?? -1,
|
let subtitleIndex = mediaSource?.DefaultSubtitleStreamIndex ?? -1;
|
||||||
DefaultSubtitleStreamIndex: mediaSource?.DefaultSubtitleStreamIndex ?? -1,
|
|
||||||
};
|
|
||||||
|
|
||||||
const mediaStreams = mediaSource?.MediaStreams ?? [];
|
// Try to match previous selections (sequential play)
|
||||||
if (settings?.rememberSubtitleSelections && previousIndexes) {
|
if (previous?.indexes && previous?.source && settings) {
|
||||||
if (previousIndexes.subtitleIndex !== undefined && previousSource) {
|
if (
|
||||||
const subtitleRanker = new SubtitleStreamRanker();
|
settings.rememberSubtitleSelections &&
|
||||||
const ranker = new StreamRanker(subtitleRanker);
|
previous.indexes.subtitleIndex !== undefined
|
||||||
|
) {
|
||||||
|
const ranker = new StreamRanker(new SubtitleStreamRanker());
|
||||||
|
const result = { DefaultSubtitleStreamIndex: subtitleIndex };
|
||||||
ranker.rankStream(
|
ranker.rankStream(
|
||||||
previousIndexes.subtitleIndex,
|
previous.indexes.subtitleIndex,
|
||||||
previousSource,
|
previous.source,
|
||||||
mediaStreams,
|
streams,
|
||||||
trackOptions,
|
result,
|
||||||
);
|
);
|
||||||
|
subtitleIndex = result.DefaultSubtitleStreamIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
settings.rememberAudioSelections &&
|
||||||
|
previous.indexes.audioIndex !== undefined
|
||||||
|
) {
|
||||||
|
const ranker = new StreamRanker(new AudioStreamRanker());
|
||||||
|
const result = { DefaultAudioStreamIndex: audioIndex };
|
||||||
|
ranker.rankStream(
|
||||||
|
previous.indexes.audioIndex,
|
||||||
|
previous.source,
|
||||||
|
streams,
|
||||||
|
result,
|
||||||
|
);
|
||||||
|
audioIndex = result.DefaultAudioStreamIndex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settings?.rememberAudioSelections && previousIndexes) {
|
|
||||||
if (previousIndexes.audioIndex !== undefined && previousSource) {
|
|
||||||
const audioRanker = new AudioStreamRanker();
|
|
||||||
const ranker = new StreamRanker(audioRanker);
|
|
||||||
ranker.rankStream(
|
|
||||||
previousIndexes.audioIndex,
|
|
||||||
previousSource,
|
|
||||||
mediaStreams,
|
|
||||||
trackOptions,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Get default bitrate from settings or fallback to max
|
|
||||||
const bitrate = settings.defaultBitrate ?? BITRATES[0];
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
item,
|
item,
|
||||||
bitrate,
|
bitrate,
|
||||||
mediaSource,
|
mediaSource,
|
||||||
audioIndex: trackOptions.DefaultAudioStreamIndex,
|
audioIndex: audioIndex ?? undefined,
|
||||||
subtitleIndex: trackOptions.DefaultSubtitleStreamIndex,
|
subtitleIndex: subtitleIndex ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Api } from "@jellyfin/sdk";
|
import type { Api } from "@jellyfin/sdk";
|
||||||
import type { AxiosResponse } from "axios";
|
import type { AxiosResponse } from "axios";
|
||||||
import type { Settings } from "@/utils/atoms/settings";
|
import type { Settings } from "../../atoms/settings";
|
||||||
import { generateDeviceProfile } from "@/utils/profiles/native";
|
import { generateDeviceProfile } from "../../profiles/native";
|
||||||
import { getAuthHeaders } from "../jellyfin";
|
import { getAuthHeaders } from "../jellyfin";
|
||||||
|
|
||||||
interface PostCapabilitiesParams {
|
interface PostCapabilitiesParams {
|
||||||
|
|||||||
115
utils/jellyfin/subtitleUtils.ts
Normal file
115
utils/jellyfin/subtitleUtils.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* Subtitle utility functions for mapping between Jellyfin and MPV track indices.
|
||||||
|
*
|
||||||
|
* Jellyfin uses server-side indices (e.g., 3, 4, 5 for subtitles in MediaStreams).
|
||||||
|
* MPV uses its own track IDs starting from 1, only counting tracks loaded into MPV.
|
||||||
|
*
|
||||||
|
* Image-based subtitles (PGS, VOBSUB) during transcoding are burned into the video
|
||||||
|
* and NOT available in MPV's track list.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
type MediaSourceInfo,
|
||||||
|
type MediaStream,
|
||||||
|
SubtitleDeliveryMethod,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
|
||||||
|
/** Check if subtitle is image-based (PGS, VOBSUB, etc.) */
|
||||||
|
export const isImageBasedSubtitle = (sub: MediaStream): boolean =>
|
||||||
|
sub.IsTextSubtitleStream === false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if a subtitle will be available in MPV's track list.
|
||||||
|
*
|
||||||
|
* A subtitle is in MPV if:
|
||||||
|
* - Delivery is Embed/Hls/External AND not an image-based sub during transcode
|
||||||
|
*/
|
||||||
|
export const isSubtitleInMpv = (
|
||||||
|
sub: MediaStream,
|
||||||
|
isTranscoding: boolean,
|
||||||
|
): boolean => {
|
||||||
|
// During transcoding, image-based subs are burned in, not in MPV
|
||||||
|
if (isTranscoding && isImageBasedSubtitle(sub)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Embed/Hls/External methods mean the sub is loaded into MPV
|
||||||
|
return (
|
||||||
|
sub.DeliveryMethod === SubtitleDeliveryMethod.Embed ||
|
||||||
|
sub.DeliveryMethod === SubtitleDeliveryMethod.Hls ||
|
||||||
|
sub.DeliveryMethod === SubtitleDeliveryMethod.External
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the MPV track ID for a given Jellyfin subtitle index.
|
||||||
|
*
|
||||||
|
* MPV track IDs are 1-based and only count subtitles that are actually in MPV.
|
||||||
|
* We iterate through all subtitles, counting only those in MPV, until we find
|
||||||
|
* the one matching the Jellyfin index.
|
||||||
|
*
|
||||||
|
* @param mediaSource - The media source containing subtitle streams
|
||||||
|
* @param jellyfinSubtitleIndex - The Jellyfin server-side subtitle index (-1 = disabled)
|
||||||
|
* @param isTranscoding - Whether the stream is being transcoded
|
||||||
|
* @returns MPV track ID (1-based), or -1 if disabled, or undefined if not in MPV
|
||||||
|
*/
|
||||||
|
export const getMpvSubtitleId = (
|
||||||
|
mediaSource: MediaSourceInfo | null | undefined,
|
||||||
|
jellyfinSubtitleIndex: number | undefined,
|
||||||
|
isTranscoding: boolean,
|
||||||
|
): number | undefined => {
|
||||||
|
// -1 or undefined means disabled
|
||||||
|
if (jellyfinSubtitleIndex === undefined || jellyfinSubtitleIndex === -1) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allSubs =
|
||||||
|
mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") || [];
|
||||||
|
|
||||||
|
// Find the subtitle with the matching Jellyfin index
|
||||||
|
const targetSub = allSubs.find((s) => s.Index === jellyfinSubtitleIndex);
|
||||||
|
|
||||||
|
// If the target subtitle isn't in MPV (e.g., image-based during transcode), return undefined
|
||||||
|
if (!targetSub || !isSubtitleInMpv(targetSub, isTranscoding)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count MPV track position (1-based)
|
||||||
|
let mpvIndex = 0;
|
||||||
|
for (const sub of allSubs) {
|
||||||
|
if (isSubtitleInMpv(sub, isTranscoding)) {
|
||||||
|
mpvIndex++;
|
||||||
|
if (sub.Index === jellyfinSubtitleIndex) {
|
||||||
|
return mpvIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the MPV track ID for a given Jellyfin audio index.
|
||||||
|
*
|
||||||
|
* Audio tracks are simpler - they're always in MPV (no burn-in like image subs).
|
||||||
|
* MPV track IDs are 1-based.
|
||||||
|
*
|
||||||
|
* @param mediaSource - The media source containing audio streams
|
||||||
|
* @param jellyfinAudioIndex - The Jellyfin server-side audio index
|
||||||
|
* @returns MPV track ID (1-based), or undefined if not found
|
||||||
|
*/
|
||||||
|
export const getMpvAudioId = (
|
||||||
|
mediaSource: MediaSourceInfo | null | undefined,
|
||||||
|
jellyfinAudioIndex: number | undefined,
|
||||||
|
): number | undefined => {
|
||||||
|
if (jellyfinAudioIndex === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allAudio =
|
||||||
|
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];
|
||||||
|
|
||||||
|
// Find position in audio list (1-based for MPV)
|
||||||
|
const position = allAudio.findIndex((a) => a.Index === jellyfinAudioIndex);
|
||||||
|
return position >= 0 ? position + 1 : undefined;
|
||||||
|
};
|
||||||
6
utils/profiles/native.d.ts
vendored
6
utils/profiles/native.d.ts
vendored
@@ -4,8 +4,4 @@
|
|||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface DeviceProfileOptions {
|
export function generateDeviceProfile(): any;
|
||||||
transcode?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function generateDeviceProfile(options?: DeviceProfileOptions): any;
|
|
||||||
|
|||||||
@@ -6,12 +6,12 @@
|
|||||||
import MediaTypes from "../../constants/MediaTypes";
|
import MediaTypes from "../../constants/MediaTypes";
|
||||||
import { getSubtitleProfiles } from "./subtitles";
|
import { getSubtitleProfiles } from "./subtitles";
|
||||||
|
|
||||||
export const generateDeviceProfile = ({ transcode = false } = {}) => {
|
export const generateDeviceProfile = () => {
|
||||||
/**
|
/**
|
||||||
* Device profile for Native video player
|
* Device profile for Native video player
|
||||||
*/
|
*/
|
||||||
const profile = {
|
const profile = {
|
||||||
Name: `1. Vlc Player${transcode ? " (Transcoding)" : ""}`,
|
Name: `1. MPV Player`,
|
||||||
MaxStaticBitrate: 999_999_999,
|
MaxStaticBitrate: 999_999_999,
|
||||||
MaxStreamingBitrate: 999_999_999,
|
MaxStreamingBitrate: 999_999_999,
|
||||||
CodecProfiles: [
|
CodecProfiles: [
|
||||||
@@ -48,7 +48,7 @@ export const generateDeviceProfile = ({ transcode = false } = {}) => {
|
|||||||
Container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp,hls",
|
Container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp,hls",
|
||||||
VideoCodec:
|
VideoCodec:
|
||||||
"h264,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1,avi,mpeg,mpeg2video",
|
"h264,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1,avi,mpeg,mpeg2video",
|
||||||
AudioCodec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,wma,dts",
|
AudioCodec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,wma,dts,truehd",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Type: MediaTypes.Audio,
|
Type: MediaTypes.Audio,
|
||||||
@@ -75,7 +75,7 @@ export const generateDeviceProfile = ({ transcode = false } = {}) => {
|
|||||||
MaxAudioChannels: "2",
|
MaxAudioChannels: "2",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
SubtitleProfiles: getSubtitleProfiles(transcode ? "hls" : "External"),
|
SubtitleProfiles: getSubtitleProfiles(),
|
||||||
};
|
};
|
||||||
|
|
||||||
return profile;
|
return profile;
|
||||||
|
|||||||
@@ -4,26 +4,19 @@
|
|||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const COMMON_SUBTITLE_PROFILES = [
|
// Image-based formats - these need to be burned in by Jellyfin (Encode method)
|
||||||
// Official formats
|
// because MPV cannot load them externally over HTTP
|
||||||
|
const IMAGE_BASED_FORMATS = [
|
||||||
{ Format: "dvdsub", Method: "Embed" },
|
"dvdsub",
|
||||||
{ Format: "dvdsub", Method: "Encode" },
|
"idx",
|
||||||
|
"pgs",
|
||||||
{ Format: "idx", Method: "Embed" },
|
"pgssub",
|
||||||
{ Format: "idx", Method: "Encode" },
|
"teletext",
|
||||||
|
"vobsub",
|
||||||
{ Format: "pgs", Method: "Embed" },
|
|
||||||
{ Format: "pgs", Method: "Encode" },
|
|
||||||
|
|
||||||
{ Format: "pgssub", Method: "Embed" },
|
|
||||||
{ Format: "pgssub", Method: "Encode" },
|
|
||||||
|
|
||||||
{ Format: "teletext", Method: "Embed" },
|
|
||||||
{ Format: "teletext", Method: "Encode" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const VARYING_SUBTITLE_FORMATS = [
|
// Text-based formats - these can be loaded externally by MPV
|
||||||
|
const TEXT_BASED_FORMATS = [
|
||||||
"webvtt",
|
"webvtt",
|
||||||
"vtt",
|
"vtt",
|
||||||
"srt",
|
"srt",
|
||||||
@@ -46,11 +39,23 @@ const VARYING_SUBTITLE_FORMATS = [
|
|||||||
"xsub",
|
"xsub",
|
||||||
];
|
];
|
||||||
|
|
||||||
export const getSubtitleProfiles = (secondaryMethod) => {
|
export const getSubtitleProfiles = () => {
|
||||||
const profiles = [...COMMON_SUBTITLE_PROFILES];
|
const profiles = [];
|
||||||
for (const format of VARYING_SUBTITLE_FORMATS) {
|
|
||||||
|
// Image-based formats: Embed or Encode (burn-in), NOT External
|
||||||
|
for (const format of IMAGE_BASED_FORMATS) {
|
||||||
profiles.push({ Format: format, Method: "Embed" });
|
profiles.push({ Format: format, Method: "Embed" });
|
||||||
profiles.push({ Format: format, Method: secondaryMethod });
|
profiles.push({ Format: format, Method: "Encode" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Text-based formats: Embed or External
|
||||||
|
for (const format of TEXT_BASED_FORMATS) {
|
||||||
|
profiles.push({ Format: format, Method: "Embed" });
|
||||||
|
profiles.push({ Format: format, Method: "External" });
|
||||||
|
}
|
||||||
|
|
||||||
return profiles;
|
return profiles;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Export for use in player filtering
|
||||||
|
export const IMAGE_SUBTITLE_CODECS = IMAGE_BASED_FORMATS;
|
||||||
|
|||||||
Reference in New Issue
Block a user