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(
}}
>