mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-01 11:38:26 +01:00
Merge branch 'feat/audio-select'
This commit is contained in:
@@ -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 ">
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
80
components/AudioTrackSelector.tsx
Normal file
80
components/AudioTrackSelector.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
92
components/SubtitleTrackSelector.tsx
Normal file
92
components/SubtitleTrackSelector.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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
7
utils/textTools.ts
Normal 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;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user