mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-10 07:50:30 +01:00
wip
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user