This commit is contained in:
Fredrik Burmester
2026-01-16 21:21:58 +01:00
parent 407ea69425
commit cfcfb486bf
3 changed files with 139 additions and 127 deletions

View File

@@ -5,7 +5,6 @@ import { useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native"; import { Platform, View } from "react-native";
import Animated, { import Animated, {
runOnJS,
useAnimatedStyle, useAnimatedStyle,
useSharedValue, useSharedValue,
withTiming, withTiming,
@@ -28,7 +27,11 @@ const Page: React.FC = () => {
// Exclude MediaSources/MediaStreams from initial fetch for faster loading // Exclude MediaSources/MediaStreams from initial fetch for faster loading
// (especially important for plugins like Gelato) // (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.MediaSources,
ItemFields.MediaSourceCount, ItemFields.MediaSourceCount,
ItemFields.MediaStreams, ItemFields.MediaStreams,
@@ -44,33 +47,14 @@ const Page: React.FC = () => {
}; };
}); });
const fadeOut = (callback: any) => { // Fast fade out when item loads (no setTimeout delay)
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);
};
useEffect(() => { useEffect(() => {
if (item) { if (item) {
fadeOut(() => {}); opacity.value = withTiming(0, { duration: 150 });
} else { } else {
fadeIn(() => {}); opacity.value = withTiming(1, { duration: 150 });
} }
}, [item]); }, [item, opacity]);
if (isError) if (isError)
return ( return (
@@ -82,37 +66,46 @@ const Page: React.FC = () => {
return ( return (
<OfflineModeProvider isOffline={isOffline}> <OfflineModeProvider isOffline={isOffline}>
<View className='flex flex-1 relative'> <View className='flex flex-1 relative'>
<Animated.View {/* Always render ItemContent - it handles loading state internally on TV */}
pointerEvents={"none"} <ItemContent
style={[animatedStyle]} item={item}
className='absolute top-0 left-0 flex flex-col items-start h-screen w-screen z-50 bg-black' itemWithSources={itemWithSources}
> isLoading={isLoading}
{Platform.isTV && ItemContentSkeletonTV ? ( />
<ItemContentSkeletonTV />
) : ( {/* Skeleton overlay - fades out when content loads */}
<View style={{ paddingHorizontal: 16, width: "100%" }}> {!item && (
<View <Animated.View
style={{ pointerEvents={"none"}
height: item?.Type === "Episode" ? 300 : 450, style={[animatedStyle]}
}} className='absolute top-0 left-0 flex flex-col items-start h-screen w-screen z-50 bg-black'
className='bg-transparent rounded-lg mb-4 w-full' >
/> {Platform.isTV && ItemContentSkeletonTV ? (
<View className='h-6 bg-neutral-900 rounded mb-4 w-14' /> <ItemContentSkeletonTV />
<View className='h-10 bg-neutral-900 rounded-lg mb-2 w-1/2' /> ) : (
<View className='h-3 bg-neutral-900 rounded mb-3 w-8' /> <View style={{ paddingHorizontal: 16, width: "100%" }}>
<View className='flex flex-row space-x-1 mb-8'> <View
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' /> style={{
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' /> height: item?.Type === "Episode" ? 300 : 450,
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' /> }}
className='bg-transparent rounded-lg mb-4 w-full'
/>
<View className='h-6 bg-neutral-900 rounded mb-4 w-14' />
<View className='h-10 bg-neutral-900 rounded-lg mb-2 w-1/2' />
<View className='h-3 bg-neutral-900 rounded mb-3 w-8' />
<View className='flex flex-row space-x-1 mb-8'>
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
</View>
<View className='h-3 bg-neutral-900 rounded w-2/3 mb-1' />
<View className='h-10 bg-neutral-900 rounded-lg w-full mb-2' />
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
</View> </View>
<View className='h-3 bg-neutral-900 rounded w-2/3 mb-1' /> )}
<View className='h-10 bg-neutral-900 rounded-lg w-full mb-2' /> </Animated.View>
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' /> )}
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
</View>
)}
</Animated.View>
{item && <ItemContent item={item} itemWithSources={itemWithSources} />}
</View> </View>
</OfflineModeProvider> </OfflineModeProvider>
); );

View File

@@ -47,8 +47,9 @@ export type SelectedOptions = {
}; };
interface ItemContentProps { interface ItemContentProps {
item: BaseItemDto; item?: BaseItemDto | null;
itemWithSources?: BaseItemDto | null; itemWithSources?: BaseItemDto | null;
isLoading?: boolean;
} }
// Mobile-specific implementation // Mobile-specific implementation

View File

@@ -51,86 +51,90 @@ export type SelectedOptions = {
}; };
interface ItemContentTVProps { interface ItemContentTVProps {
item: BaseItemDto; item?: BaseItemDto | null;
itemWithSources?: BaseItemDto | null; itemWithSources?: BaseItemDto | null;
isLoading?: boolean;
} }
// Focusable button component for TV with Apple TV-style animations // Focusable button component for TV with Apple TV-style animations
const TVFocusableButton: React.FC<{ const TVFocusableButton = React.forwardRef<
onPress: () => void; View,
children: React.ReactNode; {
hasTVPreferredFocus?: boolean; onPress: () => void;
style?: any; children: React.ReactNode;
variant?: "primary" | "secondary"; hasTVPreferredFocus?: boolean;
}> = ({ style?: any;
onPress, variant?: "primary" | "secondary";
children, }
hasTVPreferredFocus, >(
style, (
variant = "primary", { onPress, children, hasTVPreferredFocus, style, variant = "primary" },
}) => { ref,
const [focused, setFocused] = useState(false); ) => {
const scale = useRef(new Animated.Value(1)).current; const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateTo = (v: number) => const animateTo = (v: number) =>
Animated.timing(scale, { Animated.timing(scale, {
toValue: v, toValue: v,
duration: 150, duration: 150,
easing: Easing.out(Easing.quad), easing: Easing.out(Easing.quad),
useNativeDriver: true, useNativeDriver: true,
}).start(); }).start();
const isPrimary = variant === "primary"; const isPrimary = variant === "primary";
return ( return (
<Pressable <Pressable
onPress={onPress} ref={ref}
onFocus={() => { onPress={onPress}
setFocused(true); onFocus={() => {
animateTo(1.05); setFocused(true);
}} animateTo(1.05);
onBlur={() => { }}
setFocused(false); onBlur={() => {
animateTo(1); setFocused(false);
}} animateTo(1);
hasTVPreferredFocus={hasTVPreferredFocus} }}
> hasTVPreferredFocus={hasTVPreferredFocus}
<Animated.View
style={[
{
transform: [{ scale }],
shadowColor: isPrimary ? "#fff" : "#a855f7",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.6 : 0,
shadowRadius: focused ? 20 : 0,
},
style,
]}
> >
<View <Animated.View
style={{ style={[
backgroundColor: focused {
? isPrimary transform: [{ scale }],
? "#ffffff" shadowColor: isPrimary ? "#fff" : "#a855f7",
: "#7c3aed" shadowOffset: { width: 0, height: 0 },
: isPrimary shadowOpacity: focused ? 0.6 : 0,
? "rgba(255, 255, 255, 0.9)" shadowRadius: focused ? 20 : 0,
: "rgba(124, 58, 237, 0.8)", },
borderRadius: 12, style,
paddingVertical: 18, ]}
paddingHorizontal: 32,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
minWidth: 180,
}}
> >
{children} <View
</View> style={{
</Animated.View> backgroundColor: focused
</Pressable> ? isPrimary
); ? "#ffffff"
}; : "#7c3aed"
: isPrimary
? "rgba(255, 255, 255, 0.9)"
: "rgba(124, 58, 237, 0.8)",
borderRadius: 12,
paddingVertical: 18,
paddingHorizontal: 32,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
minWidth: 180,
}}
>
{children}
</View>
</Animated.View>
</Pressable>
);
},
);
// Info row component for metadata display // Info row component for metadata display
const _InfoRow: React.FC<{ label: string; value: string }> = ({ const _InfoRow: React.FC<{ label: string; value: string }> = ({
@@ -629,6 +633,9 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
SelectedOptions | undefined SelectedOptions | undefined
>(undefined); >(undefined);
// Ref for programmatic focus on play button
const playButtonRef = useRef<View>(null);
const { const {
defaultAudioIndex, defaultAudioIndex,
defaultBitrate, defaultBitrate,
@@ -656,6 +663,16 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
defaultMediaSource, 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 = () => { const handlePlay = () => {
if (!item || !selectedOptions) return; if (!item || !selectedOptions) return;
@@ -1069,6 +1086,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
}} }}
> >
<TVFocusableButton <TVFocusableButton
ref={playButtonRef}
onPress={handlePlay} onPress={handlePlay}
hasTVPreferredFocus hasTVPreferredFocus
variant='primary' variant='primary'