feat: Custom homescreen support (#424)

This commit is contained in:
lostb1t
2025-01-13 19:48:19 +01:00
committed by GitHub
parent 3a836462f5
commit 4022ccb213
5 changed files with 178 additions and 300 deletions

View File

@@ -8,7 +8,7 @@ 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 { HomeSectionStyle, useSettings } from "@/utils/atoms/settings";
import { Feather, Ionicons } from "@expo/vector-icons";
import { Api } from "@jellyfin/sdk";
import {
@@ -141,29 +141,6 @@ export default function index() {
[data, settings?.hiddenLibraries]
);
const {
data: mediaListCollections,
isError: e2,
isLoading: l2,
} = useQuery({
queryKey: ["home", "sf_promoted", user?.Id, settings?.usePopularPlugin],
queryFn: async () => {
if (!api || !user?.Id) return [];
const response = await getItemsApi(api).getItems({
userId: user.Id,
tags: ["sf_promoted"],
recursive: true,
fields: ["Tags"],
includeItemTypes: ["BoxSet"],
});
return response.data.Items || [];
},
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
staleTime: 60 * 1000,
});
const collections = useMemo(() => {
const allow = ["movies", "tvshows"];
return (
@@ -211,107 +188,139 @@ export default function index() {
[api, user?.Id]
);
const sections = useMemo(() => {
if (!api || !user?.Id) return [];
let sections: Section[] = [];
if (settings?.home === null || settings?.home?.sections === null) {
sections = useMemo(() => {
if (!api || !user?.Id) return [];
const latestMediaViews = collections.map((c) => {
const includeItemTypes: BaseItemKind[] =
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
const title = "Recently Added in " + c.Name;
const queryKey = [
"home",
"recentlyAddedIn" + c.CollectionType,
user?.Id!,
c.Id!,
];
return createCollectionConfig(
title || "",
queryKey,
includeItemTypes,
c.Id
);
});
const latestMediaViews = collections.map((c) => {
const includeItemTypes: BaseItemKind[] =
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
const title = "Recently Added in " + c.Name;
const queryKey = [
"home",
"recentlyAddedIn" + c.CollectionType,
user?.Id!,
c.Id!,
];
return createCollectionConfig(
title || "",
queryKey,
includeItemTypes,
c.Id
);
});
const ss: Section[] = [
{
title: "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: "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: "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: "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 [];
}
const ss: Section[] = [
{
title: "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",
},
type: "ScrollingCollectionList",
orientation: "horizontal",
},
];
return ss;
}, [api, user?.Id, collections, mediaListCollections]);
{
title: "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: "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: "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) {
const section = settings.home?.sections[key];
ss.push({
title: key,
queryKey: ["home", key, user?.Id],
queryFn: async () =>
(
await getItemsApi(api).getItems({
userId: user?.Id,
limit: section.items?.limit || 20,
recursive: true,
includeItemTypes: section.items?.includeItemTypes,
sortBy: section.items?.sortBy,
sortOrder: section.items?.sortOrder,
filters: section.items?.filters,
parentId: section.items?.parentId,
})
).data.Items || [],
type: "ScrollingCollectionList",
orientation: section?.orientation || "vertical",
});
}
return ss;
}, [api, user?.Id, settings.home?.sections]);
}
if (isConnected === false) {
return (
@@ -355,7 +364,7 @@ export default function index() {
);
}
if (e1 || e2)
if (e1)
return (
<View className="flex flex-col items-center justify-center h-full -mt-6">
<Text className="text-3xl font-bold mb-2">Oops!</Text>
@@ -365,7 +374,7 @@ export default function index() {
</View>
);
if (l1 || l2)
if (l1)
return (
<View className="justify-center items-center h-full">
<Loader />

View File

@@ -1,152 +0,0 @@
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { Loader } from "@/components/Loader";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { Linking, Switch } from "react-native";
export default function page() {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [settings, updateSettings, pluginSettings] = useSettings();
const handleOpenLink = () => {
Linking.openURL(
"https://github.com/lostb1t/jellyfin-plugin-collection-import"
);
};
const queryClient = useQueryClient();
const {
data: mediaListCollections,
isLoading: isLoadingMediaListCollections,
} = useQuery({
queryKey: ["sf_promoted", user?.Id, settings?.usePopularPlugin],
queryFn: async () => {
if (!api || !user?.Id) return [];
const response = await getItemsApi(api).getItems({
userId: user.Id,
tags: ["sf_promoted"],
recursive: true,
fields: ["Tags"],
includeItemTypes: ["BoxSet"],
});
return response.data.Items ?? [];
},
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
staleTime: 0,
});
const disabled = useMemo(
() =>
pluginSettings?.usePopularPlugin?.locked === true &&
pluginSettings?.mediaListCollectionIds?.locked === true,
[pluginSettings]
);
if (!settings) return null;
return (
<DisabledSetting disabled={disabled} className="px-4 pt-4">
<ListGroup title={"Enable plugin"} className="">
<ListItem
title={"Enable Popular Lists"}
disabled={pluginSettings?.usePopularPlugin?.locked}
onPress={() => {
updateSettings({ usePopularPlugin: true });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
>
<Switch
value={settings.usePopularPlugin}
disabled={pluginSettings?.usePopularPlugin?.locked}
onValueChange={(usePopularPlugin) =>
updateSettings({ usePopularPlugin })
}
/>
</ListItem>
</ListGroup>
<Text className="px-4 text-xs text-neutral-500 mt-1">
Popular Lists is a plugin that enables you to show custom Jellyfin lists
on the Streamyfin home page.{" "}
<Text className="text-blue-500" onPress={handleOpenLink}>
Read more about Popular Lists.
</Text>
</Text>
{settings.usePopularPlugin && (
<>
{!isLoadingMediaListCollections ? (
<>
{mediaListCollections?.length === 0 ? (
<Text className="text-xs opacity-50 p-4">
No collections found. Add some in Jellyfin.
</Text>
) : (
<>
<ListGroup title="Media List Collections" className="mt-4">
{mediaListCollections?.map((mlc) => (
<ListItem
key={mlc.Id}
title={mlc.Name}
disabled={
pluginSettings?.mediaListCollectionIds?.locked
}
>
<Switch
disabled={
pluginSettings?.mediaListCollectionIds?.locked
}
value={settings.mediaListCollectionIds?.includes(
mlc.Id!
)}
onValueChange={(value) => {
if (!settings.mediaListCollectionIds) {
updateSettings({
mediaListCollectionIds: [mlc.Id!],
});
return;
}
updateSettings({
mediaListCollectionIds:
settings.mediaListCollectionIds.includes(
mlc.Id!
)
? settings.mediaListCollectionIds.filter(
(id) => id !== mlc.Id
)
: [
...settings.mediaListCollectionIds,
mlc.Id!,
],
});
}}
/>
</ListItem>
))}
</ListGroup>
<Text className="px-4 text-xs text-neutral-500 mt-1">
Select the lists you want displayed on the home screen.
</Text>
</>
)}
</>
) : (
<Loader />
)}
</>
)}
</DisabledSetting>
);
}

View File

@@ -24,11 +24,6 @@ export const PluginSettings = () => {
title="Marlin Search"
showArrow
/>
<ListItem
onPress={() => router.push("/settings/popular-lists/page")}
title="Popular Lists"
showArrow
/>
</ListGroup>
</View>
);

View File

@@ -6,8 +6,13 @@ import { Platform } from "react-native";
import {
CultureDto,
SubtitlePlaybackMode,
ItemSortBy,
SortOrder,
BaseItemKind,
ItemFilter,
} from "@jellyfin/sdk/lib/generated-client";
import { apiAtom } from "@/providers/JellyfinProvider";
import { writeInfoLog } from "@/utils/log";
const STREAMYFIN_PLUGIN_ID = "1e9e5d386e6746158719e98a5c34f004";
const STREAMYFIN_PLUGIN_SETTINGS = "STREAMYFIN_PLUGIN_SETTINGS";
@@ -68,10 +73,29 @@ export enum DownloadMethod {
Optimized = "optimized",
}
export type Home = {
sections: [Object];
};
export type HomeSection = {
orientation?: "horizontal" | "vertical";
items?: HomeSectionItemResolver;
};
export type HomeSectionItemResolver = {
sortBy?: Array<ItemSortBy>;
sortOrder?: Array<SortOrder>;
includeItemTypes?: Array<BaseItemKind>;
genres?: Array<string>;
parentId?: string;
limit?: number;
filters?: Array<ItemFilter>;
};
export type Settings = {
home?: Home | null;
autoRotate?: boolean;
forceLandscapeInVideoPlayer?: boolean;
usePopularPlugin?: boolean;
deviceProfile?: "Expo" | "Native" | "Old";
mediaListCollectionIds?: string[];
searchEngine: "Marlin" | "Jellyfin";
@@ -115,9 +139,9 @@ export type StreamyfinPluginConfig = {
const loadSettings = (): Settings => {
const defaultValues: Settings = {
home: null,
autoRotate: true,
forceLandscapeInVideoPlayer: false,
usePopularPlugin: false,
deviceProfile: "Expo",
mediaListCollectionIds: [],
searchEngine: "Jellyfin",
@@ -201,6 +225,8 @@ export const useSettings = () => {
.getStreamyfinPluginConfig()
.then(({ data }) => data?.settings);
writeInfoLog(`Got remote settings: ${JSON.stringify(settings)}`);
setPluginSettings(settings);
return settings;
}, [api]);

View File

@@ -1,7 +1,7 @@
import { atomWithStorage, createJSONStorage } from "jotai/utils";
import { storage } from "./mmkv";
import {useQuery} from "@tanstack/react-query";
import React, {createContext, useContext} from "react";
import { useQuery } from "@tanstack/react-query";
import React, { createContext, useContext } from "react";
type LogLevel = "INFO" | "WARN" | "ERROR";
@@ -19,10 +19,12 @@ const mmkvStorage = createJSONStorage(() => ({
}));
const logsAtom = atomWithStorage("logs", [], mmkvStorage);
const LogContext = createContext<ReturnType<typeof useLogProvider> | null>(null);
const DownloadContext = createContext<ReturnType<
typeof useLogProvider
> | null>(null);
const LogContext = createContext<ReturnType<typeof useLogProvider> | null>(
null
);
const DownloadContext = createContext<ReturnType<typeof useLogProvider> | null>(
null
);
function useLogProvider() {
const { data: logs } = useQuery({
@@ -32,11 +34,10 @@ function useLogProvider() {
});
return {
logs
}
logs,
};
}
export const writeToLog = (level: LogLevel, message: string, data?: any) => {
const newEntry: LogEntry = {
timestamp: new Date().toISOString(),
@@ -53,10 +54,13 @@ export const writeToLog = (level: LogLevel, message: string, data?: any) => {
const recentLogs = logs.slice(Math.max(logs.length - maxLogs, 0));
storage.set("logs", JSON.stringify(recentLogs));
console.log(message);
};
export const writeInfoLog = (message: string, data?: any) => writeToLog("INFO", message, data);
export const writeErrorLog = (message: string, data?: any) => writeToLog("ERROR", message, data);
export const writeInfoLog = (message: string, data?: any) =>
writeToLog("INFO", message, data);
export const writeErrorLog = (message: string, data?: any) =>
writeToLog("ERROR", message, data);
export const readFromLog = (): LogEntry[] => {
const logs = storage.getString("logs");
@@ -75,14 +79,10 @@ export function useLog() {
return context;
}
export function LogProvider({children}: { children: React.ReactNode }) {
export function LogProvider({ children }: { children: React.ReactNode }) {
const provider = useLogProvider();
return (
<LogContext.Provider value={provider}>
{children}
</LogContext.Provider>
)
return <LogContext.Provider value={provider}>{children}</LogContext.Provider>;
}
export default logsAtom;