diff --git a/app/(auth)/items/[id]/page.tsx b/app/(auth)/items/[id]/page.tsx index 3528431d..05f5e430 100644 --- a/app/(auth)/items/[id]/page.tsx +++ b/app/(auth)/items/[id]/page.tsx @@ -34,6 +34,8 @@ import CastContext, { import { chromecastProfile } from "@/utils/profiles/chromecast"; import ios12 from "@/utils/profiles/ios12"; import { currentlyPlayingItemAtom } from "@/components/CurrentlyPlayingBar"; +import { AudioTrackSelector } from "@/components/AudioTrackSelector"; +import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector"; const page: React.FC = () => { const local = useLocalSearchParams(); @@ -45,7 +47,9 @@ const page: React.FC = () => { const castDevice = useCastDevice(); const chromecastReady = useMemo(() => !!castDevice?.deviceId, [castDevice]); - + const [selectedAudioStream, setSelectedAudioStream] = useState(-1); + const [selectedSubtitleStream, setSelectedSubtitleStream] = + useState(0); const [maxBitrate, setMaxBitrate] = useState({ key: "Max", value: undefined, @@ -95,7 +99,13 @@ const page: React.FC = () => { }); const { data: playbackUrl } = useQuery({ - queryKey: ["playbackUrl", item?.Id, maxBitrate, castDevice], + queryKey: [ + "playbackUrl", + item?.Id, + maxBitrate, + castDevice, + selectedAudioStream, + ], queryFn: async () => { if (!api || !user?.Id || !sessionData) return null; @@ -107,8 +117,12 @@ const page: React.FC = () => { maxStreamingBitrate: maxBitrate.value, sessionData, deviceProfile: castDevice?.deviceId ? chromecastProfile : ios12, + audioStreamIndex: selectedAudioStream, + subtitleStreamIndex: selectedSubtitleStream, }); + console.log("Transcode URL: ", url); + return url; }, enabled: !!sessionData, @@ -247,15 +261,23 @@ const page: React.FC = () => { {item.Overview} - setMaxBitrate(val)} - selected={maxBitrate} - /> - + + setMaxBitrate(val)} + selected={maxBitrate} + /> + + + + diff --git a/app/_layout.tsx b/app/_layout.tsx index a9f02950..0bcddda6 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -78,7 +78,7 @@ export default function RootLayout() { - + { const [api] = useAtom(apiAtom); const [serverURL, setServerURL] = useState(""); + const [error, setError] = useState(""); const [credentials, setCredentials] = useState<{ username: string; password: string; @@ -36,7 +38,18 @@ const Login: React.FC = () => { await login(credentials.username, credentials.password); } } catch (error) { - console.error(error); + const e = error as AxiosError | z.ZodError; + if (e instanceof z.ZodError) { + setError("An error occured."); + } else { + if (e.response?.status === 401) { + setError("Invalid credentials."); + } else { + setError( + "A network error occurred. Did you enter the correct server URL?", + ); + } + } } finally { setLoading(false); } @@ -122,6 +135,8 @@ const Login: React.FC = () => { /> + {error} + diff --git a/components/AudioTrackSelector.tsx b/components/AudioTrackSelector.tsx new file mode 100644 index 00000000..3ee90937 --- /dev/null +++ b/components/AudioTrackSelector.tsx @@ -0,0 +1,80 @@ +import { TouchableOpacity, View } from "react-native"; +import * as DropdownMenu from "zeego/dropdown-menu"; +import { Text } from "./common/Text"; +import { atom, useAtom } from "jotai"; +import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { useEffect, useMemo } from "react"; +import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models"; +import { tc } from "@/utils/textTools"; + +interface Props extends React.ComponentProps { + item: BaseItemDto; + onChange: (value: number) => void; + selected: number; +} + +export const AudioTrackSelector: React.FC = ({ + item, + onChange, + selected, + ...props +}) => { + const audioStreams = useMemo( + () => + item.MediaSources?.[0].MediaStreams?.filter((x) => x.Type === "Audio"), + [item], + ); + + const selectedAudioSteam = useMemo( + () => audioStreams?.find((x) => x.Index === selected), + [audioStreams, selected], + ); + + useEffect(() => { + const index = item.MediaSources?.[0].DefaultAudioStreamIndex; + if (index !== undefined && index !== null) onChange(index); + }, []); + + return ( + + + + + Audio streams + + + + {tc(selectedAudioSteam?.DisplayTitle, 13)} + + + + + + + Audio streams + {audioStreams?.map((audio, idx: number) => ( + { + if (audio.Index !== null && audio.Index !== undefined) + onChange(audio.Index); + }} + > + + {audio.DisplayTitle} + + + ))} + + + + ); +}; diff --git a/components/BitrateSelector.tsx b/components/BitrateSelector.tsx index cd749795..3c68143a 100644 --- a/components/BitrateSelector.tsx +++ b/components/BitrateSelector.tsx @@ -27,20 +27,24 @@ const BITRATES: Bitrate[] = [ }, ]; -type Props = { +interface Props extends React.ComponentProps { onChange: (value: Bitrate) => void; selected: Bitrate; -}; +} -export const BitrateSelector: React.FC = ({ onChange, selected }) => { +export const BitrateSelector: React.FC = ({ + onChange, + selected, + ...props +}) => { return ( - + Bitrate - + {BITRATES.find((b) => b.value === selected.value)?.key} diff --git a/components/CurrentlyPlayingBar.tsx b/components/CurrentlyPlayingBar.tsx index 966191b7..162b4a69 100644 --- a/components/CurrentlyPlayingBar.tsx +++ b/components/CurrentlyPlayingBar.tsx @@ -8,7 +8,12 @@ import { Text } from "./common/Text"; import { Ionicons } from "@expo/vector-icons"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import Video, { OnProgressData, VideoRef } from "react-native-video"; +import Video, { + OnProgressData, + SelectedTrack, + SelectedTrackType, + VideoRef, +} from "react-native-video"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { atom, useAtom } from "jotai"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; @@ -262,6 +267,9 @@ export const CurrentlyPlayingBar: React.FC = () => { ) } + subtitleStyle={{ + fontSize: 20, + }} /> )} diff --git a/components/SubtitleTrackSelector.tsx b/components/SubtitleTrackSelector.tsx new file mode 100644 index 00000000..6531490e --- /dev/null +++ b/components/SubtitleTrackSelector.tsx @@ -0,0 +1,92 @@ +import { TouchableOpacity, View } from "react-native"; +import * as DropdownMenu from "zeego/dropdown-menu"; +import { Text } from "./common/Text"; +import { atom, useAtom } from "jotai"; +import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { useEffect, useMemo } from "react"; +import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models"; +import { tc } from "@/utils/textTools"; + +interface Props extends React.ComponentProps { + item: BaseItemDto; + onChange: (value: number) => void; + selected: number; +} + +export const SubtitleTrackSelector: React.FC = ({ + item, + onChange, + selected, + ...props +}) => { + const subtitleStreams = useMemo( + () => + item.MediaSources?.[0].MediaStreams?.filter( + (x) => x.Type === "Subtitle", + ) ?? [], + [item], + ); + + const selectedSubtitleSteam = useMemo( + () => subtitleStreams.find((x) => x.Index === selected), + [subtitleStreams, selected], + ); + + useEffect(() => { + const index = item.MediaSources?.[0].DefaultSubtitleStreamIndex; + if (index !== undefined && index !== null) { + onChange(index); + } else { + // Get first subtitle stream + const firstSubtitle = subtitleStreams.find((x) => x.Index !== undefined); + if (firstSubtitle?.Index !== undefined) { + onChange(firstSubtitle.Index); + } + } + }, []); + + if (subtitleStreams.length === 0) return null; + + return ( + + + + + Subtitles + + + + {tc(selectedSubtitleSteam?.DisplayTitle, 13)} + + + + + + + Subtitles + {subtitleStreams?.map((subtitle, idx: number) => ( + { + if (subtitle.Index !== undefined && subtitle.Index !== null) + onChange(subtitle.Index); + }} + > + + {subtitle.DisplayTitle} + + + ))} + + + + ); +}; diff --git a/utils/jellyfin/media/getStreamUrl.ts b/utils/jellyfin/media/getStreamUrl.ts index 5df9f718..97954ee2 100644 --- a/utils/jellyfin/media/getStreamUrl.ts +++ b/utils/jellyfin/media/getStreamUrl.ts @@ -14,6 +14,8 @@ export const getStreamUrl = async ({ maxStreamingBitrate, sessionData, deviceProfile = ios12, + audioStreamIndex = 0, + subtitleStreamIndex = 0, }: { api: Api | null | undefined; item: BaseItemDto | null | undefined; @@ -22,6 +24,8 @@ export const getStreamUrl = async ({ maxStreamingBitrate?: number; sessionData: PlaybackInfoResponse; deviceProfile: any; + audioStreamIndex?: number; + subtitleStreamIndex?: number; }) => { if (!api || !userId || !item?.Id) { return null; @@ -40,6 +44,8 @@ export const getStreamUrl = async ({ AutoOpenLiveStream: true, MediaSourceId: itemId, AllowVideoStreamCopy: maxStreamingBitrate ? false : true, + AudioStreamIndex: audioStreamIndex, + SubtitleStreamIndex: subtitleStreamIndex, }, { headers: { diff --git a/utils/textTools.ts b/utils/textTools.ts new file mode 100644 index 00000000..ce12b61b --- /dev/null +++ b/utils/textTools.ts @@ -0,0 +1,7 @@ +/* + * Truncate a text longer than a certain length + */ +export const tc = (text: string | null | undefined, length: number = 20) => { + if (!text) return ""; + return text.length > length ? text.substr(0, length) + "..." : text; +};