Compare commits

...

6 Commits

Author SHA1 Message Date
Alex Kim
6494049b66 WIP 2025-02-16 06:39:02 +11:00
Alex Kim
adb20f4b10 Merge branch 'develop' into fix/remove-react-native-video 2025-02-16 04:55:40 +11:00
Alex Kim
7dc8132e7a Merge branch 'develop' into fix/remove-react-native-video 2025-02-16 04:53:42 +11:00
herrrta
ecb9b90163 fix: Stop playback when gesture navigating back 2025-02-15 12:52:09 -05:00
Alex Kim
9fb04518b0 WIP 2025-02-16 04:17:40 +11:00
Fredrik Burmester
46be3c9465 fix: remove all mentions of transcoding player and the route
only use vlc player
2025-02-14 13:46:01 +01:00
18 changed files with 386 additions and 1440 deletions

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;
@@ -331,7 +333,7 @@ export default function page() {
const handleAppStateChange = (nextAppState: AppStateStatus) => {
// Handle app going to the background
if (nextAppState.match(/inactive|background/)) {
_setShowControls(false)
_setShowControls(false);
}
setAppState(nextAppState);
};
@@ -350,66 +352,67 @@ export default function page() {
// 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 +430,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%" }}
@@ -486,4 +489,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;

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,8 +54,7 @@ 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 DropdownView from "./dropdown/DropdownView";
import { EpisodeList } from "./EpisodeList";
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
import SkipButton from "./SkipButton";
@@ -214,15 +218,10 @@ export const Controls: React.FC<Props> = ({
bitrateValue: bitrateValue.toString(),
}).toString();
stop()
stop();
if (!bitrateValue) {
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
return;
}
// @ts-expect-error
router.replace(`player/transcoding-player?${queryParams}`);
router.replace(`player/direct-player?${queryParams}`);
}, [previousItem, settings, subtitleIndex, audioIndex]);
const goToNextItem = useCallback(() => {
@@ -254,15 +253,10 @@ export const Controls: React.FC<Props> = ({
bitrateValue: bitrateValue.toString(),
}).toString();
stop()
stop();
if (!bitrateValue) {
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
return;
}
// @ts-expect-error
router.replace(`player/transcoding-player?${queryParams}`);
router.replace(`player/direct-player?${queryParams}`);
}, [nextItem, settings, subtitleIndex, audioIndex]);
const updateTimes = useCallback(
@@ -419,15 +413,10 @@ export const Controls: React.FC<Props> = ({
bitrateValue: bitrateValue.toString(),
}).toString();
stop()
stop();
if (!bitrateValue) {
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
return;
}
// @ts-expect-error
router.replace(`player/transcoding-player?${queryParams}`);
router.replace(`player/direct-player?${queryParams}`);
} catch (error) {
console.error("Error in gotoEpisode:", error);
}
@@ -508,7 +497,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 +548,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

@@ -1,34 +1,33 @@
import ExpoModulesCore
import VLCKit
import UIKit
import VLCKit
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 +40,8 @@ class VLCPlayerWrapper: NSObject {
public func setup(
parent: UIView,
updatePlayerState: (() -> ())?,
updateVideoProgress: (() -> ())?
updatePlayerState: (() -> Void)?,
updateVideoProgress: (() -> Void)?
) {
self.updatePlayerState = updatePlayerState
self.updateVideoProgress = updateVideoProgress
@@ -52,9 +51,9 @@ class VLCPlayerWrapper: NSObject {
playerView.setupView(parent: parent)
}
public func getPlayerView() -> UIView {
return playerView
}
public func getPlayerView() -> UIView {
return playerView
}
}
// MARK: - VLCPictureInPictureDrawable
@@ -63,7 +62,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 +88,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 +115,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 +141,15 @@ extension VLCPlayerWrapper: VLCMediaDelegate {
// Implement VLCMediaDelegate methods if needed
}
class VlcPlayerView: ExpoView {
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
@@ -229,10 +232,12 @@ 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 {
@@ -263,8 +268,8 @@ class VlcPlayerView: ExpoView {
media.addOptions(mediaOptions)
self.vlc.player.media = media
self.setInitialExternalSubtitles()
self.hasSource = true
if autoplay {
print("Playing...")
self.play()
@@ -274,20 +279,28 @@ 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)")
if trackIndex == -1 {
print("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;
track.isSelectedExclusively = true
print("Debug: Current subtitle track index after setting: \(track.trackName)")
}
@@ -296,12 +309,11 @@ class VlcPlayerView: ExpoView {
print("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))
print("Subtitle added with result: \(result) \(internalName)")
} else {
print("Failed to add subtitle")
}
@@ -313,30 +325,17 @@ class VlcPlayerView: ExpoView {
}
print("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)")
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)")
print("Debug: Subtitle tracks: \(tracks)")
return tracks
}
@objc func stop(completion: (() -> Void)? = nil) {
@@ -366,6 +365,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()
@@ -387,18 +399,6 @@ class VlcPlayerView: ExpoView {
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)
}
}
}
}
self.onVideoProgress?([
"currentTime": currentTimeMs,
"duration": durationMs,
@@ -414,7 +414,7 @@ class VlcPlayerView: ExpoView {
"error": false,
"isPlaying": player.isPlaying,
"isBuffering": !player.isPlaying && player.state == VLCMediaPlayerState.buffering,
"state": player.state.description
"state": player.state.description,
])
}

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

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