From cfcfb486bf8f59fa577497c80101e71742aaa172 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 16 Jan 2026 21:21:58 +0100 Subject: [PATCH] wip --- .../items/page.tsx | 103 ++++++----- components/ItemContent.tsx | 3 +- components/ItemContent.tv.tsx | 160 ++++++++++-------- 3 files changed, 139 insertions(+), 127 deletions(-) diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/items/page.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/items/page.tsx index 6baf9ca6..b9c25493 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/items/page.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/items/page.tsx @@ -5,7 +5,6 @@ import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { Platform, View } from "react-native"; import Animated, { - runOnJS, useAnimatedStyle, useSharedValue, withTiming, @@ -28,7 +27,11 @@ const Page: React.FC = () => { // Exclude MediaSources/MediaStreams from initial fetch for faster loading // (especially important for plugins like Gelato) - const { data: item, isError } = useItemQuery(id, isOffline, undefined, [ + const { + data: item, + isError, + isLoading, + } = useItemQuery(id, isOffline, undefined, [ ItemFields.MediaSources, ItemFields.MediaSourceCount, ItemFields.MediaStreams, @@ -44,33 +47,14 @@ const Page: React.FC = () => { }; }); - const fadeOut = (callback: any) => { - setTimeout(() => { - opacity.value = withTiming(0, { duration: 500 }, (finished) => { - if (finished) { - runOnJS(callback)(); - } - }); - }, 100); - }; - - const fadeIn = (callback: any) => { - setTimeout(() => { - opacity.value = withTiming(1, { duration: 500 }, (finished) => { - if (finished) { - runOnJS(callback)(); - } - }); - }, 100); - }; - + // Fast fade out when item loads (no setTimeout delay) useEffect(() => { if (item) { - fadeOut(() => {}); + opacity.value = withTiming(0, { duration: 150 }); } else { - fadeIn(() => {}); + opacity.value = withTiming(1, { duration: 150 }); } - }, [item]); + }, [item, opacity]); if (isError) return ( @@ -82,37 +66,46 @@ const Page: React.FC = () => { return ( - - {Platform.isTV && ItemContentSkeletonTV ? ( - - ) : ( - - - - - - - - - + {/* Always render ItemContent - it handles loading state internally on TV */} + + + {/* Skeleton overlay - fades out when content loads */} + {!item && ( + + {Platform.isTV && ItemContentSkeletonTV ? ( + + ) : ( + + + + + + + + + + + + + + - - - - - - )} - - {item && } + )} + + )} ); diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx index 7a57c4d8..5217e541 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -47,8 +47,9 @@ export type SelectedOptions = { }; interface ItemContentProps { - item: BaseItemDto; + item?: BaseItemDto | null; itemWithSources?: BaseItemDto | null; + isLoading?: boolean; } // Mobile-specific implementation diff --git a/components/ItemContent.tv.tsx b/components/ItemContent.tv.tsx index 12f8ad89..17b525be 100644 --- a/components/ItemContent.tv.tsx +++ b/components/ItemContent.tv.tsx @@ -51,86 +51,90 @@ export type SelectedOptions = { }; interface ItemContentTVProps { - item: BaseItemDto; + item?: BaseItemDto | null; itemWithSources?: BaseItemDto | null; + isLoading?: boolean; } // Focusable button component for TV with Apple TV-style animations -const TVFocusableButton: React.FC<{ - onPress: () => void; - children: React.ReactNode; - hasTVPreferredFocus?: boolean; - style?: any; - variant?: "primary" | "secondary"; -}> = ({ - onPress, - children, - hasTVPreferredFocus, - style, - variant = "primary", -}) => { - const [focused, setFocused] = useState(false); - const scale = useRef(new Animated.Value(1)).current; +const TVFocusableButton = React.forwardRef< + View, + { + onPress: () => void; + children: React.ReactNode; + hasTVPreferredFocus?: boolean; + style?: any; + variant?: "primary" | "secondary"; + } +>( + ( + { onPress, children, hasTVPreferredFocus, style, variant = "primary" }, + ref, + ) => { + const [focused, setFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; - const animateTo = (v: number) => - Animated.timing(scale, { - toValue: v, - duration: 150, - easing: Easing.out(Easing.quad), - useNativeDriver: true, - }).start(); + const animateTo = (v: number) => + Animated.timing(scale, { + toValue: v, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); - const isPrimary = variant === "primary"; + const isPrimary = variant === "primary"; - return ( - { - setFocused(true); - animateTo(1.05); - }} - onBlur={() => { - setFocused(false); - animateTo(1); - }} - hasTVPreferredFocus={hasTVPreferredFocus} - > - { + setFocused(true); + animateTo(1.05); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + hasTVPreferredFocus={hasTVPreferredFocus} > - - {children} - - - - ); -}; + + {children} + + + + ); + }, +); // Info row component for metadata display const _InfoRow: React.FC<{ label: string; value: string }> = ({ @@ -629,6 +633,9 @@ export const ItemContentTV: React.FC = React.memo( SelectedOptions | undefined >(undefined); + // Ref for programmatic focus on play button + const playButtonRef = useRef(null); + const { defaultAudioIndex, defaultBitrate, @@ -656,6 +663,16 @@ export const ItemContentTV: React.FC = React.memo( defaultMediaSource, ]); + // Programmatically focus play button after content renders + useEffect(() => { + if (selectedOptions && playButtonRef.current) { + const timer = setTimeout(() => { + (playButtonRef.current as any)?.requestTVFocus?.(); + }, 50); + return () => clearTimeout(timer); + } + }, [selectedOptions]); + const handlePlay = () => { if (!item || !selectedOptions) return; @@ -1069,6 +1086,7 @@ export const ItemContentTV: React.FC = React.memo( }} >