Compare commits

..

2 Commits

Author SHA1 Message Date
Alex Kim
017bd4d074 Fixed file paths in the controls directory 2025-02-16 14:06:30 +11:00
herrrta
8b3141dfc6 fix: IOS video player black screens
- restores player view when re-entering apps foreground
- added logger
2025-02-15 15:16:25 -05:00
42 changed files with 1060 additions and 2915 deletions

View File

@@ -1,39 +0,0 @@
name: Handle Stale Issues
on:
schedule:
- cron: "30 1 * * *" # Runs at 1:30 UTC every day
jobs:
stale:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v9
with:
# Issue specific settings
days-before-issue-stale: 90
days-before-issue-close: 7
stale-issue-label: "stale"
stale-issue-message: |
This issue has been automatically marked as stale because it has had no activity in the last 30 days.
If this issue is still relevant, please leave a comment to keep it open.
Otherwise, it will be closed in 7 days if no further activity occurs.
Thank you for your contributions!
close-issue-message: |
This issue has been automatically closed because it has been inactive for 7 days since being marked as stale.
If you believe this issue is still relevant, please feel free to reopen it and add a comment explaining the current status.
# Pull request settings (disabled)
days-before-pr-stale: -1
days-before-pr-close: -1
# Other settings
repo-token: ${{ secrets.GITHUB_TOKEN }}
operations-per-run: 100
exempt-issue-labels: "Roadmap v1,help needed,enhancement"

3
.gitignore vendored
View File

@@ -41,5 +41,4 @@ credentials.json
.vscode/
.idea/
.ruby-lsp
modules/hls-downloader/android/build
.ruby-lsp

View File

@@ -85,9 +85,9 @@ We welcome any help to make Streamyfin better. If you'd like to contribute, plea
1. Use node `>20`
2. Install dependencies `bun i && bun run submodule-reload`
3. Make sure you have xcode and/or android studio installed. (follow the guides for expo: https://docs.expo.dev/workflow/android-studio-emulator/)
3. Make sure you have xcode and/or android studio installed.
4. run `npm run prebuild`
5. Create an expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app.
5. Create an expo dev build by running `npm run ios` or `nom run android`. This will open a simulator on your computer and run the app.
For the TV version suffix the npm commands with `:tv`.

View File

@@ -1,5 +1,498 @@
import { SettingsIndex } from "@/components/settings/SettingsIndex";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
import { Loader } from "@/components/Loader";
import { MediaListSection } from "@/components/medialists/MediaListSection";
import { Colors } from "@/constants/Colors";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { Feather, Ionicons } from "@expo/vector-icons";
import { Api } from "@jellyfin/sdk";
import {
BaseItemDto,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getItemsApi,
getSuggestionsApi,
getTvShowsApi,
getUserLibraryApi,
getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api";
import NetInfo from "@react-native-community/netinfo";
import { QueryFunction, useQuery } from "@tanstack/react-query";
import { useNavigation, useRouter } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Platform } from "react-native";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
RefreshControl,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
useSplashScreenLoading,
useSplashScreenVisible,
} from "@/providers/SplashScreenProvider";
export default function page() {
return <SettingsIndex />;
type ScrollingCollectionListSection = {
type: "ScrollingCollectionList";
title?: string;
queryKey: (string | undefined | null)[];
queryFn: QueryFunction<BaseItemDto[]>;
orientation?: "horizontal" | "vertical";
};
type MediaListSection = {
type: "MediaListSection";
queryKey: (string | undefined)[];
queryFn: QueryFunction<BaseItemDto>;
};
type Section = ScrollingCollectionListSection | MediaListSection;
export default function index() {
const router = useRouter();
const { t } = useTranslation();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const [loading, setLoading] = useState(false);
const [
settings,
updateSettings,
pluginSettings,
setPluginSettings,
refreshStreamyfinPluginSettings,
] = useSettings();
const [isConnected, setIsConnected] = useState<boolean | null>(null);
const [loadingRetry, setLoadingRetry] = useState(false);
const navigation = useNavigation();
const insets = useSafeAreaInsets();
if (!Platform.isTV) {
const { downloadedFiles, cleanCacheDirectory } = useDownload();
useEffect(() => {
const hasDownloads = downloadedFiles && downloadedFiles.length > 0;
navigation.setOptions({
headerLeft: () => (
<TouchableOpacity
onPress={() => {
router.push("/(auth)/downloads");
}}
className="p-2"
>
<Feather
name="download"
color={hasDownloads ? Colors.primary : "white"}
size={22}
/>
</TouchableOpacity>
),
});
}, [downloadedFiles, navigation, router]);
useEffect(() => {
cleanCacheDirectory().catch((e) =>
console.error("Something went wrong cleaning cache directory")
);
}, []);
}
const checkConnection = useCallback(async () => {
setLoadingRetry(true);
const state = await NetInfo.fetch();
setIsConnected(state.isConnected);
setLoadingRetry(false);
}, []);
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
if (state.isConnected == false || state.isInternetReachable === false)
setIsConnected(false);
else setIsConnected(true);
});
NetInfo.fetch().then((state) => {
setIsConnected(state.isConnected);
});
// cleanCacheDirectory().catch((e) =>
// console.error("Something went wrong cleaning cache directory")
// );
return () => {
unsubscribe();
};
}, []);
const {
data,
isError: e1,
isLoading: l1,
} = useQuery({
queryKey: ["home", "userViews", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) {
return null;
}
const response = await getUserViewsApi(api).getUserViews({
userId: user.Id,
});
return response.data.Items || null;
},
enabled: !!api && !!user?.Id,
staleTime: 60 * 1000,
});
// show splash screen until query loaded
useSplashScreenLoading(l1);
const splashScreenVisible = useSplashScreenVisible();
const userViews = useMemo(
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
[data, settings?.hiddenLibraries]
);
const collections = useMemo(() => {
const allow = ["movies", "tvshows"];
return (
userViews?.filter(
(c) => c.CollectionType && allow.includes(c.CollectionType)
) || []
);
}, [userViews]);
const invalidateCache = useInvalidatePlaybackProgressCache();
const refetch = useCallback(async () => {
setLoading(true);
await refreshStreamyfinPluginSettings();
await invalidateCache();
setLoading(false);
}, []);
const createCollectionConfig = useCallback(
(
title: string,
queryKey: string[],
includeItemTypes: BaseItemKind[],
parentId: string | undefined
): ScrollingCollectionListSection => ({
title,
queryKey,
queryFn: async () => {
if (!api) return [];
return (
(
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
limit: 20,
fields: ["PrimaryImageAspectRatio", "Path"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes,
parentId,
})
).data || []
);
},
type: "ScrollingCollectionList",
}),
[api, user?.Id]
);
let sections: Section[] = [];
if (!settings?.home || !settings?.home?.sections) {
sections = useMemo(() => {
if (!api || !user?.Id) return [];
const latestMediaViews = collections.map((c) => {
const includeItemTypes: BaseItemKind[] =
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
const title = t("home.recently_added_in", { libraryName: c.Name });
const queryKey = [
"home",
"recentlyAddedIn" + c.CollectionType,
user?.Id!,
c.Id!,
];
return createCollectionConfig(
title || "",
queryKey,
includeItemTypes,
c.Id
);
});
const ss: Section[] = [
{
title: t("home.continue_watching"),
queryKey: ["home", "resumeItems"],
queryFn: async () =>
(
await getItemsApi(api).getResumeItems({
userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes: ["Movie", "Series", "Episode"],
})
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "horizontal",
},
{
title: t("home.next_up"),
queryKey: ["home", "nextUp-all"],
queryFn: async () =>
(
await getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount"],
limit: 20,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableResumable: false,
})
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "horizontal",
},
...latestMediaViews,
// ...(mediaListCollections?.map(
// (ml) =>
// ({
// title: ml.Name,
// queryKey: ["home", "mediaList", ml.Id!],
// queryFn: async () => ml,
// type: "MediaListSection",
// orientation: "vertical",
// } as Section)
// ) || []),
{
title: t("home.suggested_movies"),
queryKey: ["home", "suggestedMovies", user?.Id],
queryFn: async () =>
(
await getSuggestionsApi(api).getSuggestions({
userId: user?.Id,
limit: 10,
mediaType: ["Video"],
type: ["Movie"],
})
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "vertical",
},
{
title: t("home.suggested_episodes"),
queryKey: ["home", "suggestedEpisodes", user?.Id],
queryFn: async () => {
try {
const suggestions = await getSuggestions(api, user.Id);
const nextUpPromises = suggestions.map((series) =>
getNextUp(api, user.Id, series.Id)
);
const nextUpResults = await Promise.all(nextUpPromises);
return nextUpResults.filter((item) => item !== null) || [];
} catch (error) {
console.error("Error fetching data:", error);
return [];
}
},
type: "ScrollingCollectionList",
orientation: "horizontal",
},
];
return ss;
}, [api, user?.Id, collections]);
} else {
sections = useMemo(() => {
if (!api || !user?.Id) return [];
const ss: Section[] = [];
for (const key in settings.home?.sections) {
// @ts-expect-error
const section = settings.home?.sections[key];
const id = section.title || key;
ss.push({
title: id,
queryKey: ["home", id],
queryFn: async () => {
if (section.items) {
const response = await getItemsApi(api).getItems({
userId: user?.Id,
limit: section.items?.limit || 25,
recursive: true,
includeItemTypes: section.items?.includeItemTypes,
sortBy: section.items?.sortBy,
sortOrder: section.items?.sortOrder,
filters: section.items?.filters,
parentId: section.items?.parentId,
});
return response.data.Items || [];
} else if (section.nextUp) {
const response = await getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount"],
limit: section.items?.limit || 25,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableResumable: section.items?.enableResumable || false,
enableRewatching: section.items?.enableRewatching || false,
});
return response.data.Items || [];
}
return [];
},
type: "ScrollingCollectionList",
orientation: section?.orientation || "vertical",
});
}
return ss;
}, [api, user?.Id, settings.home?.sections]);
}
if (isConnected === false) {
return (
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
<Text className="text-3xl font-bold mb-2">{t("home.no_internet")}</Text>
<Text className="text-center opacity-70">
{t("home.no_internet_message")}
</Text>
<View className="mt-4">
<Button
color="purple"
onPress={() => router.push("/(auth)/downloads")}
justify="center"
iconRight={
<Ionicons name="arrow-forward" size={20} color="white" />
}
>
{t("home.go_to_downloads")}
</Button>
<Button
color="black"
onPress={() => {
checkConnection();
}}
justify="center"
className="mt-2"
iconRight={
loadingRetry ? null : (
<Ionicons name="refresh" size={20} color="white" />
)
}
>
{loadingRetry ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
"Retry"
)}
</Button>
</View>
</View>
);
}
if (e1)
return (
<View className="flex flex-col items-center justify-center h-full -mt-6">
<Text className="text-3xl font-bold mb-2">{t("home.oops")}</Text>
<Text className="text-center opacity-70">
{t("home.error_message")}
</Text>
</View>
);
// this spinner should only show up, when user navigates here
// on launch the splash screen is used for loading
if (l1 && !splashScreenVisible)
return (
<View className="justify-center items-center h-full">
<Loader />
</View>
);
return (
<ScrollView
nestedScrollEnabled
contentInsetAdjustmentBehavior="automatic"
refreshControl={
<RefreshControl refreshing={loading} onRefresh={refetch} />
}
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
}}
>
<View className="flex flex-col space-y-4">
<LargeMovieCarousel />
{sections.map((section, index) => {
if (section.type === "ScrollingCollectionList") {
return (
<ScrollingCollectionList
key={index}
title={section.title}
queryKey={section.queryKey}
queryFn={section.queryFn}
orientation={section.orientation}
hideIfEmpty
/>
);
} else if (section.type === "MediaListSection") {
return (
<MediaListSection
key={index}
queryKey={section.queryKey}
queryFn={section.queryFn}
/>
);
}
return null;
})}
</View>
</ScrollView>
);
}
// Function to get suggestions
async function getSuggestions(api: Api, userId: string | undefined) {
if (!userId) return [];
const response = await getSuggestionsApi(api).getSuggestions({
userId,
limit: 10,
mediaType: ["Unknown"],
type: ["Series"],
});
return response.data.Items ?? [];
}
// Function to get the next up TV show for a series
async function getNextUp(
api: Api,
userId: string | undefined,
seriesId: string | undefined
) {
if (!userId || !seriesId) return null;
const response = await getTvShowsApi(api).getNextUp({
userId,
seriesId,
limit: 1,
});
return response.data.Items?.[0] ?? null;
}

View File

@@ -1,9 +1,8 @@
import { Platform } from "react-native";
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
import { AudioToggles } from "@/components/settings/AudioToggles";
import DownloadSettings from "@/components/settings/DownloadSettings";
import { MediaProvider } from "@/components/settings/MediaContext";
import { MediaToggles } from "@/components/settings/MediaToggles";
import { OtherSettings } from "@/components/settings/OtherSettings";
@@ -11,16 +10,20 @@ import { PluginSettings } from "@/components/settings/PluginSettings";
import { QuickConnect } from "@/components/settings/QuickConnect";
import { StorageSettings } from "@/components/settings/StorageSettings";
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
import { UserInfo } from "@/components/settings/UserInfo";
import { useHaptic } from "@/hooks/useHaptic";
import { useJellyfin } from "@/providers/JellyfinProvider";
import { clearLogs } from "@/utils/log";
import { storage } from "@/utils/mmkv";
import { useHaptic } from "@/hooks/useHaptic";
import { useNavigation, useRouter } from "expo-router";
import { t } from "i18next";
import React, { useEffect } from "react";
import React, { lazy, useEffect } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { storage } from "@/utils/mmkv";
const DownloadSettings = lazy(
() => import("@/components/settings/DownloadSettings")
);
export default function settings() {
const router = useRouter();
@@ -69,7 +72,7 @@ export default function settings() {
<OtherSettings />
<DownloadSettings />
{!Platform.isTV && <DownloadSettings />}
<PluginSettings />

View File

@@ -19,7 +19,7 @@ export default function page() {
const local = useLocalSearchParams();
const { t } = useTranslation();
const { jellyseerrApi, jellyseerrUser, jellyseerrRegion: region, jellyseerrLocale: locale } = useJellyseerr();
const { jellyseerrApi, jellyseerrUser } = useJellyseerr();
const { personId } = local as { personId: string };
@@ -32,6 +32,15 @@ export default function page() {
enabled: !!jellyseerrApi && !!personId,
});
const locale = useMemo(() => {
return jellyseerrUser?.settings?.locale || "en";
}, [jellyseerrUser]);
const region = useMemo(
() => jellyseerrUser?.settings?.region || "US",
[jellyseerrUser]
);
const castedRoles: PersonCreditCast[] = useMemo(
() =>
uniqBy(orderBy(

View File

@@ -209,12 +209,7 @@ export default function search() {
paddingRight: insets.right,
}}
>
<View
className="flex flex-col"
style={{
marginTop: Platform.OS === "android" ? 16 : 0,
}}
>
<View className="flex flex-col">
{jellyseerrApi && (
<View className="flex flex-row flex-wrap space-x-2 px-4 mb-2">
<TouchableOpacity onPress={() => setSearchType("Library")}>

View File

@@ -55,9 +55,7 @@ export default function TabLayout() {
<NativeTabs
sidebarAdaptable={false}
ignoresTopSafeArea
tabBarStyle={{
backgroundColor: "#121212",
}}
barTintColor={Platform.OS === "android" ? "#121212" : undefined}
tabBarActiveTintColor={Colors.primary}
scrollEdgeAppearance="default"
>

View File

@@ -21,7 +21,6 @@ import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { writeToLog } from "@/utils/log";
import native from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time";
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
import {
getPlaystateApi,
getUserLibraryApi,
@@ -299,18 +298,16 @@ export default function page() {
setIsPipStarted(pipStarted);
}, []);
const onPlaybackStateChanged = useCallback(async (e: PlaybackStatePayload) => {
const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => {
const { state, isBuffering, isPlaying } = e.nativeEvent;
if (state === "Playing") {
setIsPlaying(true);
if (!Platform.isTV) await activateKeepAwakeAsync()
return;
}
if (state === "Paused") {
setIsPlaying(false);
if (!Platform.isTV) await deactivateKeepAwake();
return;
}
@@ -364,21 +361,6 @@ export default function page() {
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
}
const externalSubtitles = allSubs
.filter((sub: any) => sub.DeliveryMethod === "External")
.map((sub: any) => ({
name: sub.DisplayTitle,
DeliveryUrl: api?.basePath + sub.DeliveryUrl,
}));
const [isMounted, setIsMounted] = useState(false);
// Add useEffect to handle mounting
useEffect(() => {
setIsMounted(true);
return () => setIsMounted(false);
}, []);
const insets = useSafeAreaInsets();
useEffect(() => {
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
@@ -401,6 +383,13 @@ export default function page() {
</View>
);
const externalSubtitles = allSubs
.filter((sub: any) => sub.DeliveryMethod === "External")
.map((sub: any) => ({
name: sub.DisplayTitle,
DeliveryUrl: api?.basePath + sub.DeliveryUrl,
}));
return (
<View style={{ flex: 1, backgroundColor: "black" }}>
<View
@@ -430,6 +419,7 @@ export default function page() {
progressUpdateInterval={1000}
onVideoStateChange={onPlaybackStateChanged}
onPipStarted={onPipStarted}
onVideoLoadStart={() => {}}
onVideoLoadEnd={() => {
setIsVideoLoaded(true);
}}
@@ -443,7 +433,7 @@ export default function page() {
}}
/>
</View>
{videoRef.current && !isPipStarted && isMounted === true ? (
{videoRef.current && !isPipStarted && (
<Controls
mediaSource={stream?.mediaSource}
item={item}
@@ -473,7 +463,7 @@ export default function page() {
stop={stop}
isVlc
/>
) : null}
)}
</View>
);
}

View File

@@ -1,5 +1,6 @@
import "@/augmentations";
import { Platform } from "react-native";
import { Text } from "@/components/common/Text";
import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider";
import {
@@ -9,6 +10,10 @@ import {
} from "@/providers/JellyfinProvider";
import { JobQueueProvider } from "@/providers/JobQueueProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import {
SplashScreenProvider,
useSplashScreenLoading,
} from "@/providers/SplashScreenProvider";
import { WebSocketProvider } from "@/providers/WebSocketProvider";
import { Settings, useSettings } from "@/utils/atoms/settings";
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
@@ -27,15 +32,16 @@ const BackgroundFetch = !Platform.isTV
? require("expo-background-fetch")
: null;
import * as FileSystem from "expo-file-system";
import { useFonts } from "expo-font";
import { useKeepAwake } from "expo-keep-awake";
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
import { router, Stack } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
const TaskManager = !Platform.isTV ? require("expo-task-manager") : null;
import { getLocales } from "expo-localization";
import { Provider as JotaiProvider } from "jotai";
import { useEffect, useRef } from "react";
import { I18nextProvider } from "react-i18next";
import { I18nextProvider, useTranslation } from "react-i18next";
import { Appearance, AppState } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler";
@@ -52,15 +58,6 @@ if (!Platform.isTV) {
});
}
// Keep the splash screen visible while we fetch resources
SplashScreen.preventAutoHideAsync();
// Set the animation options. This is optional.
SplashScreen.setOptions({
duration: 500,
fade: true,
});
function useNotificationObserver() {
if (Platform.isTV) return;
@@ -227,15 +224,17 @@ export default function RootLayout() {
Appearance.setColorScheme("dark");
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<JotaiProvider>
<ActionSheetProvider>
<I18nextProvider i18n={i18n}>
<Layout />
</I18nextProvider>
</ActionSheetProvider>
</JotaiProvider>
</GestureHandlerRootView>
<SplashScreenProvider>
<GestureHandlerRootView style={{ flex: 1 }}>
<JotaiProvider>
<ActionSheetProvider>
<I18nextProvider i18n={i18n}>
<Layout />
</I18nextProvider>
</ActionSheetProvider>
</JotaiProvider>
</GestureHandlerRootView>
</SplashScreenProvider>
);
}
@@ -262,8 +261,11 @@ function Layout() {
}, [settings?.preferedLanguage, i18n]);
if (!Platform.isTV) {
useKeepAwake();
useNotificationObserver();
const { i18n } = useTranslation();
useEffect(() => {
checkAndRequestPermissions();
}, []);
@@ -301,6 +303,16 @@ function Layout() {
}, []);
}
const [loaded] = useFonts({
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
});
useSplashScreenLoading(!loaded);
if (!loaded) {
return null;
}
return (
<QueryClientProvider client={queryClient}>
<JobQueueProvider>
@@ -312,7 +324,7 @@ function Layout() {
<BottomSheetModalProvider>
<SystemBars style="light" hidden={false} />
<ThemeProvider value={DarkTheme}>
<Stack initialRouteName="(auth)/(tabs)">
<Stack>
<Stack.Screen
name="(auth)/(tabs)"
options={{

View File

@@ -9,7 +9,7 @@ import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom, useAtomValue } from "jotai";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useState } from "react";
import {
Alert,
@@ -19,20 +19,17 @@ import {
TouchableOpacity,
View,
} from "react-native";
import { Keyboard } from "react-native";
import { z } from "zod";
import { t } from "i18next";
import { t } from 'i18next';
const CredentialsSchema = z.object({
username: z.string().min(1, t("login.username_required")),
});
username: z.string().min(1, t("login.username_required")),});
const Login: React.FC = () => {
const api = useAtomValue(apiAtom);
const navigation = useNavigation();
const params = useLocalSearchParams();
const Login: React.FC = () => {
const { setServer, login, removeServer, initiateQuickConnect } =
useJellyfin();
const [api] = useAtom(apiAtom);
const params = useLocalSearchParams();
const {
apiUrl: _apiUrl,
@@ -40,8 +37,6 @@ const Login: React.FC = () => {
password: _password,
} = params as { apiUrl: string; username: string; password: string };
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
const [serverURL, setServerURL] = useState<string>(_apiUrl);
const [serverName, setServerName] = useState<string>("");
const [credentials, setCredentials] = useState<{
@@ -52,11 +47,10 @@ const Login: React.FC = () => {
password: _password,
});
/**
* A way to auto login based on a link
*/
useEffect(() => {
(async () => {
// we might re-use the checkUrl function here to check the url as well
// however, I don't think it should be necessary for now
if (_apiUrl) {
setServer({
address: _apiUrl,
@@ -72,6 +66,7 @@ const Login: React.FC = () => {
})();
}, [_apiUrl, _username, _password]);
const navigation = useNavigation();
useEffect(() => {
navigation.setOptions({
headerTitle: serverName,
@@ -84,17 +79,15 @@ const Login: React.FC = () => {
className="flex flex-row items-center"
>
<Ionicons name="chevron-back" size={18} color={Colors.primary} />
<Text className="ml-2 text-purple-600">
{t("login.change_server")}
</Text>
<Text className="ml-2 text-purple-600">{t("login.change_server")}</Text>
</TouchableOpacity>
) : null,
});
}, [serverName, navigation, api?.basePath]);
const handleLogin = async () => {
Keyboard.dismiss();
const [loading, setLoading] = useState<boolean>(false);
const handleLogin = async () => {
setLoading(true);
try {
const result = CredentialsSchema.safeParse(credentials);
@@ -105,16 +98,15 @@ const Login: React.FC = () => {
if (error instanceof Error) {
Alert.alert(t("login.connection_failed"), error.message);
} else {
Alert.alert(
t("login.connection_failed"),
t("login.an_unexpected_error_occured")
);
Alert.alert(t("login.connection_failed"), t("login.an_unexpected_error_occured"));
}
} finally {
setLoading(false);
}
};
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
/**
* Checks the availability and validity of a Jellyfin server URL.
*
@@ -188,21 +180,14 @@ const Login: React.FC = () => {
try {
const code = await initiateQuickConnect();
if (code) {
Alert.alert(
t("login.quick_connect"),
t("login.enter_code_to_login", { code: code }),
[
{
text: t("login.got_it"),
},
]
);
Alert.alert(t("login.quick_connect"), t("login.enter_code_to_login", {code: code}), [
{
text: t("login.got_it"),
},
]);
}
} catch (error) {
Alert.alert(
t("login.error_title"),
t("login.failed_to_initiate_quick_connect")
);
Alert.alert(t("login.error_title"), t("login.failed_to_initiate_quick_connect"));
}
};
@@ -216,18 +201,16 @@ const Login: React.FC = () => {
<View className="flex flex-col h-full relative items-center justify-center">
<View className="px-4 -mt-20 w-full">
<View className="flex flex-col space-y-2">
<Text className="text-2xl font-bold -mb-2">
<>
{serverName ? (
<>
{t("login.login_to_title") + " "}
<Text className="text-purple-600">{serverName}</Text>
</>
) : (
t("login.login_title")
)}
</>
</Text>
<Text className="text-2xl font-bold -mb-2">
<>
{serverName ? (
<>
{t("login.login_to_title") + " "}
<Text className="text-purple-600">{serverName}</Text>
</>
) : t("login.login_title")}
</>
</Text>
<Text className="text-xs text-neutral-400">
{api.basePath}
</Text>
@@ -237,6 +220,7 @@ const Login: React.FC = () => {
setCredentials({ ...credentials, username: text })
}
value={credentials.username}
autoFocus
secureTextEntry={false}
keyboardType="default"
returnKeyType="done"
@@ -316,9 +300,7 @@ const Login: React.FC = () => {
<Button
loading={loadingServerCheck}
disabled={loadingServerCheck}
onPress={async () => {
await handleConnect(serverURL);
}}
onPress={async () => await handleConnect(serverURL)}
className="w-full grow"
>
{t("server.connect_button")}

View File

@@ -13,6 +13,7 @@
"@gorhom/bottom-sheet": "^5.1.0",
"@jellyfin/sdk": "^0.11.0",
"@kesha-antonov/react-native-background-downloader": "3.2.6",
"@react-native-async-storage/async-storage": "1.23.1",
"@react-native-community/netinfo": "11.4.1",
"@react-native-menu/menu": "^1.2.2",
"@react-navigation/bottom-tabs": "^7.2.0",
@@ -20,6 +21,9 @@
"@react-navigation/native": "^7.0.14",
"@shopify/flash-list": "1.7.3",
"@tanstack/react-query": "^5.66.0",
"@types/lodash": "^4.17.15",
"@types/react-native-vector-icons": "^6.4.18",
"@types/uuid": "^10.0.0",
"add": "^2.0.6",
"axios": "^1.7.9",
"expo": "^52.0.31",
@@ -44,7 +48,7 @@
"expo-router": "~4.0.17",
"expo-screen-orientation": "~8.0.4",
"expo-sensors": "~14.0.2",
"expo-splash-screen": "~0.29.22",
"expo-splash-screen": "~0.29.21",
"expo-status-bar": "~2.0.1",
"expo-system-ui": "~4.0.8",
"expo-task-manager": "~12.0.5",
@@ -60,7 +64,7 @@
"react-i18next": "^15.4.0",
"react-native": "npm:react-native-tvos@~0.77.0-0",
"react-native-awesome-slider": "^2.9.0",
"react-native-bottom-tabs": "0.8.7",
"react-native-bottom-tabs": "0.8.6",
"react-native-circular-progress": "^1.4.1",
"react-native-compressor": "^1.10.3",
"react-native-country-flag": "^2.0.2",
@@ -101,11 +105,8 @@
"@react-native-community/cli": "15.1.3",
"@react-native-tvos/config-tv": "^0.1.1",
"@types/jest": "^29.5.14",
"@types/lodash": "^4.17.15",
"@types/react": "~18.3.12",
"@types/react-native-vector-icons": "^6.4.18",
"@types/react-test-renderer": "^19.0.0",
"@types/uuid": "^10.0.0",
"patch-package": "^8.0.0",
"postinstall-postinstall": "^2.1.0",
"react-test-renderer": "19.0.0",
@@ -574,6 +575,8 @@
"@radix-ui/rect": ["@radix-ui/rect@1.1.0", "", {}, "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg=="],
"@react-native-async-storage/async-storage": ["@react-native-async-storage/async-storage@1.23.1", "", { "dependencies": { "merge-options": "^3.0.4" }, "peerDependencies": { "react-native": "^0.0.0-0 || >=0.60 <1.0" } }, "sha512-Qd2kQ3yi6Y3+AcUlrHxSLlnBvpdCEMVGFlVBneVOjaFaPU61g1huc38g339ysXspwY1QZA2aNhrk/KlHGO+ewA=="],
"@react-native-community/cli": ["@react-native-community/cli@15.1.3", "", { "dependencies": { "@react-native-community/cli-clean": "15.1.3", "@react-native-community/cli-config": "15.1.3", "@react-native-community/cli-debugger-ui": "15.1.3", "@react-native-community/cli-doctor": "15.1.3", "@react-native-community/cli-server-api": "15.1.3", "@react-native-community/cli-tools": "15.1.3", "@react-native-community/cli-types": "15.1.3", "chalk": "^4.1.2", "commander": "^9.4.1", "deepmerge": "^4.3.0", "execa": "^5.0.0", "find-up": "^5.0.0", "fs-extra": "^8.1.0", "graceful-fs": "^4.1.3", "prompts": "^2.4.2", "semver": "^7.5.2" }, "bin": { "rnc-cli": "build/bin.js" } }, "sha512-+ih/WYUkJsEV2CMAnOHvVoSIz/Ahg5UJk+sqSIOmY79mWAglQzfLP71o7b0neJCnJWLmWiO6G6/S+kmULefD5g=="],
"@react-native-community/cli-clean": ["@react-native-community/cli-clean@15.1.3", "", { "dependencies": { "@react-native-community/cli-tools": "15.1.3", "chalk": "^4.1.2", "execa": "^5.0.0", "fast-glob": "^3.3.2" } }, "sha512-3s9NGapIkONFoCUN2s77NYI987GPSCdr74rTf0TWyGIDf4vTYgKoWKKR+Ml3VTa1BCj51r4cYuHEKE1pjUSc0w=="],
@@ -754,7 +757,7 @@
"aggregate-error": ["aggregate-error@3.1.0", "", { "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" } }, "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA=="],
"ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
"ajv": ["ajv@8.11.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg=="],
"ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="],
@@ -1056,7 +1059,7 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"electron-to-chromium": ["electron-to-chromium@1.5.100", "", {}, "sha512-u1z9VuzDXV86X2r3vAns0/5ojfXBue9o0+JDUDBKYqGLjxLkSqsSUoPU/6kW0gx76V44frHaf6Zo+QF74TQCMg=="],
"electron-to-chromium": ["electron-to-chromium@1.5.101", "", {}, "sha512-L0ISiQrP/56Acgu4/i/kfPwWSgrzYZUnQrC0+QPFuhqlLP1Ir7qzPPDVS9BcKIyWTRU8+o6CC8dKw38tSWhYIA=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@@ -1086,6 +1089,8 @@
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
@@ -1204,8 +1209,6 @@
"fast-loops": ["fast-loops@1.1.4", "", {}, "sha512-8dbd3XWoKCTms18ize6JmQF1SFnnfj5s0B7rRry22EofgMu7B6LKHVh+XfFqFGsqnbH54xgeO83PzpKI+ODhlg=="],
"fast-uri": ["fast-uri@3.0.6", "", {}, "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw=="],
"fast-xml-parser": ["fast-xml-parser@4.5.1", "", { "dependencies": { "strnum": "^1.0.5" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-y655CeyUQ+jj7KBbYMc4FG01V8ZQqjN+gDYGJ50RtfsUB8iG9AmwmwoAgeKLJdmueKKMrH1RJ7yXHTSoczdv5w=="],
"fastq": ["fastq@1.19.0", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-7SFSRCNjBQIZH/xZR3iy5iQYR8aGBE0h3VG6/cwlbrpdciNYBMotQav8c1XI3HjHH+NikUpP53nPdlZSdWmFzA=="],
@@ -1248,7 +1251,7 @@
"foreground-child": ["foreground-child@3.3.0", "", { "dependencies": { "cross-spawn": "^7.0.0", "signal-exit": "^4.0.1" } }, "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg=="],
"form-data": ["form-data@4.0.1", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "mime-types": "^2.1.12" } }, "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw=="],
"form-data": ["form-data@4.0.2", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "mime-types": "^2.1.12" } }, "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w=="],
"freeport-async": ["freeport-async@2.0.0", "", {}, "sha512-K7od3Uw45AJg00XUmy15+Hae2hOcgKcmN3/EF6Y7i01O0gaqiRx8sUSpsb9+BRNL8RPBrhzPsVfy8q9ADlJuWQ=="],
@@ -1392,6 +1395,8 @@
"is-path-inside": ["is-path-inside@3.0.3", "", {}, "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ=="],
"is-plain-obj": ["is-plain-obj@2.1.0", "", {}, "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA=="],
"is-plain-object": ["is-plain-object@2.0.4", "", { "dependencies": { "isobject": "^3.0.1" } }, "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og=="],
"is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
@@ -1546,6 +1551,8 @@
"memoize-one": ["memoize-one@5.2.1", "", {}, "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="],
"merge-options": ["merge-options@3.0.4", "", { "dependencies": { "is-plain-obj": "^2.1.0" } }, "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ=="],
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
@@ -1826,7 +1833,7 @@
"react-native-awesome-slider": ["react-native-awesome-slider@2.9.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.0.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-sc5qgX4YtM6IxjtosjgQLdsal120MvU+YWs0F2MdgQWijps22AXLDCUoBnZZ8vxVhVyJ2WnnIPrmtVBvVJjSuQ=="],
"react-native-bottom-tabs": ["react-native-bottom-tabs@0.8.7", "", { "dependencies": { "react-freeze": "^1.0.0", "sf-symbols-typescript": "^2.0.0", "use-latest-callback": "^0.2.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-cVQYs4r8Hb9V9oOO/SqsmBaZ7IzE/3Tpvz4mmRjNXKi1cBWC+ZpKTuqRx6EPjBCYTVK+vbAfoTM6IHS+6NVg4w=="],
"react-native-bottom-tabs": ["react-native-bottom-tabs@0.8.6", "", { "dependencies": { "sf-symbols-typescript": "^2.0.0", "use-latest-callback": "^0.2.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-N5b3MoSfsEqlmvFyIyL0X0bd+QAtB+cXH1rl/+R2Kr0BefBTC7ZldGcPhgK3FhBbt0vJDpd3kLb/dvmqZd+Eag=="],
"react-native-circular-progress": ["react-native-circular-progress@1.4.1", "", { "dependencies": { "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">=16.0.0", "react-native": ">=0.50.0", "react-native-svg": ">=7.0.0" } }, "sha512-HEzvI0WPuWvsCgWE3Ff2HBTMgAEQB2GvTFw0KHyD/t1STAlDDRiolu0mEGhVvihKR3jJu3v3V4qzvSklY/7XzQ=="],
@@ -1904,7 +1911,7 @@
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.3", "", { "dependencies": { "process": "^0.11.10", "readable-stream": "^4.7.0" } }, "sha512-In3boYjBnbGVrLuuRu/Ath/H6h1jgk30nAsk/71tCare1dTVoe1oMBGRn5LGf0n3c1BcHwwAqpraxX4AUAP5KA=="],
"readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="],
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
@@ -1970,7 +1977,7 @@
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"send": ["send@0.19.0", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "0.5.2", "http-errors": "2.0.0", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "2.4.1", "range-parser": "~1.2.1", "statuses": "2.0.1" } }, "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw=="],
"send": ["send@0.19.1", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "0.5.2", "http-errors": "2.0.0", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "2.4.1", "range-parser": "~1.2.1", "statuses": "2.0.1" } }, "sha512-p4rRk4f23ynFEfcD9LA0xRYngj+IyGiEYyqqOak8kaN0TvNmuxC2dcVeBn62GpCeR2CpWqyHCNScTP91QbAVFg=="],
"serialize-error": ["serialize-error@2.1.0", "", {}, "sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw=="],
@@ -2286,7 +2293,7 @@
"@expo/cli/arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
"@expo/cli/form-data": ["form-data@3.0.2", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "mime-types": "^2.1.12" } }, "sha512-sJe+TQb2vIaIyO783qN6BlMYWMw3WBOHA1Ay2qxsnjuafEOQFJ2JakedOQirT6D5XPRxDvS7AHYyem9fTpb4LQ=="],
"@expo/cli/form-data": ["form-data@3.0.3", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "mime-types": "^2.1.35" } }, "sha512-q5YBMeWy6E2Un0nMGWMgI65MAKtaylxfNJGJxpGh45YDciZB4epbWpaAfImil6CPAPTYB4sh0URQNDRIZG5F2w=="],
"@expo/cli/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
@@ -2448,8 +2455,6 @@
"expo-build-properties/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="],
"expo-dev-launcher/ajv": ["ajv@8.11.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg=="],
"expo-modules-autolinking/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="],
"expo-modules-autolinking/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
@@ -2600,10 +2605,10 @@
"send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="],
"send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
"serve-static/send": ["send@0.19.0", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "0.5.2", "http-errors": "2.0.0", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "2.4.1", "range-parser": "~1.2.1", "statuses": "2.0.1" } }, "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw=="],
"simple-plist/bplist-creator": ["bplist-creator@0.1.0", "", { "dependencies": { "stream-buffers": "2.2.x" } }, "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg=="],
"simple-plist/bplist-parser": ["bplist-parser@0.3.1", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA=="],
@@ -2798,6 +2803,12 @@
"send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"serve-static/send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"serve-static/send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="],
"serve-static/send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
"slice-ansi/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
"tar/fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
@@ -2876,6 +2887,8 @@
"rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],
"serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"slice-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
"temp/rimraf/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],

View File

@@ -115,100 +115,96 @@ export const PlayButton: React.FC<Props> = ({
case 0:
if (!Platform.isTV) {
await CastContext.getPlayServicesState().then(async (state) => {
if (state && state !== PlayServicesState.SUCCESS) {
if (state && state !== PlayServicesState.SUCCESS)
CastContext.showPlayServicesErrorDialog(state);
} else {
else {
// Get a new URL with the Chromecast device profile:
try {
const data = await getStreamUrl({
api,
item,
deviceProfile: chromecastProfile,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
audioStreamIndex: selectedOptions.audioIndex,
maxStreamingBitrate: selectedOptions.bitrate?.value,
mediaSourceId: selectedOptions.mediaSource?.Id,
subtitleStreamIndex: selectedOptions.subtitleIndex,
});
const data = await getStreamUrl({
api,
item,
deviceProfile: chromecastProfile,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
audioStreamIndex: selectedOptions.audioIndex,
maxStreamingBitrate: selectedOptions.bitrate?.value,
mediaSourceId: selectedOptions.mediaSource?.Id,
subtitleStreamIndex: selectedOptions.subtitleIndex,
});
if (!data?.url) {
console.warn("No URL returned from getStreamUrl", data);
Alert.alert(
t("player.client_error"),
t("player.could_not_create_stream_for_chromecast")
);
return;
}
client
.loadMedia({
mediaInfo: {
contentUrl: data?.url,
contentType: "video/mp4",
metadata:
item.Type === "Episode"
? {
type: "tvShow",
title: item.Name || "",
episodeNumber: item.IndexNumber || 0,
seasonNumber: item.ParentIndexNumber || 0,
seriesTitle: item.SeriesName || "",
images: [
{
url: getParentBackdropImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
}
: item.Type === "Movie"
? {
type: "movie",
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
}
: {
type: "generic",
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
},
},
startTime: 0,
})
.then(() => {
// state is already set when reopening current media, so skip it here.
if (isOpeningCurrentlyPlayingMedia) {
return;
}
CastContext.showExpandedControls();
});
} catch (e) {
console.log(e);
if (!data?.url) {
console.warn("No URL returned from getStreamUrl", data);
Alert.alert(
t("player.client_error"),
t("player.could_not_create_stream_for_chromecast")
);
return;
}
client
.loadMedia({
mediaInfo: {
contentUrl: data?.url,
contentType: "video/mp4",
metadata:
item.Type === "Episode"
? {
type: "tvShow",
title: item.Name || "",
episodeNumber: item.IndexNumber || 0,
seasonNumber: item.ParentIndexNumber || 0,
seriesTitle: item.SeriesName || "",
images: [
{
url: getParentBackdropImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
}
: item.Type === "Movie"
? {
type: "movie",
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
}
: {
type: "generic",
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
},
},
startTime: 0,
})
.then(() => {
// state is already set when reopening current media, so skip it here.
if (isOpeningCurrentlyPlayingMedia) {
return;
}
CastContext.showExpandedControls();
});
}
});
}

View File

@@ -19,7 +19,7 @@ interface Release {
type: number;
}
export const dateOpts: Intl.DateTimeFormatOptions = {
const dateOpts: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "long",
day: "numeric",
@@ -50,9 +50,18 @@ const Fact: React.FC<{ title: string; fact?: string | null } & ViewProps> = ({
const DetailFacts: React.FC<
{ details?: MovieDetails | TvDetails } & ViewProps
> = ({ details, className, ...props }) => {
const { jellyseerrUser, jellyseerrRegion: region, jellyseerrLocale: locale } = useJellyseerr();
const { jellyseerrUser } = useJellyseerr();
const { t } = useTranslation();
const locale = useMemo(() => {
return jellyseerrUser?.settings?.locale || "en";
}, [jellyseerrUser]);
const region = useMemo(
() => jellyseerrUser?.settings?.region || "US",
[jellyseerrUser]
);
const releases = useMemo(
() =>
(details as MovieDetails)?.releases?.results.find(

View File

@@ -23,8 +23,6 @@ import { Loader } from "../Loader";
import { t } from "i18next";
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
import {MediaRequestBody} from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import {textShadowStyle} from "@/components/jellyseerr/discover/GenericSlideCard";
import {dateOpts} from "@/components/jellyseerr/DetailFacts";
const JellyseerrSeasonEpisodes: React.FC<{
details: TvDetails;
@@ -54,51 +52,26 @@ const JellyseerrSeasonEpisodes: React.FC<{
};
const RenderItem = ({ item, index }: any) => {
const { jellyseerrApi, jellyseerrRegion: region, jellyseerrLocale: locale } = useJellyseerr();
const { jellyseerrApi } = useJellyseerr();
const [imageError, setImageError] = useState(false);
const upcomingAirDate = useMemo(() => {
const airDate = item.airDate;
if (airDate) {
let airDateObj = new Date(airDate);
if (new Date() < airDateObj) {
return airDateObj.toLocaleDateString(
`${locale}-${region}`,
dateOpts
);
}
}
}, [item]);
return (
<View className="flex flex-col w-44 mt-2">
<View className="relative aspect-video rounded-lg overflow-hidden border border-neutral-800">
{!imageError ? (
<>
<Image
key={item.id}
id={item.id}
source={{
uri: jellyseerrApi?.imageProxy(item.stillPath),
}}
cachePolicy={"memory-disk"}
contentFit="cover"
className="w-full h-full"
onError={(e) => {
setImageError(true);
}}
/>
{upcomingAirDate && (
<View className="absolute justify-center bottom-0 right-0.5 items-center">
<View className="rounded-full bg-purple-600/30 p-1">
<Text className="text-center text-xs" style={textShadowStyle.shadow}>
{upcomingAirDate}
</Text>
</View>
</View>
)}
</>
<Image
key={item.id}
id={item.id}
source={{
uri: jellyseerrApi?.imageProxy(item.stillPath),
}}
cachePolicy={"memory-disk"}
contentFit="cover"
className="w-full h-full"
onError={(e) => {
setImageError(true);
}}
/>
) : (
<View className="flex flex-col w-full h-full items-center justify-center border border-neutral-800 bg-neutral-900">
<Ionicons

View File

@@ -62,7 +62,7 @@ export default function DownloadSettings({ ...props }) {
sideOffset={8}
>
<DropdownMenu.Label>
{t("home.settings.downloads.download_method")}
{t("home.settings.downloads.methods")}
</DropdownMenu.Label>
<DropdownMenu.Item
key="1"

View File

@@ -1,5 +0,0 @@
import React from "react";
export default function DownloadSettings({ ...props }) {
return <></>;
}

View File

@@ -26,6 +26,9 @@ export const JellyseerrSettings = () => {
const [user] = useAtom(userAtom);
const [settings, updateSettings, pluginSettings] = useSettings();
const [promptForJellyseerrPass, setPromptForJellyseerrPass] =
useState<boolean>(false);
const [jellyseerrPassword, setJellyseerrPassword] = useState<
string | undefined
>(undefined);
@@ -36,16 +39,11 @@ export const JellyseerrSettings = () => {
const loginToJellyseerrMutation = useMutation({
mutationFn: async () => {
if (!jellyseerrServerUrl && !settings?.jellyseerrServerUrl)
throw new Error("Missing server url");
if (!user?.Name)
if (!jellyseerrServerUrl || !user?.Name || !jellyseerrPassword) {
throw new Error("Missing required information for login");
const jellyseerrTempApi = new JellyseerrApi(
jellyseerrServerUrl || settings.jellyseerrServerUrl || ""
);
const testResult = await jellyseerrTempApi.test();
if (!testResult.isValid) throw new Error("Invalid server url");
return jellyseerrTempApi.login(user.Name, jellyseerrPassword || "");
}
const jellyseerrTempApi = new JellyseerrApi(jellyseerrServerUrl);
return jellyseerrTempApi.login(user.Name, jellyseerrPassword);
},
onSuccess: (user) => {
setJellyseerrUser(user);
@@ -59,11 +57,31 @@ export const JellyseerrSettings = () => {
},
});
const testJellyseerrServerUrlMutation = useMutation({
mutationFn: async () => {
if (!jellyseerrServerUrl || jellyseerrApi) return null;
const jellyseerrTempApi = new JellyseerrApi(jellyseerrServerUrl);
return jellyseerrTempApi.test();
},
onSuccess: (result) => {
if (result && result.isValid) {
if (result.requiresPass) {
setPromptForJellyseerrPass(true);
} else {
updateSettings({ jellyseerrServerUrl });
}
} else {
setPromptForJellyseerrPass(false);
setjellyseerrServerUrl(undefined);
clearAllJellyseerData();
}
},
});
const clearData = () => {
clearAllJellyseerData().finally(() => {
setJellyseerrUser(undefined);
setJellyseerrPassword(undefined);
setjellyseerrServerUrl(undefined);
setPromptForJellyseerrPass(false);
});
};
@@ -74,46 +92,34 @@ export const JellyseerrSettings = () => {
<>
<ListGroup title={"Jellyseerr"}>
<ListItem
title={t(
"home.settings.plugins.jellyseerr.total_media_requests"
)}
title={t("home.settings.plugins.jellyseerr.total_media_requests")}
value={jellyseerrUser?.requestCount?.toString()}
/>
<ListItem
title={t("home.settings.plugins.jellyseerr.movie_quota_limit")}
value={
jellyseerrUser?.movieQuotaLimit?.toString() ??
t("home.settings.plugins.jellyseerr.unlimited")
jellyseerrUser?.movieQuotaLimit?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited")
}
/>
<ListItem
title={t("home.settings.plugins.jellyseerr.movie_quota_days")}
value={
jellyseerrUser?.movieQuotaDays?.toString() ??
t("home.settings.plugins.jellyseerr.unlimited")
jellyseerrUser?.movieQuotaDays?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited")
}
/>
<ListItem
title={t("home.settings.plugins.jellyseerr.tv_quota_limit")}
value={
jellyseerrUser?.tvQuotaLimit?.toString() ??
t("home.settings.plugins.jellyseerr.unlimited")
}
value={jellyseerrUser?.tvQuotaLimit?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited")}
/>
<ListItem
title={t("home.settings.plugins.jellyseerr.tv_quota_days")}
value={
jellyseerrUser?.tvQuotaDays?.toString() ??
t("home.settings.plugins.jellyseerr.unlimited")
}
value={jellyseerrUser?.tvQuotaDays?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited")}
/>
</ListGroup>
<View className="p-4">
<Button color="red" onPress={clearData}>
{t(
"home.settings.plugins.jellyseerr.reset_jellyseerr_config_button"
)}
{t("home.settings.plugins.jellyseerr.reset_jellyseerr_config_button")}
</Button>
</View>
</>
@@ -122,20 +128,15 @@ export const JellyseerrSettings = () => {
<Text className="text-xs text-red-600 mb-2">
{t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
</Text>
<Text className="font-bold mb-1">
{t("home.settings.plugins.jellyseerr.server_url")}
</Text>
<Text className="font-bold mb-1">{t("home.settings.plugins.jellyseerr.server_url")}</Text>
<View className="flex flex-col shrink mb-2">
<Text className="text-xs text-gray-600">
{t("home.settings.plugins.jellyseerr.server_url_hint")}
</Text>
</View>
<Input
className="border border-neutral-800 mb-2"
placeholder={t(
"home.settings.plugins.jellyseerr.server_url_placeholder"
)}
value={jellyseerrServerUrl ?? settings?.jellyseerrServerUrl}
placeholder={t("home.settings.plugins.jellyseerr.server_url_placeholder")}
value={settings?.jellyseerrServerUrl ?? jellyseerrServerUrl}
defaultValue={
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
}
@@ -144,20 +145,40 @@ export const JellyseerrSettings = () => {
autoCapitalize="none"
textContentType="URL"
onChangeText={setjellyseerrServerUrl}
editable={!loginToJellyseerrMutation.isPending}
editable={!testJellyseerrServerUrlMutation.isPending}
/>
<View>
<Text className="font-bold mb-2">
{t("home.settings.plugins.jellyseerr.password")}
</Text>
<Button
loading={testJellyseerrServerUrlMutation.isPending}
disabled={testJellyseerrServerUrlMutation.isPending}
color={promptForJellyseerrPass ? "red" : "purple"}
className="h-12 mt-2"
onPress={() => {
if (promptForJellyseerrPass) {
clearData();
return;
}
testJellyseerrServerUrlMutation.mutate();
}}
style={{
marginBottom: 8,
}}
>
{promptForJellyseerrPass ? t("home.settings.plugins.jellyseerr.clear_button") : t("home.settings.plugins.jellyseerr.save_button")}
</Button>
<View
pointerEvents={promptForJellyseerrPass ? "auto" : "none"}
style={{
opacity: promptForJellyseerrPass ? 1 : 0.5,
}}
>
<Text className="font-bold mb-2">{t("home.settings.plugins.jellyseerr.password")}</Text>
<Input
className="border border-neutral-800"
autoFocus={true}
focusable={true}
placeholder={t(
"home.settings.plugins.jellyseerr.password_placeholder",
{ username: user?.Name }
)}
placeholder={t("home.settings.plugins.jellyseerr.password_placeholder", {username: user?.Name})}
value={jellyseerrPassword}
keyboardType="default"
secureTextEntry={true}
@@ -165,7 +186,10 @@ export const JellyseerrSettings = () => {
autoCapitalize="none"
textContentType="password"
onChangeText={setJellyseerrPassword}
editable={!loginToJellyseerrMutation.isPending}
editable={
!loginToJellyseerrMutation.isPending &&
promptForJellyseerrPass
}
/>
<Button
loading={loginToJellyseerrMutation.isPending}

View File

@@ -165,7 +165,7 @@ export const OtherSettings: React.FC = () => {
showArrow
/>
<ListItem
title={t("home.settings.other.default_quality")}
title="Default quality"
disabled={pluginSettings?.defaultBitrate?.locked}
>
<Dropdown
@@ -186,7 +186,7 @@ export const OtherSettings: React.FC = () => {
/>
</TouchableOpacity>
}
label={t("home.settings.other.default_quality")}
label={t("home.settings.other.quality")}
onSelected={(defaultBitrate) => updateSettings({ defaultBitrate })}
/>
</ListItem>

View File

@@ -1,485 +0,0 @@
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
import { Loader } from "@/components/Loader";
import { MediaListSection } from "@/components/medialists/MediaListSection";
import { Colors } from "@/constants/Colors";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { Feather, Ionicons } from "@expo/vector-icons";
import { Api } from "@jellyfin/sdk";
import {
BaseItemDto,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getItemsApi,
getSuggestionsApi,
getTvShowsApi,
getUserLibraryApi,
getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api";
import NetInfo from "@react-native-community/netinfo";
import { QueryFunction, useQuery } from "@tanstack/react-query";
import { useNavigation, useRouter } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
RefreshControl,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
type ScrollingCollectionListSection = {
type: "ScrollingCollectionList";
title?: string;
queryKey: (string | undefined | null)[];
queryFn: QueryFunction<BaseItemDto[]>;
orientation?: "horizontal" | "vertical";
};
type MediaListSection = {
type: "MediaListSection";
queryKey: (string | undefined)[];
queryFn: QueryFunction<BaseItemDto>;
};
type Section = ScrollingCollectionListSection | MediaListSection;
export const SettingsIndex = () => {
const router = useRouter();
const { t } = useTranslation();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const [loading, setLoading] = useState(false);
const [
settings,
updateSettings,
pluginSettings,
setPluginSettings,
refreshStreamyfinPluginSettings,
] = useSettings();
const [isConnected, setIsConnected] = useState<boolean | null>(null);
const [loadingRetry, setLoadingRetry] = useState(false);
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const { downloadedFiles, cleanCacheDirectory } = useDownload();
useEffect(() => {
const hasDownloads = downloadedFiles && downloadedFiles.length > 0;
navigation.setOptions({
headerLeft: () => (
<TouchableOpacity
onPress={() => {
router.push("/(auth)/downloads");
}}
className="p-2"
>
<Feather
name="download"
color={hasDownloads ? Colors.primary : "white"}
size={22}
/>
</TouchableOpacity>
),
});
}, [downloadedFiles, navigation, router]);
useEffect(() => {
cleanCacheDirectory().catch((e) =>
console.error("Something went wrong cleaning cache directory")
);
}, []);
const checkConnection = useCallback(async () => {
setLoadingRetry(true);
const state = await NetInfo.fetch();
setIsConnected(state.isConnected);
setLoadingRetry(false);
}, []);
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
if (state.isConnected == false || state.isInternetReachable === false)
setIsConnected(false);
else setIsConnected(true);
});
NetInfo.fetch().then((state) => {
setIsConnected(state.isConnected);
});
// cleanCacheDirectory().catch((e) =>
// console.error("Something went wrong cleaning cache directory")
// );
return () => {
unsubscribe();
};
}, []);
const {
data,
isError: e1,
isLoading: l1,
} = useQuery({
queryKey: ["home", "userViews", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) {
return null;
}
const response = await getUserViewsApi(api).getUserViews({
userId: user.Id,
});
return response.data.Items || null;
},
enabled: !!api && !!user?.Id,
staleTime: 60 * 1000,
});
const userViews = useMemo(
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
[data, settings?.hiddenLibraries]
);
const collections = useMemo(() => {
const allow = ["movies", "tvshows"];
return (
userViews?.filter(
(c) => c.CollectionType && allow.includes(c.CollectionType)
) || []
);
}, [userViews]);
const invalidateCache = useInvalidatePlaybackProgressCache();
const refetch = useCallback(async () => {
setLoading(true);
await refreshStreamyfinPluginSettings();
await invalidateCache();
setLoading(false);
}, []);
const createCollectionConfig = useCallback(
(
title: string,
queryKey: string[],
includeItemTypes: BaseItemKind[],
parentId: string | undefined
): ScrollingCollectionListSection => ({
title,
queryKey,
queryFn: async () => {
if (!api) return [];
return (
(
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
limit: 20,
fields: ["PrimaryImageAspectRatio", "Path"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes,
parentId,
})
).data || []
);
},
type: "ScrollingCollectionList",
}),
[api, user?.Id]
);
let sections: Section[] = [];
if (!settings?.home || !settings?.home?.sections) {
sections = useMemo(() => {
if (!api || !user?.Id) return [];
const latestMediaViews = collections.map((c) => {
const includeItemTypes: BaseItemKind[] =
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
const title = t("home.recently_added_in", { libraryName: c.Name });
const queryKey = [
"home",
"recentlyAddedIn" + c.CollectionType,
user?.Id!,
c.Id!,
];
return createCollectionConfig(
title || "",
queryKey,
includeItemTypes,
c.Id
);
});
const ss: Section[] = [
{
title: t("home.continue_watching"),
queryKey: ["home", "resumeItems"],
queryFn: async () =>
(
await getItemsApi(api).getResumeItems({
userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes: ["Movie", "Series", "Episode"],
})
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "horizontal",
},
{
title: t("home.next_up"),
queryKey: ["home", "nextUp-all"],
queryFn: async () =>
(
await getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount"],
limit: 20,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableResumable: false,
})
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "horizontal",
},
...latestMediaViews,
// ...(mediaListCollections?.map(
// (ml) =>
// ({
// title: ml.Name,
// queryKey: ["home", "mediaList", ml.Id!],
// queryFn: async () => ml,
// type: "MediaListSection",
// orientation: "vertical",
// } as Section)
// ) || []),
{
title: t("home.suggested_movies"),
queryKey: ["home", "suggestedMovies", user?.Id],
queryFn: async () =>
(
await getSuggestionsApi(api).getSuggestions({
userId: user?.Id,
limit: 10,
mediaType: ["Video"],
type: ["Movie"],
})
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "vertical",
},
{
title: t("home.suggested_episodes"),
queryKey: ["home", "suggestedEpisodes", user?.Id],
queryFn: async () => {
try {
const suggestions = await getSuggestions(api, user.Id);
const nextUpPromises = suggestions.map((series) =>
getNextUp(api, user.Id, series.Id)
);
const nextUpResults = await Promise.all(nextUpPromises);
return nextUpResults.filter((item) => item !== null) || [];
} catch (error) {
console.error("Error fetching data:", error);
return [];
}
},
type: "ScrollingCollectionList",
orientation: "horizontal",
},
];
return ss;
}, [api, user?.Id, collections]);
} else {
sections = useMemo(() => {
if (!api || !user?.Id) return [];
const ss: Section[] = [];
for (const key in settings.home?.sections) {
// @ts-expect-error
const section = settings.home?.sections[key];
const id = section.title || key;
ss.push({
title: id,
queryKey: ["home", id],
queryFn: async () => {
if (section.items) {
const response = await getItemsApi(api).getItems({
userId: user?.Id,
limit: section.items?.limit || 25,
recursive: true,
includeItemTypes: section.items?.includeItemTypes,
sortBy: section.items?.sortBy,
sortOrder: section.items?.sortOrder,
filters: section.items?.filters,
parentId: section.items?.parentId,
});
return response.data.Items || [];
} else if (section.nextUp) {
const response = await getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount"],
limit: section.items?.limit || 25,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableResumable: section.items?.enableResumable || false,
enableRewatching: section.items?.enableRewatching || false,
});
return response.data.Items || [];
}
return [];
},
type: "ScrollingCollectionList",
orientation: section?.orientation || "vertical",
});
}
return ss;
}, [api, user?.Id, settings.home?.sections]);
}
if (isConnected === false) {
return (
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
<Text className="text-3xl font-bold mb-2">{t("home.no_internet")}</Text>
<Text className="text-center opacity-70">
{t("home.no_internet_message")}
</Text>
<View className="mt-4">
<Button
color="purple"
onPress={() => router.push("/(auth)/downloads")}
justify="center"
iconRight={
<Ionicons name="arrow-forward" size={20} color="white" />
}
>
{t("home.go_to_downloads")}
</Button>
<Button
color="black"
onPress={() => {
checkConnection();
}}
justify="center"
className="mt-2"
iconRight={
loadingRetry ? null : (
<Ionicons name="refresh" size={20} color="white" />
)
}
>
{loadingRetry ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
"Retry"
)}
</Button>
</View>
</View>
);
}
if (e1)
return (
<View className="flex flex-col items-center justify-center h-full -mt-6">
<Text className="text-3xl font-bold mb-2">{t("home.oops")}</Text>
<Text className="text-center opacity-70">
{t("home.error_message")}
</Text>
</View>
);
if (l1)
return (
<View className="justify-center items-center h-full">
<Loader />
</View>
);
return (
<ScrollView
nestedScrollEnabled
contentInsetAdjustmentBehavior="automatic"
refreshControl={
<RefreshControl refreshing={loading} onRefresh={refetch} />
}
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
}}
>
<View className="flex flex-col space-y-4">
<LargeMovieCarousel />
{sections.map((section, index) => {
if (section.type === "ScrollingCollectionList") {
return (
<ScrollingCollectionList
key={index}
title={section.title}
queryKey={section.queryKey}
queryFn={section.queryFn}
orientation={section.orientation}
hideIfEmpty
/>
);
} else if (section.type === "MediaListSection") {
return (
<MediaListSection
key={index}
queryKey={section.queryKey}
queryFn={section.queryFn}
/>
);
}
return null;
})}
</View>
</ScrollView>
);
};
// Function to get suggestions
async function getSuggestions(api: Api, userId: string | undefined) {
if (!userId) return [];
const response = await getSuggestionsApi(api).getSuggestions({
userId,
limit: 10,
mediaType: ["Unknown"],
type: ["Series"],
});
return response.data.Items ?? [];
}
// Function to get the next up TV show for a series
async function getNextUp(
api: Api,
userId: string | undefined,
seriesId: string | undefined
) {
if (!userId || !seriesId) return null;
const response = await getTvShowsApi(api).getNextUp({
userId,
seriesId,
limit: 1,
});
return response.data.Items?.[0] ?? null;
}

View File

@@ -1,453 +0,0 @@
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
import { Loader } from "@/components/Loader";
import { MediaListSection } from "@/components/medialists/MediaListSection";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons";
import { Api } from "@jellyfin/sdk";
import {
BaseItemDto,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getItemsApi,
getSuggestionsApi,
getTvShowsApi,
getUserLibraryApi,
getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api";
import NetInfo from "@react-native-community/netinfo";
import { QueryFunction, useQuery } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
RefreshControl,
ScrollView,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
type ScrollingCollectionListSection = {
type: "ScrollingCollectionList";
title?: string;
queryKey: (string | undefined | null)[];
queryFn: QueryFunction<BaseItemDto[]>;
orientation?: "horizontal" | "vertical";
};
type MediaListSection = {
type: "MediaListSection";
queryKey: (string | undefined)[];
queryFn: QueryFunction<BaseItemDto>;
};
type Section = ScrollingCollectionListSection | MediaListSection;
export const SettingsIndex = () => {
const router = useRouter();
const { t } = useTranslation();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const [loading, setLoading] = useState(false);
const [
settings,
updateSettings,
pluginSettings,
setPluginSettings,
refreshStreamyfinPluginSettings,
] = useSettings();
const [isConnected, setIsConnected] = useState<boolean | null>(null);
const [loadingRetry, setLoadingRetry] = useState(false);
const insets = useSafeAreaInsets();
const checkConnection = useCallback(async () => {
setLoadingRetry(true);
const state = await NetInfo.fetch();
setIsConnected(state.isConnected);
setLoadingRetry(false);
}, []);
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
if (state.isConnected == false || state.isInternetReachable === false)
setIsConnected(false);
else setIsConnected(true);
});
NetInfo.fetch().then((state) => {
setIsConnected(state.isConnected);
});
// cleanCacheDirectory().catch((e) =>
// console.error("Something went wrong cleaning cache directory")
// );
return () => {
unsubscribe();
};
}, []);
const {
data,
isError: e1,
isLoading: l1,
} = useQuery({
queryKey: ["home", "userViews", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) {
return null;
}
const response = await getUserViewsApi(api).getUserViews({
userId: user.Id,
});
return response.data.Items || null;
},
enabled: !!api && !!user?.Id,
staleTime: 60 * 1000,
});
const userViews = useMemo(
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
[data, settings?.hiddenLibraries]
);
const collections = useMemo(() => {
const allow = ["movies", "tvshows"];
return (
userViews?.filter(
(c) => c.CollectionType && allow.includes(c.CollectionType)
) || []
);
}, [userViews]);
const invalidateCache = useInvalidatePlaybackProgressCache();
const refetch = useCallback(async () => {
setLoading(true);
await refreshStreamyfinPluginSettings();
await invalidateCache();
setLoading(false);
}, []);
const createCollectionConfig = useCallback(
(
title: string,
queryKey: string[],
includeItemTypes: BaseItemKind[],
parentId: string | undefined
): ScrollingCollectionListSection => ({
title,
queryKey,
queryFn: async () => {
if (!api) return [];
return (
(
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
limit: 20,
fields: ["PrimaryImageAspectRatio", "Path"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes,
parentId,
})
).data || []
);
},
type: "ScrollingCollectionList",
}),
[api, user?.Id]
);
let sections: Section[] = [];
if (!settings?.home || !settings?.home?.sections) {
sections = useMemo(() => {
if (!api || !user?.Id) return [];
const latestMediaViews = collections.map((c) => {
const includeItemTypes: BaseItemKind[] =
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
const title = t("home.recently_added_in", { libraryName: c.Name });
const queryKey = [
"home",
"recentlyAddedIn" + c.CollectionType,
user?.Id!,
c.Id!,
];
return createCollectionConfig(
title || "",
queryKey,
includeItemTypes,
c.Id
);
});
const ss: Section[] = [
{
title: t("home.continue_watching"),
queryKey: ["home", "resumeItems"],
queryFn: async () =>
(
await getItemsApi(api).getResumeItems({
userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes: ["Movie", "Series", "Episode"],
})
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "horizontal",
},
{
title: t("home.next_up"),
queryKey: ["home", "nextUp-all"],
queryFn: async () =>
(
await getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount"],
limit: 20,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableResumable: false,
})
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "horizontal",
},
...latestMediaViews,
// ...(mediaListCollections?.map(
// (ml) =>
// ({
// title: ml.Name,
// queryKey: ["home", "mediaList", ml.Id!],
// queryFn: async () => ml,
// type: "MediaListSection",
// orientation: "vertical",
// } as Section)
// ) || []),
{
title: t("home.suggested_movies"),
queryKey: ["home", "suggestedMovies", user?.Id],
queryFn: async () =>
(
await getSuggestionsApi(api).getSuggestions({
userId: user?.Id,
limit: 10,
mediaType: ["Video"],
type: ["Movie"],
})
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "vertical",
},
{
title: t("home.suggested_episodes"),
queryKey: ["home", "suggestedEpisodes", user?.Id],
queryFn: async () => {
try {
const suggestions = await getSuggestions(api, user.Id);
const nextUpPromises = suggestions.map((series) =>
getNextUp(api, user.Id, series.Id)
);
const nextUpResults = await Promise.all(nextUpPromises);
return nextUpResults.filter((item) => item !== null) || [];
} catch (error) {
console.error("Error fetching data:", error);
return [];
}
},
type: "ScrollingCollectionList",
orientation: "horizontal",
},
];
return ss;
}, [api, user?.Id, collections]);
} else {
sections = useMemo(() => {
if (!api || !user?.Id) return [];
const ss: Section[] = [];
for (const key in settings.home?.sections) {
// @ts-expect-error
const section = settings.home?.sections[key];
const id = section.title || key;
ss.push({
title: id,
queryKey: ["home", id],
queryFn: async () => {
if (section.items) {
const response = await getItemsApi(api).getItems({
userId: user?.Id,
limit: section.items?.limit || 25,
recursive: true,
includeItemTypes: section.items?.includeItemTypes,
sortBy: section.items?.sortBy,
sortOrder: section.items?.sortOrder,
filters: section.items?.filters,
parentId: section.items?.parentId,
});
return response.data.Items || [];
} else if (section.nextUp) {
const response = await getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount"],
limit: section.items?.limit || 25,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableResumable: section.items?.enableResumable || false,
enableRewatching: section.items?.enableRewatching || false,
});
return response.data.Items || [];
}
return [];
},
type: "ScrollingCollectionList",
orientation: section?.orientation || "vertical",
});
}
return ss;
}, [api, user?.Id, settings.home?.sections]);
}
if (isConnected === false) {
return (
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
<Text className="text-3xl font-bold mb-2">{t("home.no_internet")}</Text>
<Text className="text-center opacity-70">
{t("home.no_internet_message")}
</Text>
<View className="mt-4">
<Button
color="purple"
onPress={() => router.push("/(auth)/downloads")}
justify="center"
iconRight={
<Ionicons name="arrow-forward" size={20} color="white" />
}
>
{t("home.go_to_downloads")}
</Button>
<Button
color="black"
onPress={() => {
checkConnection();
}}
justify="center"
className="mt-2"
iconRight={
loadingRetry ? null : (
<Ionicons name="refresh" size={20} color="white" />
)
}
>
{loadingRetry ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
"Retry"
)}
</Button>
</View>
</View>
);
}
if (e1)
return (
<View className="flex flex-col items-center justify-center h-full -mt-6">
<Text className="text-3xl font-bold mb-2">{t("home.oops")}</Text>
<Text className="text-center opacity-70">
{t("home.error_message")}
</Text>
</View>
);
if (l1)
return (
<View className="justify-center items-center h-full">
<Loader />
</View>
);
return (
<ScrollView
nestedScrollEnabled
contentInsetAdjustmentBehavior="automatic"
refreshControl={
<RefreshControl refreshing={loading} onRefresh={refetch} />
}
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
}}
>
<View className="flex flex-col space-y-4">
<LargeMovieCarousel />
{sections.map((section, index) => {
if (section.type === "ScrollingCollectionList") {
return (
<ScrollingCollectionList
key={index}
title={section.title}
queryKey={section.queryKey}
queryFn={section.queryFn}
orientation={section.orientation}
hideIfEmpty
/>
);
} else if (section.type === "MediaListSection") {
return (
<MediaListSection
key={index}
queryKey={section.queryKey}
queryFn={section.queryFn}
/>
);
}
return null;
})}
</View>
</ScrollView>
);
};
// Function to get suggestions
async function getSuggestions(api: Api, userId: string | undefined) {
if (!userId) return [];
const response = await getSuggestionsApi(api).getSuggestions({
userId,
limit: 10,
mediaType: ["Unknown"],
type: ["Series"],
});
return response.data.Items ?? [];
}
// Function to get the next up TV show for a series
async function getNextUp(
api: Api,
userId: string | undefined,
seriesId: string | undefined
) {
if (!userId || !seriesId) return null;
const response = await getTvShowsApi(api).getNextUp({
userId,
seriesId,
limit: 1,
});
return response.data.Items?.[0] ?? null;
}

View File

@@ -54,12 +54,12 @@ import AudioSlider from "./AudioSlider";
import BrightnessSlider from "./BrightnessSlider";
import { ControlProvider } from "./contexts/ControlContext";
import { VideoProvider } from "./contexts/VideoContext";
import DropdownView from "./dropdown/DropdownView";
import { EpisodeList } from "./EpisodeList";
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
import SkipButton from "./SkipButton";
import { useControlsTimeout } from "./useControlsTimeout";
import { VideoTouchOverlay } from "./VideoTouchOverlay";
import DropdownView from "./dropdown/DropdownView";
interface Props {
item: BaseItemDto;
@@ -220,8 +220,13 @@ export const Controls: React.FC<Props> = ({
stop();
if (!bitrateValue) {
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
return;
}
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
router.replace(`player/transcoding-player?${queryParams}`);
}, [previousItem, settings, subtitleIndex, audioIndex]);
const goToNextItem = useCallback(() => {
@@ -255,8 +260,13 @@ export const Controls: React.FC<Props> = ({
stop();
if (!bitrateValue) {
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
return;
}
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
router.replace(`player/transcoding-player?${queryParams}`);
}, [nextItem, settings, subtitleIndex, audioIndex]);
const updateTimes = useCallback(
@@ -415,8 +425,13 @@ export const Controls: React.FC<Props> = ({
stop();
if (!bitrateValue) {
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
return;
}
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
router.replace(`player/transcoding-player?${queryParams}`);
} catch (error) {
console.error("Error in gotoEpisode:", error);
}
@@ -554,10 +569,7 @@ export const Controls: React.FC<Props> = ({
<View className="flex flex-row items-center space-x-2 ">
{!Platform.isTV && (
<TouchableOpacity
onPress={startPictureInPicture}
className="aspect-square flex flex-col rounded-xl items-center justify-center p-2"
>
<TouchableOpacity onPress={startPictureInPicture}>
<MaterialIcons
name="picture-in-picture"
size={24}

View File

@@ -449,23 +449,12 @@ export const useJellyseerr = () => {
);
};
const jellyseerrRegion = useMemo(
() => jellyseerrUser?.settings?.region || "US",
[jellyseerrUser]
);
const jellyseerrLocale = useMemo(() => {
return jellyseerrUser?.settings?.locale || "en";
}, [jellyseerrUser]);
return {
jellyseerrApi,
jellyseerrUser,
setJellyseerrUser,
clearAllJellyseerData,
isJellyseerrResult,
jellyseerrRegion,
jellyseerrLocale,
requestMedia,
};
};

View File

@@ -5,10 +5,7 @@ import de from "./translations/de.json";
import en from "./translations/en.json";
import es from "./translations/es.json";
import fr from "./translations/fr.json";
import nl from "./translations/nl.json";
import sv from "./translations/sv.json";
import it from "./translations/it.json";
import zhTW from './translations/zh-TW.json';
import { getLocales } from "expo-localization";
export const APP_LANGUAGES = [
@@ -16,10 +13,7 @@ export const APP_LANGUAGES = [
{ label: "English", value: "en" },
{ label: "Español", value: "es" },
{ label: "Français", value: "fr" },
{ label: "Nederlands", value: "nl" },
{ label: "Svenska", value: "sv" },
{ label: "Italiano", value: "it" },
{ label: "繁體中文", value: "zh-TW" },
];
i18n.use(initReactI18next).init({
@@ -29,10 +23,7 @@ i18n.use(initReactI18next).init({
en: { translation: en },
es: { translation: es },
fr: { translation: fr },
nl: { translation: nl },
sv: { translation: sv },
it: { translation: it },
"zh-TW": { translation: zhTW },
},
lng: getLocales()[0].languageCode || "en",

View File

@@ -452,19 +452,11 @@ extension VlcPlayerView: SimpleAppLifecycleListener {
}
func applicationDidEnterForeground() {
logger.debug("Entering foreground, is player visible? \(self.vlc.getPlayerView().superview != nil)")
logger.debug("Entering foreground")
if !self.vlc.getPlayerView().isDescendant(of: self) {
logger.debug("Player view is missing. Adding back as subview")
self.addSubview(self.vlc.getPlayerView())
}
// Current solution to fixing black screen when re-entering application
if let videoTrack = self.vlc.player.videoTracks.first { $0.isSelected == true }, !self.vlc.isMediaPlaying() {
videoTrack.isSelected = false
videoTrack.isSelectedExclusively = true
self.vlc.player.play()
self.vlc.player.pause()
}
}
}

View File

@@ -27,6 +27,7 @@
"@gorhom/bottom-sheet": "^5.1.0",
"@jellyfin/sdk": "^0.11.0",
"@kesha-antonov/react-native-background-downloader": "3.2.6",
"@react-native-async-storage/async-storage": "1.23.1",
"@react-native-community/netinfo": "11.4.1",
"@react-native-menu/menu": "^1.2.2",
"@react-navigation/bottom-tabs": "^7.2.0",
@@ -34,6 +35,9 @@
"@react-navigation/native": "^7.0.14",
"@shopify/flash-list": "1.7.3",
"@tanstack/react-query": "^5.66.0",
"@types/lodash": "^4.17.15",
"@types/react-native-vector-icons": "^6.4.18",
"@types/uuid": "^10.0.0",
"add": "^2.0.6",
"axios": "^1.7.9",
"expo": "^52.0.31",
@@ -58,7 +62,7 @@
"expo-router": "~4.0.17",
"expo-screen-orientation": "~8.0.4",
"expo-sensors": "~14.0.2",
"expo-splash-screen": "~0.29.22",
"expo-splash-screen": "~0.29.21",
"expo-status-bar": "~2.0.1",
"expo-system-ui": "~4.0.8",
"expo-task-manager": "~12.0.5",
@@ -74,7 +78,7 @@
"react-i18next": "^15.4.0",
"react-native": "npm:react-native-tvos@~0.77.0-0",
"react-native-awesome-slider": "^2.9.0",
"react-native-bottom-tabs": "0.8.7",
"react-native-bottom-tabs": "0.8.6",
"react-native-circular-progress": "^1.4.1",
"react-native-compressor": "^1.10.3",
"react-native-country-flag": "^2.0.2",
@@ -120,10 +124,7 @@
"patch-package": "^8.0.0",
"postinstall-postinstall": "^2.1.0",
"react-test-renderer": "19.0.0",
"typescript": "~5.7.3",
"@types/lodash": "^4.17.15",
"@types/react-native-vector-icons": "^6.4.18",
"@types/uuid": "^10.0.0"
"typescript": "~5.7.3"
},
"private": true,
"expo": {

File diff suppressed because one or more lines are too long

View File

@@ -18,13 +18,11 @@ import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import BackGroundDownloader from "@kesha-antonov/react-native-background-downloader";
import { focusManager, useQuery, useQueryClient } from "@tanstack/react-query";
import axios from "axios";
import * as Application from "expo-application";
import * as FileSystem from "expo-file-system";
import { FileInfo } from "expo-file-system";
import Notifications from "expo-notifications";
import { useRouter } from "expo-router";
import { atom, useAtom } from "jotai";
import React, {
@@ -38,6 +36,11 @@ import { useTranslation } from "react-i18next";
import { AppState, AppStateStatus, Platform } from "react-native";
import { toast } from "sonner-native";
import { apiAtom } from "./JellyfinProvider";
const BackGroundDownloader = !Platform.isTV
? (require("@kesha-antonov/react-native-background-downloader") as typeof import("@kesha-antonov/react-native-background-downloader"))
: null;
// import * as Notifications from "expo-notifications";
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
export type DownloadedItem = {
item: Partial<BaseItemDto>;
@@ -55,6 +58,8 @@ const DownloadContext = createContext<ReturnType<
> | null>(null);
function useDownloadProvider() {
if (Platform.isTV) return;
const queryClient = useQueryClient();
const { t } = useTranslation();
const [settings] = useSettings();
@@ -742,8 +747,5 @@ export function useDownload() {
if (context === null) {
throw new Error("useDownload must be used within a DownloadProvider");
}
if (Platform.isTV) {
throw new Error("useDownload is not supported on TVOS");
}
return context;
}

View File

@@ -1,107 +0,0 @@
import { storage } from "@/utils/mmkv";
import { JobStatus } from "@/utils/optimize-server";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import * as Application from "expo-application";
import * as FileSystem from "expo-file-system";
import { atom, useAtom } from "jotai";
import React, { createContext, useCallback, useContext, useMemo } from "react";
export type DownloadedItem = {
item: Partial<BaseItemDto>;
mediaSource: MediaSourceInfo;
};
export const processesAtom = atom<JobStatus[]>([]);
const DownloadContext = createContext<ReturnType<
typeof useDownloadProvider
> | null>(null);
/**
* Dummy download provider for tvOS
*/
function useDownloadProvider() {
const [processes, setProcesses] = useAtom<JobStatus[]>(processesAtom);
const downloadedFiles: DownloadedItem[] = [];
const removeProcess = useCallback(async (id: string) => {}, []);
const startDownload = useCallback(async (process: JobStatus) => {
return null;
}, []);
const startBackgroundDownload = useCallback(
async (url: string, item: BaseItemDto, mediaSource: MediaSourceInfo) => {
return null;
},
[]
);
const deleteAllFiles = async (): Promise<void> => {};
const deleteFile = async (id: string): Promise<void> => {};
const deleteItems = async (items: BaseItemDto[]) => {};
const cleanCacheDirectory = async () => {};
const deleteFileByType = async (type: BaseItemDto["Type"]) => {};
const appSizeUsage = useMemo(async () => {
return 0;
}, []);
function getDownloadedItem(itemId: string): DownloadedItem | null {
return null;
}
function saveDownloadedItemInfo(item: BaseItemDto, size: number = 0) {}
function getDownloadedItemSize(itemId: string): number {
const size = storage.getString("downloadedItemSize-" + itemId);
return size ? parseInt(size) : 0;
}
const APP_CACHE_DOWNLOAD_DIRECTORY = `${FileSystem.cacheDirectory}${Application.applicationId}/Downloads/`;
return {
processes,
startBackgroundDownload,
downloadedFiles,
deleteAllFiles,
deleteFile,
deleteItems,
saveDownloadedItemInfo,
removeProcess,
setProcesses,
startDownload,
getDownloadedItem,
deleteFileByType,
appSizeUsage,
getDownloadedItemSize,
APP_CACHE_DOWNLOAD_DIRECTORY,
cleanCacheDirectory,
};
}
export function DownloadProvider({ children }: { children: React.ReactNode }) {
const downloadProviderValue = useDownloadProvider();
return (
<DownloadContext.Provider value={downloadProviderValue}>
{children}
</DownloadContext.Provider>
);
}
export function useDownload() {
const context = useContext(DownloadContext);
if (context === null) {
throw new Error("useDownload must be used within a DownloadProvider");
}
return context;
}

View File

@@ -1,7 +1,5 @@
import "@/augmentations";
import { useInterval } from "@/hooks/useInterval";
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
import { useSettings } from "@/utils/atoms/settings";
import { storage } from "@/utils/mmkv";
import { Api, Jellyfin } from "@jellyfin/sdk";
import { UserDto } from "@jellyfin/sdk/lib/generated-client/models";
@@ -9,7 +7,6 @@ import { getUserApi } from "@jellyfin/sdk/lib/utils/api";
import { useMutation, useQuery } from "@tanstack/react-query";
import axios, { AxiosError } from "axios";
import { router, useSegments } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import { atom, useAtom } from "jotai";
import React, {
createContext,
@@ -20,10 +17,16 @@ import React, {
useMemo,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
import { getDeviceName } from "react-native-device-info";
import uuid from "react-native-uuid";
import { getDeviceName } from "react-native-device-info";
import { useTranslation } from "react-i18next";
import { useSettings } from "@/utils/atoms/settings";
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
import {
useSplashScreenLoading,
useSplashScreenVisible,
} from "./SplashScreenProvider";
interface Server {
address: string;
@@ -85,6 +88,22 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
] = useSettings();
const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr();
useQuery({
queryKey: ["user", api],
queryFn: async () => {
if (!api) return null;
const response = await getUserApi(api).getCurrentUser();
if (response.data) setUser(response.data);
return user;
},
enabled: !!api,
refetchOnWindowFocus: true,
refetchInterval: 1000 * 60,
refetchIntervalInBackground: true,
refetchOnMount: true,
refetchOnReconnect: true,
});
const headers = useMemo(() => {
if (!deviceId) return {};
return {
@@ -160,13 +179,14 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
}
}, [api, secret, headers]);
useInterval(pollQuickConnect, isPolling ? 1000 : null);
useEffect(() => {
(async () => {
await refreshStreamyfinPluginSettings();
})();
}, []);
useInterval(pollQuickConnect, isPolling ? 1000 : null);
useInterval(refreshStreamyfinPluginSettings, 60 * 5 * 1000); // 5 min
const discoverServers = async (url: string): Promise<Server[]> => {
@@ -283,7 +303,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
mutationFn: async () => {
storage.delete("token");
setUser(null);
setApi(null);
setPluginSettings(undefined);
await clearAllJellyseerData();
},
@@ -292,44 +311,33 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
},
});
const [loaded, setLoaded] = useState(false);
const [initialLoaded, setInitialLoaded] = useState(false);
useEffect(() => {
if (initialLoaded) {
setLoaded(true);
}
}, [initialLoaded]);
useEffect(() => {
const initializeJellyfin = async () => {
if (!jellyfin) return;
const { isLoading, isFetching } = useQuery({
queryKey: [
"initializeJellyfin",
user?.Id,
api?.basePath,
jellyfin?.clientInfo,
],
queryFn: async () => {
try {
const token = getTokenFromStorage();
const serverUrl = getServerUrlFromStorage();
const storedUser = getUserFromStorage();
if (serverUrl && token) {
const user = getUserFromStorage();
if (serverUrl && token && user?.Id && jellyfin) {
const apiInstance = jellyfin.createApi(serverUrl, token);
setApi(apiInstance);
if (storedUser?.Id) {
setUser(storedUser);
}
const response = await getUserApi(apiInstance).getCurrentUser();
setUser(response.data);
setUser(user);
}
return true;
} catch (e) {
console.error(e);
} finally {
setInitialLoaded(true);
return false;
}
};
initializeJellyfin();
}, [jellyfin]);
},
staleTime: 0,
enabled: !user?.Id || !api || !jellyfin,
});
const contextValue: JellyfinContextValue = {
discoverServers,
@@ -341,17 +349,17 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
initiateQuickConnect,
};
useEffect(() => {
if (loaded) {
SplashScreen.hideAsync();
}
}, [loaded]);
let isLoadingOrFetching = isLoading || isFetching;
useProtectedRoute(user, isLoadingOrFetching);
useProtectedRoute(user, loaded);
// show splash screen until everything loaded
useSplashScreenLoading(isLoadingOrFetching);
const splashScreenVisible = useSplashScreenVisible();
return (
<JellyfinContext.Provider value={contextValue}>
{children}
{/* don't render login page when loading and splash screen visible */}
{isLoadingOrFetching && splashScreenVisible ? undefined : children}
</JellyfinContext.Provider>
);
};
@@ -363,24 +371,20 @@ export const useJellyfin = (): JellyfinContextValue => {
return context;
};
function useProtectedRoute(user: UserDto | null, loaded = false) {
function useProtectedRoute(user: UserDto | null, loading = false) {
const segments = useSegments();
useEffect(() => {
if (loaded === false) return;
console.log("Loaded", user);
if (loading) return;
const inAuthGroup = segments[0] === "(auth)";
if (!user?.Id && inAuthGroup) {
console.log("Redirected to login");
router.replace("/login");
} else if (user?.Id && !inAuthGroup) {
console.log("Redirected to home");
router.replace("/(auth)/(tabs)/(home)/");
}
}, [user, segments, loaded]);
}, [user, segments, loading]);
}
export function getTokenFromStorage(): string | null {

View File

@@ -0,0 +1,103 @@
import {
createContext,
ReactNode,
useContext,
useEffect,
useState,
useRef,
} from "react";
import * as SplashScreen from "expo-splash-screen";
type SplashScreenContextValue = {
registerLoadingComponent: () => () => void;
splashScreenVisible: boolean;
};
const SplashScreenContext = createContext<SplashScreenContextValue | undefined>(
undefined
);
// Prevent splash screen from auto-hiding
void SplashScreen.preventAutoHideAsync();
export const SplashScreenProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
const [splashScreenVisible, setSplashScreenVisible] = useState(true);
const loadingComponentsCount = useRef(0);
const isHidingRef = useRef(false);
const hideScreenIfNoLoadingComponents = async () => {
if (loadingComponentsCount.current === 0 && !isHidingRef.current) {
try {
isHidingRef.current = true;
await SplashScreen.hideAsync();
setSplashScreenVisible(false);
} catch (error) {
console.warn("Failed to hide splash screen:", error);
} finally {
isHidingRef.current = false;
}
}
};
const registerLoadingComponent = () => {
loadingComponentsCount.current += 1;
return () => {
loadingComponentsCount.current -= 1;
void hideScreenIfNoLoadingComponents();
};
};
const contextValue: SplashScreenContextValue = {
registerLoadingComponent,
splashScreenVisible,
};
return (
<SplashScreenContext.Provider value={contextValue}>
{children}
</SplashScreenContext.Provider>
);
};
/**
* Show the Splash Screen until component is ready to be displayed.
*
* @param isLoading The loading state of the component
*
* ## Usage
* ```
* const isLoading = loadSomething()
* useSplashScreenLoading(isLoading) // splash screen visible until isLoading is false
* ```
*/
export function useSplashScreenLoading(isLoading: boolean) {
const context = useContext(SplashScreenContext);
if (!context) {
throw new Error(
"useSplashScreenLoading must be used within a SplashScreenProvider"
);
}
useEffect(() => {
if (isLoading) {
return context.registerLoadingComponent();
}
}, [isLoading]);
}
/**
* Get the visibility of the Splash Screen.
* @returns the visibility of the Splash Screen
*/
export function useSplashScreenVisible() {
const context = useContext(SplashScreenContext);
if (!context) {
throw new Error(
"useSplashScreenVisible must be used within a SplashScreenProvider"
);
}
return context.splashScreenVisible;
}

View File

@@ -132,8 +132,7 @@
"show_custom_menu_links": "Benutzerdefinierte Menülinks anzeigen",
"hide_libraries": "Bibliotheken ausblenden",
"select_liraries_you_want_to_hide": "Wähl die Bibliotheken aus, die du im Bibliothekstab und auf der Startseite ausblenden möchtest.",
"disable_haptic_feedback": "Haptisches Feedback deaktivieren",
"default_quality": "Standardqualität"
"disable_haptic_feedback": "Haptisches Feedback deaktivieren"
},
"downloads": {
"downloads_title": "Downloads",
@@ -355,7 +354,7 @@
"index": "Index:"
},
"item_card": {
"next_up": "Als Nächstes",
"next_up": "Als nächstes",
"no_items_to_display": "Keine Elemente zum Anzeigen",
"cast_and_crew": "Besetzung und Crew",
"series": "Serien",

View File

@@ -132,8 +132,7 @@
"show_custom_menu_links": "Show Custom Menu Links",
"hide_libraries": "Hide Libraries",
"select_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
"disable_haptic_feedback": "Disable Haptic Feedback",
"default_quality": "Default quality"
"disable_haptic_feedback": "Disable Haptic Feedback"
},
"downloads": {
"downloads_title": "Downloads",

View File

@@ -87,7 +87,7 @@
},
"audio": {
"audio_title": "Audio",
"set_audio_track": "Establecer pista del elemento anterior",
"set_audio_track": "Establecer pista de audio del elemento anterior",
"audio_language": "Idioma de audio",
"audio_hint": "Elige un idioma de audio por defecto.",
"none": "Ninguno",
@@ -97,7 +97,7 @@
"subtitle_title": "Subtítulos",
"subtitle_language": "Idioma de subtítulos",
"subtitle_mode": "Modo de subtítulos",
"set_subtitle_track": "Establecer pista del elemento anterior",
"set_subtitle_track": "Establecer pista de subtítulos del elemento anterior",
"subtitle_size": "Tamaño de subtítulos",
"subtitle_hint": "Configurar preferencias de subtítulos.",
"none": "Ninguno",
@@ -132,8 +132,7 @@
"show_custom_menu_links": "Mostrar enlaces de menú personalizados",
"hide_libraries": "Ocultar bibliotecas",
"select_liraries_you_want_to_hide": "Selecciona las bibliotecas que quieres ocultar de la pestaña Bibliotecas y de Inicio.",
"disable_haptic_feedback": "Desactivar feedback háptico",
"default_quality": "Calidad por defecto"
"disable_haptic_feedback": "Desactivar feedback háptico"
},
"downloads": {
"downloads_title": "Descargas",

View File

@@ -132,9 +132,7 @@
"show_custom_menu_links": "Afficher les liens personnalisés",
"hide_libraries": "Cacher des bibliothèques",
"select_liraries_you_want_to_hide": "Sélectionnez les bibliothèques que vous souhaitez masquer dans longlet Bibliothèque et les sections de la page daccueil.",
"disable_haptic_feedback": "Désactiver le retour haptique",
"default_quality": "Qualité par défaut"
"disable_haptic_feedback": "Désactiver le retour haptique"
},
"downloads": {
"downloads_title": "Téléchargements",

View File

@@ -1,458 +0,0 @@
{
"login": {
"username_required": "Nome utente è obbligatorio",
"error_title": "Errore",
"login_title": "Accesso",
"login_to_title": "Accedi a",
"username_placeholder": "Nome utente",
"password_placeholder": "Password",
"login_button": "Accedi",
"quick_connect": "Connessione Rapida",
"enter_code_to_login": "Inserire {{code}} per accedere",
"failed_to_initiate_quick_connect": "Impossibile avviare la Connessione Rapida",
"got_it": "Capito",
"connection_failed": "Connessione fallita",
"could_not_connect_to_server": "Impossibile connettersi al server. Controllare l'URL e la connessione di rete.",
"an_unexpected_error_occured": "Si è verificato un errore inaspettato",
"change_server": "Cambiare il server",
"invalid_username_or_password": "Nome utente o password non validi",
"user_does_not_have_permission_to_log_in": "L'utente non ha il permesso di accedere",
"server_is_taking_too_long_to_respond_try_again_later": "Il server sta impiegando troppo tempo per rispondere, riprovare più tardi",
"server_received_too_many_requests_try_again_later": "Il server ha ricevuto troppe richieste, riprovare più tardi.",
"there_is_a_server_error": "Si è verificato un errore del server",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Si è verificato un errore imprevisto. L'URL del server è stato inserito correttamente?"
},
"server": {
"enter_url_to_jellyfin_server": "Inserisci l'URL del tuo server Jellyfin",
"server_url_placeholder": "http(s)://tuo-server.com",
"connect_button": "Connetti",
"previous_servers": "server precedente",
"clear_button": "Cancella",
"search_for_local_servers": "Ricerca dei server locali",
"searching": "Cercando...",
"servers": "Servers"
},
"home": {
"no_internet": "Nessun Internet",
"no_items": "Nessun oggetto",
"no_internet_message": "Non c'è da preoccuparsi, è ancora possibile guardare\n i contenuti scaricati.",
"go_to_downloads": "Vai agli elementi scaricati",
"oops": "Oops!",
"error_message": "Qualcosa è andato storto. \nEffetturare il logout e riaccedere.",
"continue_watching": "Continua a guardare",
"next_up": "Prossimo",
"recently_added_in": "Aggiunti di recente a {{libraryName}}",
"suggested_movies": "Film consigliati",
"suggested_episodes": "Episodi consigliati",
"intro": {
"welcome_to_streamyfin": "Benvenuto a Streamyfin",
"a_free_and_open_source_client_for_jellyfin": "Un client gratuito e open-source per Jellyfin.",
"features_title": "Funzioni",
"features_description": "Streamyfin dispone di numerose funzioni e si integra con un'ampia gamma di software che si possono trovare nel menu delle impostazioni:",
"jellyseerr_feature_description": "Connettetevi alla vostra istanza Jellyseerr e richiedete i film direttamente nell'app.",
"downloads_feature_title": "Scaricamento",
"downloads_feature_description": "Scaricate film e serie tv da vedere offline. Utilizzate il metodo predefinito o installate il server di ottimizzazione per scaricare i file in background.",
"chromecast_feature_description": "Trasmettete film e serie tv ai vostri dispositivi Chromecast.",
"centralised_settings_plugin_title": "Impostazioni dei Plugin Centralizzate",
"centralised_settings_plugin_description": "Configura le impostazioni da una posizione centralizzata sul server Jellyfin. Tutte le impostazioni del client per tutti gli utenti saranno sincronizzate automaticamente.",
"done_button": "Fatto",
"go_to_settings_button": "Vai alle impostazioni",
"read_more": "Leggi di più"
},
"settings": {
"settings_title": "Impostazioni",
"log_out_button": "Esci",
"user_info": {
"user_info_title": "Info utente",
"user": "Utente",
"server": "Server",
"token": "Token",
"app_version": "Versione dell'App"
},
"quick_connect": {
"quick_connect_title": "Connessione Rapida",
"authorize_button": "Autorizza Connessione Rapida",
"enter_the_quick_connect_code": "Inserisci il codice per la Connessione Rapida...",
"success": "Successo",
"quick_connect_autorized": "Connessione Rapida autorizzata",
"error": "Errore",
"invalid_code": "Codice invalido",
"authorize": "Autorizza"
},
"media_controls": {
"media_controls_title": "Controlli multimediali",
"forward_skip_length": "Lunghezza del salto in avanti",
"rewind_length": "Lunghezza del riavvolgimento",
"seconds_unit": "s"
},
"audio": {
"audio_title": "Audio",
"set_audio_track": "Imposta la traccia audio dall'elemento precedente",
"audio_language": "Lingua Audio",
"audio_hint": "Scegli la lingua audio predefinita.",
"none": "Nessuno",
"language": "Lingua"
},
"subtitles": {
"subtitle_title": "Sottotitoli",
"subtitle_language": "Lingua dei sottotitoli",
"subtitle_mode": "Modalità dei sottotitoli",
"set_subtitle_track": "Imposta la traccia dei sottotitoli dall'elemento precedente",
"subtitle_size": "Dimensione dei sottotitoli",
"subtitle_hint": "Configura la preferenza dei sottotitoli.",
"none": "Nessuno",
"language": "Lingua",
"loading": "Caricamento",
"modes": {
"Default": "Predefinito",
"Smart": "Intelligente",
"Always": "Sempre",
"None": "Nessuno",
"OnlyForced": "Solo forzati"
}
},
"other": {
"other_title": "Altro",
"auto_rotate": "Rotazione automatica",
"video_orientation": "Orientamento del video",
"orientation": "Orientamento",
"orientations": {
"DEFAULT": "Predefinito",
"ALL": "Tutto",
"PORTRAIT": "Verticale",
"PORTRAIT_UP": "Verticale sopra",
"PORTRAIT_DOWN": "Verticale sotto",
"LANDSCAPE": "Orizzontale",
"LANDSCAPE_LEFT": "Orizzontale sinitra",
"LANDSCAPE_RIGHT": "Orizzontale destra",
"OTHER": "Altro",
"UNKNOWN": "Sconosciuto"
},
"safe_area_in_controls": "Area sicura per i controlli",
"show_custom_menu_links": "Mostra i link del menu personalizzato",
"hide_libraries": "Nascondi Librerie",
"select_liraries_you_want_to_hide": "Selezionate le librerie che volete nascondere dalla scheda Libreria e dalle sezioni della pagina iniziale.",
"disable_haptic_feedback": "Disabilita il feedback aptico",
"default_quality": "Qualità predefinita"
},
"downloads": {
"downloads_title": "Scaricamento",
"download_method": "Metodo per lo scaricamento",
"remux_max_download": "Numero di Remux da scaricare al massimo",
"auto_download": "Scaricamento automatico",
"optimized_versions_server": "Versioni del server di ottimizzazione",
"save_button": "Salva",
"optimized_server": "Server di ottimizzazione",
"optimized": "Ottimizzato",
"default": "Predefinito",
"optimized_version_hint": "Inserire l'URL del server di ottimizzazione. L'URL deve includere http o https e, facoltativamente, la porta.",
"read_more_about_optimized_server": "Per saperne di più sul server di ottimizzazione.",
"url":"URL",
"server_url_placeholder": "http(s)://dominio.org:porta"
},
"plugins": {
"plugins_title": "Plugin",
"jellyseerr": {
"jellyseerr_warning": "Questa integrazione è in fase iniziale. Aspettarsi cambiamenti.",
"server_url": "URL del Server",
"server_url_hint": "Esempio: http(s)://tuo-host.url\n(aggiungere la porta se richiesto)",
"server_url_placeholder": "URL di Jellyseerr...",
"password": "Password",
"password_placeholder": "Inserire la password per l'utente {{username}} di Jellyfin",
"save_button": "Salva",
"clear_button": "Cancella",
"login_button": "Accedi",
"total_media_requests": "Totale di richieste di media",
"movie_quota_limit": "Limite di quota per i film",
"movie_quota_days": "Giorni di quota per i film",
"tv_quota_limit": "Limite di quota per le serie TV",
"tv_quota_days": "Giorni di quota per le serie TV",
"reset_jellyseerr_config_button": "Ripristina la configurazione di Jellyseerr",
"unlimited": "Illimitato"
},
"marlin_search": {
"enable_marlin_search": "Abilita la ricerca Marlin ",
"url": "URL",
"server_url_placeholder": "http(s)://dominio.org:porta",
"marlin_search_hint": "Inserire l'URL del server Marlin. L'URL deve includere http o https e, facoltativamente, la porta.",
"read_more_about_marlin": "Leggi di più su Marlin.",
"save_button": "Salva",
"toasts": {
"saved": "Salvato"
}
}
},
"storage": {
"storage_title": "Spazio",
"app_usage": "App {{usedSpace}}%",
"device_usage": "Dispositivo {{availableSpace}}%",
"size_used": "{{used}} di {{total}} usato",
"delete_all_downloaded_files": "Cancella Tutti i File Scaricati"
},
"intro": {
"show_intro": "Mostra intro",
"reset_intro": "Ripristina intro"
},
"logs": {
"logs_title": "Log",
"no_logs_available": "Nessun log disponibile",
"delete_all_logs": "Cancella tutti i log"
},
"languages": {
"title": "Lingue",
"app_language": "Lingua dell'App",
"app_language_description": "Selezione la lingua dell'app.",
"system": "Sistema"
},
"toasts":{
"error_deleting_files": "Errore nella cancellazione dei file",
"background_downloads_enabled": "Scaricamento in background abilitato",
"background_downloads_disabled": "Scaricamento in background disabilitato",
"connected": "Connesso",
"could_not_connect": "Non è stato possibile connettersi",
"invalid_url": "URL invalido"
}
},
"downloads": {
"downloads_title": "Scaricati",
"tvseries": "Serie TV",
"movies": "Film",
"queue": "Coda",
"queue_hint": "La coda e gli elementi scaricati saranno persi con il riavvio dell'app",
"no_items_in_queue": "Nessun elemento in coda",
"no_downloaded_items": "Nessun elemento scaricato",
"delete_all_movies_button": "Cancella tutti i film",
"delete_all_tvseries_button": "Cancella tutte le serie TV",
"delete_all_button": "Cancella tutti",
"active_download": "Scaricamento in corso",
"no_active_downloads": "Nessun scaricamento in corso",
"active_downloads": "Scaricamenti in corso",
"new_app_version_requires_re_download": "La nuova verione dell'app richiede di scaricare nuovamente i contenuti",
"new_app_version_requires_re_download_description": "Il nuovo aggiornamento richiede di scaricare nuovamente i contenuti. Rimuovere tutti i contenuti scaricati e riprovare.",
"back": "Indietro",
"delete": "Cancella",
"something_went_wrong": "Qualcosa è andato storto",
"could_not_get_stream_url_from_jellyfin": "Impossibile ottenere l'URL del flusso da Jellyfin",
"eta": "ETA {{eta}}",
"methods": "Metodi",
"toasts": {
"you_are_not_allowed_to_download_files": "Non è consentito scaricare file.",
"deleted_all_movies_successfully": "Cancellati tutti i film con successo!",
"failed_to_delete_all_movies": "Impossibile eliminare tutti i film",
"deleted_all_tvseries_successfully": "Eliminate tutte le serie TV con successo!",
"failed_to_delete_all_tvseries": "Impossibile eliminare tutte le serie TV",
"download_cancelled": "Scaricamento annullato",
"could_not_cancel_download": "Impossibile annullare lo scaricamento",
"download_completed": "Scaricamento completato",
"download_started_for": "Scaricamento iniziato per {{item}}",
"item_is_ready_to_be_downloaded": "{{item}} è pronto per essere scaricato",
"download_stated_for_item": "Scaricamento iniziato per {{item}}",
"download_failed_for_item": "Scaricamento fallito per {{item}} - {{error}}",
"download_completed_for_item": "Scaricamento completato per {{item}}",
"queued_item_for_optimization": "Messo in coda {{item}} per l'ottimizzazione",
"failed_to_start_download_for_item": "Failed to start downloading for {{item}}: {{message}}",
"server_responded_with_status_code": "Server responded with status {{statusCode}}",
"no_response_received_from_server": "No response received from the server",
"error_setting_up_the_request": "Error setting up the request",
"failed_to_start_download_for_item_unexpected_error": "Impossibile avviare il download per {{item}}: Errore imprevisto",
"all_files_folders_and_jobs_deleted_successfully": "Tutti i file, le cartelle e i processi sono stati eliminati con successo.",
"an_error_occured_while_deleting_files_and_jobs": "Si è verificato un errore durante l'eliminazione di file e processi",
"go_to_downloads": "Vai agli elementi scaricati"
}
}
},
"search": {
"search_here": "Cerca qui...",
"search": "Cerca...",
"x_items": "{{count}} elementi",
"library": "Libreria",
"discover": "Scopri",
"no_results": "Nessun risultato",
"no_results_found_for": "Nessun risultato trovato per",
"movies": "Film",
"series": "Serie",
"episodes": "Episodi",
"collections": "Collezioni",
"actors": "Attori",
"request_movies": "Film Richiesti",
"request_series": "Serie Richieste",
"recently_added": "Aggiunti di Recente",
"recent_requests": "Richiesti di Recente",
"plex_watchlist": "Plex Watchlist",
"trending": "In tendenza",
"popular_movies": "Film Popolari",
"movie_genres": "Generi Film",
"upcoming_movies": "Film in arrivo",
"studios": "Studio",
"popular_tv": "Serie Popolari",
"tv_genres": "Generi Televisivi",
"upcoming_tv": "Serie in Arrivo",
"networks": "Network",
"tmdb_movie_keyword": "TMDB Parola chiave del film",
"tmdb_movie_genre": "TMDB Genere Film",
"tmdb_tv_keyword": "TMDB Parola chiave della serie",
"tmdb_tv_genre": "TMDB Genere Televisivo",
"tmdb_search": "TMDB Cerca",
"tmdb_studio": "TMDB Studio",
"tmdb_network": "TMDB Network",
"tmdb_movie_streaming_services": "TMDB Servizi di Streaming di Film",
"tmdb_tv_streaming_services": "TMDB Servizi di Streaming di Serie"
},
"library": {
"no_items_found": "Nessun elemento trovato",
"no_results": "Nessun risultato",
"no_libraries_found": "Nessuna libreria trovata",
"item_types": {
"movies": "film",
"series": "serie TV",
"boxsets": "cofanetti",
"items": "elementi"
},
"options": {
"display": "Display",
"row": "Fila",
"list": "Lista",
"image_style": "Stile dell'immagine",
"poster": "Poster",
"cover": "Cover",
"show_titles": "Mostra titoli",
"show_stats": "Mostra statistiche"
},
"filters": {
"genres": "Generi",
"years": "Anni",
"sort_by": "Ordina per",
"sort_order": "Criterio di ordinamento",
"tags": "Tag"
}
},
"favorites": {
"series": "Serie TV",
"movies": "Film",
"episodes": "Episodi",
"videos": "Video",
"boxsets": "Boxset",
"playlists": "Playlist"
},
"custom_links": {
"no_links": "Nessun link"
},
"player": {
"error": "Errore",
"failed_to_get_stream_url": "Impossibile ottenere l'URL dello stream",
"an_error_occured_while_playing_the_video": "Si è verificato un errore durante la riproduzione del video. Controllare i log nelle impostazioni.",
"client_error": "Errore del client",
"could_not_create_stream_for_chromecast": "Impossibile creare uno stream per Chromecast",
"message_from_server": "Messaggio dal server: {{messagge}}",
"video_has_finished_playing": "La riproduzione del video è terminata!",
"no_video_source": "Nessuna sorgente video...",
"next_episode": "Prossimo Episodio",
"refresh_tracks": "Aggiorna tracce",
"subtitle_tracks": "Tracce di sottotitoli:",
"audio_tracks": "Tracce audio:",
"playback_state": "Stato della riproduzione:",
"no_data_available": "Nessun dato disponibile",
"index": "Indice:"
},
"item_card": {
"next_up": "Il prossimo",
"no_items_to_display": "Nessun elemento da visualizzare",
"cast_and_crew": "Cast e Equipaggio",
"series": "Serie",
"seasons": "Stagioni",
"season": "Stagione",
"no_episodes_for_this_season": "Nessun episodio per questa stagione",
"overview": "Panoramica",
"more_with": "Altri con {{name}}",
"similar_items": "Elementi simili",
"no_similar_items_found": "Non sono stati trovati elementi simili",
"video": "Video",
"more_details": "Più dettagli",
"quality": "Qualità",
"audio": "Audio",
"subtitles": "Sottotitoli",
"show_more": "Mostra di più",
"show_less": "Mostra di meno",
"appeared_in": "Apparso in",
"could_not_load_item": "Impossibile caricare l'elemento",
"none": "Nessuno",
"download": {
"download_season": "Scarica Stagione",
"download_series": "Scarica Serie",
"download_episode": "Scarica Episodio",
"download_movie": "Scarica Film",
"download_x_item": "Scarica {{item_count}} elementi",
"download_button": "Scarica",
"using_optimized_server": "Utilizzando il server di ottimizzazione",
"using_default_method": "Utilizzando il metodo predefinito"
}
},
"live_tv": {
"next": "Prossimo",
"previous": "Precedente",
"live_tv": "TV in diretta",
"coming_soon": "Prossimamente",
"on_now": "In onda ora",
"shows": "Programmi",
"movies": "Film",
"sports": "Sport",
"for_kids": "Per Bambini",
"news": "Notiziari"
},
"jellyseerr":{
"confirm": "Conferma",
"cancel": "Cancella",
"yes": "Si",
"whats_wrong": "Cosa c'è che non va?",
"issue_type": "Tipo di problema",
"select_an_issue": "Seleziona un problema",
"types": "Tipi",
"describe_the_issue": "(facoltativo) Descrivere il problema...",
"submit_button": "Invia",
"report_issue_button": "Segnalare il problema",
"request_button": "Richiedi",
"are_you_sure_you_want_to_request_all_seasons": "Sei sicuro di voler richiedere tutte le stagioni?",
"failed_to_login": "Accesso non riuscito",
"cast": "Cast",
"details": "Dettagli",
"status": "Stato",
"original_title": "Titolo originale",
"series_type": "Tipo di Serie",
"release_dates": "Date di Uscita",
"first_air_date": "Prima Data di Messa in Onda",
"next_air_date": "Prossima Data di Messa in Onda",
"revenue": "Ricavi",
"budget": "Budget",
"original_language": "Lingua Originale",
"production_country": "Paese di Produzione",
"studios": "Studio",
"network": "Network",
"currently_streaming_on": "Attualmente in streaming su",
"advanced": "Avanzate",
"request_as": "Richiedi Come",
"tags": "Tag",
"quality_profile": "Profilo qualità",
"root_folder": "Cartella radice",
"season_x": "Stagione {{seasons}}",
"season_number": "Stagione {{season_number}}",
"number_episodes": "{{episode_number}} Episodio",
"born": "Nato",
"appearances": "Aspetto",
"toasts": {
"jellyseer_does_not_meet_requirements": "Il server Jellyseerr non soddisfa i requisiti minimi di versione! Aggiornare almeno alla versione 2.0.0.",
"jellyseerr_test_failed": "Il test di Jellyseerr non è riuscito. Riprovare.",
"failed_to_test_jellyseerr_server_url": "Fallito il test dell'url del server jellyseerr",
"issue_submitted": "Problema inviato!",
"requested_item": "Richiesto {{item}}!",
"you_dont_have_permission_to_request": "Non hai il permesso di richiedere!",
"something_went_wrong_requesting_media": "Qualcosa è andato storto nella richiesta dei media!"
}
},
"tabs": {
"home": "Home",
"search": "Cerca",
"library": "Libreria",
"custom_links": "Collegamenti personalizzati",
"favorites": "Preferiti"
}
}

View File

@@ -1,458 +0,0 @@
{
"login": {
"username_required": "Gebruikersnaam is verplicht",
"error_title": "Fout",
"login_title": "Aanmelden",
"login_to_title": "Aanmelden bij",
"username_placeholder": "Gebruikersnaam",
"password_placeholder": "Wachtwoord",
"login_button": "Aanmelden",
"quick_connect": "Snel Verbinden",
"enter_code_to_login": "Vul code {{code}} in om aan te melden",
"failed_to_initiate_quick_connect": "Gefaald om Snel Verbinden op te starten",
"got_it": "Begrepen",
"connection_failed": "Verbinding gefaald",
"could_not_connect_to_server": "Kon niet verbinden met de server. Controleer de URL en je netwerkverbinding.",
"an_unexpected_error_occured": "Er is een onverwachte fout opgetreden",
"change_server": "Verander server",
"invalid_username_or_password": "Ongeldige gebruikersnaam of wachtwoord",
"user_does_not_have_permission_to_log_in": "Gebruiker heeft geen rechten om aan te melden",
"server_is_taking_too_long_to_respond_try_again_later": "De server doet er te lang over om te antwoorden, probeer later opnieuw",
"server_received_too_many_requests_try_again_later": "De server heeft te veel aanvragen ontvangen, probeer later opnieuw",
"there_is_a_server_error": "Er is een serverfout",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Er is een onverwachte fout opgetreden. Heb je de server URL correct ingegeven?"
},
"server": {
"enter_url_to_jellyfin_server": "Geef de URL van je Jellyfin server in",
"server_url_placeholder": "http(s)://je-server.com",
"connect_button": "Verbinden",
"previous_servers": "vorige servers",
"clear_button": "Wissen",
"search_for_local_servers": "Zoek naar lokale servers",
"searching": "Zoeken...",
"servers": "Servers"
},
"home": {
"no_internet": "Geen Internet",
"no_items": "Geen items",
"no_internet_message": "Geen zorgen, je kan nog steeds\ngedownloade content bekijken",
"go_to_downloads": "Ga naar downloads",
"oops": "Oeps!",
"error_message": "Er ging iets fout\nGelieve af en aan te melden.",
"continue_watching": "Verder Kijken",
"next_up": "Volgende",
"recently_added_in": "Recent toegevoegd in {{libraryName}}",
"suggested_movies": "Voorgestelde Films",
"suggested_episodes": "Voorgestelde Afleveringen",
"intro": {
"welcome_to_streamyfin": "Welkom bij Streamyfin",
"a_free_and_open_source_client_for_jellyfin": "Een gratis en open-source client voor Jellyfin.",
"features_title": "Functies",
"features_description": "Streamyfin heeft een heleboel functies en integreert met een breed scala aan software die je kunt vinden in het instellingenmenu, onder andere:",
"jellyseerr_feature_description": "Verbind met je Jellyseerr instantie en vraag films direct in de app aan.",
"downloads_feature_title": "Downloads",
"downloads_feature_description": "Download films en series om offline te kijken. Gebruik de standaardmethode of installeer de optimalisatieserver om bestanden op de achtergrond te downloaden.",
"chromecast_feature_description": "Cast films en series naar je Chromecast toestellen.",
"centralised_settings_plugin_title": "Plugin voor gecentraliseerde instellingen",
"centralised_settings_plugin_description": "Configureer instellingen vanaf een centrale locatie op je Jellyfin server. Alle clientinstellingen voor alle gebruikers worden automatisch gesynchroniseerd.",
"done_button": "Gedaan",
"go_to_settings_button": "Go naar instellingen",
"read_more": "Lees meer"
},
"settings": {
"settings_title": "Instellingen",
"log_out_button": "Afmelden",
"user_info": {
"user_info_title": "Gebruiker Info",
"user": "Gebruiker",
"server": "Server",
"token": "Token",
"app_version": "App Versie"
},
"quick_connect": {
"quick_connect_title": "Snel Verbinden",
"authorize_button": "Snel Verbinden toestaan",
"enter_the_quick_connect_code": "Vul de Snel Verbinden code in...",
"success": "Succes",
"quick_connect_autorized": "Snel Verbinden toegestaan",
"error": "Fout",
"invalid_code": "Ongeldige code",
"authorize": "Toestaan"
},
"media_controls": {
"media_controls_title": "Media Bedieningen",
"forward_skip_length": "Duur voorwaarts overslaan",
"rewind_length": "Duur terugspeolen",
"seconds_unit": "s"
},
"audio": {
"audio_title": "Audio",
"set_audio_track": "Gebruik Audio Track Van Vorig Item",
"audio_language": "Audio taal",
"audio_hint": "Kies een standaard audio taal.",
"none": "Geen",
"language": "Taal"
},
"subtitles": {
"subtitle_title": "Ondertitels",
"subtitle_language": "Ondertitel taal",
"subtitle_mode": "Ondertitle Modus",
"set_subtitle_track": "Gebruik Ondertitel Track Van Vorig Item",
"subtitle_size": "Ondertitel Grootte",
"subtitle_hint": "Stel ondertitel voorkeuren in.",
"none": "Geen",
"language": "Taal",
"loading": "Laden",
"modes": {
"Default": "Standaard",
"Smart": "Slim",
"Always": "Altijd",
"None": "Geen",
"OnlyForced": "Alleen Geforceeerd"
}
},
"other": {
"other_title": "Andere",
"auto_rotate": "Automatisch draaien",
"video_orientation": "Video oriëntatie",
"orientation": "Oriëntatie",
"orientations": {
"DEFAULT": "Standaard",
"ALL": "Alle",
"PORTRAIT": "Portret",
"PORTRAIT_UP": "Portret Omhoog",
"PORTRAIT_DOWN": "Portret Omlaag",
"LANDSCAPE": "Landschap",
"LANDSCAPE_LEFT": "Landschap Links",
"LANDSCAPE_RIGHT": "Landschap Rechts",
"OTHER": "Andere",
"UNKNOWN": "Onbekend"
},
"safe_area_in_controls": "Veilig gebied in bedieningen",
"show_custom_menu_links": "Aangepaste menulinks tonen",
"hide_libraries": "Verberg Bibliotheken",
"select_liraries_you_want_to_hide": "Selecteer de bibliotheken die je wil verbergen van de Bibliotheek tab en hoofdpagina onderdelen.",
"disable_haptic_feedback": "Haptische feedback uitschakelen",
"default_quality": "Standaard kwaliteit"
},
"downloads": {
"downloads_title": "Downloads",
"download_method": "Download methode",
"remux_max_download": "Remux max download",
"auto_download": "Auto download",
"optimized_versions_server": "Geoptimaliseerde server versies",
"save_button": "Opslaan",
"optimized_server": "Geoptimailseerde Server",
"optimized": "Geoptimaliseerd",
"default": "Standaard",
"optimized_version_hint": "Vul de URL van de optimalisatieserver in. De URL moet http of https bevatten en eventueel de poort.",
"read_more_about_optimized_server": "Lees meer over de optimalisatieserver.",
"url":"URL",
"server_url_placeholder": "http(s)://domein.org:poort"
},
"plugins": {
"plugins_title": "Plugins",
"jellyseerr": {
"jellyseerr_warning": "Deze integratie is nog in een vroeg stadium. Verwacht dat zaken nog veranderen.",
"server_url": "Server URL",
"server_url_hint": "Voorbeeld: http(s)://je-host.url\n(indien nodig: voeg de poort toe)",
"server_url_placeholder": "Jellyseerr URL...",
"password": "Wachtwoord",
"password_placeholder": "Voeg het wachtwoord in voor de Jellyfin gebruiker {{username}}",
"save_button": "Opslaan",
"clear_button": "Wissen",
"login_button": "Aannmelden",
"total_media_requests": "Totaal aantal mediaverzoeken",
"movie_quota_limit": "Limiet filmquota",
"movie_quota_days": "Filmquota dagen",
"tv_quota_limit": "Limiet serie quota",
"tv_quota_days": "Serie Quota dagen",
"reset_jellyseerr_config_button": "Jellyseerr opnieuw instellen",
"unlimited": "Ongelimiteerd"
},
"marlin_search": {
"enable_marlin_search": "Marlin Search inschakeln ",
"url": "URL",
"server_url_placeholder": "http(s)://domein.org:poort",
"marlin_search_hint": "Vul de URL van de Marlin Search server in. De URL moet http of https bevatten en eventueel de poort.",
"read_more_about_marlin": "Lees meer over Marlin.",
"save_button": "Opslaan",
"toasts": {
"saved": "Opgeslagen"
}
}
},
"storage": {
"storage_title": "Opslag",
"app_usage": "App {{usedSpace}}%",
"device_usage": "Toestel {{availableSpace}}%",
"size_used": "{{used}} van {{total}} gebruikt",
"delete_all_downloaded_files": "Verwijder alle gedownloade bestanden"
},
"intro": {
"show_intro": "Toon intro",
"reset_intro": "intro opnieuw instellen"
},
"logs": {
"logs_title": "Logs",
"no_logs_available": "Geen logs beschikbaar",
"delete_all_logs": "Verwijder alle logs"
},
"languages": {
"title": "Talen",
"app_language": "App taal",
"app_language_description": "Selecteer een taal voor de app.",
"system": "Systeem"
},
"toasts":{
"error_deleting_files": "Fout bij het verwijden van bestanden",
"background_downloads_enabled": "Downloads op de achtergrond ingeschakeld",
"background_downloads_disabled": "Downloads op de achtergrond uitgeschakeld",
"connected": "Verbonden",
"could_not_connect": "Kon niet verbinden",
"invalid_url": "Ongeldige URL"
}
},
"downloads": {
"downloads_title": "Downloads",
"tvseries": "Series",
"movies": "Films",
"queue": "Wachtrij",
"queue_hint": "Wachtrij en downloads verdwijnen bij een herstart van de app",
"no_items_in_queue": "Geen items in wachtrij",
"no_downloaded_items": "Geen gedownloade items",
"delete_all_movies_button": "Verwijder alle films",
"delete_all_tvseries_button": "Verwijder alle Series",
"delete_all_button": "Verwijder alles",
"active_download": "Actieve download",
"no_active_downloads": "Geen actieve downloads",
"active_downloads": "Actieve downloads",
"new_app_version_requires_re_download": "Nieuwe app-versie vereist opnieuw downloaden",
"new_app_version_requires_re_download_description": "Voor de nieuwe update moet de content opnieuw worden gedownload. Verwijder alle gedownloade content en probeer het opnieuw.",
"back": "Terug",
"delete": "Verwijder",
"something_went_wrong": "Er ging iets mis",
"could_not_get_stream_url_from_jellyfin": "Kon de URL van de stream niet krijgen van Jellyfin",
"eta": "ETA {{eta}}",
"methods": "Methoden",
"toasts": {
"you_are_not_allowed_to_download_files": "Je mag geen bestanden downloaden.",
"deleted_all_movies_successfully": "Alle filns succesvol verwijderd!",
"failed_to_delete_all_movies": "Alle films zijn niet verwijderd",
"deleted_all_tvseries_successfully": "Alle series succesvol verwijderd!",
"failed_to_delete_all_tvseries": "Alle series zijn niet verwijderd",
"download_cancelled": "Download geannuleerd",
"could_not_cancel_download": "Kon de download niet annuleren",
"download_completed": "Download afgerond",
"download_started_for": "Download gestart voor {{item}}",
"item_is_ready_to_be_downloaded": "{{item}} is klaar op te downloaden",
"download_stated_for_item": "Download gestart voor {{item}}",
"download_failed_for_item": "Download gefaald voor {{item}} - {{error}}",
"download_completed_for_item": "Download afgerond voor {{item}}",
"queued_item_for_optimization": "{{item}} in de wachtrij gezet voor optimalisatie",
"failed_to_start_download_for_item": "Kon de download voor {{item}} niet starten: {{message}}",
"server_responded_with_status_code": "Server heeft geantwoord met {{statusCode}}",
"no_response_received_from_server": "Geen antwoord gekregen van de server",
"error_setting_up_the_request": "Fout bij het opstellen van de aanvraag",
"failed_to_start_download_for_item_unexpected_error": "Kon de download voor {{item}} niet starten: Onverwachte fout",
"all_files_folders_and_jobs_deleted_successfully": "Alle bestanden, mappen en taken succesvol verwijderd",
"an_error_occured_while_deleting_files_and_jobs": "Er is een fout opgetreden tijdens het verwijderen van bestanden en taken",
"go_to_downloads": "Ga naar downloads"
}
}
},
"search": {
"search_here": "Zoek hier...",
"search": "Zoek...",
"x_items": "{{count}} items",
"library": "Bibliotheek",
"discover": "Ontdek",
"no_results": "Geen resultaten",
"no_results_found_for": "Geen resultaten gevonden voor",
"movies": "Films",
"series": "Series",
"episodes": "Afleveringen",
"collections": "Collecties",
"actors": "Acteurs",
"request_movies": "Vraag films aan",
"request_series": "Vraag series aan",
"recently_added": "Recent Toegevoegd",
"recent_requests": "Recent Aangevraagd",
"plex_watchlist": "Plex Kijklijst",
"trending": "Trending",
"popular_movies": "Populaire Films",
"movie_genres": "Film Genres",
"upcoming_movies": "Aankomende Movies",
"studios": "Studios",
"popular_tv": "Populaire TV",
"tv_genres": "TV Genres",
"upcoming_tv": "Opkomend TV",
"networks": "Netwerken",
"tmdb_movie_keyword": "TMDB Film Trefwoord",
"tmdb_movie_genre": "TMDB Film Genre",
"tmdb_tv_keyword": "TMDB TV Trefwoord",
"tmdb_tv_genre": "TMDB TV Genre",
"tmdb_search": "TMDB Zoeken",
"tmdb_studio": "TMDB Studio",
"tmdb_network": "TMDB Netwerk",
"tmdb_movie_streaming_services": "TMDB Film Streaming Diensten",
"tmdb_tv_streaming_services": "TMDB TV Streaming Diensten"
},
"library": {
"no_items_found": "Geen items gevonden",
"no_results": "Geen resultaten",
"no_libraries_found": "Geen bibliotheken gevonden",
"item_types": {
"movies": "films",
"series": "series",
"boxsets": "box sets",
"items": "items"
},
"options": {
"display": "Weergave",
"row": "Rij",
"list": "Lijst",
"image_style": "Stijl van afbeelding",
"poster": "Poster",
"cover": "Cover",
"show_titles": "Toon titels",
"show_stats": "Toon statistieken"
},
"filters": {
"genres": "Genres",
"years": "Jaren",
"sort_by": "Sorteren op",
"sort_order": "Sorteer volgorde",
"tags": "Labels"
}
},
"favorites": {
"series": "Series",
"movies": "Films",
"episodes": "Afleveringen",
"videos": "Videos",
"boxsets": "Boxsets",
"playlists": "Afspeellijsten"
},
"custom_links": {
"no_links": "Geen links"
},
"player": {
"error": "Fout",
"failed_to_get_stream_url": "De stream-URL kon niet worden verkregen",
"an_error_occured_while_playing_the_video": "Er is een fout opgetreden tijdens het afspelen van de video. Controleer de logs in de instellingen.",
"client_error": "Fout van de client",
"could_not_create_stream_for_chromecast": "Kon geen stream maken voor Chromecast",
"message_from_server": "Bericht van de server: {{message}}",
"video_has_finished_playing": "Video is gedaan met spelen!",
"no_video_source": "Geen video bron...",
"next_episode": "Volgende Aflevering",
"refresh_tracks": "Tracks verversen",
"subtitle_tracks": "Ondertitel Tracks:",
"audio_tracks": "Audio Tracks:",
"playback_state": "Afspeelstatus:",
"no_data_available": "Geen data beschikbaar",
"index": "Index:"
},
"item_card": {
"next_up": "Volgende",
"no_items_to_display": "Geen items om te tonen",
"cast_and_crew": "Cast & Crew",
"series": "Series",
"seasons": "Seizoenen",
"season": "Seizoen",
"no_episodes_for_this_season": "Geen afleveringen voor dit seizoen",
"overview": "Overzicht",
"more_with": "Meer met {{name}}",
"similar_items": "Gelijkaardige items",
"no_similar_items_found": "Geen gelijkaardige items gevonden",
"video": "Video",
"more_details": "Meer details",
"quality": "Kwaliteit",
"audio": "Audio",
"subtitles": "Ondertitel",
"show_more": "Toon meer",
"show_less": "Toon minden",
"appeared_in": "Verschenen in",
"could_not_load_item": "Kon item niet laden",
"none": "Geen",
"download": {
"download_season": "Download Seizoen",
"download_series": "Download Serie",
"download_episode": "Download Aflevering",
"download_movie": "Download Film",
"download_x_item": "Download {{item_count}} items",
"download_button": "Download",
"using_optimized_server": "Geoptimaliseerde server gebruiken",
"using_default_method": "Standaard methode gebruiken"
}
},
"live_tv": {
"next": "Volgende ",
"previous": "Vorige",
"live_tv": "Live TV",
"coming_soon": "Binnenkort beschikbaar",
"on_now": "Nu op",
"shows": "Shows",
"movies": "Films",
"sports": "Sport",
"for_kids": "Voor kinderen",
"news": "Nieuws"
},
"jellyseerr":{
"confirm": "Bevestig",
"cancel": "Annuleer",
"yes": "Ja",
"whats_wrong": "Wat is er mis?",
"issue_type": "Type probleem",
"select_an_issue": "Selecteer een probleem",
"types": "Types",
"describe_the_issue": "(optioneel) beschrijf het probleem...",
"submit_button": "Verzenden",
"report_issue_button": "Meld een probleem",
"request_button": "Aanvragen",
"are_you_sure_you_want_to_request_all_seasons": "Ben je zeker dat je alle seizoenen wil aanvragen?",
"failed_to_login": "Kon niet aanmelden",
"cast": "Cast",
"details": "Details",
"status": "Status",
"original_title": "Originele titel",
"series_type": "Serie Type",
"release_dates": "Verschijningsdatums",
"first_air_date": "Eerste uitzenddatum",
"next_air_date": "Volgende uitzenddatum",
"revenue": "Inkomsten",
"budget": "Budget",
"original_language": "Originele taal",
"production_country": "Land van productie",
"studios": "Studio",
"network": "Netwerk",
"currently_streaming_on": "Momenteel te streamen op",
"advanced": "Geavanceerd",
"request_as": "Vraag aan als",
"tags": "Labels",
"quality_profile": "Kwaliteitsprofiel",
"root_folder": "Hoofdmap",
"season_x": "Seizoen {{seasons}}",
"season_number": "Seizoen {{season_number}}",
"number_episodes": "{{episode_number}} Afleveringen",
"born": "Geboren",
"appearances": "Verschijningen",
"toasts": {
"jellyseer_does_not_meet_requirements": "Jellyseerr server voldoet niet aan de minimale versievereisten! Update naar minimaal 2.0.0",
"jellyseerr_test_failed": "Jellyseerr test gefaald. Probeer opnieuw.",
"failed_to_test_jellyseerr_server_url": "Mislukt bij het testen van jellyseerr server url",
"issue_submitted": "Probleem ingediend!",
"requested_item": "{{item}} aangevraagd!",
"you_dont_have_permission_to_request": "Je hebt geen toestemming om aanvragen te doen!",
"something_went_wrong_requesting_media": "Er ging iets iets mis met het aavragen van media!"
}
},
"tabs": {
"home": "Thuis",
"search": "Zoeken",
"library": "Bibliotheek",
"custom_links": "Aangepaste links",
"favorites": "Favorieten"
}
}

View File

@@ -1,457 +0,0 @@
{
"login": {
"username_required": "需要用戶名",
"error_title": "錯誤",
"login_title": "登入",
"login_to_title": "登入至",
"username_placeholder": "用戶名",
"password_placeholder": "密碼",
"login_button": "登入",
"quick_connect": "快速連接",
"enter_code_to_login": "輸入代碼 {{code}} 以登入",
"failed_to_initiate_quick_connect": "無法啟動快速連接",
"got_it": "知道了",
"connection_failed": "連接失敗",
"could_not_connect_to_server": "無法連接到伺服器。請檢查 URL 和您的網絡連接。",
"an_unexpected_error_occured": "發生意外錯誤",
"change_server": "更改伺服器",
"invalid_username_or_password": "無效的用戶名或密碼",
"user_does_not_have_permission_to_log_in": "用戶無權登入",
"server_is_taking_too_long_to_respond_try_again_later": "伺服器響應時間過長,請稍後再試",
"server_received_too_many_requests_try_again_later": "伺服器收到太多請求,請稍後再試。",
"there_is_a_server_error": "伺服器出錯",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "發生意外錯誤。您是否正確輸入了伺服器 URL"
},
"server": {
"enter_url_to_jellyfin_server": "輸入您的 Jellyfin 伺服器的 URL",
"server_url_placeholder": "http(s)://your-server.com",
"connect_button": "連接",
"previous_servers": "先前的伺服器",
"clear_button": "清除",
"search_for_local_servers": "搜尋本地伺服器",
"searching": "搜尋中...",
"servers": "伺服器"
},
"home": {
"no_internet": "無網絡",
"no_items": "無項目",
"no_internet_message": "別擔心,您仍然可以觀看\n已下載的內容。",
"go_to_downloads": "前往下載",
"oops": "哎呀!",
"error_message": "出錯了。\n請重新登出並登入。",
"continue_watching": "繼續觀看",
"next_up": "下一個",
"recently_added_in": "最近添加於 {{libraryName}}",
"suggested_movies": "推薦電影",
"suggested_episodes": "推薦劇集",
"intro": {
"welcome_to_streamyfin": "歡迎來到 Streamyfin",
"a_free_and_open_source_client_for_jellyfin": "一個免費且開源的 Jellyfin 客戶端。",
"features_title": "功能",
"features_description": "Streamyfin 擁有許多功能,並與多種軟體整合,您可以在設置菜單中找到這些功能,包括:",
"jellyseerr_feature_description": "連接到您的 Jellyseerr 實例並直接在應用程序中請求電影。",
"downloads_feature_title": "下載",
"downloads_feature_description": "下載電影和電視節目以離線觀看。使用默認方法或安裝 Optimized Server 以在背景中下載文件。",
"chromecast_feature_description": "將電影和電視節目投射到您的 Chromecast 設備。",
"centralised_settings_plugin_title": "統一設置插件",
"centralised_settings_plugin_description": "從 Jellyfin 伺服器上的統一位置改變設置。所有用戶的所有客戶端設置將會自動同步。",
"done_button": "完成",
"go_to_settings_button": "前往設置",
"read_more": "閱讀更多"
},
"settings": {
"settings_title": "設置",
"log_out_button": "登出",
"user_info": {
"user_info_title": "用戶信息",
"user": "用戶",
"server": "伺服器",
"token": "令牌",
"app_version": "應用版本"
},
"quick_connect": {
"quick_connect_title": "快速連接",
"authorize_button": "授權快速連接",
"enter_the_quick_connect_code": "輸入快速連接代碼...",
"success": "成功",
"quick_connect_autorized": "快速連接已授權",
"error": "錯誤",
"invalid_code": "無效代碼",
"authorize": "授權"
},
"media_controls": {
"media_controls_title": "媒體控制",
"forward_skip_length": "前進跳過長度",
"rewind_length": "倒帶長度",
"seconds_unit": "秒"
},
"audio": {
"audio_title": "音頻",
"set_audio_track": "從上一個項目設置音軌",
"audio_language": "音頻語言",
"audio_hint": "選擇默認音頻語言。",
"none": "無",
"language": "語言"
},
"subtitles": {
"subtitle_title": "字幕",
"subtitle_language": "字幕語言",
"subtitle_mode": "字幕模式",
"set_subtitle_track": "從上一個項目設置字幕軌道",
"subtitle_size": "字幕大小",
"subtitle_hint": "配置字幕偏好。",
"none": "無",
"language": "語言",
"loading": "加載中",
"modes": {
"Default": "默認",
"Smart": "智能",
"Always": "總是",
"None": "無",
"OnlyForced": "僅強制"
}
},
"other": {
"other_title": "其他",
"auto_rotate": "自動旋轉",
"video_orientation": "影片方向",
"orientation": "方向",
"orientations": {
"DEFAULT": "默認",
"ALL": "全部",
"PORTRAIT": "縱向",
"PORTRAIT_UP": "縱向向上",
"PORTRAIT_DOWN": "縱向向下",
"LANDSCAPE": "橫向",
"LANDSCAPE_LEFT": "橫向左",
"LANDSCAPE_RIGHT": "橫向右",
"OTHER": "其他",
"UNKNOWN": "未知"
},
"safe_area_in_controls": "控制中的安全區域",
"show_custom_menu_links": "顯示自定義菜單鏈接",
"hide_libraries": "隱藏媒體庫",
"select_liraries_you_want_to_hide": "選擇您想從媒體庫頁面和主頁隱藏的媒體庫。",
"disable_haptic_feedback": "禁用觸覺回饋"
},
"downloads": {
"downloads_title": "下載",
"download_method": "下載方法",
"remux_max_download": "Remux 最大下載",
"auto_download": "自動下載",
"optimized_versions_server": "Optimized Version 伺服器",
"save_button": "保存",
"optimized_server": "Optimized Server",
"optimized": "優化",
"default": "默認",
"optimized_version_hint": "輸入 Optimized Server 的 URL。URL 應包括 http(s) 和端口 (可選)。",
"read_more_about_optimized_server": "閱讀更多關於 Optimized Server 的信息。",
"url": "URL",
"server_url_placeholder": "http(s)://domain.org:port"
},
"plugins": {
"plugins_title": "插件",
"jellyseerr": {
"jellyseerr_warning": "此集成處於早期階段。功能可能會有變化。",
"server_url": "伺服器 URL",
"server_url_hint": "示例http(s)://your-host.url\n如果需要添加端口",
"server_url_placeholder": "Jellyseerr URL...",
"password": "密碼",
"password_placeholder": "輸入 Jellyfin 用戶 {{username}} 的密碼",
"save_button": "保存",
"clear_button": "清除",
"login_button": "登入",
"total_media_requests": "總媒體請求",
"movie_quota_limit": "電影配額限制",
"movie_quota_days": "電影配額天數",
"tv_quota_limit": "電視配額限制",
"tv_quota_days": "電視配額天數",
"reset_jellyseerr_config_button": "重置 Jellyseerr 配置",
"unlimited": "無限制"
},
"marlin_search": {
"enable_marlin_search": "啟用 Marlin 搜索",
"url": "URL",
"server_url_placeholder": "http(s)://domain.org:port",
"marlin_search_hint": "輸入 Marlin 伺服器的 URL。URL 應包括 http(s) 和端口 (可選)。",
"read_more_about_marlin": "閱讀更多關於 Marlin 的信息。",
"save_button": "保存",
"toasts": {
"saved": "已保存"
}
}
},
"storage": {
"storage_title": "存儲",
"app_usage": "應用 {{usedSpace}}%",
"device_usage": "設備 {{availableSpace}}%",
"size_used": "已使用 {{used}} / {{total}}",
"delete_all_downloaded_files": "刪除所有已下載文件"
},
"intro": {
"show_intro": "顯示介紹",
"reset_intro": "重置介紹"
},
"logs": {
"logs_title": "日誌",
"no_logs_available": "無可用日誌",
"delete_all_logs": "刪除所有日誌"
},
"languages": {
"title": "語言",
"app_language": "應用語言",
"app_language_description": "選擇應用的語言。",
"system": "系統"
},
"toasts": {
"error_deleting_files": "刪除文件時出錯",
"background_downloads_enabled": "背景下載已啟用",
"background_downloads_disabled": "背景下載已禁用",
"connected": "已連接",
"could_not_connect": "無法連接",
"invalid_url": "無效的 URL"
}
},
"downloads": {
"downloads_title": "下載",
"tvseries": "電視劇",
"movies": "電影",
"queue": "隊列",
"queue_hint": "應用重啟後隊列和下載將會丟失",
"no_items_in_queue": "隊列中無項目",
"no_downloaded_items": "無已下載項目",
"delete_all_movies_button": "刪除所有電影",
"delete_all_tvseries_button": "刪除所有電視劇",
"delete_all_button": "刪除全部",
"active_download": "活動下載",
"no_active_downloads": "無活動下載",
"active_downloads": "活動下載",
"new_app_version_requires_re_download": "新應用版本需要重新下載",
"new_app_version_requires_re_download_description": "新更新需要重新下載內容。請刪除所有已下載內容後再重試。",
"back": "返回",
"delete": "刪除",
"something_went_wrong": "出了些問題",
"could_not_get_stream_url_from_jellyfin": "無法從 Jellyfin 獲取串流 URL",
"eta": "預計完成時間 {{eta}}",
"methods": "方法",
"toasts": {
"you_are_not_allowed_to_download_files": "您無權下載文件。",
"deleted_all_movies_successfully": "成功刪除所有電影!",
"failed_to_delete_all_movies": "刪除所有電影失敗",
"deleted_all_tvseries_successfully": "成功刪除所有電視劇!",
"failed_to_delete_all_tvseries": "刪除所有電視劇失敗",
"download_cancelled": "下載已取消",
"could_not_cancel_download": "無法取消下載",
"download_completed": "下載完成",
"download_started_for": "開始下載 {{item}}",
"item_is_ready_to_be_downloaded": "{{item}} 準備好下載",
"download_stated_for_item": "開始下載 {{item}}",
"download_failed_for_item": "下載失敗 {{item}} - {{error}}",
"download_completed_for_item": "下載完成 {{item}}",
"queued_item_for_optimization": "已將 {{item}} 排隊進行優化",
"failed_to_start_download_for_item": "無法開始下載 {{item}}: {{message}}",
"server_responded_with_status_code": "伺服器響應狀態 {{statusCode}}",
"no_response_received_from_server": "未收到伺服器的響應",
"error_setting_up_the_request": "設置請求時出錯",
"failed_to_start_download_for_item_unexpected_error": "無法開始下載 {{item}}: 發生意外錯誤",
"all_files_folders_and_jobs_deleted_successfully": "所有文件、文件夾和任務成功刪除",
"an_error_occured_while_deleting_files_and_jobs": "刪除文件和任務時發生錯誤",
"go_to_downloads": "前往下載"
}
}
},
"search": {
"search_here": "在這裡搜索...",
"search": "搜索...",
"x_items": "{{count}} 項目",
"library": "媒體庫",
"discover": "發現",
"no_results": "沒有結果",
"no_results_found_for": "未找到結果",
"movies": "電影",
"series": "系列",
"episodes": "劇集",
"collections": "收藏",
"actors": "演員",
"request_movies": "請求電影",
"request_series": "請求系列",
"recently_added": "最近添加",
"recent_requests": "最近請求",
"plex_watchlist": "Plex 觀影清單",
"trending": "趨勢",
"popular_movies": "熱門電影",
"movie_genres": "電影類型",
"upcoming_movies": "即將上映的電影",
"studios": "工作室",
"popular_tv": "熱門電視",
"tv_genres": "電視類型",
"upcoming_tv": "即將上映的電視",
"networks": "網絡",
"tmdb_movie_keyword": "TMDB 電影關鍵詞",
"tmdb_movie_genre": "TMDB 電影類型",
"tmdb_tv_keyword": "TMDB 電視關鍵詞",
"tmdb_tv_genre": "TMDB 電視類型",
"tmdb_search": "TMDB 搜索",
"tmdb_studio": "TMDB 工作室",
"tmdb_network": "TMDB 網絡",
"tmdb_movie_streaming_services": "TMDB 電影流媒體服務",
"tmdb_tv_streaming_services": "TMDB 電視流媒體服務"
},
"library": {
"no_items_found": "未找到項目",
"no_results": "沒有結果",
"no_libraries_found": "未找到媒體庫",
"item_types": {
"movies": "電影",
"series": "系列",
"boxsets": "套裝",
"items": "項目"
},
"options": {
"display": "顯示",
"row": "行",
"list": "列表",
"image_style": "圖片樣式",
"poster": "海報",
"cover": "封面",
"show_titles": "顯示標題",
"show_stats": "顯示統計"
},
"filters": {
"genres": "類型",
"years": "年份",
"sort_by": "排序依據",
"sort_order": "排序順序",
"tags": "標籤"
}
},
"favorites": {
"series": "系列",
"movies": "電影",
"episodes": "劇集",
"videos": "影片",
"boxsets": "套裝",
"playlists": "播放列表"
},
"custom_links": {
"no_links": "無鏈接"
},
"player": {
"error": "錯誤",
"failed_to_get_stream_url": "無法獲取流 URL",
"an_error_occured_while_playing_the_video": "播放影片時發生錯誤。請檢查設置中的日誌。",
"client_error": "客戶端錯誤",
"could_not_create_stream_for_chromecast": "無法為 Chromecast 建立串流",
"message_from_server": "來自伺服器的消息:{{message}}",
"video_has_finished_playing": "影片播放完畢!",
"no_video_source": "無影片來源...",
"next_episode": "下一集",
"refresh_tracks": "刷新軌道",
"subtitle_tracks": "字幕軌道:",
"audio_tracks": "音頻軌道:",
"playback_state": "播放狀態:",
"no_data_available": "無可用數據",
"index": "索引:"
},
"item_card": {
"next_up": "下一個",
"no_items_to_display": "無項目顯示",
"cast_and_crew": "演員和工作人員",
"series": "系列",
"seasons": "季",
"season": "季",
"no_episodes_for_this_season": "本季無劇集",
"overview": "概覽",
"more_with": "更多 {{name}} 的作品",
"similar_items": "類似項目",
"no_similar_items_found": "未找到類似項目",
"video": "影片",
"more_details": "更多詳情",
"quality": "質量",
"audio": "音頻",
"subtitles": "字幕",
"show_more": "顯示更多",
"show_less": "顯示更少",
"appeared_in": "出現於",
"could_not_load_item": "無法加載項目",
"none": "無",
"download": {
"download_season": "下載季度",
"download_series": "下載系列",
"download_episode": "下載劇集",
"download_movie": "下載電影",
"download_x_item": "下載 {{item_count}} 項目",
"download_button": "下載",
"using_optimized_server": "使用 Optimized Server",
"using_default_method": "使用默認方法"
}
},
"live_tv": {
"next": "下一個",
"previous": "上一個",
"live_tv": "直播電視",
"coming_soon": "即將推出",
"on_now": "正在播放",
"shows": "節目",
"movies": "電影",
"sports": "體育",
"for_kids": "兒童",
"news": "新聞"
},
"jellyseerr": {
"confirm": "確認",
"cancel": "取消",
"yes": "是",
"whats_wrong": "出了什麼問題?",
"issue_type": "問題類型",
"select_an_issue": "選擇一個問題",
"types": "類型",
"describe_the_issue": "(可選)描述問題...",
"submit_button": "提交",
"report_issue_button": "報告問題",
"request_button": "請求",
"are_you_sure_you_want_to_request_all_seasons": "您確定要請求所有季度的節目嗎?",
"failed_to_login": "登入失敗",
"cast": "演員",
"details": "詳情",
"status": "狀態",
"original_title": "原標題",
"series_type": "系列類型",
"release_dates": "發行日期",
"first_air_date": "首次播出日期",
"next_air_date": "下次播出日期",
"revenue": "收入",
"budget": "預算",
"original_language": "原始語言",
"production_country": "製作國家",
"studios": "工作室",
"network": "網絡",
"currently_streaming_on": "目前在以下流媒體上播放",
"advanced": "高級",
"request_as": "請求為",
"tags": "標籤",
"quality_profile": "質量配置文件",
"root_folder": "根文件夾",
"season_x": "第 {{seasons}} 季",
"season_number": "第 {{season_number}} 季",
"number_episodes": "{{episode_number}} 集",
"born": "出生",
"appearances": "出場",
"toasts": {
"jellyseer_does_not_meet_requirements": "Jellyseerr 伺服器不符合最低版本要求!請更新至至少 2.0.0",
"jellyseerr_test_failed": "Jellyseerr 測試失敗。請再試一次。",
"failed_to_test_jellyseerr_server_url": "無法測試 Jellyseerr 伺服器 URL",
"issue_submitted": "問題已提交!",
"requested_item": "已請求 {{item}}",
"you_dont_have_permission_to_request": "您無權請求媒體!",
"something_went_wrong_requesting_media": "請求媒體時出了些問題!"
}
},
"tabs": {
"home": "主頁",
"search": "搜索",
"library": "庫",
"custom_links": "自定義鏈接",
"favorites": "收藏"
}
}

View File

@@ -3,8 +3,15 @@
"compilerOptions": {
"strict": true,
"paths": {
"@/*": ["./*"]
"@/*": [
"./*"
]
}
},
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
]
}

View File

@@ -268,8 +268,6 @@ export const useSettings = () => {
const newSettings = { ..._settings, ...update };
setSettings(newSettings);
// @ts-expect-error
saveSettings(newSettings);
}
};

View File

@@ -36,7 +36,6 @@ export const getStreamUrl = async ({
mediaSource: MediaSourceInfo | undefined;
} | null> => {
if (!api || !userId || !item?.Id) {
console.warn("Missing required parameters for getStreamUrl");
return null;
}
@@ -121,7 +120,9 @@ export const getStreamUrl = async ({
sessionId: sessionId,
mediaSource,
};
} else {
}
if (mediaSource?.SupportsDirectPlay) {
const searchParams = new URLSearchParams({
playSessionId: sessionData?.PlaySessionId || "",
mediaSourceId: mediaSource?.Id || "",
@@ -148,4 +149,39 @@ export const getStreamUrl = async ({
};
}
}
if (item.MediaType === "Audio") {
if (mediaSource?.TranscodingUrl) {
return {
url: `${api.basePath}${mediaSource.TranscodingUrl}`,
sessionId,
mediaSource,
};
}
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 {
url: `${
api.basePath
}/Audio/${itemId}/universal?${searchParams.toString()}`,
sessionId,
mediaSource,
};
}
throw new Error("Unsupported media type");
};

View File

@@ -15,7 +15,6 @@ export const chromecastProfile: DeviceProfile = {
Codec: "aac,mp3,flac,opus,vorbis",
},
],
ContainerProfiles: [],
DirectPlayProfiles: [
{
Container: "mp4",