diff --git a/components/ContinueWatchingPoster.tv.tsx b/components/ContinueWatchingPoster.tv.tsx new file mode 100644 index 00000000..9dda5694 --- /dev/null +++ b/components/ContinueWatchingPoster.tv.tsx @@ -0,0 +1,132 @@ +import { Ionicons } from "@expo/vector-icons"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { Image } from "expo-image"; +import { useAtomValue } from "jotai"; +import type React from "react"; +import { useMemo } from "react"; +import { View } from "react-native"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { ProgressBar } from "./common/ProgressBar"; +import { WatchedIndicator } from "./WatchedIndicator"; + +export const TV_LANDSCAPE_WIDTH = 340; + +type ContinueWatchingPosterProps = { + item: BaseItemDto; + useEpisodePoster?: boolean; + size?: "small" | "normal"; + showPlayButton?: boolean; +}; + +const ContinueWatchingPoster: React.FC = ({ + item, + useEpisodePoster = false, + // TV version uses fixed width, size prop kept for API compatibility + size: _size = "normal", + showPlayButton = false, +}) => { + const api = useAtomValue(apiAtom); + + const url = useMemo(() => { + if (!api) { + return; + } + if (item.Type === "Episode" && useEpisodePoster) { + return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`; + } + if (item.Type === "Episode") { + if (item.ParentBackdropItemId && item.ParentThumbImageTag) { + return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ParentThumbImageTag}`; + } + return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`; + } + if (item.Type === "Movie") { + if (item.ImageTags?.Thumb) { + return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ImageTags?.Thumb}`; + } + return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`; + } + if (item.Type === "Program") { + if (item.ImageTags?.Thumb) { + return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ImageTags?.Thumb}`; + } + return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`; + } + + if (item.ImageTags?.Thumb) { + return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ImageTags?.Thumb}`; + } + + return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`; + }, [api, item, useEpisodePoster]); + + if (!url) { + return ( + + ); + } + + return ( + + + + {showPlayButton && ( + + + + )} + + {!item.UserData?.Played && } + + + ); +}; + +export default ContinueWatchingPoster; diff --git a/components/home/Home.tsx b/components/home/Home.tsx index 1da3b358..abd53e0f 100644 --- a/components/home/Home.tsx +++ b/components/home/Home.tsx @@ -44,6 +44,9 @@ import { useSettings } from "@/utils/atoms/settings"; import { eventBus } from "@/utils/eventBus"; import { storage } from "@/utils/mmkv"; +// Conditionally load TV version +const HomeTV = Platform.isTV ? require("./Home.tv").Home : null; + type InfiniteScrollingCollectionListSection = { type: "InfiniteScrollingCollectionList"; title?: string; @@ -64,7 +67,7 @@ type MediaListSectionType = { type Section = InfiniteScrollingCollectionListSection | MediaListSectionType; -export const Home = () => { +const HomeMobile = () => { const router = useRouter(); const { t } = useTranslation(); const api = useAtomValue(apiAtom); @@ -687,3 +690,11 @@ export const Home = () => { ); }; + +// Exported component that renders TV or mobile version based on platform +export const Home = () => { + if (Platform.isTV && HomeTV) { + return ; + } + return ; +}; diff --git a/components/home/Home.tv.tsx b/components/home/Home.tv.tsx new file mode 100644 index 00000000..d073976b --- /dev/null +++ b/components/home/Home.tv.tsx @@ -0,0 +1,572 @@ +import { Ionicons } from "@expo/vector-icons"; +import type { + BaseItemDto, + BaseItemDtoQueryResult, + BaseItemKind, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { + getItemsApi, + getSuggestionsApi, + getTvShowsApi, + getUserLibraryApi, + getUserViewsApi, +} from "@jellyfin/sdk/lib/utils/api"; +import { type QueryFunction, useQuery } from "@tanstack/react-query"; +import { useAtomValue } from "jotai"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ActivityIndicator, ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Button } from "@/components/Button"; +import { Text } from "@/components/common/Text"; +import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv"; +import { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPromotedWatchlists.tv"; +import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations.tv"; +import { Loader } from "@/components/Loader"; +import useRouter from "@/hooks/useAppRouter"; +import { useNetworkStatus } from "@/hooks/useNetworkStatus"; +import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useSettings } from "@/utils/atoms/settings"; + +const HORIZONTAL_PADDING = 60; +const TOP_PADDING = 100; +// Reduced gap since sections have internal padding for scale animations +const SECTION_GAP = 10; + +type InfiniteScrollingCollectionListSection = { + type: "InfiniteScrollingCollectionList"; + title?: string; + queryKey: (string | undefined | null)[]; + queryFn: QueryFunction; + orientation?: "horizontal" | "vertical"; + pageSize?: number; + priority?: 1 | 2; + parentId?: string; +}; + +type Section = InfiniteScrollingCollectionListSection; + +export const Home = () => { + const _router = useRouter(); + const { t } = useTranslation(); + const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); + const insets = useSafeAreaInsets(); + const { settings } = useSettings(); + const scrollRef = useRef(null); + const { + isConnected, + serverConnected, + loading: retryLoading, + retryCheck, + } = useNetworkStatus(); + const _invalidateCache = useInvalidatePlaybackProgressCache(); + const [loadedSections, setLoadedSections] = useState>(new Set()); + + const { + data, + isError: e1, + isLoading: l1, + } = useQuery({ + queryKey: ["home", "userViews", user?.Id], + queryFn: async () => { + if (!api || !user?.Id) { + return null; + } + + const response = await getUserViewsApi(api).getUserViews({ + userId: user.Id, + }); + + return response.data.Items || null; + }, + enabled: !!api && !!user?.Id, + staleTime: 60 * 1000, + }); + + const userViews = useMemo( + () => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)), + [data, settings?.hiddenLibraries], + ); + + const collections = useMemo(() => { + const allow = ["movies", "tvshows"]; + return ( + userViews?.filter( + (c) => c.CollectionType && allow.includes(c.CollectionType), + ) || [] + ); + }, [userViews]); + + const createCollectionConfig = useCallback( + ( + title: string, + queryKey: string[], + includeItemTypes: BaseItemKind[], + parentId: string | undefined, + pageSize = 10, + ): InfiniteScrollingCollectionListSection => ({ + title, + queryKey, + queryFn: async ({ pageParam = 0 }) => { + if (!api) return []; + const allData = + ( + await getUserLibraryApi(api).getLatestMedia({ + userId: user?.Id, + limit: 10, + fields: ["PrimaryImageAspectRatio"], + imageTypeLimit: 1, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + includeItemTypes, + parentId, + }) + ).data || []; + + return allData.slice(pageParam, pageParam + pageSize); + }, + type: "InfiniteScrollingCollectionList", + pageSize, + parentId, + }), + [api, user?.Id], + ); + + const defaultSections = useMemo(() => { + if (!api || !user?.Id) return []; + + const latestMediaViews = collections.map((c) => { + const includeItemTypes: BaseItemKind[] = + c.CollectionType === "tvshows" || c.CollectionType === "movies" + ? [] + : ["Movie"]; + const title = t("home.recently_added_in", { libraryName: c.Name }); + const queryKey: string[] = [ + "home", + `recentlyAddedIn${c.CollectionType}`, + user.Id!, + c.Id!, + ]; + return createCollectionConfig( + title || "", + queryKey, + includeItemTypes, + c.Id, + 10, + ); + }); + + const sortByRecentActivity = (items: BaseItemDto[]): BaseItemDto[] => { + return items.sort((a, b) => { + const dateA = a.UserData?.LastPlayedDate || a.DateCreated || ""; + const dateB = b.UserData?.LastPlayedDate || b.DateCreated || ""; + return new Date(dateB).getTime() - new Date(dateA).getTime(); + }); + }; + + const deduplicateById = (items: BaseItemDto[]): BaseItemDto[] => { + const seen = new Set(); + return items.filter((item) => { + if (!item.Id || seen.has(item.Id)) return false; + seen.add(item.Id); + return true; + }); + }; + + const firstSections: Section[] = settings.mergeNextUpAndContinueWatching + ? [ + { + title: t("home.continue_and_next_up"), + queryKey: ["home", "continueAndNextUp"], + queryFn: async ({ pageParam = 0 }) => { + const [resumeResponse, nextUpResponse] = await Promise.all([ + getItemsApi(api).getResumeItems({ + userId: user.Id, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + includeItemTypes: ["Movie", "Series", "Episode"], + startIndex: 0, + limit: 20, + }), + getTvShowsApi(api).getNextUp({ + userId: user?.Id, + startIndex: 0, + limit: 20, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + enableResumable: false, + }), + ]); + + const resumeItems = resumeResponse.data.Items || []; + const nextUpItems = nextUpResponse.data.Items || []; + + const combined = [...resumeItems, ...nextUpItems]; + const sorted = sortByRecentActivity(combined); + const deduplicated = deduplicateById(sorted); + + return deduplicated.slice(pageParam, pageParam + 10); + }, + type: "InfiniteScrollingCollectionList", + orientation: "horizontal", + pageSize: 10, + priority: 1, + }, + ] + : [ + { + title: t("home.continue_watching"), + queryKey: ["home", "resumeItems"], + queryFn: async ({ pageParam = 0 }) => + ( + await getItemsApi(api).getResumeItems({ + userId: user.Id, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + includeItemTypes: ["Movie", "Series", "Episode"], + startIndex: pageParam, + limit: 10, + }) + ).data.Items || [], + type: "InfiniteScrollingCollectionList", + orientation: "horizontal", + pageSize: 10, + priority: 1, + }, + { + title: t("home.next_up"), + queryKey: ["home", "nextUp-all"], + queryFn: async ({ pageParam = 0 }) => + ( + await getTvShowsApi(api).getNextUp({ + userId: user?.Id, + startIndex: pageParam, + limit: 10, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + enableResumable: false, + }) + ).data.Items || [], + type: "InfiniteScrollingCollectionList", + orientation: "horizontal", + pageSize: 10, + priority: 1, + }, + ]; + + const ss: Section[] = [ + ...firstSections, + ...latestMediaViews.map((s) => ({ ...s, priority: 2 as const })), + ...(!settings?.streamyStatsMovieRecommendations + ? [ + { + title: t("home.suggested_movies"), + queryKey: ["home", "suggestedMovies", user?.Id], + queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => + ( + await getSuggestionsApi(api).getSuggestions({ + userId: user?.Id, + startIndex: pageParam, + limit: 10, + mediaType: ["Video"], + type: ["Movie"], + }) + ).data.Items || [], + type: "InfiniteScrollingCollectionList" as const, + orientation: "vertical" as const, + pageSize: 10, + priority: 2 as const, + }, + ] + : []), + ]; + return ss; + }, [ + api, + user?.Id, + collections, + t, + createCollectionConfig, + settings?.streamyStatsMovieRecommendations, + settings.mergeNextUpAndContinueWatching, + ]); + + const customSections = useMemo(() => { + if (!api || !user?.Id || !settings?.home?.sections) return []; + const ss: Section[] = []; + settings.home.sections.forEach((section, index) => { + const id = section.title || `section-${index}`; + const pageSize = 10; + ss.push({ + title: t(`${id}`), + queryKey: ["home", "custom", String(index), section.title ?? null], + queryFn: async ({ pageParam = 0 }) => { + if (section.items) { + const response = await getItemsApi(api).getItems({ + userId: user?.Id, + startIndex: pageParam, + limit: section.items?.limit || pageSize, + 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 || []; + } + if (section.nextUp) { + const response = await getTvShowsApi(api).getNextUp({ + userId: user?.Id, + startIndex: pageParam, + limit: section.nextUp?.limit || pageSize, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + enableResumable: section.nextUp?.enableResumable, + enableRewatching: section.nextUp?.enableRewatching, + }); + return response.data.Items || []; + } + if (section.latest) { + const allData = + ( + await getUserLibraryApi(api).getLatestMedia({ + userId: user?.Id, + includeItemTypes: section.latest?.includeItemTypes, + limit: section.latest?.limit || 10, + isPlayed: section.latest?.isPlayed, + groupItems: section.latest?.groupItems, + }) + ).data || []; + + return allData.slice(pageParam, pageParam + pageSize); + } + if (section.custom) { + const response = await api.get( + section.custom.endpoint, + { + params: { + ...(section.custom.query || {}), + userId: user?.Id, + startIndex: pageParam, + limit: pageSize, + }, + headers: section.custom.headers || {}, + }, + ); + return response.data.Items || []; + } + return []; + }, + type: "InfiniteScrollingCollectionList", + orientation: section?.orientation || "vertical", + pageSize, + priority: index < 2 ? 1 : 2, + }); + }); + return ss; + }, [api, user?.Id, settings?.home?.sections, t]); + + const sections = settings?.home?.sections ? customSections : defaultSections; + + const highPrioritySectionKeys = useMemo(() => { + return sections + .filter((s) => s.priority === 1) + .map((s) => s.queryKey.join("-")); + }, [sections]); + + const allHighPriorityLoaded = useMemo(() => { + return highPrioritySectionKeys.every((key) => loadedSections.has(key)); + }, [highPrioritySectionKeys, loadedSections]); + + const markSectionLoaded = useCallback( + (queryKey: (string | undefined | null)[]) => { + const key = queryKey.join("-"); + setLoadedSections((prev) => new Set(prev).add(key)); + }, + [], + ); + + if (!isConnected || serverConnected !== true) { + let title = ""; + let subtitle = ""; + + if (!isConnected) { + title = t("home.no_internet"); + subtitle = t("home.no_internet_message"); + } else if (serverConnected === null) { + title = t("home.checking_server_connection"); + subtitle = t("home.checking_server_connection_message"); + } else if (!serverConnected) { + title = t("home.server_unreachable"); + subtitle = t("home.server_unreachable_message"); + } + return ( + + + {title} + + + {subtitle} + + + + + + + ); + } + + if (e1) + return ( + + + {t("home.oops")} + + + {t("home.error_message")} + + + ); + + if (l1) + return ( + + + + ); + + return ( + + + {sections.map((section, index) => { + // Render Streamystats sections after Continue Watching and Next Up + // When merged, they appear after index 0; otherwise after index 1 + const streamystatsIndex = settings.mergeNextUpAndContinueWatching + ? 0 + : 1; + const hasStreamystatsContent = + settings.streamyStatsMovieRecommendations || + settings.streamyStatsSeriesRecommendations || + settings.streamyStatsPromotedWatchlists; + const streamystatsSections = + index === streamystatsIndex && hasStreamystatsContent ? ( + + {settings.streamyStatsMovieRecommendations && ( + + )} + {settings.streamyStatsSeriesRecommendations && ( + + )} + {settings.streamyStatsPromotedWatchlists && ( + + )} + + ) : null; + + if (section.type === "InfiniteScrollingCollectionList") { + const isHighPriority = section.priority === 1; + const isFirstSection = index === 0; + return ( + + markSectionLoaded(section.queryKey) + : undefined + } + isFirstSection={isFirstSection} + /> + {streamystatsSections} + + ); + } + return null; + })} + + + ); +}; diff --git a/components/home/InfiniteScrollingCollectionList.tv.tsx b/components/home/InfiniteScrollingCollectionList.tv.tsx new file mode 100644 index 00000000..ee834e7b --- /dev/null +++ b/components/home/InfiniteScrollingCollectionList.tv.tsx @@ -0,0 +1,347 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { + type QueryFunction, + type QueryKey, + useInfiniteQuery, +} from "@tanstack/react-query"; +import { useSegments } from "expo-router"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { + ActivityIndicator, + FlatList, + View, + type ViewProps, +} from "react-native"; +import { Text } from "@/components/common/Text"; +import { getItemNavigation } from "@/components/common/TouchableItemRouter"; +import MoviePoster, { + TV_POSTER_WIDTH, +} from "@/components/posters/MoviePoster.tv"; +import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; +import { Colors } from "@/constants/Colors"; +import useRouter from "@/hooks/useAppRouter"; +import ContinueWatchingPoster, { + TV_LANDSCAPE_WIDTH, +} from "../ContinueWatchingPoster.tv"; +import SeriesPoster from "../posters/SeriesPoster.tv"; + +const ITEM_GAP = 16; +// Extra padding to accommodate scale animation (1.05x) and glow shadow +const SCALE_PADDING = 20; + +interface Props extends ViewProps { + title?: string | null; + orientation?: "horizontal" | "vertical"; + disabled?: boolean; + queryKey: QueryKey; + queryFn: QueryFunction; + hideIfEmpty?: boolean; + pageSize?: number; + onPressSeeAll?: () => void; + enabled?: boolean; + onLoaded?: () => void; + isFirstSection?: boolean; +} + +// TV-specific ItemCardText with larger fonts +const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => { + return ( + + {item.Type === "Episode" ? ( + <> + + {item.Name} + + + {`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`} + {" - "} + {item.SeriesName} + + + ) : ( + <> + + {item.Name} + + + {item.ProductionYear} + + + )} + + ); +}; + +export const InfiniteScrollingCollectionList: React.FC = ({ + title, + orientation = "vertical", + disabled = false, + queryFn, + queryKey, + hideIfEmpty = false, + pageSize = 10, + enabled = true, + onLoaded, + isFirstSection = false, + ...props +}) => { + const effectivePageSize = Math.max(1, pageSize); + const hasCalledOnLoaded = useRef(false); + const router = useRouter(); + const segments = useSegments(); + const from = (segments as string[])[2] || "(home)"; + + const { + data, + isLoading, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + isSuccess, + } = useInfiniteQuery({ + queryKey: queryKey, + queryFn: ({ pageParam = 0, ...context }) => + queryFn({ ...context, queryKey, pageParam }), + getNextPageParam: (lastPage, allPages) => { + if (lastPage.length < effectivePageSize) { + return undefined; + } + return allPages.reduce((acc, page) => acc + page.length, 0); + }, + initialPageParam: 0, + staleTime: 60 * 1000, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: true, + enabled, + }); + + useEffect(() => { + if (isSuccess && !hasCalledOnLoaded.current && onLoaded) { + hasCalledOnLoaded.current = true; + onLoaded(); + } + }, [isSuccess, onLoaded]); + + const { t } = useTranslation(); + + const allItems = useMemo(() => { + const items = data?.pages.flat() ?? []; + const seen = new Set(); + const deduped: BaseItemDto[] = []; + + for (const item of items) { + const id = item.Id; + if (!id) continue; + if (seen.has(id)) continue; + seen.add(id); + deduped.push(item); + } + + return deduped; + }, [data]); + + const itemWidth = + orientation === "horizontal" ? TV_LANDSCAPE_WIDTH : TV_POSTER_WIDTH; + + const handleItemPress = useCallback( + (item: BaseItemDto) => { + const navigation = getItemNavigation(item, from); + router.push(navigation as any); + }, + [from, router], + ); + + const handleEndReached = useCallback(() => { + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + const getItemLayout = useCallback( + (_data: ArrayLike | null | undefined, index: number) => ({ + length: itemWidth + ITEM_GAP, + offset: (itemWidth + ITEM_GAP) * index, + index, + }), + [itemWidth], + ); + + const renderItem = useCallback( + ({ item, index }: { item: BaseItemDto; index: number }) => { + const isFirstItem = isFirstSection && index === 0; + const isHorizontal = orientation === "horizontal"; + + const renderPoster = () => { + if (item.Type === "Episode" && isHorizontal) { + return ; + } + if (item.Type === "Episode" && !isHorizontal) { + return ; + } + if (item.Type === "Movie" && isHorizontal) { + return ; + } + if (item.Type === "Movie" && !isHorizontal) { + return ; + } + if (item.Type === "Series" && !isHorizontal) { + return ; + } + if (item.Type === "Series" && isHorizontal) { + return ; + } + if (item.Type === "Program") { + return ; + } + if (item.Type === "BoxSet" && !isHorizontal) { + return ; + } + if (item.Type === "BoxSet" && isHorizontal) { + return ; + } + if (item.Type === "Playlist" && !isHorizontal) { + return ; + } + if (item.Type === "Playlist" && isHorizontal) { + return ; + } + if (item.Type === "Video" && !isHorizontal) { + return ; + } + if (item.Type === "Video" && isHorizontal) { + return ; + } + // Default fallback + return isHorizontal ? ( + + ) : ( + + ); + }; + + return ( + + handleItemPress(item)} + hasTVPreferredFocus={isFirstItem} + > + {renderPoster()} + + + + ); + }, + [orientation, isFirstSection, itemWidth, handleItemPress], + ); + + if (hideIfEmpty === true && allItems.length === 0 && !isLoading) return null; + if (disabled || !title) return null; + + return ( + + {/* Section Header */} + + {title} + + + {isLoading === false && allItems.length === 0 && ( + + {t("home.no_items")} + + )} + + {isLoading ? ( + + {[1, 2, 3, 4, 5].map((i) => ( + + + + + Placeholder text here + + + + ))} + + ) : ( + item.Id!} + renderItem={renderItem} + showsHorizontalScrollIndicator={false} + onEndReached={handleEndReached} + onEndReachedThreshold={0.5} + initialNumToRender={5} + maxToRenderPerBatch={3} + windowSize={5} + removeClippedSubviews={false} + getItemLayout={getItemLayout} + style={{ overflow: "visible" }} + contentContainerStyle={{ + paddingVertical: SCALE_PADDING, + paddingHorizontal: SCALE_PADDING, + }} + ListFooterComponent={ + isFetchingNextPage ? ( + + + + ) : null + } + /> + )} + + ); +}; diff --git a/components/home/StreamystatsPromotedWatchlists.tv.tsx b/components/home/StreamystatsPromotedWatchlists.tv.tsx new file mode 100644 index 00000000..45521979 --- /dev/null +++ b/components/home/StreamystatsPromotedWatchlists.tv.tsx @@ -0,0 +1,327 @@ +import type { + BaseItemDto, + PublicSystemInfo, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { getItemsApi, getSystemApi } from "@jellyfin/sdk/lib/utils/api"; +import { useQuery } from "@tanstack/react-query"; +import { useSegments } from "expo-router"; +import { useAtomValue } from "jotai"; +import { useCallback, useMemo } from "react"; +import { FlatList, View, type ViewProps } from "react-native"; + +import { Text } from "@/components/common/Text"; +import { getItemNavigation } from "@/components/common/TouchableItemRouter"; +import MoviePoster, { + TV_POSTER_WIDTH, +} from "@/components/posters/MoviePoster.tv"; +import SeriesPoster from "@/components/posters/SeriesPoster.tv"; +import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; +import useRouter from "@/hooks/useAppRouter"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useSettings } from "@/utils/atoms/settings"; +import { createStreamystatsApi } from "@/utils/streamystats/api"; +import type { StreamystatsWatchlist } from "@/utils/streamystats/types"; + +const ITEM_GAP = 16; +const SCALE_PADDING = 20; + +const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => { + return ( + + + {item.Name} + + + {item.ProductionYear} + + + ); +}; + +interface WatchlistSectionProps extends ViewProps { + watchlist: StreamystatsWatchlist; + jellyfinServerId: string; +} + +const WatchlistSection: React.FC = ({ + watchlist, + jellyfinServerId, + ...props +}) => { + const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); + const { settings } = useSettings(); + const router = useRouter(); + const segments = useSegments(); + const from = (segments as string[])[2] || "(home)"; + + const { data: items, isLoading } = useQuery({ + queryKey: [ + "streamystats", + "watchlist", + watchlist.id, + jellyfinServerId, + settings?.streamyStatsServerUrl, + ], + queryFn: async (): Promise => { + if (!settings?.streamyStatsServerUrl || !api?.accessToken || !user?.Id) { + return []; + } + + const streamystatsApi = createStreamystatsApi({ + serverUrl: settings.streamyStatsServerUrl, + jellyfinToken: api.accessToken, + }); + + const watchlistDetail = await streamystatsApi.getWatchlistItemIds({ + watchlistId: watchlist.id, + jellyfinServerId, + }); + + const itemIds = watchlistDetail.data?.items; + if (!itemIds?.length) { + return []; + } + + const response = await getItemsApi(api).getItems({ + userId: user.Id, + ids: itemIds, + fields: ["PrimaryImageAspectRatio", "Genres"], + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + }); + + return response.data.Items || []; + }, + enabled: + Boolean(settings?.streamyStatsServerUrl) && + Boolean(api?.accessToken) && + Boolean(user?.Id), + staleTime: 5 * 60 * 1000, + refetchOnMount: false, + refetchOnWindowFocus: false, + }); + + const handleItemPress = useCallback( + (item: BaseItemDto) => { + const navigation = getItemNavigation(item, from); + router.push(navigation as any); + }, + [from, router], + ); + + const getItemLayout = useCallback( + (_data: ArrayLike | null | undefined, index: number) => ({ + length: TV_POSTER_WIDTH + ITEM_GAP, + offset: (TV_POSTER_WIDTH + ITEM_GAP) * index, + index, + }), + [], + ); + + const renderItem = useCallback( + ({ item }: { item: BaseItemDto }) => { + return ( + + handleItemPress(item)} + hasTVPreferredFocus={false} + > + {item.Type === "Movie" && } + {item.Type === "Series" && } + + + + ); + }, + [handleItemPress], + ); + + if (!isLoading && (!items || items.length === 0)) return null; + + return ( + + + {watchlist.name} + + + {isLoading ? ( + + {[1, 2, 3, 4, 5].map((i) => ( + + + + ))} + + ) : ( + item.Id!} + renderItem={renderItem} + showsHorizontalScrollIndicator={false} + initialNumToRender={5} + maxToRenderPerBatch={3} + windowSize={5} + removeClippedSubviews={false} + getItemLayout={getItemLayout} + style={{ overflow: "visible" }} + contentContainerStyle={{ + paddingVertical: SCALE_PADDING, + paddingHorizontal: SCALE_PADDING, + }} + /> + )} + + ); +}; + +interface StreamystatsPromotedWatchlistsProps extends ViewProps { + enabled?: boolean; +} + +export const StreamystatsPromotedWatchlists: React.FC< + StreamystatsPromotedWatchlistsProps +> = ({ enabled = true, ...props }) => { + const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); + const { settings } = useSettings(); + + const streamyStatsEnabled = useMemo(() => { + return Boolean(settings?.streamyStatsServerUrl); + }, [settings?.streamyStatsServerUrl]); + + const { data: serverInfo } = useQuery({ + queryKey: ["jellyfin", "serverInfo"], + queryFn: async (): Promise => { + if (!api) return null; + const response = await getSystemApi(api).getPublicSystemInfo(); + return response.data; + }, + enabled: enabled && Boolean(api) && streamyStatsEnabled, + staleTime: 60 * 60 * 1000, + }); + + const jellyfinServerId = serverInfo?.Id; + + const { + data: watchlists, + isLoading, + isError, + } = useQuery({ + queryKey: [ + "streamystats", + "promotedWatchlists", + jellyfinServerId, + settings?.streamyStatsServerUrl, + ], + queryFn: async (): Promise => { + if ( + !settings?.streamyStatsServerUrl || + !api?.accessToken || + !jellyfinServerId + ) { + return []; + } + + const streamystatsApi = createStreamystatsApi({ + serverUrl: settings.streamyStatsServerUrl, + jellyfinToken: api.accessToken, + }); + + const response = await streamystatsApi.getPromotedWatchlists({ + jellyfinServerId, + includePreview: false, + }); + + return response.data || []; + }, + enabled: + enabled && + streamyStatsEnabled && + Boolean(api?.accessToken) && + Boolean(jellyfinServerId) && + Boolean(user?.Id), + staleTime: 5 * 60 * 1000, + refetchOnMount: false, + refetchOnWindowFocus: false, + }); + + if (!streamyStatsEnabled) return null; + if (isError) return null; + if (!isLoading && (!watchlists || watchlists.length === 0)) return null; + + if (isLoading) { + return ( + + + + {[1, 2, 3, 4, 5].map((i) => ( + + + + ))} + + + ); + } + + return ( + <> + {watchlists?.map((watchlist) => ( + + ))} + + ); +}; diff --git a/components/home/StreamystatsRecommendations.tv.tsx b/components/home/StreamystatsRecommendations.tv.tsx new file mode 100644 index 00000000..c163a94e --- /dev/null +++ b/components/home/StreamystatsRecommendations.tv.tsx @@ -0,0 +1,262 @@ +import type { + BaseItemDto, + PublicSystemInfo, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { getItemsApi, getSystemApi } from "@jellyfin/sdk/lib/utils/api"; +import { useQuery } from "@tanstack/react-query"; +import { useSegments } from "expo-router"; +import { useAtomValue } from "jotai"; +import { useCallback, useMemo } from "react"; +import { FlatList, View, type ViewProps } from "react-native"; + +import { Text } from "@/components/common/Text"; +import { getItemNavigation } from "@/components/common/TouchableItemRouter"; +import MoviePoster, { + TV_POSTER_WIDTH, +} from "@/components/posters/MoviePoster.tv"; +import SeriesPoster from "@/components/posters/SeriesPoster.tv"; +import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; +import useRouter from "@/hooks/useAppRouter"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useSettings } from "@/utils/atoms/settings"; +import { createStreamystatsApi } from "@/utils/streamystats/api"; +import type { StreamystatsRecommendationsIdsResponse } from "@/utils/streamystats/types"; + +const ITEM_GAP = 16; +const SCALE_PADDING = 20; + +interface Props extends ViewProps { + title: string; + type: "Movie" | "Series"; + limit?: number; + enabled?: boolean; +} + +const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => { + return ( + + + {item.Name} + + + {item.ProductionYear} + + + ); +}; + +export const StreamystatsRecommendations: React.FC = ({ + title, + type, + limit = 20, + enabled = true, + ...props +}) => { + const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); + const { settings } = useSettings(); + const router = useRouter(); + const segments = useSegments(); + const from = (segments as string[])[2] || "(home)"; + + const streamyStatsEnabled = useMemo(() => { + return Boolean(settings?.streamyStatsServerUrl); + }, [settings?.streamyStatsServerUrl]); + + const { data: serverInfo } = useQuery({ + queryKey: ["jellyfin", "serverInfo"], + queryFn: async (): Promise => { + if (!api) return null; + const response = await getSystemApi(api).getPublicSystemInfo(); + return response.data; + }, + enabled: enabled && Boolean(api) && streamyStatsEnabled, + staleTime: 60 * 60 * 1000, + }); + + const jellyfinServerId = serverInfo?.Id; + + const { + data: recommendationIds, + isLoading: isLoadingRecommendations, + isError: isRecommendationsError, + } = useQuery({ + queryKey: [ + "streamystats", + "recommendations", + type, + jellyfinServerId, + settings?.streamyStatsServerUrl, + ], + queryFn: async (): Promise => { + if ( + !settings?.streamyStatsServerUrl || + !api?.accessToken || + !jellyfinServerId + ) { + return []; + } + + const streamyStatsApi = createStreamystatsApi({ + serverUrl: settings.streamyStatsServerUrl, + jellyfinToken: api.accessToken, + }); + + const response = await streamyStatsApi.getRecommendationIds( + jellyfinServerId, + type, + limit, + ); + + const data = response as StreamystatsRecommendationsIdsResponse; + + if (type === "Movie") { + return data.data.movies || []; + } + return data.data.series || []; + }, + enabled: + enabled && + streamyStatsEnabled && + Boolean(api?.accessToken) && + Boolean(jellyfinServerId) && + Boolean(user?.Id), + staleTime: 5 * 60 * 1000, + refetchOnMount: false, + refetchOnWindowFocus: false, + }); + + const { + data: items, + isLoading: isLoadingItems, + isError: isItemsError, + } = useQuery({ + queryKey: [ + "streamystats", + "recommendations", + "items", + type, + recommendationIds, + ], + queryFn: async (): Promise => { + if (!api || !user?.Id || !recommendationIds?.length) { + return []; + } + + const response = await getItemsApi(api).getItems({ + userId: user.Id, + ids: recommendationIds, + fields: ["PrimaryImageAspectRatio", "Genres"], + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + }); + + return response.data.Items || []; + }, + enabled: + Boolean(recommendationIds?.length) && Boolean(api) && Boolean(user?.Id), + staleTime: 5 * 60 * 1000, + refetchOnMount: false, + refetchOnWindowFocus: false, + }); + + const isLoading = isLoadingRecommendations || isLoadingItems; + const isError = isRecommendationsError || isItemsError; + + const handleItemPress = useCallback( + (item: BaseItemDto) => { + const navigation = getItemNavigation(item, from); + router.push(navigation as any); + }, + [from, router], + ); + + const getItemLayout = useCallback( + (_data: ArrayLike | null | undefined, index: number) => ({ + length: TV_POSTER_WIDTH + ITEM_GAP, + offset: (TV_POSTER_WIDTH + ITEM_GAP) * index, + index, + }), + [], + ); + + const renderItem = useCallback( + ({ item }: { item: BaseItemDto }) => { + return ( + + handleItemPress(item)} + hasTVPreferredFocus={false} + > + {item.Type === "Movie" && } + {item.Type === "Series" && } + + + + ); + }, + [handleItemPress], + ); + + if (!streamyStatsEnabled) return null; + if (isError) return null; + if (!isLoading && (!items || items.length === 0)) return null; + + return ( + + + {title} + + + {isLoading ? ( + + {[1, 2, 3, 4, 5].map((i) => ( + + + + ))} + + ) : ( + item.Id!} + renderItem={renderItem} + showsHorizontalScrollIndicator={false} + initialNumToRender={5} + maxToRenderPerBatch={3} + windowSize={5} + removeClippedSubviews={false} + getItemLayout={getItemLayout} + style={{ overflow: "visible" }} + contentContainerStyle={{ + paddingVertical: SCALE_PADDING, + paddingHorizontal: SCALE_PADDING, + }} + /> + )} + + ); +}; diff --git a/components/posters/MoviePoster.tv.tsx b/components/posters/MoviePoster.tv.tsx new file mode 100644 index 00000000..c4d9daee --- /dev/null +++ b/components/posters/MoviePoster.tv.tsx @@ -0,0 +1,84 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { Image } from "expo-image"; +import { useAtom } from "jotai"; +import { useMemo } from "react"; +import { View } from "react-native"; +import { WatchedIndicator } from "@/components/WatchedIndicator"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; + +export const TV_POSTER_WIDTH = 210; + +type MoviePosterProps = { + item: BaseItemDto; + showProgress?: boolean; +}; + +const MoviePoster: React.FC = ({ + item, + showProgress = false, +}) => { + const [api] = useAtom(apiAtom); + + const url = useMemo(() => { + return getPrimaryImageUrl({ + api, + item, + width: 420, // 2x for quality on large screens + }); + }, [api, item]); + + const progress = item.UserData?.PlayedPercentage || 0; + + const blurhash = useMemo(() => { + const key = item.ImageTags?.Primary as string; + return item.ImageBlurHashes?.Primary?.[key]; + }, [item]); + + return ( + + + + {showProgress && progress > 0 && ( + + )} + + ); +}; + +export default MoviePoster; diff --git a/components/posters/SeriesPoster.tv.tsx b/components/posters/SeriesPoster.tv.tsx new file mode 100644 index 00000000..5d4bb97a --- /dev/null +++ b/components/posters/SeriesPoster.tv.tsx @@ -0,0 +1,71 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { Image } from "expo-image"; +import { useAtom } from "jotai"; +import { useMemo } from "react"; +import { View } from "react-native"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; + +export const TV_POSTER_WIDTH = 210; + +type SeriesPosterProps = { + item: BaseItemDto; + showProgress?: boolean; +}; + +const SeriesPoster: React.FC = ({ item }) => { + const [api] = useAtom(apiAtom); + + const url = useMemo(() => { + if (item.Type === "Episode") { + return `${api?.basePath}/Items/${item.SeriesId}/Images/Primary?fillHeight=630&quality=80&tag=${item.SeriesPrimaryImageTag}`; + } + return getPrimaryImageUrl({ + api, + item, + width: 420, // 2x for quality on large screens + }); + }, [api, item]); + + const blurhash = useMemo(() => { + const key = item.ImageTags?.Primary as string; + return item.ImageBlurHashes?.Primary?.[key]; + }, [item]); + + return ( + + + + ); +}; + +export default SeriesPoster; diff --git a/components/tv/TVFocusablePoster.tsx b/components/tv/TVFocusablePoster.tsx new file mode 100644 index 00000000..9748ed41 --- /dev/null +++ b/components/tv/TVFocusablePoster.tsx @@ -0,0 +1,63 @@ +import React, { useRef, useState } from "react"; +import { Animated, Easing, Pressable, type ViewStyle } from "react-native"; + +interface TVFocusablePosterProps { + children: React.ReactNode; + onPress: () => void; + hasTVPreferredFocus?: boolean; + glowColor?: "white" | "purple"; + scaleAmount?: number; + style?: ViewStyle; +} + +export const TVFocusablePoster: React.FC = ({ + children, + onPress, + hasTVPreferredFocus = false, + glowColor = "white", + scaleAmount = 1.05, + style, +}) => { + const [focused, setFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + + const animateTo = (value: number) => + Animated.timing(scale, { + toValue: value, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + + const shadowColor = glowColor === "white" ? "#ffffff" : "#a855f7"; + + return ( + { + setFocused(true); + animateTo(scaleAmount); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + hasTVPreferredFocus={hasTVPreferredFocus} + > + + {children} + + + ); +}; diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index c07388cd..d1d47604 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -1,3 +1,4 @@ +import { Ionicons } from "@expo/vector-icons"; import type { BaseItemDto, MediaSourceInfo, @@ -199,6 +200,15 @@ export const Controls: FC = ({ return ( + {/* Center Play Button - shown when paused */} + {!isPlaying && showControls && ( + + + + + + )} +