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);