From 36d18e2becd1acbd3961a32863706f11b0e7b4e0 Mon Sep 17 00:00:00 2001
From: Lance Chant <13349722+lancechant@users.noreply.github.com>
Date: Fri, 5 Jun 2026 13:24:11 +0200
Subject: [PATCH] wip: initial implementation of seerr for TV
Allowed the tv to login manually and discover content via search.
Still work to do, I.E centralize the logic for mobile and tv
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
---
app/(auth)/(tabs)/(home)/settings.tv.tsx | 110 +++++++++++++++++-
.../jellyseerr/discover/TVDiscoverSlide.tsx | 29 ++---
.../search/TVJellyseerrSearchResults.tsx | 81 +++++++------
components/search/TVSearchPage.tsx | 8 +-
translations/en.json | 3 +-
5 files changed, 182 insertions(+), 49 deletions(-)
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...",