mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-05 20:12:51 +01:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b206be6bcf | ||
|
|
656d4ba46b | ||
|
|
b1025c81ae | ||
|
|
b05b43c12e | ||
|
|
11f9d0fe33 | ||
|
|
0498f2e718 | ||
|
|
077f99fd46 | ||
|
|
cc72186a80 | ||
|
|
65837cd303 | ||
|
|
d5ee79d740 | ||
|
|
040ef3b79a | ||
|
|
07c0f81f36 | ||
|
|
a62e5d24da |
5
app.json
5
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.3.2",
|
"version": "0.3.4",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"versionCode": 10,
|
"versionCode": 12,
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"androidNavigationBar": {
|
"androidNavigationBar": {
|
||||||
"visible": true,
|
"visible": true,
|
||||||
@@ -61,6 +61,7 @@
|
|||||||
"react-native-video",
|
"react-native-video",
|
||||||
{
|
{
|
||||||
"enableNotificationControls": true,
|
"enableNotificationControls": true,
|
||||||
|
"enableBackgroundAudio": true,
|
||||||
"androidExtensions": {
|
"androidExtensions": {
|
||||||
"useExoplayerRtsp": false,
|
"useExoplayerRtsp": false,
|
||||||
"useExoplayerSmoothStreaming": false,
|
"useExoplayerSmoothStreaming": false,
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ import { Loading } from "@/components/Loading";
|
|||||||
import MoviePoster from "@/components/MoviePoster";
|
import MoviePoster from "@/components/MoviePoster";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import {
|
||||||
|
BaseItemDto,
|
||||||
|
ItemSortBy,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { router, useLocalSearchParams } from "expo-router";
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
@@ -23,16 +26,21 @@ const page: React.FC = () => {
|
|||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("CollectionId", collectionId);
|
||||||
|
}, [collectionId]);
|
||||||
|
|
||||||
const { data: collection } = useQuery({
|
const { data: collection } = useQuery({
|
||||||
queryKey: ["collection", collectionId],
|
queryKey: ["collection", collectionId],
|
||||||
queryFn: async () =>
|
queryFn: async () => {
|
||||||
(api &&
|
if (!api) return null;
|
||||||
(
|
const response = await getItemsApi(api).getItems({
|
||||||
await getItemsApi(api).getItems({
|
userId: user?.Id,
|
||||||
userId: user?.Id,
|
ids: [collectionId],
|
||||||
})
|
});
|
||||||
).data.Items?.find((item) => item.Id == collectionId)) ||
|
const data = response.data.Items?.[0];
|
||||||
null,
|
return data;
|
||||||
|
},
|
||||||
enabled: !!api && !!user?.Id,
|
enabled: !!api && !!user?.Id,
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
@@ -45,40 +53,84 @@ const page: React.FC = () => {
|
|||||||
}>({
|
}>({
|
||||||
queryKey: ["collection-items", collectionId, startIndex],
|
queryKey: ["collection-items", collectionId, startIndex],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api) return [];
|
if (!api || !collectionId)
|
||||||
|
return {
|
||||||
|
Items: [],
|
||||||
|
TotalRecordCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
const response = await api.axiosInstance.get(
|
const sortBy: ItemSortBy[] = [];
|
||||||
`${api.basePath}/Users/${user?.Id}/Items`,
|
|
||||||
{
|
|
||||||
params: {
|
|
||||||
SortBy:
|
|
||||||
collection?.CollectionType === "movies"
|
|
||||||
? "SortName,ProductionYear"
|
|
||||||
: "SortName",
|
|
||||||
SortOrder: "Ascending",
|
|
||||||
IncludeItemTypes:
|
|
||||||
collection?.CollectionType === "movies" ? "Movie" : "Series",
|
|
||||||
Recursive: true,
|
|
||||||
Fields:
|
|
||||||
collection?.CollectionType === "movies"
|
|
||||||
? "PrimaryImageAspectRatio,MediaSourceCount"
|
|
||||||
: "PrimaryImageAspectRatio",
|
|
||||||
ImageTypeLimit: 1,
|
|
||||||
EnableImageTypes: "Primary,Backdrop,Banner,Thumb",
|
|
||||||
ParentId: collectionId,
|
|
||||||
Limit: 100,
|
|
||||||
StartIndex: startIndex,
|
|
||||||
},
|
|
||||||
headers: {
|
|
||||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return response.data || [];
|
switch (collection?.CollectionType) {
|
||||||
|
case "movies":
|
||||||
|
sortBy.push("SortName", "ProductionYear");
|
||||||
|
break;
|
||||||
|
case "boxsets":
|
||||||
|
sortBy.push("IsFolder", "SortName");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
sortBy.push("SortName");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await getItemsApi(api).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: collectionId,
|
||||||
|
limit: 100,
|
||||||
|
startIndex,
|
||||||
|
sortBy,
|
||||||
|
sortOrder: ["Ascending"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = response.data.Items;
|
||||||
|
|
||||||
|
return {
|
||||||
|
Items: data || [],
|
||||||
|
TotalRecordCount: response.data.TotalRecordCount || 0,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
enabled: !!collection && !!api,
|
enabled: !!collectionId && !!api,
|
||||||
});
|
});
|
||||||
|
// const { data, isLoading, isError } = useQuery<{
|
||||||
|
// Items: BaseItemDto[];
|
||||||
|
// TotalRecordCount: number;
|
||||||
|
// }>({
|
||||||
|
// queryKey: ["collection-items", collectionId, startIndex],
|
||||||
|
// queryFn: async () => {
|
||||||
|
// if (!api) return [];
|
||||||
|
|
||||||
|
// const response = await api.axiosInstance.get(
|
||||||
|
// `${api.basePath}/Users/${user?.Id}/Items`,
|
||||||
|
// {
|
||||||
|
// params: {
|
||||||
|
// SortBy:
|
||||||
|
// collection?.CollectionType === "movies"
|
||||||
|
// ? "SortName,ProductionYear"
|
||||||
|
// : "SortName",
|
||||||
|
// SortOrder: "Ascending",
|
||||||
|
// IncludeItemTypes:
|
||||||
|
// collection?.CollectionType === "movies" ? "Movie" : "Series",
|
||||||
|
// Recursive: true,
|
||||||
|
// Fields:
|
||||||
|
// collection?.CollectionType === "movies"
|
||||||
|
// ? "PrimaryImageAspectRatio,MediaSourceCount"
|
||||||
|
// : "PrimaryImageAspectRatio",
|
||||||
|
// ImageTypeLimit: 1,
|
||||||
|
// EnableImageTypes: "Primary,Backdrop,Banner,Thumb",
|
||||||
|
// ParentId: collectionId,
|
||||||
|
// Limit: 100,
|
||||||
|
// StartIndex: startIndex,
|
||||||
|
// },
|
||||||
|
// headers: {
|
||||||
|
// Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// );
|
||||||
|
|
||||||
|
// return response.data || [];
|
||||||
|
// },
|
||||||
|
// enabled: !!collection && !!api,
|
||||||
|
// });
|
||||||
|
|
||||||
const totalItems = useMemo(() => {
|
const totalItems = useMemo(() => {
|
||||||
return data?.TotalRecordCount;
|
return data?.TotalRecordCount;
|
||||||
@@ -91,7 +143,8 @@ const page: React.FC = () => {
|
|||||||
<Text className="font-bold text-3xl mb-2">{collection?.Name}</Text>
|
<Text className="font-bold text-3xl mb-2">{collection?.Name}</Text>
|
||||||
<View className="flex flex-row items-center justify-between">
|
<View className="flex flex-row items-center justify-between">
|
||||||
<Text>
|
<Text>
|
||||||
{startIndex + 1}-{startIndex + 100} of {totalItems}
|
{startIndex + 1}-{Math.min(startIndex + 100, totalItems || 0)} of{" "}
|
||||||
|
{totalItems}
|
||||||
</Text>
|
</Text>
|
||||||
<View className="flex flex-row items-center space-x-2">
|
<View className="flex flex-row items-center space-x-2">
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -125,7 +178,7 @@ const page: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View className="flex flex-row flex-wrap">
|
<View className="flex flex-row flex-wrap">
|
||||||
{data?.Items?.map((item: any, index: number) => (
|
{data?.Items?.map((item: BaseItemDto, index: number) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={{
|
style={{
|
||||||
maxWidth: "33%",
|
maxWidth: "33%",
|
||||||
@@ -134,10 +187,12 @@ const page: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
key={index}
|
key={index}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (collection?.CollectionType === "movies") {
|
if (item?.Type === "Series") {
|
||||||
router.push(`/items/${item.Id}/page`);
|
|
||||||
} else if (collection?.CollectionType === "tvshows") {
|
|
||||||
router.push(`/series/${item.Id}/page`);
|
router.push(`/series/${item.Id}/page`);
|
||||||
|
} else if (item.IsFolder) {
|
||||||
|
router.push(`/collections/${item?.Id}/page`);
|
||||||
|
} else {
|
||||||
|
router.push(`/items/${item.Id}/page`);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -28,6 +33,9 @@ import Animated, {
|
|||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { useRouter, useSegments } from "expo-router";
|
import { useRouter, useSegments } from "expo-router";
|
||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
|
import { writeToLog } from "@/utils/log";
|
||||||
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
|
||||||
export const currentlyPlayingItemAtom = atom<{
|
export const currentlyPlayingItemAtom = atom<{
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -173,6 +181,17 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
|||||||
[item],
|
[item],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const backdropUrl = useMemo(
|
||||||
|
() =>
|
||||||
|
getBackdropUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
quality: 70,
|
||||||
|
width: 200,
|
||||||
|
}),
|
||||||
|
[item],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (cp?.playbackUrl) {
|
if (cp?.playbackUrl) {
|
||||||
play();
|
play();
|
||||||
@@ -203,32 +222,54 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
videoRef.current?.presentFullscreenPlayer();
|
videoRef.current?.presentFullscreenPlayer();
|
||||||
}}
|
}}
|
||||||
className="aspect-video h-full bg-neutral-800 rounded-md overflow-hidden"
|
className={`relative h-full bg-neutral-800 rounded-md overflow-hidden
|
||||||
|
${item?.Type === "Audio" ? "aspect-square" : "aspect-video"}
|
||||||
|
`}
|
||||||
>
|
>
|
||||||
{cp.playbackUrl && (
|
{cp.playbackUrl && (
|
||||||
<Video
|
<Video
|
||||||
|
ref={videoRef}
|
||||||
style={{ width: "100%", height: "100%" }}
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
allowsExternalPlayback={true}
|
||||||
|
playInBackground={true}
|
||||||
|
playWhenInactive={true}
|
||||||
|
showNotificationControls={true}
|
||||||
|
ignoreSilentSwitch="ignore"
|
||||||
|
controls={false}
|
||||||
|
poster={backdropUrl ? backdropUrl : undefined}
|
||||||
|
paused={paused}
|
||||||
|
onProgress={(e) => onProgress(e)}
|
||||||
|
subtitleStyle={{
|
||||||
|
fontSize: 16,
|
||||||
|
}}
|
||||||
source={{
|
source={{
|
||||||
uri: cp.playbackUrl,
|
uri: cp.playbackUrl,
|
||||||
isNetwork: true,
|
isNetwork: true,
|
||||||
startPosition,
|
startPosition,
|
||||||
}}
|
}}
|
||||||
controls={false}
|
|
||||||
ref={videoRef}
|
|
||||||
onBuffer={(e) =>
|
onBuffer={(e) =>
|
||||||
e.isBuffering ? console.log("Buffering...") : null
|
e.isBuffering ? console.log("Buffering...") : null
|
||||||
}
|
}
|
||||||
onProgress={(e) => onProgress(e)}
|
|
||||||
paused={paused}
|
|
||||||
onFullscreenPlayerDidDismiss={() => {
|
onFullscreenPlayerDidDismiss={() => {
|
||||||
play();
|
play();
|
||||||
}}
|
}}
|
||||||
ignoreSilentSwitch="ignore"
|
onError={(e) => {
|
||||||
|
console.log(e);
|
||||||
|
writeToLog(
|
||||||
|
"ERROR",
|
||||||
|
"Video playback error: " + JSON.stringify(e),
|
||||||
|
);
|
||||||
|
}}
|
||||||
renderLoader={
|
renderLoader={
|
||||||
<View className="flex flex-col items-center justify-center h-full">
|
item?.Type === "Video" && (
|
||||||
<ActivityIndicator size={"small"} color={"white"} />
|
<View className="flex flex-col items-center justify-center h-full">
|
||||||
</View>
|
<ActivityIndicator size={"small"} color={"white"} />
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
4
eas.json
4
eas.json
@@ -21,13 +21,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"channel": "0.3.1",
|
"channel": "0.3.4",
|
||||||
"android": {
|
"android": {
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
"channel": "0.3.1",
|
"channel": "0.3.4",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
|
|||||||
@@ -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: {
|
||||||
@@ -58,8 +64,28 @@ export const getStreamUrl = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (mediaSource.SupportsDirectPlay) {
|
if (mediaSource.SupportsDirectPlay) {
|
||||||
console.log("Using direct stream!");
|
if (item.MediaType === "Video") {
|
||||||
return `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData.PlaySessionId}&mediaSourceId=${itemId}&static=true`;
|
console.log("Using direct stream for video!");
|
||||||
|
return `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData.PlaySessionId}&mediaSourceId=${itemId}&static=true`;
|
||||||
|
} else if (item.MediaType === "Audio") {
|
||||||
|
console.log("Using direct stream for audio!");
|
||||||
|
const searchParams = new URLSearchParams({
|
||||||
|
UserId: userId,
|
||||||
|
DeviceId: api.deviceInfo.id,
|
||||||
|
MaxStreamingBitrate: "140000000",
|
||||||
|
Container:
|
||||||
|
"opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg",
|
||||||
|
TranscodingContainer: "mp4",
|
||||||
|
TranscodingProtocol: "hls",
|
||||||
|
AudioCodec: "aac",
|
||||||
|
api_key: api.accessToken,
|
||||||
|
PlaySessionId: sessionData.PlaySessionId,
|
||||||
|
StartTimeTicks: "0",
|
||||||
|
EnableRedirection: "true",
|
||||||
|
EnableRemoteMedia: "false",
|
||||||
|
});
|
||||||
|
return `${api.basePath}/Audio/${itemId}/universal?${searchParams.toString()}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Using transcoded stream!");
|
console.log("Using transcoded stream!");
|
||||||
|
|||||||
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