fix: sheet

This commit is contained in:
Fredrik Burmester
2026-01-16 21:00:46 +01:00
parent e7ea8a2c3b
commit e1e91ea1a6
6 changed files with 720 additions and 139 deletions

View File

@@ -2,6 +2,7 @@ import { useCallback, useState } from "react";
import { Platform, RefreshControl, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Favorites } from "@/components/home/Favorites";
import { Favorites as TVFavorites } from "@/components/home/Favorites.tv";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
export default function favorites() {
@@ -15,6 +16,10 @@ export default function favorites() {
}, []);
const insets = useSafeAreaInsets();
if (Platform.isTV) {
return <TVFavorites />;
}
return (
<ScrollView
nestedScrollEnabled

View File

@@ -17,10 +17,13 @@ import React, {
import { useTranslation } from "react-i18next";
import {
Animated,
BackHandler,
Dimensions,
Easing,
Platform,
Pressable,
ScrollView,
TVFocusGuideView,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -308,11 +311,33 @@ const TVOptionSelector = <T,>({
onSelect: (value: T) => void;
onClose: () => void;
}) => {
const [isReady, setIsReady] = useState(false);
const firstCardRef = useRef<View>(null);
const initialSelectedIndex = useMemo(() => {
const idx = options.findIndex((o) => o.selected);
return idx >= 0 ? idx : 0;
}, [options]);
// Delay rendering to work around hasTVPreferredFocus timing issue
useEffect(() => {
if (visible) {
const timer = setTimeout(() => setIsReady(true), 100);
return () => clearTimeout(timer);
}
setIsReady(false);
}, [visible]);
// Programmatic focus fallback
useEffect(() => {
if (isReady && firstCardRef.current) {
const timer = setTimeout(() => {
(firstCardRef.current as any)?.requestTVFocus?.();
}, 50);
return () => clearTimeout(timer);
}
}, [isReady]);
if (!visible) return null;
return (
@@ -337,7 +362,12 @@ const TVOptionSelector = <T,>({
overflow: "hidden",
}}
>
<View
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={{
paddingTop: 24,
paddingBottom: 50,
@@ -360,42 +390,50 @@ const TVOptionSelector = <T,>({
</Text>
{/* Horizontal options */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: 48,
paddingVertical: 10,
gap: 12,
}}
>
{options.map((option, index) => (
<TVOptionCard
key={index}
label={option.label}
selected={option.selected}
hasTVPreferredFocus={index === initialSelectedIndex}
onPress={() => {
onSelect(option.value);
onClose();
}}
/>
))}
</ScrollView>
</View>
{isReady && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: 48,
paddingVertical: 10,
gap: 12,
}}
>
{options.map((option, index) => (
<TVOptionCard
key={index}
ref={
index === initialSelectedIndex ? firstCardRef : undefined
}
label={option.label}
selected={option.selected}
hasTVPreferredFocus={index === initialSelectedIndex}
onPress={() => {
onSelect(option.value);
onClose();
}}
/>
))}
</ScrollView>
)}
</TVFocusGuideView>
</BlurView>
</View>
);
};
// Option card for horizontal selector (Apple TV style)
const TVOptionCard: React.FC<{
label: string;
selected: boolean;
hasTVPreferredFocus?: boolean;
onPress: () => void;
}> = ({ label, selected, hasTVPreferredFocus, onPress }) => {
// Option card for horizontal selector (Apple TV style) - with forwardRef for programmatic focus
const TVOptionCard = React.forwardRef<
View,
{
label: string;
selected: boolean;
hasTVPreferredFocus?: boolean;
onPress: () => void;
}
>(({ label, selected, hasTVPreferredFocus, onPress }, ref) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
@@ -409,6 +447,7 @@ const TVOptionCard: React.FC<{
return (
<Pressable
ref={ref}
onPress={onPress}
onFocus={() => {
setFocused(true);
@@ -465,7 +504,7 @@ const TVOptionCard: React.FC<{
</Animated.View>
</Pressable>
);
};
});
// Button to open option selector
const TVOptionButton: React.FC<{
@@ -606,6 +645,21 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
// Modal state for option selectors
type ModalType = "audio" | "subtitle" | "mediaSource" | "quality" | null;
const [openModal, setOpenModal] = useState<ModalType>(null);
const isModalOpen = openModal !== null;
// Android TV BackHandler for closing modals
useEffect(() => {
if (Platform.OS === "android" && isModalOpen) {
const backHandler = BackHandler.addEventListener(
"hardwareBackPress",
() => {
setOpenModal(null);
return true;
},
);
return () => backHandler.remove();
}
}, [isModalOpen]);
// Get available audio tracks
const audioTracks = useMemo(() => {

View File

@@ -0,0 +1,231 @@
import type { Api } from "@jellyfin/sdk";
import type { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import heart from "@/assets/icons/heart.fill.png";
import { Text } from "@/components/common/Text";
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
import { Colors } from "@/constants/Colors";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
const HORIZONTAL_PADDING = 60;
const TOP_PADDING = 100;
const SECTION_GAP = 10;
type FavoriteTypes =
| "Series"
| "Movie"
| "Episode"
| "Video"
| "BoxSet"
| "Playlist";
type EmptyState = Record<FavoriteTypes, boolean>;
export const Favorites = () => {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const pageSize = 20;
const [emptyState, setEmptyState] = useState<EmptyState>({
Series: false,
Movie: false,
Episode: false,
Video: false,
BoxSet: false,
Playlist: false,
});
const fetchFavoritesByType = useCallback(
async (
itemType: BaseItemKind,
startIndex: number = 0,
limit: number = 20,
) => {
const response = await getItemsApi(api as Api).getItems({
userId: user?.Id,
sortBy: ["SeriesSortName", "SortName"],
sortOrder: ["Ascending"],
filters: ["IsFavorite"],
recursive: true,
fields: ["PrimaryImageAspectRatio"],
collapseBoxSetItems: false,
excludeLocationTypes: ["Virtual"],
enableTotalRecordCount: false,
startIndex: startIndex,
limit: limit,
includeItemTypes: [itemType],
});
const items = response.data.Items || [];
if (startIndex === 0) {
setEmptyState((prev) => ({
...prev,
[itemType as FavoriteTypes]: items.length === 0,
}));
}
return items;
},
[api, user],
);
useEffect(() => {
setEmptyState({
Series: false,
Movie: false,
Episode: false,
Video: false,
BoxSet: false,
Playlist: false,
});
}, [api, user]);
const areAllEmpty = () => {
const loadedCategories = Object.values(emptyState);
return (
loadedCategories.length > 0 &&
loadedCategories.every((isEmpty) => isEmpty)
);
};
const fetchFavoriteSeries = useCallback(
({ pageParam }: { pageParam: number }) =>
fetchFavoritesByType("Series", pageParam, pageSize),
[fetchFavoritesByType, pageSize],
);
const fetchFavoriteMovies = useCallback(
({ pageParam }: { pageParam: number }) =>
fetchFavoritesByType("Movie", pageParam, pageSize),
[fetchFavoritesByType, pageSize],
);
const fetchFavoriteEpisodes = useCallback(
({ pageParam }: { pageParam: number }) =>
fetchFavoritesByType("Episode", pageParam, pageSize),
[fetchFavoritesByType, pageSize],
);
const fetchFavoriteVideos = useCallback(
({ pageParam }: { pageParam: number }) =>
fetchFavoritesByType("Video", pageParam, pageSize),
[fetchFavoritesByType, pageSize],
);
const fetchFavoriteBoxsets = useCallback(
({ pageParam }: { pageParam: number }) =>
fetchFavoritesByType("BoxSet", pageParam, pageSize),
[fetchFavoritesByType, pageSize],
);
const fetchFavoritePlaylists = useCallback(
({ pageParam }: { pageParam: number }) =>
fetchFavoritesByType("Playlist", pageParam, pageSize),
[fetchFavoritesByType, pageSize],
);
if (areAllEmpty()) {
return (
<View
style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: HORIZONTAL_PADDING,
}}
>
<Image
style={{
width: 64,
height: 64,
marginBottom: 16,
tintColor: Colors.primary,
}}
contentFit='contain'
source={heart}
/>
<Text
style={{
fontSize: 32,
fontWeight: "bold",
marginBottom: 8,
color: "#FFFFFF",
}}
>
{t("favorites.noDataTitle")}
</Text>
<Text
style={{
textAlign: "center",
opacity: 0.7,
fontSize: 18,
color: "#FFFFFF",
}}
>
{t("favorites.noData")}
</Text>
</View>
);
}
return (
<ScrollView
nestedScrollEnabled
showsVerticalScrollIndicator={false}
contentContainerStyle={{
paddingTop: insets.top + TOP_PADDING,
paddingBottom: insets.bottom + 60,
paddingLeft: insets.left + HORIZONTAL_PADDING,
paddingRight: insets.right + HORIZONTAL_PADDING,
}}
>
<View style={{ gap: SECTION_GAP }}>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteSeries}
queryKey={["home", "favorites", "series"]}
title={t("favorites.series")}
hideIfEmpty
pageSize={pageSize}
isFirstSection
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteMovies}
queryKey={["home", "favorites", "movies"]}
title={t("favorites.movies")}
hideIfEmpty
orientation='vertical'
pageSize={pageSize}
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteEpisodes}
queryKey={["home", "favorites", "episodes"]}
title={t("favorites.episodes")}
hideIfEmpty
pageSize={pageSize}
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteVideos}
queryKey={["home", "favorites", "videos"]}
title={t("favorites.videos")}
hideIfEmpty
pageSize={pageSize}
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteBoxsets}
queryKey={["home", "favorites", "boxsets"]}
title={t("favorites.boxsets")}
hideIfEmpty
pageSize={pageSize}
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoritePlaylists}
queryKey={["home", "favorites", "playlists"]}
title={t("favorites.playlists")}
hideIfEmpty
pageSize={pageSize}
/>
</View>
</ScrollView>
);
};

View File

@@ -1,27 +1,235 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
import { Ionicons } from "@expo/vector-icons";
import type {
BaseItemDto,
CollectionType,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi, getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { BlurView } from "expo-blur";
import { Image } from "expo-image";
import { LinearGradient } from "expo-linear-gradient";
import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { FlatList, View } from "react-native";
import { Animated, Easing, FlatList, Pressable, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import { Loader } from "@/components/Loader";
import {
TV_LIBRARY_CARD_WIDTH,
TVLibraryCard,
} from "@/components/library/TVLibraryCard";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
const HORIZONTAL_PADDING = 60;
const ITEM_GAP = 24;
const HORIZONTAL_PADDING = 80;
const CARD_HEIGHT = 220;
const CARD_GAP = 24;
const SCALE_PADDING = 20;
type IconName = React.ComponentProps<typeof Ionicons>["name"];
const icons: Record<CollectionType, IconName> = {
movies: "film",
tvshows: "tv",
music: "musical-notes",
books: "book",
homevideos: "videocam",
boxsets: "albums",
playlists: "list",
folders: "folder",
livetv: "tv",
musicvideos: "musical-notes",
photos: "images",
trailers: "videocam",
unknown: "help-circle",
} as const;
interface LibraryWithPreview extends BaseItemDto {
previewItems?: BaseItemDto[];
itemCount?: number;
}
const TVLibraryRow: React.FC<{
library: LibraryWithPreview;
isFirst: boolean;
onPress: () => void;
}> = ({ library, isFirst, onPress }) => {
const [api] = useAtom(apiAtom);
const { t } = useTranslation();
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const opacity = useRef(new Animated.Value(0.7)).current;
const animateTo = (toScale: number, toOpacity: number) => {
Animated.parallel([
Animated.timing(scale, {
toValue: toScale,
duration: 200,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(opacity, {
toValue: toOpacity,
duration: 200,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
]).start();
};
const backdropUrl = useMemo(() => {
// Try to get backdrop from library or first preview item
if (library.previewItems?.[0]) {
return getBackdropUrl({
api,
item: library.previewItems[0],
width: 1920,
});
}
return getBackdropUrl({
api,
item: library,
width: 1920,
});
}, [api, library]);
const iconName = icons[library.CollectionType!] || "folder";
const itemTypeName = useMemo(() => {
if (library.CollectionType === "movies")
return t("library.item_types.movies");
if (library.CollectionType === "tvshows")
return t("library.item_types.series");
if (library.CollectionType === "boxsets")
return t("library.item_types.boxsets");
if (library.CollectionType === "music")
return t("library.item_types.items");
return t("library.item_types.items");
}, [library.CollectionType, t]);
return (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.02, 1);
}}
onBlur={() => {
setFocused(false);
animateTo(1, 0.7);
}}
hasTVPreferredFocus={isFirst}
>
<Animated.View
style={{
transform: [{ scale }],
opacity,
height: CARD_HEIGHT,
borderRadius: 20,
overflow: "hidden",
backgroundColor: "#1a1a1a",
borderWidth: focused ? 4 : 0,
borderColor: "#FFFFFF",
shadowColor: "#FFFFFF",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.3 : 0,
shadowRadius: focused ? 30 : 0,
}}
>
{/* Background Image */}
{backdropUrl && (
<Image
source={{ uri: backdropUrl }}
style={{
position: "absolute",
width: "100%",
height: "100%",
}}
contentFit='cover'
cachePolicy='memory-disk'
/>
)}
{/* Gradient Overlay */}
<LinearGradient
colors={["rgba(0,0,0,0.3)", "rgba(0,0,0,0.8)"]}
style={{
position: "absolute",
width: "100%",
height: "100%",
}}
/>
{/* Content */}
<View
style={{
flex: 1,
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 40,
}}
>
{/* Icon Container */}
<BlurView
intensity={60}
tint='dark'
style={{
width: 80,
height: 80,
borderRadius: 20,
justifyContent: "center",
alignItems: "center",
overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)",
}}
>
<Ionicons name={iconName} size={40} color='#FFFFFF' />
</BlurView>
{/* Text Content */}
<View style={{ marginLeft: 24, flex: 1 }}>
<Text
numberOfLines={1}
style={{
fontSize: 32,
fontWeight: "700",
color: "#FFFFFF",
textShadowColor: "rgba(0,0,0,0.8)",
textShadowOffset: { width: 0, height: 2 },
textShadowRadius: 4,
}}
>
{library.Name}
</Text>
{library.itemCount !== undefined && (
<Text
style={{
fontSize: 18,
color: "rgba(255,255,255,0.7)",
marginTop: 4,
textShadowColor: "rgba(0,0,0,0.8)",
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
}}
>
{library.itemCount} {itemTypeName}
</Text>
)}
</View>
{/* Arrow Indicator */}
<Animated.View
style={{
opacity: focused ? 1 : 0.5,
}}
>
<Ionicons name='chevron-forward' size={32} color='#FFFFFF' />
</Animated.View>
</View>
</Animated.View>
</Pressable>
);
};
export const TVLibraries: React.FC = () => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
@@ -29,85 +237,105 @@ export const TVLibraries: React.FC = () => {
const insets = useSafeAreaInsets();
const router = useRouter();
const { t } = useTranslation();
const flatListRef = useRef<FlatList<BaseItemDto>>(null);
const [focusedCount, setFocusedCount] = useState(0);
const prevFocusedCount = useRef(0);
const { data, isLoading } = useQuery({
const { data: userViews, isLoading: viewsLoading } = useQuery({
queryKey: ["user-views", user?.Id],
queryFn: async () => {
const response = await getUserViewsApi(api!).getUserViews({
userId: user?.Id,
});
return response.data.Items || null;
return response.data.Items || [];
},
staleTime: 60,
staleTime: 60 * 1000,
enabled: !!api && !!user?.Id,
});
const libraries = useMemo(
() =>
data
userViews
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
.filter((l) => l.CollectionType !== "books") || [],
[data, settings?.hiddenLibraries],
[userViews, settings?.hiddenLibraries],
);
// Scroll back to start when section loses focus
useEffect(() => {
if (prevFocusedCount.current > 0 && focusedCount === 0) {
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
}
prevFocusedCount.current = focusedCount;
}, [focusedCount]);
// Fetch item counts and preview items for each library
const { data: librariesWithData, isLoading: dataLoading } = useQuery({
queryKey: ["library-data", libraries.map((l) => l.Id).join(",")],
queryFn: async () => {
const results: LibraryWithPreview[] = await Promise.all(
libraries.map(async (library) => {
let itemType: string | undefined;
if (library.CollectionType === "movies") itemType = "Movie";
else if (library.CollectionType === "tvshows") itemType = "Series";
else if (library.CollectionType === "boxsets") itemType = "BoxSet";
const handleItemFocus = useCallback(() => {
setFocusedCount((c) => c + 1);
}, []);
// Fetch count
const countResponse = await getItemsApi(api!).getItems({
userId: user?.Id,
parentId: library.Id,
recursive: true,
limit: 0,
includeItemTypes: itemType ? [itemType as any] : undefined,
});
const handleItemBlur = useCallback(() => {
setFocusedCount((c) => Math.max(0, c - 1));
}, []);
// Fetch preview items with backdrops
const previewResponse = await getItemsApi(api!).getItems({
userId: user?.Id,
parentId: library.Id,
recursive: true,
limit: 1,
sortBy: ["Random"],
includeItemTypes: itemType ? [itemType as any] : undefined,
imageTypes: ["Backdrop"],
});
const handleItemPress = useCallback(
(item: BaseItemDto) => {
const navigation = getItemNavigation(item, "(libraries)");
router.push(navigation as any);
return {
...library,
itemCount: countResponse.data.TotalRecordCount,
previewItems: previewResponse.data.Items || [],
};
}),
);
return results;
},
enabled: !!api && !!user?.Id && libraries.length > 0,
staleTime: 60 * 1000,
});
const handleLibraryPress = useCallback(
(library: BaseItemDto) => {
if (library.CollectionType === "music") {
router.push({
pathname: `/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions`,
params: { libraryId: library.Id! },
});
} else {
router.push({
pathname: "/(auth)/(tabs)/(libraries)/[libraryId]",
params: { libraryId: library.Id! },
});
}
},
[router],
);
const getItemLayout = useCallback(
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
length: TV_LIBRARY_CARD_WIDTH + ITEM_GAP,
offset: (TV_LIBRARY_CARD_WIDTH + ITEM_GAP) * index,
index,
}),
[],
);
const renderItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => {
const isFirstItem = index === 0;
return (
<View style={{ marginRight: ITEM_GAP }}>
<TVFocusablePoster
onPress={() => handleItemPress(item)}
hasTVPreferredFocus={isFirstItem}
onFocus={handleItemFocus}
onBlur={handleItemBlur}
>
<TVLibraryCard library={item} />
</TVFocusablePoster>
</View>
);
},
[handleItemPress, handleItemFocus, handleItemBlur],
({ item, index }: { item: LibraryWithPreview; index: number }) => (
<View style={{ marginBottom: CARD_GAP, paddingHorizontal: 8 }}>
<TVLibraryRow
library={item}
isFirst={index === 0}
onPress={() => handleLibraryPress(item)}
/>
</View>
),
[handleLibraryPress],
);
if (isLoading) {
const isLoading = viewsLoading || dataLoading;
const displayLibraries = librariesWithData || libraries;
if (isLoading && libraries.length === 0) {
return (
<View
style={{
@@ -121,7 +349,7 @@ export const TVLibraries: React.FC = () => {
);
}
if (!libraries || libraries.length === 0) {
if (!displayLibraries || displayLibraries.length === 0) {
return (
<View
style={{
@@ -141,23 +369,20 @@ export const TVLibraries: React.FC = () => {
<View
style={{
flex: 1,
justifyContent: "center",
paddingLeft: insets.left + HORIZONTAL_PADDING,
paddingRight: insets.right + HORIZONTAL_PADDING,
paddingTop: insets.top + 80,
paddingBottom: insets.bottom + 40,
}}
>
<FlatList
ref={flatListRef}
horizontal
data={libraries}
data={displayLibraries}
keyExtractor={(item) => item.Id || ""}
renderItem={renderItem}
showsHorizontalScrollIndicator={false}
getItemLayout={getItemLayout}
style={{ overflow: "visible", flexGrow: 0 }}
showsVerticalScrollIndicator={false}
removeClippedSubviews={false}
contentContainerStyle={{
paddingBottom: 40,
paddingHorizontal: insets.left + HORIZONTAL_PADDING,
paddingVertical: SCALE_PADDING,
paddingHorizontal: SCALE_PADDING,
}}
/>
</View>

View File

@@ -7,7 +7,7 @@ import type {
import { BlurView } from "expo-blur";
import { useLocalSearchParams } from "expo-router";
import { useAtomValue } from "jotai";
import {
import React, {
type FC,
useCallback,
useEffect,
@@ -17,12 +17,15 @@ import {
} from "react";
import { useTranslation } from "react-i18next";
import {
BackHandler,
Image,
Platform,
Pressable,
Animated as RNAnimated,
Easing as RNEasing,
ScrollView,
StyleSheet,
TVFocusGuideView,
View,
} from "react-native";
import Animated, {
@@ -99,50 +102,87 @@ const TVOptionSelector = <T,>({
onSelect: (value: T) => void;
onClose: () => void;
}) => {
const [isReady, setIsReady] = useState(false);
const firstCardRef = useRef<View>(null);
const initialSelectedIndex = useMemo(() => {
const idx = options.findIndex((o) => o.selected);
return idx >= 0 ? idx : 0;
}, [options]);
// Delay rendering to work around hasTVPreferredFocus timing issue
useEffect(() => {
if (visible) {
const timer = setTimeout(() => setIsReady(true), 100);
return () => clearTimeout(timer);
}
setIsReady(false);
}, [visible]);
// Programmatic focus fallback
useEffect(() => {
if (isReady && firstCardRef.current) {
const timer = setTimeout(() => {
(firstCardRef.current as any)?.requestTVFocus?.();
}, 50);
return () => clearTimeout(timer);
}
}, [isReady]);
if (!visible) return null;
return (
<View style={selectorStyles.overlay}>
<BlurView intensity={80} tint='dark' style={selectorStyles.blurContainer}>
<View style={selectorStyles.content}>
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={selectorStyles.content}
>
<Text style={selectorStyles.title}>{title}</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={selectorStyles.scrollView}
contentContainerStyle={selectorStyles.scrollContent}
>
{options.map((option, index) => (
<TVOptionCard
key={index}
label={option.label}
selected={option.selected}
hasTVPreferredFocus={index === initialSelectedIndex}
onPress={() => {
onSelect(option.value);
onClose();
}}
/>
))}
</ScrollView>
</View>
{isReady && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={selectorStyles.scrollView}
contentContainerStyle={selectorStyles.scrollContent}
>
{options.map((option, index) => (
<TVOptionCard
key={index}
ref={
index === initialSelectedIndex ? firstCardRef : undefined
}
label={option.label}
selected={option.selected}
hasTVPreferredFocus={index === initialSelectedIndex}
onPress={() => {
onSelect(option.value);
onClose();
}}
/>
))}
</ScrollView>
)}
</TVFocusGuideView>
</BlurView>
</View>
);
};
// Option card for horizontal selector
const TVOptionCard: FC<{
label: string;
selected: boolean;
hasTVPreferredFocus?: boolean;
onPress: () => void;
}> = ({ label, selected, hasTVPreferredFocus, onPress }) => {
// Option card for horizontal selector (with forwardRef for programmatic focus)
const TVOptionCard = React.forwardRef<
View,
{
label: string;
selected: boolean;
hasTVPreferredFocus?: boolean;
onPress: () => void;
}
>(({ label, selected, hasTVPreferredFocus, onPress }, ref) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new RNAnimated.Value(1)).current;
@@ -156,6 +196,7 @@ const TVOptionCard: FC<{
return (
<Pressable
ref={ref}
onPress={onPress}
onFocus={() => {
setFocused(true);
@@ -202,7 +243,7 @@ const TVOptionCard: FC<{
</RNAnimated.View>
</Pressable>
);
};
});
// Settings panel with tabs for Audio and Subtitles
const _TVSettingsPanel: FC<{
@@ -782,6 +823,20 @@ export const Controls: FC<Props> = ({
// Track which button last opened a modal (for returning focus)
const [lastOpenedModal, setLastOpenedModal] = useState<ModalType>(null);
// Android TV BackHandler for closing modals
useEffect(() => {
if (Platform.OS === "android" && isModalOpen) {
const backHandler = BackHandler.addEventListener(
"hardwareBackPress",
() => {
setOpenModal(null);
return true;
},
);
return () => backHandler.remove();
}
}, [isModalOpen]);
// Get available audio tracks
const audioTracks = useMemo(() => {
return mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") ?? [];
@@ -951,6 +1006,7 @@ export const Controls: FC<Props> = ({
const { isSliding: isRemoteSliding } = useRemoteControl({
showControls,
toggleControls,
togglePlay,
onBack: handleBack,
});

View File

@@ -42,10 +42,12 @@ interface UseRemoteControlProps {
* Simplified version - D-pad navigation is handled by native focus system.
* This hook handles:
* - Showing controls on any button press
* - Play/pause button on TV remote
*/
export function useRemoteControl({
showControls,
toggleControls,
togglePlay,
onBack,
}: UseRemoteControlProps) {
// Keep these for backward compatibility with the component
@@ -67,6 +69,14 @@ export function useRemoteControl({
return;
}
// Handle play/pause button press on TV remote
if (evt.eventType === "playPause") {
if (togglePlay) {
togglePlay();
}
return;
}
// Show controls on any D-pad press
if (!showControls) {
toggleControls();