Merge branch 'feat/audio-select'

This commit is contained in:
Fredrik Burmester
2024-08-13 13:27:09 +02:00
9 changed files with 253 additions and 19 deletions

View File

@@ -34,6 +34,8 @@ import CastContext, {
import { chromecastProfile } from "@/utils/profiles/chromecast"; import { chromecastProfile } from "@/utils/profiles/chromecast";
import ios12 from "@/utils/profiles/ios12"; import ios12 from "@/utils/profiles/ios12";
import { currentlyPlayingItemAtom } from "@/components/CurrentlyPlayingBar"; import { currentlyPlayingItemAtom } from "@/components/CurrentlyPlayingBar";
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
const page: React.FC = () => { const page: React.FC = () => {
const local = useLocalSearchParams(); const local = useLocalSearchParams();
@@ -45,7 +47,9 @@ const page: React.FC = () => {
const castDevice = useCastDevice(); const castDevice = useCastDevice();
const chromecastReady = useMemo(() => !!castDevice?.deviceId, [castDevice]); const chromecastReady = useMemo(() => !!castDevice?.deviceId, [castDevice]);
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] =
useState<number>(0);
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({ const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
key: "Max", key: "Max",
value: undefined, value: undefined,
@@ -95,7 +99,13 @@ const page: React.FC = () => {
}); });
const { data: playbackUrl } = useQuery({ const { data: playbackUrl } = useQuery({
queryKey: ["playbackUrl", item?.Id, maxBitrate, castDevice], queryKey: [
"playbackUrl",
item?.Id,
maxBitrate,
castDevice,
selectedAudioStream,
],
queryFn: async () => { queryFn: async () => {
if (!api || !user?.Id || !sessionData) return null; if (!api || !user?.Id || !sessionData) return null;
@@ -107,8 +117,12 @@ const page: React.FC = () => {
maxStreamingBitrate: maxBitrate.value, maxStreamingBitrate: maxBitrate.value,
sessionData, sessionData,
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios12, deviceProfile: castDevice?.deviceId ? chromecastProfile : ios12,
audioStreamIndex: selectedAudioStream,
subtitleStreamIndex: selectedSubtitleStream,
}); });
console.log("Transcode URL: ", url);
return url; return url;
}, },
enabled: !!sessionData, enabled: !!sessionData,
@@ -247,15 +261,23 @@ const page: React.FC = () => {
<Text>{item.Overview}</Text> <Text>{item.Overview}</Text>
</View> </View>
<View className="flex flex-col p-4"> <View className="flex flex-col p-4">
<BitrateSelector <View className="flex flex-row items-center space-x-2 w-full">
onChange={(val) => setMaxBitrate(val)} <BitrateSelector
selected={maxBitrate} onChange={(val) => setMaxBitrate(val)}
/> selected={maxBitrate}
<PlayButton />
item={item} <AudioTrackSelector
chromecastReady={chromecastReady} item={item}
onPress={onPressPlay} onChange={setSelectedAudioStream}
/> selected={selectedAudioStream}
/>
<SubtitleTrackSelector
item={item}
onChange={setSelectedSubtitleStream}
selected={selectedSubtitleStream}
/>
</View>
<PlayButton item={item} chromecastReady={false} onPress={onPressPlay} />
</View> </View>
<ScrollView horizontal className="flex px-4 mb-4"> <ScrollView horizontal className="flex px-4 mb-4">
<View className="flex flex-row space-x-2 "> <View className="flex flex-row space-x-2 ">

View File

@@ -78,7 +78,7 @@ export default function RootLayout() {
<JellyfinProvider> <JellyfinProvider>
<StatusBar style="light" backgroundColor="#000" /> <StatusBar style="light" backgroundColor="#000" />
<ThemeProvider value={DarkTheme}> <ThemeProvider value={DarkTheme}>
<Stack screenOptions={{}}> <Stack>
<Stack.Screen <Stack.Screen
name="(auth)/(tabs)" name="(auth)/(tabs)"
options={{ options={{

View File

@@ -3,6 +3,7 @@ import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider"; import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { AxiosError } from "axios";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useMemo, useState } from "react"; import React, { useMemo, useState } from "react";
import { KeyboardAvoidingView, Platform, View } from "react-native"; import { KeyboardAvoidingView, Platform, View } from "react-native";
@@ -18,6 +19,7 @@ const Login: React.FC = () => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [serverURL, setServerURL] = useState<string>(""); const [serverURL, setServerURL] = useState<string>("");
const [error, setError] = useState<string>("");
const [credentials, setCredentials] = useState<{ const [credentials, setCredentials] = useState<{
username: string; username: string;
password: string; password: string;
@@ -36,7 +38,18 @@ const Login: React.FC = () => {
await login(credentials.username, credentials.password); await login(credentials.username, credentials.password);
} }
} catch (error) { } 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 { } finally {
setLoading(false); setLoading(false);
} }
@@ -122,6 +135,8 @@ const Login: React.FC = () => {
/> />
</View> </View>
<Text className="text-red-600 mb-2">{error}</Text>
<Button onPress={handleLogin} loading={loading}> <Button onPress={handleLogin} loading={loading}>
Log in Log in
</Button> </Button>

View File

@@ -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<typeof View> {
item: BaseItemDto;
onChange: (value: number) => void;
selected: number;
}
export const AudioTrackSelector: React.FC<Props> = ({
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 (
<View className="flex flex-row items-center justify-between" {...props}>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col mb-2">
<Text className="opacity-50 mb-1 text-xs">Audio streams</Text>
<View className="flex flex-row">
<TouchableOpacity className="bg-neutral-900 max-w-32 h-12 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text className="">
{tc(selectedAudioSteam?.DisplayTitle, 13)}
</Text>
</TouchableOpacity>
</View>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Audio streams</DropdownMenu.Label>
{audioStreams?.map((audio, idx: number) => (
<DropdownMenu.Item
key={idx.toString()}
onSelect={() => {
if (audio.Index !== null && audio.Index !== undefined)
onChange(audio.Index);
}}
>
<DropdownMenu.ItemTitle>
{audio.DisplayTitle}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
);
};

View File

@@ -27,20 +27,24 @@ const BITRATES: Bitrate[] = [
}, },
]; ];
type Props = { interface Props extends React.ComponentProps<typeof View> {
onChange: (value: Bitrate) => void; onChange: (value: Bitrate) => void;
selected: Bitrate; selected: Bitrate;
}; }
export const BitrateSelector: React.FC<Props> = ({ onChange, selected }) => { export const BitrateSelector: React.FC<Props> = ({
onChange,
selected,
...props
}) => {
return ( return (
<View className="flex flex-row items-center justify-between"> <View className="flex flex-row items-center justify-between" {...props}>
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<View className="flex flex-col mb-2"> <View className="flex flex-col mb-2">
<Text className="opacity-50 mb-1 text-xs">Bitrate</Text> <Text className="opacity-50 mb-1 text-xs">Bitrate</Text>
<View className="flex flex-row"> <View className="flex flex-row">
<TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between"> <TouchableOpacity className="bg-neutral-900 h-12 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text> <Text>
{BITRATES.find((b) => b.value === selected.value)?.key} {BITRATES.find((b) => b.value === selected.value)?.key}
</Text> </Text>

View File

@@ -8,7 +8,12 @@ import { Text } from "./common/Text";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSafeAreaInsets } from "react-native-safe-area-context"; 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 { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -262,6 +267,9 @@ export const CurrentlyPlayingBar: React.FC = () => {
</View> </View>
) )
} }
subtitleStyle={{
fontSize: 20,
}}
/> />
)} )}
</TouchableOpacity> </TouchableOpacity>

View File

@@ -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<typeof View> {
item: BaseItemDto;
onChange: (value: number) => void;
selected: number;
}
export const SubtitleTrackSelector: React.FC<Props> = ({
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 (
<View className="flex flex-row items-center justify-between" {...props}>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col mb-2">
<Text className="opacity-50 mb-1 text-xs">Subtitles</Text>
<View className="flex flex-row">
<TouchableOpacity className="bg-neutral-900 max-w-32 h-12 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text className="">
{tc(selectedSubtitleSteam?.DisplayTitle, 13)}
</Text>
</TouchableOpacity>
</View>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Subtitles</DropdownMenu.Label>
{subtitleStreams?.map((subtitle, idx: number) => (
<DropdownMenu.Item
key={idx.toString()}
onSelect={() => {
if (subtitle.Index !== undefined && subtitle.Index !== null)
onChange(subtitle.Index);
}}
>
<DropdownMenu.ItemTitle>
{subtitle.DisplayTitle}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
);
};

View File

@@ -14,6 +14,8 @@ export const getStreamUrl = async ({
maxStreamingBitrate, maxStreamingBitrate,
sessionData, sessionData,
deviceProfile = ios12, deviceProfile = ios12,
audioStreamIndex = 0,
subtitleStreamIndex = 0,
}: { }: {
api: Api | null | undefined; api: Api | null | undefined;
item: BaseItemDto | null | undefined; item: BaseItemDto | null | undefined;
@@ -22,6 +24,8 @@ export const getStreamUrl = async ({
maxStreamingBitrate?: number; maxStreamingBitrate?: number;
sessionData: PlaybackInfoResponse; sessionData: PlaybackInfoResponse;
deviceProfile: any; deviceProfile: any;
audioStreamIndex?: number;
subtitleStreamIndex?: number;
}) => { }) => {
if (!api || !userId || !item?.Id) { if (!api || !userId || !item?.Id) {
return null; return null;
@@ -40,6 +44,8 @@ export const getStreamUrl = async ({
AutoOpenLiveStream: true, AutoOpenLiveStream: true,
MediaSourceId: itemId, MediaSourceId: itemId,
AllowVideoStreamCopy: maxStreamingBitrate ? false : true, AllowVideoStreamCopy: maxStreamingBitrate ? false : true,
AudioStreamIndex: audioStreamIndex,
SubtitleStreamIndex: subtitleStreamIndex,
}, },
{ {
headers: { headers: {

7
utils/textTools.ts Normal file
View File

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