forked from Ninjalama/streamyfin_mirror
chore: refactor for tv stuff
This commit is contained in:
@@ -1,498 +1,5 @@
|
|||||||
import { Button } from "@/components/Button";
|
import { SettingsIndex } from "@/components/settings/SettingsIndex";
|
||||||
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";
|
|
||||||
|
|
||||||
type ScrollingCollectionListSection = {
|
export default function page() {
|
||||||
type: "ScrollingCollectionList";
|
return <SettingsIndex />;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Platform } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
|
||||||
import { AudioToggles } from "@/components/settings/AudioToggles";
|
import { AudioToggles } from "@/components/settings/AudioToggles";
|
||||||
|
import DownloadSettings from "@/components/settings/DownloadSettings";
|
||||||
import { MediaProvider } from "@/components/settings/MediaContext";
|
import { MediaProvider } from "@/components/settings/MediaContext";
|
||||||
import { MediaToggles } from "@/components/settings/MediaToggles";
|
import { MediaToggles } from "@/components/settings/MediaToggles";
|
||||||
import { OtherSettings } from "@/components/settings/OtherSettings";
|
import { OtherSettings } from "@/components/settings/OtherSettings";
|
||||||
@@ -10,20 +11,16 @@ import { PluginSettings } from "@/components/settings/PluginSettings";
|
|||||||
import { QuickConnect } from "@/components/settings/QuickConnect";
|
import { QuickConnect } from "@/components/settings/QuickConnect";
|
||||||
import { StorageSettings } from "@/components/settings/StorageSettings";
|
import { StorageSettings } from "@/components/settings/StorageSettings";
|
||||||
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
||||||
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
|
|
||||||
import { UserInfo } from "@/components/settings/UserInfo";
|
import { UserInfo } from "@/components/settings/UserInfo";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useJellyfin } from "@/providers/JellyfinProvider";
|
import { useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
import { clearLogs } from "@/utils/log";
|
import { clearLogs } from "@/utils/log";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { storage } from "@/utils/mmkv";
|
||||||
import { useNavigation, useRouter } from "expo-router";
|
import { useNavigation, useRouter } from "expo-router";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import React, { lazy, useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { storage } from "@/utils/mmkv";
|
|
||||||
const DownloadSettings = lazy(
|
|
||||||
() => import("@/components/settings/DownloadSettings")
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function settings() {
|
export default function settings() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -72,7 +69,7 @@ export default function settings() {
|
|||||||
|
|
||||||
<OtherSettings />
|
<OtherSettings />
|
||||||
|
|
||||||
{!Platform.isTV && <DownloadSettings />}
|
<DownloadSettings />
|
||||||
|
|
||||||
<PluginSettings />
|
<PluginSettings />
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
|||||||
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -21,22 +21,26 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { t } from 'i18next';
|
import { t } from "i18next";
|
||||||
const CredentialsSchema = z.object({
|
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 Login: React.FC = () => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const params = useLocalSearchParams();
|
||||||
const { setServer, login, removeServer, initiateQuickConnect } =
|
const { setServer, login, removeServer, initiateQuickConnect } =
|
||||||
useJellyfin();
|
useJellyfin();
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const params = useLocalSearchParams();
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
apiUrl: _apiUrl,
|
apiUrl: _apiUrl,
|
||||||
username: _username,
|
username: _username,
|
||||||
password: _password,
|
password: _password,
|
||||||
} = params as { apiUrl: string; username: string; password: string };
|
} = 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 [serverURL, setServerURL] = useState<string>(_apiUrl);
|
||||||
const [serverName, setServerName] = useState<string>("");
|
const [serverName, setServerName] = useState<string>("");
|
||||||
const [credentials, setCredentials] = useState<{
|
const [credentials, setCredentials] = useState<{
|
||||||
@@ -47,10 +51,11 @@ const CredentialsSchema = z.object({
|
|||||||
password: _password,
|
password: _password,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A way to auto login based on a link
|
||||||
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(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) {
|
if (_apiUrl) {
|
||||||
setServer({
|
setServer({
|
||||||
address: _apiUrl,
|
address: _apiUrl,
|
||||||
@@ -66,7 +71,6 @@ const CredentialsSchema = z.object({
|
|||||||
})();
|
})();
|
||||||
}, [_apiUrl, _username, _password]);
|
}, [_apiUrl, _username, _password]);
|
||||||
|
|
||||||
const navigation = useNavigation();
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerTitle: serverName,
|
headerTitle: serverName,
|
||||||
@@ -79,14 +83,14 @@ const CredentialsSchema = z.object({
|
|||||||
className="flex flex-row items-center"
|
className="flex flex-row items-center"
|
||||||
>
|
>
|
||||||
<Ionicons name="chevron-back" size={18} color={Colors.primary} />
|
<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>
|
</TouchableOpacity>
|
||||||
) : null,
|
) : null,
|
||||||
});
|
});
|
||||||
}, [serverName, navigation, api?.basePath]);
|
}, [serverName, navigation, api?.basePath]);
|
||||||
|
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
|
||||||
|
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -98,14 +102,16 @@ const CredentialsSchema = z.object({
|
|||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
Alert.alert(t("login.connection_failed"), error.message);
|
Alert.alert(t("login.connection_failed"), error.message);
|
||||||
} else {
|
} 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 {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks the availability and validity of a Jellyfin server URL.
|
* Checks the availability and validity of a Jellyfin server URL.
|
||||||
@@ -180,14 +186,21 @@ const CredentialsSchema = z.object({
|
|||||||
try {
|
try {
|
||||||
const code = await initiateQuickConnect();
|
const code = await initiateQuickConnect();
|
||||||
if (code) {
|
if (code) {
|
||||||
Alert.alert(t("login.quick_connect"), t("login.enter_code_to_login", {code: code}), [
|
Alert.alert(
|
||||||
{
|
t("login.quick_connect"),
|
||||||
text: t("login.got_it"),
|
t("login.enter_code_to_login", { code: code }),
|
||||||
},
|
[
|
||||||
]);
|
{
|
||||||
|
text: t("login.got_it"),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -201,16 +214,18 @@ const CredentialsSchema = z.object({
|
|||||||
<View className="flex flex-col h-full relative items-center justify-center">
|
<View className="flex flex-col h-full relative items-center justify-center">
|
||||||
<View className="px-4 -mt-20 w-full">
|
<View className="px-4 -mt-20 w-full">
|
||||||
<View className="flex flex-col space-y-2">
|
<View className="flex flex-col space-y-2">
|
||||||
<Text className="text-2xl font-bold -mb-2">
|
<Text className="text-2xl font-bold -mb-2">
|
||||||
<>
|
<>
|
||||||
{serverName ? (
|
{serverName ? (
|
||||||
<>
|
<>
|
||||||
{t("login.login_to_title") + " "}
|
{t("login.login_to_title") + " "}
|
||||||
<Text className="text-purple-600">{serverName}</Text>
|
<Text className="text-purple-600">{serverName}</Text>
|
||||||
</>
|
</>
|
||||||
) : t("login.login_title")}
|
) : (
|
||||||
</>
|
t("login.login_title")
|
||||||
</Text>
|
)}
|
||||||
|
</>
|
||||||
|
</Text>
|
||||||
<Text className="text-xs text-neutral-400">
|
<Text className="text-xs text-neutral-400">
|
||||||
{api.basePath}
|
{api.basePath}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
4
bun.lock
4
bun.lock
@@ -60,7 +60,7 @@
|
|||||||
"react-i18next": "^15.4.0",
|
"react-i18next": "^15.4.0",
|
||||||
"react-native": "npm:react-native-tvos@~0.77.0-0",
|
"react-native": "npm:react-native-tvos@~0.77.0-0",
|
||||||
"react-native-awesome-slider": "^2.9.0",
|
"react-native-awesome-slider": "^2.9.0",
|
||||||
"react-native-bottom-tabs": "0.8.6",
|
"react-native-bottom-tabs": "0.8.7",
|
||||||
"react-native-circular-progress": "^1.4.1",
|
"react-native-circular-progress": "^1.4.1",
|
||||||
"react-native-compressor": "^1.10.3",
|
"react-native-compressor": "^1.10.3",
|
||||||
"react-native-country-flag": "^2.0.2",
|
"react-native-country-flag": "^2.0.2",
|
||||||
@@ -1826,7 +1826,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-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.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-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-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=="],
|
"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=="],
|
||||||
|
|
||||||
|
|||||||
5
components/settings/DownloadSettings.tv.tsx
Normal file
5
components/settings/DownloadSettings.tv.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export default function DownloadSettings({ ...props }) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
495
components/settings/SettingsIndex.tsx
Normal file
495
components/settings/SettingsIndex.tsx
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
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 {
|
||||||
|
useSplashScreenLoading,
|
||||||
|
useSplashScreenVisible,
|
||||||
|
} from "@/providers/SplashScreenProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Api } from "@jellyfin/sdk";
|
||||||
|
import {
|
||||||
|
BaseItemDto,
|
||||||
|
BaseItemKind,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import {
|
||||||
|
getItemsApi,
|
||||||
|
getSuggestionsApi,
|
||||||
|
getTvShowsApi,
|
||||||
|
getUserLibraryApi,
|
||||||
|
getUserViewsApi,
|
||||||
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import NetInfo from "@react-native-community/netinfo";
|
||||||
|
import { QueryFunction, useQuery } from "@tanstack/react-query";
|
||||||
|
import { useNavigation, useRouter } from "expo-router";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
RefreshControl,
|
||||||
|
ScrollView,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
type ScrollingCollectionListSection = {
|
||||||
|
type: "ScrollingCollectionList";
|
||||||
|
title?: string;
|
||||||
|
queryKey: (string | undefined | null)[];
|
||||||
|
queryFn: QueryFunction<BaseItemDto[]>;
|
||||||
|
orientation?: "horizontal" | "vertical";
|
||||||
|
};
|
||||||
|
|
||||||
|
type MediaListSection = {
|
||||||
|
type: "MediaListSection";
|
||||||
|
queryKey: (string | undefined)[];
|
||||||
|
queryFn: QueryFunction<BaseItemDto>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Section = ScrollingCollectionListSection | MediaListSection;
|
||||||
|
|
||||||
|
export const SettingsIndex = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [
|
||||||
|
settings,
|
||||||
|
updateSettings,
|
||||||
|
pluginSettings,
|
||||||
|
setPluginSettings,
|
||||||
|
refreshStreamyfinPluginSettings,
|
||||||
|
] = useSettings();
|
||||||
|
|
||||||
|
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
||||||
|
const [loadingRetry, setLoadingRetry] = useState(false);
|
||||||
|
|
||||||
|
const navigation = useNavigation();
|
||||||
|
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const { downloadedFiles, cleanCacheDirectory } = useDownload();
|
||||||
|
useEffect(() => {
|
||||||
|
const hasDownloads = downloadedFiles && downloadedFiles.length > 0;
|
||||||
|
navigation.setOptions({
|
||||||
|
headerLeft: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
router.push("/(auth)/downloads");
|
||||||
|
}}
|
||||||
|
className="p-2"
|
||||||
|
>
|
||||||
|
<Feather
|
||||||
|
name="download"
|
||||||
|
color={hasDownloads ? Colors.primary : "white"}
|
||||||
|
size={22}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [downloadedFiles, navigation, router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
cleanCacheDirectory().catch((e) =>
|
||||||
|
console.error("Something went wrong cleaning cache directory")
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const checkConnection = useCallback(async () => {
|
||||||
|
setLoadingRetry(true);
|
||||||
|
const state = await NetInfo.fetch();
|
||||||
|
setIsConnected(state.isConnected);
|
||||||
|
setLoadingRetry(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = NetInfo.addEventListener((state) => {
|
||||||
|
if (state.isConnected == false || state.isInternetReachable === false)
|
||||||
|
setIsConnected(false);
|
||||||
|
else setIsConnected(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
NetInfo.fetch().then((state) => {
|
||||||
|
setIsConnected(state.isConnected);
|
||||||
|
});
|
||||||
|
|
||||||
|
// cleanCacheDirectory().catch((e) =>
|
||||||
|
// console.error("Something went wrong cleaning cache directory")
|
||||||
|
// );
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isError: e1,
|
||||||
|
isLoading: l1,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["home", "userViews", user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await getUserViewsApi(api).getUserViews({
|
||||||
|
userId: user.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data.Items || null;
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id,
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
463
components/settings/SettingsIndex.tv.tsx
Normal file
463
components/settings/SettingsIndex.tv.tsx
Normal file
@@ -0,0 +1,463 @@
|
|||||||
|
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 {
|
||||||
|
useSplashScreenLoading,
|
||||||
|
useSplashScreenVisible,
|
||||||
|
} from "@/providers/SplashScreenProvider";
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
"react-i18next": "^15.4.0",
|
"react-i18next": "^15.4.0",
|
||||||
"react-native": "npm:react-native-tvos@~0.77.0-0",
|
"react-native": "npm:react-native-tvos@~0.77.0-0",
|
||||||
"react-native-awesome-slider": "^2.9.0",
|
"react-native-awesome-slider": "^2.9.0",
|
||||||
"react-native-bottom-tabs": "0.8.6",
|
"react-native-bottom-tabs": "0.8.7",
|
||||||
"react-native-circular-progress": "^1.4.1",
|
"react-native-circular-progress": "^1.4.1",
|
||||||
"react-native-compressor": "^1.10.3",
|
"react-native-compressor": "^1.10.3",
|
||||||
"react-native-country-flag": "^2.0.2",
|
"react-native-country-flag": "^2.0.2",
|
||||||
|
|||||||
@@ -18,11 +18,13 @@ import {
|
|||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} 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 { focusManager, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import * as Application from "expo-application";
|
import * as Application from "expo-application";
|
||||||
import * as FileSystem from "expo-file-system";
|
import * as FileSystem from "expo-file-system";
|
||||||
import { FileInfo } from "expo-file-system";
|
import { FileInfo } from "expo-file-system";
|
||||||
|
import Notifications from "expo-notifications";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import React, {
|
import React, {
|
||||||
@@ -36,11 +38,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { AppState, AppStateStatus, Platform } from "react-native";
|
import { AppState, AppStateStatus, Platform } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { apiAtom } from "./JellyfinProvider";
|
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 = {
|
export type DownloadedItem = {
|
||||||
item: Partial<BaseItemDto>;
|
item: Partial<BaseItemDto>;
|
||||||
@@ -58,8 +55,6 @@ const DownloadContext = createContext<ReturnType<
|
|||||||
> | null>(null);
|
> | null>(null);
|
||||||
|
|
||||||
function useDownloadProvider() {
|
function useDownloadProvider() {
|
||||||
if (Platform.isTV) return;
|
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
@@ -747,5 +742,8 @@ export function useDownload() {
|
|||||||
if (context === null) {
|
if (context === null) {
|
||||||
throw new Error("useDownload must be used within a DownloadProvider");
|
throw new Error("useDownload must be used within a DownloadProvider");
|
||||||
}
|
}
|
||||||
|
if (Platform.isTV) {
|
||||||
|
throw new Error("useDownload is not supported on TVOS");
|
||||||
|
}
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|||||||
107
providers/DownloadProvider.tv.tsx
Normal file
107
providers/DownloadProvider.tv.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -3,15 +3,8 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": [
|
"@/*": ["./*"]
|
||||||
"./*"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
|
||||||
"**/*.ts",
|
|
||||||
"**/*.tsx",
|
|
||||||
".expo/types/**/*.ts",
|
|
||||||
"expo-env.d.ts"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user