diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index b0997e88..138a6c4a 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -125,14 +125,7 @@ export default function page() { isLoading: isLoadingStreamUrl, isError: isErrorStreamUrl, } = useQuery({ - queryKey: [ - "stream-url", - itemId, - audioIndex, - subtitleIndex, - mediaSourceId, - bitrateValue, - ], + queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue], queryFn: async () => { console.log("Offline:", offline); if (offline) { @@ -254,6 +247,7 @@ export default function page() { videoRef.current?.stop(); }, [videoRef, reportPlaybackStopped]); + // TODO: unused should remove. const reportPlaybackStart = useCallback(async () => { if (offline) return; @@ -287,7 +281,12 @@ export default function page() { if (!item?.Id || !stream) return; - console.log("onProgress ~", currentTimeInTicks, isPlaying); + console.log( + "onProgress ~", + currentTimeInTicks, + isPlaying, + `AUDIO index: ${audioIndex} SUB index" ${subtitleIndex}` + ); await getPlaystateApi(api!).onPlaybackProgress({ itemId: item.Id, @@ -300,7 +299,7 @@ export default function page() { playSessionId: stream.sessionId, }); }, - [item?.Id, isPlaying, api, isPlaybackStopped] + [item?.Id, isPlaying, api, isPlaybackStopped, audioIndex, subtitleIndex] ); useOrientation(); diff --git a/app/(auth)/player/transcoding-player.tsx b/app/(auth)/player/transcoding-player.tsx index e2d23431..b72a9185 100644 --- a/app/(auth)/player/transcoding-player.tsx +++ b/app/(auth)/player/transcoding-player.tsx @@ -38,6 +38,7 @@ import Video, { SelectedTrackType, VideoRef, } from "react-native-video"; +import index from "../(tabs)/(home)"; const Player = () => { const api = useAtomValue(apiAtom); @@ -116,14 +117,7 @@ const Player = () => { isLoading: isLoadingStreamUrl, isError: isErrorStreamUrl, } = useQuery({ - queryKey: [ - "stream-url", - itemId, - audioIndex, - subtitleIndex, - bitrateValue, - mediaSourceId, - ], + queryKey: ["stream-url", itemId, bitrateValue, mediaSourceId], queryFn: async () => { if (!api) { @@ -263,6 +257,13 @@ const Player = () => { progress.value = ticks; cacheProgress.value = secondsToTicks(data.playableDuration); + console.log( + "onProgress ~", + ticks, + isPlaying, + `AUDIO index: ${audioIndex} SUB index" ${subtitleIndex}` + ); + // 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); @@ -326,23 +327,36 @@ const Player = () => { // Set intial Subtitle Track. // We will only select external tracks if they are are text based. Else it should be burned in already. - const textSubs = - stream?.mediaSource.MediaStreams?.filter( - (sub) => sub.Type === "Subtitle" && sub.IsTextSubtitleStream - ) || []; + // This function aims to get the embedded track index from the source subtitle index. + const getEmbeddedTrackIndex = (sourceSubtitleIndex: number) => { + const textSubs = + stream?.mediaSource.MediaStreams?.filter( + (sub) => sub.Type === "Subtitle" && sub.IsTextSubtitleStream + ) || []; + + // Get unique text-based subtitles because react-native-video removes hls text tracks duplicates. + const matchingSubtitle = textSubs.find( + (sub) => sub?.Index === sourceSubtitleIndex + ); + + if (!matchingSubtitle) return -1; + return textSubs.indexOf(matchingSubtitle); + }; - const uniqueTextSubs = Array.from( - new Set(textSubs.map((sub) => sub.DisplayTitle)) - ).map((title) => textSubs.find((sub) => sub.DisplayTitle === title)); - const chosenSubtitleTrack = textSubs.find( - (sub) => sub.Index === subtitleIndex - ); useEffect(() => { - if (chosenSubtitleTrack && selectedTextTrack === undefined) { - console.log("Setting selected text track", chosenSubtitleTrack); + if (selectedTextTrack === undefined) { + const embeddedTrackIndex = getEmbeddedTrackIndex(subtitleIndex!); + + // Most likely the subtitle is burned in. + if (embeddedTrackIndex === -1) return; + console.log( + "Setting selected text track", + subtitleIndex, + embeddedTrackIndex + ); setSelectedTextTrack({ type: SelectedTrackType.INDEX, - value: uniqueTextSubs.indexOf(chosenSubtitleTrack), + value: embeddedTrackIndex, }); } }, [embededTextTracks]); diff --git a/components/settings/AudioToggles.tsx b/components/settings/AudioToggles.tsx new file mode 100644 index 00000000..ec9d71ce --- /dev/null +++ b/components/settings/AudioToggles.tsx @@ -0,0 +1,114 @@ +import { TouchableOpacity, View, ViewProps } from "react-native"; +import * as DropdownMenu from "zeego/dropdown-menu"; +import { Text } from "../common/Text"; +import { useMedia } from "./MediaContext"; +import { Switch } from "react-native-gesture-handler"; + +interface Props extends ViewProps {} + +export const AudioToggles: React.FC = ({ ...props }) => { + const media = useMedia(); + const { settings, updateSettings } = media; + const cultures = media.cultures; + + if (!settings) return null; + + return ( + + Audio + + + + Audio language + + Choose a default audio language. + + + + + + + {settings?.defaultAudioLanguage?.DisplayName || "None"} + + + + + Languages + { + updateSettings({ + defaultAudioLanguage: null, + }); + }} + > + None + + {cultures?.map((l) => ( + { + updateSettings({ + defaultAudioLanguage: l, + }); + }} + > + + {l.DisplayName} + + + ))} + + + + + + + Use Default Audio + + Play default audio track regardless of language. + + + + updateSettings({ playDefaultAudioTrack: value }) + } + /> + + + + + + + Set Audio Track From Previous Item + + + Try to set the audio track to the closest match to the last + video. + + + + updateSettings({ rememberAudioSelections: value }) + } + /> + + + + + ); +}; diff --git a/components/settings/MediaContext.tsx b/components/settings/MediaContext.tsx new file mode 100644 index 00000000..68ebe1f7 --- /dev/null +++ b/components/settings/MediaContext.tsx @@ -0,0 +1,150 @@ +import { Settings, useSettings } from "@/utils/atoms/settings"; +import { useAtomValue } from "jotai"; +import React, { createContext, useContext, ReactNode, useEffect } from "react"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { getLocalizationApi, getUserApi } from "@jellyfin/sdk/lib/utils/api"; +import { + CultureDto, + UserDto, + UserConfiguration, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; + +interface MediaContextType { + settings: Settings | null; + updateSettings: (update: Partial) => void; + user: UserDto | undefined; + cultures: CultureDto[]; +} + +const MediaContext = createContext(undefined); + +export const useMedia = () => { + const context = useContext(MediaContext); + if (!context) { + throw new Error("useMedia must be used within a MediaProvider"); + } + return context; +}; + +export const MediaProvider = ({ children }: { children: ReactNode }) => { + const [settings, updateSettings] = useSettings(); + const api = useAtomValue(apiAtom); + const queryClient = useQueryClient(); + + const updateSetingsWrapper = (update: Partial) => { + const updateUserConfiguration = async ( + update: Partial + ) => { + if (api && user) { + try { + await getUserApi(api).updateUserConfiguration({ + userConfiguration: { + ...user.Configuration, + ...update, + }, + }); + queryClient.invalidateQueries({ queryKey: ["authUser"] }); + } catch (error) {} + } + }; + + updateSettings(update); + + console.log("update", update); + + let updatePayload = { + SubtitleMode: update?.subtitleMode ?? settings?.subtitleMode, + PlayDefaultAudioTrack: + update?.playDefaultAudioTrack ?? settings?.playDefaultAudioTrack, + RememberAudioSelections: + update?.rememberAudioSelections ?? settings?.rememberAudioSelections, + RememberSubtitleSelections: + update?.rememberSubtitleSelections ?? + settings?.rememberSubtitleSelections, + } as Partial; + + updatePayload.AudioLanguagePreference = + update?.defaultAudioLanguage === null + ? "" + : update?.defaultAudioLanguage?.ThreeLetterISOLanguageName || + settings?.defaultAudioLanguage?.ThreeLetterISOLanguageName || + ""; + + updatePayload.SubtitleLanguagePreference = + update?.defaultSubtitleLanguage === null + ? "" + : update?.defaultSubtitleLanguage?.ThreeLetterISOLanguageName || + settings?.defaultSubtitleLanguage?.ThreeLetterISOLanguageName || + ""; + + console.log("updatePayload", updatePayload); + + updateUserConfiguration(updatePayload); + }; + + const { data: user } = useQuery({ + queryKey: ["authUser"], + queryFn: async () => { + if (!api) return; + + const userApi = await getUserApi(api).getCurrentUser(); + return userApi.data; + }, + enabled: !!api, + staleTime: 0, + refetchOnMount: true, + }); + + const { data: cultures = [] } = useQuery({ + queryKey: ["cultures"], + queryFn: async () => { + if (!api) return []; + const localizationApi = await getLocalizationApi(api).getCultures(); + const cultures = localizationApi.data; + return cultures; + }, + enabled: !!api, + staleTime: 0, + refetchOnMount: true, + }); + + // Set default settings from user configuration.s + useEffect(() => { + const userSubtitlePreference = + user?.Configuration?.SubtitleLanguagePreference; + const userAudioPreference = user?.Configuration?.AudioLanguagePreference; + + const subtitlePreference = cultures.find( + (x) => x.ThreeLetterISOLanguageName === userSubtitlePreference + ); + const audioPreference = cultures.find( + (x) => x.ThreeLetterISOLanguageName === userAudioPreference + ); + + updateSettings({ + defaultSubtitleLanguage: subtitlePreference, + defaultAudioLanguage: audioPreference, + subtitleMode: user?.Configuration?.SubtitleMode, + playDefaultAudioTrack: user?.Configuration?.PlayDefaultAudioTrack, + rememberAudioSelections: user?.Configuration?.RememberAudioSelections, + rememberSubtitleSelections: + user?.Configuration?.RememberSubtitleSelections, + }); + }, [user, cultures]); + + if (!api) return null; + + return ( + + {children} + + ); +}; diff --git a/components/settings/MediaToggles.tsx b/components/settings/MediaToggles.tsx index 0d8f0f9e..c92902e2 100644 --- a/components/settings/MediaToggles.tsx +++ b/components/settings/MediaToggles.tsx @@ -1,9 +1,6 @@ import { useSettings } from "@/utils/atoms/settings"; import { TouchableOpacity, View, ViewProps } from "react-native"; -import * as DropdownMenu from "zeego/dropdown-menu"; import { Text } from "../common/Text"; -import { LANGUAGES } from "@/constants/Languages"; -import { TextInput } from "react-native-gesture-handler"; interface Props extends ViewProps {} @@ -16,152 +13,6 @@ export const MediaToggles: React.FC = ({ ...props }) => { Media - - - Audio language - - Choose a default audio language. - - - - - - {settings?.defaultAudioLanguage?.label || "None"} - - - - Languages - { - updateSettings({ - defaultAudioLanguage: null, - }); - }} - > - None - - {LANGUAGES.map((l) => ( - { - updateSettings({ - defaultAudioLanguage: l, - }); - }} - > - {l.label} - - ))} - - - - - - Subtitle language - - Choose a default subtitle language. - - - - - - - {settings?.defaultSubtitleLanguage?.label || "None"} - - - - - Languages - { - updateSettings({ - defaultSubtitleLanguage: null, - }); - }} - > - None - - {LANGUAGES.map((l) => ( - { - updateSettings({ - defaultSubtitleLanguage: l, - }); - }} - > - {l.label} - - ))} - - - - - - - Subtitle Size - - Choose a default subtitle size for direct play (only works for - some subtitle formats). - - - - - updateSettings({ - subtitleSize: Math.max(0, settings.subtitleSize - 5), - }) - } - className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center" - > - - - - - {settings.subtitleSize} - - - updateSettings({ - subtitleSize: Math.min(120, settings.subtitleSize + 5), - }) - } - > - + - - - - = ({ ...props }) => { */} - + + + + + Other @@ -409,19 +420,24 @@ export const SettingToggles: React.FC = ({ ...props }) => { Show Custom Menu Links - Show custom menu links defined inside your Jellyfin web config.json file + Show custom menu links defined inside your Jellyfin web + config.json file - Linking.openURL("https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links") + onPress={() => + Linking.openURL( + "https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links" + ) } > More info updateSettings({ showCustomMenuLinks: value })} + value={settings.showCustomMenuLinks} + onValueChange={(value) => + updateSettings({ showCustomMenuLinks: value }) + } /> @@ -491,15 +507,16 @@ export const SettingToggles: React.FC = ({ ...props }) => { className={` flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4 ${ - settings.downloadMethod === "remux" - ? "opacity-100" - : "opacity-50" - }`} + settings.downloadMethod === "remux" + ? "opacity-100" + : "opacity-50" + }`} > Remux max download - This is the total media you want to be able to download at the same time. + This is the total media you want to be able to download at the + same time. = ({ ...props }) => { step={1} min={1} max={4} - onUpdate={(value) => updateSettings({remuxConcurrentLimit: value as Settings["remuxConcurrentLimit"]})} + onUpdate={(value) => + updateSettings({ + remuxConcurrentLimit: + value as Settings["remuxConcurrentLimit"], + }) + } /> = ({ ...props }) => { className={` flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4 ${ - settings.downloadMethod === "optimized" - ? "opacity-100" - : "opacity-50" - }`} + settings.downloadMethod === "optimized" + ? "opacity-100" + : "opacity-50" + }`} > Auto download diff --git a/components/settings/SubtitleToggles.tsx b/components/settings/SubtitleToggles.tsx new file mode 100644 index 00000000..93745df2 --- /dev/null +++ b/components/settings/SubtitleToggles.tsx @@ -0,0 +1,191 @@ +import { TouchableOpacity, View, ViewProps } from "react-native"; +import * as DropdownMenu from "zeego/dropdown-menu"; +import { Text } from "../common/Text"; +import { useMedia } from "./MediaContext"; +import { Switch } from "react-native-gesture-handler"; +import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client"; + +interface Props extends ViewProps {} + +export const SubtitleToggles: React.FC = ({ ...props }) => { + const media = useMedia(); + const { settings, updateSettings } = media; + const cultures = media.cultures; + if (!settings) return null; + + const subtitleModes = [ + SubtitlePlaybackMode.Default, + SubtitlePlaybackMode.Smart, + SubtitlePlaybackMode.OnlyForced, + SubtitlePlaybackMode.Always, + SubtitlePlaybackMode.None, + ]; + + return ( + + Subtitle + + + + Subtitle language + + Choose a default subtitle language. + + + + + + + {settings?.defaultSubtitleLanguage?.DisplayName || "None"} + + + + + Languages + { + updateSettings({ + defaultSubtitleLanguage: null, + }); + }} + > + None + + {cultures?.map((l) => ( + { + updateSettings({ + defaultSubtitleLanguage: l, + }); + }} + > + + {l.DisplayName} + + + ))} + + + + + + + Subtitle Mode + + Subtitles are loaded based on the default and forced flags in the + embedded metadata. Language preferences are considered when + multiple options are available. + + + + + + {settings?.subtitleMode || "Loading"} + + + + Subtitle Mode + {subtitleModes?.map((l) => ( + { + updateSettings({ + subtitleMode: l, + }); + }} + > + {l} + + ))} + + + + + + + + + Set Subtitle Track From Previous Item + + + Try to set the subtitle track to the closest match to the last + video. + + + + updateSettings({ rememberSubtitleSelections: value }) + } + /> + + + + + + Subtitle Size + + Choose a default subtitle size for direct play (only works for + some subtitle formats). + + + + + updateSettings({ + subtitleSize: Math.max(0, settings.subtitleSize - 5), + }) + } + className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center" + > + - + + + {settings.subtitleSize} + + + updateSettings({ + subtitleSize: Math.min(120, settings.subtitleSize + 5), + }) + } + > + + + + + + + + ); +}; diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index a695473d..bedfeb31 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -10,7 +10,10 @@ import { } from "@/modules/vlc-player/src/VlcPlayer.types"; import { apiAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; -import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; +import { + getDefaultPlaySettings, + previousIndexes, +} from "@/utils/jellyfin/getDefaultPlaySettings"; import { getItemById } from "@/utils/jellyfin/user-library/getItemById"; import { writeToLog } from "@/utils/log"; import { @@ -129,8 +132,10 @@ export const Controls: React.FC = ({ const wasPlayingRef = useRef(false); const lastProgressRef = useRef(0); - const { bitrateValue } = useLocalSearchParams<{ + const { bitrateValue, subtitleIndex, audioIndex } = useLocalSearchParams<{ bitrateValue: string; + audioIndex: string; + subtitleIndex: string; }>(); const { showSkipButton, skipIntro } = useIntroSkipper( @@ -154,16 +159,27 @@ export const Controls: React.FC = ({ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - const { mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings( + const previousIndexes: previousIndexes = { + subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined, + audioIndex: audioIndex ? parseInt(audioIndex) : undefined, + }; + + const { + mediaSource: newMediaSource, + audioIndex: defaultAudioIndex, + subtitleIndex: defaultSubtitleIndex, + } = getDefaultPlaySettings( previousItem, - settings + settings, + previousIndexes, + mediaSource ?? undefined ); const queryParams = new URLSearchParams({ itemId: previousItem.Id ?? "", // Ensure itemId is a string - audioIndex: audioIndex?.toString() ?? "", - subtitleIndex: subtitleIndex?.toString() ?? "", - mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string + audioIndex: defaultAudioIndex?.toString() ?? "", + subtitleIndex: defaultSubtitleIndex?.toString() ?? "", + mediaSourceId: newMediaSource?.Id ?? "", // Ensure mediaSourceId is a string bitrateValue: bitrateValue.toString(), }).toString(); @@ -174,23 +190,34 @@ export const Controls: React.FC = ({ } // @ts-expect-error router.replace(`player/transcoding-player?${queryParams}`); - }, [previousItem, settings]); + }, [previousItem, settings, subtitleIndex, audioIndex]); const goToNextItem = useCallback(() => { if (!nextItem || !settings) return; Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - const { mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings( + const previousIndexes: previousIndexes = { + subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined, + audioIndex: audioIndex ? parseInt(audioIndex) : undefined, + }; + + const { + mediaSource: newMediaSource, + audioIndex: defaultAudioIndex, + subtitleIndex: defaultSubtitleIndex, + } = getDefaultPlaySettings( nextItem, - settings + settings, + previousIndexes, + mediaSource ?? undefined ); const queryParams = new URLSearchParams({ itemId: nextItem.Id ?? "", // Ensure itemId is a string - audioIndex: audioIndex?.toString() ?? "", - subtitleIndex: subtitleIndex?.toString() ?? "", - mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string + audioIndex: defaultAudioIndex?.toString() ?? "", + subtitleIndex: defaultSubtitleIndex?.toString() ?? "", + mediaSourceId: newMediaSource?.Id ?? "", // Ensure mediaSourceId is a string bitrateValue: bitrateValue.toString(), }).toString(); @@ -201,7 +228,7 @@ export const Controls: React.FC = ({ } // @ts-expect-error router.replace(`player/transcoding-player?${queryParams}`); - }, [nextItem, settings]); + }, [nextItem, settings, subtitleIndex, audioIndex]); const updateTimes = useCallback( (currentProgress: number, maxValue: number) => { @@ -409,32 +436,51 @@ export const Controls: React.FC = ({ if (isPlaying) togglePlay(); }; - const gotoEpisode = async (itemId: string) => { - const item = await getItemById(api, itemId); - console.log("Item", item); - if (!settings || !item) return; + const goToItem = useCallback( + async (itemId: string) => { + try { + const gotoItem = await getItemById(api, itemId); + if (!settings || !gotoItem) return; - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - const { bitrate, mediaSource, audioIndex, subtitleIndex } = - getDefaultPlaySettings(item, settings); + const previousIndexes: previousIndexes = { + subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined, + audioIndex: audioIndex ? parseInt(audioIndex) : undefined, + }; - 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: bitrate.toString(), - }).toString(); + const { + mediaSource: newMediaSource, + audioIndex: defaultAudioIndex, + subtitleIndex: defaultSubtitleIndex, + } = getDefaultPlaySettings( + gotoItem, + settings, + previousIndexes, + mediaSource ?? undefined + ); - if (!bitrate.value) { - // @ts-expect-error - router.replace(`player/direct-player?${queryParams}`); - return; - } - // @ts-expect-error - router.replace(`player/transcoding-player?${queryParams}`); - }; + const queryParams = new URLSearchParams({ + itemId: gotoItem.Id ?? "", // Ensure itemId is a string + audioIndex: defaultAudioIndex?.toString() ?? "", + subtitleIndex: defaultSubtitleIndex?.toString() ?? "", + mediaSourceId: newMediaSource?.Id ?? "", // Ensure mediaSourceId is a string + bitrateValue: bitrateValue.toString(), + }).toString(); + + if (!bitrateValue) { + // @ts-expect-error + router.replace(`player/direct-player?${queryParams}`); + return; + } + // @ts-expect-error + router.replace(`player/transcoding-player?${queryParams}`); + } catch (error) { + console.error("Error in gotoEpisode:", error); + } + }, + [settings, subtitleIndex, audioIndex] + ); // Used when user changes audio through audio button on device. const [showAudioSlider, setShowAudioSlider] = useState(false); @@ -446,7 +492,11 @@ export const Controls: React.FC = ({ isVideoLoaded={isVideoLoaded} > {EpisodeView ? ( - setEpisodeView(false)} /> + setEpisodeView(false)} + goToItem={goToItem} + /> ) : ( <> void; + goToItem: (itemId: string) => Promise; }; export const seasonIndexAtom = atom({}); -export const EpisodeList: React.FC = ({ item, close }) => { +export const EpisodeList: React.FC = ({ item, close, goToItem }) => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const insets = useSafeAreaInsets(); // Get safe area insets - const [settings] = useSettings(); const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom); const scrollViewRef = useRef(null); // Reference to the HorizontalScroll const scrollToIndex = (index: number) => { @@ -154,36 +150,6 @@ export const EpisodeList: React.FC = ({ item, close }) => { } }, [episodes, item.Id]); - const { bitrateValue } = useLocalSearchParams<{ - bitrateValue: string; - }>(); - - const gotoEpisode = async (itemId: string) => { - const item = await getItemById(api, itemId); - if (!settings || !item) return; - - const { mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings( - item, - settings - ); - - 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(); - - if (!bitrateValue) { - // @ts-expect-error - router.replace(`player/direct-player?${queryParams}`); - return; - } - // @ts-expect-error - router.replace(`player/transcoding-player?${queryParams}`); - }; - if (!episodes) { return ; } @@ -241,7 +207,7 @@ export const EpisodeList: React.FC = ({ item, close }) => { > { - gotoEpisode(_item.Id); + goToItem(_item.Id); }} > = ({ bitrateValue: string; }>(); - const [selectedSubtitleIndex, setSelectedSubtitleIndex] = useState( - parseInt(subtitleIndex) - ); - const [selectedAudioIndex, setSelectedAudioIndex] = useState( - parseInt(audioIndex) - ); - return ( = ({ {allSubtitleTracksForDirectPlay?.map((sub, idx: number) => ( { if ("deliveryUrl" in sub && sub.deliveryUrl) { setSubtitleURL && @@ -133,8 +126,9 @@ const DropdownViewDirect: React.FC = ({ console.log("Set sub index: ", sub.index); setSubtitleTrack && setSubtitleTrack(sub.index); } - - setSelectedSubtitleIndex(sub.index); + router.setParams({ + subtitleIndex: sub.index.toString(), + }); console.log("Subtitle: ", sub); }} > @@ -159,10 +153,12 @@ const DropdownViewDirect: React.FC = ({ {audioTracks?.map((track, idx: number) => ( { - setSelectedAudioIndex(track.index); setAudioTrack && setAudioTrack(track.index); + router.setParams({ + audioIndex: track.index.toString(), + }); }} > diff --git a/components/video-player/controls/dropdown/DropdownViewTranscoding.tsx b/components/video-player/controls/dropdown/DropdownViewTranscoding.tsx index 66e68450..a555616a 100644 --- a/components/video-player/controls/dropdown/DropdownViewTranscoding.tsx +++ b/components/video-player/controls/dropdown/DropdownViewTranscoding.tsx @@ -14,10 +14,7 @@ interface DropdownViewProps { offline?: boolean; // used to disable external subs for downloads } -const DropdownView: React.FC = ({ - showControls, - offline = false, -}) => { +const DropdownView: React.FC = ({ showControls }) => { const router = useRouter(); const api = useAtomValue(apiAtom); const ControlContext = useControlContext(); @@ -46,24 +43,6 @@ const DropdownView: React.FC = ({ mediaSource?.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? []; const textBasedSubs = allSubs.filter((x) => x.IsTextSubtitleStream); - // This is used in the case where it is transcoding stream. - const chosenSubtitle = textBasedSubs.find( - (x) => x.Index === parseInt(subtitleIndex) - ); - - let initialSubtitleIndex = -1; - if (!isOnTextSubtitle) { - initialSubtitleIndex = parseInt(subtitleIndex); - } else if (chosenSubtitle) { - initialSubtitleIndex = textBasedSubs.indexOf(chosenSubtitle); - } - - const [selectedSubtitleIndex, setSelectedSubtitleIndex] = - useState(initialSubtitleIndex); - const [selectedAudioIndex, setSelectedAudioIndex] = useState( - parseInt(audioIndex) - ); - const allSubtitleTracksForTranscodingStream = useMemo(() => { const disableSubtitle = { name: "Disable", @@ -78,38 +57,26 @@ const DropdownView: React.FC = ({ IsTextSubtitleStream: true, })) || []; - const imageSubtitles = allSubs - .filter((x) => !x.IsTextSubtitleStream) - .map( - (x) => - ({ - name: x.DisplayTitle!, - index: x.Index!, - IsTextSubtitleStream: x.IsTextSubtitleStream, - } as TranscodedSubtitle) - ); + console.log("textSubtitles", textSubtitles); - const textSubtitlesMap = new Map(textSubtitles.map((s) => [s.name, s])); - const imageSubtitlesMap = new Map(imageSubtitles.map((s) => [s.name, s])); + 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 (textSubtitles.length === 0) return disableSubtitle; + const textSubtitle = textSubtitles[textIndex]; + textIndex++; + return textSubtitle; + } else { + return { + name: sub.DisplayTitle!, + index: sub.Index!, + IsTextSubtitleStream: sub.IsTextSubtitleStream, + } as TranscodedSubtitle; + } + }); - const sortedSubtitles = Array.from( - new Set( - allSubs - .map((sub) => { - const displayTitle = sub.DisplayTitle ?? ""; - if (textSubtitlesMap.has(displayTitle)) { - return textSubtitlesMap.get(displayTitle); - } - if (imageSubtitlesMap.has(displayTitle)) { - return imageSubtitlesMap.get(displayTitle); - } - return null; - }) - .filter( - (subtitle): subtitle is TranscodedSubtitle => subtitle !== null - ) - ) - ); + console.log("sortedSubtitles", sortedSubtitles); return [disableSubtitle, ...sortedSubtitles]; } @@ -145,26 +112,24 @@ const DropdownView: React.FC = ({ name: x.DisplayTitle!, index: x.Index!, })) || []; - const ChangeTranscodingAudio = useCallback( - (audioIndex: number, currentSelectedSubtitleIndex: number) => { - let newSubtitleIndex: number; - if (!isOnTextSubtitle) { - newSubtitleIndex = parseInt(subtitleIndex); - } else if ( - currentSelectedSubtitleIndex >= 0 && - currentSelectedSubtitleIndex < textBasedSubs.length - ) { - console.log("setHere SubtitleIndex", currentSelectedSubtitleIndex); - newSubtitleIndex = textBasedSubs[currentSelectedSubtitleIndex].Index!; - console.log("newSubtitleIndex", newSubtitleIndex); - } else { - newSubtitleIndex = -1; - } + // 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. + const getSourceSubtitleIndex = (embeddedTrackIndex: number): number => { + // If we're not on text-based subtitles, return the embedded track index + if (!isOnTextSubtitle) { + return parseInt(subtitleIndex); + } + return textBasedSubs[embeddedTrackIndex]?.Index ?? -1; + }; + + const ChangeTranscodingAudio = useCallback( + (audioIndex: number) => { + console.log("ChangeTranscodingAudio", subtitleIndex, audioIndex); const queryParams = new URLSearchParams({ itemId: item.Id ?? "", // Ensure itemId is a string audioIndex: audioIndex?.toString() ?? "", - subtitleIndex: newSubtitleIndex?.toString() ?? "", + subtitleIndex: subtitleIndex?.toString() ?? "", mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string bitrateValue: bitrateValue, }).toString(); @@ -172,7 +137,7 @@ const DropdownView: React.FC = ({ // @ts-expect-error router.replace(`player/transcoding-player?${queryParams}`); }, - [mediaSource] + [mediaSource, subtitleIndex, audioIndex] ); return ( @@ -213,17 +178,35 @@ const DropdownView: React.FC = ({ {allSubtitleTracksForTranscodingStream?.map( (sub, idx: number) => ( { console.log("sub", sub); - if (selectedSubtitleIndex === sub?.index) return; - setSelectedSubtitleIndex(sub.index); + if ( + subtitleIndex === + (sub.IsTextSubtitleStream && isOnTextSubtitle + ? getSourceSubtitleIndex(sub.index).toString() + : sub?.index.toString()) + ) + return; + + router.setParams({ + subtitleIndex: getSourceSubtitleIndex( + sub.index + ).toString(), + }); + console.log("Got here"); + if (sub.IsTextSubtitleStream && isOnTextSubtitle) { setSubtitleTrack && setSubtitleTrack(sub.index); return; } - + console.log("ChangeTranscodingSubtitle", subtitleIndex); ChangeTranscodingSubtitle(sub.index); }} > @@ -249,11 +232,14 @@ const DropdownView: React.FC = ({ {allAudio?.map((track, idx: number) => ( { - if (selectedAudioIndex === track.index) return; - setSelectedAudioIndex(track.index); - ChangeTranscodingAudio(track.index, selectedSubtitleIndex); + if (audioIndex === track.index.toString()) return; + console.log("Setting audio track to: ", track.index); + router.setParams({ + audioIndex: track.index.toString(), + }); + ChangeTranscodingAudio(track.index); }} > diff --git a/hooks/useDefaultPlaySettings.ts b/hooks/useDefaultPlaySettings.ts index b13e1c43..5c2d9cc6 100644 --- a/hooks/useDefaultPlaySettings.ts +++ b/hooks/useDefaultPlaySettings.ts @@ -6,6 +6,7 @@ import { } from "@jellyfin/sdk/lib/generated-client"; import { useMemo } from "react"; +// Used only for intial play settings. const useDefaultPlaySettings = ( item: BaseItemDto, settings: Settings | null @@ -17,34 +18,23 @@ const useDefaultPlaySettings = ( // 2. Get default or preferred audio const defaultAudioIndex = mediaSource?.DefaultAudioStreamIndex; const preferedAudioIndex = mediaSource?.MediaStreams?.find( - (x) => x.Type === "Audio" && x.Language === settings?.defaultAudioLanguage + (x) => + x.Type === "Audio" && + x.Language === + settings?.defaultAudioLanguage?.ThreeLetterISOLanguageName )?.Index; const firstAudioIndex = mediaSource?.MediaStreams?.find( (x) => x.Type === "Audio" )?.Index; - // 3. Get default or preferred subtitle - const preferedSubtitleIndex = mediaSource?.MediaStreams?.find( - (x) => - x.Type === "Subtitle" && - x.Language === settings?.defaultSubtitleLanguage?.value - )?.Index; - - const defaultSubtitleIndex = mediaSource?.MediaStreams?.find( - (stream) => stream.Type === "Subtitle" && stream.IsDefault - )?.Index; - // 4. Get default bitrate const bitrate = BITRATES[0]; return { defaultAudioIndex: preferedAudioIndex || defaultAudioIndex || firstAudioIndex || undefined, - defaultSubtitleIndex: - preferedSubtitleIndex !== undefined - ? preferedSubtitleIndex - : defaultSubtitleIndex || undefined, + defaultSubtitleIndex: mediaSource?.DefaultSubtitleStreamIndex || -1, defaultMediaSource: mediaSource || undefined, defaultBitrate: bitrate || undefined, }; diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index ca825e87..66cece20 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -3,6 +3,10 @@ import { useEffect } from "react"; import * as ScreenOrientation from "expo-screen-orientation"; import { storage } from "../mmkv"; import { Platform } from "react-native"; +import { + CultureDto, + SubtitlePlaybackMode, +} from "@jellyfin/sdk/lib/generated-client"; export type DownloadQuality = "original" | "high" | "low"; @@ -66,8 +70,12 @@ export type Settings = { openInVLC?: boolean; downloadQuality?: DownloadOption; libraryOptions: LibraryOptions; - defaultSubtitleLanguage: DefaultLanguageOption | null; - defaultAudioLanguage: DefaultLanguageOption | null; + defaultAudioLanguage: CultureDto | null; + playDefaultAudioTrack: boolean; + rememberAudioSelections: boolean; + defaultSubtitleLanguage: CultureDto | null; + subtitleMode: SubtitlePlaybackMode; + rememberSubtitleSelections: boolean; showHomeTitles: boolean; defaultVideoOrientation: ScreenOrientation.OrientationLock; forwardSkipTime: number; @@ -99,7 +107,11 @@ const loadSettings = (): Settings => { showStats: true, }, defaultAudioLanguage: null, + playDefaultAudioTrack: true, + rememberAudioSelections: true, defaultSubtitleLanguage: null, + subtitleMode: SubtitlePlaybackMode.Default, + rememberSubtitleSelections: true, showHomeTitles: true, defaultVideoOrientation: ScreenOrientation.OrientationLock.DEFAULT, forwardSkipTime: 30, @@ -144,6 +156,7 @@ export const useSettings = () => { const updateSettings = (update: Partial) => { if (settings) { const newSettings = { ...settings, ...update }; + setSettings(newSettings); saveSettings(newSettings); } diff --git a/utils/jellyfin/getDefaultPlaySettings.ts b/utils/jellyfin/getDefaultPlaySettings.ts index d82b387f..2ef7cd68 100644 --- a/utils/jellyfin/getDefaultPlaySettings.ts +++ b/utils/jellyfin/getDefaultPlaySettings.ts @@ -4,7 +4,12 @@ import { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client"; -import { Settings } from "../atoms/settings"; +import { Settings, useSettings } from "../atoms/settings"; +import { + AudioStreamRanker, + StreamRanker, + SubtitleStreamRanker, +} from "../streamRanker"; interface PlaySettings { item: BaseItemDto; @@ -14,9 +19,22 @@ interface PlaySettings { subtitleIndex?: number | undefined; } +export interface previousIndexes { + audioIndex?: number; + subtitleIndex?: number; +} + +interface TrackOptions { + DefaultAudioStreamIndex: number | undefined; + DefaultSubtitleStreamIndex: number | undefined; +} + +// Used getting default values for the next player. export function getDefaultPlaySettings( item: BaseItemDto, - settings: Settings + settings: Settings, + previousIndexes?: previousIndexes, + previousSource?: MediaSourceInfo ): PlaySettings { if (item.Type === "Program") { return { @@ -35,19 +53,44 @@ export function getDefaultPlaySettings( // 2. Get default or preferred audio const defaultAudioIndex = mediaSource?.DefaultAudioStreamIndex; const preferedAudioIndex = mediaSource?.MediaStreams?.find( - (x) => x.Language === settings?.defaultAudioLanguage + (x) => x.Type === "Audio" && x.Language === settings?.defaultAudioLanguage )?.Index; const firstAudioIndex = mediaSource?.MediaStreams?.find( (x) => x.Type === "Audio" )?.Index; - // 3. Get default or preferred subtitle - const preferedSubtitleIndex = mediaSource?.MediaStreams?.find( - (x) => x.Language === settings?.defaultSubtitleLanguage?.value - )?.Index; - const defaultSubtitleIndex = mediaSource?.MediaStreams?.find( - (stream) => stream.Type === "Subtitle" && stream.IsDefault - )?.Index; + // We prefer the previous track over the default track. + let trackOptions: TrackOptions = { + DefaultAudioStreamIndex: defaultAudioIndex ?? -1, + DefaultSubtitleStreamIndex: mediaSource?.DefaultSubtitleStreamIndex ?? -1, + }; + + const mediaStreams = mediaSource?.MediaStreams ?? []; + if (settings?.rememberSubtitleSelections && previousIndexes) { + if (previousIndexes.subtitleIndex !== undefined && previousSource) { + const subtitleRanker = new SubtitleStreamRanker(); + const ranker = new StreamRanker(subtitleRanker); + ranker.rankStream( + previousIndexes.subtitleIndex, + previousSource, + mediaStreams, + trackOptions + ); + } + } + + if (settings?.rememberAudioSelections && previousIndexes) { + if (previousIndexes.audioIndex !== undefined && previousSource) { + const audioRanker = new AudioStreamRanker(); + const ranker = new StreamRanker(audioRanker); + ranker.rankStream( + previousIndexes.audioIndex, + previousSource, + mediaStreams, + trackOptions + ); + } + } // 4. Get default bitrate const bitrate = BITRATES.sort( @@ -58,7 +101,7 @@ export function getDefaultPlaySettings( item, bitrate, mediaSource, - audioIndex: preferedAudioIndex ?? defaultAudioIndex ?? firstAudioIndex, - subtitleIndex: preferedSubtitleIndex ?? defaultSubtitleIndex ?? -1, + audioIndex: trackOptions.DefaultAudioStreamIndex, + subtitleIndex: trackOptions.DefaultSubtitleStreamIndex, }; } diff --git a/utils/jellyfin/media/getStreamUrl.ts b/utils/jellyfin/media/getStreamUrl.ts index 0b3b853d..0250ebeb 100644 --- a/utils/jellyfin/media/getStreamUrl.ts +++ b/utils/jellyfin/media/getStreamUrl.ts @@ -109,7 +109,6 @@ export const getStreamUrl = async ({ if (item.MediaType === "Video") { 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. @@ -124,10 +123,7 @@ export const getStreamUrl = async ({ // Get the updated URL const transcodeUrl = urlObj.toString(); - console.log( - "Video has transcoding URL:", - `${transcodeUrl}` - ); + console.log("Video has transcoding URL:", `${transcodeUrl}`); return { url: transcodeUrl, sessionId: sessionId, diff --git a/utils/streamRanker.ts b/utils/streamRanker.ts new file mode 100644 index 00000000..665e57be --- /dev/null +++ b/utils/streamRanker.ts @@ -0,0 +1,147 @@ +import { + MediaSourceInfo, + MediaStream, +} from "@jellyfin/sdk/lib/generated-client"; + +abstract class StreamRankerStrategy { + abstract streamType: string; + + abstract rankStream( + prevIndex: number, + prevSource: MediaSourceInfo, + mediaStreams: MediaStream[], + trackOptions: any + ): void; + + protected rank( + prevIndex: number, + prevSource: MediaSourceInfo, + mediaStreams: MediaStream[], + trackOptions: any + ): void { + if (prevIndex == -1) { + console.debug(`AutoSet Subtitle - No Stream Set`); + trackOptions[`Default${this.streamType}StreamIndex`] = -1; + return; + } + + if (!prevSource.MediaStreams || !mediaStreams) { + console.debug(`AutoSet ${this.streamType} - No MediaStreams`); + return; + } + + let bestStreamIndex = null; + let bestStreamScore = 0; + + const prevStream = prevSource.MediaStreams[prevIndex]; + + if (!prevStream) { + console.debug(`AutoSet ${this.streamType} - No prevStream`); + return; + } + + console.debug( + `AutoSet ${this.streamType} - Previous was ${prevStream.Index} - ${prevStream.DisplayTitle}` + ); + + let prevRelIndex = 0; + for (const stream of prevSource.MediaStreams) { + if (stream.Type != this.streamType) continue; + + if (stream.Index == prevIndex) break; + + prevRelIndex += 1; + } + + let newRelIndex = 0; + for (const stream of mediaStreams) { + if (stream.Type != this.streamType) continue; + + let score = 0; + + if (prevStream.Codec == stream.Codec) score += 1; + if (prevRelIndex == newRelIndex) score += 1; + if ( + prevStream.DisplayTitle && + prevStream.DisplayTitle == stream.DisplayTitle + ) + score += 2; + if ( + prevStream.Language && + prevStream.Language != "und" && + prevStream.Language == stream.Language + ) + score += 2; + + console.debug( + `AutoSet ${this.streamType} - Score ${score} for ${stream.Index} - ${stream.DisplayTitle}` + ); + if (score > bestStreamScore && score >= 3) { + bestStreamScore = score; + bestStreamIndex = stream.Index; + } + + newRelIndex += 1; + } + + if (bestStreamIndex != null) { + console.debug( + `AutoSet ${this.streamType} - Using ${bestStreamIndex} score ${bestStreamScore}.` + ); + trackOptions[`Default${this.streamType}StreamIndex`] = bestStreamIndex; + } else { + console.debug( + `AutoSet ${this.streamType} - Threshold not met. Using default.` + ); + } + } +} + +class SubtitleStreamRanker extends StreamRankerStrategy { + streamType = "Subtitle"; + + rankStream( + prevIndex: number, + prevSource: MediaSourceInfo, + mediaStreams: MediaStream[], + trackOptions: any + ): void { + super.rank(prevIndex, prevSource, mediaStreams, trackOptions); + } +} + +class AudioStreamRanker extends StreamRankerStrategy { + streamType = "Audio"; + + rankStream( + prevIndex: number, + prevSource: MediaSourceInfo, + mediaStreams: MediaStream[], + trackOptions: any + ): void { + super.rank(prevIndex, prevSource, mediaStreams, trackOptions); + } +} + +class StreamRanker { + private strategy: StreamRankerStrategy; + + constructor(strategy: StreamRankerStrategy) { + this.strategy = strategy; + } + + setStrategy(strategy: StreamRankerStrategy) { + this.strategy = strategy; + } + + rankStream( + prevIndex: number, + prevSource: MediaSourceInfo, + mediaStreams: MediaStream[], + trackOptions: any + ) { + this.strategy.rankStream(prevIndex, prevSource, mediaStreams, trackOptions); + } +} + +export { StreamRanker, SubtitleStreamRanker, AudioStreamRanker };