mirror of
https://github.com/streamyfin/streamyfin.git
synced 2025-08-20 18:37:18 +02:00
Compare commits
14 Commits
bot-sugges
...
hotfix/fil
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
017bd4d074 | ||
|
|
8b3141dfc6 | ||
|
|
d83ecb881b | ||
|
|
4c14c08b35 | ||
|
|
ecb9b90163 | ||
|
|
33a2be24f4 | ||
|
|
e8b0d52515 | ||
|
|
9faa0de2d6 | ||
|
|
221155d002 | ||
|
|
4a37e17324 | ||
|
|
52b2a3418e | ||
|
|
2753b243e5 | ||
|
|
f22b356b7c | ||
|
|
d8ba5af8d9 |
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -43,6 +43,7 @@ body:
|
||||
label: Version
|
||||
description: What version of Streamyfin are you running?
|
||||
options:
|
||||
- 0.26.1
|
||||
- 0.26.0
|
||||
- 0.25.0
|
||||
- 0.24.0
|
||||
|
||||
24
app.json
24
app.json
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Streamyfin",
|
||||
"slug": "streamyfin",
|
||||
"version": "0.26.0",
|
||||
"version": "0.26.1",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "streamyfin",
|
||||
@@ -31,7 +31,7 @@
|
||||
},
|
||||
"android": {
|
||||
"jsEngine": "hermes",
|
||||
"versionCode": 52,
|
||||
"versionCode": 53,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/adaptive_icon.png"
|
||||
},
|
||||
@@ -106,21 +106,11 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
"react-native-bottom-tabs"
|
||||
],
|
||||
[
|
||||
"./plugins/withChangeNativeAndroidTextToWhite.js"
|
||||
],
|
||||
[
|
||||
"./plugins/withAndroidManifest.js"
|
||||
],
|
||||
[
|
||||
"./plugins/withTrustLocalCerts.js"
|
||||
],
|
||||
[
|
||||
"./plugins/withGradleProperties.js"
|
||||
],
|
||||
["react-native-bottom-tabs"],
|
||||
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
||||
["./plugins/withAndroidManifest.js"],
|
||||
["./plugins/withTrustLocalCerts.js"],
|
||||
["./plugins/withGradleProperties.js"],
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
|
||||
@@ -38,9 +38,18 @@ export default function SearchLayout() {
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="jellyseerr/page" options={commonScreenOptions} />
|
||||
<Stack.Screen name="jellyseerr/person/[personId]" options={commonScreenOptions} />
|
||||
<Stack.Screen name="jellyseerr/company/[companyId]" options={commonScreenOptions} />
|
||||
<Stack.Screen name="jellyseerr/genre/[genreId]" options={commonScreenOptions} />
|
||||
<Stack.Screen
|
||||
name="jellyseerr/person/[personId]"
|
||||
options={commonScreenOptions}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="jellyseerr/company/[companyId]"
|
||||
options={commonScreenOptions}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="jellyseerr/genre/[genreId]"
|
||||
options={commonScreenOptions}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function search() {
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { q, prev } = params as { q: string; prev: Href<string> };
|
||||
const { q } = params as { q: string };
|
||||
|
||||
const [searchType, setSearchType] = useState<SearchType>("Library");
|
||||
const [search, setSearch] = useState<string>("");
|
||||
@@ -122,18 +122,17 @@ export default function search() {
|
||||
|
||||
const navigation = useNavigation();
|
||||
useLayoutEffect(() => {
|
||||
if (Platform.OS === "ios")
|
||||
navigation.setOptions({
|
||||
headerSearchBarOptions: {
|
||||
placeholder: t("search.search"),
|
||||
onChangeText: (e: any) => {
|
||||
router.setParams({ q: "" });
|
||||
setSearch(e.nativeEvent.text);
|
||||
},
|
||||
hideWhenScrolling: false,
|
||||
autoFocus: true,
|
||||
navigation.setOptions({
|
||||
headerSearchBarOptions: {
|
||||
placeholder: t("search.search"),
|
||||
onChangeText: (e: any) => {
|
||||
router.setParams({ q: "" });
|
||||
setSearch(e.nativeEvent.text);
|
||||
},
|
||||
});
|
||||
hideWhenScrolling: false,
|
||||
autoFocus: true,
|
||||
},
|
||||
});
|
||||
}, [navigation]);
|
||||
|
||||
const { data: movies, isFetching: l1 } = useQuery({
|
||||
@@ -211,18 +210,6 @@ export default function search() {
|
||||
}}
|
||||
>
|
||||
<View className="flex flex-col">
|
||||
{Platform.OS === "android" && (
|
||||
<View className="mb-4 px-4">
|
||||
<Input
|
||||
autoCorrect={false}
|
||||
returnKeyType="done"
|
||||
keyboardType="web-search"
|
||||
placeholder={t("search.search_here")}
|
||||
value={search}
|
||||
onChangeText={(text) => setSearch(text)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{jellyseerrApi && (
|
||||
<View className="flex flex-row flex-wrap space-x-2 px-4 mb-2">
|
||||
<TouchableOpacity onPress={() => setSearchType("Library")}>
|
||||
|
||||
@@ -36,15 +36,6 @@ export default function Layout() {
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="transcoding-player"
|
||||
options={{
|
||||
headerShown: false,
|
||||
autoHideHomeIndicator: true,
|
||||
title: "",
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useFocusEffect, useGlobalSearchParams } from "expo-router";
|
||||
import { useGlobalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, {
|
||||
useCallback,
|
||||
@@ -36,17 +36,12 @@ import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import {
|
||||
Alert,
|
||||
View,
|
||||
AppState,
|
||||
AppStateStatus,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { Alert, View, AppState, AppStateStatus, Platform } from "react-native";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||
|
||||
export default function page() {
|
||||
console.log("Direct Player");
|
||||
@@ -54,6 +49,7 @@ export default function page() {
|
||||
const user = useAtomValue(userAtom);
|
||||
const api = useAtomValue(apiAtom);
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||
const [showControls, _setShowControls] = useState(true);
|
||||
@@ -127,57 +123,80 @@ export default function page() {
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const {
|
||||
data: stream,
|
||||
isLoading: isLoadingStreamUrl,
|
||||
isError: isErrorStreamUrl,
|
||||
} = useQuery({
|
||||
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue],
|
||||
queryFn: async () => {
|
||||
if (offline && !Platform.isTV) {
|
||||
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
||||
if (!data?.mediaSource) return null;
|
||||
const [stream, setStream] = useState<{
|
||||
mediaSource: MediaSourceInfo;
|
||||
url: string;
|
||||
sessionId: string | undefined;
|
||||
} | null>(null);
|
||||
const [isLoadingStream, setIsLoadingStream] = useState(true);
|
||||
const [isErrorStream, setIsErrorStream] = useState(false);
|
||||
|
||||
const url = await getDownloadedFileUrl(data.item.Id!);
|
||||
useEffect(() => {
|
||||
const fetchStream = async () => {
|
||||
setIsLoadingStream(true);
|
||||
setIsErrorStream(false);
|
||||
|
||||
if (item)
|
||||
return {
|
||||
mediaSource: data.mediaSource,
|
||||
url,
|
||||
sessionId: undefined,
|
||||
};
|
||||
try {
|
||||
if (offline && !Platform.isTV) {
|
||||
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
||||
if (!data?.mediaSource) {
|
||||
setStream(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const url = await getDownloadedFileUrl(data.item.Id!);
|
||||
|
||||
if (item) {
|
||||
setStream({
|
||||
mediaSource: data.mediaSource as MediaSourceInfo,
|
||||
url,
|
||||
sessionId: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const res = await getStreamUrl({
|
||||
api,
|
||||
item,
|
||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
||||
userId: user?.Id,
|
||||
audioStreamIndex: audioIndex,
|
||||
maxStreamingBitrate: bitrateValue,
|
||||
mediaSourceId: mediaSourceId,
|
||||
subtitleStreamIndex: subtitleIndex,
|
||||
deviceProfile: native,
|
||||
});
|
||||
|
||||
if (!res) {
|
||||
setStream(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const { mediaSource, sessionId, url } = res;
|
||||
|
||||
if (!sessionId || !mediaSource || !url) {
|
||||
Alert.alert(t("player.error"), t("player.failed_to_get_stream_url"));
|
||||
setStream(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setStream({
|
||||
mediaSource,
|
||||
sessionId,
|
||||
url,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching stream:", error);
|
||||
setIsErrorStream(true);
|
||||
setStream(null);
|
||||
} finally {
|
||||
setIsLoadingStream(false);
|
||||
}
|
||||
};
|
||||
|
||||
const res = await getStreamUrl({
|
||||
api,
|
||||
item,
|
||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
||||
userId: user?.Id,
|
||||
audioStreamIndex: audioIndex,
|
||||
maxStreamingBitrate: bitrateValue,
|
||||
mediaSourceId: mediaSourceId,
|
||||
subtitleStreamIndex: subtitleIndex,
|
||||
deviceProfile: native,
|
||||
});
|
||||
|
||||
if (!res) return null;
|
||||
|
||||
const { mediaSource, sessionId, url } = res;
|
||||
|
||||
if (!sessionId || !mediaSource || !url) {
|
||||
Alert.alert(t("player.error"), t("player.failed_to_get_stream_url"));
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
mediaSource,
|
||||
sessionId,
|
||||
url,
|
||||
};
|
||||
},
|
||||
enabled: !!itemId && !!item,
|
||||
staleTime: 0,
|
||||
});
|
||||
fetchStream();
|
||||
}, [itemId, mediaSourceId]);
|
||||
|
||||
const togglePlay = useCallback(async () => {
|
||||
if (!api) return;
|
||||
@@ -197,9 +216,7 @@ export default function page() {
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: msToTicks(progress.get()),
|
||||
isPaused: !isPlaying,
|
||||
playMethod: stream?.url.includes("m3u8")
|
||||
? "Transcode"
|
||||
: "DirectStream",
|
||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: stream.sessionId,
|
||||
});
|
||||
}
|
||||
@@ -237,21 +254,6 @@ export default function page() {
|
||||
videoRef.current?.stop();
|
||||
}, [videoRef, reportPlaybackStopped]);
|
||||
|
||||
// TODO: unused should remove.
|
||||
const reportPlaybackStart = useCallback(async () => {
|
||||
if (offline) return;
|
||||
|
||||
if (!stream) return;
|
||||
await getPlaystateApi(api!).onPlaybackStart({
|
||||
itemId: item?.Id!,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
mediaSourceId: mediaSourceId,
|
||||
playMethod: stream.url?.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: stream?.sessionId ? stream?.sessionId : undefined,
|
||||
});
|
||||
}, [api, item, mediaSourceId, stream]);
|
||||
|
||||
const onProgress = useCallback(
|
||||
async (data: ProgressUpdatePayload) => {
|
||||
if (isSeeking.get() || isPlaybackStopped) return;
|
||||
@@ -293,8 +295,8 @@ export default function page() {
|
||||
|
||||
const onPipStarted = useCallback((e: PipStartedPayload) => {
|
||||
const { pipStarted } = e.nativeEvent;
|
||||
setIsPipStarted(pipStarted)
|
||||
}, [])
|
||||
setIsPipStarted(pipStarted);
|
||||
}, []);
|
||||
|
||||
const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => {
|
||||
const { state, isBuffering, isPlaying } = e.nativeEvent;
|
||||
@@ -325,91 +327,69 @@ export default function page() {
|
||||
: 0;
|
||||
}, [item]);
|
||||
|
||||
const [appState, setAppState] = useState(AppState.currentState);
|
||||
|
||||
useEffect(() => {
|
||||
const handleAppStateChange = (nextAppState: AppStateStatus) => {
|
||||
// Handle app going to the background
|
||||
if (nextAppState.match(/inactive|background/)) {
|
||||
_setShowControls(false)
|
||||
}
|
||||
setAppState(nextAppState);
|
||||
};
|
||||
|
||||
// Use AppState.addEventListener and return a cleanup function
|
||||
const subscription = AppState.addEventListener(
|
||||
"change",
|
||||
handleAppStateChange
|
||||
);
|
||||
|
||||
return () => {
|
||||
// Cleanup the event listener when the component is unmounted
|
||||
subscription.remove();
|
||||
};
|
||||
}, [appState, isPipStarted, isPlaying]);
|
||||
|
||||
// Preselection of audio and subtitle tracks.
|
||||
if (!settings) return null;
|
||||
|
||||
let initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
|
||||
let externalTrack = { name: "", DeliveryUrl: "" };
|
||||
|
||||
const allSubs =
|
||||
stream?.mediaSource.MediaStreams?.filter(
|
||||
(sub: { Type: string }) => sub.Type === "Subtitle"
|
||||
) || [];
|
||||
const chosenSubtitleTrack = allSubs.find(
|
||||
(sub: { Index: number }) => sub.Index === subtitleIndex
|
||||
);
|
||||
const allAudio =
|
||||
stream?.mediaSource.MediaStreams?.filter(
|
||||
(audio: { Type: string }) => audio.Type === "Audio"
|
||||
(audio) => audio.Type === "Audio"
|
||||
) || [];
|
||||
const chosenAudioTrack = allAudio.find(
|
||||
(audio: { Index: number | undefined }) => audio.Index === audioIndex
|
||||
const allSubs =
|
||||
stream?.mediaSource.MediaStreams?.filter(
|
||||
(sub) => sub.Type === "Subtitle"
|
||||
) || [];
|
||||
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
|
||||
|
||||
const chosenSubtitleTrack = allSubs.find(
|
||||
(sub) => sub.Index === subtitleIndex
|
||||
);
|
||||
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
|
||||
|
||||
// Direct playback CASE
|
||||
if (!bitrateValue) {
|
||||
// If Subtitle is embedded we can use the position to select it straight away.
|
||||
if (chosenSubtitleTrack && !chosenSubtitleTrack.DeliveryUrl) {
|
||||
initOptions.push(`--sub-track=${allSubs.indexOf(chosenSubtitleTrack)}`);
|
||||
} else if (chosenSubtitleTrack && chosenSubtitleTrack.DeliveryUrl) {
|
||||
// If Subtitle is external we need to pass the URL to the player.
|
||||
externalTrack = {
|
||||
name: chosenSubtitleTrack.DisplayTitle || "",
|
||||
DeliveryUrl: `${api?.basePath || ""}${chosenSubtitleTrack.DeliveryUrl}`,
|
||||
};
|
||||
}
|
||||
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
|
||||
if (
|
||||
chosenSubtitleTrack &&
|
||||
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
|
||||
) {
|
||||
const finalIndex = notTranscoding
|
||||
? allSubs.indexOf(chosenSubtitleTrack)
|
||||
: textSubs.indexOf(chosenSubtitleTrack);
|
||||
initOptions.push(`--sub-track=${finalIndex}`);
|
||||
}
|
||||
|
||||
if (chosenAudioTrack)
|
||||
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
|
||||
} else {
|
||||
// Transcoded playback CASE
|
||||
if (chosenSubtitleTrack?.DeliveryMethod === "Hls") {
|
||||
externalTrack = {
|
||||
name: `subs ${chosenSubtitleTrack.DisplayTitle}`,
|
||||
DeliveryUrl: "",
|
||||
};
|
||||
}
|
||||
if (notTranscoding && chosenAudioTrack) {
|
||||
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
|
||||
}
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
useEffect(() => {
|
||||
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
|
||||
return () => {
|
||||
beforeRemoveListener();
|
||||
};
|
||||
}, [navigation]);
|
||||
|
||||
if (!item || isLoadingItem || isLoadingStreamUrl || !stream)
|
||||
if (!item || isLoadingItem || !stream)
|
||||
return (
|
||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
if (isErrorItem || isErrorStreamUrl)
|
||||
if (isErrorItem || isErrorStream)
|
||||
return (
|
||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||
<Text className="text-white">{t("player.error")}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
const externalSubtitles = allSubs
|
||||
.filter((sub: any) => sub.DeliveryMethod === "External")
|
||||
.map((sub: any) => ({
|
||||
name: sub.DisplayTitle,
|
||||
DeliveryUrl: api?.basePath + sub.DeliveryUrl,
|
||||
}));
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: "black" }}>
|
||||
<View
|
||||
@@ -427,11 +407,11 @@ export default function page() {
|
||||
<VlcPlayerView
|
||||
ref={videoRef}
|
||||
source={{
|
||||
uri: stream.url,
|
||||
uri: stream?.url || "",
|
||||
autoplay: true,
|
||||
isNetwork: true,
|
||||
startPosition,
|
||||
externalTrack,
|
||||
externalSubtitles,
|
||||
initOptions,
|
||||
}}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
@@ -453,7 +433,7 @@ export default function page() {
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
{videoRef.current && (
|
||||
{videoRef.current && !isPipStarted && (
|
||||
<Controls
|
||||
mediaSource={stream?.mediaSource}
|
||||
item={item}
|
||||
@@ -486,4 +466,4 @@ export default function page() {
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,546 +0,0 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { Controls } from "@/components/video-player/controls/Controls";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||
import { TrackInfo } from "@/modules/vlc-player";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import transcoding from "@/utils/profiles/transcoding";
|
||||
import { secondsToTicks } from "@/utils/secondsToTicks";
|
||||
import { Api } from "@jellyfin/sdk";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import {
|
||||
getPlaystateApi,
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { View } from "react-native";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import Video, {
|
||||
OnProgressData,
|
||||
SelectedTrack,
|
||||
SelectedTrackType,
|
||||
VideoRef,
|
||||
} from "react-native-video";
|
||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const Player = () => {
|
||||
console.log("Transcoding Player");
|
||||
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const [settings] = useSettings();
|
||||
const videoRef = useRef<VideoRef | null>(null);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const firstTime = useRef(true);
|
||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||
const [showControls, _setShowControls] = useState(true);
|
||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isBuffering, setIsBuffering] = useState(true);
|
||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
||||
|
||||
const setShowControls = useCallback((show: boolean) => {
|
||||
_setShowControls(show);
|
||||
lightHapticFeedback();
|
||||
}, []);
|
||||
|
||||
const progress = useSharedValue(0);
|
||||
const isSeeking = useSharedValue(false);
|
||||
const cacheProgress = useSharedValue(0);
|
||||
|
||||
const {
|
||||
itemId,
|
||||
audioIndex: audioIndexStr,
|
||||
subtitleIndex: subtitleIndexStr,
|
||||
mediaSourceId,
|
||||
bitrateValue: bitrateValueStr,
|
||||
} = useLocalSearchParams<{
|
||||
itemId: string;
|
||||
audioIndex: string;
|
||||
subtitleIndex: string;
|
||||
mediaSourceId: string;
|
||||
bitrateValue: string;
|
||||
}>();
|
||||
|
||||
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
|
||||
const subtitleIndex = subtitleIndexStr
|
||||
? parseInt(subtitleIndexStr, 10)
|
||||
: undefined;
|
||||
const bitrateValue = bitrateValueStr
|
||||
? parseInt(bitrateValueStr, 10)
|
||||
: undefined;
|
||||
|
||||
const {
|
||||
data: item,
|
||||
isLoading: isLoadingItem,
|
||||
isError: isErrorItem,
|
||||
} = useQuery({
|
||||
queryKey: ["item", itemId],
|
||||
queryFn: async () => {
|
||||
if (!api) {
|
||||
throw new Error("No api");
|
||||
}
|
||||
|
||||
if (!itemId) {
|
||||
console.warn("No itemId");
|
||||
return null;
|
||||
}
|
||||
|
||||
const res = await getUserLibraryApi(api).getItem({
|
||||
itemId,
|
||||
userId: user?.Id,
|
||||
});
|
||||
|
||||
return res.data;
|
||||
},
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
// TODO: NEED TO FIND A WAY TO FROM SWITCHING TO IMAGE BASED TO TEXT BASED SUBTITLES, THERE IS A BUG.
|
||||
// MOST LIKELY LIKELY NEED A MASSIVE REFACTOR.
|
||||
const {
|
||||
data: stream,
|
||||
isLoading: isLoadingStreamUrl,
|
||||
isError: isErrorStreamUrl,
|
||||
} = useQuery({
|
||||
queryKey: ["stream-url", itemId, bitrateValue, mediaSourceId, audioIndex],
|
||||
|
||||
queryFn: async () => {
|
||||
if (!api) {
|
||||
throw new Error("No api");
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
console.warn("No item", itemId, item);
|
||||
return null;
|
||||
}
|
||||
|
||||
const res = await getStreamUrl({
|
||||
api,
|
||||
item,
|
||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
||||
userId: user?.Id,
|
||||
audioStreamIndex: audioIndex,
|
||||
maxStreamingBitrate: bitrateValue,
|
||||
mediaSourceId: mediaSourceId,
|
||||
subtitleStreamIndex: subtitleIndex,
|
||||
deviceProfile: transcoding,
|
||||
});
|
||||
|
||||
if (!res) return null;
|
||||
|
||||
const { mediaSource, sessionId, url } = res;
|
||||
|
||||
if (!sessionId || !mediaSource || !url) {
|
||||
console.warn("No sessionId or mediaSource or url", url);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
mediaSource,
|
||||
sessionId,
|
||||
url,
|
||||
};
|
||||
},
|
||||
enabled: !!item,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const poster = usePoster(item, api);
|
||||
const videoSource = useVideoSource(item, api, poster, stream?.url);
|
||||
|
||||
const togglePlay = useCallback(async () => {
|
||||
lightHapticFeedback();
|
||||
if (isPlaying) {
|
||||
videoRef.current?.pause();
|
||||
await getPlaystateApi(api!).onPlaybackProgress({
|
||||
itemId: item?.Id!,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: Math.floor(progress.value),
|
||||
isPaused: true,
|
||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: stream?.sessionId,
|
||||
});
|
||||
} else {
|
||||
videoRef.current?.resume();
|
||||
await getPlaystateApi(api!).onPlaybackProgress({
|
||||
itemId: item?.Id!,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: Math.floor(progress.value),
|
||||
isPaused: false,
|
||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: stream?.sessionId,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
isPlaying,
|
||||
api,
|
||||
item,
|
||||
videoRef,
|
||||
settings,
|
||||
stream,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
mediaSourceId,
|
||||
]);
|
||||
|
||||
const play = useCallback(() => {
|
||||
videoRef.current?.resume();
|
||||
reportPlaybackStart();
|
||||
}, [videoRef]);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
videoRef.current?.pause();
|
||||
}, [videoRef]);
|
||||
|
||||
const seek = useCallback(
|
||||
(seconds: number) => {
|
||||
videoRef.current?.seek(seconds);
|
||||
},
|
||||
[videoRef]
|
||||
);
|
||||
|
||||
const reportPlaybackStopped = async () => {
|
||||
if (!item?.Id) return;
|
||||
await getPlaystateApi(api!).onPlaybackStopped({
|
||||
itemId: item.Id,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: Math.floor(progress.value),
|
||||
playSessionId: stream?.sessionId,
|
||||
});
|
||||
revalidateProgressCache();
|
||||
};
|
||||
|
||||
const stop = useCallback(() => {
|
||||
reportPlaybackStopped();
|
||||
videoRef.current?.pause();
|
||||
setIsPlaybackStopped(true);
|
||||
}, [videoRef, reportPlaybackStopped]);
|
||||
|
||||
const reportPlaybackStart = async () => {
|
||||
if (!item?.Id) return;
|
||||
await getPlaystateApi(api!).onPlaybackStart({
|
||||
itemId: item.Id,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
mediaSourceId: mediaSourceId,
|
||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: stream?.sessionId,
|
||||
});
|
||||
};
|
||||
|
||||
const onProgress = useCallback(
|
||||
async (data: OnProgressData) => {
|
||||
if (isSeeking.value === true) return;
|
||||
if (isPlaybackStopped === true) return;
|
||||
|
||||
const ticks = secondsToTicks(data.currentTime);
|
||||
|
||||
progress.value = ticks;
|
||||
cacheProgress.value = secondsToTicks(data.playableDuration);
|
||||
|
||||
// TODO: Use this when streaming with HLS url, but NOT when direct playing
|
||||
// TODO: since playable duration is always 0 then.
|
||||
setIsBuffering(data.playableDuration === 0);
|
||||
|
||||
if (!item?.Id || data.currentTime === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await getPlaystateApi(api!).onPlaybackProgress({
|
||||
itemId: item.Id,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: Math.round(ticks),
|
||||
isPaused: !isPlaying,
|
||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: stream?.sessionId,
|
||||
});
|
||||
},
|
||||
[
|
||||
item,
|
||||
isPlaying,
|
||||
api,
|
||||
isPlaybackStopped,
|
||||
isSeeking,
|
||||
stream,
|
||||
mediaSourceId,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
]
|
||||
);
|
||||
|
||||
useWebSocket({
|
||||
isPlaying: isPlaying,
|
||||
togglePlay: togglePlay,
|
||||
stopPlayback: stop,
|
||||
offline: false,
|
||||
});
|
||||
|
||||
const [selectedTextTrack, setSelectedTextTrack] = useState<
|
||||
SelectedTrack | undefined
|
||||
>();
|
||||
|
||||
const [embededTextTracks, setEmbededTextTracks] = useState<
|
||||
{
|
||||
index: number;
|
||||
language?: string | undefined;
|
||||
selected?: boolean | undefined;
|
||||
title?: string | undefined;
|
||||
type: any;
|
||||
}[]
|
||||
>([]);
|
||||
|
||||
const [audioTracks, setAudioTracks] = useState<TrackInfo[]>([]);
|
||||
const [selectedAudioTrack, setSelectedAudioTrack] = useState<
|
||||
SelectedTrack | undefined
|
||||
>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTextTrack === undefined) {
|
||||
const subtitleHelper = new SubtitleHelper(
|
||||
stream?.mediaSource.MediaStreams ?? []
|
||||
);
|
||||
const embeddedTrackIndex = subtitleHelper.getEmbeddedTrackIndex(
|
||||
subtitleIndex!
|
||||
);
|
||||
|
||||
// Most likely the subtitle is burned in.
|
||||
if (embeddedTrackIndex === -1) return;
|
||||
|
||||
setSelectedTextTrack({
|
||||
type: SelectedTrackType.INDEX,
|
||||
value: embeddedTrackIndex,
|
||||
});
|
||||
}
|
||||
}, [embededTextTracks]);
|
||||
|
||||
const getAudioTracks = (): TrackInfo[] => {
|
||||
return audioTracks.map((t) => ({
|
||||
name: t.name,
|
||||
index: t.index,
|
||||
}));
|
||||
};
|
||||
|
||||
const getSubtitleTracks = (): TrackInfo[] => {
|
||||
return embededTextTracks.map((t) => ({
|
||||
name: t.title ?? "",
|
||||
index: t.index,
|
||||
language: t.language,
|
||||
}));
|
||||
};
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
return async () => {
|
||||
stop();
|
||||
};
|
||||
}, [])
|
||||
);
|
||||
|
||||
if (isLoadingItem || isLoadingStreamUrl)
|
||||
return (
|
||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
if (isErrorItem || isErrorStreamUrl)
|
||||
return (
|
||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||
<Text className="text-white">{t("player.error")}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: "black" }}>
|
||||
<View
|
||||
style={{
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "relative",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{videoSource ? (
|
||||
<>
|
||||
<Video
|
||||
ref={videoRef}
|
||||
source={videoSource}
|
||||
style={{
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
}}
|
||||
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
|
||||
onProgress={onProgress}
|
||||
onError={(e) => {
|
||||
console.error("Error playing video", e);
|
||||
}}
|
||||
onLoad={() => {
|
||||
if (firstTime.current === true) {
|
||||
play();
|
||||
firstTime.current = false;
|
||||
}
|
||||
}}
|
||||
progressUpdateInterval={500}
|
||||
playWhenInactive={true}
|
||||
allowsExternalPlayback={true}
|
||||
playInBackground={true}
|
||||
showNotificationControls={true}
|
||||
ignoreSilentSwitch="ignore"
|
||||
fullscreen={false}
|
||||
onPlaybackStateChanged={(state) => {
|
||||
if (isSeeking.value === false) setIsPlaying(state.isPlaying);
|
||||
}}
|
||||
onTextTracks={(data) => {
|
||||
setEmbededTextTracks(data.textTracks as any);
|
||||
}}
|
||||
onBuffer={(e) => {
|
||||
setIsBuffering(e.isBuffering);
|
||||
}}
|
||||
onAudioTracks={(e) => {
|
||||
setAudioTracks(
|
||||
e.audioTracks.map((t) => ({
|
||||
index: t.index,
|
||||
name: t.title ?? "",
|
||||
language: t.language,
|
||||
}))
|
||||
);
|
||||
}}
|
||||
selectedTextTrack={selectedTextTrack}
|
||||
selectedAudioTrack={selectedAudioTrack}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Text>{t("player.no_video_source")}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{item && (
|
||||
<Controls
|
||||
mediaSource={stream?.mediaSource}
|
||||
videoRef={videoRef}
|
||||
enableTrickplay={true}
|
||||
item={item}
|
||||
togglePlay={togglePlay}
|
||||
isPlaying={isPlaying}
|
||||
isSeeking={isSeeking}
|
||||
progress={progress}
|
||||
cacheProgress={cacheProgress}
|
||||
isBuffering={isBuffering}
|
||||
showControls={showControls}
|
||||
setShowControls={setShowControls}
|
||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
||||
ignoreSafeAreas={ignoreSafeAreas}
|
||||
seek={seek}
|
||||
play={play}
|
||||
pause={pause}
|
||||
stop={stop}
|
||||
getSubtitleTracks={getSubtitleTracks}
|
||||
setSubtitleTrack={(i) => {
|
||||
if (i === -1) {
|
||||
setSelectedTextTrack({
|
||||
type: SelectedTrackType.DISABLED,
|
||||
value: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSelectedTextTrack({
|
||||
type: SelectedTrackType.INDEX,
|
||||
value: i,
|
||||
});
|
||||
}}
|
||||
getAudioTracks={getAudioTracks}
|
||||
setAudioTrack={(i) => {
|
||||
setSelectedAudioTrack({
|
||||
type: SelectedTrackType.INDEX,
|
||||
value: i,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export function usePoster(
|
||||
item: BaseItemDto | null | undefined,
|
||||
api: Api | null
|
||||
): string | undefined {
|
||||
const poster = useMemo(() => {
|
||||
if (!item || !api) return undefined;
|
||||
return item.Type === "Audio"
|
||||
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
|
||||
: getBackdropUrl({
|
||||
api,
|
||||
item: item,
|
||||
quality: 70,
|
||||
width: 200,
|
||||
});
|
||||
}, [item, api]);
|
||||
|
||||
return poster ?? undefined;
|
||||
}
|
||||
|
||||
export function useVideoSource(
|
||||
item: BaseItemDto | null | undefined,
|
||||
api: Api | null,
|
||||
poster: string | undefined,
|
||||
url?: string | null
|
||||
) {
|
||||
const videoSource = useMemo(() => {
|
||||
if (!item || !api || !url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startPosition = item?.UserData?.PlaybackPositionTicks
|
||||
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
uri: url,
|
||||
isNetwork: true,
|
||||
startPosition,
|
||||
headers: getAuthHeaders(api),
|
||||
metadata: {
|
||||
title: item?.Name || "Unknown",
|
||||
description: item?.Overview ?? undefined,
|
||||
imageUri: poster,
|
||||
subtitle: item?.Album ?? undefined,
|
||||
},
|
||||
};
|
||||
}, [item, api, poster, url]);
|
||||
|
||||
return videoSource;
|
||||
}
|
||||
|
||||
export default Player;
|
||||
@@ -16,7 +16,6 @@ import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||
import { useImageColors } from "@/hooks/useImageColors";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import {
|
||||
@@ -118,37 +117,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
const loading = useMemo(() => {
|
||||
return Boolean(logoUrl && loadingLogo);
|
||||
}, [loadingLogo, logoUrl]);
|
||||
|
||||
const [isTranscoding, setIsTranscoding] = useState(false);
|
||||
const [previouslyChosenSubtitleIndex, setPreviouslyChosenSubtitleIndex] =
|
||||
useState<number | undefined>(selectedOptions?.subtitleIndex);
|
||||
|
||||
useEffect(() => {
|
||||
const isTranscoding = Boolean(selectedOptions?.bitrate.value);
|
||||
if (isTranscoding) {
|
||||
setPreviouslyChosenSubtitleIndex(selectedOptions?.subtitleIndex);
|
||||
const subHelper = new SubtitleHelper(
|
||||
selectedOptions?.mediaSource?.MediaStreams ?? []
|
||||
);
|
||||
|
||||
const newSubtitleIndex = subHelper.getMostCommonSubtitleByName(
|
||||
selectedOptions?.subtitleIndex
|
||||
);
|
||||
|
||||
setSelectedOptions((prev) => ({
|
||||
...prev!,
|
||||
subtitleIndex: newSubtitleIndex ?? -1,
|
||||
}));
|
||||
}
|
||||
if (!isTranscoding && previouslyChosenSubtitleIndex !== undefined) {
|
||||
setSelectedOptions((prev) => ({
|
||||
...prev!,
|
||||
subtitleIndex: previouslyChosenSubtitleIndex,
|
||||
}));
|
||||
}
|
||||
setIsTranscoding(isTranscoding);
|
||||
}, [selectedOptions?.bitrate]);
|
||||
|
||||
if (!selectedOptions) return null;
|
||||
|
||||
return (
|
||||
@@ -239,7 +207,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
selected={selectedOptions.audioIndex}
|
||||
/>
|
||||
<SubtitleTrackSelector
|
||||
isTranscoding={isTranscoding}
|
||||
source={selectedOptions.mediaSource}
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
|
||||
@@ -73,11 +73,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
|
||||
const goToPlayer = useCallback(
|
||||
(q: string, bitrateValue: number | undefined) => {
|
||||
if (!bitrateValue) {
|
||||
router.push(`/player/direct-player?${q}`);
|
||||
return;
|
||||
}
|
||||
router.push(`/player/transcoding-player?${q}`);
|
||||
router.push(`/player/direct-player?${q}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
@@ -58,11 +58,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
|
||||
const goToPlayer = useCallback(
|
||||
(q: string, bitrateValue: number | undefined) => {
|
||||
if (!bitrateValue) {
|
||||
router.push(`/player/direct-player?${q}`);
|
||||
return;
|
||||
}
|
||||
router.push(`/player/transcoding-player?${q}`);
|
||||
router.push(`/player/direct-player?${q}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
@@ -4,40 +4,31 @@ import { useMemo } from "react";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||
import { Text } from "./common/Text";
|
||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
source?: MediaSourceInfo;
|
||||
onChange: (value: number) => void;
|
||||
selected?: number | undefined;
|
||||
isTranscoding?: boolean;
|
||||
}
|
||||
|
||||
export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
source,
|
||||
onChange,
|
||||
selected,
|
||||
isTranscoding,
|
||||
...props
|
||||
}) => {
|
||||
if (Platform.isTV) return null;
|
||||
const subtitleStreams = useMemo(() => {
|
||||
const subtitleHelper = new SubtitleHelper(source?.MediaStreams ?? []);
|
||||
|
||||
if (isTranscoding && Platform.OS === "ios") {
|
||||
return subtitleHelper.getUniqueSubtitles();
|
||||
}
|
||||
|
||||
return subtitleHelper.getSubtitles();
|
||||
}, [source, isTranscoding]);
|
||||
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
|
||||
}, [source]);
|
||||
|
||||
const selectedSubtitleSteam = useMemo(
|
||||
() => subtitleStreams.find((x) => x.Index === selected),
|
||||
() => subtitleStreams?.find((x) => x.Index === selected),
|
||||
[subtitleStreams, selected]
|
||||
);
|
||||
|
||||
if (subtitleStreams.length === 0) return null;
|
||||
if (subtitleStreams?.length === 0) return null;
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
ticksToMs,
|
||||
ticksToSeconds,
|
||||
} from "@/utils/time";
|
||||
import {Ionicons, MaterialIcons} from "@expo/vector-icons";
|
||||
import { Ionicons, MaterialIcons } from "@expo/vector-icons";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
@@ -35,7 +35,12 @@ import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { useAtom } from "jotai";
|
||||
import { debounce } from "lodash";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {Platform, TouchableOpacity, useWindowDimensions, View} from "react-native";
|
||||
import {
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import {
|
||||
runOnJS,
|
||||
@@ -49,13 +54,12 @@ import AudioSlider from "./AudioSlider";
|
||||
import BrightnessSlider from "./BrightnessSlider";
|
||||
import { ControlProvider } from "./contexts/ControlContext";
|
||||
import { VideoProvider } from "./contexts/VideoContext";
|
||||
import DropdownViewDirect from "./dropdown/DropdownViewDirect";
|
||||
import DropdownViewTranscoding from "./dropdown/DropdownViewTranscoding";
|
||||
import { EpisodeList } from "./EpisodeList";
|
||||
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
||||
import SkipButton from "./SkipButton";
|
||||
import { useControlsTimeout } from "./useControlsTimeout";
|
||||
import { VideoTouchOverlay } from "./VideoTouchOverlay";
|
||||
import DropdownView from "./dropdown/DropdownView";
|
||||
|
||||
interface Props {
|
||||
item: BaseItemDto;
|
||||
@@ -214,7 +218,7 @@ export const Controls: React.FC<Props> = ({
|
||||
bitrateValue: bitrateValue.toString(),
|
||||
}).toString();
|
||||
|
||||
stop()
|
||||
stop();
|
||||
|
||||
if (!bitrateValue) {
|
||||
// @ts-expect-error
|
||||
@@ -254,7 +258,7 @@ export const Controls: React.FC<Props> = ({
|
||||
bitrateValue: bitrateValue.toString(),
|
||||
}).toString();
|
||||
|
||||
stop()
|
||||
stop();
|
||||
|
||||
if (!bitrateValue) {
|
||||
// @ts-expect-error
|
||||
@@ -419,7 +423,7 @@ export const Controls: React.FC<Props> = ({
|
||||
bitrateValue: bitrateValue.toString(),
|
||||
}).toString();
|
||||
|
||||
stop()
|
||||
stop();
|
||||
|
||||
if (!bitrateValue) {
|
||||
// @ts-expect-error
|
||||
@@ -508,7 +512,7 @@ export const Controls: React.FC<Props> = ({
|
||||
}, [trickPlayUrl, trickplayInfo, time]);
|
||||
|
||||
const onClose = async () => {
|
||||
stop()
|
||||
stop();
|
||||
lightHapticFeedback();
|
||||
await ScreenOrientation.lockAsync(
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||
@@ -559,19 +563,13 @@ export const Controls: React.FC<Props> = ({
|
||||
setSubtitleTrack={setSubtitleTrack}
|
||||
setSubtitleURL={setSubtitleURL}
|
||||
>
|
||||
{!mediaSource?.TranscodingUrl ? (
|
||||
<DropdownViewDirect showControls={showControls} />
|
||||
) : (
|
||||
<DropdownViewTranscoding showControls={showControls} />
|
||||
)}
|
||||
<DropdownView showControls={showControls} />
|
||||
</VideoProvider>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row items-center space-x-2 ">
|
||||
{!Platform.isTV && (
|
||||
<TouchableOpacity
|
||||
onPress={startPictureInPicture}
|
||||
>
|
||||
<TouchableOpacity onPress={startPictureInPicture}>
|
||||
<MaterialIcons
|
||||
name="picture-in-picture"
|
||||
size={24}
|
||||
|
||||
@@ -9,12 +9,15 @@ import React, {
|
||||
useState,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { useControlContext } from "./ControlContext";
|
||||
import { Track } from "../types";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
|
||||
interface VideoContextProps {
|
||||
audioTracks: TrackInfo[] | null;
|
||||
subtitleTracks: TrackInfo[] | null;
|
||||
audioTracks: Track[] | null;
|
||||
subtitleTracks: Track[] | null;
|
||||
setAudioTrack: ((index: number) => void) | undefined;
|
||||
setSubtitleTrack: ((index: number) => void) | undefined;
|
||||
setSubtitleURL: ((url: string, customName: string) => void) | undefined;
|
||||
@@ -45,30 +48,155 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
||||
setSubtitleURL,
|
||||
setAudioTrack,
|
||||
}) => {
|
||||
const [audioTracks, setAudioTracks] = useState<TrackInfo[] | null>(null);
|
||||
const [subtitleTracks, setSubtitleTracks] = useState<TrackInfo[] | null>(
|
||||
null
|
||||
);
|
||||
const [audioTracks, setAudioTracks] = useState<Track[] | null>(null);
|
||||
const [subtitleTracks, setSubtitleTracks] = useState<Track[] | null>(null);
|
||||
|
||||
const ControlContext = useControlContext();
|
||||
const isVideoLoaded = ControlContext?.isVideoLoaded;
|
||||
const mediaSource = ControlContext?.mediaSource;
|
||||
|
||||
const allSubs =
|
||||
mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") || [];
|
||||
|
||||
const { itemId, audioIndex, bitrateValue, subtitleIndex } =
|
||||
useLocalSearchParams<{
|
||||
itemId: string;
|
||||
audioIndex: string;
|
||||
subtitleIndex: string;
|
||||
mediaSourceId: string;
|
||||
bitrateValue: string;
|
||||
}>();
|
||||
|
||||
const onTextBasedSubtitle = useMemo(
|
||||
() =>
|
||||
allSubs.find(
|
||||
(s) => s.Index?.toString() === subtitleIndex && s.IsTextSubtitleStream
|
||||
) || subtitleIndex === "-1",
|
||||
[allSubs, subtitleIndex]
|
||||
);
|
||||
|
||||
const setPlayerParams = ({
|
||||
chosenAudioIndex = audioIndex,
|
||||
chosenSubtitleIndex = subtitleIndex,
|
||||
}: {
|
||||
chosenAudioIndex?: string;
|
||||
chosenSubtitleIndex?: string;
|
||||
}) => {
|
||||
console.log("chosenSubtitleIndex", chosenSubtitleIndex);
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: itemId ?? "",
|
||||
audioIndex: chosenAudioIndex,
|
||||
subtitleIndex: chosenSubtitleIndex,
|
||||
mediaSourceId: mediaSource?.Id ?? "",
|
||||
bitrateValue: bitrateValue,
|
||||
}).toString();
|
||||
|
||||
//@ts-ignore
|
||||
router.replace(`player/direct-player?${queryParams}`);
|
||||
};
|
||||
|
||||
const setTrackParams = (
|
||||
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 && setTrack(index);
|
||||
router.setParams({
|
||||
[paramKey]: serverIndex.toString(),
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTracks = async () => {
|
||||
if (
|
||||
getSubtitleTracks &&
|
||||
(subtitleTracks === null || subtitleTracks.length === 0)
|
||||
) {
|
||||
const subtitles = await getSubtitleTracks();
|
||||
console.log("Getting embeded subtitles...", subtitles);
|
||||
if (getSubtitleTracks) {
|
||||
const subtitleData = await getSubtitleTracks();
|
||||
|
||||
let textSubIndex = 0;
|
||||
const subtitles: Track[] = allSubs?.map((sub) => {
|
||||
// Always increment for non-transcoding subtitles
|
||||
// Only increment for text-based subtitles when transcoding
|
||||
const shouldIncrement =
|
||||
!mediaSource?.TranscodingUrl || sub.IsTextSubtitleStream;
|
||||
|
||||
const displayTitle = sub.DisplayTitle || "Undefined Subtitle";
|
||||
const vlcIndex = subtitleData?.at(textSubIndex)?.index ?? -1;
|
||||
|
||||
const finalIndex = shouldIncrement ? vlcIndex : sub.Index ?? -1;
|
||||
|
||||
if (shouldIncrement) textSubIndex++;
|
||||
return {
|
||||
name: displayTitle,
|
||||
index: sub.Index ?? -1,
|
||||
originalIndex: finalIndex,
|
||||
setTrack: () =>
|
||||
shouldIncrement
|
||||
? setTrackParams("subtitle", finalIndex, sub.Index ?? -1)
|
||||
: setPlayerParams({
|
||||
chosenSubtitleIndex: sub.Index?.toString(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// Add a "Disable Subtitles" option
|
||||
subtitles.unshift({
|
||||
name: "Disable",
|
||||
index: -1,
|
||||
setTrack: () =>
|
||||
!mediaSource?.TranscodingUrl || onTextBasedSubtitle
|
||||
? setTrackParams("subtitle", -1, -1)
|
||||
: setPlayerParams({ chosenSubtitleIndex: "-1" }),
|
||||
});
|
||||
|
||||
setSubtitleTracks(subtitles);
|
||||
}
|
||||
if (
|
||||
getAudioTracks &&
|
||||
(audioTracks === null || audioTracks.length === 0)
|
||||
) {
|
||||
const audio = await getAudioTracks();
|
||||
setAudioTracks(audio);
|
||||
const audioData = await getAudioTracks();
|
||||
if (!audioData) return;
|
||||
|
||||
console.log("audioData", audioData);
|
||||
|
||||
const allAudio =
|
||||
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];
|
||||
|
||||
const audioTracks: Track[] = allAudio?.map((audio, idx) => {
|
||||
if (!mediaSource?.TranscodingUrl) {
|
||||
const vlcIndex = audioData?.at(idx)?.index ?? -1;
|
||||
return {
|
||||
name: audio.DisplayTitle ?? "Undefined Audio",
|
||||
index: audio.Index ?? -1,
|
||||
setTrack: () =>
|
||||
setTrackParams("audio", vlcIndex, audio.Index ?? -1),
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: audio.DisplayTitle ?? "Undefined Audio",
|
||||
index: audio.Index ?? -1,
|
||||
setTrack: () =>
|
||||
setPlayerParams({ chosenAudioIndex: audio.Index?.toString() }),
|
||||
};
|
||||
});
|
||||
setAudioTracks(audioTracks);
|
||||
}
|
||||
};
|
||||
fetchTracks();
|
||||
|
||||
@@ -1,67 +1,21 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { View, TouchableOpacity, Platform } from "react-native";
|
||||
import React from "react";
|
||||
import { TouchableOpacity, Platform } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||
import { useControlContext } from "../contexts/ControlContext";
|
||||
import { useVideoContext } from "../contexts/VideoContext";
|
||||
import { EmbeddedSubtitle, ExternalSubtitle } from "../types";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
|
||||
interface DropdownViewDirectProps {
|
||||
interface DropdownViewProps {
|
||||
showControls: boolean;
|
||||
offline?: boolean; // used to disable external subs for downloads
|
||||
}
|
||||
|
||||
const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
|
||||
const DropdownView: React.FC<DropdownViewProps> = ({
|
||||
showControls,
|
||||
offline = false,
|
||||
}) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const ControlContext = useControlContext();
|
||||
const mediaSource = ControlContext?.mediaSource;
|
||||
const item = ControlContext?.item;
|
||||
const isVideoLoaded = ControlContext?.isVideoLoaded;
|
||||
|
||||
const videoContext = useVideoContext();
|
||||
const {
|
||||
subtitleTracks,
|
||||
audioTracks,
|
||||
setSubtitleURL,
|
||||
setSubtitleTrack,
|
||||
setAudioTrack,
|
||||
} = videoContext;
|
||||
|
||||
const allSubtitleTracksForDirectPlay = useMemo(() => {
|
||||
if (mediaSource?.TranscodingUrl) return null;
|
||||
const embeddedSubs =
|
||||
subtitleTracks
|
||||
?.map((s) => ({
|
||||
name: s.name,
|
||||
index: s.index,
|
||||
deliveryUrl: undefined,
|
||||
}))
|
||||
.filter((sub) => !sub.name.endsWith("[External]")) || [];
|
||||
|
||||
const externalSubs =
|
||||
mediaSource?.MediaStreams?.filter(
|
||||
(stream) => stream.Type === "Subtitle" && !!stream.DeliveryUrl
|
||||
).map((s) => ({
|
||||
name: s.DisplayTitle! + " [External]",
|
||||
index: s.Index!,
|
||||
deliveryUrl: s.DeliveryUrl,
|
||||
})) || [];
|
||||
|
||||
// Combine embedded subs with external subs only if not offline
|
||||
if (!offline) {
|
||||
return [...embeddedSubs, ...externalSubs] as (
|
||||
| EmbeddedSubtitle
|
||||
| ExternalSubtitle
|
||||
)[];
|
||||
}
|
||||
return embeddedSubs as EmbeddedSubtitle[];
|
||||
}, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams, offline]);
|
||||
const { subtitleTracks, audioTracks } = videoContext;
|
||||
|
||||
const { subtitleIndex, audioIndex } = useLocalSearchParams<{
|
||||
itemId: string;
|
||||
@@ -98,21 +52,11 @@ const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
|
||||
loop={true}
|
||||
sideOffset={10}
|
||||
>
|
||||
{allSubtitleTracksForDirectPlay?.map((sub, idx: number) => (
|
||||
{subtitleTracks?.map((sub, idx: number) => (
|
||||
<DropdownMenu.CheckboxItem
|
||||
key={`subtitle-item-${idx}`}
|
||||
value={subtitleIndex === sub.index.toString()}
|
||||
onValueChange={() => {
|
||||
if ("deliveryUrl" in sub && sub.deliveryUrl) {
|
||||
setSubtitleURL &&
|
||||
setSubtitleURL(api?.basePath + sub.deliveryUrl, sub.name);
|
||||
} else {
|
||||
setSubtitleTrack && setSubtitleTrack(sub.index);
|
||||
}
|
||||
router.setParams({
|
||||
subtitleIndex: sub.index.toString(),
|
||||
});
|
||||
}}
|
||||
onValueChange={() => sub.setTrack()}
|
||||
>
|
||||
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
|
||||
{sub.name}
|
||||
@@ -136,12 +80,7 @@ const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
|
||||
<DropdownMenu.CheckboxItem
|
||||
key={`audio-item-${idx}`}
|
||||
value={audioIndex === track.index.toString()}
|
||||
onValueChange={() => {
|
||||
setAudioTrack && setAudioTrack(track.index);
|
||||
router.setParams({
|
||||
audioIndex: track.index.toString(),
|
||||
});
|
||||
}}
|
||||
onValueChange={() => track.setTrack()}
|
||||
>
|
||||
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
|
||||
{track.name}
|
||||
@@ -155,4 +94,4 @@ const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default DropdownViewDirect;
|
||||
export default DropdownView;
|
||||
@@ -1,228 +0,0 @@
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { View, TouchableOpacity, Platform } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||
import { useControlContext } from "../contexts/ControlContext";
|
||||
import { useVideoContext } from "../contexts/VideoContext";
|
||||
import { TranscodedSubtitle } from "../types";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
||||
|
||||
interface DropdownViewProps {
|
||||
showControls: boolean;
|
||||
offline?: boolean; // used to disable external subs for downloads
|
||||
}
|
||||
|
||||
const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
|
||||
const router = useRouter();
|
||||
const api = useAtomValue(apiAtom);
|
||||
const ControlContext = useControlContext();
|
||||
const mediaSource = ControlContext?.mediaSource;
|
||||
const item = ControlContext?.item;
|
||||
const isVideoLoaded = ControlContext?.isVideoLoaded;
|
||||
|
||||
const videoContext = useVideoContext();
|
||||
const { subtitleTracks, setSubtitleTrack } = videoContext;
|
||||
|
||||
const { subtitleIndex, audioIndex, bitrateValue } = useLocalSearchParams<{
|
||||
itemId: string;
|
||||
audioIndex: string;
|
||||
subtitleIndex: string;
|
||||
mediaSourceId: string;
|
||||
bitrateValue: string;
|
||||
}>();
|
||||
|
||||
// Either its on a text subtitle or its on not on any subtitle therefore it should show all the embedded HLS subtitles.
|
||||
|
||||
const isOnTextSubtitle = useMemo(() => {
|
||||
const res = Boolean(
|
||||
mediaSource?.MediaStreams?.find(
|
||||
(x) => x.Index === parseInt(subtitleIndex) && x.IsTextSubtitleStream
|
||||
) || subtitleIndex === "-1"
|
||||
);
|
||||
return res;
|
||||
}, []);
|
||||
|
||||
const allSubs =
|
||||
mediaSource?.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [];
|
||||
|
||||
const subtitleHelper = new SubtitleHelper(mediaSource?.MediaStreams ?? []);
|
||||
|
||||
const allSubtitleTracksForTranscodingStream = useMemo(() => {
|
||||
const disableSubtitle = {
|
||||
name: "Disable",
|
||||
index: -1,
|
||||
IsTextSubtitleStream: true,
|
||||
} as TranscodedSubtitle;
|
||||
if (isOnTextSubtitle) {
|
||||
const textSubtitles =
|
||||
subtitleTracks?.map((s) => ({
|
||||
name: s.name,
|
||||
index: s.index,
|
||||
IsTextSubtitleStream: true,
|
||||
})) || [];
|
||||
|
||||
const sortedSubtitles = subtitleHelper.getSortedSubtitles(textSubtitles);
|
||||
|
||||
return [disableSubtitle, ...sortedSubtitles];
|
||||
}
|
||||
|
||||
const transcodedSubtitle: TranscodedSubtitle[] = allSubs.map((x) => ({
|
||||
name: x.DisplayTitle!,
|
||||
index: x.Index!,
|
||||
IsTextSubtitleStream: x.IsTextSubtitleStream!,
|
||||
}));
|
||||
|
||||
return [disableSubtitle, ...transcodedSubtitle];
|
||||
}, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams]);
|
||||
|
||||
const changeToImageBasedSub = useCallback(
|
||||
(subtitleIndex: number) => {
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id ?? "", // Ensure itemId is a string
|
||||
audioIndex: audioIndex?.toString() ?? "",
|
||||
subtitleIndex: subtitleIndex?.toString() ?? "",
|
||||
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
||||
bitrateValue: bitrateValue,
|
||||
}).toString();
|
||||
|
||||
// @ts-expect-error
|
||||
router.replace(`player/transcoding-player?${queryParams}`);
|
||||
},
|
||||
[mediaSource]
|
||||
);
|
||||
|
||||
// Audio tracks for transcoding streams.
|
||||
const allAudio =
|
||||
mediaSource?.MediaStreams?.filter((x) => x.Type === "Audio").map((x) => ({
|
||||
name: x.DisplayTitle!,
|
||||
index: x.Index!,
|
||||
})) || [];
|
||||
|
||||
const ChangeTranscodingAudio = useCallback(
|
||||
(audioIndex: number) => {
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id ?? "", // Ensure itemId is a string
|
||||
audioIndex: audioIndex?.toString() ?? "",
|
||||
subtitleIndex: subtitleIndex?.toString() ?? "",
|
||||
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
||||
bitrateValue: bitrateValue,
|
||||
}).toString();
|
||||
|
||||
// @ts-expect-error
|
||||
router.replace(`player/transcoding-player?${queryParams}`);
|
||||
},
|
||||
[mediaSource, subtitleIndex, audioIndex]
|
||||
);
|
||||
|
||||
return (
|
||||
<View>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity className="aspect-square flex flex-col rounded-xl items-center justify-center p-2">
|
||||
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger key="subtitle-trigger">
|
||||
Subtitle
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent
|
||||
alignOffset={-10}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
loop={true}
|
||||
sideOffset={10}
|
||||
>
|
||||
{allSubtitleTracksForTranscodingStream?.map(
|
||||
(sub, idx: number) => (
|
||||
<DropdownMenu.CheckboxItem
|
||||
value={
|
||||
subtitleIndex ===
|
||||
(isOnTextSubtitle && sub.IsTextSubtitleStream
|
||||
? subtitleHelper
|
||||
.getSourceSubtitleIndex(sub.index)
|
||||
.toString()
|
||||
: sub?.index.toString())
|
||||
}
|
||||
key={`subtitle-item-${idx}`}
|
||||
onValueChange={() => {
|
||||
if (
|
||||
subtitleIndex ===
|
||||
(isOnTextSubtitle && sub.IsTextSubtitleStream
|
||||
? subtitleHelper
|
||||
.getSourceSubtitleIndex(sub.index)
|
||||
.toString()
|
||||
: sub?.index.toString())
|
||||
)
|
||||
return;
|
||||
|
||||
router.setParams({
|
||||
subtitleIndex: subtitleHelper
|
||||
.getSourceSubtitleIndex(sub.index)
|
||||
.toString(),
|
||||
});
|
||||
|
||||
if (sub.IsTextSubtitleStream && isOnTextSubtitle) {
|
||||
setSubtitleTrack && setSubtitleTrack(sub.index);
|
||||
return;
|
||||
}
|
||||
changeToImageBasedSub(sub.index);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
|
||||
{sub.name}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
)
|
||||
)}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger key="audio-trigger">
|
||||
Audio
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent
|
||||
alignOffset={-10}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
loop={true}
|
||||
sideOffset={10}
|
||||
>
|
||||
{allAudio?.map((track, idx: number) => (
|
||||
<DropdownMenu.CheckboxItem
|
||||
key={`audio-item-${idx}`}
|
||||
value={audioIndex === track.index.toString()}
|
||||
onValueChange={() => {
|
||||
if (audioIndex === track.index.toString()) return;
|
||||
router.setParams({
|
||||
audioIndex: track.index.toString(),
|
||||
});
|
||||
ChangeTranscodingAudio(track.index);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
|
||||
{track.name}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
))}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default DropdownView;
|
||||
@@ -13,7 +13,14 @@ type ExternalSubtitle = {
|
||||
type TranscodedSubtitle = {
|
||||
name: string;
|
||||
index: number;
|
||||
deliveryUrl: string;
|
||||
IsTextSubtitleStream: boolean;
|
||||
};
|
||||
|
||||
export { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle };
|
||||
type Track = {
|
||||
name: string;
|
||||
index: number;
|
||||
setTrack: () => void;
|
||||
};
|
||||
|
||||
export { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle, Track };
|
||||
|
||||
6
eas.json
6
eas.json
@@ -32,20 +32,20 @@
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"channel": "0.26.0",
|
||||
"channel": "0.26.1",
|
||||
"android": {
|
||||
"image": "latest"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
"channel": "0.26.0",
|
||||
"channel": "0.26.1",
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"image": "latest"
|
||||
}
|
||||
},
|
||||
"production-apk-tv": {
|
||||
"channel": "0.26.0",
|
||||
"channel": "0.26.1",
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"image": "latest"
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
--- expo.js.original 2024-11-10 09:08:19
|
||||
+++ node_modules/react-native-edge-to-edge/dist/commonjs/expo.js 2024-11-10 09:08:23
|
||||
@@ -19,10 +19,8 @@
|
||||
const {
|
||||
barStyle
|
||||
} = androidStatusBar;
|
||||
+ const android = props?.android || {};
|
||||
const {
|
||||
- android = {}
|
||||
- } = props;
|
||||
- const {
|
||||
parentTheme = "Default"
|
||||
} = android;
|
||||
config.modResults.resources.style = config.modResults.resources.style?.map(style => {
|
||||
\ No newline at end of file
|
||||
@@ -0,0 +1,38 @@
|
||||
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,5 +1,6 @@
|
||||
package expo.modules.vlcplayer
|
||||
|
||||
import androidx.core.os.bundleOf
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
|
||||
@@ -7,6 +8,18 @@ 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)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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
|
||||
@@ -14,13 +15,20 @@ 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.ComponentActivity
|
||||
import androidx.core.content.ContextCompat
|
||||
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
|
||||
@@ -31,7 +39,8 @@ import org.videolan.libvlc.interfaces.IMedia
|
||||
import org.videolan.libvlc.util.VLCVideoLayout
|
||||
|
||||
|
||||
class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext), LifecycleObserver, MediaPlayer.EventListener {
|
||||
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"
|
||||
@@ -43,6 +52,7 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
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()
|
||||
@@ -64,53 +74,87 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
}
|
||||
private val currentActivity get() = context.findActivity()
|
||||
private val actions: MutableList<RemoteAction> = mutableListOf()
|
||||
|
||||
private val actionReceiver: BroadcastReceiver = object : BroadcastReceiver() {
|
||||
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()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
setupView()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
setupPipActions()
|
||||
currentActivity.apply {
|
||||
setPictureInPictureParams(getPipParams()!!)
|
||||
addOnPictureInPictureModeChangedListener { info ->
|
||||
onPipStarted(mapOf(
|
||||
"pipStarted" to info.isInPictureInPictureMode
|
||||
))
|
||||
}
|
||||
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.d("VlcPlayerView", "Setting up view")
|
||||
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.d("VlcPlayerView", "View setup complete")
|
||||
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() {
|
||||
val remoteActionFilter = IntentFilter()
|
||||
val playPauseIntent: Intent = Intent(PIP_PLAY_PAUSE_ACTION).setPackage(context.packageName)
|
||||
val forwardIntent: Intent = Intent(PIP_FORWARD_ACTION).setPackage(context.packageName)
|
||||
val rewindIntent: Intent = Intent(PIP_REWIND_ACTION).setPackage(context.packageName)
|
||||
|
||||
remoteActionFilter.addAction(PIP_PLAY_PAUSE_ACTION)
|
||||
remoteActionFilter.addAction(PIP_FORWARD_ACTION)
|
||||
remoteActionFilter.addAction(PIP_REWIND_ACTION)
|
||||
|
||||
actions.clear()
|
||||
actions.addAll(
|
||||
listOf(
|
||||
RemoteAction(
|
||||
@@ -125,12 +169,13 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
)
|
||||
),
|
||||
RemoteAction(
|
||||
Icon.createWithResource(context, R.drawable.ic_media_play),
|
||||
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,
|
||||
0,
|
||||
if (isPaused) 0 else 1,
|
||||
playPauseIntent,
|
||||
FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE
|
||||
)
|
||||
@@ -148,13 +193,6 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
ContextCompat.registerReceiver(
|
||||
context,
|
||||
actionReceiver,
|
||||
remoteActionFilter,
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED
|
||||
)
|
||||
}
|
||||
|
||||
private fun getPipParams(): PictureInPictureParams? {
|
||||
@@ -171,7 +209,9 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
}
|
||||
|
||||
fun setSource(source: Map<String, Any>) {
|
||||
log.debug("setting source $source")
|
||||
if (hasSource) {
|
||||
log.debug("Source already set. Resuming")
|
||||
mediaPlayer?.attachViews(videoLayout, null, false, false)
|
||||
play()
|
||||
return
|
||||
@@ -196,12 +236,12 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
mediaPlayer?.attachViews(videoLayout, null, false, false)
|
||||
mediaPlayer?.setEventListener(this)
|
||||
|
||||
Log.d("VlcPlayerView", "Loading network file: $uri")
|
||||
log.debug("Loading network file: $uri")
|
||||
media = Media(libVLC, Uri.parse(uri))
|
||||
mediaPlayer?.media = media
|
||||
|
||||
|
||||
Log.d("VlcPlayerView", "Debug: Media options: $mediaOptions")
|
||||
log.debug("Debug: Media options: $mediaOptions")
|
||||
// media.addOptions(mediaOptions)
|
||||
|
||||
// Apply subtitle options
|
||||
@@ -218,7 +258,7 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
hasSource = true
|
||||
|
||||
if (autoplay) {
|
||||
Log.d("VlcPlayerView", "Playing...")
|
||||
log.debug("Playing...")
|
||||
play()
|
||||
}
|
||||
}
|
||||
@@ -268,9 +308,7 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
}
|
||||
|
||||
fun getAudioTracks(): List<Map<String, Any>>? {
|
||||
|
||||
println("getAudioTracks")
|
||||
println(mediaPlayer?.getAudioTracks())
|
||||
log.debug("getAudioTracks ${mediaPlayer?.audioTracks}")
|
||||
val trackDescriptions = mediaPlayer?.audioTracks ?: return null
|
||||
|
||||
return trackDescriptions.map { trackDescription ->
|
||||
@@ -294,19 +332,32 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
}
|
||||
|
||||
// Debug statement to print the result
|
||||
Log.d("VlcPlayerView", "Subtitle Tracks: $subtitleTracks")
|
||||
log.debug("Subtitle Tracks: $subtitleTracks")
|
||||
|
||||
return subtitleTracks
|
||||
}
|
||||
|
||||
fun setSubtitleURL(subtitleURL: String, name: String) {
|
||||
println("Setting subtitle URL: $subtitleURL, name: $name")
|
||||
log.debug("Setting subtitle URL: $subtitleURL, name: $name")
|
||||
mediaPlayer?.addSlave(IMedia.Slave.Type.Subtitle, Uri.parse(subtitleURL), true)
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
println("onDetachedFromWindow")
|
||||
log.debug("onDetachedFromWindow")
|
||||
super.onDetachedFromWindow()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
currentActivity.setPictureInPictureParams(
|
||||
PictureInPictureParams.Builder()
|
||||
.setAutoEnterEnabled(false)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
currentActivity.unregisterReceiver(actionReceiver)
|
||||
currentActivity.removeOnPictureInPictureModeChangedListener(pipChangeListener)
|
||||
VLCManager.listeners.clear()
|
||||
|
||||
mediaPlayer?.stop()
|
||||
handler.removeCallbacks(updateProgressRunnable) // Stop updating progress
|
||||
|
||||
@@ -319,6 +370,7 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -340,35 +392,27 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
"target" to "null", // Replace with actual target if needed
|
||||
"currentTime" to player.time.toInt(),
|
||||
"duration" to (player.media?.duration?.toInt() ?: 0),
|
||||
"error" to false
|
||||
"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["isPlaying"] = true
|
||||
stateInfo["isBuffering"] = false
|
||||
MediaPlayer.Event.Playing ->
|
||||
stateInfo["state"] = "Playing"
|
||||
}
|
||||
MediaPlayer.Event.Paused -> {
|
||||
stateInfo["isPlaying"] = false
|
||||
MediaPlayer.Event.Paused ->
|
||||
stateInfo["state"] = "Paused"
|
||||
}
|
||||
MediaPlayer.Event.Buffering -> {
|
||||
stateInfo["isBuffering"] = true
|
||||
MediaPlayer.Event.Buffering ->
|
||||
stateInfo["state"] = "Buffering"
|
||||
}
|
||||
MediaPlayer.Event.EncounteredError -> {
|
||||
Log.e("VlcPlayerView", "player.state ~ error")
|
||||
stateInfo["state"] = "Error"
|
||||
onVideoLoadEnd(stateInfo);
|
||||
}
|
||||
MediaPlayer.Event.Opening -> {
|
||||
Log.d("VlcPlayerView", "player.state ~ opening")
|
||||
MediaPlayer.Event.Opening ->
|
||||
stateInfo["state"] = "Opening"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (lastReportedState != currentState || lastReportedIsPlaying != player.isPlaying) {
|
||||
lastReportedState = currentState
|
||||
lastReportedIsPlaying = player.isPlaying
|
||||
@@ -400,6 +444,16 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"platforms": ["ios", "tvos", "android", "web"],
|
||||
"ios": {
|
||||
"modules": ["VlcPlayerModule"]
|
||||
"modules": ["VlcPlayerModule"],
|
||||
"appDelegateSubscribers": ["AppLifecycleDelegate"]
|
||||
},
|
||||
"android": {
|
||||
"modules": ["expo.modules.vlcplayer.VlcPlayerModule"]
|
||||
|
||||
32
modules/vlc-player/ios/AppLifecycleDelegate.swift
Normal file
32
modules/vlc-player/ios/AppLifecycleDelegate.swift
Normal file
@@ -0,0 +1,32 @@
|
||||
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.
|
||||
}
|
||||
}
|
||||
4
modules/vlc-player/ios/VLCManager.swift
Normal file
4
modules/vlc-player/ios/VLCManager.swift
Normal file
@@ -0,0 +1,4 @@
|
||||
class VLCManager {
|
||||
static let shared = VLCManager()
|
||||
var listeners: [SimpleAppLifecycleListener] = []
|
||||
}
|
||||
@@ -1,34 +1,35 @@
|
||||
import ExpoModulesCore
|
||||
import VLCKit
|
||||
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),
|
||||
])
|
||||
}
|
||||
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()
|
||||
public override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
for subview in subviews {
|
||||
subview.frame = bounds
|
||||
}
|
||||
}
|
||||
for subview in subviews {
|
||||
subview.frame = bounds
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class VLCPlayerWrapper: NSObject {
|
||||
private var lastProgressCall = Date().timeIntervalSince1970
|
||||
public var player: VLCMediaPlayer = VLCMediaPlayer()
|
||||
private var updatePlayerState: (() -> ())?
|
||||
private var updateVideoProgress: (() -> ())?
|
||||
private var updatePlayerState: (() -> Void)?
|
||||
private var updateVideoProgress: (() -> Void)?
|
||||
private var playerView: VLCPlayerView = VLCPlayerView()
|
||||
public weak var pipController: VLCPictureInPictureWindowControlling?
|
||||
|
||||
@@ -41,8 +42,8 @@ class VLCPlayerWrapper: NSObject {
|
||||
|
||||
public func setup(
|
||||
parent: UIView,
|
||||
updatePlayerState: (() -> ())?,
|
||||
updateVideoProgress: (() -> ())?
|
||||
updatePlayerState: (() -> Void)?,
|
||||
updateVideoProgress: (() -> Void)?
|
||||
) {
|
||||
self.updatePlayerState = updatePlayerState
|
||||
self.updateVideoProgress = updateVideoProgress
|
||||
@@ -52,9 +53,9 @@ class VLCPlayerWrapper: NSObject {
|
||||
playerView.setupView(parent: parent)
|
||||
}
|
||||
|
||||
public func getPlayerView() -> UIView {
|
||||
return playerView
|
||||
}
|
||||
public func getPlayerView() -> UIView {
|
||||
return playerView
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - VLCPictureInPictureDrawable
|
||||
@@ -63,7 +64,8 @@ extension VLCPlayerWrapper: VLCPictureInPictureDrawable {
|
||||
return self
|
||||
}
|
||||
|
||||
public func pictureInPictureReady() -> (((any VLCPictureInPictureWindowControlling)?) -> Void)! {
|
||||
public func pictureInPictureReady() -> (((any VLCPictureInPictureWindowControlling)?) -> Void)!
|
||||
{
|
||||
return { [weak self] controller in
|
||||
self?.pipController = controller
|
||||
}
|
||||
@@ -88,7 +90,7 @@ extension VLCPlayerWrapper: VLCPictureInPictureMediaControlling {
|
||||
player.pause()
|
||||
}
|
||||
|
||||
func seek(by offset: Int64, completion: @escaping () -> ()) {
|
||||
func seek(by offset: Int64, completion: @escaping () -> Void) {
|
||||
player.jump(withOffset: Int32(offset), completion: completion)
|
||||
}
|
||||
|
||||
@@ -115,20 +117,24 @@ extension VLCPlayerWrapper: VLCDrawable {
|
||||
// MARK: - VLCMediaPlayerDelegate
|
||||
extension VLCPlayerWrapper: VLCMediaPlayerDelegate {
|
||||
func mediaPlayerTimeChanged(_ aNotification: Notification) {
|
||||
let timeNow = Date().timeIntervalSince1970
|
||||
if timeNow - lastProgressCall >= 1 {
|
||||
lastProgressCall = timeNow
|
||||
updateVideoProgress?()
|
||||
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) {
|
||||
self.updatePlayerState?()
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.updatePlayerState?()
|
||||
|
||||
guard let pipController = self.pipController else { return }
|
||||
DispatchQueue.main.async(execute: {
|
||||
guard let pipController = self.pipController else { return }
|
||||
pipController.invalidatePlaybackState()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,16 +143,17 @@ extension VLCPlayerWrapper: VLCMediaDelegate {
|
||||
// Implement VLCMediaDelegate methods if needed
|
||||
}
|
||||
|
||||
|
||||
class VlcPlayerView: ExpoView {
|
||||
let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "VlcPlayerView")
|
||||
|
||||
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 isMediaReady: Bool = false
|
||||
private var externalTrack: [String: String]?
|
||||
private var isStopping: Bool = false // Define isStopping here
|
||||
private var externalSubtitles: [[String: String]]?
|
||||
var hasSource = false
|
||||
|
||||
// MARK: - Initialization
|
||||
@@ -154,6 +161,7 @@ class VlcPlayerView: ExpoView {
|
||||
super.init(appContext: appContext)
|
||||
setupVLC()
|
||||
setupNotifications()
|
||||
VLCManager.shared.listeners.append(self)
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
@@ -185,7 +193,7 @@ class VlcPlayerView: ExpoView {
|
||||
@objc func play() {
|
||||
self.vlc.player.play()
|
||||
self.isPaused = false
|
||||
print("Play")
|
||||
logger.debug("Play")
|
||||
}
|
||||
|
||||
@objc func pause() {
|
||||
@@ -200,7 +208,7 @@ class VlcPlayerView: ExpoView {
|
||||
}
|
||||
|
||||
if let duration = vlc.player.media?.length.intValue {
|
||||
print("Seeking to time: \(time) Video Duration \(duration)")
|
||||
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
|
||||
@@ -214,11 +222,12 @@ class VlcPlayerView: ExpoView {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
print("Error: Unable to retrieve video duration")
|
||||
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 {
|
||||
@@ -229,14 +238,16 @@ class VlcPlayerView: ExpoView {
|
||||
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: ""))
|
||||
mediaOptions.updateValue(
|
||||
option[1], forKey: option[0].replacingOccurrences(of: "--", with: ""))
|
||||
}
|
||||
|
||||
guard let uri = source["uri"] as? String, !uri.isEmpty else {
|
||||
print("Error: Invalid or empty URI")
|
||||
logger.error("Invalid or empty URI")
|
||||
self.onVideoError?(["error": "Invalid or empty URI"])
|
||||
return
|
||||
}
|
||||
@@ -248,10 +259,10 @@ class VlcPlayerView: ExpoView {
|
||||
|
||||
let media: VLCMedia!
|
||||
if isNetwork {
|
||||
print("Loading network file: \(uri)")
|
||||
logger.debug("Loading network file: \(uri)")
|
||||
media = VLCMedia(url: URL(string: uri)!)
|
||||
} else {
|
||||
print("Loading local file: \(uri)")
|
||||
logger.debug("Loading local file: \(uri)")
|
||||
if uri.starts(with: "file://"), let url = URL(string: uri) {
|
||||
media = VLCMedia(url: url)
|
||||
} else {
|
||||
@@ -259,14 +270,14 @@ class VlcPlayerView: ExpoView {
|
||||
}
|
||||
}
|
||||
|
||||
print("Debug: Media options: \(mediaOptions)")
|
||||
logger.debug("Media options: \(mediaOptions)")
|
||||
media.addOptions(mediaOptions)
|
||||
|
||||
self.vlc.player.media = media
|
||||
self.setInitialExternalSubtitles()
|
||||
self.hasSource = true
|
||||
|
||||
if autoplay {
|
||||
print("Playing...")
|
||||
logger.info("Playing...")
|
||||
self.play()
|
||||
self.vlc.player.time = VLCTime(number: NSNumber(value: self.startPosition * 1000))
|
||||
}
|
||||
@@ -274,36 +285,43 @@ class VlcPlayerView: ExpoView {
|
||||
}
|
||||
|
||||
@objc func setAudioTrack(_ trackIndex: Int) {
|
||||
print("Setting audio track: \(trackIndex)")
|
||||
let track = self.vlc.player.audioTracks[trackIndex]
|
||||
track.isSelectedExclusively = true;
|
||||
track.isSelectedExclusively = true
|
||||
}
|
||||
|
||||
@objc func getAudioTracks() -> [[String: Any]]? {
|
||||
return vlc.player.audioTracks.enumerated().map {
|
||||
return ["name": $1.trackName, "index": $0 ]
|
||||
return ["name": $1.trackName, "index": $0]
|
||||
}
|
||||
}
|
||||
|
||||
@objc func setSubtitleTrack(_ trackIndex: Int) {
|
||||
print("Debug: Attempting to set subtitle track to index: \(trackIndex)")
|
||||
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;
|
||||
print("Debug: Current subtitle track index after setting: \(track.trackName)")
|
||||
logger.debug("Current subtitle track index after setting: \(track.trackName)")
|
||||
}
|
||||
|
||||
@objc func setSubtitleURL(_ subtitleURL: String, name: String) {
|
||||
guard let url = URL(string: subtitleURL) else {
|
||||
print("Error: Invalid subtitle URL")
|
||||
logger.error("Invalid subtitle URL")
|
||||
return
|
||||
}
|
||||
let result = self.vlc.player.addPlaybackSlave(url, type: .subtitle, enforce: true)
|
||||
|
||||
if result > 0 {
|
||||
let internalName = "Track \(self.customSubtitles.count + 1)"
|
||||
print("Subtitle added with result: \(result) \(internalName)")
|
||||
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 {
|
||||
print("Failed to add subtitle")
|
||||
logger.debug("Failed to add subtitle")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,34 +330,24 @@ class VlcPlayerView: ExpoView {
|
||||
return nil
|
||||
}
|
||||
|
||||
print("Debug: Number of subtitle tracks: \(self.vlc.player.textTracks.count)")
|
||||
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 ]
|
||||
if let customSubtitle = customSubtitles.first(where: {
|
||||
$0.internalName == track.trackName
|
||||
}) {
|
||||
return ["name": customSubtitle.originalName, "index": index]
|
||||
} else {
|
||||
return ["name": track.trackName, "index": index]
|
||||
}
|
||||
}
|
||||
|
||||
print("Debug: Subtitle tracks: \(tracks)")
|
||||
logger.debug("Subtitle tracks: \(tracks)")
|
||||
return tracks
|
||||
}
|
||||
|
||||
private func setSubtitleTrackByName(_ trackName: String) {
|
||||
for track in self.vlc.player.textTracks {
|
||||
if (track.trackName.starts(with: trackName)) {
|
||||
print("Track Index setting to: \(track.trackName)")
|
||||
track.isSelectedExclusively = true
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
print("Track not found for name: \(trackName)")
|
||||
}
|
||||
|
||||
@objc func stop(completion: (() -> Void)? = nil) {
|
||||
logger.debug("Stopping media...")
|
||||
guard !isStopping else {
|
||||
completion?()
|
||||
return
|
||||
@@ -366,6 +374,19 @@ class VlcPlayerView: ExpoView {
|
||||
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -386,19 +407,7 @@ class VlcPlayerView: ExpoView {
|
||||
let currentTimeMs = self.vlc.player.time.intValue
|
||||
let durationMs = self.vlc.player.media?.length.intValue ?? 0
|
||||
|
||||
print("Debug: Current time: \(currentTimeMs)")
|
||||
if currentTimeMs >= 0 && currentTimeMs < durationMs {
|
||||
if !self.isMediaReady {
|
||||
self.isMediaReady = true
|
||||
// Set external track subtitle when starting.
|
||||
if let externalTrack = self.externalTrack {
|
||||
if let name = externalTrack["name"], !name.isEmpty {
|
||||
let deliveryUrl = externalTrack["DeliveryUrl"] ?? ""
|
||||
self.setSubtitleURL(deliveryUrl, name: name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.debug("Current time: \(currentTimeMs)")
|
||||
self.onVideoProgress?([
|
||||
"currentTime": currentTimeMs,
|
||||
"duration": durationMs,
|
||||
@@ -414,7 +423,7 @@ class VlcPlayerView: ExpoView {
|
||||
"error": false,
|
||||
"isPlaying": player.isPlaying,
|
||||
"isBuffering": !player.isPlaying && player.state == VLCMediaPlayerState.buffering,
|
||||
"state": player.state.description
|
||||
"state": player.state.description,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -430,7 +439,24 @@ class VlcPlayerView: ExpoView {
|
||||
// MARK: - Deinitialization
|
||||
|
||||
deinit {
|
||||
logger.debug("Deinitialization")
|
||||
performStop()
|
||||
VLCManager.shared.listeners.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SimpleAppLifecycleListener
|
||||
extension VlcPlayerView: SimpleAppLifecycleListener {
|
||||
func applicationDidEnterBackground() {
|
||||
logger.debug("Entering background")
|
||||
}
|
||||
|
||||
func applicationDidEnterForeground() {
|
||||
logger.debug("Entering foreground")
|
||||
if !self.vlc.getPlayerView().isDescendant(of: self) {
|
||||
logger.debug("Player view is missing. Adding back as subview")
|
||||
self.addSubview(self.vlc.getPlayerView())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ export type VlcPlayerSource = {
|
||||
type?: string;
|
||||
isNetwork?: boolean;
|
||||
autoplay?: boolean;
|
||||
externalTrack?: { name: string, DeliveryUrl: string };
|
||||
externalSubtitles: { name: string; DeliveryUrl: string }[];
|
||||
initOptions?: any[];
|
||||
mediaOptions?: { [key: string]: any };
|
||||
startPosition?: number;
|
||||
|
||||
18
package.json
18
package.json
@@ -27,7 +27,7 @@
|
||||
"@gorhom/bottom-sheet": "^5.1.0",
|
||||
"@jellyfin/sdk": "^0.11.0",
|
||||
"@kesha-antonov/react-native-background-downloader": "3.2.6",
|
||||
"@react-native-async-storage/async-storage": "2.1.1",
|
||||
"@react-native-async-storage/async-storage": "1.23.1",
|
||||
"@react-native-community/netinfo": "11.4.1",
|
||||
"@react-native-menu/menu": "^1.2.2",
|
||||
"@react-navigation/bottom-tabs": "^7.2.0",
|
||||
@@ -75,8 +75,8 @@
|
||||
"nativewind": "^2.0.11",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-native": "npm:react-native-tvos@~0.77.0-0",
|
||||
"react-i18next": "^15.4.0",
|
||||
"react-native": "npm:react-native-tvos@~0.77.0-0",
|
||||
"react-native-awesome-slider": "^2.9.0",
|
||||
"react-native-bottom-tabs": "0.8.6",
|
||||
"react-native-circular-progress": "^1.4.1",
|
||||
@@ -84,19 +84,19 @@
|
||||
"react-native-country-flag": "^2.0.2",
|
||||
"react-native-device-info": "^14.0.4",
|
||||
"react-native-edge-to-edge": "^1.4.3",
|
||||
"react-native-gesture-handler": "~2.23.0",
|
||||
"react-native-gesture-handler": "2.22.0",
|
||||
"react-native-get-random-values": "^1.11.0",
|
||||
"react-native-google-cast": "^4.8.3",
|
||||
"react-native-image-colors": "^2.4.0",
|
||||
"react-native-ios-context-menu": "^3.1.0",
|
||||
"react-native-ios-utilities": "5.1.1",
|
||||
"react-native-mmkv": "^2.12.2",
|
||||
"react-native-pager-view": "6.7.0",
|
||||
"react-native-pager-view": "6.5.1",
|
||||
"react-native-progress": "^5.0.1",
|
||||
"react-native-reanimated": "~3.16.7",
|
||||
"react-native-reanimated-carousel": "3.5.1",
|
||||
"react-native-safe-area-context": "5.2.0",
|
||||
"react-native-screens": "4.6.0",
|
||||
"react-native-safe-area-context": "5.1.0",
|
||||
"react-native-screens": "~4.5.0",
|
||||
"react-native-svg": "15.11.1",
|
||||
"react-native-tab-view": "^4.0.5",
|
||||
"react-native-udp": "^4.1.7",
|
||||
@@ -115,11 +115,11 @@
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.26.8",
|
||||
"@react-native-community/cli": "15.1.3",
|
||||
"@react-native-tvos/config-tv": "^0.1.1",
|
||||
"@babel/core": "^7.26.8",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/react": "~19.0.8",
|
||||
"@types/react": "~18.3.12",
|
||||
"@types/react-test-renderer": "^19.0.0",
|
||||
"patch-package": "^8.0.0",
|
||||
"postinstall-postinstall": "^2.1.0",
|
||||
@@ -134,4 +134,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
setJellyfin(
|
||||
() =>
|
||||
new Jellyfin({
|
||||
clientInfo: { name: "Streamyfin", version: "0.26.0" },
|
||||
clientInfo: { name: "Streamyfin", version: "0.26.1" },
|
||||
deviceInfo: {
|
||||
name: deviceName,
|
||||
id,
|
||||
@@ -109,7 +109,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
return {
|
||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||
Platform.OS === "android" ? "Android" : "iOS"
|
||||
}, DeviceId="${deviceId}", Version="0.26.0"`,
|
||||
}, DeviceId="${deviceId}", Version="0.26.1"`,
|
||||
};
|
||||
}, [deviceId]);
|
||||
|
||||
|
||||
457
translations/tr.json
Normal file
457
translations/tr.json
Normal file
@@ -0,0 +1,457 @@
|
||||
{
|
||||
"login": {
|
||||
"username_required": "Kullanıcı adı gereklidir",
|
||||
"error_title": "Hata",
|
||||
"login_title": "Giriş yap",
|
||||
"login_to_title": " 'e giriş yap",
|
||||
"username_placeholder": "Kullanıcı adı",
|
||||
"password_placeholder": "Şifre",
|
||||
"login_button": "Giriş yap",
|
||||
"quick_connect": "Quick Connect",
|
||||
"enter_code_to_login": "Giriş yapmak için {{code}} kodunu girin",
|
||||
"failed_to_initiate_quick_connect": "Quick Connect başlatılamadı",
|
||||
"got_it": "Anlaşıldı",
|
||||
"connection_failed": "Bağlantı başarısız",
|
||||
"could_not_connect_to_server": "Sunucuya bağlanılamadı. Lütfen URL'yi ve ağ bağlantınızı kontrol edin",
|
||||
"an_unexpected_error_occured": "Beklenmedik bir hata oluştu",
|
||||
"change_server": "Sunucuyu değiştir",
|
||||
"invalid_username_or_password": "Geçersiz kullanıcı adı veya şifre",
|
||||
"user_does_not_have_permission_to_log_in": "Kullanıcının giriş yapma izni yok",
|
||||
"server_is_taking_too_long_to_respond_try_again_later": "Sunucu yanıt vermekte çok uzun sürüyor, lütfen tekrar deneyin",
|
||||
"server_received_too_many_requests_try_again_later": "Sunucu çok fazla istek aldı, lütfen tekrar deneyin.",
|
||||
"there_is_a_server_error": "Sunucu hatası var",
|
||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Beklenmedik bir hata oluştu. Sunucu URL'sini doğru girdiğinizden emin oldunuz mu?"
|
||||
},
|
||||
"server": {
|
||||
"enter_url_to_jellyfin_server": "Jellyfin sunucunusun URL'sini girin",
|
||||
"server_url_placeholder": "http(s)://sunucunuz.com",
|
||||
"connect_button": "Bağlan",
|
||||
"previous_servers": "Önceki sunucular",
|
||||
"clear_button": "Temizle",
|
||||
"search_for_local_servers": "Yerel sunucuları ara",
|
||||
"searching": "Aranıyor...",
|
||||
"servers": "Sunucular"
|
||||
},
|
||||
"home": {
|
||||
"no_internet": "İnternet Yok",
|
||||
"no_items": "Öge Yok",
|
||||
"no_internet_message": "Endişelenmeyin, hala\ndownloaded içerik izleyebilirsiniz.",
|
||||
"go_to_downloads": "İndirmelere Git",
|
||||
"oops": "Hups!",
|
||||
"error_message": "Bir şeyler ters gitti.\nLütfen çıkış yapın ve tekrar giriş yapın.",
|
||||
"continue_watching": "İzlemeye Devam Et",
|
||||
"next_up": "Sonraki",
|
||||
"recently_added_in": "{{libraryName}}'de Yakınlarda Eklendi",
|
||||
"suggested_movies": "Önerilen Filmler",
|
||||
"suggested_episodes": "Önerilen Bölümler",
|
||||
"intro": {
|
||||
"welcome_to_streamyfin": "Streamyfin'e Hoş Geldiniz",
|
||||
"a_free_and_open_source_client_for_jellyfin": "Jellyfin için ücretsiz ve açık kaynak bir istemci.",
|
||||
"features_title": "Özellikler",
|
||||
"features_description": "Streamyfin birçok özelliğe sahip ve ayarlar menüsünde bulabileceğiniz çeşitli yazılımlarla entegre olabiliyor. Bunlar arasında şunlar bulunuyor:",
|
||||
"jellyseerr_feature_description": "Jellyseerr örneğinizle bağlantı kurun ve uygulama içinde doğrudan film talep edin.",
|
||||
"downloads_feature_title": "İndirmeler",
|
||||
"downloads_feature_description": "Filmleri ve TV dizilerini çevrimdışı izlemek için indirin. Varsayılan yöntemi veya dosyaları arka planda indirmek için optimize sunucuyu kurabilirsiniz.",
|
||||
"chromecast_feature_description": "Filmleri ve TV dizilerini Chromecast cihazlarınıza aktarın.",
|
||||
"centralised_settings_plugin_title": "Merkezi Ayarlar Eklentisi",
|
||||
"centralised_settings_plugin_description": "Jellyfin sunucunuzda merkezi bir yerden ayarları yapılandırın. Tüm istemci ayarları tüm kullanıcılar için otomatik olarak senkronize edilecektir.",
|
||||
"done_button": "Tamam",
|
||||
"go_to_settings_button": "Ayrıntılara Git",
|
||||
"read_more": "Daha fazla oku"
|
||||
},
|
||||
"settings": {
|
||||
"settings_title": "Ayarlar",
|
||||
"log_out_button": "Çıkış Yap",
|
||||
"user_info": {
|
||||
"user_info_title": "Kullanıcı Bilgisi",
|
||||
"user": "Kullanıcı",
|
||||
"server": "Sunucu",
|
||||
"token": "Token",
|
||||
"app_version": "Uygulama Sürümü"
|
||||
},
|
||||
"quick_connect": {
|
||||
"quick_connect_title": "Hızlı Bağlantı",
|
||||
"authorize_button": "Hızlı Bağlantıyı Yetkilendir",
|
||||
"enter_the_quick_connect_code": "Hızlı bağlantı kodunu girin...",
|
||||
"success": "Başarılı",
|
||||
"quick_connect_autorized": "Hızlı Bağlantı Yetkilendirildi",
|
||||
"error": "Hata",
|
||||
"invalid_code": "Geçersiz kod",
|
||||
"authorize": "Yetkilendir"
|
||||
},
|
||||
"media_controls": {
|
||||
"media_controls_title": "Medya Kontrolleri",
|
||||
"forward_skip_length": "İleri Sarma Uzunluğu",
|
||||
"rewind_length": "Geri Sarma Uzunluğu",
|
||||
"seconds_unit": "s"
|
||||
},
|
||||
"audio": {
|
||||
"audio_title": "Ses",
|
||||
"set_audio_track": "Önceki Öğeden Ses Parçası Ayarla",
|
||||
"audio_language": "Ses Dili",
|
||||
"audio_hint": "Varsayılan ses dilini seçin.",
|
||||
"none": "Yok",
|
||||
"language": "Dil"
|
||||
},
|
||||
"subtitles": {
|
||||
"subtitle_title": "Altyazılar",
|
||||
"subtitle_language": "Altyazı Dili",
|
||||
"subtitle_mode": "Altyazı Modu",
|
||||
"set_subtitle_track": "Önceki Öğeden Altyazı Parçası Ayarla",
|
||||
"subtitle_size": "Altyazı Boyutu",
|
||||
"subtitle_hint": "Altyazı tercihini yapılandırın.",
|
||||
"none": "Yok",
|
||||
"language": "Dil",
|
||||
"loading": "Yükleniyor",
|
||||
"modes": {
|
||||
"Default": "Varsayılan",
|
||||
"Smart": "Akıllı",
|
||||
"Always": "Her Zaman",
|
||||
"None": "Yok",
|
||||
"OnlyForced": "Sadece Zorunlu"
|
||||
}
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Diğer",
|
||||
"auto_rotate": "Otomatik Döndürme",
|
||||
"video_orientation": "Video Yönü",
|
||||
"orientation": "Yön",
|
||||
"orientations": {
|
||||
"DEFAULT": "Varsayılan",
|
||||
"ALL": "Tümü",
|
||||
"PORTRAIT": "Dikey",
|
||||
"PORTRAIT_UP": "Dikey Yukarı",
|
||||
"PORTRAIT_DOWN": "Dikey Aşağı",
|
||||
"LANDSCAPE": "Yatay",
|
||||
"LANDSCAPE_LEFT": "Yatay Sol",
|
||||
"LANDSCAPE_RIGHT": "Yatay Sağ",
|
||||
"OTHER": "Diğer",
|
||||
"UNKNOWN": "Bilinmeyen"
|
||||
},
|
||||
"safe_area_in_controls": "Kontrollerde Güvenli Alan",
|
||||
"show_custom_menu_links": "Özel Menü Bağlantılarını Göster",
|
||||
"hide_libraries": "Kütüphaneleri Gizle",
|
||||
"select_liraries_you_want_to_hide": "Kütüphane sekmesinden ve ana sayfa bölümlerinden gizlemek istediğiniz kütüphaneleri seçin.",
|
||||
"disable_haptic_feedback": "Dokunsal Geri Bildirimi Devre Dışı Bırak"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "İndirmeler",
|
||||
"download_method": "İndirme Yöntemi",
|
||||
"remux_max_download": "Remux max indirme",
|
||||
"auto_download": "Otomatik İndirme",
|
||||
"optimized_versions_server": "Optimize edilmiş sürümler sunucusu",
|
||||
"save_button": "Kaydet",
|
||||
"optimized_server": "Optimize Sunucu",
|
||||
"optimized": "Optimize",
|
||||
"default": "Varsayılan",
|
||||
"optimized_version_hint": "Optimize sunucusu için URL girin. URL, http veya https içermeli ve isteğe bağlı olarak portu içerebilir.",
|
||||
"read_more_about_optimized_server": "Optimize sunucusu hakkında daha fazla oku.",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://domain.org:port"
|
||||
},
|
||||
"plugins": {
|
||||
"plugins_title": "Eklentiler",
|
||||
"jellyseerr": {
|
||||
"jellyseerr_warning": "Bu entegrasyon erken aşamalardadır. Değişiklikler olabilir.",
|
||||
"server_url": "Sunucu URL'si",
|
||||
"server_url_hint": "Örnek: http(s)://your-host.url\n(port gerekiyorsa ekleyin)",
|
||||
"server_url_placeholder": "Jellyseerr URL...",
|
||||
"password": "Şifre",
|
||||
"password_placeholder": "Jellyfin kullanıcısı {{username}} için şifre girin",
|
||||
"save_button": "Kaydet",
|
||||
"clear_button": "Temizle",
|
||||
"login_button": "Giriş Yap",
|
||||
"total_media_requests": "Toplam medya istekleri",
|
||||
"movie_quota_limit": "Film kota limiti",
|
||||
"movie_quota_days": "Film kota günleri",
|
||||
"tv_quota_limit": "TV kota limiti",
|
||||
"tv_quota_days": "TV kota günleri",
|
||||
"reset_jellyseerr_config_button": "Jellyseerr yapılandırmasını sıfırla",
|
||||
"unlimited": "Sınırsız"
|
||||
},
|
||||
"marlin_search": {
|
||||
"enable_marlin_search": "Marlin Aramasını Etkinleştir ",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://domain.org:port",
|
||||
"marlin_search_hint": "Marlin sunucu URL'sini girin. URL, http veya https içermeli ve isteğe bağlı olarak portu içerebilir.",
|
||||
"read_more_about_marlin": "Marlin hakkında daha fazla oku.",
|
||||
"save_button": "Kaydet",
|
||||
"toasts": {
|
||||
"saved": "Kaydedildi"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"storage_title": "Depolama",
|
||||
"app_usage": "Uygulama {{usedSpace}}%",
|
||||
"device_usage": "Cihaz {{availableSpace}}%",
|
||||
"size_used": "{{used}} / {{total}} kullanıldı",
|
||||
"delete_all_downloaded_files": "Tüm indirilen dosyaları sil"
|
||||
},
|
||||
"intro": {
|
||||
"show_intro": "Tanıtımı Göster",
|
||||
"reset_intro": "Tanıtımı Sıfırla"
|
||||
},
|
||||
"logs": {
|
||||
"logs_title": "Günlükler",
|
||||
"no_logs_available": "Günlükler mevcut değil",
|
||||
"delete_all_logs": "Tüm günlükleri sil"
|
||||
},
|
||||
"languages": {
|
||||
"title": "Diller",
|
||||
"app_language": "Uygulama dili",
|
||||
"app_language_description": "Uygulama dilini seçin.",
|
||||
"system": "Sistem"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Dosyalar silinirken hata oluştu",
|
||||
"background_downloads_enabled": "Arka plan indirmeleri etkinleştirildi",
|
||||
"background_downloads_disabled": "Arka plan indirmeleri devre dışı bırakıldı",
|
||||
"connected": "Bağlandı",
|
||||
"could_not_connect": "Bağlanılamadı",
|
||||
"invalid_url": "Geçersiz URL"
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "İndirilenler",
|
||||
"tvseries": "Diziler",
|
||||
"movies": "Filmler",
|
||||
"queue": "Sıra",
|
||||
"queue_hint": "Sıra ve indirmeler uygulama yeniden başlatıldığında kaybolacaktır",
|
||||
"no_items_in_queue": "Sırada öğe yok",
|
||||
"no_downloaded_items": "İndirilen öğe yok",
|
||||
"delete_all_movies_button": "Tüm Filmleri Sil",
|
||||
"delete_all_tvseries_button": "Tüm Dizileri Sil",
|
||||
"delete_all_button": "Tümünü Sil",
|
||||
"active_download": "Aktif indirme",
|
||||
"no_active_downloads": "Aktif indirme yok",
|
||||
"active_downloads": "Aktif indirmeler",
|
||||
"new_app_version_requires_re_download": "Yeni uygulama sürümü yeniden indirme gerektiriyor",
|
||||
"new_app_version_requires_re_download_description": "Yeni güncelleme, içeriğin yeniden indirilmesini gerektiriyor. Lütfen tüm indirilen içerikleri kaldırıp tekrar deneyin.",
|
||||
"back": "Geri",
|
||||
"delete": "Sil",
|
||||
"something_went_wrong": "Bir şeyler ters gitti",
|
||||
"could_not_get_stream_url_from_jellyfin": "Jellyfin'den yayın URL'si alınamadı",
|
||||
"eta": "Tahmini Süre {{eta}}",
|
||||
"methods": "Yöntemler",
|
||||
"toasts": {
|
||||
"you_are_not_allowed_to_download_files": "Dosyaları indirme izniniz yok.",
|
||||
"deleted_all_movies_successfully": "Tüm filmler başarıyla silindi!",
|
||||
"failed_to_delete_all_movies": "Filmler silinemedi",
|
||||
"deleted_all_tvseries_successfully": "Tüm diziler başarıyla silindi!",
|
||||
"failed_to_delete_all_tvseries": "Diziler silinemedi",
|
||||
"download_cancelled": "İndirme iptal edildi",
|
||||
"could_not_cancel_download": "İndirme iptal edilemedi",
|
||||
"download_completed": "İndirme tamamlandı",
|
||||
"download_started_for": "{{item}} için indirme başlatıldı",
|
||||
"item_is_ready_to_be_downloaded": "{{item}} indirmeye hazır",
|
||||
"download_stated_for_item": "{{item}} için indirme başlatıldı",
|
||||
"download_failed_for_item": "{{item}} için indirme başarısız oldu - {{error}}",
|
||||
"download_completed_for_item": "{{item}} için indirme tamamlandı",
|
||||
"queued_item_for_optimization": "{{item}} optimizasyon için sıraya alındı",
|
||||
"failed_to_start_download_for_item": "{{item}} için indirme başlatılamadı: {{message}}",
|
||||
"server_responded_with_status_code": "Sunucu şu durum koduyla yanıt verdi: {{statusCode}}",
|
||||
"no_response_received_from_server": "Sunucudan yanıt alınamadı",
|
||||
"error_setting_up_the_request": "İstek ayarlanırken hata oluştu",
|
||||
"failed_to_start_download_for_item_unexpected_error": "{{item}} için indirme başlatılamadı: Beklenmeyen hata",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Tüm dosyalar, klasörler ve işler başarıyla silindi",
|
||||
"an_error_occured_while_deleting_files_and_jobs": "Dosyalar ve işler silinirken hata oluştu",
|
||||
"go_to_downloads": "İndirmelere git"
|
||||
}
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"search_here": "Burada ara...",
|
||||
"search": "Ara...",
|
||||
"x_items": "{{count}} öge(ler)",
|
||||
"library": "Kütüphane",
|
||||
"discover": "Keşfet",
|
||||
"no_results": "Sonuç bulunamadı",
|
||||
"no_results_found_for": "\"{{query}}\" için sonuç bulunamadı",
|
||||
"movies": "Filmler",
|
||||
"series": "Diziler",
|
||||
"episodes": "Bölümler",
|
||||
"collections": "Koleksiyonlar",
|
||||
"actors": "Oyuncular",
|
||||
"request_movies": "Film Talep Et",
|
||||
"request_series": "Dizi Talep Et",
|
||||
"recently_added": "Son Eklenenler",
|
||||
"recent_requests": "Son Talepler",
|
||||
"plex_watchlist": "Plex İzleme Listesi",
|
||||
"trending": "Şu An Popüler",
|
||||
"popular_movies": "Popüler Filmler",
|
||||
"movie_genres": "Film Türleri",
|
||||
"upcoming_movies": "Yaklaşan Filmler",
|
||||
"studios": "Stüdyolar",
|
||||
"popular_tv": "Popüler Diziler",
|
||||
"tv_genres": "Dizi Türleri",
|
||||
"upcoming_tv": "Yaklaşan Diziler",
|
||||
"networks": "Ağlar",
|
||||
"tmdb_movie_keyword": "TMDB Film Anahtar Kelimesi",
|
||||
"tmdb_movie_genre": "TMDB Film Türü",
|
||||
"tmdb_tv_keyword": "TMDB Dizi Anahtar Kelimesi",
|
||||
"tmdb_tv_genre": "TMDB Dizi Türü",
|
||||
"tmdb_search": "TMDB Arama",
|
||||
"tmdb_studio": "TMDB Stüdyo",
|
||||
"tmdb_network": "TMDB Ağ",
|
||||
"tmdb_movie_streaming_services": "TMDB Film Yayın Servisleri",
|
||||
"tmdb_tv_streaming_services": "TMDB Dizi Yayın Servisleri"
|
||||
},
|
||||
"library": {
|
||||
"no_items_found": "Öğe bulunamadı",
|
||||
"no_results": "Sonuç bulunamadı",
|
||||
"no_libraries_found": "Kütüphane bulunamadı",
|
||||
"item_types": {
|
||||
"movies": "filmler",
|
||||
"series": "diziler",
|
||||
"boxsets": "koleksiyonlar",
|
||||
"items": "ögeler"
|
||||
},
|
||||
"options": {
|
||||
"display": "Görüntüleme",
|
||||
"row": "Satır",
|
||||
"list": "Liste",
|
||||
"image_style": "Görsel stili",
|
||||
"poster": "Poster",
|
||||
"cover": "Kapak",
|
||||
"show_titles": "Başlıkları göster",
|
||||
"show_stats": "İstatistikleri göster"
|
||||
},
|
||||
"filters": {
|
||||
"genres": "Türler",
|
||||
"years": "Yıllar",
|
||||
"sort_by": "Sırala",
|
||||
"sort_order": "Sıralama düzeni",
|
||||
"tags": "Etiketler"
|
||||
}
|
||||
},
|
||||
"favorites": {
|
||||
"series": "Diziler",
|
||||
"movies": "Filmler",
|
||||
"episodes": "Bölümler",
|
||||
"videos": "Videolar",
|
||||
"boxsets": "Koleksiyonlar",
|
||||
"playlists": "Çalma listeleri"
|
||||
},
|
||||
"custom_links": {
|
||||
"no_links": "Bağlantı yok"
|
||||
},
|
||||
"player": {
|
||||
"error": "Hata",
|
||||
"failed_to_get_stream_url": "Yayın URL'si alınamadı",
|
||||
"an_error_occured_while_playing_the_video": "Video oynatılırken bir hata oluştu. Ayarlardaki günlüklere bakın.",
|
||||
"client_error": "İstemci hatası",
|
||||
"could_not_create_stream_for_chromecast": "Chromecast için yayın oluşturulamadı",
|
||||
"message_from_server": "Sunucudan mesaj: {{message}}",
|
||||
"video_has_finished_playing": "Video oynatıldı!",
|
||||
"no_video_source": "Video kaynağı yok...",
|
||||
"next_episode": "Sonraki bölüm",
|
||||
"refresh_tracks": "Parçaları yenile",
|
||||
"subtitle_tracks": "Altyazı Parçaları:",
|
||||
"audio_tracks": "Ses Parçaları:",
|
||||
"playback_state": "Oynatma Durumu:",
|
||||
"no_data_available": "Veri bulunamadı",
|
||||
"index": "İndeks:"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "Sıradaki",
|
||||
"no_items_to_display": "Görüntülenecek öğe yok",
|
||||
"cast_and_crew": "Oyuncular & Ekip",
|
||||
"series": "Dizi",
|
||||
"seasons": "Sezonlar",
|
||||
"season": "Sezon",
|
||||
"no_episodes_for_this_season": "Bu sezona ait bölüm yok",
|
||||
"overview": "Özet",
|
||||
"more_with": "Daha fazla {{name}}",
|
||||
"similar_items": "Benzer ögeler",
|
||||
"no_similar_items_found": "Benzer öge bulunamadı",
|
||||
"video": "Video",
|
||||
"more_details": "Daha fazla detay",
|
||||
"quality": "Kalite",
|
||||
"audio": "Ses",
|
||||
"subtitles": "Altyazı",
|
||||
"show_more": "Daha fazla göster",
|
||||
"show_less": "Daha az göster",
|
||||
"appeared_in": "Şurada yer aldı",
|
||||
"could_not_load_item": "Öge yüklenemedi",
|
||||
"none": "Hiçbiri",
|
||||
"download": {
|
||||
"download_season": "Sezonu indir",
|
||||
"download_series": "Diziyi indir",
|
||||
"download_episode": "Bölümü indir",
|
||||
"download_movie": "Filmi indir",
|
||||
"download_x_item": "{{item_count}} tane ögeyi indir",
|
||||
"download_button": "İndir",
|
||||
"using_optimized_server": "Optimize edilmiş sunucu kullanılıyor",
|
||||
"using_default_method": "Varsayılan yöntem kullanılıyor"
|
||||
}
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "Sonraki",
|
||||
"previous": "Önceki",
|
||||
"live_tv": "Canlı TV",
|
||||
"coming_soon": "Yakında",
|
||||
"on_now": "Şu anda yayında",
|
||||
"shows": "Programlar",
|
||||
"movies": "Filmler",
|
||||
"sports": "Spor",
|
||||
"for_kids": "Çocuklar İçin",
|
||||
"news": "Haberler"
|
||||
},
|
||||
"jellyseerr": {
|
||||
"confirm": "Onayla",
|
||||
"cancel": "İptal",
|
||||
"yes": "Evet",
|
||||
"whats_wrong": "Problem nedir?",
|
||||
"issue_type": "Sorun türü",
|
||||
"select_an_issue": "Bir sorun seçin",
|
||||
"types": "Türler",
|
||||
"describe_the_issue": "(isteğe bağlı) Sorunu açıklayın...",
|
||||
"submit_button": "Gönder",
|
||||
"report_issue_button": "Sorunu bildir",
|
||||
"request_button": "Talep et",
|
||||
"are_you_sure_you_want_to_request_all_seasons": "Tüm sezonları talep etmek istediğinizden emin misiniz?",
|
||||
"failed_to_login": "Giriş yapılamadı",
|
||||
"cast": "Oyuncular",
|
||||
"details": "Detaylar",
|
||||
"status": "Durum",
|
||||
"original_title": "Orijinal Başlık",
|
||||
"series_type": "Dizi Türü",
|
||||
"release_dates": "Yayın Tarihleri",
|
||||
"first_air_date": "İlk Yayın Tarihi",
|
||||
"next_air_date": "Sonraki Yayın Tarihi",
|
||||
"revenue": "Gelir",
|
||||
"budget": "Bütçe",
|
||||
"original_language": "Orijinal Dil",
|
||||
"production_country": "Yapım Ülkesi",
|
||||
"studios": "Stüdyolar",
|
||||
"network": "Ağ",
|
||||
"currently_streaming_on": "Şu anda yayınlanıyor",
|
||||
"advanced": "Gelişmiş",
|
||||
"request_as": "Şu olarak iste",
|
||||
"tags": "Etiketler",
|
||||
"quality_profile": "Kalite Profili",
|
||||
"root_folder": "Kök Klasör",
|
||||
"season_x": "Sezon {{seasons}}",
|
||||
"season_number": "Sezon {{season_number}}",
|
||||
"number_episodes": "Bölüm {{episode_number}}",
|
||||
"born": "Doğum",
|
||||
"appearances": "Görünmeler",
|
||||
"toasts": {
|
||||
"jellyseer_does_not_meet_requirements": "Jellyseerr sunucusu minimum sürüm gereksinimlerini karşılamıyor! Lütfen en az 2.0.0 sürümüne güncelleyin",
|
||||
"jellyseerr_test_failed": "Jellyseerr testi başarısız oldu. Lütfen tekrar deneyin.",
|
||||
"failed_to_test_jellyseerr_server_url": "Jellyseerr sunucu URL'si test edilemedi",
|
||||
"issue_submitted": "Sorun gönderildi!",
|
||||
"requested_item": "{{item}} talep edildi!",
|
||||
"you_dont_have_permission_to_request": "İstek göndermeye izniniz yok!",
|
||||
"something_went_wrong_requesting_media": "Medya talep edilirken bir şeyler ters gitti!"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"home": "Ana Sayfa",
|
||||
"search": "Ara",
|
||||
"library": "Kütüphane",
|
||||
"custom_links": "Özel Bağlantılar",
|
||||
"favorites": "Favoriler"
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import { TranscodedSubtitle } from "@/components/video-player/controls/types";
|
||||
import { TrackInfo } from "@/modules/vlc-player";
|
||||
import { MediaStream } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
const disableSubtitle = {
|
||||
name: "Disable",
|
||||
index: -1,
|
||||
IsTextSubtitleStream: true,
|
||||
} as TranscodedSubtitle;
|
||||
|
||||
export class SubtitleHelper {
|
||||
private mediaStreams: MediaStream[];
|
||||
|
||||
constructor(mediaStreams: MediaStream[]) {
|
||||
this.mediaStreams = mediaStreams.filter((x) => x.Type === "Subtitle");
|
||||
}
|
||||
|
||||
getSubtitles(): MediaStream[] {
|
||||
return this.mediaStreams;
|
||||
}
|
||||
|
||||
getUniqueSubtitles(): MediaStream[] {
|
||||
const uniqueSubs: MediaStream[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
this.mediaStreams.forEach((x) => {
|
||||
if (!seen.has(x.DisplayTitle!)) {
|
||||
seen.add(x.DisplayTitle!);
|
||||
uniqueSubs.push(x);
|
||||
}
|
||||
});
|
||||
|
||||
return uniqueSubs;
|
||||
}
|
||||
|
||||
getCurrentSubtitle(subtitleIndex?: number): MediaStream | undefined {
|
||||
return this.mediaStreams.find((x) => x.Index === subtitleIndex);
|
||||
}
|
||||
|
||||
getMostCommonSubtitleByName(
|
||||
subtitleIndex: number | undefined
|
||||
): number | undefined {
|
||||
if (subtitleIndex === undefined) -1;
|
||||
const uniqueSubs = this.getUniqueSubtitles();
|
||||
const currentSub = this.getCurrentSubtitle(subtitleIndex);
|
||||
|
||||
return uniqueSubs.find((x) => x.DisplayTitle === currentSub?.DisplayTitle)
|
||||
?.Index;
|
||||
}
|
||||
|
||||
getTextSubtitles(): MediaStream[] {
|
||||
return this.mediaStreams.filter((x) => x.IsTextSubtitleStream);
|
||||
}
|
||||
|
||||
getImageSubtitles(): MediaStream[] {
|
||||
return this.mediaStreams.filter((x) => !x.IsTextSubtitleStream);
|
||||
}
|
||||
|
||||
getEmbeddedTrackIndex(sourceSubtitleIndex: number): number {
|
||||
if (Platform.OS === "android") {
|
||||
const textSubs = this.getTextSubtitles();
|
||||
const matchingSubtitle = textSubs.find(
|
||||
(sub) => sub.Index === sourceSubtitleIndex
|
||||
);
|
||||
|
||||
if (!matchingSubtitle) return -1;
|
||||
return textSubs.indexOf(matchingSubtitle);
|
||||
}
|
||||
|
||||
// Get unique text-based subtitles because react-native-video removes hls text tracks duplicates. (iOS)
|
||||
const uniqueTextSubs = this.getUniqueTextBasedSubtitles();
|
||||
const matchingSubtitle = uniqueTextSubs.find(
|
||||
(sub) => sub.Index === sourceSubtitleIndex
|
||||
);
|
||||
|
||||
if (!matchingSubtitle) return -1;
|
||||
return uniqueTextSubs.indexOf(matchingSubtitle);
|
||||
}
|
||||
|
||||
sortSubtitles(
|
||||
textSubs: TranscodedSubtitle[],
|
||||
allSubs: MediaStream[]
|
||||
): TranscodedSubtitle[] {
|
||||
let textIndex = 0; // To track position in textSubtitles
|
||||
// Merge text and image subtitles in the order of allSubs
|
||||
const sortedSubtitles = allSubs.map((sub) => {
|
||||
if (sub.IsTextSubtitleStream) {
|
||||
if (textSubs.length === 0) return disableSubtitle;
|
||||
const textSubtitle = textSubs[textIndex];
|
||||
if (!textSubtitle) return disableSubtitle;
|
||||
textIndex++;
|
||||
return textSubtitle;
|
||||
} else {
|
||||
return {
|
||||
name: sub.DisplayTitle!,
|
||||
index: sub.Index!,
|
||||
IsTextSubtitleStream: sub.IsTextSubtitleStream,
|
||||
} as TranscodedSubtitle;
|
||||
}
|
||||
});
|
||||
|
||||
return sortedSubtitles;
|
||||
}
|
||||
|
||||
getSortedSubtitles(subtitleTracks: TrackInfo[]): TranscodedSubtitle[] {
|
||||
const textSubtitles =
|
||||
subtitleTracks.map((s) => ({
|
||||
name: s.name,
|
||||
index: s.index,
|
||||
IsTextSubtitleStream: true,
|
||||
})) || [];
|
||||
|
||||
const sortedSubs =
|
||||
Platform.OS === "android"
|
||||
? this.sortSubtitles(textSubtitles, this.mediaStreams)
|
||||
: this.sortSubtitles(textSubtitles, this.getUniqueSubtitles());
|
||||
|
||||
return sortedSubs;
|
||||
}
|
||||
|
||||
getUniqueTextBasedSubtitles(): MediaStream[] {
|
||||
return this.getUniqueSubtitles().filter((x) => x.IsTextSubtitleStream);
|
||||
}
|
||||
|
||||
// HLS stream indexes are not the same as the actual source indexes.
|
||||
// This function aims to get the source subtitle index from the embedded track index.
|
||||
getSourceSubtitleIndex = (embeddedTrackIndex: number): number => {
|
||||
if (Platform.OS === "android") {
|
||||
return this.getTextSubtitles()[embeddedTrackIndex]?.Index ?? -1;
|
||||
}
|
||||
return this.getUniqueTextBasedSubtitles()[embeddedTrackIndex]?.Index ?? -1;
|
||||
};
|
||||
}
|
||||
@@ -111,15 +111,6 @@ export const getStreamUrl = async ({
|
||||
if (mediaSource?.TranscodingUrl) {
|
||||
const urlObj = new URL(api.basePath + mediaSource?.TranscodingUrl); // Create a URL object
|
||||
|
||||
// If there is no subtitle stream index, add it to the URL.
|
||||
if (subtitleStreamIndex == -1) {
|
||||
urlObj.searchParams.set("SubtitleMethod", "Hls");
|
||||
}
|
||||
|
||||
// Add 'SubtitleMethod=Hls' if it doesn't exist
|
||||
if (!urlObj.searchParams.has("SubtitleMethod")) {
|
||||
urlObj.searchParams.append("SubtitleMethod", "Hls");
|
||||
}
|
||||
// Get the updated URL
|
||||
const transcodeUrl = urlObj.toString();
|
||||
|
||||
|
||||
@@ -42,11 +42,9 @@ export default {
|
||||
Type: MediaTypes.Video,
|
||||
Context: "Streaming",
|
||||
Protocol: "hls",
|
||||
Container: "ts",
|
||||
Container: "fmp4",
|
||||
VideoCodec: "h264, hevc",
|
||||
AudioCodec: "aac,mp3,ac3",
|
||||
CopyTimestamps: false,
|
||||
EnableSubtitlesInManifest: true,
|
||||
AudioCodec: "aac,mp3,ac3,dts",
|
||||
},
|
||||
{
|
||||
Type: MediaTypes.Audio,
|
||||
@@ -58,131 +56,81 @@ export default {
|
||||
},
|
||||
],
|
||||
SubtitleProfiles: [
|
||||
// Official foramts
|
||||
// Official formats
|
||||
{ Format: "vtt", Method: "Embed" },
|
||||
{ Format: "vtt", Method: "Hls" },
|
||||
{ Format: "vtt", Method: "External" },
|
||||
{ Format: "vtt", Method: "Encode" },
|
||||
|
||||
{ Format: "webvtt", Method: "Embed" },
|
||||
{ Format: "webvtt", Method: "Hls" },
|
||||
{ Format: "webvtt", Method: "External" },
|
||||
{ Format: "webvtt", Method: "Encode" },
|
||||
|
||||
{ Format: "srt", Method: "Embed" },
|
||||
{ Format: "srt", Method: "Hls" },
|
||||
{ Format: "srt", Method: "External" },
|
||||
{ Format: "srt", Method: "Encode" },
|
||||
|
||||
{ Format: "subrip", Method: "Embed" },
|
||||
{ Format: "subrip", Method: "Hls" },
|
||||
{ Format: "subrip", Method: "External" },
|
||||
{ Format: "subrip", Method: "Encode" },
|
||||
|
||||
{ Format: "ttml", Method: "Embed" },
|
||||
{ Format: "ttml", Method: "Hls" },
|
||||
{ Format: "ttml", Method: "External" },
|
||||
{ Format: "ttml", Method: "Encode" },
|
||||
|
||||
{ Format: "dvbsub", Method: "Embed" },
|
||||
{ Format: "dvbsub", Method: "Hls" },
|
||||
{ Format: "dvbsub", Method: "External" },
|
||||
{ Format: "dvdsub", Method: "Encode" },
|
||||
|
||||
{ Format: "ass", Method: "Embed" },
|
||||
{ Format: "ass", Method: "Hls" },
|
||||
{ Format: "ass", Method: "External" },
|
||||
{ Format: "ass", Method: "Encode" },
|
||||
|
||||
{ Format: "idx", Method: "Embed" },
|
||||
{ Format: "idx", Method: "Hls" },
|
||||
{ Format: "idx", Method: "External" },
|
||||
{ Format: "idx", Method: "Encode" },
|
||||
|
||||
{ Format: "pgs", Method: "Embed" },
|
||||
{ Format: "pgs", Method: "Hls" },
|
||||
{ Format: "pgs", Method: "External" },
|
||||
{ Format: "pgs", Method: "Encode" },
|
||||
|
||||
{ Format: "pgssub", Method: "Embed" },
|
||||
{ Format: "pgssub", Method: "Hls" },
|
||||
{ Format: "pgssub", Method: "External" },
|
||||
{ Format: "pgssub", Method: "Encode" },
|
||||
|
||||
{ Format: "ssa", Method: "Embed" },
|
||||
{ Format: "ssa", Method: "Hls" },
|
||||
{ Format: "ssa", Method: "External" },
|
||||
{ Format: "ssa", Method: "Encode" },
|
||||
|
||||
// Other formats
|
||||
{ Format: "microdvd", Method: "Embed" },
|
||||
{ Format: "microdvd", Method: "Hls" },
|
||||
{ Format: "microdvd", Method: "External" },
|
||||
{ Format: "microdvd", Method: "Encode" },
|
||||
|
||||
{ Format: "mov_text", Method: "Embed" },
|
||||
{ Format: "mov_text", Method: "Hls" },
|
||||
{ Format: "mov_text", Method: "External" },
|
||||
{ Format: "mov_text", Method: "Encode" },
|
||||
|
||||
{ Format: "mpl2", Method: "Embed" },
|
||||
{ Format: "mpl2", Method: "Hls" },
|
||||
{ Format: "mpl2", Method: "External" },
|
||||
{ Format: "mpl2", Method: "Encode" },
|
||||
|
||||
{ Format: "pjs", Method: "Embed" },
|
||||
{ Format: "pjs", Method: "Hls" },
|
||||
{ Format: "pjs", Method: "External" },
|
||||
{ Format: "pjs", Method: "Encode" },
|
||||
|
||||
{ Format: "realtext", Method: "Embed" },
|
||||
{ Format: "realtext", Method: "Hls" },
|
||||
{ Format: "realtext", Method: "External" },
|
||||
{ Format: "realtext", Method: "Encode" },
|
||||
|
||||
{ Format: "scc", Method: "Embed" },
|
||||
{ Format: "scc", Method: "Hls" },
|
||||
{ Format: "scc", Method: "External" },
|
||||
{ Format: "scc", Method: "Encode" },
|
||||
|
||||
{ Format: "smi", Method: "Embed" },
|
||||
{ Format: "smi", Method: "Hls" },
|
||||
{ Format: "smi", Method: "External" },
|
||||
{ Format: "smi", Method: "Encode" },
|
||||
|
||||
{ Format: "stl", Method: "Embed" },
|
||||
{ Format: "stl", Method: "Hls" },
|
||||
{ Format: "stl", Method: "External" },
|
||||
{ Format: "stl", Method: "Encode" },
|
||||
|
||||
{ Format: "sub", Method: "Embed" },
|
||||
{ Format: "sub", Method: "Hls" },
|
||||
{ Format: "sub", Method: "External" },
|
||||
{ Format: "sub", Method: "Encode" },
|
||||
|
||||
{ Format: "subviewer", Method: "Embed" },
|
||||
{ Format: "subviewer", Method: "Hls" },
|
||||
{ Format: "subviewer", Method: "External" },
|
||||
{ Format: "subviewer", Method: "Encode" },
|
||||
|
||||
{ Format: "teletext", Method: "Embed" },
|
||||
{ Format: "teletext", Method: "Hls" },
|
||||
{ Format: "teletext", Method: "External" },
|
||||
{ Format: "teletext", Method: "Encode" },
|
||||
|
||||
{ Format: "text", Method: "Embed" },
|
||||
{ Format: "text", Method: "Hls" },
|
||||
{ Format: "text", Method: "External" },
|
||||
{ Format: "text", Method: "Encode" },
|
||||
|
||||
{ Format: "vplayer", Method: "Embed" },
|
||||
{ Format: "vplayer", Method: "Hls" },
|
||||
{ Format: "vplayer", Method: "External" },
|
||||
{ Format: "vplayer", Method: "Encode" },
|
||||
|
||||
{ Format: "xsub", Method: "Embed" },
|
||||
{ Format: "xsub", Method: "Hls" },
|
||||
{ Format: "xsub", Method: "External" },
|
||||
{ Format: "xsub", Method: "Encode" },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
/**
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
import MediaTypes from "../../constants/MediaTypes";
|
||||
|
||||
export default {
|
||||
Name: "Vlc Player for HLS streams.",
|
||||
MaxStaticBitrate: 20_000_000,
|
||||
MaxStreamingBitrate: 12_000_000,
|
||||
CodecProfiles: [
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
Codec: "h264,h265,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1",
|
||||
},
|
||||
{
|
||||
Type: MediaTypes.Audio,
|
||||
Codec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,pcm,wma",
|
||||
},
|
||||
],
|
||||
DirectPlayProfiles: [
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
Container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp,hls",
|
||||
VideoCodec:
|
||||
"h264,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1,avi,mpeg,mpeg2video",
|
||||
AudioCodec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,wma",
|
||||
},
|
||||
{
|
||||
Type: MediaTypes.Audio,
|
||||
Container: "mp3,aac,flac,alac,wav,ogg,wma",
|
||||
AudioCodec:
|
||||
"mp3,aac,flac,alac,opus,vorbis,wma,pcm,mpa,wav,ogg,oga,webma,ape",
|
||||
},
|
||||
],
|
||||
TranscodingProfiles: [
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
Context: "Streaming",
|
||||
Protocol: "hls",
|
||||
Container: "fmp4",
|
||||
VideoCodec: "h264, hevc",
|
||||
AudioCodec: "aac,mp3,ac3",
|
||||
CopyTimestamps: false,
|
||||
EnableSubtitlesInManifest: true,
|
||||
},
|
||||
{
|
||||
Type: MediaTypes.Audio,
|
||||
Context: "Streaming",
|
||||
Protocol: "http",
|
||||
Container: "mp3",
|
||||
AudioCodec: "mp3",
|
||||
MaxAudioChannels: "2",
|
||||
},
|
||||
],
|
||||
SubtitleProfiles: [
|
||||
// Text based subtitles must use HLS.
|
||||
{ Format: "ass", Method: "Hls" },
|
||||
{ Format: "microdvd", Method: "Hls" },
|
||||
{ Format: "mov_text", Method: "Hls" },
|
||||
{ Format: "mpl2", Method: "Hls" },
|
||||
{ Format: "pjs", Method: "Hls" },
|
||||
{ Format: "realtext", Method: "Hls" },
|
||||
{ Format: "scc", Method: "Hls" },
|
||||
{ Format: "smi", Method: "Hls" },
|
||||
{ Format: "srt", Method: "Hls" },
|
||||
{ Format: "ssa", Method: "Hls" },
|
||||
{ Format: "stl", Method: "Hls" },
|
||||
{ Format: "sub", Method: "Hls" },
|
||||
{ Format: "subrip", Method: "Hls" },
|
||||
{ Format: "subviewer", Method: "Hls" },
|
||||
{ Format: "teletext", Method: "Hls" },
|
||||
{ Format: "text", Method: "Hls" },
|
||||
{ Format: "ttml", Method: "Hls" },
|
||||
{ Format: "vplayer", Method: "Hls" },
|
||||
{ Format: "vtt", Method: "Hls" },
|
||||
{ Format: "webvtt", Method: "Hls" },
|
||||
|
||||
// Image based subs use encode.
|
||||
{ Format: "dvdsub", Method: "Encode" },
|
||||
{ Format: "pgs", Method: "Encode" },
|
||||
{ Format: "pgssub", Method: "Encode" },
|
||||
{ Format: "xsub", Method: "Encode" },
|
||||
],
|
||||
};
|
||||
Reference in New Issue
Block a user