Compare commits

..

5 Commits

Author SHA1 Message Date
Alex Kim
6494049b66 WIP 2025-02-16 06:39:02 +11:00
Alex Kim
adb20f4b10 Merge branch 'develop' into fix/remove-react-native-video 2025-02-16 04:55:40 +11:00
Alex Kim
7dc8132e7a Merge branch 'develop' into fix/remove-react-native-video 2025-02-16 04:53:42 +11:00
Alex Kim
9fb04518b0 WIP 2025-02-16 04:17:40 +11:00
Fredrik Burmester
46be3c9465 fix: remove all mentions of transcoding player and the route
only use vlc player
2025-02-14 13:46:01 +01:00
56 changed files with 1139 additions and 3978 deletions

View File

@@ -43,7 +43,6 @@ body:
label: Version
description: What version of Streamyfin are you running?
options:
- 0.27.0
- 0.26.1
- 0.26.0
- 0.25.0

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

@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.27.0",
"version": "0.26.1",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",

View File

@@ -1,5 +1,498 @@
import { HomeIndex } from "@/components/settings/HomeIndex";
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 <HomeIndex />;
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,26 +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 { RECENTLY_ADDED_SENT_NOTIFICATIONS_ITEM_IDS_KEY } from "@/utils/recently-added-notifications";
import { useHaptic } from "@/hooks/useHaptic";
import { useNavigation, useRouter } from "expo-router";
import { t } from "i18next";
import React, { useCallback, useEffect, useMemo } from "react";
import {
ScrollView,
StyleSheet,
Switch,
TouchableOpacity,
View,
} from "react-native";
import React, { lazy, useEffect } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import * as TaskManager from "expo-task-manager";
import { BACKGROUND_FETCH_TASK_RECENTLY_ADDED } from "@/utils/background-tasks";
import { RecentlyAddedNotificationsSettings } from "@/components/settings/RecentlyAddedNotifications";
import { storage } from "@/utils/mmkv";
const DownloadSettings = lazy(
() => import("@/components/settings/DownloadSettings")
);
export default function settings() {
const router = useRouter();
@@ -79,7 +72,7 @@ export default function settings() {
<OtherSettings />
<DownloadSettings />
{!Platform.isTV && <DownloadSettings />}
<PluginSettings />
@@ -101,7 +94,7 @@ export default function settings() {
/>
</ListGroup>
<View className="">
<View className="mb-4">
<ListGroup title={t("home.settings.logs.logs_title")}>
<ListItem
onPress={() => router.push("/settings/logs/page")}
@@ -116,21 +109,7 @@ export default function settings() {
</ListGroup>
</View>
<RecentlyAddedNotificationsSettings />
<View
style={{
height: StyleSheet.hairlineWidth,
backgroundColor: "white",
overflow: "hidden",
marginVertical: 16,
opacity: 0.3,
}}
></View>
<View className="">
<StorageSettings />
</View>
<StorageSettings />
</View>
</ScrollView>
);

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;
}
@@ -330,6 +327,29 @@ export default function page() {
: 0;
}, [item]);
const [appState, setAppState] = useState(AppState.currentState);
useEffect(() => {
const handleAppStateChange = (nextAppState: AppStateStatus) => {
// Handle app going to the background
if (nextAppState.match(/inactive|background/)) {
_setShowControls(false);
}
setAppState(nextAppState);
};
// Use AppState.addEventListener and return a cleanup function
const subscription = AppState.addEventListener(
"change",
handleAppStateChange
);
return () => {
// Cleanup the event listener when the component is unmounted
subscription.remove();
};
}, [appState, isPipStarted, isPlaying]);
// Preselection of audio and subtitle tracks.
if (!settings) return null;
let initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
@@ -364,21 +384,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 +406,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 +442,7 @@ export default function page() {
progressUpdateInterval={1000}
onVideoStateChange={onPlaybackStateChanged}
onPipStarted={onPipStarted}
onVideoLoadStart={() => {}}
onVideoLoadEnd={() => {
setIsVideoLoaded(true);
}}
@@ -443,7 +456,7 @@ export default function page() {
}}
/>
</View>
{videoRef.current && !isPipStarted && isMounted === true ? (
{videoRef.current && (
<Controls
mediaSource={stream?.mediaSource}
item={item}
@@ -473,7 +486,7 @@ export default function page() {
stop={stop}
isVlc
/>
) : null}
)}
</View>
);
}

View File

@@ -1,24 +1,22 @@
import "@/augmentations";
import { Platform } from "react-native";
import { Text } from "@/components/common/Text";
import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider";
import {
getOrSetDeviceId,
getServerUrlFromStorage,
getTokenFromStorage,
getUserFromStorage,
JellyfinProvider,
} 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,
BACKGROUND_FETCH_TASK_RECENTLY_ADDED,
registerBackgroundFetchAsyncRecentlyAdded,
unregisterBackgroundFetchAsyncRecentlyAdded,
} from "@/utils/background-tasks";
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
import { LogProvider, writeToLog } from "@/utils/log";
import { storage } from "@/utils/mmkv";
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
@@ -34,22 +32,21 @@ 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";
import "react-native-reanimated";
import { Toaster } from "sonner-native";
import { Jellyfin } from "@jellyfin/sdk";
import { fetchAndStoreRecentlyAdded } from "@/utils/recently-added-notifications";
if (!Platform.isTV) {
Notifications.setNotificationHandler({
@@ -61,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;
@@ -106,30 +94,6 @@ function useNotificationObserver() {
}
if (!Platform.isTV) {
TaskManager.defineTask(BACKGROUND_FETCH_TASK_RECENTLY_ADDED, async () => {
const token = getTokenFromStorage();
const url = getServerUrlFromStorage();
const user = getUserFromStorage();
const c = storage.getNumber("notification_send_for_item_ids.count");
storage.set("notification_send_for_item_ids.count", (c || 0) + 1);
console.log(
"TaskManager ~ trigger ~ recently added notifications:",
token,
url,
user?.Id
);
if (!token || !url || !user?.Id) return;
const result = await fetchAndStoreRecentlyAdded(user.Id, url, token);
if (!result) return BackgroundFetch.BackgroundFetchResult.NoData;
return BackgroundFetch.BackgroundFetchResult.NewData;
});
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
console.log("TaskManager ~ trigger");
@@ -260,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>
);
}
@@ -295,38 +261,15 @@ function Layout() {
}, [settings?.preferedLanguage, i18n]);
if (!Platform.isTV) {
useKeepAwake();
useNotificationObserver();
const { i18n } = useTranslation();
useEffect(() => {
checkAndRequestPermissions();
}, []);
const checkStatusAsync = async () => {
if (Platform.isTV) return;
await BackgroundFetch.getStatusAsync();
return await TaskManager.isTaskRegisteredAsync(
BACKGROUND_FETCH_TASK_RECENTLY_ADDED
);
};
useEffect(() => {
(async () => {
const isRegistered = await checkStatusAsync();
if (settings.recentlyAddedNotifications === false && isRegistered) {
console.log("unregisterBackgroundFetchAsyncRecentlyAdded");
unregisterBackgroundFetchAsyncRecentlyAdded();
} else if (
settings.recentlyAddedNotifications === true &&
!isRegistered
) {
console.log("registerBackgroundFetchAsyncRecentlyAdded");
registerBackgroundFetchAsyncRecentlyAdded();
}
})();
}, [settings.recentlyAddedNotifications]);
useEffect(() => {
// If the user has auto rotate enabled, unlock the orientation
if (settings.autoRotate === true) {
@@ -360,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>
@@ -371,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=="],
@@ -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=="],

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

@@ -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 HomeIndex = () => {
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

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

@@ -22,7 +22,6 @@ import { ListItem } from "../list/ListItem";
import { useTranslation } from "react-i18next";
import DisabledSetting from "@/components/settings/DisabledSetting";
import Dropdown from "@/components/common/Dropdown";
import { RECENTLY_ADDED_SENT_NOTIFICATIONS_ITEM_IDS_KEY } from "@/utils/recently-added-notifications";
export const OtherSettings: React.FC = () => {
const router = useRouter();
@@ -165,9 +164,8 @@ export const OtherSettings: React.FC = () => {
title={t("home.settings.other.hide_libraries")}
showArrow
/>
<ListItem
title={t("home.settings.other.default_quality")}
title="Default quality"
disabled={pluginSettings?.defaultBitrate?.locked}
>
<Dropdown
@@ -175,6 +173,7 @@ export const OtherSettings: React.FC = () => {
disabled={pluginSettings?.defaultBitrate?.locked}
keyExtractor={(item) => item.key}
titleExtractor={(item) => item.key}
selected={settings.defaultBitrate}
title={
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
<Text className="mr-1 text-[#8E8D91]">
@@ -187,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,56 +0,0 @@
import settings from "@/app/(auth)/(tabs)/(home)/settings";
import { Switch, View } from "react-native";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { useSettings } from "@/utils/atoms/settings";
import React, { useCallback, useEffect, useMemo } from "react";
import { BACKGROUND_FETCH_TASK_RECENTLY_ADDED } from "@/utils/background-tasks";
import { storage } from "@/utils/mmkv";
import { RECENTLY_ADDED_SENT_NOTIFICATIONS_ITEM_IDS_KEY } from "@/utils/recently-added-notifications";
import * as TaskManager from "expo-task-manager";
import * as BackgroundFetch from "expo-background-fetch";
import { useMMKVNumber } from "react-native-mmkv";
export const RecentlyAddedNotificationsSettings: React.FC = ({ ...props }) => {
const [settings, updateSettings] = useSettings();
const clearRecentlyAddedNotifications = useCallback(() => {
storage.delete(RECENTLY_ADDED_SENT_NOTIFICATIONS_ITEM_IDS_KEY);
}, []);
const recentlyAddedNotificationsItemIds = useMemo(() => {
const s = storage.getString(RECENTLY_ADDED_SENT_NOTIFICATIONS_ITEM_IDS_KEY);
if (!s) return [] as string[];
try {
const t: string[] = JSON.parse(s);
return t;
} catch (e) {
throw new Error("Failed to parse recently added notifications item ids");
}
}, []);
const [triggerCount, setTriggerCount] = useMMKVNumber(
"notification_send_for_item_ids.count"
);
return (
<View className="mb-4" {...props}>
<ListGroup title={"Recently Added Notifications"}>
<ListItem title={"Recently added notifications"}>
<Switch
value={settings.recentlyAddedNotifications}
onValueChange={(recentlyAddedNotifications) =>
updateSettings({ recentlyAddedNotifications })
}
/>
</ListItem>
<ListItem title={`Trigger count (${triggerCount || 0})`} />
<ListItem
textColor="red"
onPress={clearRecentlyAddedNotifications}
title={`Reset recently added notifications (${recentlyAddedNotificationsItemIds.length})`}
/>
</ListGroup>
</View>
);
};

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

@@ -8,9 +8,6 @@ import { toast } from "sonner-native";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { useTranslation } from "react-i18next";
import { storage } from "@/utils/mmkv";
import { RECENTLY_ADDED_SENT_NOTIFICATIONS_ITEM_IDS_KEY } from "@/utils/recently-added-notifications";
import { useCallback, useMemo } from "react";
export const StorageSettings = () => {
const { deleteAllFiles, appSizeUsage } = useDownload();
@@ -44,7 +41,6 @@ export const StorageSettings = () => {
return ((value / total) * 100).toFixed(2);
};
return (
<View>
<View className="flex flex-col gap-y-1">
@@ -113,7 +109,6 @@ export const StorageSettings = () => {
title={t("home.settings.storage.delete_all_downloaded_files")}
/>
</ListGroup>
</View>
);
};

View File

@@ -554,10 +554,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

@@ -32,20 +32,20 @@
}
},
"production": {
"channel": "0.27.0",
"channel": "0.26.1",
"android": {
"image": "latest"
}
},
"production-apk": {
"channel": "0.27.0",
"channel": "0.26.1",
"android": {
"buildType": "apk",
"image": "latest"
}
},
"production-apk-tv": {
"channel": "0.27.0",
"channel": "0.26.1",
"android": {
"buildType": "apk",
"image": "latest"

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

12
i18n.ts
View File

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

View File

@@ -1,38 +0,0 @@
package expo.modules.vlcplayer
import expo.modules.core.interfaces.ReactActivityLifecycleListener
// TODO: Creating a separate package class and adding this as a lifecycle listener did not work...
// https://docs.expo.dev/modules/android-lifecycle-listeners/
object VLCManager: ReactActivityLifecycleListener {
val listeners: MutableList<ReactActivityLifecycleListener> = mutableListOf()
// override fun onCreate(activity: Activity?, savedInstanceState: Bundle?) {
// listeners.forEach {
// it.onCreate(activity, savedInstanceState)
// }
// }
//
// override fun onResume(activity: Activity?) {
// listeners.forEach {
// it.onResume(activity)
// }
// }
//
// override fun onPause(activity: Activity?) {
// listeners.forEach {
// it.onPause(activity)
// }
// }
//
// override fun onUserLeaveHint(activity: Activity?) {
// listeners.forEach {
// it.onUserLeaveHint(activity)
// }
// }
//
// override fun onDestroy(activity: Activity?) {
// listeners.forEach {
// it.onDestroy(activity)
// }
// }
}

View File

@@ -1,6 +1,5 @@
package expo.modules.vlcplayer
import androidx.core.os.bundleOf
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
@@ -8,18 +7,6 @@ class VlcPlayerModule : Module() {
override fun definition() = ModuleDefinition {
Name("VlcPlayer")
OnActivityEntersForeground {
VLCManager.listeners.forEach {
it.onResume(appContext.currentActivity)
}
}
OnActivityEntersBackground {
VLCManager.listeners.forEach {
it.onPause(appContext.currentActivity)
}
}
View(VlcPlayerView::class) {
Prop("source") { view: VlcPlayerView, source: Map<String, Any> ->
view.setSource(source)

View File

@@ -1,7 +1,6 @@
package expo.modules.vlcplayer
import android.R
import android.app.Activity
import android.app.PendingIntent
import android.app.PendingIntent.FLAG_IMMUTABLE
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
@@ -15,20 +14,13 @@ import android.content.IntentFilter
import android.graphics.drawable.Icon
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.View
import androidx.annotation.RequiresApi
import androidx.core.app.PictureInPictureModeChangedInfo
import androidx.core.view.isVisible
import androidx.lifecycle.Lifecycle
import androidx.core.app.ComponentActivity
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.OnLifecycleEvent
import expo.modules.core.interfaces.ReactActivityLifecycleListener
import expo.modules.core.logging.LogHandlers
import expo.modules.core.logging.Logger
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView
@@ -39,8 +31,7 @@ import org.videolan.libvlc.interfaces.IMedia
import org.videolan.libvlc.util.VLCVideoLayout
class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext), LifecycleObserver, MediaPlayer.EventListener, ReactActivityLifecycleListener {
private val log = Logger(listOf(LogHandlers.createOSLogHandler(this::class.simpleName!!)))
class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext), LifecycleObserver, MediaPlayer.EventListener {
private val PIP_PLAY_PAUSE_ACTION = "PIP_PLAY_PAUSE_ACTION"
private val PIP_REWIND_ACTION = "PIP_REWIND_ACTION"
private val PIP_FORWARD_ACTION = "PIP_FORWARD_ACTION"
@@ -52,7 +43,6 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
private var lastReportedState: Int? = null
private var lastReportedIsPlaying: Boolean? = null
private var media : Media? = null
private var timeLeft: Long? = null
private val onVideoProgress by EventDispatcher()
private val onVideoStateChange by EventDispatcher()
@@ -74,87 +64,53 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
}
private val currentActivity get() = context.findActivity()
private val actions: MutableList<RemoteAction> = mutableListOf()
private val remoteActionFilter = IntentFilter()
private val playPauseIntent: Intent = Intent(PIP_PLAY_PAUSE_ACTION).setPackage(context.packageName)
private val forwardIntent: Intent = Intent(PIP_FORWARD_ACTION).setPackage(context.packageName)
private val rewindIntent: Intent = Intent(PIP_REWIND_ACTION).setPackage(context.packageName)
private var actionReceiver: BroadcastReceiver = object : BroadcastReceiver() {
private val actionReceiver: BroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
PIP_PLAY_PAUSE_ACTION -> {
if (isPaused) play() else pause()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
setupPipActions()
currentActivity.setPictureInPictureParams(getPipParams()!!)
}
}
PIP_PLAY_PAUSE_ACTION -> if (isPaused) play() else pause()
PIP_FORWARD_ACTION -> seekTo((mediaPlayer?.time?.toInt() ?: 0) + 15_000)
PIP_REWIND_ACTION -> seekTo((mediaPlayer?.time?.toInt() ?: 0) - 15_000)
}
}
}
private var pipChangeListener: (PictureInPictureModeChangedInfo) -> Unit = { info ->
if (!info.isInPictureInPictureMode && mediaPlayer?.isPlaying == true) {
log.debug("Exiting PiP")
timeLeft = mediaPlayer?.time
pause()
// Setting the media after reattaching the view allows for a fast video view render
if (mediaPlayer?.vlcVout?.areViewsAttached() == false) {
mediaPlayer?.attachViews(videoLayout, null, false, false)
mediaPlayer?.media = media
mediaPlayer?.play()
timeLeft?.let { mediaPlayer?.time = it }
mediaPlayer?.pause()
init {
setupView()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
setupPipActions()
currentActivity.apply {
setPictureInPictureParams(getPipParams()!!)
addOnPictureInPictureModeChangedListener { info ->
onPipStarted(mapOf(
"pipStarted" to info.isInPictureInPictureMode
))
}
}
}
onPipStarted(mapOf(
"pipStarted" to info.isInPictureInPictureMode
))
}
init {
VLCManager.listeners.add(this)
setupView()
setupPiP()
}
private fun setupView() {
log.debug("Setting up view")
Log.d("VlcPlayerView", "Setting up view")
setBackgroundColor(android.graphics.Color.WHITE)
videoLayout = VLCVideoLayout(context).apply {
layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
}
videoLayout.keepScreenOn = true
addView(videoLayout)
log.debug("View setup complete")
}
private fun setupPiP() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
remoteActionFilter.addAction(PIP_PLAY_PAUSE_ACTION)
remoteActionFilter.addAction(PIP_FORWARD_ACTION)
remoteActionFilter.addAction(PIP_REWIND_ACTION)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
currentActivity.registerReceiver(
actionReceiver,
remoteActionFilter,
Context.RECEIVER_NOT_EXPORTED
)
}
setupPipActions()
currentActivity.apply {
setPictureInPictureParams(getPipParams()!!)
addOnPictureInPictureModeChangedListener(pipChangeListener)
}
}
Log.d("VlcPlayerView", "View setup complete")
}
@RequiresApi(Build.VERSION_CODES.O)
private fun setupPipActions() {
actions.clear()
val remoteActionFilter = IntentFilter()
val playPauseIntent: Intent = Intent(PIP_PLAY_PAUSE_ACTION).setPackage(context.packageName)
val forwardIntent: Intent = Intent(PIP_FORWARD_ACTION).setPackage(context.packageName)
val rewindIntent: Intent = Intent(PIP_REWIND_ACTION).setPackage(context.packageName)
remoteActionFilter.addAction(PIP_PLAY_PAUSE_ACTION)
remoteActionFilter.addAction(PIP_FORWARD_ACTION)
remoteActionFilter.addAction(PIP_REWIND_ACTION)
actions.addAll(
listOf(
RemoteAction(
@@ -169,13 +125,12 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
)
),
RemoteAction(
if (isPaused) Icon.createWithResource(context, R.drawable.ic_media_play)
else Icon.createWithResource(context, R.drawable.ic_media_pause),
Icon.createWithResource(context, R.drawable.ic_media_play),
"Play",
"Play Video",
PendingIntent.getBroadcast(
context,
if (isPaused) 0 else 1,
0,
playPauseIntent,
FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE
)
@@ -193,6 +148,13 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
)
)
)
ContextCompat.registerReceiver(
context,
actionReceiver,
remoteActionFilter,
ContextCompat.RECEIVER_NOT_EXPORTED
)
}
private fun getPipParams(): PictureInPictureParams? {
@@ -209,9 +171,7 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
}
fun setSource(source: Map<String, Any>) {
log.debug("setting source $source")
if (hasSource) {
log.debug("Source already set. Resuming")
mediaPlayer?.attachViews(videoLayout, null, false, false)
play()
return
@@ -236,12 +196,12 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
mediaPlayer?.attachViews(videoLayout, null, false, false)
mediaPlayer?.setEventListener(this)
log.debug("Loading network file: $uri")
Log.d("VlcPlayerView", "Loading network file: $uri")
media = Media(libVLC, Uri.parse(uri))
mediaPlayer?.media = media
log.debug("Debug: Media options: $mediaOptions")
Log.d("VlcPlayerView", "Debug: Media options: $mediaOptions")
// media.addOptions(mediaOptions)
// Apply subtitle options
@@ -258,7 +218,7 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
hasSource = true
if (autoplay) {
log.debug("Playing...")
Log.d("VlcPlayerView", "Playing...")
play()
}
}
@@ -308,7 +268,9 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
}
fun getAudioTracks(): List<Map<String, Any>>? {
log.debug("getAudioTracks ${mediaPlayer?.audioTracks}")
println("getAudioTracks")
println(mediaPlayer?.getAudioTracks())
val trackDescriptions = mediaPlayer?.audioTracks ?: return null
return trackDescriptions.map { trackDescription ->
@@ -332,32 +294,19 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
}
// Debug statement to print the result
log.debug("Subtitle Tracks: $subtitleTracks")
Log.d("VlcPlayerView", "Subtitle Tracks: $subtitleTracks")
return subtitleTracks
}
fun setSubtitleURL(subtitleURL: String, name: String) {
log.debug("Setting subtitle URL: $subtitleURL, name: $name")
println("Setting subtitle URL: $subtitleURL, name: $name")
mediaPlayer?.addSlave(IMedia.Slave.Type.Subtitle, Uri.parse(subtitleURL), true)
}
override fun onDetachedFromWindow() {
log.debug("onDetachedFromWindow")
println("onDetachedFromWindow")
super.onDetachedFromWindow()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
currentActivity.setPictureInPictureParams(
PictureInPictureParams.Builder()
.setAutoEnterEnabled(false)
.build()
)
}
currentActivity.unregisterReceiver(actionReceiver)
currentActivity.removeOnPictureInPictureModeChangedListener(pipChangeListener)
VLCManager.listeners.clear()
mediaPlayer?.stop()
handler.removeCallbacks(updateProgressRunnable) // Stop updating progress
@@ -370,7 +319,6 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
}
override fun onEvent(event: MediaPlayer.Event) {
keepScreenOn = event.type == MediaPlayer.Event.Playing || event.type == MediaPlayer.Event.Buffering
when (event.type) {
MediaPlayer.Event.Playing,
MediaPlayer.Event.Paused,
@@ -392,27 +340,35 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
"target" to "null", // Replace with actual target if needed
"currentTime" to player.time.toInt(),
"duration" to (player.media?.duration?.toInt() ?: 0),
"error" to false,
"isPlaying" to (currentState == MediaPlayer.Event.Playing),
"isBuffering" to (!player.isPlaying && currentState == MediaPlayer.Event.Buffering)
"error" to false
)
// Todo: make enum - string to prevent this when statement from becoming exhaustive
when (currentState) {
MediaPlayer.Event.Playing ->
MediaPlayer.Event.Playing -> {
stateInfo["isPlaying"] = true
stateInfo["isBuffering"] = false
stateInfo["state"] = "Playing"
MediaPlayer.Event.Paused ->
}
MediaPlayer.Event.Paused -> {
stateInfo["isPlaying"] = false
stateInfo["state"] = "Paused"
MediaPlayer.Event.Buffering ->
}
MediaPlayer.Event.Buffering -> {
stateInfo["isBuffering"] = true
stateInfo["state"] = "Buffering"
}
MediaPlayer.Event.EncounteredError -> {
Log.e("VlcPlayerView", "player.state ~ error")
stateInfo["state"] = "Error"
onVideoLoadEnd(stateInfo);
}
MediaPlayer.Event.Opening ->
MediaPlayer.Event.Opening -> {
Log.d("VlcPlayerView", "player.state ~ opening")
stateInfo["state"] = "Opening"
}
}
if (lastReportedState != currentState || lastReportedIsPlaying != player.isPlaying) {
lastReportedState = currentState
lastReportedIsPlaying = player.isPlaying
@@ -444,16 +400,6 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
));
}
}
override fun onPause(activity: Activity?) {
log.debug("Pausing activity...")
}
override fun onResume(activity: Activity?) {
log.debug("Resuming activity...")
if (isPaused) play()
}
}
internal fun Context.findActivity(): androidx.activity.ComponentActivity {

View File

@@ -1,8 +1,7 @@
{
"platforms": ["ios", "tvos", "android", "web"],
"ios": {
"modules": ["VlcPlayerModule"],
"appDelegateSubscribers": ["AppLifecycleDelegate"]
"modules": ["VlcPlayerModule"]
},
"android": {
"modules": ["expo.modules.vlcplayer.VlcPlayerModule"]

View File

@@ -1,32 +0,0 @@
import ExpoModulesCore
protocol SimpleAppLifecycleListener {
func applicationDidEnterBackground() -> Void
func applicationDidEnterForeground() -> Void
}
public class AppLifecycleDelegate: ExpoAppDelegateSubscriber {
public func applicationDidBecomeActive(_ application: UIApplication) {
// The app has become active.
}
public func applicationWillResignActive(_ application: UIApplication) {
// The app is about to become inactive.
}
public func applicationDidEnterBackground(_ application: UIApplication) {
VLCManager.shared.listeners.forEach { listener in
listener.applicationDidEnterBackground()
}
}
public func applicationWillEnterForeground(_ application: UIApplication) {
VLCManager.shared.listeners.forEach { listener in
listener.applicationDidEnterForeground()
}
}
public func applicationWillTerminate(_ application: UIApplication) {
// The app is about to terminate.
}
}

View File

@@ -1,4 +0,0 @@
class VLCManager {
static let shared = VLCManager()
var listeners: [SimpleAppLifecycleListener] = []
}

View File

@@ -1,8 +1,6 @@
import ExpoModulesCore
import UIKit
import VLCKit
import os
public class VLCPlayerView: UIView {
func setupView(parent: UIView) {
@@ -144,8 +142,6 @@ extension VLCPlayerWrapper: VLCMediaDelegate {
}
class VlcPlayerView: ExpoView {
let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "VlcPlayerView")
private var vlc: VLCPlayerWrapper = VLCPlayerWrapper()
private var progressUpdateInterval: TimeInterval = 1.0 // Update interval set to 1 second
private var isPaused: Bool = false
@@ -161,7 +157,6 @@ class VlcPlayerView: ExpoView {
super.init(appContext: appContext)
setupVLC()
setupNotifications()
VLCManager.shared.listeners.append(self)
}
// MARK: - Setup
@@ -193,7 +188,7 @@ class VlcPlayerView: ExpoView {
@objc func play() {
self.vlc.player.play()
self.isPaused = false
logger.debug("Play")
print("Play")
}
@objc func pause() {
@@ -208,7 +203,7 @@ class VlcPlayerView: ExpoView {
}
if let duration = vlc.player.media?.length.intValue {
logger.debug("Seeking to time: \(time) Video Duration \(duration)")
print("Seeking to time: \(time) Video Duration \(duration)")
// If the specified time is greater than the duration, seek to the end
let seekTime = time > duration ? duration - 1000 : time
@@ -222,12 +217,11 @@ class VlcPlayerView: ExpoView {
}
}
} else {
logger.error("Unable to retrieve video duration")
print("Error: Unable to retrieve video duration")
}
}
@objc func setSource(_ source: [String: Any]) {
logger.debug("Setting source...")
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
if self.hasSource {
@@ -247,7 +241,7 @@ class VlcPlayerView: ExpoView {
}
guard let uri = source["uri"] as? String, !uri.isEmpty else {
logger.error("Invalid or empty URI")
print("Error: Invalid or empty URI")
self.onVideoError?(["error": "Invalid or empty URI"])
return
}
@@ -259,10 +253,10 @@ class VlcPlayerView: ExpoView {
let media: VLCMedia!
if isNetwork {
logger.debug("Loading network file: \(uri)")
print("Loading network file: \(uri)")
media = VLCMedia(url: URL(string: uri)!)
} else {
logger.debug("Loading local file: \(uri)")
print("Loading local file: \(uri)")
if uri.starts(with: "file://"), let url = URL(string: uri) {
media = VLCMedia(url: url)
} else {
@@ -270,14 +264,14 @@ class VlcPlayerView: ExpoView {
}
}
logger.debug("Media options: \(mediaOptions)")
print("Debug: Media options: \(mediaOptions)")
media.addOptions(mediaOptions)
self.vlc.player.media = media
self.setInitialExternalSubtitles()
self.hasSource = true
if autoplay {
logger.info("Playing...")
print("Playing...")
self.play()
self.vlc.player.time = VLCTime(number: NSNumber(value: self.startPosition * 1000))
}
@@ -297,31 +291,31 @@ class VlcPlayerView: ExpoView {
}
@objc func setSubtitleTrack(_ trackIndex: Int) {
logger.debug("Attempting to set subtitle track to index: \(trackIndex)")
print("Debug: Attempting to set subtitle track to index: \(trackIndex)")
if trackIndex == -1 {
logger.debug("Disabling all subtitles")
print("Debug: Disabling all subtitles")
for track in self.vlc.player.textTracks {
track.isSelected = false
}
return
}
let track = self.vlc.player.textTracks[trackIndex]
track.isSelectedExclusively = true;
logger.debug("Current subtitle track index after setting: \(track.trackName)")
track.isSelectedExclusively = true
print("Debug: Current subtitle track index after setting: \(track.trackName)")
}
@objc func setSubtitleURL(_ subtitleURL: String, name: String) {
guard let url = URL(string: subtitleURL) else {
logger.error("Invalid subtitle URL")
print("Error: Invalid subtitle URL")
return
}
let result = self.vlc.player.addPlaybackSlave(url, type: .subtitle, enforce: false)
if result == 0 {
let internalName = "Track \(self.customSubtitles.count)"
self.customSubtitles.append((internalName: internalName, originalName: name))
logger.debug("Subtitle added with result: \(result) \(internalName)")
print("Subtitle added with result: \(result) \(internalName)")
} else {
logger.debug("Failed to add subtitle")
print("Failed to add subtitle")
}
}
@@ -330,8 +324,7 @@ class VlcPlayerView: ExpoView {
return nil
}
logger.debug("Number of subtitle tracks: \(self.vlc.player.textTracks.count)")
print("Debug: Number of subtitle tracks: \(self.vlc.player.textTracks.count)")
let tracks = self.vlc.player.textTracks.enumerated().map { (index, track) in
if let customSubtitle = customSubtitles.first(where: {
$0.internalName == track.trackName
@@ -341,13 +334,11 @@ class VlcPlayerView: ExpoView {
return ["name": track.trackName, "index": index]
}
}
logger.debug("Subtitle tracks: \(tracks)")
return tracks
print("Debug: Subtitle tracks: \(tracks)")
return tracks
}
@objc func stop(completion: (() -> Void)? = nil) {
logger.debug("Stopping media...")
guard !isStopping else {
completion?()
return
@@ -407,7 +398,7 @@ class VlcPlayerView: ExpoView {
let currentTimeMs = self.vlc.player.time.intValue
let durationMs = self.vlc.player.media?.length.intValue ?? 0
logger.debug("Current time: \(currentTimeMs)")
print("Debug: Current time: \(currentTimeMs)")
self.onVideoProgress?([
"currentTime": currentTimeMs,
"duration": durationMs,
@@ -439,32 +430,7 @@ class VlcPlayerView: ExpoView {
// MARK: - Deinitialization
deinit {
logger.debug("Deinitialization")
performStop()
VLCManager.shared.listeners.removeAll()
}
}
// MARK: - SimpleAppLifecycleListener
extension VlcPlayerView: SimpleAppLifecycleListener {
func applicationDidEnterBackground() {
logger.debug("Entering background")
}
func applicationDidEnterForeground() {
logger.debug("Entering foreground, is player visible? \(self.vlc.getPlayerView().superview != nil)")
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;
@@ -61,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin(
() =>
new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.27.0" },
clientInfo: { name: "Streamyfin", version: "0.26.1" },
deviceInfo: {
name: deviceName,
id,
@@ -85,12 +88,28 @@ 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 {
authorization: `MediaBrowser Client="Streamyfin", Device=${
Platform.OS === "android" ? "Android" : "iOS"
}, DeviceId="${deviceId}", Version="0.27.0"`,
}, DeviceId="${deviceId}", Version="0.26.1"`,
};
}, [deviceId]);
@@ -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,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": "映画やテレビ番組をダウンロードしてオフラインで視聴します。デフォルトの方法を使用するか、バックグラウンドでファイルをダウンロードするために最適化されたサーバーをインストールしてください。",
"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": "s"
},
"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 versionsサーバー",
"save_button": "保存",
"optimized_server": "Optimizedサーバー",
"optimized": "最適化",
"default": "デフォルト",
"optimized_version_hint": "OptimizeサーバーのURLを入力します。URLにはhttpまたはhttpsを含め、オプションでポートを指定します。",
"read_more_about_optimized_server": "Optimizeサーバーの詳細をご覧ください。",
"url": "URL",
"server_url_placeholder": "http(s)://domain.org:ポート"
},
"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": "マーリン検索を有効にする ",
"url": "URL",
"server_url_placeholder": "http(s)://domain.org:ポート",
"marlin_search_hint": "MarlinサーバーのURLを入力します。URLにはhttpまたはhttpsを含め、オプションでポートを指定します。",
"read_more_about_marlin": "Marlinについて詳しく読む。",
"save_button": "保存",
"toasts": {
"saved": "保存しました"
}
}
},
"storage": {
"storage_title": "ストレージ",
"app_usage": "アプリ {{usedSpace}}%",
"phone_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": "TVシリーズ",
"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 {{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}}をoptimizeのキューに追加しました",
"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": "Optimizeサーバーを使用する",
"using_default_method": "デフォルトの方法を使用"
}
},
"live_tv": {
"next": "次",
"previous": "前",
"live_tv": "ライブ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

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

@@ -145,7 +145,6 @@ export type Settings = {
safeAreaInControlsEnabled: boolean;
jellyseerrServerUrl?: string;
hiddenLibraries?: string[];
recentlyAddedNotifications: boolean;
};
export interface Lockable<T> {
@@ -199,7 +198,6 @@ const defaultValues: Settings = {
safeAreaInControlsEnabled: true,
jellyseerrServerUrl: undefined,
hiddenLibraries: [],
recentlyAddedNotifications: true,
};
const loadSettings = (): Partial<Settings> => {
@@ -270,8 +268,6 @@ export const useSettings = () => {
const newSettings = { ..._settings, ...update };
setSettings(newSettings);
// @ts-expect-error
saveSettings(newSettings);
}
};

View File

@@ -8,7 +8,7 @@ export const BACKGROUND_FETCH_TASK = "background-fetch";
export async function registerBackgroundFetchAsync() {
try {
BackgroundFetch.registerTaskAsync(BACKGROUND_FETCH_TASK, {
minimumInterval: 10 * 60, // 10 minutes
minimumInterval: 60 * 1, // 1 minutes
stopOnTerminate: false, // android only,
startOnBoot: false, // android only
});
@@ -24,26 +24,3 @@ export async function unregisterBackgroundFetchAsync() {
console.log("Error unregistering background fetch task", error);
}
}
export const BACKGROUND_FETCH_TASK_RECENTLY_ADDED =
"background-fetch-recently-added";
export async function registerBackgroundFetchAsyncRecentlyAdded() {
try {
BackgroundFetch.registerTaskAsync(BACKGROUND_FETCH_TASK_RECENTLY_ADDED, {
minimumInterval: 10 * 60, // 10 minutes
stopOnTerminate: false, // android only,
startOnBoot: true, // android only
});
} catch (error) {
console.log("Error registering background fetch task", error);
}
}
export async function unregisterBackgroundFetchAsyncRecentlyAdded() {
try {
BackgroundFetch.unregisterTaskAsync(BACKGROUND_FETCH_TASK_RECENTLY_ADDED);
} catch (error) {
console.log("Error unregistering background fetch task", error);
}
}

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",

View File

@@ -1,187 +0,0 @@
import { storage } from "@/utils/mmkv";
import { Jellyfin } from "@jellyfin/sdk";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import * as Notifications from "expo-notifications";
import { Platform } from "react-native";
import { getDeviceName } from "react-native-device-info";
import { getOrSetDeviceId } from "./device";
export const RECENTLY_ADDED_SENT_NOTIFICATIONS_ITEM_IDS_KEY =
"notification_send_for_item_ids";
const acceptedIemTypes = ["Movie", "Episode", "Series"];
async function sendNewItemNotification(item: BaseItemDto) {
if (Platform.isTV) return;
if (!item.Type) return;
if (!acceptedIemTypes.includes(item.Type)) return;
if (item.Type === "Movie")
await Notifications.scheduleNotificationAsync({
content: {
title: `New Movie Added`,
body: `${item.Name} (${item.ProductionYear})`,
},
trigger: null,
});
else if (item.Type === "Episode")
await Notifications.scheduleNotificationAsync({
content: {
title: `New Episode Added`,
body: `${item.SeriesName} - ${item.Name}`,
},
trigger: null,
});
else if (item.Type === "Series")
await Notifications.scheduleNotificationAsync({
content: {
title: `New Series Added`,
body: `${item.Name} (${item.ProductionYear})`,
},
trigger: null,
});
}
/**
* Fetches recently added items from Jellyfin and sends notifications for new content.
*
* This function performs the following operations:
* 1. Retrieves previously notified item IDs from storage
* 2. Connects to Jellyfin server using provided credentials
* 3. Fetches 5 most recent episodes and 5 most recent movies
* 4. Checks for new items that haven't been notified before
* 5. Sends notifications for new items
* 6. Updates storage with new item IDs
*
* Note: On first run (when no previous notifications exist), it will store all
* current items without sending notifications to avoid mass-notifications.
*
* @param userId - The Jellyfin user ID to fetch items for
* @param basePath - The base URL of the Jellyfin server
* @param token - The authentication token for the Jellyfin server
*/
export async function fetchAndStoreRecentlyAdded(
userId: string,
basePath: string,
token: string
): Promise<number> {
try {
const deviceName = await getDeviceName();
const id = getOrSetDeviceId();
// Get stored items
const _alreadySentItemIds = storage.getString(
RECENTLY_ADDED_SENT_NOTIFICATIONS_ITEM_IDS_KEY
);
const alreadySentItemIds: string[] = _alreadySentItemIds
? JSON.parse(_alreadySentItemIds)
: [];
console.log(
"fetchAndStoreRecentlyAdded ~ notifications stored:",
alreadySentItemIds.length
);
const jellyfin = new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.27.0" },
deviceInfo: {
name: deviceName,
id,
},
});
const api = jellyfin?.createApi(basePath, token);
const response1 = await getItemsApi(api).getItems({
userId: userId,
limit: 10,
recursive: true,
includeItemTypes: ["Episode"],
sortOrder: ["Descending"],
sortBy: ["DateCreated"],
});
const response2 = await getItemsApi(api).getItems({
userId: userId,
limit: 10,
recursive: true,
includeItemTypes: ["Movie"],
sortOrder: ["Descending"],
sortBy: ["DateCreated"],
});
const response3 = await getItemsApi(api).getItems({
userId: userId,
limit: 10,
recursive: true,
includeItemTypes: ["Series"],
sortOrder: ["Descending"],
sortBy: ["DateCreated"],
});
const newEpisodes =
response1.data.Items?.map((item) => ({
Id: item.Id,
Name: item.Name,
DateCreated: item.DateCreated,
Type: item.Type,
})) ?? [];
const newMovies =
response2.data.Items?.map((item) => ({
Id: item.Id,
Name: item.Name,
DateCreated: item.DateCreated,
Type: item.Type,
})) ?? [];
const newSeries =
response3.data.Items?.map((item) => ({
Id: item.Id,
Name: item.Name,
DateCreated: item.DateCreated,
Type: item.Type,
})) ?? [];
const newIds: string[] = [];
const items = [...newEpisodes, ...newMovies, ...newSeries];
// Don't send initial mass-notifications if there are no previously sent notifications
if (alreadySentItemIds.length === 0) {
// Store all items as sent (since these items are already in the users library)
storage.set(
RECENTLY_ADDED_SENT_NOTIFICATIONS_ITEM_IDS_KEY,
JSON.stringify(items.map((item) => item.Id))
);
return items.length;
} else {
// Only send notifications for items that haven't been sent yet
for (const newItem of items) {
const alreadySentNotificationFor = alreadySentItemIds.some(
(id) => id === newItem.Id
);
if (!alreadySentNotificationFor) {
const fullItem = await getUserLibraryApi(api).getItem({
itemId: newItem.Id!,
userId: userId,
});
await sendNewItemNotification(fullItem.data);
newIds.push(newItem.Id!);
}
}
// Store all new items as sent, so that we don't send notifications for them again
storage.set(
RECENTLY_ADDED_SENT_NOTIFICATIONS_ITEM_IDS_KEY,
JSON.stringify([...new Set([...alreadySentItemIds, ...newIds])])
);
return newIds.length;
}
} catch (error) {
console.error("Error fetching recently added items:", error);
}
return 0;
}