Compare commits

...

14 Commits

Author SHA1 Message Date
Alex Kim
017bd4d074 Fixed file paths in the controls directory 2025-02-16 14:06:30 +11:00
herrrta
8b3141dfc6 fix: IOS video player black screens
- restores player view when re-entering apps foreground
- added logger
2025-02-15 15:16:25 -05:00
herrrta
d83ecb881b fix: Android PiP support fully working
- fixed black screen on re-entering
- ensured screen stays alive when video is playing
- PiP button states now reflect media status
2025-02-15 15:11:57 -05:00
Fredrik Burmester
4c14c08b35 fix: move from react-native-video -> VLC for transcoded streams (#529)
Co-authored-by: Alex Kim <alexkim5682@gmail.com>
2025-02-16 07:10:36 +11:00
herrrta
ecb9b90163 fix: Stop playback when gesture navigating back 2025-02-15 12:52:09 -05:00
Fredrik Burmester
33a2be24f4 fix: hidden be default ios 2025-02-15 12:00:58 +01:00
Fredrik Burmester
e8b0d52515 feat: change to native searchbar on android 2025-02-15 11:59:17 +01:00
Fredrik Burmester
9faa0de2d6 chore: bump version 2025-02-15 11:48:54 +01:00
Fredrik Burmester
221155d002 fix: deps 2025-02-15 11:34:50 +01:00
Fredrik Burmester
4a37e17324 chore 2025-02-14 13:28:06 +01:00
Fredrik Burmester
52b2a3418e fix: wrong deps 2025-02-13 10:35:58 +01:00
Fredrik Burmester
2753b243e5 chore: remove yarn lock file 2025-02-13 10:33:56 +01:00
Fredrik Burmester
f22b356b7c feat: turkish translations 2025-02-13 10:33:41 +01:00
Fredrik Burmester
d8ba5af8d9 chore: remove old patch 2025-02-13 10:33:34 +01:00
36 changed files with 4065 additions and 11002 deletions

View File

@@ -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

View File

@@ -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",
{

View File

@@ -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>
);
}

View File

@@ -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")}>

View File

@@ -36,15 +36,6 @@ export default function Layout() {
animation: "fade",
}}
/>
<Stack.Screen
name="transcoding-player"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
/>
</Stack>
</>
);

View File

@@ -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>
);
}
}

View File

@@ -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;

2940
bun.lock Normal file

File diff suppressed because it is too large Load Diff

BIN
bun.lockb

Binary file not shown.

View File

@@ -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(

View File

@@ -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]
);

View File

@@ -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]
);

View File

@@ -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();

View File

@@ -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}

View File

@@ -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();

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 };

View File

@@ -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"

View File

@@ -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

View 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)
// }
// }
}

View File

@@ -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)

View File

@@ -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 {

View File

@@ -1,7 +1,8 @@
{
"platforms": ["ios", "tvos", "android", "web"],
"ios": {
"modules": ["VlcPlayerModule"]
"modules": ["VlcPlayerModule"],
"appDelegateSubscribers": ["AppLifecycleDelegate"]
},
"android": {
"modules": ["expo.modules.vlcplayer.VlcPlayerModule"]

View 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.
}
}

View File

@@ -0,0 +1,4 @@
class VLCManager {
static let shared = VLCManager()
var listeners: [SimpleAppLifecycleListener] = []
}

View File

@@ -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())
}
}
}

View File

@@ -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;

View File

@@ -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 @@
]
}
}
}
}

View File

@@ -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
View 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"
}
}

View File

@@ -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;
};
}

View File

@@ -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();

View File

@@ -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" },
],
};

View File

@@ -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" },
],
};

9412
yarn.lock

File diff suppressed because it is too large Load Diff