mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-03-08 02:36:20 +00:00
feat(tv): seerr
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 <TVJellyseerrPage />;
|
||||
}
|
||||
return <MobilePage />;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: 48,
|
||||
paddingVertical: 10,
|
||||
paddingVertical: 20,
|
||||
gap: 12,
|
||||
},
|
||||
});
|
||||
|
||||
489
app/(auth)/tv-request-modal.tsx
Normal file
489
app/(auth)/tv-request-modal.tsx
Normal file
@@ -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<MediaRequestBody>({
|
||||
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<number>[] = 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<string>[] = 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<number>[] = 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 (
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.sheetContainer,
|
||||
{ transform: [{ translateY: sheetTranslateY }] },
|
||||
]}
|
||||
>
|
||||
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
|
||||
<TVFocusGuideView
|
||||
autoFocus
|
||||
trapFocusUp
|
||||
trapFocusDown
|
||||
trapFocusLeft
|
||||
trapFocusRight
|
||||
style={styles.content}
|
||||
>
|
||||
<Text style={styles.heading}>{t("jellyseerr.advanced")}</Text>
|
||||
<Text style={styles.subtitle}>{modalState.title}</Text>
|
||||
|
||||
{isDataLoaded && isReady ? (
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.optionsContainer}>
|
||||
<TVRequestOptionRow
|
||||
label={t("jellyseerr.quality_profile")}
|
||||
value={selectedProfileName}
|
||||
onPress={() => setActiveSelector("profile")}
|
||||
hasTVPreferredFocus
|
||||
/>
|
||||
<TVRequestOptionRow
|
||||
label={t("jellyseerr.root_folder")}
|
||||
value={selectedFolderName}
|
||||
onPress={() => setActiveSelector("folder")}
|
||||
/>
|
||||
<TVRequestOptionRow
|
||||
label={t("jellyseerr.request_as")}
|
||||
value={selectedUserName}
|
||||
onPress={() => setActiveSelector("user")}
|
||||
/>
|
||||
|
||||
{tagItems.length > 0 && (
|
||||
<TVToggleOptionRow
|
||||
label={t("jellyseerr.tags")}
|
||||
items={tagItems}
|
||||
onToggle={handleTagToggle}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
) : (
|
||||
<View style={styles.loadingContainer}>
|
||||
<Text style={styles.loadingText}>{t("common.loading")}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{isReady && (
|
||||
<View style={styles.buttonContainer}>
|
||||
<TVButton
|
||||
onPress={handleRequest}
|
||||
variant='secondary'
|
||||
disabled={!isDataLoaded}
|
||||
>
|
||||
<Ionicons
|
||||
name='add'
|
||||
size={22}
|
||||
color='#FFFFFF'
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Text style={styles.buttonText}>
|
||||
{t("jellyseerr.request_button")}
|
||||
</Text>
|
||||
</TVButton>
|
||||
</View>
|
||||
)}
|
||||
</TVFocusGuideView>
|
||||
</BlurView>
|
||||
</Animated.View>
|
||||
|
||||
{/* Sub-selectors */}
|
||||
<TVOptionSelector
|
||||
visible={activeSelector === "profile"}
|
||||
title={t("jellyseerr.quality_profile")}
|
||||
options={qualityProfileOptions}
|
||||
onSelect={handleProfileChange}
|
||||
onClose={() => setActiveSelector(null)}
|
||||
cancelLabel={t("jellyseerr.cancel")}
|
||||
/>
|
||||
<TVOptionSelector
|
||||
visible={activeSelector === "folder"}
|
||||
title={t("jellyseerr.root_folder")}
|
||||
options={rootFolderOptions}
|
||||
onSelect={handleFolderChange}
|
||||
onClose={() => setActiveSelector(null)}
|
||||
cancelLabel={t("jellyseerr.cancel")}
|
||||
cardWidth={280}
|
||||
/>
|
||||
<TVOptionSelector
|
||||
visible={activeSelector === "user"}
|
||||
title={t("jellyseerr.request_as")}
|
||||
options={userOptions}
|
||||
onSelect={handleUserChange}
|
||||
onClose={() => setActiveSelector(null)}
|
||||
cancelLabel={t("jellyseerr.cancel")}
|
||||
/>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
});
|
||||
@@ -445,6 +445,14 @@ function Layout() {
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='(auth)/tv-request-modal'
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: "transparentModal",
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<Toaster
|
||||
duration={4000}
|
||||
|
||||
47
components/jellyseerr/discover/TVDiscover.tsx
Normal file
47
components/jellyseerr/discover/TVDiscover.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { sortBy } from "lodash";
|
||||
import React, { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
|
||||
import { TVDiscoverSlide } from "./TVDiscoverSlide";
|
||||
|
||||
interface TVDiscoverProps {
|
||||
sliders?: DiscoverSlider[];
|
||||
}
|
||||
|
||||
// Only show movie/TV slides on TV - skip genres, networks, studios for now
|
||||
const SUPPORTED_SLIDE_TYPES = [
|
||||
DiscoverSliderType.TRENDING,
|
||||
DiscoverSliderType.POPULAR_MOVIES,
|
||||
DiscoverSliderType.UPCOMING_MOVIES,
|
||||
DiscoverSliderType.POPULAR_TV,
|
||||
DiscoverSliderType.UPCOMING_TV,
|
||||
];
|
||||
|
||||
export const TVDiscover: React.FC<TVDiscoverProps> = ({ 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 (
|
||||
<View>
|
||||
{sortedSliders.map((slide, index) => (
|
||||
<TVDiscoverSlide
|
||||
key={slide.id}
|
||||
slide={slide}
|
||||
isFirstSlide={index === 0}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
249
components/jellyseerr/discover/TVDiscoverSlide.tsx
Normal file
249
components/jellyseerr/discover/TVDiscoverSlide.tsx
Normal file
@@ -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<TVDiscoverPosterProps> = ({
|
||||
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 (
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={isFirstItem}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
width: 180,
|
||||
shadowColor: "#fff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.4 : 0,
|
||||
shadowRadius: focused ? 12 : 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 180,
|
||||
aspectRatio: 2 / 3,
|
||||
borderRadius: 12,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "rgba(255,255,255,0.1)",
|
||||
borderWidth: focused ? 3 : 0,
|
||||
borderColor: "#fff",
|
||||
}}
|
||||
>
|
||||
{posterUrl ? (
|
||||
<Image
|
||||
source={{ uri: posterUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
cachePolicy='memory-disk'
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#262626",
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name='image-outline'
|
||||
size={40}
|
||||
color='rgba(255,255,255,0.3)'
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
||||
fontWeight: "600",
|
||||
marginTop: 10,
|
||||
}}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
{year && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: focused
|
||||
? "rgba(255,255,255,0.7)"
|
||||
: "rgba(255,255,255,0.5)",
|
||||
}}
|
||||
>
|
||||
{year}
|
||||
</Text>
|
||||
)}
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
interface TVDiscoverSlideProps {
|
||||
slide: DiscoverSlider;
|
||||
isFirstSlide?: boolean;
|
||||
}
|
||||
|
||||
export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
|
||||
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<string, unknown> = {
|
||||
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 (
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.heading,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 16,
|
||||
marginLeft: SCALE_PADDING,
|
||||
}}
|
||||
>
|
||||
{slideTitle}
|
||||
</Text>
|
||||
<FlatList
|
||||
horizontal
|
||||
data={flatData}
|
||||
keyExtractor={(item) => 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 }) => (
|
||||
<TVDiscoverPoster
|
||||
item={item}
|
||||
isFirstItem={isFirstSlide && index === 0}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
430
components/search/TVJellyseerrSearchResults.tsx
Normal file
430
components/search/TVJellyseerrSearchResults.tsx
Normal file
@@ -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<TVJellyseerrPosterProps> = ({
|
||||
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 (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={isFirstItem}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
width: 210,
|
||||
shadowColor: "#fff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.4 : 0,
|
||||
shadowRadius: focused ? 12 : 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 210,
|
||||
aspectRatio: 2 / 3,
|
||||
borderRadius: 12,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "rgba(255,255,255,0.1)",
|
||||
borderWidth: focused ? 3 : 0,
|
||||
borderColor: "#fff",
|
||||
}}
|
||||
>
|
||||
{posterUrl ? (
|
||||
<Image
|
||||
source={{ uri: posterUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
cachePolicy='memory-disk'
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#262626",
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name='image-outline'
|
||||
size={40}
|
||||
color='rgba(255,255,255,0.3)'
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
||||
fontWeight: "600",
|
||||
marginTop: 10,
|
||||
}}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
{year && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: focused
|
||||
? "rgba(255,255,255,0.7)"
|
||||
: "rgba(255,255,255,0.5)",
|
||||
}}
|
||||
>
|
||||
{year}
|
||||
</Text>
|
||||
)}
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
interface TVJellyseerrPersonPosterProps {
|
||||
item: PersonResult;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
|
||||
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 (
|
||||
<Pressable onPress={onPress} onFocus={handleFocus} onBlur={handleBlur}>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
width: 160,
|
||||
alignItems: "center",
|
||||
shadowColor: "#fff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.4 : 0,
|
||||
shadowRadius: focused ? 12 : 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 140,
|
||||
height: 140,
|
||||
borderRadius: 70,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "rgba(255,255,255,0.1)",
|
||||
borderWidth: focused ? 3 : 0,
|
||||
borderColor: "#fff",
|
||||
}}
|
||||
>
|
||||
{posterUrl ? (
|
||||
<Image
|
||||
source={{ uri: posterUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
cachePolicy='memory-disk'
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons name='person' size={56} color='rgba(255,255,255,0.4)' />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
||||
fontWeight: "600",
|
||||
marginTop: 12,
|
||||
textAlign: "center",
|
||||
}}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{item.name}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
interface TVJellyseerrMovieSectionProps {
|
||||
title: string;
|
||||
items: MovieResult[];
|
||||
isFirstSection?: boolean;
|
||||
onItemPress: (item: MovieResult) => void;
|
||||
}
|
||||
|
||||
const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
|
||||
title,
|
||||
items,
|
||||
isFirstSection = false,
|
||||
onItemPress,
|
||||
}) => {
|
||||
if (!items || items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.heading,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 16,
|
||||
marginLeft: SCALE_PADDING,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
<FlatList
|
||||
horizontal
|
||||
data={items}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: SCALE_PADDING,
|
||||
paddingVertical: SCALE_PADDING,
|
||||
gap: 20,
|
||||
}}
|
||||
style={{ overflow: "visible" }}
|
||||
renderItem={({ item, index }) => (
|
||||
<TVJellyseerrPoster
|
||||
item={item}
|
||||
onPress={() => onItemPress(item)}
|
||||
isFirstItem={isFirstSection && index === 0}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
interface TVJellyseerrTvSectionProps {
|
||||
title: string;
|
||||
items: TvResult[];
|
||||
isFirstSection?: boolean;
|
||||
onItemPress: (item: TvResult) => void;
|
||||
}
|
||||
|
||||
const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
|
||||
title,
|
||||
items,
|
||||
isFirstSection = false,
|
||||
onItemPress,
|
||||
}) => {
|
||||
if (!items || items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.heading,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 16,
|
||||
marginLeft: SCALE_PADDING,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
<FlatList
|
||||
horizontal
|
||||
data={items}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: SCALE_PADDING,
|
||||
paddingVertical: SCALE_PADDING,
|
||||
gap: 20,
|
||||
}}
|
||||
style={{ overflow: "visible" }}
|
||||
renderItem={({ item, index }) => (
|
||||
<TVJellyseerrPoster
|
||||
item={item}
|
||||
onPress={() => onItemPress(item)}
|
||||
isFirstItem={isFirstSection && index === 0}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
interface TVJellyseerrPersonSectionProps {
|
||||
title: string;
|
||||
items: PersonResult[];
|
||||
isFirstSection?: boolean;
|
||||
onItemPress: (item: PersonResult) => void;
|
||||
}
|
||||
|
||||
const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
|
||||
title,
|
||||
items,
|
||||
isFirstSection: _isFirstSection = false,
|
||||
onItemPress,
|
||||
}) => {
|
||||
if (!items || items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.heading,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 16,
|
||||
marginLeft: SCALE_PADDING,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
<FlatList
|
||||
horizontal
|
||||
data={items}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: SCALE_PADDING,
|
||||
paddingVertical: SCALE_PADDING,
|
||||
gap: 20,
|
||||
}}
|
||||
style={{ overflow: "visible" }}
|
||||
renderItem={({ item }) => (
|
||||
<TVJellyseerrPersonPoster
|
||||
item={item}
|
||||
onPress={() => onItemPress(item)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<View style={{ alignItems: "center", paddingTop: 40 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 24,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{t("search.no_results_found_for")}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 18, color: "rgba(255,255,255,0.6)" }}>
|
||||
"{searchQuery}"
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
<TVJellyseerrMovieSection
|
||||
title={t("search.request_movies")}
|
||||
items={movieResults}
|
||||
isFirstSection={hasMovies}
|
||||
onItemPress={onMoviePress}
|
||||
/>
|
||||
<TVJellyseerrTvSection
|
||||
title={t("search.request_series")}
|
||||
items={tvResults}
|
||||
isFirstSection={!hasMovies && hasTv}
|
||||
onItemPress={onTvPress}
|
||||
/>
|
||||
<TVJellyseerrPersonSection
|
||||
title={t("search.actors")}
|
||||
items={personResults}
|
||||
isFirstSection={!hasMovies && !hasTv && hasPersons}
|
||||
onItemPress={onPersonPress}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -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<TVSearchPageProps> = ({
|
||||
@@ -121,6 +136,18 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
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<TVSearchPageProps> = ({
|
||||
t,
|
||||
]);
|
||||
|
||||
const isLibraryMode = searchType === "Library";
|
||||
const isDiscoverMode = searchType === "Discover";
|
||||
const currentLoading = isLibraryMode ? loading : jellyseerrLoading;
|
||||
const currentNoResults = isLibraryMode ? noResults : jellyseerrNoResults;
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
nestedScrollEnabled
|
||||
@@ -190,7 +222,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
}}
|
||||
>
|
||||
{/* Search Input */}
|
||||
<View style={{ marginBottom: 32, marginHorizontal: SCALE_PADDING }}>
|
||||
<View style={{ marginBottom: 24, marginHorizontal: SCALE_PADDING }}>
|
||||
<Input
|
||||
placeholder={t("search.search")}
|
||||
value={search}
|
||||
@@ -201,21 +233,34 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
clearButtonMode='while-editing'
|
||||
maxLength={500}
|
||||
hasTVPreferredFocus={
|
||||
debouncedSearch.length === 0 && sections.length === 0
|
||||
debouncedSearch.length === 0 &&
|
||||
sections.length === 0 &&
|
||||
!showDiscover
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Search Type Tab Badges */}
|
||||
{showDiscover && (
|
||||
<View style={{ marginHorizontal: SCALE_PADDING }}>
|
||||
<TVSearchTabBadges
|
||||
searchType={searchType}
|
||||
setSearchType={setSearchType}
|
||||
showDiscover={showDiscover}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
{currentLoading && (
|
||||
<View style={{ gap: SECTION_GAP }}>
|
||||
<TVLoadingSkeleton />
|
||||
<TVLoadingSkeleton />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Search Results */}
|
||||
{!loading && (
|
||||
{/* Library Search Results */}
|
||||
{isLibraryMode && !loading && (
|
||||
<View style={{ gap: SECTION_GAP }}>
|
||||
{sections.map((section, index) => (
|
||||
<TVSearchSection
|
||||
@@ -237,8 +282,28 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Jellyseerr/Discover Search Results */}
|
||||
{isDiscoverMode && !jellyseerrLoading && debouncedSearch.length > 0 && (
|
||||
<TVJellyseerrSearchResults
|
||||
movieResults={jellyseerrMovies}
|
||||
tvResults={jellyseerrTv}
|
||||
personResults={jellyseerrPersons}
|
||||
loading={jellyseerrLoading}
|
||||
noResults={jellyseerrNoResults}
|
||||
searchQuery={debouncedSearch}
|
||||
onMoviePress={onJellyseerrMoviePress || (() => {})}
|
||||
onTvPress={onJellyseerrTvPress || (() => {})}
|
||||
onPersonPress={onJellyseerrPersonPress || (() => {})}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Discover Content (when no search query in Discover mode) */}
|
||||
{isDiscoverMode && !jellyseerrLoading && debouncedSearch.length === 0 && (
|
||||
<TVDiscover sliders={discoverSliders} />
|
||||
)}
|
||||
|
||||
{/* No Results State */}
|
||||
{!loading && noResults && debouncedSearch.length > 0 && (
|
||||
{!currentLoading && currentNoResults && debouncedSearch.length > 0 && (
|
||||
<View style={{ alignItems: "center", paddingTop: 40 }}>
|
||||
<Text
|
||||
style={{
|
||||
@@ -250,33 +315,11 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
>
|
||||
{t("search.no_results_found_for")}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 18, color: "#9334E9" }}>
|
||||
<Text style={{ fontSize: 18, color: "rgba(255,255,255,0.6)" }}>
|
||||
"{debouncedSearch}"
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Example Searches (when no search query) */}
|
||||
{!loading && debouncedSearch.length === 0 && (
|
||||
<View style={{ alignItems: "center", paddingTop: 40, gap: 16 }}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: 12,
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{exampleSearches.map((example) => (
|
||||
<TVSearchBadge
|
||||
key={example}
|
||||
label={example}
|
||||
onPress={() => setSearch(example)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
115
components/search/TVSearchTabBadges.tsx
Normal file
115
components/search/TVSearchTabBadges.tsx
Normal file
@@ -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<TVSearchTabBadgeProps> = ({
|
||||
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 (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
||||
disabled={disabled}
|
||||
focusable={!disabled}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 24,
|
||||
backgroundColor: getBackgroundColor(),
|
||||
shadowColor: "#fff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.4 : 0,
|
||||
shadowRadius: focused ? 12 : 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: getTextColor(),
|
||||
fontWeight: isSelected || focused ? "600" : "400",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
export interface TVSearchTabBadgesProps {
|
||||
searchType: SearchType;
|
||||
setSearchType: (type: SearchType) => void;
|
||||
showDiscover: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const TVSearchTabBadges: React.FC<TVSearchTabBadgesProps> = ({
|
||||
searchType,
|
||||
setSearchType,
|
||||
showDiscover,
|
||||
disabled = false,
|
||||
}) => {
|
||||
if (!showDiscover) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
gap: 16,
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<TVSearchTabBadge
|
||||
label='Library'
|
||||
isSelected={searchType === "Library"}
|
||||
onPress={() => setSearchType("Library")}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<TVSearchTabBadge
|
||||
label='Discover'
|
||||
isSelected={searchType === "Discover"}
|
||||
onPress={() => setSearchType("Discover")}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -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<TVSeriesPageProps> = ({
|
||||
[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<ScrollView>(null);
|
||||
@@ -403,22 +403,24 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
|
||||
|
||||
// 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<TVSeriesPageProps> = ({
|
||||
onPress={() => handleEpisodePress(episode)}
|
||||
onFocus={handleEpisodeFocus}
|
||||
onBlur={handleEpisodeBlur}
|
||||
disabled={isSeasonModalVisible}
|
||||
/>
|
||||
</View>
|
||||
),
|
||||
[handleEpisodePress, handleEpisodeFocus, handleEpisodeBlur],
|
||||
[
|
||||
handleEpisodePress,
|
||||
handleEpisodeFocus,
|
||||
handleEpisodeBlur,
|
||||
isSeasonModalVisible,
|
||||
],
|
||||
);
|
||||
|
||||
// Get play button text
|
||||
@@ -563,7 +571,8 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
|
||||
>
|
||||
<TVFocusableButton
|
||||
onPress={handlePlayNextEpisode}
|
||||
hasTVPreferredFocus
|
||||
hasTVPreferredFocus={!isSeasonModalVisible}
|
||||
disabled={isSeasonModalVisible}
|
||||
variant='primary'
|
||||
>
|
||||
<Ionicons
|
||||
@@ -587,6 +596,7 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
|
||||
<TVSeasonButton
|
||||
seasonName={selectedSeasonName}
|
||||
onPress={handleOpenSeasonModal}
|
||||
disabled={isSeasonModalVisible}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
@@ -638,6 +648,18 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Season selector modal */}
|
||||
<TVOptionSelector
|
||||
visible={isSeasonModalVisible}
|
||||
title={t("item_card.select_season")}
|
||||
options={seasonOptions}
|
||||
onSelect={handleSeasonSelect}
|
||||
onClose={handleCloseSeasonModal}
|
||||
cancelLabel={t("common.cancel")}
|
||||
cardWidth={180}
|
||||
cardHeight={85}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -189,7 +189,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: 48,
|
||||
paddingVertical: 10,
|
||||
paddingVertical: 20,
|
||||
gap: 12,
|
||||
},
|
||||
cancelButtonContainer: {
|
||||
|
||||
34
hooks/useTVRequestModal.ts
Normal file
34
hooks/useTVRequestModal.ts
Normal file
@@ -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 };
|
||||
};
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
13
utils/atoms/tvRequestModal.ts
Normal file
13
utils/atoms/tvRequestModal.ts
Normal file
@@ -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<TVRequestModalState>(null);
|
||||
Reference in New Issue
Block a user