diff --git a/app/(auth)/(tabs)/(home)/settings.tv.tsx b/app/(auth)/(tabs)/(home)/settings.tv.tsx index 8fb8dcef5..f57a149b6 100644 --- a/app/(auth)/(tabs)/(home)/settings.tv.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tv.tsx @@ -3,7 +3,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { Directory, Paths } from "expo-file-system"; import { Image } from "expo-image"; import { useAtom } from "jotai"; -import { useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Alert, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -21,6 +21,8 @@ import { TVSettingsToggle, } from "@/components/tv"; import { useScaledTVTypography } from "@/constants/TVTypography"; +import { useJellyseerr } from "@/hooks/useJellyseerr"; +import { useJellyseerrConnect } from "@/hooks/useJellyseerrConnect"; import { useTVOptionModal } from "@/hooks/useTVOptionModal"; import { useTVUserSwitchModal } from "@/hooks/useTVUserSwitchModal"; import { APP_LANGUAGES } from "@/i18n"; @@ -59,6 +61,37 @@ export default function SettingsTV() { const { showUserSwitchModal } = useTVUserSwitchModal(); const typography = useScaledTVTypography(); const queryClient = useQueryClient(); + const { jellyseerrApi, clearAllJellyseerData } = useJellyseerr(); + const { connecting: jellyseerrConnecting, connect: jellyseerrConnect } = + useJellyseerrConnect(); + + // Jellyseerr state + const [jellyseerrServerUrl, setJellyseerrServerUrl] = useState( + settings.jellyseerrServerUrl || "", + ); + const [jellyseerrPassword, setJellyseerrPassword] = useState(""); + const { pluginSettings } = useSettings(); + + const isJellyseerrLocked = + pluginSettings?.jellyseerrServerUrl?.locked === true; + const isJellyseerrConnected = !!jellyseerrApi; + + const handleJellyseerrUrlBlur = useCallback(() => { + const url = jellyseerrServerUrl.trim(); + updateSettings({ jellyseerrServerUrl: url || undefined }); + }, [jellyseerrServerUrl, updateSettings]); + + const handleJellyseerrConnect = useCallback(async () => { + const url = jellyseerrServerUrl.trim(); + if (!url) return; + await jellyseerrConnect(url, jellyseerrPassword); + }, [jellyseerrServerUrl, jellyseerrPassword, jellyseerrConnect]); + + const handleDisconnectJellyseerr = useCallback(() => { + clearAllJellyseerData(); + setJellyseerrServerUrl(""); + setJellyseerrPassword(""); + }, [clearAllJellyseerData]); // Local state for OpenSubtitles API key (only commit on blur) const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState( @@ -883,6 +916,81 @@ export default function SettingsTV() { onToggle={(value) => updateSettings({ tvThemeMusicEnabled: value })} /> + {/* seerr Section */} + + + {t("home.settings.plugins.jellyseerr.server_url_hint") || + "Enter your Jellyseerr server URL to enable discover and request features."} + + + {!isJellyseerrConnected && !isJellyseerrLocked && ( + <> + + + + )} + + {isJellyseerrConnected && !isJellyseerrLocked && ( + + )} + {/* Storage Section */} = ({ isFirstItem = false, }) => { const typography = useScaledTVTypography(); + const sizes = useScaledTVSizes(); const router = useRouter(); const { jellyseerrApi, getTitle, getYear } = useJellyseerr(); const { focused, handleFocus, handleBlur, animatedStyle } = @@ -50,6 +50,8 @@ const TVDiscoverPoster: React.FC = ({ item.mediaInfo?.status === MediaStatus.AVAILABLE || item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE; + const posterWidth = sizes.posters.poster; + const handlePress = () => { router.push({ pathname: "/(auth)/(tabs)/(search)/jellyseerr/page", @@ -71,7 +73,7 @@ const TVDiscoverPoster: React.FC = ({ style={[ animatedStyle, { - width: 210, + width: posterWidth, shadowColor: "#fff", shadowOffset: { width: 0, height: 0 }, shadowOpacity: focused ? 0.6 : 0, @@ -81,9 +83,9 @@ const TVDiscoverPoster: React.FC = ({ > = ({ > {title} - {year && ( + {year != null && ( {year} @@ -166,6 +168,7 @@ export const TVDiscoverSlide: React.FC = ({ isFirstSlide = false, }) => { const typography = useScaledTVTypography(); + const sizes = useScaledTVSizes(); const { t } = useTranslation(); const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr(); @@ -231,14 +234,14 @@ export const TVDiscoverSlide: React.FC = ({ if (!flatData || flatData.length === 0) return null; return ( - + {slideTitle} @@ -249,9 +252,9 @@ export const TVDiscoverSlide: React.FC = ({ keyExtractor={(item) => item.id.toString()} showsHorizontalScrollIndicator={false} contentContainerStyle={{ - paddingHorizontal: SCALE_PADDING, - paddingVertical: SCALE_PADDING, - gap: 20, + paddingHorizontal: sizes.padding.scale, + paddingVertical: sizes.padding.scale, + gap: sizes.gaps.item, }} style={{ overflow: "visible" }} onEndReached={() => { diff --git a/components/search/TVJellyseerrSearchResults.tsx b/components/search/TVJellyseerrSearchResults.tsx index 699c50401..80f0dcd26 100644 --- a/components/search/TVJellyseerrSearchResults.tsx +++ b/components/search/TVJellyseerrSearchResults.tsx @@ -5,6 +5,7 @@ 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 { useScaledTVSizes } from "@/constants/TVSizes"; import { useScaledTVTypography } from "@/constants/TVTypography"; import { useJellyseerr } from "@/hooks/useJellyseerr"; import { MediaStatus } from "@/utils/jellyseerr/server/constants/media"; @@ -14,8 +15,6 @@ import type { TvResult, } from "@/utils/jellyseerr/server/models/Search"; -const SCALE_PADDING = 20; - interface TVJellyseerrPosterProps { item: MovieResult | TvResult; onPress: () => void; @@ -28,6 +27,7 @@ const TVJellyseerrPoster: React.FC = ({ isFirstItem = false, }) => { const typography = useScaledTVTypography(); + const sizes = useScaledTVSizes(); const { jellyseerrApi, getTitle, getYear } = useJellyseerr(); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.05 }); @@ -43,6 +43,8 @@ const TVJellyseerrPoster: React.FC = ({ item.mediaInfo?.status === MediaStatus.AVAILABLE || item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE; + const posterWidth = sizes.posters.poster; + return ( = ({ style={[ animatedStyle, { - width: 210, + width: posterWidth, shadowColor: "#fff", shadowOffset: { width: 0, height: 0 }, shadowOpacity: focused ? 0.6 : 0, @@ -64,9 +66,9 @@ const TVJellyseerrPoster: React.FC = ({ > = ({ fontSize: typography.callout, color: "#fff", fontWeight: "600", - marginTop: 12, + marginTop: sizes.gaps.small, }} numberOfLines={2} > {title} - {year && ( + {year != null && ( = ({ onPress, }) => { const typography = useScaledTVTypography(); + const sizes = useScaledTVSizes(); const { jellyseerrApi } = useJellyseerr(); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation(); @@ -157,13 +160,15 @@ const TVJellyseerrPersonPoster: React.FC = ({ ? jellyseerrApi?.imageProxy(item.profilePath, "w185") : null; + const avatarSize = Math.round(sizes.posters.poster * 0.67); + return ( = ({ > = ({ alignItems: "center", }} > - + )} @@ -207,7 +216,7 @@ const TVJellyseerrPersonPoster: React.FC = ({ fontSize: typography.callout, color: focused ? "#fff" : "rgba(255,255,255,0.9)", fontWeight: "600", - marginTop: 12, + marginTop: sizes.gaps.small, textAlign: "center", }} numberOfLines={2} @@ -233,17 +242,18 @@ const TVJellyseerrMovieSection: React.FC = ({ onItemPress, }) => { const typography = useScaledTVTypography(); + const sizes = useScaledTVSizes(); if (!items || items.length === 0) return null; return ( - + {title} @@ -254,9 +264,9 @@ const TVJellyseerrMovieSection: React.FC = ({ keyExtractor={(item) => item.id.toString()} showsHorizontalScrollIndicator={false} contentContainerStyle={{ - paddingHorizontal: SCALE_PADDING, - paddingVertical: SCALE_PADDING, - gap: 20, + paddingHorizontal: sizes.padding.scale, + paddingVertical: sizes.padding.scale, + gap: sizes.gaps.item, }} style={{ overflow: "visible" }} renderItem={({ item, index }) => ( @@ -285,17 +295,18 @@ const TVJellyseerrTvSection: React.FC = ({ onItemPress, }) => { const typography = useScaledTVTypography(); + const sizes = useScaledTVSizes(); if (!items || items.length === 0) return null; return ( - + {title} @@ -306,9 +317,9 @@ const TVJellyseerrTvSection: React.FC = ({ keyExtractor={(item) => item.id.toString()} showsHorizontalScrollIndicator={false} contentContainerStyle={{ - paddingHorizontal: SCALE_PADDING, - paddingVertical: SCALE_PADDING, - gap: 20, + paddingHorizontal: sizes.padding.scale, + paddingVertical: sizes.padding.scale, + gap: sizes.gaps.item, }} style={{ overflow: "visible" }} renderItem={({ item, index }) => ( @@ -337,17 +348,18 @@ const TVJellyseerrPersonSection: React.FC = ({ onItemPress, }) => { const typography = useScaledTVTypography(); + const sizes = useScaledTVSizes(); if (!items || items.length === 0) return null; return ( - + {title} @@ -358,9 +370,9 @@ const TVJellyseerrPersonSection: React.FC = ({ keyExtractor={(item) => item.id.toString()} showsHorizontalScrollIndicator={false} contentContainerStyle={{ - paddingHorizontal: SCALE_PADDING, - paddingVertical: SCALE_PADDING, - gap: 20, + paddingHorizontal: sizes.padding.scale, + paddingVertical: sizes.padding.scale, + gap: sizes.gaps.item, }} style={{ overflow: "visible" }} renderItem={({ item }) => ( @@ -400,6 +412,7 @@ export const TVJellyseerrSearchResults: React.FC< onPersonPress, }) => { const { t } = useTranslation(); + const typography = useScaledTVTypography(); if (loading) { return null; @@ -410,7 +423,7 @@ export const TVJellyseerrSearchResults: React.FC< {t("search.no_results_found_for")} - + "{searchQuery}" diff --git a/components/search/TVSearchPage.tsx b/components/search/TVSearchPage.tsx index 24e6120d1..98781ef50 100644 --- a/components/search/TVSearchPage.tsx +++ b/components/search/TVSearchPage.tsx @@ -1,6 +1,6 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { useAtom } from "jotai"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Platform, ScrollView, TextInput, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -166,6 +166,7 @@ export const TVSearchPage: React.FC = ({ const { t } = useTranslation(); const insets = useSafeAreaInsets(); const [api] = useAtom(apiAtom); + const [isSearchFocused, setIsSearchFocused] = useState(false); // Image URL getter for music items const getImageUrl = useMemo(() => { @@ -270,6 +271,9 @@ export const TVSearchPage: React.FC = ({ onChangeText={setSearch} defaultValue='' autoFocus={false} + onFocus={() => setIsSearchFocused(true)} + onBlur={() => setIsSearchFocused(false)} + hasTVPreferredFocus /> )} @@ -290,6 +294,7 @@ export const TVSearchPage: React.FC = ({ searchType={searchType} setSearchType={setSearchType} showDiscover={showDiscover} + disabled={isSearchFocused} /> )} @@ -316,6 +321,7 @@ export const TVSearchPage: React.FC = ({ // every keystroke as results re-render. User navigates down to the // grid manually. isFirstSection={false} + disabled={isSearchFocused} onItemPress={onItemPress} onItemLongPress={onItemLongPress} imageUrlGetter={ diff --git a/translations/en.json b/translations/en.json index b8e64df03..733f94b19 100644 --- a/translations/en.json +++ b/translations/en.json @@ -592,7 +592,8 @@ "continue": "Continue", "verifying": "Verifying...", "login": "Login", - "refresh": "Refresh" + "refresh": "Refresh", + "loading": "Loading..." }, "search": { "search": "Search...",