fix: design

This commit is contained in:
Fredrik Burmester
2026-01-20 22:15:00 +01:00
parent 0353a718f3
commit 2a9f4c2885
8 changed files with 457 additions and 323 deletions

View File

@@ -15,6 +15,7 @@ import {
useJellyseerr, useJellyseerr,
} from "@/hooks/useJellyseerr"; } from "@/hooks/useJellyseerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover"; import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider"; import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
import type { import type {
MovieResult, MovieResult,
@@ -35,7 +36,7 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
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 } =
useTVFocusAnimation({ scaleAmount: 1.08 }); useTVFocusAnimation({ scaleAmount: 1.05 });
const posterUrl = item.posterPath const posterUrl = item.posterPath
? jellyseerrApi?.imageProxy(item.posterPath, "w342") ? jellyseerrApi?.imageProxy(item.posterPath, "w342")
@@ -44,6 +45,10 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
const title = getTitle(item); const title = getTitle(item);
const year = getYear(item); const year = getYear(item);
const isInLibrary =
item.mediaInfo?.status === MediaStatus.AVAILABLE ||
item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE;
const handlePress = () => { const handlePress = () => {
router.push({ router.push({
pathname: "/(auth)/(tabs)/(search)/jellyseerr/page", pathname: "/(auth)/(tabs)/(search)/jellyseerr/page",
@@ -65,23 +70,21 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
style={[ style={[
animatedStyle, animatedStyle,
{ {
width: 180, width: 210,
shadowColor: "#fff", shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 }, shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.4 : 0, shadowOpacity: focused ? 0.6 : 0,
shadowRadius: focused ? 12 : 0, shadowRadius: focused ? 20 : 0,
}, },
]} ]}
> >
<View <View
style={{ style={{
width: 180, width: 210,
aspectRatio: 2 / 3, aspectRatio: 10 / 15,
borderRadius: 12, borderRadius: 24,
overflow: "hidden", overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)", backgroundColor: "rgba(255,255,255,0.1)",
borderWidth: focused ? 3 : 0,
borderColor: "#fff",
}} }}
> >
{posterUrl ? ( {posterUrl ? (
@@ -107,13 +110,30 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
/> />
</View> </View>
)} )}
{isInLibrary && (
<View
style={{
position: "absolute",
top: 8,
right: 8,
backgroundColor: "rgba(255,255,255,0.9)",
borderRadius: 14,
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons name='checkmark' size={18} color='black' />
</View>
)}
</View> </View>
<Text <Text
style={{ style={{
fontSize: TVTypography.callout, fontSize: TVTypography.callout,
color: focused ? "#fff" : "rgba(255,255,255,0.9)", color: "#fff",
fontWeight: "600", fontWeight: "600",
marginTop: 10, marginTop: 12,
}} }}
numberOfLines={2} numberOfLines={2}
> >
@@ -122,10 +142,9 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
{year && ( {year && (
<Text <Text
style={{ style={{
fontSize: 14, fontSize: TVTypography.callout,
color: focused color: "#9CA3AF",
? "rgba(255,255,255,0.7)" marginTop: 2,
: "rgba(255,255,255,0.5)",
}} }}
> >
{year} {year}

View File

@@ -10,7 +10,6 @@ import { useTranslation } from "react-i18next";
import { import {
Animated, Animated,
Dimensions, Dimensions,
FlatList,
Pressable, Pressable,
ScrollView, ScrollView,
TVFocusGuideView, TVFocusGuideView,
@@ -61,7 +60,6 @@ interface TVCastCardProps {
}; };
imageProxy: (path: string, size?: string) => string; imageProxy: (path: string, size?: string) => string;
onPress: () => void; onPress: () => void;
isFirst?: boolean;
refSetter?: (ref: View | null) => void; refSetter?: (ref: View | null) => void;
} }
@@ -69,7 +67,6 @@ const TVCastCard: React.FC<TVCastCardProps> = ({
person, person,
imageProxy, imageProxy,
onPress, onPress,
isFirst,
refSetter, refSetter,
}) => { }) => {
const { focused, handleFocus, handleBlur, animatedStyle } = const { focused, handleFocus, handleBlur, animatedStyle } =
@@ -85,7 +82,6 @@ const TVCastCard: React.FC<TVCastCardProps> = ({
onPress={onPress} onPress={onPress}
onFocus={handleFocus} onFocus={handleFocus}
onBlur={handleBlur} onBlur={handleBlur}
hasTVPreferredFocus={isFirst}
> >
<Animated.View <Animated.View
style={[ style={[
@@ -174,6 +170,7 @@ interface TVSeasonCardProps {
canRequest: boolean; canRequest: boolean;
disabled?: boolean; disabled?: boolean;
onCardFocus?: () => void; onCardFocus?: () => void;
refSetter?: (ref: View | null) => void;
} }
const TVSeasonCard: React.FC<TVSeasonCardProps> = ({ const TVSeasonCard: React.FC<TVSeasonCardProps> = ({
@@ -182,6 +179,7 @@ const TVSeasonCard: React.FC<TVSeasonCardProps> = ({
canRequest, canRequest,
disabled = false, disabled = false,
onCardFocus, onCardFocus,
refSetter,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { focused, handleFocus, handleBlur, animatedStyle } = const { focused, handleFocus, handleBlur, animatedStyle } =
@@ -194,6 +192,7 @@ const TVSeasonCard: React.FC<TVSeasonCardProps> = ({
return ( return (
<Pressable <Pressable
ref={refSetter}
onPress={canRequest ? onPress : undefined} onPress={canRequest ? onPress : undefined}
onFocus={handleCardFocus} onFocus={handleCardFocus}
onBlur={handleBlur} onBlur={handleBlur}
@@ -205,7 +204,8 @@ const TVSeasonCard: React.FC<TVSeasonCardProps> = ({
animatedStyle, animatedStyle,
{ {
minWidth: 180, minWidth: 180,
padding: 16, paddingVertical: 18,
paddingHorizontal: 32,
backgroundColor: focused backgroundColor: focused
? "rgba(255,255,255,0.15)" ? "rgba(255,255,255,0.15)"
: "rgba(255,255,255,0.08)", : "rgba(255,255,255,0.08)",
@@ -221,42 +221,46 @@ const TVSeasonCard: React.FC<TVSeasonCardProps> = ({
}, },
]} ]}
> >
<View <View style={{ height: 40, justifyContent: "center" }}>
style={{ <View
flexDirection: "row", style={{
justifyContent: "space-between", flexDirection: "row",
alignItems: "center", justifyContent: "space-between",
gap: 12, 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 <Text
style={{ style={{
fontSize: TVTypography.callout, fontSize: 14,
fontWeight: "600", color: focused
color: focused ? "#FFFFFF" : "rgba(255,255,255,0.9)", ? "rgba(255,255,255,0.8)"
: "rgba(255,255,255,0.5)",
marginTop: 4,
}} }}
numberOfLines={1}
> >
{t("jellyseerr.season_number", { {t("jellyseerr.number_episodes", {
season_number: season.seasonNumber, episode_number: season.episodeCount,
})} })}
</Text> </Text>
<JellyseerrStatusIcon
mediaStatus={season.status}
showRequestIcon={canRequest}
/>
</View> </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>
</Animated.View> </Animated.View>
</Pressable> </Pressable>
); );
@@ -279,9 +283,10 @@ export const TVJellyseerrPage: React.FC = () => {
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr(); const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
const { showRequestModal } = useTVRequestModal(); const { showRequestModal } = useTVRequestModal();
const [lastActionButtonRef, setLastActionButtonRef] = useState<View | null>(
null, // 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 // Scroll control ref
const mainScrollRef = useRef<ScrollView>(null); const mainScrollRef = useRef<ScrollView>(null);
@@ -757,7 +762,7 @@ export const TVJellyseerrPage: React.FC = () => {
onPress={handlePlay} onPress={handlePlay}
hasTVPreferredFocus hasTVPreferredFocus
variant='primary' variant='primary'
refSetter={!canRequest ? setLastActionButtonRef : undefined} refSetter={setPlayButtonRef}
> >
<Ionicons <Ionicons
name='play' name='play'
@@ -777,12 +782,13 @@ export const TVJellyseerrPage: React.FC = () => {
</TVButton> </TVButton>
)} )}
{canRequest && ( {/* Request button - only show for movies, TV series use Request All + season cards */}
{canRequest && mediaType === MediaType.MOVIE && (
<TVButton <TVButton
onPress={handleRequest} onPress={handleRequest}
variant='secondary' variant='secondary'
hasTVPreferredFocus={!hasJellyfinMedia} hasTVPreferredFocus={!hasJellyfinMedia}
refSetter={setLastActionButtonRef} refSetter={!hasJellyfinMedia ? setPlayButtonRef : undefined}
> >
<Ionicons <Ionicons
name='add' name='add'
@@ -801,6 +807,62 @@ export const TVJellyseerrPage: React.FC = () => {
</Text> </Text>
</TVButton> </TVButton>
)} )}
{/* Request All button for TV series */}
{mediaType === MediaType.TV &&
seasons.filter((s) => s.seasonNumber !== 0).length > 0 &&
!allSeasonsAvailable && (
<TVButton
onPress={handleRequestAll}
variant='secondary'
hasTVPreferredFocus={!hasJellyfinMedia}
refSetter={!hasJellyfinMedia ? setPlayButtonRef : undefined}
>
<View
style={{
height: 40,
flexDirection: "row",
alignItems: "center",
}}
>
<Ionicons
name='bag-add'
size={20}
color='#FFFFFF'
style={{ marginRight: 8 }}
/>
<Text
style={{
fontSize: TVTypography.callout,
fontWeight: "600",
color: "#FFFFFF",
}}
>
{t("jellyseerr.request_all")}
</Text>
</View>
</TVButton>
)}
{/* Individual season cards 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}
/>
);
})}
</View> </View>
{/* Approve/Decline for managers */} {/* Approve/Decline for managers */}
@@ -867,67 +929,6 @@ export const TVJellyseerrPage: React.FC = () => {
</View> </View>
</View> </View>
{/* Seasons section (TV shows only) */}
{mediaType === MediaType.TV &&
seasons.filter((s) => s.seasonNumber !== 0).length > 0 && (
<View style={{ marginTop: 40, marginBottom: 32 }}>
<Text
style={{
fontSize: TVTypography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 16,
}}
>
{t("item_card.seasons")}
</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{ paddingVertical: 16, gap: 16 }}
>
{!allSeasonsAvailable && (
<TVButton onPress={handleRequestAll} variant='secondary'>
<Ionicons
name='bag-add'
size={20}
color='#FFFFFF'
style={{ marginRight: 8 }}
/>
<Text
style={{
fontSize: TVTypography.callout,
fontWeight: "600",
color: "#FFFFFF",
}}
>
{t("jellyseerr.request_all")}
</Text>
</TVButton>
)}
{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}
/>
);
})}
</ScrollView>
</View>
)}
{/* Cast section */} {/* Cast section */}
{cast.length > 0 && jellyseerrApi && ( {cast.length > 0 && jellyseerrApi && (
<View style={{ marginTop: 24 }}> <View style={{ marginTop: 24 }}>
@@ -942,35 +943,51 @@ export const TVJellyseerrPage: React.FC = () => {
{t("jellyseerr.cast")} {t("jellyseerr.cast")}
</Text> </Text>
{/* Focus guide for upward navigation from cast to action buttons */} {/* Focus guides for bidirectional navigation - stacked together */}
{lastActionButtonRef && ( {/* Downward: action buttons → first cast card */}
{firstCastCardRef && (
<TVFocusGuideView <TVFocusGuideView
destinations={[lastActionButtonRef]} destinations={[firstCastCardRef]}
style={{ height: 1, width: "100%" }} style={{
height: 1,
width: SCREEN_WIDTH,
marginLeft: -(insets.left + 80),
}}
/>
)}
{/* Upward: cast → action buttons */}
{playButtonRef && (
<TVFocusGuideView
destinations={[playButtonRef]}
style={{
height: 1,
width: SCREEN_WIDTH,
marginLeft: -(insets.left + 80),
}}
/> />
)} )}
<FlatList <ScrollView
horizontal horizontal
data={cast}
keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{ contentContainerStyle={{
paddingVertical: 16, paddingVertical: 16,
gap: 28, gap: 28,
}} }}
style={{ overflow: "visible" }} >
renderItem={({ item, index }) => ( {cast.map((person, index) => (
<TVCastCard <TVCastCard
person={item} key={person.id}
person={person}
imageProxy={(path, size) => imageProxy={(path, size) =>
jellyseerrApi.imageProxy(path, size || "w185") jellyseerrApi.imageProxy(path, size || "w185")
} }
onPress={() => handleCastPress(item.id)} onPress={() => handleCastPress(person.id)}
isFirst={index === 0} refSetter={index === 0 ? setFirstCastCardRef : undefined}
/> />
)} ))}
/> </ScrollView>
</View> </View>
)} )}
</ScrollView> </ScrollView>

View File

@@ -7,6 +7,7 @@ import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { TVTypography } from "@/constants/TVTypography"; import { TVTypography } from "@/constants/TVTypography";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
import type { import type {
MovieResult, MovieResult,
PersonResult, PersonResult,
@@ -28,7 +29,7 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
}) => { }) => {
const { jellyseerrApi, getTitle, getYear } = useJellyseerr(); const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
const { focused, handleFocus, handleBlur, animatedStyle } = const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.08 }); useTVFocusAnimation({ scaleAmount: 1.05 });
const posterUrl = item.posterPath const posterUrl = item.posterPath
? jellyseerrApi?.imageProxy(item.posterPath, "w342") ? jellyseerrApi?.imageProxy(item.posterPath, "w342")
@@ -37,6 +38,10 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
const title = getTitle(item); const title = getTitle(item);
const year = getYear(item); const year = getYear(item);
const isInLibrary =
item.mediaInfo?.status === MediaStatus.AVAILABLE ||
item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE;
return ( return (
<Pressable <Pressable
onPress={onPress} onPress={onPress}
@@ -51,20 +56,18 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
width: 210, width: 210,
shadowColor: "#fff", shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 }, shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.4 : 0, shadowOpacity: focused ? 0.6 : 0,
shadowRadius: focused ? 12 : 0, shadowRadius: focused ? 20 : 0,
}, },
]} ]}
> >
<View <View
style={{ style={{
width: 210, width: 210,
aspectRatio: 2 / 3, aspectRatio: 10 / 15,
borderRadius: 12, borderRadius: 24,
overflow: "hidden", overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)", backgroundColor: "rgba(255,255,255,0.1)",
borderWidth: focused ? 3 : 0,
borderColor: "#fff",
}} }}
> >
{posterUrl ? ( {posterUrl ? (
@@ -90,13 +93,30 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
/> />
</View> </View>
)} )}
{isInLibrary && (
<View
style={{
position: "absolute",
top: 8,
right: 8,
backgroundColor: "rgba(255,255,255,0.9)",
borderRadius: 14,
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons name='checkmark' size={18} color='black' />
</View>
)}
</View> </View>
<Text <Text
style={{ style={{
fontSize: TVTypography.callout, fontSize: TVTypography.callout,
color: focused ? "#fff" : "rgba(255,255,255,0.9)", color: "#fff",
fontWeight: "600", fontWeight: "600",
marginTop: 10, marginTop: 12,
}} }}
numberOfLines={2} numberOfLines={2}
> >
@@ -105,10 +125,9 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
{year && ( {year && (
<Text <Text
style={{ style={{
fontSize: 14, fontSize: TVTypography.callout,
color: focused color: "#9CA3AF",
? "rgba(255,255,255,0.7)" marginTop: 2,
: "rgba(255,255,255,0.5)",
}} }}
> >
{year} {year}

View File

@@ -20,6 +20,8 @@ interface TVEpisodeCardProps {
onPress: () => void; onPress: () => void;
onFocus?: () => void; onFocus?: () => void;
onBlur?: () => void; onBlur?: () => void;
/** Setter function for the ref (for focus guide destinations) */
refSetter?: (ref: View | null) => void;
} }
export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({ export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
@@ -29,6 +31,7 @@ export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
onPress, onPress,
onFocus, onFocus,
onBlur, onBlur,
refSetter,
}) => { }) => {
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
@@ -71,6 +74,7 @@ export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
disabled={disabled} disabled={disabled}
onFocus={onFocus} onFocus={onFocus}
onBlur={onBlur} onBlur={onBlur}
refSetter={refSetter}
> >
<View <View
style={{ style={{

View File

@@ -17,9 +17,9 @@ import {
Animated, Animated,
Dimensions, Dimensions,
Easing, Easing,
FlatList,
Pressable, Pressable,
ScrollView, ScrollView,
TVFocusGuideView,
View, View,
} from "react-native"; } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -27,10 +27,7 @@ import { ItemImage } from "@/components/common/ItemImage";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter"; import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import { seasonIndexAtom } from "@/components/series/SeasonPicker"; import { seasonIndexAtom } from "@/components/series/SeasonPicker";
import { import { TVEpisodeCard } from "@/components/series/TVEpisodeCard";
TV_EPISODE_WIDTH,
TVEpisodeCard,
} from "@/components/series/TVEpisodeCard";
import { TVSeriesHeader } from "@/components/series/TVSeriesHeader"; import { TVSeriesHeader } from "@/components/series/TVSeriesHeader";
import { TVOptionSelector } from "@/components/tv/TVOptionSelector"; import { TVOptionSelector } from "@/components/tv/TVOptionSelector";
import { TVTypography } from "@/constants/TVTypography"; import { TVTypography } from "@/constants/TVTypography";
@@ -64,12 +61,14 @@ const TVFocusableButton: React.FC<{
hasTVPreferredFocus?: boolean; hasTVPreferredFocus?: boolean;
disabled?: boolean; disabled?: boolean;
variant?: "primary" | "secondary"; variant?: "primary" | "secondary";
refSetter?: (ref: View | null) => void;
}> = ({ }> = ({
onPress, onPress,
children, children,
hasTVPreferredFocus, hasTVPreferredFocus,
disabled = false, disabled = false,
variant = "primary", variant = "primary",
refSetter,
}) => { }) => {
const [focused, setFocused] = useState(false); const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current; const scale = useRef(new Animated.Value(1)).current;
@@ -86,6 +85,7 @@ const TVFocusableButton: React.FC<{
return ( return (
<Pressable <Pressable
ref={refSetter}
onPress={onPress} onPress={onPress}
onFocus={() => { onFocus={() => {
setFocused(true); setFocused(true);
@@ -232,17 +232,21 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
// Season selector modal state // Season selector modal state
const [isSeasonModalVisible, setIsSeasonModalVisible] = useState(false); const [isSeasonModalVisible, setIsSeasonModalVisible] = useState(false);
// Focus guide refs (using useState to trigger re-renders when refs are set)
const [playButtonRef, setPlayButtonRef] = useState<View | null>(null);
const [firstEpisodeRef, setFirstEpisodeRef] = useState<View | null>(null);
// ScrollView ref for page scrolling // ScrollView ref for page scrolling
const mainScrollRef = useRef<ScrollView>(null); const mainScrollRef = useRef<ScrollView>(null);
// FlatList ref for scrolling back // ScrollView ref for scrolling back
const episodeListRef = useRef<FlatList<BaseItemDto>>(null); const episodeListRef = useRef<ScrollView>(null);
const [focusedCount, setFocusedCount] = useState(0); const [focusedCount, setFocusedCount] = useState(0);
const prevFocusedCount = useRef(0); const prevFocusedCount = useRef(0);
// Scroll back to start when episode list loses focus // Scroll back to start when episode list loses focus
useEffect(() => { useEffect(() => {
if (prevFocusedCount.current > 0 && focusedCount === 0) { if (prevFocusedCount.current > 0 && focusedCount === 0) {
episodeListRef.current?.scrollToOffset({ offset: 0, animated: true }); episodeListRef.current?.scrollTo({ x: 0, animated: true });
// Scroll page back to top when leaving episode section // Scroll page back to top when leaving episode section
mainScrollRef.current?.scrollTo({ y: 0, animated: true }); mainScrollRef.current?.scrollTo({ y: 0, animated: true });
} }
@@ -422,37 +426,6 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
})); }));
}, [seasons, selectedSeasonIndex]); }, [seasons, selectedSeasonIndex]);
// Episode list item layout
const getItemLayout = useCallback(
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
length: TV_EPISODE_WIDTH + ITEM_GAP,
offset: (TV_EPISODE_WIDTH + ITEM_GAP) * index,
index,
}),
[],
);
// Render episode card
const renderEpisode = useCallback(
({ item: episode }: { item: BaseItemDto; index: number }) => (
<View style={{ marginRight: ITEM_GAP }}>
<TVEpisodeCard
episode={episode}
onPress={() => handleEpisodePress(episode)}
onFocus={handleEpisodeFocus}
onBlur={handleEpisodeBlur}
disabled={isSeasonModalVisible}
/>
</View>
),
[
handleEpisodePress,
handleEpisodeFocus,
handleEpisodeBlur,
isSeasonModalVisible,
],
);
// Get play button text // Get play button text
const playButtonText = useMemo(() => { const playButtonText = useMemo(() => {
if (!nextUnwatchedEpisode) return t("common.play"); if (!nextUnwatchedEpisode) return t("common.play");
@@ -574,6 +547,7 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
hasTVPreferredFocus={!isSeasonModalVisible} hasTVPreferredFocus={!isSeasonModalVisible}
disabled={isSeasonModalVisible} disabled={isSeasonModalVisible}
variant='primary' variant='primary'
refSetter={setPlayButtonRef}
> >
<Ionicons <Ionicons
name='play' name='play'
@@ -617,24 +591,48 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
{selectedSeasonName} {selectedSeasonName}
</Text> </Text>
<FlatList {/* Bidirectional focus guides - stacked together above the list */}
{/* Downward: Play button → first episode */}
{firstEpisodeRef && (
<TVFocusGuideView
destinations={[firstEpisodeRef]}
style={{ height: 1, width: "100%" }}
/>
)}
{/* Upward: episodes → Play button */}
{playButtonRef && (
<TVFocusGuideView
destinations={[playButtonRef]}
style={{ height: 1, width: "100%" }}
/>
)}
<ScrollView
ref={episodeListRef} ref={episodeListRef}
horizontal horizontal
data={episodesForSeason}
keyExtractor={(ep) => ep.Id!}
renderItem={renderEpisode}
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
initialNumToRender={5}
maxToRenderPerBatch={3}
windowSize={5}
removeClippedSubviews={false}
getItemLayout={getItemLayout}
style={{ overflow: "visible" }} style={{ overflow: "visible" }}
contentContainerStyle={{ contentContainerStyle={{
paddingVertical: SCALE_PADDING, paddingVertical: SCALE_PADDING,
paddingHorizontal: SCALE_PADDING, paddingHorizontal: SCALE_PADDING,
gap: ITEM_GAP,
}} }}
ListEmptyComponent={ >
{episodesForSeason.length > 0 ? (
episodesForSeason.map((episode, index) => (
<TVEpisodeCard
key={episode.Id}
episode={episode}
onPress={() => handleEpisodePress(episode)}
onFocus={handleEpisodeFocus}
onBlur={handleEpisodeBlur}
disabled={isSeasonModalVisible}
// Pass refSetter to first episode for focus guide destination
// Note: Do NOT use hasTVPreferredFocus on focus guide destinations
refSetter={index === 0 ? setFirstEpisodeRef : undefined}
/>
))
) : (
<Text <Text
style={{ style={{
color: "#737373", color: "#737373",
@@ -644,8 +642,8 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
> >
{t("item_card.no_episodes_for_this_season")} {t("item_card.no_episodes_for_this_season")}
</Text> </Text>
} )}
/> </ScrollView>
</View> </View>
</ScrollView> </ScrollView>

View File

@@ -12,6 +12,8 @@ export interface TVButtonProps {
scaleAmount?: number; scaleAmount?: number;
square?: boolean; square?: boolean;
refSetter?: (ref: View | null) => void; refSetter?: (ref: View | null) => void;
nextFocusDown?: number;
nextFocusUp?: number;
} }
const getButtonStyles = ( const getButtonStyles = (
@@ -59,6 +61,8 @@ export const TVButton: React.FC<TVButtonProps> = ({
scaleAmount = 1.05, scaleAmount = 1.05,
square = false, square = false,
refSetter, refSetter,
nextFocusDown,
nextFocusUp,
}) => { }) => {
const { focused, handleFocus, handleBlur, animatedStyle } = const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount }); useTVFocusAnimation({ scaleAmount });
@@ -74,6 +78,8 @@ export const TVButton: React.FC<TVButtonProps> = ({
hasTVPreferredFocus={hasTVPreferredFocus && !disabled} hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
disabled={disabled} disabled={disabled}
focusable={!disabled} focusable={!disabled}
nextFocusDown={nextFocusDown}
nextFocusUp={nextFocusUp}
> >
<Animated.View <Animated.View
style={[ style={[

View File

@@ -1,5 +1,11 @@
import React, { useRef, useState } from "react"; import React, { useRef, useState } from "react";
import { Animated, Easing, Pressable, type ViewStyle } from "react-native"; import {
Animated,
Easing,
Pressable,
View,
type ViewStyle,
} from "react-native";
export interface TVFocusablePosterProps { export interface TVFocusablePosterProps {
children: React.ReactNode; children: React.ReactNode;
@@ -11,6 +17,8 @@ export interface TVFocusablePosterProps {
onFocus?: () => void; onFocus?: () => void;
onBlur?: () => void; onBlur?: () => void;
disabled?: boolean; disabled?: boolean;
/** Setter function for the ref (for focus guide destinations) */
refSetter?: (ref: View | null) => void;
} }
export const TVFocusablePoster: React.FC<TVFocusablePosterProps> = ({ export const TVFocusablePoster: React.FC<TVFocusablePosterProps> = ({
@@ -23,6 +31,7 @@ export const TVFocusablePoster: React.FC<TVFocusablePosterProps> = ({
onFocus: onFocusProp, onFocus: onFocusProp,
onBlur: onBlurProp, onBlur: onBlurProp,
disabled = false, disabled = false,
refSetter,
}) => { }) => {
const [focused, setFocused] = useState(false); const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current; const scale = useRef(new Animated.Value(1)).current;
@@ -39,6 +48,7 @@ export const TVFocusablePoster: React.FC<TVFocusablePosterProps> = ({
return ( return (
<Pressable <Pressable
ref={refSetter}
onPress={onPress} onPress={onPress}
onFocus={() => { onFocus={() => {
setFocused(true); setFocused(true);

View File

@@ -2,6 +2,54 @@
This document explains how to use `TVFocusGuideView` to create reliable focus navigation between non-adjacent sections on Apple TV and Android TV. This document explains how to use `TVFocusGuideView` to create reliable focus navigation between non-adjacent sections on Apple TV and Android TV.
## Platform Differences (CRITICAL)
### tvOS vs Android TV
**`nextFocusUp`, `nextFocusDown`, `nextFocusLeft`, `nextFocusRight` props only work on Android TV, NOT tvOS.**
This is a [known limitation](https://github.com/react-native-tvos/react-native-tvos/issues/490). These props are documented as "only for Android" in React Native.
```typescript
// ❌ Does NOT work on tvOS (Apple TV)
<Pressable nextFocusUp={someNodeHandle} nextFocusDown={anotherNodeHandle}>
...
</Pressable>
// ✅ Works on both tvOS and Android TV
<TVFocusGuideView destinations={[targetRef]}>
...
</TVFocusGuideView>
```
**For tvOS, always use `TVFocusGuideView` with the `destinations` prop.**
## ScrollView vs FlatList for TV
**Use ScrollView instead of FlatList for horizontal lists on TV when focus navigation is critical.**
FlatList only renders visible items and manages its own recycling, which can interfere with focus navigation. ScrollView renders all items at once, providing more predictable focus behavior.
```typescript
// ❌ FlatList can cause focus issues on TV
<FlatList
horizontal
data={cast}
renderItem={({ item, index }) => <CastCard ... />}
/>
// ✅ ScrollView provides reliable focus navigation
<ScrollView horizontal>
{cast.map((person, index) => (
<CastCard key={person.id} ... />
))}
</ScrollView>
```
**When to use which:**
- **ScrollView**: Small to medium lists (< 20 items) where focus navigation must be reliable
- **FlatList**: Large lists where performance is more important than perfect focus navigation
## The Problem ## The Problem
tvOS uses a **geometric focus engine** that draws a ray in the navigation direction and finds the nearest focusable element. This works well for adjacent elements but fails when: tvOS uses a **geometric focus engine** that draws a ray in the navigation direction and finds the nearest focusable element. This works well for adjacent elements but fails when:
@@ -53,159 +101,160 @@ const [targetRef, setTargetRef] = useState<View | null>(null);
<TVFocusGuideView destinations={targetRef ? [targetRef] : []} /> <TVFocusGuideView destinations={targetRef ? [targetRef] : []} />
``` ```
## Complete Example: Bidirectional Navigation ## Bidirectional Navigation (CRITICAL PATTERN)
This example shows how to create focus navigation between a vertical list of buttons and a horizontal ScrollView of cards. When you need focus to navigate both UP and DOWN between sections, you must stack both focus guides together AND avoid `hasTVPreferredFocus` on the destination element.
### Step 1: Convert Components to forwardRef ### The Focus Flickering Problem
Any component that needs to be a focus destination must forward its ref: If you use `hasTVPreferredFocus={true}` on an element that is ALSO the destination of a focus guide, you will get **focus flickering** where focus rapidly jumps back and forth between elements.
```typescript ```typescript
const TVOptionButton = React.forwardRef< // ❌ CAUSES FOCUS FLICKERING - destination has hasTVPreferredFocus
View, <TVFocusGuideView destinations={[firstCardRef]} />
{ <ScrollView horizontal>
label: string; {items.map((item, index) => (
onPress: () => void; <Card
} ref={index === 0 ? setFirstCardRef : undefined}
>(({ label, onPress }, ref) => { hasTVPreferredFocus={index === 0} // ❌ DON'T DO THIS
return ( />
<Pressable ref={ref} onPress={onPress}> ))}
<Text>{label}</Text> </ScrollView>
</Pressable>
);
});
const TVActorCard = React.forwardRef< // ✅ CORRECT - destination does NOT have hasTVPreferredFocus
View, <TVFocusGuideView destinations={[firstCardRef]} />
{ <ScrollView horizontal>
name: string; {items.map((item, index) => (
onPress: () => void; <Card
} ref={index === 0 ? setFirstCardRef : undefined}
>(({ name, onPress }, ref) => { // No hasTVPreferredFocus - the focus guide handles directing focus here
return ( />
<Pressable ref={ref} onPress={onPress}> ))}
<Text>{name}</Text> </ScrollView>
</Pressable>
);
});
``` ```
### Step 2: Track Refs with State ### Complete Bidirectional Example
```typescript ```typescript
const MyScreen: React.FC = () => { const MyScreen: React.FC = () => {
// Track the first actor card (for downward navigation) // Track refs for focus navigation
const [firstActorRef, setFirstActorRef] = useState<View | null>(null); const [playButtonRef, setPlayButtonRef] = useState<View | null>(null);
const [firstCastCardRef, setFirstCastCardRef] = useState<View | null>(null);
// Track the last option button (for upward navigation) return (
const [lastButtonRef, setLastButtonRef] = useState<View | null>(null); <View style={{ flex: 1 }}>
{/* Action buttons section */}
<View style={{ flexDirection: "row", gap: 16 }}>
<TVButton
ref={setPlayButtonRef}
onPress={handlePlay}
hasTVPreferredFocus // OK here - this is NOT a focus guide destination
>
Play
</TVButton>
</View>
// ... {/* Cast section */}
<View>
<Text>Cast</Text>
{/* BOTH focus guides stacked together, above the list */}
{/* Downward: Play button → first cast card */}
{firstCastCardRef && (
<TVFocusGuideView
destinations={[firstCastCardRef]}
style={{ height: 1, width: "100%" }}
/>
)}
{/* Upward: cast → Play button */}
{playButtonRef && (
<TVFocusGuideView
destinations={[playButtonRef]}
style={{ height: 1, width: "100%" }}
/>
)}
{/* Use ScrollView, not FlatList, for reliable focus */}
<ScrollView horizontal style={{ overflow: "visible" }}>
{cast.map((person, index) => (
<CastCard
key={person.id}
person={person}
refSetter={index === 0 ? setFirstCastCardRef : undefined}
// ⚠️ NO hasTVPreferredFocus here - causes flickering!
/>
))}
</ScrollView>
</View>
</View>
);
}; };
``` ```
### Step 3: Place Focus Guides ### Key Rules for Bidirectional Navigation
```typescript 1. **Stack both focus guides together** - Place them adjacent to each other, above the destination list
return ( 2. **Do NOT use `hasTVPreferredFocus` on focus guide destinations** - This causes focus flickering
<View style={{ flex: 1 }}> 3. **Use ScrollView instead of FlatList** - More reliable focus behavior
{/* Option buttons */} 4. **Use `useState` for refs, not `useRef`** - Triggers re-renders when refs are set
<View>
<TVOptionButton label="Quality" onPress={...} />
<TVOptionButton label="Audio" onPress={...} />
<TVOptionButton
ref={setLastButtonRef} // Last button gets the ref
label="Subtitles"
onPress={...}
/>
</View>
{/* Focus guide: options → cast (downward navigation) */}
{firstActorRef && (
<TVFocusGuideView
destinations={[firstActorRef]}
style={{ height: 1, width: "100%" }}
/>
)}
{/* Cast section */}
<View>
<Text>Cast</Text>
{/* Focus guide: cast → options (upward navigation) */}
{lastButtonRef && (
<TVFocusGuideView
destinations={[lastButtonRef]}
style={{ height: 1, width: "100%" }}
/>
)}
<ScrollView horizontal>
{actors.map((actor, index) => (
<TVActorCard
key={actor.id}
ref={index === 0 ? setFirstActorRef : undefined} // First card gets the ref
name={actor.name}
onPress={...}
/>
))}
</ScrollView>
</View>
</View>
);
```
### Step 4: Handle Dynamic "Last" Element
When the last button varies based on conditions (e.g., subtitle button only shows if subtitles exist), compute which one is last:
```typescript
// Determine which button is last
const lastOptionButton = useMemo(() => {
if (hasSubtitles) return "subtitle";
if (hasAudio) return "audio";
return "quality";
}, [hasSubtitles, hasAudio]);
// Pass ref only to the last one
<TVOptionButton
ref={lastOptionButton === "quality" ? setLastButtonRef : undefined}
label="Quality"
onPress={...}
/>
<TVOptionButton
ref={lastOptionButton === "audio" ? setLastButtonRef : undefined}
label="Audio"
onPress={...}
/>
<TVOptionButton
ref={lastOptionButton === "subtitle" ? setLastButtonRef : undefined}
label="Subtitles"
onPress={...}
/>
```
## Focus Guide Placement ## Focus Guide Placement
The focus guide should be placed **between** the source and destination sections: The focus guides should be placed **together** above the destination section:
``` ```
┌─────────────────────────┐ ┌─────────────────────────┐
Option Buttons │ ← Source (going down) Action Buttons │ ← Source (going down)
│ [Quality] [Audio] │ │ [Play] [Request] │ Has hasTVPreferredFocus ✓
└─────────────────────────┘ └─────────────────────────┘
┌─────────────────────────┐ ┌─────────────────────────┐
│ TVFocusGuideView │ ← Invisible guide (height: 1px) │ TVFocusGuideView │ ← Downward guide
│ destinations=[actor1] │ Catches downward navigation │ destinations=[card1] │
└─────────────────────────┘
┌─────────────────────────┐
│ TVFocusGuideView │ ← Invisible guide (height: 1px)
│ destinations=[lastBtn] │ Catches upward navigation
├─────────────────────────┤ ├─────────────────────────┤
Actor Cards │ ← Destination (going down) TVFocusGuideView │ ← Upward guide
[👤] [👤] [👤] [👤] │ Source (going up) destinations=[playBtn] │ (stacked together)
└─────────────────────────┘ └─────────────────────────┘
┌─────────────────────────┐
│ Cast Cards (ScrollView)│ ← First card is destination
│ [👤] [👤] [👤] [👤] │ NO hasTVPreferredFocus ✗
└─────────────────────────┘
```
## Component Pattern with refSetter
For components that need to be focus guide destinations, use a `refSetter` callback prop:
```typescript
interface TVCastCardProps {
person: { id: number; name: string };
onPress: () => void;
refSetter?: (ref: View | null) => void;
}
const TVCastCard: React.FC<TVCastCardProps> = ({
person,
onPress,
refSetter,
}) => {
return (
<Pressable
ref={refSetter}
onPress={onPress}
// No hasTVPreferredFocus when this is a focus guide destination
>
<Text>{person.name}</Text>
</Pressable>
);
};
// Usage
<TVCastCard
person={person}
onPress={handlePress}
refSetter={index === 0 ? setFirstCastCardRef : undefined}
/>
``` ```
## Tips and Gotchas ## Tips and Gotchas
@@ -232,13 +281,25 @@ The focus guide should be placed **between** the source and destination sections
</TVFocusGuideView> </TVFocusGuideView>
``` ```
5. **Auto focus**: Use `autoFocus` to automatically focus the first focusable child: 5. **Auto focus**: Use `autoFocus` to automatically focus the first focusable child when entering a region:
```typescript ```typescript
<TVFocusGuideView autoFocus> <TVFocusGuideView autoFocus>
{/* First focusable child will receive focus */} {/* First focusable child will receive focus */}
</TVFocusGuideView> </TVFocusGuideView>
``` ```
**Warning**: Don't use `autoFocus` on a wrapper when you also have bidirectional focus guides - it can interfere with upward navigation.
## Common Mistakes
| Mistake | Result | Fix |
|---------|--------|-----|
| Using `nextFocusUp`/`nextFocusDown` props | Doesn't work on tvOS | Use `TVFocusGuideView` |
| Using FlatList for horizontal lists | Focus navigation unreliable | Use ScrollView |
| `hasTVPreferredFocus` on focus guide destination | Focus flickering loop | Remove `hasTVPreferredFocus` from destination |
| Focus guides placed separately | Focus flickering | Stack both guides together |
| Using `useRef` for focus guide refs | Focus guide doesn't update | Use `useState` |
## Reference Implementation ## Reference Implementation
See `components/ItemContent.tv.tsx` for a complete implementation of bidirectional focus navigation between playback options and the cast list. See `components/jellyseerr/tv/TVJellyseerrPage.tsx` for a complete implementation of bidirectional focus navigation between action buttons and a cast list.