mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-12 17:00:23 +01:00
fix: design
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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={[
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user