fix(tv): wrap actor page in scrollview to fix focus navigation between sections

This commit is contained in:
Fredrik Burmester
2026-01-20 22:15:00 +01:00
parent a8acdf4299
commit 506d8b14dc
7 changed files with 566 additions and 188 deletions

View File

@@ -0,0 +1,443 @@
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import { useAtomValue } from "jotai";
import { orderBy } from "lodash";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Animated,
Easing,
Pressable,
ScrollView,
StyleSheet,
TVFocusGuideView,
View,
} from "react-native";
import { Text } from "@/components/common/Text";
import { TVButton } from "@/components/tv";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { TVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useTVRequestModal } from "@/hooks/useTVRequestModal";
import { tvSeasonSelectModalAtom } from "@/utils/atoms/tvSeasonSelectModal";
import {
MediaStatus,
MediaType,
} from "@/utils/jellyseerr/server/constants/media";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import { store } from "@/utils/store";
interface TVSeasonToggleCardProps {
season: {
id: number;
seasonNumber: number;
episodeCount: number;
status: MediaStatus;
};
selected: boolean;
onToggle: () => void;
canRequest: boolean;
hasTVPreferredFocus?: boolean;
}
const TVSeasonToggleCard: React.FC<TVSeasonToggleCardProps> = ({
season,
selected,
onToggle,
canRequest,
hasTVPreferredFocus,
}) => {
const { t } = useTranslation();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.08 });
// Get status icon and color based on MediaStatus
const getStatusIcon = (): {
icon: keyof typeof MaterialCommunityIcons.glyphMap;
color: string;
} | null => {
switch (season.status) {
case MediaStatus.PROCESSING:
return { icon: "clock", color: "#6366f1" };
case MediaStatus.AVAILABLE:
return { icon: "check", color: "#22c55e" };
case MediaStatus.PENDING:
return { icon: "bell", color: "#eab308" };
case MediaStatus.PARTIALLY_AVAILABLE:
return { icon: "minus", color: "#22c55e" };
case MediaStatus.BLACKLISTED:
return { icon: "eye-off", color: "#ef4444" };
default:
return canRequest ? { icon: "plus", color: "#22c55e" } : null;
}
};
const statusInfo = getStatusIcon();
const isDisabled = !canRequest;
return (
<Pressable
onPress={canRequest ? onToggle : undefined}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={isDisabled}
focusable={!isDisabled}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={[
animatedStyle,
styles.seasonCard,
{
backgroundColor: focused
? "#FFFFFF"
: selected
? "rgba(255,255,255,0.2)"
: "rgba(255,255,255,0.08)",
borderWidth: focused ? 0 : 1,
borderColor: selected
? "rgba(255,255,255,0.4)"
: "rgba(255,255,255,0.1)",
opacity: isDisabled ? 0.5 : 1,
},
]}
>
{/* Checkmark for selected */}
<View style={styles.checkmarkContainer}>
{selected && (
<Ionicons
name='checkmark-circle'
size={24}
color={focused ? "#22c55e" : "#FFFFFF"}
/>
)}
</View>
{/* Season info */}
<View style={styles.seasonInfo}>
<Text
style={[
styles.seasonTitle,
{ color: focused ? "#000000" : "#FFFFFF" },
]}
numberOfLines={1}
>
{t("jellyseerr.season_number", {
season_number: season.seasonNumber,
})}
</Text>
<View style={styles.episodeRow}>
<Text
style={[
styles.episodeCount,
{
color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.6)",
},
]}
>
{t("jellyseerr.number_episodes", {
episode_number: season.episodeCount,
})}
</Text>
{statusInfo && (
<View
style={[
styles.statusBadge,
{ backgroundColor: statusInfo.color },
]}
>
<MaterialCommunityIcons
name={statusInfo.icon}
size={14}
color='#FFFFFF'
/>
</View>
)}
</View>
</View>
</Animated.View>
</Pressable>
);
};
export default function TVSeasonSelectModalPage() {
const router = useRouter();
const modalState = useAtomValue(tvSeasonSelectModalAtom);
const { t } = useTranslation();
const { requestMedia } = useJellyseerr();
const { showRequestModal } = useTVRequestModal();
// Selected seasons - initially select all requestable (UNKNOWN status) seasons
const [selectedSeasons, setSelectedSeasons] = useState<Set<number>>(
new Set(),
);
const overlayOpacity = useRef(new Animated.Value(0)).current;
const sheetTranslateY = useRef(new Animated.Value(200)).current;
// Initialize selected seasons when modal state changes
useEffect(() => {
if (modalState?.seasons) {
const requestableSeasons = modalState.seasons
.filter((s) => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0)
.map((s) => s.seasonNumber);
setSelectedSeasons(new Set(requestableSeasons));
}
}, [modalState?.seasons]);
// Animate in on mount
useEffect(() => {
overlayOpacity.setValue(0);
sheetTranslateY.setValue(200);
Animated.parallel([
Animated.timing(overlayOpacity, {
toValue: 1,
duration: 250,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(sheetTranslateY, {
toValue: 0,
duration: 300,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
]).start();
return () => {
store.set(tvSeasonSelectModalAtom, null);
};
}, [overlayOpacity, sheetTranslateY]);
// Sort seasons by season number (ascending)
const sortedSeasons = useMemo(() => {
if (!modalState?.seasons) return [];
return orderBy(
modalState.seasons.filter((s) => s.seasonNumber !== 0),
"seasonNumber",
"asc",
);
}, [modalState?.seasons]);
// Find the index of the first requestable season for initial focus
const firstRequestableIndex = useMemo(() => {
return sortedSeasons.findIndex((s) => s.status === MediaStatus.UNKNOWN);
}, [sortedSeasons]);
const handleToggleSeason = useCallback((seasonNumber: number) => {
setSelectedSeasons((prev) => {
const newSet = new Set(prev);
if (newSet.has(seasonNumber)) {
newSet.delete(seasonNumber);
} else {
newSet.add(seasonNumber);
}
return newSet;
});
}, []);
const handleRequestSelected = useCallback(() => {
if (!modalState || selectedSeasons.size === 0) return;
const seasonsArray = Array.from(selectedSeasons);
const body: MediaRequestBody = {
mediaId: modalState.mediaId,
mediaType: MediaType.TV,
tvdbId: modalState.tvdbId,
seasons: seasonsArray,
};
if (modalState.hasAdvancedRequestPermission) {
// Close this modal and open the advanced request modal
router.back();
showRequestModal({
requestBody: body,
title: modalState.title,
id: modalState.mediaId,
mediaType: MediaType.TV,
onRequested: modalState.onRequested,
});
return;
}
// Build the title based on selected seasons
const seasonTitle =
seasonsArray.length === 1
? t("jellyseerr.season_number", { season_number: seasonsArray[0] })
: seasonsArray.length === sortedSeasons.length
? t("jellyseerr.season_all")
: t("jellyseerr.n_selected", { count: seasonsArray.length });
requestMedia(`${modalState.title}, ${seasonTitle}`, body, () => {
modalState.onRequested();
router.back();
});
}, [
modalState,
selectedSeasons,
sortedSeasons.length,
requestMedia,
router,
t,
showRequestModal,
]);
if (!modalState) {
return null;
}
return (
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Animated.View
style={[
styles.sheetContainer,
{ transform: [{ translateY: sheetTranslateY }] },
]}
>
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={styles.content}
>
<Text style={styles.heading}>{t("jellyseerr.select_seasons")}</Text>
<Text style={styles.subtitle}>{modalState.title}</Text>
{/* Season cards horizontal scroll */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
>
{sortedSeasons.map((season, index) => {
const canRequestSeason = season.status === MediaStatus.UNKNOWN;
return (
<TVSeasonToggleCard
key={season.id}
season={season}
selected={selectedSeasons.has(season.seasonNumber)}
onToggle={() => handleToggleSeason(season.seasonNumber)}
canRequest={canRequestSeason}
hasTVPreferredFocus={index === firstRequestableIndex}
/>
);
})}
</ScrollView>
{/* Request button */}
<View style={styles.buttonContainer}>
<TVButton
onPress={handleRequestSelected}
variant='secondary'
disabled={selectedSeasons.size === 0}
>
<Ionicons
name='add'
size={22}
color='#FFFFFF'
style={{ marginRight: 8 }}
/>
<Text style={styles.buttonText}>
{t("jellyseerr.request_selected")}
{selectedSeasons.size > 0 && ` (${selectedSeasons.size})`}
</Text>
</TVButton>
</View>
</TVFocusGuideView>
</BlurView>
</Animated.View>
</Animated.View>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
},
sheetContainer: {
width: "100%",
},
blurContainer: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
},
content: {
paddingTop: 24,
paddingBottom: 50,
paddingHorizontal: 44,
overflow: "visible",
},
heading: {
fontSize: TVTypography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
},
subtitle: {
fontSize: TVTypography.callout,
color: "rgba(255,255,255,0.6)",
marginBottom: 24,
},
scrollView: {
overflow: "visible",
},
scrollContent: {
paddingVertical: 12,
paddingHorizontal: 4,
gap: 16,
},
seasonCard: {
width: 160,
paddingVertical: 16,
paddingHorizontal: 16,
borderRadius: 12,
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.2,
shadowRadius: 8,
},
checkmarkContainer: {
height: 24,
marginBottom: 8,
},
seasonInfo: {
flex: 1,
},
seasonTitle: {
fontSize: TVTypography.callout,
fontWeight: "600",
marginBottom: 4,
},
episodeRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
episodeCount: {
fontSize: 14,
},
statusBadge: {
width: 22,
height: 22,
borderRadius: 11,
justifyContent: "center",
alignItems: "center",
},
buttonContainer: {
marginTop: 24,
},
buttonText: {
fontSize: TVTypography.callout,
fontWeight: "bold",
color: "#FFFFFF",
},
});

View File

@@ -453,6 +453,14 @@ function Layout() {
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-season-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
</Stack>
<Toaster
duration={4000}

View File

@@ -4,8 +4,7 @@ import { BlurView } from "expo-blur";
import { Image } from "expo-image";
import { LinearGradient } from "expo-linear-gradient";
import { useLocalSearchParams } from "expo-router";
import { orderBy } from "lodash";
import React, { useCallback, useMemo, useRef, useState } from "react";
import React, { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Animated,
@@ -19,7 +18,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { GenreTags } from "@/components/GenreTags";
import JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon";
import { Loader } from "@/components/Loader";
import { JellyserrRatings } from "@/components/Ratings";
import { TVButton } from "@/components/tv";
@@ -28,6 +26,7 @@ import { TVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useTVRequestModal } from "@/hooks/useTVRequestModal";
import { useTVSeasonSelectModal } from "@/hooks/useTVSeasonSelectModal";
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import {
MediaRequestStatus,
@@ -158,114 +157,6 @@ const TVCastCard: React.FC<TVCastCardProps> = ({
);
};
// Season card component
interface TVSeasonCardProps {
season: {
id: number;
seasonNumber: number;
episodeCount: number;
status: MediaStatus;
};
onPress: () => void;
canRequest: boolean;
disabled?: boolean;
onCardFocus?: () => void;
refSetter?: (ref: View | null) => void;
}
const TVSeasonCard: React.FC<TVSeasonCardProps> = ({
season,
onPress,
canRequest,
disabled = false,
onCardFocus,
refSetter,
}) => {
const { t } = useTranslation();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05 });
const handleCardFocus = useCallback(() => {
handleFocus();
onCardFocus?.();
}, [handleFocus, onCardFocus]);
return (
<Pressable
ref={refSetter}
onPress={canRequest ? onPress : undefined}
onFocus={handleCardFocus}
onBlur={handleBlur}
disabled={disabled || !canRequest}
focusable={!disabled && canRequest}
>
<Animated.View
style={[
animatedStyle,
{
minWidth: 180,
paddingVertical: 18,
paddingHorizontal: 32,
backgroundColor: focused
? "rgba(255,255,255,0.15)"
: "rgba(255,255,255,0.08)",
borderRadius: 12,
borderWidth: focused ? 2 : 1,
borderColor: focused
? "rgba(255,255,255,0.4)"
: "rgba(255,255,255,0.1)",
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.3 : 0,
shadowRadius: focused ? 12 : 0,
},
]}
>
<View style={{ height: 40, justifyContent: "center" }}>
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
gap: 12,
}}
>
<Text
style={{
fontSize: TVTypography.callout,
fontWeight: "600",
color: focused ? "#FFFFFF" : "rgba(255,255,255,0.9)",
}}
numberOfLines={1}
>
{t("jellyseerr.season_number", {
season_number: season.seasonNumber,
})}
</Text>
<JellyseerrStatusIcon
mediaStatus={season.status}
showRequestIcon={canRequest}
/>
</View>
<Text
style={{
fontSize: 14,
color: focused
? "rgba(255,255,255,0.8)"
: "rgba(255,255,255,0.5)",
marginTop: 4,
}}
>
{t("jellyseerr.number_episodes", {
episode_number: season.episodeCount,
})}
</Text>
</View>
</Animated.View>
</Pressable>
);
};
export const TVJellyseerrPage: React.FC = () => {
const insets = useSafeAreaInsets();
const params = useLocalSearchParams();
@@ -283,15 +174,12 @@ export const TVJellyseerrPage: React.FC = () => {
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
const { showRequestModal } = useTVRequestModal();
const { showSeasonSelectModal } = useTVSeasonSelectModal();
// Refs for TVFocusGuideView destinations (useState triggers re-render when set)
const [playButtonRef, setPlayButtonRef] = useState<View | null>(null);
const [firstCastCardRef, setFirstCastCardRef] = useState<View | null>(null);
// Scroll control ref
const mainScrollRef = useRef<ScrollView>(null);
const scrollPositionRef = useRef(0);
const {
data: details,
isFetching,
@@ -352,11 +240,21 @@ export const TVJellyseerrPage: React.FC = () => {
);
}, [details, mediaType]);
const allSeasonsAvailable = useMemo(
const _allSeasonsAvailable = useMemo(
() => seasons.every((season) => season.status === MediaStatus.AVAILABLE),
[seasons],
);
// Check if there are any requestable seasons (status === UNKNOWN)
const hasRequestableSeasons = useMemo(
() =>
seasons.some(
(season) =>
season.seasonNumber !== 0 && season.status === MediaStatus.UNKNOWN,
),
[seasons],
);
// Get cast
const cast = useMemo(() => {
return details?.credits?.cast?.slice(0, 10) ?? [];
@@ -435,43 +333,6 @@ export const TVJellyseerrPage: React.FC = () => {
showRequestModal,
]);
const handleSeasonRequest = useCallback(
(seasonNumber: number) => {
const body: MediaRequestBody = {
mediaId: Number(result.id!),
mediaType: MediaType.TV,
tvdbId: details?.externalIds?.tvdbId,
seasons: [seasonNumber],
};
if (hasAdvancedRequestPermission) {
showRequestModal({
requestBody: body,
title: mediaTitle,
id: result.id!,
mediaType: MediaType.TV,
onRequested: refetch,
});
return;
}
const seasonTitle = t("jellyseerr.season_number", {
season_number: seasonNumber,
});
requestMedia(`${mediaTitle}, ${seasonTitle}`, body, refetch);
},
[
details,
result,
hasAdvancedRequestPermission,
requestMedia,
mediaTitle,
refetch,
t,
showRequestModal,
],
);
const handleRequestAll = useCallback(() => {
const body: MediaRequestBody = {
mediaId: Number(result.id!),
@@ -506,16 +367,24 @@ export const TVJellyseerrPage: React.FC = () => {
showRequestModal,
]);
// Restore scroll position when navigating within seasons section
const handleSeasonsFocus = useCallback(() => {
// Use requestAnimationFrame to restore scroll after TV focus engine scrolls
requestAnimationFrame(() => {
mainScrollRef.current?.scrollTo({
y: scrollPositionRef.current,
animated: false,
});
const handleOpenSeasonSelectModal = useCallback(() => {
showSeasonSelectModal({
seasons: seasons.filter((s) => s.seasonNumber !== 0),
title: mediaTitle,
mediaId: Number(result.id!),
tvdbId: details?.externalIds?.tvdbId,
hasAdvancedRequestPermission,
onRequested: refetch,
});
}, []);
}, [
seasons,
mediaTitle,
result,
details,
hasAdvancedRequestPermission,
refetch,
showSeasonSelectModal,
]);
const handlePlay = useCallback(() => {
const jellyfinMediaId = details?.mediaInfo?.jellyfinMediaId;
@@ -610,7 +479,6 @@ export const TVJellyseerrPage: React.FC = () => {
{/* Main content */}
<ScrollView
ref={mainScrollRef}
style={{ flex: 1 }}
contentContainerStyle={{
paddingTop: insets.top + 140,
@@ -618,10 +486,6 @@ export const TVJellyseerrPage: React.FC = () => {
paddingHorizontal: insets.left + 80,
}}
showsVerticalScrollIndicator={false}
scrollEventThrottle={16}
onScroll={(e) => {
scrollPositionRef.current = e.nativeEvent.contentOffset.y;
}}
>
{/* Top section - Poster + Content */}
<View
@@ -811,7 +675,7 @@ export const TVJellyseerrPage: React.FC = () => {
{/* Request All button for TV series */}
{mediaType === MediaType.TV &&
seasons.filter((s) => s.seasonNumber !== 0).length > 0 &&
!allSeasonsAvailable && (
hasRequestableSeasons && (
<TVButton
onPress={handleRequestAll}
variant='secondary'
@@ -844,25 +708,39 @@ export const TVJellyseerrPage: React.FC = () => {
</TVButton>
)}
{/* Individual season cards for TV series */}
{/* Request Seasons button for TV series */}
{mediaType === MediaType.TV &&
orderBy(
seasons.filter((s) => s.seasonNumber !== 0),
"seasonNumber",
"desc",
).map((season) => {
const canRequestSeason =
season.status === MediaStatus.UNKNOWN;
return (
<TVSeasonCard
key={season.id}
season={season}
onPress={() => handleSeasonRequest(season.seasonNumber)}
canRequest={canRequestSeason}
onCardFocus={handleSeasonsFocus}
/>
);
})}
seasons.filter((s) => s.seasonNumber !== 0).length > 0 &&
hasRequestableSeasons && (
<TVButton
onPress={handleOpenSeasonSelectModal}
variant='secondary'
>
<View
style={{
height: 40,
flexDirection: "row",
alignItems: "center",
}}
>
<Ionicons
name='list'
size={20}
color='#FFFFFF'
style={{ marginRight: 8 }}
/>
<Text
style={{
fontSize: TVTypography.callout,
fontWeight: "600",
color: "#FFFFFF",
}}
>
{t("jellyseerr.request_seasons")}
</Text>
</View>
</TVButton>
)}
</View>
{/* Approve/Decline for managers */}

View File

@@ -20,6 +20,7 @@ import {
Easing,
FlatList,
Pressable,
ScrollView,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -400,11 +401,14 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
</View>
{/* Main content area */}
<View
<ScrollView
style={{
flex: 1,
}}
contentContainerStyle={{
paddingTop: insets.top + TOP_PADDING,
paddingHorizontal: insets.left + HORIZONTAL_PADDING,
paddingBottom: 60,
}}
>
{/* Top section - Actor image + Info */}
@@ -607,7 +611,7 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
</Text>
)}
</View>
</View>
</ScrollView>
</View>
);
};

View File

@@ -0,0 +1,23 @@
import { useCallback } from "react";
import useRouter from "@/hooks/useAppRouter";
import {
type TVSeasonSelectModalState,
tvSeasonSelectModalAtom,
} from "@/utils/atoms/tvSeasonSelectModal";
import { store } from "@/utils/store";
type ShowSeasonSelectModalParams = NonNullable<TVSeasonSelectModalState>;
export const useTVSeasonSelectModal = () => {
const router = useRouter();
const showSeasonSelectModal = useCallback(
(params: ShowSeasonSelectModalParams) => {
store.set(tvSeasonSelectModalAtom, params);
router.push("/(auth)/tv-season-select-modal");
},
[router],
);
return { showSeasonSelectModal };
};

View File

@@ -756,6 +756,10 @@
"unknown_user": "Unknown User",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0",
"jellyseerr_test_failed": "Seerr test failed. Please try again.",

View File

@@ -0,0 +1,18 @@
import { atom } from "jotai";
import type { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
export type TVSeasonSelectModalState = {
seasons: Array<{
id: number;
seasonNumber: number;
episodeCount: number;
status: MediaStatus;
}>;
title: string;
mediaId: number;
tvdbId?: number;
hasAdvancedRequestPermission: boolean;
onRequested: () => void;
} | null;
export const tvSeasonSelectModalAtom = atom<TVSeasonSelectModalState>(null);