From 4022ccb213b0975d3b0e4c5933a1ddd66dc44b8e Mon Sep 17 00:00:00 2001 From: lostb1t Date: Mon, 13 Jan 2025 19:48:19 +0100 Subject: [PATCH] feat: Custom homescreen support (#424) --- app/(auth)/(tabs)/(home)/index.tsx | 257 +++++++++--------- .../(home)/settings/popular-lists/page.tsx | 152 ----------- components/settings/PluginSettings.tsx | 5 - utils/atoms/settings.ts | 30 +- utils/log.tsx | 34 +-- 5 files changed, 178 insertions(+), 300 deletions(-) delete mode 100644 app/(auth)/(tabs)/(home)/settings/popular-lists/page.tsx diff --git a/app/(auth)/(tabs)/(home)/index.tsx b/app/(auth)/(tabs)/(home)/index.tsx index 95cfd856..0675f12f 100644 --- a/app/(auth)/(tabs)/(home)/index.tsx +++ b/app/(auth)/(tabs)/(home)/index.tsx @@ -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 ( Oops! @@ -365,7 +374,7 @@ export default function index() { ); - if (l1 || l2) + if (l1) return ( diff --git a/app/(auth)/(tabs)/(home)/settings/popular-lists/page.tsx b/app/(auth)/(tabs)/(home)/settings/popular-lists/page.tsx deleted file mode 100644 index 6dfcb3c8..00000000 --- a/app/(auth)/(tabs)/(home)/settings/popular-lists/page.tsx +++ /dev/null @@ -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 ( - - - { - updateSettings({ usePopularPlugin: true }); - queryClient.invalidateQueries({ queryKey: ["search"] }); - }} - > - - updateSettings({ usePopularPlugin }) - } - /> - - - - Popular Lists is a plugin that enables you to show custom Jellyfin lists - on the Streamyfin home page.{" "} - - Read more about Popular Lists. - - - - {settings.usePopularPlugin && ( - <> - {!isLoadingMediaListCollections ? ( - <> - {mediaListCollections?.length === 0 ? ( - - No collections found. Add some in Jellyfin. - - ) : ( - <> - - {mediaListCollections?.map((mlc) => ( - - { - 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!, - ], - }); - }} - /> - - ))} - - - Select the lists you want displayed on the home screen. - - - )} - - ) : ( - - )} - - )} - - ); -} diff --git a/components/settings/PluginSettings.tsx b/components/settings/PluginSettings.tsx index f611d1a6..a4ac9e18 100644 --- a/components/settings/PluginSettings.tsx +++ b/components/settings/PluginSettings.tsx @@ -24,11 +24,6 @@ export const PluginSettings = () => { title="Marlin Search" showArrow /> - router.push("/settings/popular-lists/page")} - title="Popular Lists" - showArrow - /> ); diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index d30ccf30..a16c51a6 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -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; + sortOrder?: Array; + includeItemTypes?: Array; + genres?: Array; + parentId?: string; + limit?: number; + filters?: Array; +}; + 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]); diff --git a/utils/log.tsx b/utils/log.tsx index 7c432406..45999062 100644 --- a/utils/log.tsx +++ b/utils/log.tsx @@ -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 | null>(null); -const DownloadContext = createContext | null>(null); +const LogContext = createContext | null>( + null +); +const DownloadContext = createContext | 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 ( - - {children} - - ) + return {children}; } export default logsAtom;