From a62e5d24da7a8bb84897ad5faadb116e69b29220 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 12 Aug 2024 19:09:56 +0200 Subject: [PATCH 1/3] wip --- app/(auth)/items/[id]/page.tsx | 12 +++-- app/_layout.tsx | 2 +- app/login.tsx | 17 ++++++- components/AudioTrackSelector.tsx | 75 +++++++++++++++++++++++++++++++ components/BitrateSelector.tsx | 12 +++-- 5 files changed, 108 insertions(+), 10 deletions(-) create mode 100644 components/AudioTrackSelector.tsx diff --git a/app/(auth)/items/[id]/page.tsx b/app/(auth)/items/[id]/page.tsx index 4e85ec07..0abc1e4d 100644 --- a/app/(auth)/items/[id]/page.tsx +++ b/app/(auth)/items/[id]/page.tsx @@ -30,6 +30,7 @@ import { useCastDevice } from "react-native-google-cast"; import { chromecastProfile } from "@/utils/profiles/chromecast"; import ios12 from "@/utils/profiles/ios12"; import { currentlyPlayingItemAtom } from "@/components/CurrentlyPlayingBar"; +import { AudioTrackSelector } from "@/components/AudioTrackSelector"; const page: React.FC = () => { const local = useLocalSearchParams(); @@ -218,10 +219,13 @@ const page: React.FC = () => { {item.Overview} - setMaxBitrate(val)} - selected={maxBitrate} - /> + + setMaxBitrate(val)} + selected={maxBitrate} + /> + {}} selected={null} /> + diff --git a/app/_layout.tsx b/app/_layout.tsx index 7b07e5c1..f437aa0e 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -64,7 +64,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..5b702d9b --- /dev/null +++ b/components/AudioTrackSelector.tsx @@ -0,0 +1,75 @@ +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"; + +interface Props extends React.ComponentProps { + item: BaseItemDto; + onChange: (value: number) => void; + selected: number; +} + +export const AudioTrackSelector: React.FC = ({ + item, + onChange, + selected, + ...props +}) => { + console.log( + item.MediaSources?.[0].MediaStreams?.filter((x) => x.Type === "Audio"), + ); + + const audioStreams = useMemo( + () => + item.MediaSources?.[0].MediaStreams?.filter((x) => x.Type === "Audio"), + [item], + ); + + const selectedAudioSteam = useMemo( + () => audioStreams?.[selected], + [audioStreams, selected], + ); + + return ( + + + + + Bitrate + + + {selectedAudioSteam?.DisplayTitle} + + + + + + Bitrates + {audioStreams?.map((audio, index: number) => ( + { + onChange(index); + }} + > + + {audio.DisplayTitle} + + + ))} + + + + ); +}; diff --git a/components/BitrateSelector.tsx b/components/BitrateSelector.tsx index cd749795..e65b1064 100644 --- a/components/BitrateSelector.tsx +++ b/components/BitrateSelector.tsx @@ -27,14 +27,18 @@ 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 ( - + From d5ee79d74050a4f7dcb1d0f6040ad5be4ded1e7e Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 12 Aug 2024 19:38:17 +0200 Subject: [PATCH 2/3] wip --- app/(auth)/items/[id]/page.tsx | 8 +++++++- components/AudioTrackSelector.tsx | 18 ++++++++++-------- components/BitrateSelector.tsx | 2 +- utils/textTools.ts | 7 +++++++ 4 files changed, 25 insertions(+), 10 deletions(-) create mode 100644 utils/textTools.ts diff --git a/app/(auth)/items/[id]/page.tsx b/app/(auth)/items/[id]/page.tsx index 0abc1e4d..d2118d5e 100644 --- a/app/(auth)/items/[id]/page.tsx +++ b/app/(auth)/items/[id]/page.tsx @@ -46,6 +46,8 @@ const page: React.FC = () => { value: undefined, }); + const [selectedAudioStream, setSelectedAudioStream] = useState(0); + const { data: item, isLoading: l1 } = useQuery({ queryKey: ["item", id], queryFn: async () => @@ -224,7 +226,11 @@ const page: React.FC = () => { onChange={(val) => setMaxBitrate(val)} selected={maxBitrate} /> - {}} selected={null} /> + diff --git a/components/AudioTrackSelector.tsx b/components/AudioTrackSelector.tsx index 5b702d9b..84c777f4 100644 --- a/components/AudioTrackSelector.tsx +++ b/components/AudioTrackSelector.tsx @@ -5,6 +5,7 @@ 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; @@ -18,10 +19,6 @@ export const AudioTrackSelector: React.FC = ({ selected, ...props }) => { - console.log( - item.MediaSources?.[0].MediaStreams?.filter((x) => x.Type === "Audio"), - ); - const audioStreams = useMemo( () => item.MediaSources?.[0].MediaStreams?.filter((x) => x.Type === "Audio"), @@ -33,15 +30,20 @@ export const AudioTrackSelector: React.FC = ({ [audioStreams, selected], ); + useEffect(() => { + console.log(audioStreams, selected); + }, [audioStreams, selected]); return ( - Bitrate + Audio streams - - {selectedAudioSteam?.DisplayTitle} + + + {tc(selectedAudioSteam?.DisplayTitle, 13)} + @@ -55,7 +57,7 @@ export const AudioTrackSelector: React.FC = ({ collisionPadding={8} sideOffset={8} > - Bitrates + Audio streams {audioStreams?.map((audio, index: number) => ( = ({ Bitrate - + {BITRATES.find((b) => b.value === selected.value)?.key} 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; +}; From cc72186a80a572c42aa47117db52634ec11c1b6d Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 12 Aug 2024 22:24:51 +0200 Subject: [PATCH 3/3] feat: audio and subtitle picker --- app.json | 11 +--- app/(auth)/items/[id]/page.tsx | 26 ++++++-- components/AudioTrackSelector.tsx | 15 +++-- components/CurrentlyPlayingBar.tsx | 10 ++- components/SubtitleTrackSelector.tsx | 92 ++++++++++++++++++++++++++++ eas.json | 4 +- utils/jellyfin/media/getStreamUrl.ts | 6 ++ 7 files changed, 142 insertions(+), 22 deletions(-) create mode 100644 components/SubtitleTrackSelector.tsx diff --git a/app.json b/app.json index f38e93a5..11ecd837 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Streamyfin", "slug": "streamyfin", - "version": "0.2.1", + "version": "0.3.0", "orientation": "portrait", "icon": "./assets/images/icon.png", "scheme": "streamyfin", @@ -23,12 +23,7 @@ "bundleIdentifier": "com.fredrikburmester.streamyfin" }, "android": { - "jsEngine": "jsc", - "androidNavigationBar": { - "visible": true, - "barStyle": "dark-content", - "backgroundColor": "#000000" - }, + "jsEngine": "hermes", "adaptiveIcon": { "foregroundImage": "./assets/images/icon.png" }, @@ -37,7 +32,7 @@ "android.permission.FOREGROUND_SERVICE", "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" ], - "versionCode": 7 + "versionCode": 8 }, "web": { "bundler": "metro", diff --git a/app/(auth)/items/[id]/page.tsx b/app/(auth)/items/[id]/page.tsx index 7b8bb901..e69f0f7f 100644 --- a/app/(auth)/items/[id]/page.tsx +++ b/app/(auth)/items/[id]/page.tsx @@ -31,6 +31,7 @@ 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(); @@ -42,14 +43,14 @@ 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, }); - const [selectedAudioStream, setSelectedAudioStream] = useState(0); - const { data: item, isLoading: l1 } = useQuery({ queryKey: ["item", id], queryFn: async () => @@ -94,7 +95,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; @@ -106,8 +113,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, @@ -240,7 +251,7 @@ const page: React.FC = () => { {item.Overview} - + setMaxBitrate(val)} selected={maxBitrate} @@ -250,6 +261,11 @@ const page: React.FC = () => { onChange={setSelectedAudioStream} selected={selectedAudioStream} /> + diff --git a/components/AudioTrackSelector.tsx b/components/AudioTrackSelector.tsx index 84c777f4..3ee90937 100644 --- a/components/AudioTrackSelector.tsx +++ b/components/AudioTrackSelector.tsx @@ -26,13 +26,15 @@ export const AudioTrackSelector: React.FC = ({ ); const selectedAudioSteam = useMemo( - () => audioStreams?.[selected], + () => audioStreams?.find((x) => x.Index === selected), [audioStreams, selected], ); useEffect(() => { - console.log(audioStreams, selected); - }, [audioStreams, selected]); + const index = item.MediaSources?.[0].DefaultAudioStreamIndex; + if (index !== undefined && index !== null) onChange(index); + }, []); + return ( @@ -58,11 +60,12 @@ export const AudioTrackSelector: React.FC = ({ sideOffset={8} > Audio streams - {audioStreams?.map((audio, index: number) => ( + {audioStreams?.map((audio, idx: number) => ( { - onChange(index); + if (audio.Index !== null && audio.Index !== undefined) + onChange(audio.Index); }} > diff --git a/components/CurrentlyPlayingBar.tsx b/components/CurrentlyPlayingBar.tsx index 42244641..26fc6c27 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"; @@ -229,6 +234,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/eas.json b/eas.json index da4ca32b..754a0ec1 100644 --- a/eas.json +++ b/eas.json @@ -21,13 +21,13 @@ } }, "production": { - "channel": "0.2.1", + "channel": "0.3.0", "android": { "image": "latest" } }, "production-apk": { - "channel": "0.2.1", + "channel": "0.3.0", "android": { "buildType": "apk", "image": "latest" diff --git a/utils/jellyfin/media/getStreamUrl.ts b/utils/jellyfin/media/getStreamUrl.ts index fbc6d8bb..92c70ea2 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: {