From e1e91ea1a66584da5bf72531cd55d7b9a05b0891 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 16 Jan 2026 21:00:46 +0100 Subject: [PATCH] fix: sheet --- app/(auth)/(tabs)/(favorites)/index.tsx | 5 + components/ItemContent.tv.tsx | 120 ++++-- components/home/Favorites.tv.tsx | 231 +++++++++++ components/library/TVLibraries.tsx | 377 ++++++++++++++---- .../video-player/controls/Controls.tv.tsx | 116 ++++-- .../controls/hooks/useRemoteControl.ts | 10 + 6 files changed, 720 insertions(+), 139 deletions(-) create mode 100644 components/home/Favorites.tv.tsx diff --git a/app/(auth)/(tabs)/(favorites)/index.tsx b/app/(auth)/(tabs)/(favorites)/index.tsx index 198695a8..a3c83c04 100644 --- a/app/(auth)/(tabs)/(favorites)/index.tsx +++ b/app/(auth)/(tabs)/(favorites)/index.tsx @@ -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 ; + } + return ( ({ onSelect: (value: T) => void; onClose: () => void; }) => { + const [isReady, setIsReady] = useState(false); + const firstCardRef = useRef(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 = ({ overflow: "hidden", }} > - ({ {/* Horizontal options */} - - {options.map((option, index) => ( - { - onSelect(option.value); - onClose(); - }} - /> - ))} - - + {isReady && ( + + {options.map((option, index) => ( + { + onSelect(option.value); + onClose(); + }} + /> + ))} + + )} + ); }; -// 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 ( { setFocused(true); @@ -465,7 +504,7 @@ const TVOptionCard: React.FC<{ ); -}; +}); // Button to open option selector const TVOptionButton: React.FC<{ @@ -606,6 +645,21 @@ export const ItemContentTV: React.FC = React.memo( // Modal state for option selectors type ModalType = "audio" | "subtitle" | "mediaSource" | "quality" | null; const [openModal, setOpenModal] = useState(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(() => { diff --git a/components/home/Favorites.tv.tsx b/components/home/Favorites.tv.tsx new file mode 100644 index 00000000..6da3befa --- /dev/null +++ b/components/home/Favorites.tv.tsx @@ -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; + +export const Favorites = () => { + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + const pageSize = 20; + const [emptyState, setEmptyState] = useState({ + 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 ( + + + + {t("favorites.noDataTitle")} + + + {t("favorites.noData")} + + + ); + } + + return ( + + + + + + + + + + + ); +}; diff --git a/components/library/TVLibraries.tsx b/components/library/TVLibraries.tsx index 55e96db4..b98ffa09 100644 --- a/components/library/TVLibraries.tsx +++ b/components/library/TVLibraries.tsx @@ -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["name"]; + +const icons: Record = { + 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 ( + { + setFocused(true); + animateTo(1.02, 1); + }} + onBlur={() => { + setFocused(false); + animateTo(1, 0.7); + }} + hasTVPreferredFocus={isFirst} + > + + {/* Background Image */} + {backdropUrl && ( + + )} + + {/* Gradient Overlay */} + + + {/* Content */} + + {/* Icon Container */} + + + + + {/* Text Content */} + + + {library.Name} + + {library.itemCount !== undefined && ( + + {library.itemCount} {itemTypeName} + + )} + + + {/* Arrow Indicator */} + + + + + + + ); +}; + 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>(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 | 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 ( - - handleItemPress(item)} - hasTVPreferredFocus={isFirstItem} - onFocus={handleItemFocus} - onBlur={handleItemBlur} - > - - - - ); - }, - [handleItemPress, handleItemFocus, handleItemBlur], + ({ item, index }: { item: LibraryWithPreview; index: number }) => ( + + handleLibraryPress(item)} + /> + + ), + [handleLibraryPress], ); - if (isLoading) { + const isLoading = viewsLoading || dataLoading; + const displayLibraries = librariesWithData || libraries; + + if (isLoading && libraries.length === 0) { return ( { ); } - if (!libraries || libraries.length === 0) { + if (!displayLibraries || displayLibraries.length === 0) { return ( { 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, }} /> diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index 1c720b09..433d8a52 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -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 = ({ onSelect: (value: T) => void; onClose: () => void; }) => { + const [isReady, setIsReady] = useState(false); + const firstCardRef = useRef(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 ( - + {title} - - {options.map((option, index) => ( - { - onSelect(option.value); - onClose(); - }} - /> - ))} - - + {isReady && ( + + {options.map((option, index) => ( + { + onSelect(option.value); + onClose(); + }} + /> + ))} + + )} + ); }; -// 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 ( { setFocused(true); @@ -202,7 +243,7 @@ const TVOptionCard: FC<{ ); -}; +}); // Settings panel with tabs for Audio and Subtitles const _TVSettingsPanel: FC<{ @@ -782,6 +823,20 @@ export const Controls: FC = ({ // Track which button last opened a modal (for returning focus) const [lastOpenedModal, setLastOpenedModal] = useState(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 = ({ const { isSliding: isRemoteSliding } = useRemoteControl({ showControls, toggleControls, + togglePlay, onBack: handleBack, }); diff --git a/components/video-player/controls/hooks/useRemoteControl.ts b/components/video-player/controls/hooks/useRemoteControl.ts index c279f649..406a99c3 100644 --- a/components/video-player/controls/hooks/useRemoteControl.ts +++ b/components/video-player/controls/hooks/useRemoteControl.ts @@ -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();