diff --git a/CLAUDE.md b/CLAUDE.md index 858d15ab..10e4f559 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -134,6 +134,11 @@ import { apiAtom } from "@/providers/JellyfinProvider"; - TV version uses `:tv` suffix for scripts - Platform checks: `Platform.isTV`, `Platform.OS === "android"` or `"ios"` - Some features disabled on TV (e.g., notifications, Chromecast) +- **TV Design**: Don't use purple accent colors on TV. Use white for focused states and `expo-blur` (`BlurView`) for backgrounds/overlays. +- **TV Typography**: Use `TVTypography` from `@/components/tv/TVTypography` for all text on TV. It provides consistent font sizes optimized for TV viewing distance. +- **TV Button Sizing**: Ensure buttons placed next to each other have the same size for visual consistency. +- **TV Focus Scale Padding**: Add sufficient padding around focusable items in tables/rows/columns/lists. The focus scale animation (typically 1.05x) will clip against parent containers without proper padding. Use `overflow: "visible"` on containers and add padding to prevent clipping. +- **TV Modals**: Never use overlay/absolute-positioned modals on TV as they don't handle the back button correctly. Instead, use the navigation-based modal pattern: create a Jotai atom for state, a hook that sets the atom and calls `router.push()`, and a page file in `app/(auth)/` that reads the atom and clears it on unmount. You must also add a `Stack.Screen` entry in `app/_layout.tsx` with `presentation: "transparentModal"` and `animation: "fade"` for the modal to render correctly as an overlay. See `useTVRequestModal` + `tv-request-modal.tsx` for reference. ### TV Component Rendering Pattern diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/page.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/page.tsx index 2f0af594..519d5e5c 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/page.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/page.tsx @@ -21,6 +21,7 @@ import { GenreTags } from "@/components/GenreTags"; import Cast from "@/components/jellyseerr/Cast"; import DetailFacts from "@/components/jellyseerr/DetailFacts"; import RequestModal from "@/components/jellyseerr/RequestModal"; +import { TVJellyseerrPage } from "@/components/jellyseerr/tv"; import { OverviewText } from "@/components/OverviewText"; import { ParallaxScrollView } from "@/components/ParallaxPage"; import { PlatformDropdown } from "@/components/PlatformDropdown"; @@ -52,7 +53,8 @@ import type { } from "@/utils/jellyseerr/server/models/Search"; import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv"; -const Page: React.FC = () => { +// Mobile page component +const MobilePage: React.FC = () => { const insets = useSafeAreaInsets(); const params = useLocalSearchParams(); const { t } = useTranslation(); @@ -542,4 +544,12 @@ const Page: React.FC = () => { ); }; +// Platform-conditional page component +const Page: React.FC = () => { + if (Platform.isTV) { + return ; + } + return ; +}; + export default Page; diff --git a/app/(auth)/(tabs)/(search)/index.tsx b/app/(auth)/(tabs)/(search)/index.tsx index 43a90c4e..831d96c1 100644 --- a/app/(auth)/(tabs)/(search)/index.tsx +++ b/app/(auth)/(tabs)/(search)/index.tsx @@ -9,6 +9,7 @@ import axios from "axios"; import { Image } from "expo-image"; import { useLocalSearchParams, useNavigation, useSegments } from "expo-router"; import { useAtom } from "jotai"; +import { orderBy, uniqBy } from "lodash"; import { useCallback, useEffect, @@ -45,6 +46,12 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; import { eventBus } from "@/utils/eventBus"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; +import { MediaType } from "@/utils/jellyseerr/server/constants/media"; +import type { + MovieResult, + PersonResult, + TvResult, +} from "@/utils/jellyseerr/server/models/Search"; import { createStreamystatsApi } from "@/utils/streamystats"; type SearchType = "Library" | "Discover"; @@ -452,6 +459,135 @@ export default function search() { [from, router], ); + // Jellyseerr search for TV + const { data: jellyseerrTVResults, isFetching: jellyseerrTVLoading } = + useQuery({ + queryKey: ["search", "jellyseerr", "tv", debouncedSearch], + queryFn: async () => { + const params = { + query: new URLSearchParams(debouncedSearch || "").toString(), + }; + return await Promise.all([ + jellyseerrApi?.search({ ...params, page: 1 }), + jellyseerrApi?.search({ ...params, page: 2 }), + jellyseerrApi?.search({ ...params, page: 3 }), + jellyseerrApi?.search({ ...params, page: 4 }), + ]).then((all) => + uniqBy( + all.flatMap((v) => v?.results || []), + "id", + ), + ); + }, + enabled: + Platform.isTV && + !!jellyseerrApi && + searchType === "Discover" && + debouncedSearch.length > 0, + }); + + // Process Jellyseerr results for TV + const jellyseerrMovieResults = useMemo( + () => + orderBy( + jellyseerrTVResults?.filter( + (r) => r.mediaType === MediaType.MOVIE, + ) as MovieResult[], + [(m) => m?.title?.toLowerCase() === debouncedSearch.toLowerCase()], + "desc", + ), + [jellyseerrTVResults, debouncedSearch], + ); + + const jellyseerrTvResults = useMemo( + () => + orderBy( + jellyseerrTVResults?.filter( + (r) => r.mediaType === MediaType.TV, + ) as TvResult[], + [(t) => t?.name?.toLowerCase() === debouncedSearch.toLowerCase()], + "desc", + ), + [jellyseerrTVResults, debouncedSearch], + ); + + const jellyseerrPersonResults = useMemo( + () => + orderBy( + jellyseerrTVResults?.filter( + (r) => r.mediaType === "person", + ) as PersonResult[], + [(p) => p?.name?.toLowerCase() === debouncedSearch.toLowerCase()], + "desc", + ), + [jellyseerrTVResults, debouncedSearch], + ); + + const jellyseerrTVNoResults = useMemo(() => { + return ( + !jellyseerrMovieResults?.length && + !jellyseerrTvResults?.length && + !jellyseerrPersonResults?.length + ); + }, [jellyseerrMovieResults, jellyseerrTvResults, jellyseerrPersonResults]); + + // Fetch discover settings for TV (when no search query in Discover mode) + const { data: discoverSliders } = useQuery({ + queryKey: ["search", "jellyseerr", "discoverSettings", "tv"], + queryFn: async () => jellyseerrApi?.discoverSettings(), + enabled: + Platform.isTV && + !!jellyseerrApi && + searchType === "Discover" && + debouncedSearch.length === 0, + }); + + // TV Jellyseerr press handlers + const handleJellyseerrMoviePress = useCallback( + (item: MovieResult) => { + router.push({ + pathname: "/(auth)/(tabs)/(search)/jellyseerr/page", + params: { + mediaTitle: item.title, + releaseYear: String(new Date(item.releaseDate || "").getFullYear()), + canRequest: "true", + posterSrc: jellyseerrApi?.imageProxy(item.posterPath) || "", + mediaType: MediaType.MOVIE, + id: String(item.id), + backdropPath: item.backdropPath || "", + overview: item.overview || "", + }, + }); + }, + [router, jellyseerrApi], + ); + + const handleJellyseerrTvPress = useCallback( + (item: TvResult) => { + router.push({ + pathname: "/(auth)/(tabs)/(search)/jellyseerr/page", + params: { + mediaTitle: item.name, + releaseYear: String(new Date(item.firstAirDate || "").getFullYear()), + canRequest: "true", + posterSrc: jellyseerrApi?.imageProxy(item.posterPath) || "", + mediaType: MediaType.TV, + id: String(item.id), + backdropPath: item.backdropPath || "", + overview: item.overview || "", + }, + }); + }, + [router, jellyseerrApi], + ); + + const handleJellyseerrPersonPress = useCallback( + (item: PersonResult) => { + router.push(`/(auth)/jellyseerr/person/${item.id}` as any); + }, + [router], + ); + // Render TV search page if (Platform.isTV) { return ( @@ -471,6 +607,18 @@ export default function search() { loading={loading} noResults={noResults} onItemPress={handleItemPress} + searchType={searchType} + setSearchType={setSearchType} + showDiscover={!!jellyseerrApi} + jellyseerrMovies={jellyseerrMovieResults} + jellyseerrTv={jellyseerrTvResults} + jellyseerrPersons={jellyseerrPersonResults} + jellyseerrLoading={jellyseerrTVLoading} + jellyseerrNoResults={jellyseerrTVNoResults} + onJellyseerrMoviePress={handleJellyseerrMoviePress} + onJellyseerrTvPress={handleJellyseerrTvPress} + onJellyseerrPersonPress={handleJellyseerrPersonPress} + discoverSliders={discoverSliders} /> ); } diff --git a/app/(auth)/tv-option-modal.tsx b/app/(auth)/tv-option-modal.tsx index a21c5e32..330885a1 100644 --- a/app/(auth)/tv-option-modal.tsx +++ b/app/(auth)/tv-option-modal.tsx @@ -165,7 +165,7 @@ const styles = StyleSheet.create({ }, scrollContent: { paddingHorizontal: 48, - paddingVertical: 10, + paddingVertical: 20, gap: 12, }, }); diff --git a/app/(auth)/tv-request-modal.tsx b/app/(auth)/tv-request-modal.tsx new file mode 100644 index 00000000..685bb668 --- /dev/null +++ b/app/(auth)/tv-request-modal.tsx @@ -0,0 +1,489 @@ +import { Ionicons } from "@expo/vector-icons"; +import { useQuery } from "@tanstack/react-query"; +import { BlurView } from "expo-blur"; +import { useAtomValue } from "jotai"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Animated, + Easing, + ScrollView, + StyleSheet, + TVFocusGuideView, + View, +} from "react-native"; +import { Text } from "@/components/common/Text"; +import { TVRequestOptionRow } from "@/components/jellyseerr/tv/TVRequestOptionRow"; +import { TVToggleOptionRow } from "@/components/jellyseerr/tv/TVToggleOptionRow"; +import { TVButton, TVOptionSelector } from "@/components/tv"; +import type { TVOptionItem } from "@/components/tv/TVOptionSelector"; +import { TVTypography } from "@/constants/TVTypography"; +import useRouter from "@/hooks/useAppRouter"; +import { useJellyseerr } from "@/hooks/useJellyseerr"; +import { tvRequestModalAtom } from "@/utils/atoms/tvRequestModal"; +import type { + QualityProfile, + RootFolder, + Tag, +} from "@/utils/jellyseerr/server/api/servarr/base"; +import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces"; +import { store } from "@/utils/store"; + +export default function TVRequestModalPage() { + const router = useRouter(); + const modalState = useAtomValue(tvRequestModalAtom); + const { t } = useTranslation(); + const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr(); + + const [isReady, setIsReady] = useState(false); + const [requestOverrides, setRequestOverrides] = useState({ + mediaId: modalState?.id ? Number(modalState.id) : 0, + mediaType: modalState?.mediaType, + userId: jellyseerrUser?.id, + }); + + const [activeSelector, setActiveSelector] = useState< + "profile" | "folder" | "user" | null + >(null); + + const overlayOpacity = useRef(new Animated.Value(0)).current; + const sheetTranslateY = useRef(new Animated.Value(200)).current; + + // Animate in on mount + useEffect(() => { + overlayOpacity.setValue(0); + sheetTranslateY.setValue(200); + + Animated.parallel([ + Animated.timing(overlayOpacity, { + toValue: 1, + duration: 250, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + Animated.timing(sheetTranslateY, { + toValue: 0, + duration: 300, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }), + ]).start(); + + const timer = setTimeout(() => setIsReady(true), 100); + return () => { + clearTimeout(timer); + store.set(tvRequestModalAtom, null); + }; + }, [overlayOpacity, sheetTranslateY]); + + const { data: serviceSettings } = useQuery({ + queryKey: ["jellyseerr", "request", modalState?.mediaType, "service"], + queryFn: async () => + jellyseerrApi?.service( + modalState?.mediaType === "movie" ? "radarr" : "sonarr", + ), + enabled: !!jellyseerrApi && !!jellyseerrUser && !!modalState, + }); + + const { data: users } = useQuery({ + queryKey: ["jellyseerr", "users"], + queryFn: async () => + jellyseerrApi?.user({ take: 1000, sort: "displayname" }), + enabled: !!jellyseerrApi && !!jellyseerrUser && !!modalState, + }); + + const defaultService = useMemo( + () => serviceSettings?.find?.((v) => v.isDefault), + [serviceSettings], + ); + + const { data: defaultServiceDetails } = useQuery({ + queryKey: [ + "jellyseerr", + "request", + modalState?.mediaType, + "service", + "details", + defaultService?.id, + ], + queryFn: async () => { + setRequestOverrides((prev) => ({ + ...prev, + serverId: defaultService?.id, + })); + return jellyseerrApi?.serviceDetails( + modalState?.mediaType === "movie" ? "radarr" : "sonarr", + defaultService!.id, + ); + }, + enabled: + !!jellyseerrApi && !!jellyseerrUser && !!defaultService && !!modalState, + }); + + const defaultProfile: QualityProfile | undefined = useMemo( + () => + defaultServiceDetails?.profiles.find( + (p) => p.id === defaultServiceDetails.server?.activeProfileId, + ), + [defaultServiceDetails], + ); + + const defaultFolder: RootFolder | undefined = useMemo( + () => + defaultServiceDetails?.rootFolders.find( + (f) => f.path === defaultServiceDetails.server?.activeDirectory, + ), + [defaultServiceDetails], + ); + + const defaultTags: Tag[] = useMemo(() => { + return ( + defaultServiceDetails?.tags.filter((t) => + defaultServiceDetails?.server.activeTags?.includes(t.id), + ) ?? [] + ); + }, [defaultServiceDetails]); + + const pathTitleExtractor = (item: RootFolder) => + `${item.path} (${item.freeSpace.bytesToReadable()})`; + + // Option builders + const qualityProfileOptions: TVOptionItem[] = useMemo( + () => + defaultServiceDetails?.profiles.map((profile) => ({ + label: profile.name, + value: profile.id, + selected: + (requestOverrides.profileId || defaultProfile?.id) === profile.id, + })) || [], + [ + defaultServiceDetails?.profiles, + defaultProfile, + requestOverrides.profileId, + ], + ); + + const rootFolderOptions: TVOptionItem[] = useMemo( + () => + defaultServiceDetails?.rootFolders.map((folder) => ({ + label: pathTitleExtractor(folder), + value: folder.path, + selected: + (requestOverrides.rootFolder || defaultFolder?.path) === folder.path, + })) || [], + [ + defaultServiceDetails?.rootFolders, + defaultFolder, + requestOverrides.rootFolder, + ], + ); + + const userOptions: TVOptionItem[] = useMemo( + () => + users?.map((user) => ({ + label: user.displayName, + value: user.id, + selected: (requestOverrides.userId || jellyseerrUser?.id) === user.id, + })) || [], + [users, jellyseerrUser, requestOverrides.userId], + ); + + const tagItems = useMemo(() => { + return ( + defaultServiceDetails?.tags.map((tag) => ({ + id: tag.id, + label: tag.label, + selected: + requestOverrides.tags?.includes(tag.id) || + defaultTags.some((dt) => dt.id === tag.id), + })) ?? [] + ); + }, [defaultServiceDetails?.tags, defaultTags, requestOverrides.tags]); + + // Selected display values + const selectedProfileName = useMemo(() => { + const profile = defaultServiceDetails?.profiles.find( + (p) => p.id === (requestOverrides.profileId || defaultProfile?.id), + ); + return profile?.name || defaultProfile?.name || t("jellyseerr.select"); + }, [ + defaultServiceDetails?.profiles, + requestOverrides.profileId, + defaultProfile, + t, + ]); + + const selectedFolderName = useMemo(() => { + const folder = defaultServiceDetails?.rootFolders.find( + (f) => f.path === (requestOverrides.rootFolder || defaultFolder?.path), + ); + return folder + ? pathTitleExtractor(folder) + : defaultFolder + ? pathTitleExtractor(defaultFolder) + : t("jellyseerr.select"); + }, [ + defaultServiceDetails?.rootFolders, + requestOverrides.rootFolder, + defaultFolder, + t, + ]); + + const selectedUserName = useMemo(() => { + const user = users?.find( + (u) => u.id === (requestOverrides.userId || jellyseerrUser?.id), + ); + return ( + user?.displayName || jellyseerrUser?.displayName || t("jellyseerr.select") + ); + }, [users, requestOverrides.userId, jellyseerrUser, t]); + + // Handlers + const handleProfileChange = useCallback((profileId: number) => { + setRequestOverrides((prev) => ({ ...prev, profileId })); + setActiveSelector(null); + }, []); + + const handleFolderChange = useCallback((rootFolder: string) => { + setRequestOverrides((prev) => ({ ...prev, rootFolder })); + setActiveSelector(null); + }, []); + + const handleUserChange = useCallback((userId: number) => { + setRequestOverrides((prev) => ({ ...prev, userId })); + setActiveSelector(null); + }, []); + + const handleTagToggle = useCallback( + (tagId: number) => { + setRequestOverrides((prev) => { + const currentTags = prev.tags || defaultTags.map((t) => t.id); + const hasTag = currentTags.includes(tagId); + return { + ...prev, + tags: hasTag + ? currentTags.filter((id) => id !== tagId) + : [...currentTags, tagId], + }; + }); + }, + [defaultTags], + ); + + const handleRequest = useCallback(() => { + if (!modalState) return; + + const body = { + is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k, + profileId: defaultProfile?.id, + rootFolder: defaultFolder?.path, + tags: defaultTags.map((t) => t.id), + ...modalState.requestBody, + ...requestOverrides, + }; + + const seasonTitle = + modalState.requestBody?.seasons?.length === 1 + ? t("jellyseerr.season_number", { + season_number: modalState.requestBody.seasons[0], + }) + : modalState.requestBody?.seasons && + modalState.requestBody.seasons.length > 1 + ? t("jellyseerr.season_all") + : undefined; + + requestMedia( + seasonTitle ? `${modalState.title}, ${seasonTitle}` : modalState.title, + body, + () => { + modalState.onRequested(); + router.back(); + }, + ); + }, [ + modalState, + requestOverrides, + defaultProfile, + defaultFolder, + defaultTags, + defaultService, + defaultServiceDetails, + requestMedia, + router, + t, + ]); + + if (!modalState) { + return null; + } + + const isDataLoaded = defaultService && defaultServiceDetails && users; + + return ( + + + + + {t("jellyseerr.advanced")} + {modalState.title} + + {isDataLoaded && isReady ? ( + + + setActiveSelector("profile")} + hasTVPreferredFocus + /> + setActiveSelector("folder")} + /> + setActiveSelector("user")} + /> + + {tagItems.length > 0 && ( + + )} + + + ) : ( + + {t("common.loading")} + + )} + + {isReady && ( + + + + + {t("jellyseerr.request_button")} + + + + )} + + + + + {/* Sub-selectors */} + setActiveSelector(null)} + cancelLabel={t("jellyseerr.cancel")} + /> + setActiveSelector(null)} + cancelLabel={t("jellyseerr.cancel")} + cardWidth={280} + /> + setActiveSelector(null)} + cancelLabel={t("jellyseerr.cancel")} + /> + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: "rgba(0, 0, 0, 0.5)", + justifyContent: "flex-end", + }, + sheetContainer: { + width: "100%", + }, + blurContainer: { + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + overflow: "hidden", + }, + content: { + paddingTop: 24, + paddingBottom: 50, + paddingHorizontal: 44, + overflow: "visible", + }, + heading: { + fontSize: TVTypography.heading, + fontWeight: "bold", + color: "#FFFFFF", + marginBottom: 8, + }, + subtitle: { + fontSize: TVTypography.callout, + color: "rgba(255,255,255,0.6)", + marginBottom: 24, + }, + scrollView: { + maxHeight: 320, + overflow: "visible", + }, + optionsContainer: { + gap: 12, + paddingVertical: 8, + paddingHorizontal: 4, + }, + loadingContainer: { + height: 200, + justifyContent: "center", + alignItems: "center", + }, + loadingText: { + color: "rgba(255,255,255,0.5)", + }, + buttonContainer: { + marginTop: 24, + }, + buttonText: { + fontSize: TVTypography.callout, + fontWeight: "bold", + color: "#FFFFFF", + }, +}); diff --git a/app/_layout.tsx b/app/_layout.tsx index bddb6120..64312a80 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -445,6 +445,14 @@ function Layout() { animation: "fade", }} /> + = ({ sliders }) => { + const sortedSliders = useMemo( + () => + sortBy( + (sliders ?? []).filter( + (s) => s.enabled && SUPPORTED_SLIDE_TYPES.includes(s.type), + ), + "order", + "asc", + ), + [sliders], + ); + + if (!sliders || sortedSliders.length === 0) return null; + + return ( + + {sortedSliders.map((slide, index) => ( + + ))} + + ); +}; diff --git a/components/jellyseerr/discover/TVDiscoverSlide.tsx b/components/jellyseerr/discover/TVDiscoverSlide.tsx new file mode 100644 index 00000000..876b96bb --- /dev/null +++ b/components/jellyseerr/discover/TVDiscoverSlide.tsx @@ -0,0 +1,249 @@ +import { Ionicons } from "@expo/vector-icons"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { Image } from "expo-image"; +import { uniqBy } from "lodash"; +import React, { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Animated, FlatList, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; +import { TVTypography } from "@/constants/TVTypography"; +import useRouter from "@/hooks/useAppRouter"; +import { + type DiscoverEndpoint, + Endpoints, + useJellyseerr, +} from "@/hooks/useJellyseerr"; +import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover"; +import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider"; +import type { + MovieResult, + TvResult, +} from "@/utils/jellyseerr/server/models/Search"; + +const SCALE_PADDING = 20; + +interface TVDiscoverPosterProps { + item: MovieResult | TvResult; + isFirstItem?: boolean; +} + +const TVDiscoverPoster: React.FC = ({ + item, + isFirstItem = false, +}) => { + const router = useRouter(); + const { jellyseerrApi, getTitle, getYear } = useJellyseerr(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.08 }); + + const posterUrl = item.posterPath + ? jellyseerrApi?.imageProxy(item.posterPath, "w342") + : null; + + const title = getTitle(item); + const year = getYear(item); + + const handlePress = () => { + router.push({ + pathname: "/(auth)/(tabs)/(search)/jellyseerr/page", + params: { + id: String(item.id), + mediaType: item.mediaType, + }, + }); + }; + + return ( + + + + {posterUrl ? ( + + ) : ( + + + + )} + + + {title} + + {year && ( + + {year} + + )} + + + ); +}; + +interface TVDiscoverSlideProps { + slide: DiscoverSlider; + isFirstSlide?: boolean; +} + +export const TVDiscoverSlide: React.FC = ({ + slide, + isFirstSlide = false, +}) => { + const { t } = useTranslation(); + const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr(); + + const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({ + queryKey: ["jellyseerr", "discover", "tv", slide.id], + queryFn: async ({ pageParam }) => { + let endpoint: DiscoverEndpoint | undefined; + let params: Record = { + page: Number(pageParam), + }; + + switch (slide.type) { + case DiscoverSliderType.TRENDING: + endpoint = Endpoints.DISCOVER_TRENDING; + break; + case DiscoverSliderType.POPULAR_MOVIES: + case DiscoverSliderType.UPCOMING_MOVIES: + endpoint = Endpoints.DISCOVER_MOVIES; + if (slide.type === DiscoverSliderType.UPCOMING_MOVIES) + params = { + ...params, + primaryReleaseDateGte: new Date().toISOString().split("T")[0], + }; + break; + case DiscoverSliderType.POPULAR_TV: + case DiscoverSliderType.UPCOMING_TV: + endpoint = Endpoints.DISCOVER_TV; + if (slide.type === DiscoverSliderType.UPCOMING_TV) + params = { + ...params, + firstAirDateGte: new Date().toISOString().split("T")[0], + }; + break; + } + + return endpoint ? jellyseerrApi?.discover(endpoint, params) : null; + }, + initialPageParam: 1, + getNextPageParam: (lastPage, pages) => + (lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) + + 1, + enabled: !!jellyseerrApi, + staleTime: 0, + }); + + const flatData = useMemo( + () => + uniqBy( + data?.pages + ?.filter((p) => p?.results.length) + .flatMap((p) => + p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)), + ), + "id", + ) as (MovieResult | TvResult)[], + [data, isJellyseerrMovieOrTvResult], + ); + + const slideTitle = t( + `search.${DiscoverSliderType[slide.type].toString().toLowerCase()}`, + ); + + if (!flatData || flatData.length === 0) return null; + + return ( + + + {slideTitle} + + item.id.toString()} + showsHorizontalScrollIndicator={false} + contentContainerStyle={{ + paddingHorizontal: SCALE_PADDING, + paddingVertical: SCALE_PADDING, + gap: 20, + }} + style={{ overflow: "visible" }} + onEndReached={() => { + if (hasNextPage) fetchNextPage(); + }} + onEndReachedThreshold={0.5} + renderItem={({ item, index }) => ( + + )} + /> + + ); +}; diff --git a/components/search/TVJellyseerrSearchResults.tsx b/components/search/TVJellyseerrSearchResults.tsx new file mode 100644 index 00000000..dd0e5507 --- /dev/null +++ b/components/search/TVJellyseerrSearchResults.tsx @@ -0,0 +1,430 @@ +import { Ionicons } from "@expo/vector-icons"; +import { Image } from "expo-image"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Animated, FlatList, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; +import { TVTypography } from "@/constants/TVTypography"; +import { useJellyseerr } from "@/hooks/useJellyseerr"; +import type { + MovieResult, + PersonResult, + TvResult, +} from "@/utils/jellyseerr/server/models/Search"; + +const SCALE_PADDING = 20; + +interface TVJellyseerrPosterProps { + item: MovieResult | TvResult; + onPress: () => void; + isFirstItem?: boolean; +} + +const TVJellyseerrPoster: React.FC = ({ + item, + onPress, + isFirstItem = false, +}) => { + const { jellyseerrApi, getTitle, getYear } = useJellyseerr(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.08 }); + + const posterUrl = item.posterPath + ? jellyseerrApi?.imageProxy(item.posterPath, "w342") + : null; + + const title = getTitle(item); + const year = getYear(item); + + return ( + + + + {posterUrl ? ( + + ) : ( + + + + )} + + + {title} + + {year && ( + + {year} + + )} + + + ); +}; + +interface TVJellyseerrPersonPosterProps { + item: PersonResult; + onPress: () => void; +} + +const TVJellyseerrPersonPoster: React.FC = ({ + item, + onPress, +}) => { + const { jellyseerrApi } = useJellyseerr(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.08 }); + + const posterUrl = item.profilePath + ? jellyseerrApi?.imageProxy(item.profilePath, "w185") + : null; + + return ( + + + + {posterUrl ? ( + + ) : ( + + + + )} + + + {item.name} + + + + ); +}; + +interface TVJellyseerrMovieSectionProps { + title: string; + items: MovieResult[]; + isFirstSection?: boolean; + onItemPress: (item: MovieResult) => void; +} + +const TVJellyseerrMovieSection: React.FC = ({ + title, + items, + isFirstSection = false, + onItemPress, +}) => { + if (!items || items.length === 0) return null; + + return ( + + + {title} + + item.id.toString()} + showsHorizontalScrollIndicator={false} + contentContainerStyle={{ + paddingHorizontal: SCALE_PADDING, + paddingVertical: SCALE_PADDING, + gap: 20, + }} + style={{ overflow: "visible" }} + renderItem={({ item, index }) => ( + onItemPress(item)} + isFirstItem={isFirstSection && index === 0} + /> + )} + /> + + ); +}; + +interface TVJellyseerrTvSectionProps { + title: string; + items: TvResult[]; + isFirstSection?: boolean; + onItemPress: (item: TvResult) => void; +} + +const TVJellyseerrTvSection: React.FC = ({ + title, + items, + isFirstSection = false, + onItemPress, +}) => { + if (!items || items.length === 0) return null; + + return ( + + + {title} + + item.id.toString()} + showsHorizontalScrollIndicator={false} + contentContainerStyle={{ + paddingHorizontal: SCALE_PADDING, + paddingVertical: SCALE_PADDING, + gap: 20, + }} + style={{ overflow: "visible" }} + renderItem={({ item, index }) => ( + onItemPress(item)} + isFirstItem={isFirstSection && index === 0} + /> + )} + /> + + ); +}; + +interface TVJellyseerrPersonSectionProps { + title: string; + items: PersonResult[]; + isFirstSection?: boolean; + onItemPress: (item: PersonResult) => void; +} + +const TVJellyseerrPersonSection: React.FC = ({ + title, + items, + isFirstSection: _isFirstSection = false, + onItemPress, +}) => { + if (!items || items.length === 0) return null; + + return ( + + + {title} + + item.id.toString()} + showsHorizontalScrollIndicator={false} + contentContainerStyle={{ + paddingHorizontal: SCALE_PADDING, + paddingVertical: SCALE_PADDING, + gap: 20, + }} + style={{ overflow: "visible" }} + renderItem={({ item }) => ( + onItemPress(item)} + /> + )} + /> + + ); +}; + +export interface TVJellyseerrSearchResultsProps { + movieResults: MovieResult[]; + tvResults: TvResult[]; + personResults: PersonResult[]; + loading: boolean; + noResults: boolean; + searchQuery: string; + onMoviePress: (item: MovieResult) => void; + onTvPress: (item: TvResult) => void; + onPersonPress: (item: PersonResult) => void; +} + +export const TVJellyseerrSearchResults: React.FC< + TVJellyseerrSearchResultsProps +> = ({ + movieResults, + tvResults, + personResults, + loading, + noResults, + searchQuery, + onMoviePress, + onTvPress, + onPersonPress, +}) => { + const { t } = useTranslation(); + + const hasMovies = movieResults && movieResults.length > 0; + const hasTv = tvResults && tvResults.length > 0; + const hasPersons = personResults && personResults.length > 0; + + if (loading) { + return null; + } + + if (noResults && searchQuery.length > 0) { + return ( + + + {t("search.no_results_found_for")} + + + "{searchQuery}" + + + ); + } + + return ( + + + + + + ); +}; diff --git a/components/search/TVSearchPage.tsx b/components/search/TVSearchPage.tsx index 610b7a43..2212e351 100644 --- a/components/search/TVSearchPage.tsx +++ b/components/search/TVSearchPage.tsx @@ -6,10 +6,18 @@ import { ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Input } from "@/components/common/Input"; import { Text } from "@/components/common/Text"; +import { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover"; import { apiAtom } from "@/providers/JellyfinProvider"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; -import { TVSearchBadge } from "./TVSearchBadge"; +import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider"; +import type { + MovieResult, + PersonResult, + TvResult, +} from "@/utils/jellyseerr/server/models/Search"; +import { TVJellyseerrSearchResults } from "./TVJellyseerrSearchResults"; import { TVSearchSection } from "./TVSearchSection"; +import { TVSearchTabBadges } from "./TVSearchTabBadges"; const HORIZONTAL_PADDING = 60; const TOP_PADDING = 100; @@ -77,20 +85,13 @@ const TVLoadingSkeleton: React.FC = () => { ); }; -// Example search suggestions for TV -const exampleSearches = [ - "Lord of the rings", - "Avengers", - "Game of Thrones", - "Breaking Bad", - "Stranger Things", - "The Mandalorian", -]; +type SearchType = "Library" | "Discover"; interface TVSearchPageProps { search: string; setSearch: (text: string) => void; debouncedSearch: string; + // Library search results movies?: BaseItemDto[]; series?: BaseItemDto[]; episodes?: BaseItemDto[]; @@ -103,6 +104,20 @@ interface TVSearchPageProps { loading: boolean; noResults: boolean; onItemPress: (item: BaseItemDto) => void; + // Jellyseerr/Discover props + searchType: SearchType; + setSearchType: (type: SearchType) => void; + showDiscover: boolean; + jellyseerrMovies?: MovieResult[]; + jellyseerrTv?: TvResult[]; + jellyseerrPersons?: PersonResult[]; + jellyseerrLoading?: boolean; + jellyseerrNoResults?: boolean; + onJellyseerrMoviePress?: (item: MovieResult) => void; + onJellyseerrTvPress?: (item: TvResult) => void; + onJellyseerrPersonPress?: (item: PersonResult) => void; + // Discover sliders for empty state + discoverSliders?: DiscoverSlider[]; } export const TVSearchPage: React.FC = ({ @@ -121,6 +136,18 @@ export const TVSearchPage: React.FC = ({ loading, noResults, onItemPress, + searchType, + setSearchType, + showDiscover, + jellyseerrMovies = [], + jellyseerrTv = [], + jellyseerrPersons = [], + jellyseerrLoading = false, + jellyseerrNoResults = false, + onJellyseerrMoviePress, + onJellyseerrTvPress, + onJellyseerrPersonPress, + discoverSliders, }) => { const { t } = useTranslation(); const insets = useSafeAreaInsets(); @@ -177,6 +204,11 @@ export const TVSearchPage: React.FC = ({ t, ]); + const isLibraryMode = searchType === "Library"; + const isDiscoverMode = searchType === "Discover"; + const currentLoading = isLibraryMode ? loading : jellyseerrLoading; + const currentNoResults = isLibraryMode ? noResults : jellyseerrNoResults; + return ( = ({ }} > {/* Search Input */} - + = ({ clearButtonMode='while-editing' maxLength={500} hasTVPreferredFocus={ - debouncedSearch.length === 0 && sections.length === 0 + debouncedSearch.length === 0 && + sections.length === 0 && + !showDiscover } /> + {/* Search Type Tab Badges */} + {showDiscover && ( + + + + )} + {/* Loading State */} - {loading && ( + {currentLoading && ( )} - {/* Search Results */} - {!loading && ( + {/* Library Search Results */} + {isLibraryMode && !loading && ( {sections.map((section, index) => ( = ({ )} + {/* Jellyseerr/Discover Search Results */} + {isDiscoverMode && !jellyseerrLoading && debouncedSearch.length > 0 && ( + {})} + onTvPress={onJellyseerrTvPress || (() => {})} + onPersonPress={onJellyseerrPersonPress || (() => {})} + /> + )} + + {/* Discover Content (when no search query in Discover mode) */} + {isDiscoverMode && !jellyseerrLoading && debouncedSearch.length === 0 && ( + + )} + {/* No Results State */} - {!loading && noResults && debouncedSearch.length > 0 && ( + {!currentLoading && currentNoResults && debouncedSearch.length > 0 && ( = ({ > {t("search.no_results_found_for")} - + "{debouncedSearch}" )} - - {/* Example Searches (when no search query) */} - {!loading && debouncedSearch.length === 0 && ( - - - {exampleSearches.map((example) => ( - setSearch(example)} - /> - ))} - - - )} ); }; diff --git a/components/search/TVSearchTabBadges.tsx b/components/search/TVSearchTabBadges.tsx new file mode 100644 index 00000000..bce7390d --- /dev/null +++ b/components/search/TVSearchTabBadges.tsx @@ -0,0 +1,115 @@ +import React from "react"; +import { Animated, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; + +type SearchType = "Library" | "Discover"; + +interface TVSearchTabBadgeProps { + label: string; + isSelected: boolean; + onPress: () => void; + hasTVPreferredFocus?: boolean; + disabled?: boolean; +} + +const TVSearchTabBadge: React.FC = ({ + label, + isSelected, + onPress, + hasTVPreferredFocus = false, + disabled = false, +}) => { + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.08, duration: 150 }); + + // Design language: white for focused/selected, transparent white for unfocused + const getBackgroundColor = () => { + if (focused) return "#fff"; + if (isSelected) return "rgba(255,255,255,0.25)"; + return "rgba(255,255,255,0.1)"; + }; + + const getTextColor = () => { + if (focused) return "#000"; + return "#fff"; + }; + + return ( + + + + {label} + + + + ); +}; + +export interface TVSearchTabBadgesProps { + searchType: SearchType; + setSearchType: (type: SearchType) => void; + showDiscover: boolean; + disabled?: boolean; +} + +export const TVSearchTabBadges: React.FC = ({ + searchType, + setSearchType, + showDiscover, + disabled = false, +}) => { + if (!showDiscover) { + return null; + } + + return ( + + setSearchType("Library")} + disabled={disabled} + /> + setSearchType("Discover")} + disabled={disabled} + /> + + ); +}; diff --git a/components/series/TVSeriesPage.tsx b/components/series/TVSeriesPage.tsx index 23a78b9a..0f5daf5c 100644 --- a/components/series/TVSeriesPage.tsx +++ b/components/series/TVSeriesPage.tsx @@ -32,9 +32,9 @@ import { TVEpisodeCard, } from "@/components/series/TVEpisodeCard"; import { TVSeriesHeader } from "@/components/series/TVSeriesHeader"; +import { TVOptionSelector } from "@/components/tv/TVOptionSelector"; import { TVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; -import { useTVOptionModal } from "@/hooks/useTVOptionModal"; import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useOfflineMode } from "@/providers/OfflineModeProvider"; @@ -229,8 +229,8 @@ export const TVSeriesPage: React.FC = ({ [item.Id, seasonIndexState], ); - // TV option modal hook - const { showOptions } = useTVOptionModal(); + // Season selector modal state + const [isSeasonModalVisible, setIsSeasonModalVisible] = useState(false); // ScrollView ref for page scrolling const mainScrollRef = useRef(null); @@ -403,22 +403,24 @@ export const TVSeriesPage: React.FC = ({ // Open season modal const handleOpenSeasonModal = useCallback(() => { - const options = seasons.map((season: BaseItemDto) => ({ + setIsSeasonModalVisible(true); + }, []); + + // Close season modal + const handleCloseSeasonModal = useCallback(() => { + setIsSeasonModalVisible(false); + }, []); + + // Season options for the modal + const seasonOptions = useMemo(() => { + return seasons.map((season: BaseItemDto) => ({ label: season.Name || `Season ${season.IndexNumber}`, value: season.IndexNumber ?? 0, selected: season.IndexNumber === selectedSeasonIndex || season.Name === String(selectedSeasonIndex), })); - - showOptions({ - title: t("item_card.select_season"), - options, - onSelect: handleSeasonSelect, - cardWidth: 180, - cardHeight: 85, - }); - }, [seasons, selectedSeasonIndex, showOptions, t, handleSeasonSelect]); + }, [seasons, selectedSeasonIndex]); // Episode list item layout const getItemLayout = useCallback( @@ -439,10 +441,16 @@ export const TVSeriesPage: React.FC = ({ onPress={() => handleEpisodePress(episode)} onFocus={handleEpisodeFocus} onBlur={handleEpisodeBlur} + disabled={isSeasonModalVisible} /> ), - [handleEpisodePress, handleEpisodeFocus, handleEpisodeBlur], + [ + handleEpisodePress, + handleEpisodeFocus, + handleEpisodeBlur, + isSeasonModalVisible, + ], ); // Get play button text @@ -563,7 +571,8 @@ export const TVSeriesPage: React.FC = ({ > = ({ )} @@ -638,6 +648,18 @@ export const TVSeriesPage: React.FC = ({ /> + + {/* Season selector modal */} + ); }; diff --git a/components/tv/TVOptionSelector.tsx b/components/tv/TVOptionSelector.tsx index 2e6f34be..0d260c34 100644 --- a/components/tv/TVOptionSelector.tsx +++ b/components/tv/TVOptionSelector.tsx @@ -189,7 +189,7 @@ const styles = StyleSheet.create({ }, scrollContent: { paddingHorizontal: 48, - paddingVertical: 10, + paddingVertical: 20, gap: 12, }, cancelButtonContainer: { diff --git a/hooks/useTVRequestModal.ts b/hooks/useTVRequestModal.ts new file mode 100644 index 00000000..0c096bb4 --- /dev/null +++ b/hooks/useTVRequestModal.ts @@ -0,0 +1,34 @@ +import { useCallback } from "react"; +import useRouter from "@/hooks/useAppRouter"; +import { tvRequestModalAtom } from "@/utils/atoms/tvRequestModal"; +import type { MediaType } from "@/utils/jellyseerr/server/constants/media"; +import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces"; +import { store } from "@/utils/store"; + +interface ShowRequestModalParams { + requestBody: MediaRequestBody; + title: string; + id: number; + mediaType: MediaType; + onRequested: () => void; +} + +export const useTVRequestModal = () => { + const router = useRouter(); + + const showRequestModal = useCallback( + (params: ShowRequestModalParams) => { + store.set(tvRequestModalAtom, { + requestBody: params.requestBody, + title: params.title, + id: params.id, + mediaType: params.mediaType, + onRequested: params.onRequested, + }); + router.push("/(auth)/tv-request-modal"); + }, + [router], + ); + + return { showRequestModal }; +}; diff --git a/translations/en.json b/translations/en.json index 1aa4f114..9481f39d 100644 --- a/translations/en.json +++ b/translations/en.json @@ -754,6 +754,8 @@ "decline": "Decline", "requested_by": "Requested by {{user}}", "unknown_user": "Unknown User", + "select": "Select", + "request_all": "Request All", "toasts": { "jellyseer_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0", "jellyseerr_test_failed": "Seerr test failed. Please try again.", diff --git a/translations/sv.json b/translations/sv.json index e6db4004..483be971 100644 --- a/translations/sv.json +++ b/translations/sv.json @@ -709,7 +709,7 @@ "quality_profile": "Kvalitetsprofil", "root_folder": "Rotkatalog", "season_all": "Säsong (alla)", - "season_number": "Säsong {{seasonNumber}}", + "season_number": "Säsong {{season_number}}", "number_episodes": "{{episode_number}} Avsnitt", "born": "Född", "appearances": "Framträdanden", @@ -717,6 +717,8 @@ "decline": "Avvisa", "requested_by": "Begärt av {{user}}", "unknown_user": "Okänd användare", + "select": "Välj", + "request_all": "Begär alla", "toasts": { "jellyseer_does_not_meet_requirements": "Seerr-servern uppfyller inte minimikrav för version! Vänligen uppdatera till minst 2.0.0", "jellyseerr_test_failed": "Seerr test misslyckades. Försök igen.", diff --git a/utils/atoms/tvRequestModal.ts b/utils/atoms/tvRequestModal.ts new file mode 100644 index 00000000..a1cd6ea7 --- /dev/null +++ b/utils/atoms/tvRequestModal.ts @@ -0,0 +1,13 @@ +import { atom } from "jotai"; +import type { MediaType } from "@/utils/jellyseerr/server/constants/media"; +import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces"; + +export type TVRequestModalState = { + requestBody: MediaRequestBody; + title: string; + id: number; + mediaType: MediaType; + onRequested: () => void; +} | null; + +export const tvRequestModalAtom = atom(null);