Compare commits

..

4 Commits

Author SHA1 Message Date
Lance Chant
6f1d3e4f66 chore: PR comments
Fixing PR comments from coderabbit

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-09 09:41:49 +02:00
lance chant
151a39c7fc Merge branch 'fix/android-tv-issues' into fix/tv-seerr 2026-06-08 14:00:05 +02:00
Lance Chant
a4bc67bc23 fix: fixing seerr intergration
added an alert if seerr if configured via settings, but not authed
Added an alert if the auth for seerr expires it should fire
cleaned up a little

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-08 13:55:52 +02:00
Lance Chant
36d18e2bec 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>
2026-06-05 13:24:11 +02:00
18 changed files with 378 additions and 480 deletions

View File

@@ -1,60 +0,0 @@
name: 🛡️ Trivy Security Scan
# Filesystem scan (Streamyfin ships no container image): finds vulnerable dependencies,
# leaked secrets and misconfigurations, and reports them to GitHub code scanning.
# Runs post-merge + weekly (not on PRs — dependency-review already gates PRs, and SARIF
# upload needs a write token that fork PRs don't get).
on:
push:
branches: [develop, master]
schedule:
- cron: "50 7 * * 5" # Weekly, Friday 07:50 UTC
workflow_dispatch:
permissions:
contents: read
concurrency:
group: trivy-${{ github.ref }}
cancel-in-progress: true
jobs:
trivy:
name: 🔎 Filesystem scan
runs-on: ubuntu-24.04
permissions:
contents: read
security-events: write # upload SARIF to code scanning
steps:
- name: 📥 Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# Rotate the DB cache weekly (matches the scheduled scan): cache hits within the week
# instead of a fresh immutable entry per run, still refreshing the DB every week.
- name: 🗓️ Compute weekly Trivy cache key
id: trivy-cache-key
run: echo "value=trivy-db-${{ runner.os }}-$(date -u +%G-%V)" >> "$GITHUB_OUTPUT"
- name: 💾 Cache Trivy vulnerability DB
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.cache/trivy
key: ${{ steps.trivy-cache-key.outputs.value }}
restore-keys: trivy-db-${{ runner.os }}-
- name: 🔎 Run Trivy filesystem scan
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: fs
scan-ref: .
scanners: vuln,secret,misconfig
ignore-unfixed: true
severity: CRITICAL,HIGH
format: sarif
output: trivy-results.sarif
- name: 📤 Upload results to code scanning
uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
sarif_file: trivy-results.sarif
category: trivy-fs

View File

@@ -1,12 +1,13 @@
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client"; import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
import { useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Directory, Paths } from "expo-file-system"; import { Directory, Paths } from "expo-file-system";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert, ScrollView, View } from "react-native"; import { Alert, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal"; import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
import { TVPINEntryModal } from "@/components/login/TVPINEntryModal"; import { TVPINEntryModal } from "@/components/login/TVPINEntryModal";
@@ -21,6 +22,7 @@ import {
TVSettingsToggle, TVSettingsToggle,
} from "@/components/tv"; } from "@/components/tv";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
import { useTVOptionModal } from "@/hooks/useTVOptionModal"; import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { useTVUserSwitchModal } from "@/hooks/useTVUserSwitchModal"; import { useTVUserSwitchModal } from "@/hooks/useTVUserSwitchModal";
import { APP_LANGUAGES } from "@/i18n"; import { APP_LANGUAGES } from "@/i18n";
@@ -50,7 +52,7 @@ import { clearTopShelfCacheSafely } from "@/utils/topshelf/cache";
export default function SettingsTV() { export default function SettingsTV() {
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { settings, updateSettings } = useSettings(); const { settings, updateSettings, pluginSettings } = useSettings();
const { logout, loginWithSavedCredential, loginWithPassword } = useJellyfin(); const { logout, loginWithSavedCredential, loginWithPassword } = useJellyfin();
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
@@ -59,6 +61,51 @@ export default function SettingsTV() {
const { showUserSwitchModal } = useTVUserSwitchModal(); const { showUserSwitchModal } = useTVUserSwitchModal();
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { jellyseerrApi, setJellyseerrUser, clearAllJellyseerData } =
useJellyseerr();
// Jellyseerr state
const [jellyseerrServerUrl, setJellyseerrServerUrl] = useState(
settings.jellyseerrServerUrl || "",
);
const [jellyseerrPassword, setJellyseerrPassword] = useState("");
const isJellyseerrLocked =
pluginSettings?.jellyseerrServerUrl?.locked === true;
const isJellyseerrConnected = !!jellyseerrApi;
const handleJellyseerrUrlBlur = useCallback(() => {
const url = jellyseerrServerUrl.trim();
updateSettings({ jellyseerrServerUrl: url || undefined });
}, [jellyseerrServerUrl, updateSettings]);
const jellyseerrLoginMutation = useMutation({
mutationFn: async () => {
const url = jellyseerrServerUrl.trim();
if (!url) throw new Error("Missing server url");
if (!user?.Name) throw new Error("Missing user info");
const tempApi = new JellyseerrApi(url);
const testResult = await tempApi.test();
if (!testResult.isValid) throw new Error("Invalid server url");
return tempApi.login(user.Name, jellyseerrPassword);
},
onSuccess: (loggedInUser) => {
setJellyseerrUser(loggedInUser);
updateSettings({ jellyseerrServerUrl: jellyseerrServerUrl.trim() });
},
onError: () => {
toast.error(t("jellyseerr.failed_to_login"));
},
onSettled: () => {
setJellyseerrPassword("");
},
});
const handleDisconnectJellyseerr = useCallback(() => {
clearAllJellyseerData();
setJellyseerrServerUrl("");
setJellyseerrPassword("");
}, [clearAllJellyseerData]);
// Local state for OpenSubtitles API key (only commit on blur) // Local state for OpenSubtitles API key (only commit on blur)
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState( const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
@@ -877,6 +924,81 @@ export default function SettingsTV() {
onToggle={(value) => updateSettings({ tvThemeMusicEnabled: value })} onToggle={(value) => updateSettings({ tvThemeMusicEnabled: value })}
/> />
{/* seerr Section */}
<TVSectionHeader title='seerr' />
<Text
style={{
color: "#9CA3AF",
fontSize: typography.callout - 2,
marginBottom: 16,
marginLeft: 8,
}}
>
{t("home.settings.plugins.jellyseerr.server_url_hint") ||
"Enter your Jellyseerr server URL to enable discover and request features."}
</Text>
<TVSettingsTextInput
label={
t("home.settings.plugins.jellyseerr.server_url") || "Server URL"
}
value={jellyseerrServerUrl}
placeholder={
t("home.settings.plugins.jellyseerr.server_url_placeholder") ||
"https://jellyseerr.example.com"
}
onChangeText={setJellyseerrServerUrl}
onBlur={handleJellyseerrUrlBlur}
disabled={isJellyseerrLocked || jellyseerrLoginMutation.isPending}
/>
{!isJellyseerrConnected && !isJellyseerrLocked && (
<>
<TVSettingsTextInput
label={
t("home.settings.plugins.jellyseerr.password") || "Password"
}
value={jellyseerrPassword}
placeholder={
t("home.settings.plugins.jellyseerr.password_placeholder", {
username: user?.Name,
}) || `Jellyfin password`
}
onChangeText={setJellyseerrPassword}
secureTextEntry
disabled={jellyseerrLoginMutation.isPending}
/>
<TVSettingsOptionButton
label={
jellyseerrLoginMutation.isPending
? t("common.connecting", "Connecting...") || "Connecting..."
: t("common.connect", "Connect") || "Connect"
}
value=''
onPress={() => jellyseerrLoginMutation.mutate()}
disabled={jellyseerrLoginMutation.isPending}
/>
</>
)}
<TVSettingsRow
label={
isJellyseerrConnected
? t("common.connected", "Connected") || "Connected"
: t("common.not_connected", "Not connected") || "Not connected"
}
value=''
showChevron={false}
/>
{isJellyseerrConnected && !isJellyseerrLocked && (
<TVSettingsOptionButton
label={
t(
"home.settings.plugins.jellyseerr.reset_jellyseerr_config_button",
) || "Disconnect"
}
value=''
onPress={handleDisconnectJellyseerr}
/>
)}
{/* Storage Section */} {/* Storage Section */}
<TVSectionHeader title={t("home.settings.storage.storage_title")} /> <TVSectionHeader title={t("home.settings.storage.storage_title")} />
<TVSettingsOptionButton <TVSettingsOptionButton

View File

@@ -7,7 +7,12 @@ import { useAsyncDebouncer } from "@tanstack/react-pacer";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import axios from "axios"; import axios from "axios";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation, useSegments } from "expo-router"; import {
useIsFocused,
useLocalSearchParams,
useNavigation,
useSegments,
} from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { orderBy, uniqBy } from "lodash"; import { orderBy, uniqBy } from "lodash";
import { import {
@@ -20,7 +25,13 @@ import {
useState, useState,
} from "react"; } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native"; import {
Alert,
Platform,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster"; import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
@@ -41,7 +52,10 @@ import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
import { SearchTabButtons } from "@/components/search/SearchTabButtons"; import { SearchTabButtons } from "@/components/search/SearchTabButtons";
import { TVSearchPage } from "@/components/search/TVSearchPage"; import { TVSearchPage } from "@/components/search/TVSearchPage";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import {
useJellyseerr,
validateJellyseerrSession,
} from "@/hooks/useJellyseerr";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
@@ -106,8 +120,44 @@ export default function SearchPage() {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const isFocused = useIsFocused();
const { settings } = useSettings(); const { settings } = useSettings();
const { jellyseerrApi } = useJellyseerr(); const { jellyseerrApi } = useJellyseerr();
// Alert when seerr server is configured but user hasn't connected (only when focused)
const jellyseerrAlertedRef = useRef(false);
useEffect(() => {
if (!isFocused || !settings?.jellyseerrServerUrl || jellyseerrApi) return;
if (jellyseerrAlertedRef.current) return;
jellyseerrAlertedRef.current = true;
Alert.alert(
t("jellyseerr.connect_to_jellyseerr", "Connect to Jellyseerr"),
t(
"jellyseerr.connect_in_settings",
"Jellyseerr is available. Connect in Settings to enable request features.",
),
);
}, [isFocused, settings?.jellyseerrServerUrl, jellyseerrApi, t]);
// Validate jellyseerr session when switching to Discover
useEffect(() => {
if (
searchType !== "Discover" ||
!jellyseerrApi ||
!settings?.jellyseerrServerUrl
)
return;
validateJellyseerrSession(settings.jellyseerrServerUrl).then((status) => {
if (status.valid) return;
Alert.alert(
t("jellyseerr.session_expired", "Session expired"),
t(
"jellyseerr.session_expired_connect_again",
"Your Jellyseerr session has expired. Please reconnect in Settings.",
),
);
});
}, [searchType, jellyseerrApi, settings?.jellyseerrServerUrl, t]);
const [jellyseerrOrderBy, setJellyseerrOrderBy] = const [jellyseerrOrderBy, setJellyseerrOrderBy] =
useState<JellyseerrSearchSort>( useState<JellyseerrSearchSort>(
JellyseerrSearchSort[ JellyseerrSearchSort[

View File

@@ -3,24 +3,16 @@ import {
type NativeBottomTabNavigationEventMap, type NativeBottomTabNavigationEventMap,
type NativeBottomTabNavigationOptions, type NativeBottomTabNavigationOptions,
} from "@bottom-tabs/react-navigation"; } from "@bottom-tabs/react-navigation";
import { Stack, useSegments, withLayoutContext } from "expo-router"; import { withLayoutContext } from "expo-router";
import type { import type {
ParamListBase, ParamListBase,
TabNavigationState, TabNavigationState,
} from "expo-router/react-navigation"; } from "expo-router/react-navigation";
import { useCallback, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native"; import { Platform, View } from "react-native";
import { SystemBars } from "react-native-edge-to-edge"; import { SystemBars } from "react-native-edge-to-edge";
import type { TVNavBarTab } from "@/components/tv/TVNavBar";
import { TVNavBar } from "@/components/tv/TVNavBar";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import useRouter from "@/hooks/useAppRouter"; import { useTVHomeBackHandler } from "@/hooks/useTVBackHandler";
import {
isTabRoute,
useTVHomeBackHandler,
useTVTabRootBackHandler,
} from "@/hooks/useTVBackHandler";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus"; import { eventBus } from "@/utils/eventBus";
@@ -41,107 +33,13 @@ export const NativeTabs = withLayoutContext<
NativeBottomTabNavigationEventMap NativeBottomTabNavigationEventMap
>(Navigator); >(Navigator);
const IS_ANDROID_TV = Platform.isTV && Platform.OS === "android";
function TVTabLayout() {
const { settings } = useSettings();
const { t } = useTranslation();
const segments = useSegments();
const router = useRouter();
const currentTab = segments.find(isTabRoute);
const atTabRoot = isTabRoute(segments[segments.length - 1] ?? "");
const tabs: TVNavBarTab[] = useMemo(
() =>
[
{ key: "(home)", label: t("tabs.home") },
{ key: "(search)", label: t("tabs.search") },
{ key: "(favorites)", label: t("tabs.favorites") },
!settings?.streamyStatsServerUrl || settings?.hideWatchlistsTab
? null
: { key: "(watchlists)", label: t("watchlists.title") },
{ key: "(libraries)", label: t("tabs.library") },
!settings?.showCustomMenuLinks
? null
: { key: "(custom-links)", label: t("tabs.custom_links") },
{ key: "(settings)", label: t("tabs.settings") },
].filter((tab): tab is TVNavBarTab => tab !== null),
[
settings?.streamyStatsServerUrl,
settings?.hideWatchlistsTab,
settings?.showCustomMenuLinks,
t,
],
);
const activeTabKey = currentTab ?? "(home)";
const visibleKeys = useMemo(
() => new Set(tabs.map((tab) => tab.key)),
[tabs],
);
const handleTabChange = useCallback(
(key: string) => {
if (key === currentTab) return;
if (key === "(home)") eventBus.emit("scrollToTop");
if (key === "(search)") eventBus.emit("searchTabPressed");
router.replace(`/(auth)/(tabs)/${key}`);
},
[currentTab, router],
);
const navigateHome = useCallback(() => {
router.replace("/(auth)/(tabs)/(home)");
}, [router]);
useTVTabRootBackHandler(navigateHome, atTabRoot, currentTab);
// If current tab is no longer visible (setting changed), navigate to home
useEffect(() => {
if (!visibleKeys.has(activeTabKey) && activeTabKey !== "(home)") {
router.replace("/(auth)/(tabs)/(home)");
}
}, [visibleKeys, activeTabKey, router]);
return (
<View style={{ flex: 1 }}>
<SystemBars hidden={false} style='light' />
<Stack
screenOptions={{ headerShown: false, animation: "none" }}
initialRouteName='(home)'
>
<Stack.Screen name='index' redirect />
</Stack>
<TVNavBar
tabs={tabs}
activeTabKey={activeTabKey}
onTabChange={handleTabChange}
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
zIndex: 1000,
}}
/>
</View>
);
}
export default function TabLayout() { export default function TabLayout() {
const { settings } = useSettings(); const { settings } = useSettings();
const { t } = useTranslation(); const { t } = useTranslation();
// Must be called before any conditional return (rules of hooks) // Handle TV back button - prevent app exit when at root
useTVHomeBackHandler(); useTVHomeBackHandler();
if (IS_ANDROID_TV) {
return <TVTabLayout />;
}
return ( return (
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<SystemBars hidden={false} style='light' /> <SystemBars hidden={false} style='light' />

View File

@@ -379,7 +379,7 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
if (items.length === 0) return null; if (items.length === 0) return null;
// Extra top padding for tvOS to clear the menu bar // Extra top padding for tvOS to clear the menu bar
const tvosTopPadding = scaleSize(145); const tvosTopPadding = Platform.OS === "ios" ? scaleSize(145) : 0;
const heroHeight = SCREEN_HEIGHT * sizes.padding.heroHeight; const heroHeight = SCREEN_HEIGHT * sizes.padding.heroHeight;
return ( return (

View File

@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
import { Animated, FlatList, Pressable, View } from "react-native"; import { Animated, FlatList, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { import {
@@ -22,8 +23,6 @@ import type {
TvResult, TvResult,
} from "@/utils/jellyseerr/server/models/Search"; } from "@/utils/jellyseerr/server/models/Search";
const SCALE_PADDING = 20;
interface TVDiscoverPosterProps { interface TVDiscoverPosterProps {
item: MovieResult | TvResult; item: MovieResult | TvResult;
isFirstItem?: boolean; isFirstItem?: boolean;
@@ -34,6 +33,7 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
isFirstItem = false, isFirstItem = false,
}) => { }) => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const router = useRouter(); const router = useRouter();
const { jellyseerrApi, getTitle, getYear } = useJellyseerr(); const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
const { focused, handleFocus, handleBlur, animatedStyle } = const { focused, handleFocus, handleBlur, animatedStyle } =
@@ -50,6 +50,8 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
item.mediaInfo?.status === MediaStatus.AVAILABLE || item.mediaInfo?.status === MediaStatus.AVAILABLE ||
item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE; item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE;
const posterWidth = sizes.posters.poster;
const handlePress = () => { const handlePress = () => {
router.push({ router.push({
pathname: "/(auth)/(tabs)/(search)/jellyseerr/page", pathname: "/(auth)/(tabs)/(search)/jellyseerr/page",
@@ -71,7 +73,7 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
style={[ style={[
animatedStyle, animatedStyle,
{ {
width: 210, width: posterWidth,
shadowColor: "#fff", shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 }, shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.6 : 0, shadowOpacity: focused ? 0.6 : 0,
@@ -81,9 +83,9 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
> >
<View <View
style={{ style={{
width: 210, width: posterWidth,
aspectRatio: 10 / 15, aspectRatio: 10 / 15,
borderRadius: 24, borderRadius: sizes.gaps.small,
overflow: "hidden", overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)", backgroundColor: "rgba(255,255,255,0.1)",
}} }}
@@ -140,12 +142,12 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
> >
{title} {title}
</Text> </Text>
{year && ( {year != null && (
<Text <Text
style={{ style={{
fontSize: typography.callout, fontSize: typography.callout,
color: "#9CA3AF", color: "#9CA3AF",
marginTop: 2, marginTop: sizes.gaps.small,
}} }}
> >
{year} {year}
@@ -166,6 +168,7 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
isFirstSlide = false, isFirstSlide = false,
}) => { }) => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const { t } = useTranslation(); const { t } = useTranslation();
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr(); const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
@@ -231,14 +234,14 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
if (!flatData || flatData.length === 0) return null; if (!flatData || flatData.length === 0) return null;
return ( return (
<View style={{ marginBottom: 24 }}> <View style={{ marginBottom: sizes.gaps.section }}>
<Text <Text
style={{ style={{
fontSize: typography.heading, fontSize: typography.heading,
fontWeight: "bold", fontWeight: "bold",
color: "#FFFFFF", color: "#FFFFFF",
marginBottom: 16, marginBottom: sizes.gaps.small,
marginLeft: SCALE_PADDING, marginLeft: sizes.padding.scale,
}} }}
> >
{slideTitle} {slideTitle}
@@ -249,9 +252,9 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
keyExtractor={(item) => item.id.toString()} keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={{ contentContainerStyle={{
paddingHorizontal: SCALE_PADDING, paddingHorizontal: sizes.padding.scale,
paddingVertical: SCALE_PADDING, paddingVertical: sizes.padding.scale,
gap: 20, gap: sizes.gaps.item,
}} }}
style={{ overflow: "visible" }} style={{ overflow: "visible" }}
onEndReached={() => { onEndReached={() => {

View File

@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
import { Animated, FlatList, Pressable, View } from "react-native"; import { Animated, FlatList, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import { MediaStatus } from "@/utils/jellyseerr/server/constants/media"; import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
@@ -14,20 +15,21 @@ import type {
TvResult, TvResult,
} from "@/utils/jellyseerr/server/models/Search"; } from "@/utils/jellyseerr/server/models/Search";
const SCALE_PADDING = 20;
interface TVJellyseerrPosterProps { interface TVJellyseerrPosterProps {
item: MovieResult | TvResult; item: MovieResult | TvResult;
onPress: () => void; onPress: () => void;
isFirstItem?: boolean; isFirstItem?: boolean;
disabled?: boolean;
} }
const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
item, item,
onPress, onPress,
isFirstItem = false, isFirstItem = false,
disabled = false,
}) => { }) => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const { jellyseerrApi, getTitle, getYear } = useJellyseerr(); const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
const { focused, handleFocus, handleBlur, animatedStyle } = const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05 }); useTVFocusAnimation({ scaleAmount: 1.05 });
@@ -43,18 +45,22 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
item.mediaInfo?.status === MediaStatus.AVAILABLE || item.mediaInfo?.status === MediaStatus.AVAILABLE ||
item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE; item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE;
const posterWidth = sizes.posters.poster;
return ( return (
<Pressable <Pressable
onPress={onPress} onPress={onPress}
onFocus={handleFocus} onFocus={handleFocus}
onBlur={handleBlur} onBlur={handleBlur}
hasTVPreferredFocus={isFirstItem} hasTVPreferredFocus={isFirstItem && !disabled}
disabled={disabled}
focusable={!disabled}
> >
<Animated.View <Animated.View
style={[ style={[
animatedStyle, animatedStyle,
{ {
width: 210, width: posterWidth,
shadowColor: "#fff", shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 }, shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.6 : 0, shadowOpacity: focused ? 0.6 : 0,
@@ -64,9 +70,9 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
> >
<View <View
style={{ style={{
width: 210, width: posterWidth,
aspectRatio: 10 / 15, aspectRatio: 10 / 15,
borderRadius: 24, borderRadius: sizes.gaps.small,
overflow: "hidden", overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)", backgroundColor: "rgba(255,255,255,0.1)",
}} }}
@@ -117,13 +123,13 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
fontSize: typography.callout, fontSize: typography.callout,
color: "#fff", color: "#fff",
fontWeight: "600", fontWeight: "600",
marginTop: 12, marginTop: sizes.gaps.small,
}} }}
numberOfLines={2} numberOfLines={2}
> >
{title} {title}
</Text> </Text>
{year && ( {year != null && (
<Text <Text
style={{ style={{
fontSize: typography.callout, fontSize: typography.callout,
@@ -142,13 +148,16 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
interface TVJellyseerrPersonPosterProps { interface TVJellyseerrPersonPosterProps {
item: PersonResult; item: PersonResult;
onPress: () => void; onPress: () => void;
disabled?: boolean;
} }
const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
item, item,
onPress, onPress,
disabled = false,
}) => { }) => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const { jellyseerrApi } = useJellyseerr(); const { jellyseerrApi } = useJellyseerr();
const { focused, handleFocus, handleBlur, animatedStyle } = const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation(); useTVFocusAnimation();
@@ -157,13 +166,21 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
? jellyseerrApi?.imageProxy(item.profilePath, "w185") ? jellyseerrApi?.imageProxy(item.profilePath, "w185")
: null; : null;
const avatarSize = Math.round(sizes.posters.poster * 0.67);
return ( return (
<Pressable onPress={onPress} onFocus={handleFocus} onBlur={handleBlur}> <Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={disabled}
focusable={!disabled}
>
<Animated.View <Animated.View
style={[ style={[
animatedStyle, animatedStyle,
{ {
width: 160, width: avatarSize,
alignItems: "center", alignItems: "center",
shadowColor: "#fff", shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 }, shadowOffset: { width: 0, height: 0 },
@@ -174,9 +191,9 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
> >
<View <View
style={{ style={{
width: 140, width: avatarSize,
height: 140, height: avatarSize,
borderRadius: 70, borderRadius: avatarSize / 2,
overflow: "hidden", overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)", backgroundColor: "rgba(255,255,255,0.1)",
borderWidth: focused ? 3 : 0, borderWidth: focused ? 3 : 0,
@@ -198,7 +215,11 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
alignItems: "center", alignItems: "center",
}} }}
> >
<Ionicons name='person' size={56} color='rgba(255,255,255,0.4)' /> <Ionicons
name='person'
size={Math.round(avatarSize * 0.35)}
color='rgba(255,255,255,0.4)'
/>
</View> </View>
)} )}
</View> </View>
@@ -207,7 +228,7 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
fontSize: typography.callout, fontSize: typography.callout,
color: focused ? "#fff" : "rgba(255,255,255,0.9)", color: focused ? "#fff" : "rgba(255,255,255,0.9)",
fontWeight: "600", fontWeight: "600",
marginTop: 12, marginTop: sizes.gaps.small,
textAlign: "center", textAlign: "center",
}} }}
numberOfLines={2} numberOfLines={2}
@@ -223,6 +244,7 @@ interface TVJellyseerrMovieSectionProps {
title: string; title: string;
items: MovieResult[]; items: MovieResult[];
isFirstSection?: boolean; isFirstSection?: boolean;
disabled?: boolean;
onItemPress: (item: MovieResult) => void; onItemPress: (item: MovieResult) => void;
} }
@@ -230,20 +252,22 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
title, title,
items, items,
isFirstSection = false, isFirstSection = false,
disabled = false,
onItemPress, onItemPress,
}) => { }) => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
if (!items || items.length === 0) return null; if (!items || items.length === 0) return null;
return ( return (
<View style={{ marginBottom: 24 }}> <View style={{ marginBottom: sizes.gaps.section }}>
<Text <Text
style={{ style={{
fontSize: typography.heading, fontSize: typography.heading,
fontWeight: "bold", fontWeight: "bold",
color: "#FFFFFF", color: "#FFFFFF",
marginBottom: 16, marginBottom: sizes.gaps.small,
marginLeft: SCALE_PADDING, marginLeft: sizes.padding.scale,
}} }}
> >
{title} {title}
@@ -254,9 +278,9 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
keyExtractor={(item) => item.id.toString()} keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={{ contentContainerStyle={{
paddingHorizontal: SCALE_PADDING, paddingHorizontal: sizes.padding.scale,
paddingVertical: SCALE_PADDING, paddingVertical: sizes.padding.scale,
gap: 20, gap: sizes.gaps.item,
}} }}
style={{ overflow: "visible" }} style={{ overflow: "visible" }}
renderItem={({ item, index }) => ( renderItem={({ item, index }) => (
@@ -264,6 +288,7 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
item={item} item={item}
onPress={() => onItemPress(item)} onPress={() => onItemPress(item)}
isFirstItem={isFirstSection && index === 0} isFirstItem={isFirstSection && index === 0}
disabled={disabled}
/> />
)} )}
/> />
@@ -275,6 +300,7 @@ interface TVJellyseerrTvSectionProps {
title: string; title: string;
items: TvResult[]; items: TvResult[];
isFirstSection?: boolean; isFirstSection?: boolean;
disabled?: boolean;
onItemPress: (item: TvResult) => void; onItemPress: (item: TvResult) => void;
} }
@@ -282,20 +308,22 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
title, title,
items, items,
isFirstSection = false, isFirstSection = false,
disabled = false,
onItemPress, onItemPress,
}) => { }) => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
if (!items || items.length === 0) return null; if (!items || items.length === 0) return null;
return ( return (
<View style={{ marginBottom: 24 }}> <View style={{ marginBottom: sizes.gaps.section }}>
<Text <Text
style={{ style={{
fontSize: typography.heading, fontSize: typography.heading,
fontWeight: "bold", fontWeight: "bold",
color: "#FFFFFF", color: "#FFFFFF",
marginBottom: 16, marginBottom: sizes.gaps.small,
marginLeft: SCALE_PADDING, marginLeft: sizes.padding.scale,
}} }}
> >
{title} {title}
@@ -306,9 +334,9 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
keyExtractor={(item) => item.id.toString()} keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={{ contentContainerStyle={{
paddingHorizontal: SCALE_PADDING, paddingHorizontal: sizes.padding.scale,
paddingVertical: SCALE_PADDING, paddingVertical: sizes.padding.scale,
gap: 20, gap: sizes.gaps.item,
}} }}
style={{ overflow: "visible" }} style={{ overflow: "visible" }}
renderItem={({ item, index }) => ( renderItem={({ item, index }) => (
@@ -316,6 +344,7 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
item={item} item={item}
onPress={() => onItemPress(item)} onPress={() => onItemPress(item)}
isFirstItem={isFirstSection && index === 0} isFirstItem={isFirstSection && index === 0}
disabled={disabled}
/> />
)} )}
/> />
@@ -327,6 +356,7 @@ interface TVJellyseerrPersonSectionProps {
title: string; title: string;
items: PersonResult[]; items: PersonResult[];
isFirstSection?: boolean; isFirstSection?: boolean;
disabled?: boolean;
onItemPress: (item: PersonResult) => void; onItemPress: (item: PersonResult) => void;
} }
@@ -334,20 +364,22 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
title, title,
items, items,
isFirstSection: _isFirstSection = false, isFirstSection: _isFirstSection = false,
disabled = false,
onItemPress, onItemPress,
}) => { }) => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
if (!items || items.length === 0) return null; if (!items || items.length === 0) return null;
return ( return (
<View style={{ marginBottom: 24 }}> <View style={{ marginBottom: sizes.gaps.section }}>
<Text <Text
style={{ style={{
fontSize: typography.heading, fontSize: typography.heading,
fontWeight: "bold", fontWeight: "bold",
color: "#FFFFFF", color: "#FFFFFF",
marginBottom: 16, marginBottom: sizes.gaps.small,
marginLeft: SCALE_PADDING, marginLeft: sizes.padding.scale,
}} }}
> >
{title} {title}
@@ -358,15 +390,16 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
keyExtractor={(item) => item.id.toString()} keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={{ contentContainerStyle={{
paddingHorizontal: SCALE_PADDING, paddingHorizontal: sizes.padding.scale,
paddingVertical: SCALE_PADDING, paddingVertical: sizes.padding.scale,
gap: 20, gap: sizes.gaps.item,
}} }}
style={{ overflow: "visible" }} style={{ overflow: "visible" }}
renderItem={({ item }) => ( renderItem={({ item }) => (
<TVJellyseerrPersonPoster <TVJellyseerrPersonPoster
item={item} item={item}
onPress={() => onItemPress(item)} onPress={() => onItemPress(item)}
disabled={disabled}
/> />
)} )}
/> />
@@ -384,6 +417,7 @@ export interface TVJellyseerrSearchResultsProps {
onMoviePress: (item: MovieResult) => void; onMoviePress: (item: MovieResult) => void;
onTvPress: (item: TvResult) => void; onTvPress: (item: TvResult) => void;
onPersonPress: (item: PersonResult) => void; onPersonPress: (item: PersonResult) => void;
disabled?: boolean;
} }
export const TVJellyseerrSearchResults: React.FC< export const TVJellyseerrSearchResults: React.FC<
@@ -398,8 +432,10 @@ export const TVJellyseerrSearchResults: React.FC<
onMoviePress, onMoviePress,
onTvPress, onTvPress,
onPersonPress, onPersonPress,
disabled = false,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const typography = useScaledTVTypography();
if (loading) { if (loading) {
return null; return null;
@@ -410,7 +446,7 @@ export const TVJellyseerrSearchResults: React.FC<
<View style={{ alignItems: "center", paddingTop: 40 }}> <View style={{ alignItems: "center", paddingTop: 40 }}>
<Text <Text
style={{ style={{
fontSize: 24, fontSize: typography.heading,
fontWeight: "bold", fontWeight: "bold",
color: "#FFFFFF", color: "#FFFFFF",
marginBottom: 8, marginBottom: 8,
@@ -418,7 +454,9 @@ export const TVJellyseerrSearchResults: React.FC<
> >
{t("search.no_results_found_for")} {t("search.no_results_found_for")}
</Text> </Text>
<Text style={{ fontSize: 18, color: "rgba(255,255,255,0.6)" }}> <Text
style={{ fontSize: typography.body, color: "rgba(255,255,255,0.6)" }}
>
"{searchQuery}" "{searchQuery}"
</Text> </Text>
</View> </View>
@@ -435,18 +473,21 @@ export const TVJellyseerrSearchResults: React.FC<
title={t("search.request_movies")} title={t("search.request_movies")}
items={movieResults} items={movieResults}
isFirstSection={false} isFirstSection={false}
disabled={disabled}
onItemPress={onMoviePress} onItemPress={onMoviePress}
/> />
<TVJellyseerrTvSection <TVJellyseerrTvSection
title={t("search.request_series")} title={t("search.request_series")}
items={tvResults} items={tvResults}
isFirstSection={false} isFirstSection={false}
disabled={disabled}
onItemPress={onTvPress} onItemPress={onTvPress}
/> />
<TVJellyseerrPersonSection <TVJellyseerrPersonSection
title={t("search.actors")} title={t("search.actors")}
items={personResults} items={personResults}
isFirstSection={false} isFirstSection={false}
disabled={disabled}
onItemPress={onPersonPress} onItemPress={onPersonPress}
/> />
</View> </View>

View File

@@ -1,6 +1,6 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useMemo } from "react"; import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, ScrollView, TextInput, View } from "react-native"; import { Platform, ScrollView, TextInput, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -166,6 +166,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [isSearchFocused, setIsSearchFocused] = useState(false);
// Image URL getter for music items // Image URL getter for music items
const getImageUrl = useMemo(() => { const getImageUrl = useMemo(() => {
@@ -270,6 +271,9 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
onChangeText={setSearch} onChangeText={setSearch}
defaultValue='' defaultValue=''
autoFocus={false} autoFocus={false}
onFocus={() => setIsSearchFocused(true)}
onBlur={() => setIsSearchFocused(false)}
hasTVPreferredFocus
/> />
</View> </View>
)} )}
@@ -290,6 +294,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
searchType={searchType} searchType={searchType}
setSearchType={setSearchType} setSearchType={setSearchType}
showDiscover={showDiscover} showDiscover={showDiscover}
disabled={isSearchFocused}
/> />
</View> </View>
)} )}
@@ -316,6 +321,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
// every keystroke as results re-render. User navigates down to the // every keystroke as results re-render. User navigates down to the
// grid manually. // grid manually.
isFirstSection={false} isFirstSection={false}
disabled={isSearchFocused}
onItemPress={onItemPress} onItemPress={onItemPress}
onItemLongPress={onItemLongPress} onItemLongPress={onItemLongPress}
imageUrlGetter={ imageUrlGetter={
@@ -339,6 +345,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
loading={jellyseerrLoading} loading={jellyseerrLoading}
noResults={jellyseerrNoResults} noResults={jellyseerrNoResults}
searchQuery={debouncedSearch} searchQuery={debouncedSearch}
disabled={isSearchFocused}
onMoviePress={onJellyseerrMoviePress || (() => {})} onMoviePress={onJellyseerrMoviePress || (() => {})}
onTvPress={onJellyseerrTvPress || (() => {})} onTvPress={onJellyseerrTvPress || (() => {})}
onPersonPress={onJellyseerrPersonPress || (() => {})} onPersonPress={onJellyseerrPersonPress || (() => {})}

View File

@@ -1,148 +0,0 @@
import React from "react";
import { Animated, Pressable, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { TVPadding } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { scaleSize } from "@/utils/scaleSize";
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
export interface TVNavBarTab {
key: string;
label: string;
}
export interface TVNavBarProps {
tabs: TVNavBarTab[];
activeTabKey: string;
onTabChange: (key: string) => void;
style?: ViewStyleProp;
}
const TVNavBarTabItem: React.FC<{
label: string;
isActive: boolean;
onSelect: () => void;
onLayout: (e: {
nativeEvent: { layout: { x: number; width: number } };
}) => void;
hasTVPreferredFocus: boolean;
}> = ({ label, isActive, onSelect, onLayout, hasTVPreferredFocus }) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({
scaleAmount: 1.05,
duration: 120,
});
const bg = focused
? "rgba(255, 255, 255, 0.95)"
: isActive
? "rgba(255, 255, 255, 0.15)"
: "transparent";
const textColor = focused
? "#000"
: isActive
? "#fff"
: "rgba(255, 255, 255, 0.7)";
return (
<Pressable
onPress={onSelect}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
onLayout={onLayout}
>
<Animated.View
style={[
animatedStyle,
{
backgroundColor: bg,
borderRadius: scaleSize(24),
borderWidth: isActive && !focused ? 1 : 0,
borderColor: "rgba(255, 255, 255, 0.3)",
paddingHorizontal: scaleSize(28),
paddingVertical: scaleSize(14),
},
]}
>
<Text
style={{
fontSize: typography.heading,
color: textColor,
fontWeight: isActive || focused ? "600" : "400",
}}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
};
export const TVNavBar: React.FC<TVNavBarProps> = ({
tabs,
activeTabKey,
onTabChange,
style,
}) => {
const scrollRef = React.useRef<ScrollView>(null);
const tabLayouts = React.useRef<Record<string, { x: number; width: number }>>(
{},
);
const insets = useSafeAreaInsets();
const handleTabLayout = React.useCallback(
(key: string) =>
(e: { nativeEvent: { layout: { x: number; width: number } } }) => {
tabLayouts.current[key] = e.nativeEvent.layout;
},
[],
);
const handleTabChange = React.useCallback(
(key: string) => {
onTabChange(key);
const layout = tabLayouts.current[key];
if (layout && scrollRef.current) {
scrollRef.current.scrollTo({
x: Math.max(0, layout.x - TVPadding.horizontal / 2),
animated: true,
});
}
},
[onTabChange],
);
if (tabs.length === 0) return null;
return (
<View style={[{ paddingTop: insets.top + 16, paddingBottom: 8 }, style]}>
<ScrollView
ref={scrollRef}
horizontal
showsHorizontalScrollIndicator={false}
keyboardShouldPersistTaps='handled'
contentContainerStyle={{
flexGrow: 1,
justifyContent: "center",
gap: scaleSize(12),
}}
>
{tabs.map((tab) => (
<TVNavBarTabItem
key={tab.key}
label={tab.label}
isActive={tab.key === activeTabKey}
onSelect={() => handleTabChange(tab.key)}
onLayout={handleTabLayout(tab.key)}
hasTVPreferredFocus={tab.key === activeTabKey}
/>
))}
</ScrollView>
</View>
);
};

View File

@@ -35,8 +35,6 @@ export type { TVLanguageCardProps } from "./TVLanguageCard";
export { TVLanguageCard } from "./TVLanguageCard"; export { TVLanguageCard } from "./TVLanguageCard";
export type { TVMetadataBadgesProps } from "./TVMetadataBadges"; export type { TVMetadataBadgesProps } from "./TVMetadataBadges";
export { TVMetadataBadges } from "./TVMetadataBadges"; export { TVMetadataBadges } from "./TVMetadataBadges";
export type { TVNavBarProps, TVNavBarTab } from "./TVNavBar";
export { TVNavBar } from "./TVNavBar";
export type { TVNextEpisodeCountdownProps } from "./TVNextEpisodeCountdown"; export type { TVNextEpisodeCountdownProps } from "./TVNextEpisodeCountdown";
export { TVNextEpisodeCountdown } from "./TVNextEpisodeCountdown"; export { TVNextEpisodeCountdown } from "./TVNextEpisodeCountdown";
export type { TVOptionButtonProps } from "./TVOptionButton"; export type { TVOptionButtonProps } from "./TVOptionButton";

View File

@@ -70,6 +70,30 @@ export const clearJellyseerrStorageData = () => {
storage.remove(JELLYSEERR_COOKIES); storage.remove(JELLYSEERR_COOKIES);
}; };
export type JellyseerrSessionStatus =
| { valid: true }
| { valid: false; reason: "no_session" | "expired" };
export async function validateJellyseerrSession(
serverUrl: string,
): Promise<JellyseerrSessionStatus> {
const user = storage.get<JellyseerrUser>(JELLYSEERR_USER);
const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
if (!user || !cookies) {
return { valid: false, reason: "no_session" };
}
try {
const api = new JellyseerrApi(serverUrl);
await api.axios.get(Endpoints.API_V1 + Endpoints.STATUS);
return { valid: true };
} catch {
clearJellyseerrStorageData();
return { valid: false, reason: "expired" };
}
}
export enum Endpoints { export enum Endpoints {
STATUS = "/status", STATUS = "/status",
API_V1 = "/api/v1", API_V1 = "/api/v1",
@@ -450,7 +474,8 @@ export const useJellyseerr = () => {
clearJellyseerrStorageData(); clearJellyseerrStorageData();
setJellyseerrUser(undefined); setJellyseerrUser(undefined);
updateSettings({ jellyseerrServerUrl: undefined }); updateSettings({ jellyseerrServerUrl: undefined });
}, []); queryClient.removeQueries({ queryKey: ["search", "jellyseerr"] });
}, [queryClient]);
const requestMedia = useCallback( const requestMedia = useCallback(
(title: string, request: MediaRequestBody, onSuccess?: () => void) => { (title: string, request: MediaRequestBody, onSuccess?: () => void) => {

View File

@@ -4,42 +4,41 @@ import { Platform } from "react-native";
import { import {
disableTVMenuKeyInterception, disableTVMenuKeyInterception,
enableTVMenuKeyInterception, enableTVMenuKeyInterception,
useTVBackPress,
} from "./useTVBackPress"; } from "./useTVBackPress";
export { enableTVMenuKeyInterception } from "./useTVBackPress"; export { enableTVMenuKeyInterception } from "./useTVBackPress";
/** All tab route names used in the bottom tab navigator. */
export const TAB_ROUTES = [
"(home)",
"(search)",
"(favorites)",
"(libraries)",
"(watchlists)",
"(custom-links)",
"(settings)",
] as const;
export type TabRoute = (typeof TAB_ROUTES)[number];
/** Check if a segment string is a tab route. */
export function isTabRoute(s: string): s is TabRoute {
return (TAB_ROUTES as readonly string[]).includes(s);
}
/** /**
* Check if we're at the root of a tab * Check if we're at the root of a tab
*/ */
function isAtTabRoot(segments: string[]): boolean { function isAtTabRoot(segments: string[]): boolean {
const lastSegment = segments[segments.length - 1]; const lastSegment = segments[segments.length - 1];
return isTabRoute(lastSegment) || lastSegment === "index"; const tabNames = [
"(home)",
"(search)",
"(favorites)",
"(libraries)",
"(watchlists)",
"(settings)",
"(custom-links)",
];
return tabNames.includes(lastSegment) || lastSegment === "index";
} }
/** /**
* Get the current tab name from segments * Get the current tab name from segments
*/ */
function getCurrentTab(segments: string[]): TabRoute | undefined { function getCurrentTab(segments: string[]): string | undefined {
return segments.find(isTabRoute); return segments.find(
(s) =>
s === "(home)" ||
s === "(search)" ||
s === "(favorites)" ||
s === "(libraries)" ||
s === "(watchlists)" ||
s === "(settings)" ||
s === "(custom-links)",
);
} }
/** /**
@@ -50,6 +49,7 @@ function getCurrentTab(segments: string[]): TabRoute | undefined {
export function useTVHomeBackHandler() { export function useTVHomeBackHandler() {
const segments = useSegments(); const segments = useSegments();
// Get current state
const currentTab = getCurrentTab(segments); const currentTab = getCurrentTab(segments);
const atTabRoot = isAtTabRoot(segments); const atTabRoot = isAtTabRoot(segments);
const isOnHomeRoot = atTabRoot && currentTab === "(home)"; const isOnHomeRoot = atTabRoot && currentTab === "(home)";
@@ -65,24 +65,3 @@ export function useTVHomeBackHandler() {
enableTVMenuKeyInterception(); enableTVMenuKeyInterception();
}, [isOnHomeRoot]); }, [isOnHomeRoot]);
} }
/**
* Handles back press at a non-Home tab root on Android TV by navigating to Home.
*
* Without NativeTabs, the Stack navigator used for the Android TV nav bar has no
* built-in tab-level back handling — pressing back at a tab root would pop the
* Stack entirely and exit the tab navigator. This hook intercepts that and routes
* to Home instead.
*/
export function useTVTabRootBackHandler(
onNavigateHome: () => void,
isAtTabRoot: boolean,
currentTab: string | undefined,
) {
useTVBackPress(() => {
if (!Platform.isTV || Platform.OS !== "android") return false;
if (!isAtTabRoot || currentTab === "(home)") return false;
onNavigateHome();
return true;
}, [isAtTabRoot, currentTab, onNavigateHome]);
}

View File

@@ -1,4 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
<application> <application>
<receiver android:name=".TvRecommendationsReceiver" android:exported="true"> <receiver android:name=".TvRecommendationsReceiver" android:exported="true">
<intent-filter> <intent-filter>

View File

@@ -16,13 +16,12 @@ import androidx.tvprovider.media.tv.PreviewProgram
import androidx.tvprovider.media.tv.TvContractCompat import androidx.tvprovider.media.tv.TvContractCompat
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.security.MessageDigest
internal object TvRecommendationsPublisher { internal object TvRecommendationsPublisher {
private const val TAG = "TvRecommendations" private const val TAG = "TvRecommendations"
private const val PREFS_NAME = "StreamyfinTvRecommendations" private const val PREFS_NAME = "StreamyfinTvRecommendations"
private const val KEY_PAYLOAD = "payload" private const val KEY_PAYLOAD = "payload"
private const val KEY_CHANNEL_ID_PREFIX = "channelId_" private const val KEY_CHANNEL_ID = "channelId"
private const val KEY_PROGRAM_IDS = "programIds" private const val KEY_PROGRAM_IDS = "programIds"
private const val DEFAULT_CHANNEL_NAME = "Continue and Next Up" private const val DEFAULT_CHANNEL_NAME = "Continue and Next Up"
@@ -163,24 +162,16 @@ internal object TvRecommendationsPublisher {
val prefs = preferences(context) val prefs = preferences(context)
val programIdsJson = prefs.getString(KEY_PROGRAM_IDS, null) ?: return val programIdsJson = prefs.getString(KEY_PROGRAM_IDS, null) ?: return
try { try {
val channelMap = JSONObject(programIdsJson) val programIds = JSONObject(programIdsJson)
val channelKeys = channelMap.keys() val keys = programIds.keys()
while (channelKeys.hasNext()) { while (keys.hasNext()) {
val channelId = channelKeys.next() val key = keys.next()
val inner = channelMap.optJSONObject(channelId) ?: continue if (programIds.optLong(key, -1L) == programId) {
val providerKeys = inner.keys() programIds.remove(key)
while (providerKeys.hasNext()) {
val providerId = providerKeys.next()
if (inner.optLong(providerId, -1L) == programId) {
inner.remove(providerId)
if (inner.length() == 0) {
channelMap.remove(channelId)
}
break break
} }
} }
} prefs.edit().putString(KEY_PROGRAM_IDS, programIds.toString()).apply()
prefs.edit().putString(KEY_PROGRAM_IDS, channelMap.toString()).apply()
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "removeProgramFromPrefs(): failed to update prefs for programId=$programId", e) Log.w(TAG, "removeProgramFromPrefs(): failed to update prefs for programId=$programId", e)
} }
@@ -331,8 +322,7 @@ internal object TvRecommendationsPublisher {
private fun getOrCreateChannel(context: Context, displayName: String): Long { private fun getOrCreateChannel(context: Context, displayName: String): Long {
val prefs = preferences(context) val prefs = preferences(context)
val channelKey = getChannelKey(displayName) val existingChannelId = prefs.getLong(KEY_CHANNEL_ID, -1L)
val existingChannelId = prefs.getLong(channelKey, -1L)
val contentResolver = context.contentResolver val contentResolver = context.contentResolver
if (existingChannelId > 0L) { if (existingChannelId > 0L) {
@@ -373,7 +363,7 @@ internal object TvRecommendationsPublisher {
// Channel truly doesn't exist in provider — recreate // Channel truly doesn't exist in provider — recreate
Log.w(TAG, "getOrCreateChannel(): channelId=$existingChannelId not in provider, recreating") Log.w(TAG, "getOrCreateChannel(): channelId=$existingChannelId not in provider, recreating")
prefs.edit().remove(channelKey).apply() prefs.edit().remove(KEY_CHANNEL_ID).apply()
} }
// Create a new channel // Create a new channel
@@ -394,7 +384,6 @@ internal object TvRecommendationsPublisher {
} ?: return -1L } ?: return -1L
val channelId = ContentUris.parseId(channelUri) val channelId = ContentUris.parseId(channelUri)
prefs.edit().putLong(channelKey, channelId).apply()
TvContractCompat.requestChannelBrowsable(context, channelId) TvContractCompat.requestChannelBrowsable(context, channelId)
storeChannelLogo(context, channelId) storeChannelLogo(context, channelId)
Log.d(TAG, "getOrCreateChannel(): created new channelId=$channelId displayName=\"$displayName\"") Log.d(TAG, "getOrCreateChannel(): created new channelId=$channelId displayName=\"$displayName\"")
@@ -402,10 +391,6 @@ internal object TvRecommendationsPublisher {
return channelId return channelId
} }
private fun getChannelKey(displayName: String): String {
return KEY_CHANNEL_ID_PREFIX + displayName.hashCode()
}
private fun upsertPreviewProgram( private fun upsertPreviewProgram(
context: Context, context: Context,
channelId: Long, channelId: Long,
@@ -477,18 +462,13 @@ internal object TvRecommendationsPublisher {
} }
/** /**
* Append a stable cache key derived from the image URL. * Append a cache-busting parameter to ensure unique URIs when images change.
* The Jellyfin image URLs already include a `tag=` query param (etag) * Per Android docs: "Use unique Uris for all images... the old image will
* that changes whenever the image content changes, so a deterministic * continue to appear if you don't change the Uri."
* hash of the URL is sufficient — the param only changes when the URL
* (and therefore the image) actually changes, avoiding unnecessary
* re-downloads on every sync.
*/ */
private fun appendCacheBuster(imageUrl: String): String { private fun appendCacheBuster(imageUrl: String): String {
val digest = MessageDigest.getInstance("MD5").digest(imageUrl.toByteArray())
val hash = digest.joinToString("") { "%02x".format(it) }.substring(0, 16)
val separator = if (imageUrl.contains("?")) "&" else "?" val separator = if (imageUrl.contains("?")) "&" else "?"
return "$imageUrl${separator}_v=$hash" return "$imageUrl${separator}_t=${System.currentTimeMillis()}"
} }
private fun buildIntentUri(context: Context, deepLink: String): Uri { private fun buildIntentUri(context: Context, deepLink: String): Uri {
@@ -551,8 +531,8 @@ internal object TvRecommendationsPublisher {
return bitmap return bitmap
} }
fun getChannelId(context: Context, displayName: String = DEFAULT_CHANNEL_NAME): Long { fun getChannelId(context: Context): Long {
return preferences(context).getLong(getChannelKey(displayName), -1L) return preferences(context).getLong(KEY_CHANNEL_ID, -1L)
} }
private fun preferences(context: Context): SharedPreferences { private fun preferences(context: Context): SharedPreferences {
@@ -587,10 +567,8 @@ internal object TvRecommendationsPublisher {
Log.w(TAG, "logProviderState(): channelId=$channelId exists=false") Log.w(TAG, "logProviderState(): channelId=$channelId exists=false")
} }
} ?: Log.w(TAG, "logProviderState(): channel query returned null for channelId=$channelId") } ?: Log.w(TAG, "logProviderState(): channel query returned null for channelId=$channelId")
} catch (error: SecurityException) { } catch (error: SecurityException) {
Log.w(TAG, "logProviderState(): lost provider permission for channelId=$channelId", error) Log.w(TAG, "logProviderState(): lost provider permission for channelId=$channelId", error)
} catch (error: Exception) { }
Log.w(TAG, "logProviderState(): failed to query channelId=$channelId", error)
}
} }
} }

View File

@@ -3,24 +3,16 @@ package expo.modules.tvrecommendations
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.ContentUris
import android.util.Log import android.util.Log
import androidx.tvprovider.media.tv.TvContractCompat import androidx.tvprovider.media.tv.TvContractCompat
class TvRecommendationsReceiver : BroadcastReceiver() { class TvRecommendationsReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
when (intent.action) { if (intent.action != TvContractCompat.ACTION_INITIALIZE_PROGRAMS) {
TvContractCompat.ACTION_INITIALIZE_PROGRAMS -> { return
}
Log.d("TvRecommendations", "Handling INITIALIZE_PROGRAMS broadcast") Log.d("TvRecommendations", "Handling INITIALIZE_PROGRAMS broadcast")
TvRecommendationsPublisher.refreshFromCache(context) TvRecommendationsPublisher.refreshFromCache(context)
} }
"android.media.tv.action.PREVIEW_PROGRAM_BROWSABLE_DISABLED" -> {
val programId = intent.data?.let { ContentUris.parseId(it) } ?: -1L
if (programId > 0L) {
Log.d("TvRecommendations", "Handling PREVIEW_PROGRAM_BROWSABLE_DISABLED for programId=$programId")
TvRecommendationsPublisher.deletePreviewProgram(context, programId)
}
}
}
}
} }

View File

@@ -247,12 +247,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
} }
}, [api, secret, headers, jellyfin]); }, [api, secret, headers, jellyfin]);
useEffect(() => {
(async () => {
await refreshStreamyfinPluginSettings();
})();
}, []);
useEffect(() => { useEffect(() => {
store.set(apiAtom, api); store.set(apiAtom, api);
}, [api]); }, [api]);
@@ -553,7 +547,20 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
); );
// Refresh plugin settings // Refresh plugin settings
await refreshStreamyfinPluginSettings(); const recentPluginSettings = await refreshStreamyfinPluginSettings();
if (recentPluginSettings?.jellyseerrServerUrl?.value) {
const jellyseerrApi = new JellyseerrApi(
recentPluginSettings.jellyseerrServerUrl.value,
);
await jellyseerrApi.test().then((result) => {
if (result.isValid && result.requiresPass) {
jellyseerrApi
.login(username, password)
.then(setJellyseerrUser)
.catch(console.error);
}
});
}
} }
}, },
onError: (error) => { onError: (error) => {

View File

@@ -592,7 +592,8 @@
"continue": "Continue", "continue": "Continue",
"verifying": "Verifying...", "verifying": "Verifying...",
"login": "Login", "login": "Login",
"refresh": "Refresh" "refresh": "Refresh",
"loading": "Loading..."
}, },
"search": { "search": {
"search": "Search...", "search": "Search...",
@@ -821,7 +822,11 @@
"report_issue_button": "Report issue", "report_issue_button": "Report issue",
"request_button": "Request", "request_button": "Request",
"are_you_sure_you_want_to_request_all_seasons": "Are you sure you want to request all seasons?", "are_you_sure_you_want_to_request_all_seasons": "Are you sure you want to request all seasons?",
"failed_to_login": "Failed to log in", "failed_to_login": "Failed to Login",
"connect_to_jellyseerr": "Connect to Jellyseerr",
"session_expired": "Session expired",
"session_expired_connect_again": "Your Jellyseerr session has expired. Please reconnect in Settings.",
"connect_in_settings": "Jellyseerr is available. Connect in Settings to enable request features.",
"cast": "Cast", "cast": "Cast",
"details": "Details", "details": "Details",
"status": "Status", "status": "Status",

View File

@@ -50,7 +50,7 @@ export const getDownloadUrl = async ({
if (maxBitrate.key === "Max" && !streamDetails?.mediaSource?.TranscodingUrl) { if (maxBitrate.key === "Max" && !streamDetails?.mediaSource?.TranscodingUrl) {
console.log("Downloading item directly"); console.log("Downloading item directly");
return { return {
url: `${api.basePath}/Items/${mediaSource.Id}/Download?api_key=${api.accessToken}`, url: `${api.basePath}/Items/${item.Id}/Download?api_key=${api.accessToken}`,
mediaSource: streamDetails?.mediaSource ?? null, mediaSource: streamDetails?.mediaSource ?? null,
}; };
} }