Compare commits

..

7 Commits

Author SHA1 Message Date
Lance Chant
d3e6f6311e feat: android tv menu to tabs
Change for the android tv side to use tabs instead of the mobile menu

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-10 10:22:59 +02:00
lance chant
2597b4af49 Merge branch 'develop' into fix/android-tv-issues 2026-06-09 08:39:30 +02:00
lance chant
2ad9753957 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-09 08:37:58 +02:00
Lance Chant
c2c6bf0b45 chore: coderabbit comments
Fixing coderabbit raised issues

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-09 08:35:27 +02:00
boolemancer
1685571406 fix(downloads): Use mediaSource.Id instead of item.Id in direct download URL (#1666)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
Co-authored-by: lance chant <13349722+lancechant@users.noreply.github.com>
Co-authored-by: Gauvain <contact@uruk.dev>
2026-06-08 14:59:29 +02:00
lance chant
7f68506ceb Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-08 14:47:39 +02:00
Gauvain
36ed7539a2 ci(security): add Trivy filesystem scan to code scanning (#1644) 2026-06-08 14:05:23 +02:00
18 changed files with 480 additions and 378 deletions

60
.github/workflows/trivy-scan.yml vendored Normal file
View File

@@ -0,0 +1,60 @@
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,13 +1,12 @@
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client"; import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { 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 { useCallback, useMemo, useState } from "react"; import { 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";
@@ -22,7 +21,6 @@ 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";
@@ -52,7 +50,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, pluginSettings } = useSettings(); const { settings, updateSettings } = 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);
@@ -61,51 +59,6 @@ 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(
@@ -924,81 +877,6 @@ 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,12 +7,7 @@ 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 { import { useLocalSearchParams, useNavigation, useSegments } from "expo-router";
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 {
@@ -25,13 +20,7 @@ import {
useState, useState,
} from "react"; } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
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";
@@ -52,10 +41,7 @@ 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 { import { useJellyseerr } from "@/hooks/useJellyseerr";
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";
@@ -120,44 +106,8 @@ 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,16 +3,24 @@ import {
type NativeBottomTabNavigationEventMap, type NativeBottomTabNavigationEventMap,
type NativeBottomTabNavigationOptions, type NativeBottomTabNavigationOptions,
} from "@bottom-tabs/react-navigation"; } from "@bottom-tabs/react-navigation";
import { withLayoutContext } from "expo-router"; import { Stack, useSegments, 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 { useTVHomeBackHandler } from "@/hooks/useTVBackHandler"; import useRouter from "@/hooks/useAppRouter";
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";
@@ -33,13 +41,107 @@ 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();
// Handle TV back button - prevent app exit when at root // Must be called before any conditional return (rules of hooks)
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 = Platform.OS === "ios" ? scaleSize(145) : 0; const tvosTopPadding = scaleSize(145);
const heroHeight = SCREEN_HEIGHT * sizes.padding.heroHeight; const heroHeight = SCREEN_HEIGHT * sizes.padding.heroHeight;
return ( return (

View File

@@ -7,7 +7,6 @@ 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 {
@@ -23,6 +22,8 @@ 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;
@@ -33,7 +34,6 @@ 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,8 +50,6 @@ 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",
@@ -73,7 +71,7 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
style={[ style={[
animatedStyle, animatedStyle,
{ {
width: posterWidth, width: 210,
shadowColor: "#fff", shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 }, shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.6 : 0, shadowOpacity: focused ? 0.6 : 0,
@@ -83,9 +81,9 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
> >
<View <View
style={{ style={{
width: posterWidth, width: 210,
aspectRatio: 10 / 15, aspectRatio: 10 / 15,
borderRadius: sizes.gaps.small, borderRadius: 24,
overflow: "hidden", overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)", backgroundColor: "rgba(255,255,255,0.1)",
}} }}
@@ -142,12 +140,12 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
> >
{title} {title}
</Text> </Text>
{year != null && ( {year && (
<Text <Text
style={{ style={{
fontSize: typography.callout, fontSize: typography.callout,
color: "#9CA3AF", color: "#9CA3AF",
marginTop: sizes.gaps.small, marginTop: 2,
}} }}
> >
{year} {year}
@@ -168,7 +166,6 @@ 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();
@@ -234,14 +231,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: sizes.gaps.section }}> <View style={{ marginBottom: 24 }}>
<Text <Text
style={{ style={{
fontSize: typography.heading, fontSize: typography.heading,
fontWeight: "bold", fontWeight: "bold",
color: "#FFFFFF", color: "#FFFFFF",
marginBottom: sizes.gaps.small, marginBottom: 16,
marginLeft: sizes.padding.scale, marginLeft: SCALE_PADDING,
}} }}
> >
{slideTitle} {slideTitle}
@@ -252,9 +249,9 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
keyExtractor={(item) => item.id.toString()} keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={{ contentContainerStyle={{
paddingHorizontal: sizes.padding.scale, paddingHorizontal: SCALE_PADDING,
paddingVertical: sizes.padding.scale, paddingVertical: SCALE_PADDING,
gap: sizes.gaps.item, gap: 20,
}} }}
style={{ overflow: "visible" }} style={{ overflow: "visible" }}
onEndReached={() => { onEndReached={() => {

View File

@@ -5,7 +5,6 @@ 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";
@@ -15,21 +14,20 @@ 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 });
@@ -45,22 +43,18 @@ 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 && !disabled} hasTVPreferredFocus={isFirstItem}
disabled={disabled}
focusable={!disabled}
> >
<Animated.View <Animated.View
style={[ style={[
animatedStyle, animatedStyle,
{ {
width: posterWidth, width: 210,
shadowColor: "#fff", shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 }, shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.6 : 0, shadowOpacity: focused ? 0.6 : 0,
@@ -70,9 +64,9 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
> >
<View <View
style={{ style={{
width: posterWidth, width: 210,
aspectRatio: 10 / 15, aspectRatio: 10 / 15,
borderRadius: sizes.gaps.small, borderRadius: 24,
overflow: "hidden", overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)", backgroundColor: "rgba(255,255,255,0.1)",
}} }}
@@ -123,13 +117,13 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
fontSize: typography.callout, fontSize: typography.callout,
color: "#fff", color: "#fff",
fontWeight: "600", fontWeight: "600",
marginTop: sizes.gaps.small, marginTop: 12,
}} }}
numberOfLines={2} numberOfLines={2}
> >
{title} {title}
</Text> </Text>
{year != null && ( {year && (
<Text <Text
style={{ style={{
fontSize: typography.callout, fontSize: typography.callout,
@@ -148,16 +142,13 @@ 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();
@@ -166,21 +157,13 @@ 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 <Pressable onPress={onPress} onFocus={handleFocus} onBlur={handleBlur}>
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={disabled}
focusable={!disabled}
>
<Animated.View <Animated.View
style={[ style={[
animatedStyle, animatedStyle,
{ {
width: avatarSize, width: 160,
alignItems: "center", alignItems: "center",
shadowColor: "#fff", shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 }, shadowOffset: { width: 0, height: 0 },
@@ -191,9 +174,9 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
> >
<View <View
style={{ style={{
width: avatarSize, width: 140,
height: avatarSize, height: 140,
borderRadius: avatarSize / 2, borderRadius: 70,
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,
@@ -215,11 +198,7 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
alignItems: "center", alignItems: "center",
}} }}
> >
<Ionicons <Ionicons name='person' size={56} color='rgba(255,255,255,0.4)' />
name='person'
size={Math.round(avatarSize * 0.35)}
color='rgba(255,255,255,0.4)'
/>
</View> </View>
)} )}
</View> </View>
@@ -228,7 +207,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: sizes.gaps.small, marginTop: 12,
textAlign: "center", textAlign: "center",
}} }}
numberOfLines={2} numberOfLines={2}
@@ -244,7 +223,6 @@ interface TVJellyseerrMovieSectionProps {
title: string; title: string;
items: MovieResult[]; items: MovieResult[];
isFirstSection?: boolean; isFirstSection?: boolean;
disabled?: boolean;
onItemPress: (item: MovieResult) => void; onItemPress: (item: MovieResult) => void;
} }
@@ -252,22 +230,20 @@ 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: sizes.gaps.section }}> <View style={{ marginBottom: 24 }}>
<Text <Text
style={{ style={{
fontSize: typography.heading, fontSize: typography.heading,
fontWeight: "bold", fontWeight: "bold",
color: "#FFFFFF", color: "#FFFFFF",
marginBottom: sizes.gaps.small, marginBottom: 16,
marginLeft: sizes.padding.scale, marginLeft: SCALE_PADDING,
}} }}
> >
{title} {title}
@@ -278,9 +254,9 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
keyExtractor={(item) => item.id.toString()} keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={{ contentContainerStyle={{
paddingHorizontal: sizes.padding.scale, paddingHorizontal: SCALE_PADDING,
paddingVertical: sizes.padding.scale, paddingVertical: SCALE_PADDING,
gap: sizes.gaps.item, gap: 20,
}} }}
style={{ overflow: "visible" }} style={{ overflow: "visible" }}
renderItem={({ item, index }) => ( renderItem={({ item, index }) => (
@@ -288,7 +264,6 @@ 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}
/> />
)} )}
/> />
@@ -300,7 +275,6 @@ interface TVJellyseerrTvSectionProps {
title: string; title: string;
items: TvResult[]; items: TvResult[];
isFirstSection?: boolean; isFirstSection?: boolean;
disabled?: boolean;
onItemPress: (item: TvResult) => void; onItemPress: (item: TvResult) => void;
} }
@@ -308,22 +282,20 @@ 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: sizes.gaps.section }}> <View style={{ marginBottom: 24 }}>
<Text <Text
style={{ style={{
fontSize: typography.heading, fontSize: typography.heading,
fontWeight: "bold", fontWeight: "bold",
color: "#FFFFFF", color: "#FFFFFF",
marginBottom: sizes.gaps.small, marginBottom: 16,
marginLeft: sizes.padding.scale, marginLeft: SCALE_PADDING,
}} }}
> >
{title} {title}
@@ -334,9 +306,9 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
keyExtractor={(item) => item.id.toString()} keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={{ contentContainerStyle={{
paddingHorizontal: sizes.padding.scale, paddingHorizontal: SCALE_PADDING,
paddingVertical: sizes.padding.scale, paddingVertical: SCALE_PADDING,
gap: sizes.gaps.item, gap: 20,
}} }}
style={{ overflow: "visible" }} style={{ overflow: "visible" }}
renderItem={({ item, index }) => ( renderItem={({ item, index }) => (
@@ -344,7 +316,6 @@ 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}
/> />
)} )}
/> />
@@ -356,7 +327,6 @@ interface TVJellyseerrPersonSectionProps {
title: string; title: string;
items: PersonResult[]; items: PersonResult[];
isFirstSection?: boolean; isFirstSection?: boolean;
disabled?: boolean;
onItemPress: (item: PersonResult) => void; onItemPress: (item: PersonResult) => void;
} }
@@ -364,22 +334,20 @@ 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: sizes.gaps.section }}> <View style={{ marginBottom: 24 }}>
<Text <Text
style={{ style={{
fontSize: typography.heading, fontSize: typography.heading,
fontWeight: "bold", fontWeight: "bold",
color: "#FFFFFF", color: "#FFFFFF",
marginBottom: sizes.gaps.small, marginBottom: 16,
marginLeft: sizes.padding.scale, marginLeft: SCALE_PADDING,
}} }}
> >
{title} {title}
@@ -390,16 +358,15 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
keyExtractor={(item) => item.id.toString()} keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={{ contentContainerStyle={{
paddingHorizontal: sizes.padding.scale, paddingHorizontal: SCALE_PADDING,
paddingVertical: sizes.padding.scale, paddingVertical: SCALE_PADDING,
gap: sizes.gaps.item, gap: 20,
}} }}
style={{ overflow: "visible" }} style={{ overflow: "visible" }}
renderItem={({ item }) => ( renderItem={({ item }) => (
<TVJellyseerrPersonPoster <TVJellyseerrPersonPoster
item={item} item={item}
onPress={() => onItemPress(item)} onPress={() => onItemPress(item)}
disabled={disabled}
/> />
)} )}
/> />
@@ -417,7 +384,6 @@ 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<
@@ -432,10 +398,8 @@ 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;
@@ -446,7 +410,7 @@ export const TVJellyseerrSearchResults: React.FC<
<View style={{ alignItems: "center", paddingTop: 40 }}> <View style={{ alignItems: "center", paddingTop: 40 }}>
<Text <Text
style={{ style={{
fontSize: typography.heading, fontSize: 24,
fontWeight: "bold", fontWeight: "bold",
color: "#FFFFFF", color: "#FFFFFF",
marginBottom: 8, marginBottom: 8,
@@ -454,9 +418,7 @@ export const TVJellyseerrSearchResults: React.FC<
> >
{t("search.no_results_found_for")} {t("search.no_results_found_for")}
</Text> </Text>
<Text <Text style={{ fontSize: 18, color: "rgba(255,255,255,0.6)" }}>
style={{ fontSize: typography.body, color: "rgba(255,255,255,0.6)" }}
>
"{searchQuery}" "{searchQuery}"
</Text> </Text>
</View> </View>
@@ -473,21 +435,18 @@ 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, useState } from "react"; import { useMemo } 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,7 +166,6 @@ 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(() => {
@@ -271,9 +270,6 @@ 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>
)} )}
@@ -294,7 +290,6 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
searchType={searchType} searchType={searchType}
setSearchType={setSearchType} setSearchType={setSearchType}
showDiscover={showDiscover} showDiscover={showDiscover}
disabled={isSearchFocused}
/> />
</View> </View>
)} )}
@@ -321,7 +316,6 @@ 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={
@@ -345,7 +339,6 @@ 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 || (() => {})}

148
components/tv/TVNavBar.tsx Normal file
View File

@@ -0,0 +1,148 @@
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,6 +35,8 @@ 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,30 +70,6 @@ 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",
@@ -474,8 +450,7 @@ 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,41 +4,42 @@ 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];
const tabNames = [ return isTabRoute(lastSegment) || lastSegment === "index";
"(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[]): string | undefined { function getCurrentTab(segments: string[]): TabRoute | undefined {
return segments.find( return segments.find(isTabRoute);
(s) =>
s === "(home)" ||
s === "(search)" ||
s === "(favorites)" ||
s === "(libraries)" ||
s === "(watchlists)" ||
s === "(settings)" ||
s === "(custom-links)",
);
} }
/** /**
@@ -49,7 +50,6 @@ function getCurrentTab(segments: string[]): string | 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,3 +65,24 @@ 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,5 +1,4 @@
<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,12 +16,13 @@ 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 = "channelId" private const val KEY_CHANNEL_ID_PREFIX = "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"
@@ -162,16 +163,24 @@ 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 programIds = JSONObject(programIdsJson) val channelMap = JSONObject(programIdsJson)
val keys = programIds.keys() val channelKeys = channelMap.keys()
while (keys.hasNext()) { while (channelKeys.hasNext()) {
val key = keys.next() val channelId = channelKeys.next()
if (programIds.optLong(key, -1L) == programId) { val inner = channelMap.optJSONObject(channelId) ?: continue
programIds.remove(key) val providerKeys = inner.keys()
break while (providerKeys.hasNext()) {
val providerId = providerKeys.next()
if (inner.optLong(providerId, -1L) == programId) {
inner.remove(providerId)
if (inner.length() == 0) {
channelMap.remove(channelId)
}
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)
} }
@@ -322,7 +331,8 @@ 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 existingChannelId = prefs.getLong(KEY_CHANNEL_ID, -1L) val channelKey = getChannelKey(displayName)
val existingChannelId = prefs.getLong(channelKey, -1L)
val contentResolver = context.contentResolver val contentResolver = context.contentResolver
if (existingChannelId > 0L) { if (existingChannelId > 0L) {
@@ -363,7 +373,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(KEY_CHANNEL_ID).apply() prefs.edit().remove(channelKey).apply()
} }
// Create a new channel // Create a new channel
@@ -384,6 +394,7 @@ 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\"")
@@ -391,6 +402,10 @@ 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,
@@ -462,13 +477,18 @@ internal object TvRecommendationsPublisher {
} }
/** /**
* Append a cache-busting parameter to ensure unique URIs when images change. * Append a stable cache key derived from the image URL.
* Per Android docs: "Use unique Uris for all images... the old image will * The Jellyfin image URLs already include a `tag=` query param (etag)
* continue to appear if you don't change the Uri." * that changes whenever the image content changes, so a deterministic
* 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}_t=${System.currentTimeMillis()}" return "$imageUrl${separator}_v=$hash"
} }
private fun buildIntentUri(context: Context, deepLink: String): Uri { private fun buildIntentUri(context: Context, deepLink: String): Uri {
@@ -531,8 +551,8 @@ internal object TvRecommendationsPublisher {
return bitmap return bitmap
} }
fun getChannelId(context: Context): Long { fun getChannelId(context: Context, displayName: String = DEFAULT_CHANNEL_NAME): Long {
return preferences(context).getLong(KEY_CHANNEL_ID, -1L) return preferences(context).getLong(getChannelKey(displayName), -1L)
} }
private fun preferences(context: Context): SharedPreferences { private fun preferences(context: Context): SharedPreferences {
@@ -567,8 +587,10 @@ 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,16 +3,24 @@ 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) {
if (intent.action != TvContractCompat.ACTION_INITIALIZE_PROGRAMS) { when (intent.action) {
return TvContractCompat.ACTION_INITIALIZE_PROGRAMS -> {
Log.d("TvRecommendations", "Handling INITIALIZE_PROGRAMS broadcast")
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)
}
}
} }
Log.d("TvRecommendations", "Handling INITIALIZE_PROGRAMS broadcast")
TvRecommendationsPublisher.refreshFromCache(context)
} }
} }

View File

@@ -247,6 +247,12 @@ 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]);
@@ -547,20 +553,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
); );
// Refresh plugin settings // Refresh plugin settings
const recentPluginSettings = await refreshStreamyfinPluginSettings(); 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,8 +592,7 @@
"continue": "Continue", "continue": "Continue",
"verifying": "Verifying...", "verifying": "Verifying...",
"login": "Login", "login": "Login",
"refresh": "Refresh", "refresh": "Refresh"
"loading": "Loading..."
}, },
"search": { "search": {
"search": "Search...", "search": "Search...",
@@ -822,11 +821,7 @@
"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 Login", "failed_to_login": "Failed to log in",
"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/${item.Id}/Download?api_key=${api.accessToken}`, url: `${api.basePath}/Items/${mediaSource.Id}/Download?api_key=${api.accessToken}`,
mediaSource: streamDetails?.mediaSource ?? null, mediaSource: streamDetails?.mediaSource ?? null,
}; };
} }