mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-03-21 08:46:27 +00:00
638 lines
18 KiB
TypeScript
638 lines
18 KiB
TypeScript
import { Ionicons } from "@expo/vector-icons";
|
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
import { Image } from "expo-image";
|
|
import { LinearGradient } from "expo-linear-gradient";
|
|
import { useAtomValue } from "jotai";
|
|
import React, {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import {
|
|
Animated,
|
|
Dimensions,
|
|
Easing,
|
|
FlatList,
|
|
Platform,
|
|
Pressable,
|
|
View,
|
|
} from "react-native";
|
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
import { ProgressBar } from "@/components/common/ProgressBar";
|
|
import { Text } from "@/components/common/Text";
|
|
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
|
|
import { type ScaledTVSizes, useScaledTVSizes } from "@/constants/TVSizes";
|
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
|
import useRouter from "@/hooks/useAppRouter";
|
|
import {
|
|
GlassPosterView,
|
|
isGlassEffectAvailable,
|
|
} from "@/modules/glass-poster";
|
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
|
|
|
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
|
|
|
|
interface TVHeroCarouselProps {
|
|
items: BaseItemDto[];
|
|
onItemFocus?: (item: BaseItemDto) => void;
|
|
onItemLongPress?: (item: BaseItemDto) => void;
|
|
}
|
|
|
|
interface HeroCardProps {
|
|
item: BaseItemDto;
|
|
isFirst: boolean;
|
|
sizes: ScaledTVSizes;
|
|
onFocus: (item: BaseItemDto) => void;
|
|
onPress: (item: BaseItemDto) => void;
|
|
onLongPress?: (item: BaseItemDto) => void;
|
|
}
|
|
|
|
const HeroCard: React.FC<HeroCardProps> = React.memo(
|
|
({ item, isFirst, sizes, onFocus, onPress, onLongPress }) => {
|
|
const api = useAtomValue(apiAtom);
|
|
const [focused, setFocused] = useState(false);
|
|
const scale = useRef(new Animated.Value(1)).current;
|
|
|
|
// Check if glass effect is available (tvOS 26+)
|
|
const useGlass = Platform.OS === "ios" && isGlassEffectAvailable();
|
|
|
|
const posterUrl = useMemo(() => {
|
|
if (!api) return null;
|
|
|
|
// For episodes, always use series thumb
|
|
if (item.Type === "Episode") {
|
|
if (item.ParentThumbImageTag) {
|
|
return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`;
|
|
}
|
|
if (item.SeriesId) {
|
|
return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=400&quality=80`;
|
|
}
|
|
}
|
|
|
|
// For non-episodes, use item's own thumb/primary
|
|
if (item.ImageTags?.Thumb) {
|
|
return `${api.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ImageTags.Thumb}`;
|
|
}
|
|
if (item.ImageTags?.Primary) {
|
|
return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=400&quality=80&tag=${item.ImageTags.Primary}`;
|
|
}
|
|
return null;
|
|
}, [api, item]);
|
|
|
|
const animateTo = useCallback(
|
|
(value: number) =>
|
|
Animated.timing(scale, {
|
|
toValue: value,
|
|
duration: 150,
|
|
easing: Easing.out(Easing.quad),
|
|
useNativeDriver: true,
|
|
}).start(),
|
|
[scale],
|
|
);
|
|
|
|
const handleFocus = useCallback(() => {
|
|
setFocused(true);
|
|
animateTo(sizes.animation.focusScale);
|
|
onFocus(item);
|
|
}, [animateTo, onFocus, item, sizes.animation.focusScale]);
|
|
|
|
const handleBlur = useCallback(() => {
|
|
setFocused(false);
|
|
animateTo(1);
|
|
}, [animateTo]);
|
|
|
|
const handlePress = useCallback(() => {
|
|
onPress(item);
|
|
}, [onPress, item]);
|
|
|
|
const handleLongPress = useCallback(() => {
|
|
onLongPress?.(item);
|
|
}, [onLongPress, item]);
|
|
|
|
// Use glass poster for tvOS 26+
|
|
if (useGlass && posterUrl) {
|
|
const progress = item.UserData?.PlayedPercentage || 0;
|
|
return (
|
|
<Pressable
|
|
onPress={handlePress}
|
|
onLongPress={handleLongPress}
|
|
onFocus={handleFocus}
|
|
onBlur={handleBlur}
|
|
hasTVPreferredFocus={isFirst}
|
|
style={{ marginRight: sizes.gaps.item }}
|
|
>
|
|
<GlassPosterView
|
|
imageUrl={posterUrl}
|
|
aspectRatio={16 / 9}
|
|
cornerRadius={24}
|
|
progress={progress}
|
|
showWatchedIndicator={false}
|
|
isFocused={focused}
|
|
width={sizes.posters.episode}
|
|
style={{ width: sizes.posters.episode }}
|
|
/>
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
// Fallback for non-tvOS or older tvOS
|
|
return (
|
|
<Pressable
|
|
onPress={handlePress}
|
|
onLongPress={handleLongPress}
|
|
onFocus={handleFocus}
|
|
onBlur={handleBlur}
|
|
hasTVPreferredFocus={isFirst}
|
|
style={{ marginRight: sizes.gaps.item }}
|
|
>
|
|
<Animated.View
|
|
style={{
|
|
width: sizes.posters.episode,
|
|
aspectRatio: 16 / 9,
|
|
borderRadius: 24,
|
|
overflow: "hidden",
|
|
transform: [{ scale }],
|
|
borderWidth: 2,
|
|
borderColor: focused ? "#FFFFFF" : "transparent",
|
|
shadowColor: "#FFFFFF",
|
|
shadowOffset: { width: 0, height: 0 },
|
|
shadowOpacity: focused ? 0.6 : 0,
|
|
shadowRadius: focused ? 20 : 0,
|
|
}}
|
|
>
|
|
{posterUrl ? (
|
|
<Image
|
|
source={{ uri: posterUrl }}
|
|
style={{ width: "100%", height: "100%" }}
|
|
contentFit='cover'
|
|
/>
|
|
) : (
|
|
<View
|
|
style={{
|
|
width: "100%",
|
|
height: "100%",
|
|
backgroundColor: "rgba(255,255,255,0.1)",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<Ionicons
|
|
name='film-outline'
|
|
size={48}
|
|
color='rgba(255,255,255,0.3)'
|
|
/>
|
|
</View>
|
|
)}
|
|
<ProgressBar item={item} />
|
|
</Animated.View>
|
|
</Pressable>
|
|
);
|
|
},
|
|
);
|
|
|
|
// Debounce delay to prevent rapid backdrop changes when navigating fast
|
|
const BACKDROP_DEBOUNCE_MS = 300;
|
|
|
|
export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
|
|
items,
|
|
onItemFocus,
|
|
onItemLongPress,
|
|
}) => {
|
|
const typography = useScaledTVTypography();
|
|
const sizes = useScaledTVSizes();
|
|
const api = useAtomValue(apiAtom);
|
|
const insets = useSafeAreaInsets();
|
|
const router = useRouter();
|
|
|
|
// Active item for featured display (debounced)
|
|
const [activeItem, setActiveItem] = useState<BaseItemDto | null>(
|
|
items[0] || null,
|
|
);
|
|
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
// Cleanup debounce timer on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (debounceTimerRef.current) {
|
|
clearTimeout(debounceTimerRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
// Crossfade animation state
|
|
const [activeLayer, setActiveLayer] = useState<0 | 1>(0);
|
|
const [layer0Url, setLayer0Url] = useState<string | null>(null);
|
|
const [layer1Url, setLayer1Url] = useState<string | null>(null);
|
|
const layer0Opacity = useRef(new Animated.Value(0)).current;
|
|
const layer1Opacity = useRef(new Animated.Value(0)).current;
|
|
|
|
// Get backdrop URL for active item
|
|
const backdropUrl = useMemo(() => {
|
|
if (!activeItem) return null;
|
|
return getBackdropUrl({
|
|
api,
|
|
item: activeItem,
|
|
quality: 90,
|
|
width: 1920,
|
|
});
|
|
}, [api, activeItem]);
|
|
|
|
// Get logo URL for active item
|
|
const logoUrl = useMemo(() => {
|
|
if (!activeItem) return null;
|
|
return getLogoImageUrlById({ api, item: activeItem });
|
|
}, [api, activeItem]);
|
|
|
|
// Crossfade effect for backdrop
|
|
useEffect(() => {
|
|
if (!backdropUrl) return;
|
|
|
|
let isCancelled = false;
|
|
|
|
const performCrossfade = async () => {
|
|
try {
|
|
await Image.prefetch(backdropUrl);
|
|
} catch {
|
|
// Continue even if prefetch fails
|
|
}
|
|
|
|
if (isCancelled) return;
|
|
|
|
const incomingLayer = activeLayer === 0 ? 1 : 0;
|
|
const incomingOpacity =
|
|
incomingLayer === 0 ? layer0Opacity : layer1Opacity;
|
|
const outgoingOpacity =
|
|
incomingLayer === 0 ? layer1Opacity : layer0Opacity;
|
|
|
|
if (incomingLayer === 0) {
|
|
setLayer0Url(backdropUrl);
|
|
} else {
|
|
setLayer1Url(backdropUrl);
|
|
}
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
|
|
if (isCancelled) return;
|
|
|
|
Animated.parallel([
|
|
Animated.timing(incomingOpacity, {
|
|
toValue: 1,
|
|
duration: 500,
|
|
easing: Easing.inOut(Easing.quad),
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.timing(outgoingOpacity, {
|
|
toValue: 0,
|
|
duration: 500,
|
|
easing: Easing.inOut(Easing.quad),
|
|
useNativeDriver: true,
|
|
}),
|
|
]).start(() => {
|
|
if (!isCancelled) {
|
|
setActiveLayer(incomingLayer);
|
|
}
|
|
});
|
|
};
|
|
|
|
performCrossfade();
|
|
|
|
return () => {
|
|
isCancelled = true;
|
|
};
|
|
}, [backdropUrl]);
|
|
|
|
// Handle card focus with debounce
|
|
const handleCardFocus = useCallback(
|
|
(item: BaseItemDto) => {
|
|
// Clear any pending debounce timer
|
|
if (debounceTimerRef.current) {
|
|
clearTimeout(debounceTimerRef.current);
|
|
}
|
|
// Set new timer to update active item after debounce delay
|
|
debounceTimerRef.current = setTimeout(() => {
|
|
setActiveItem(item);
|
|
onItemFocus?.(item);
|
|
}, BACKDROP_DEBOUNCE_MS);
|
|
},
|
|
[onItemFocus],
|
|
);
|
|
|
|
// Handle card press - navigate to item
|
|
const handleCardPress = useCallback(
|
|
(item: BaseItemDto) => {
|
|
const navigation = getItemNavigation(item, "(home)");
|
|
router.push(navigation as any);
|
|
},
|
|
[router],
|
|
);
|
|
|
|
// Get metadata for active item
|
|
const year = activeItem?.ProductionYear;
|
|
const duration = activeItem?.RunTimeTicks
|
|
? runtimeTicksToMinutes(activeItem.RunTimeTicks)
|
|
: null;
|
|
const hasProgress = (activeItem?.UserData?.PlaybackPositionTicks ?? 0) > 0;
|
|
const playedPercent = activeItem?.UserData?.PlayedPercentage ?? 0;
|
|
|
|
// Get display title
|
|
const displayTitle = useMemo(() => {
|
|
if (!activeItem) return "";
|
|
if (activeItem.Type === "Episode") {
|
|
return activeItem.SeriesName || activeItem.Name || "";
|
|
}
|
|
return activeItem.Name || "";
|
|
}, [activeItem]);
|
|
|
|
// Get subtitle for episodes
|
|
const episodeSubtitle = useMemo(() => {
|
|
if (!activeItem || activeItem.Type !== "Episode") return null;
|
|
return `S${activeItem.ParentIndexNumber} E${activeItem.IndexNumber} · ${activeItem.Name}`;
|
|
}, [activeItem]);
|
|
|
|
// Memoize hero items to prevent re-renders
|
|
const heroItems = useMemo(() => items.slice(0, 8), [items]);
|
|
|
|
// Memoize renderItem for FlatList
|
|
const renderHeroCard = useCallback(
|
|
({ item, index }: { item: BaseItemDto; index: number }) => (
|
|
<HeroCard
|
|
item={item}
|
|
isFirst={index === 0}
|
|
sizes={sizes}
|
|
onFocus={handleCardFocus}
|
|
onPress={handleCardPress}
|
|
onLongPress={onItemLongPress}
|
|
/>
|
|
),
|
|
[handleCardFocus, handleCardPress, onItemLongPress, sizes],
|
|
);
|
|
|
|
// Memoize keyExtractor
|
|
const keyExtractor = useCallback((item: BaseItemDto) => item.Id!, []);
|
|
|
|
if (items.length === 0) return null;
|
|
|
|
const heroHeight = SCREEN_HEIGHT * sizes.padding.heroHeight;
|
|
|
|
return (
|
|
<View style={{ height: heroHeight, width: "100%" }}>
|
|
{/* Backdrop layers with crossfade */}
|
|
<View
|
|
style={{
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
}}
|
|
>
|
|
{/* Layer 0 */}
|
|
<Animated.View
|
|
style={{
|
|
position: "absolute",
|
|
width: "100%",
|
|
height: "100%",
|
|
opacity: layer0Opacity,
|
|
}}
|
|
>
|
|
{layer0Url && (
|
|
<Image
|
|
source={{ uri: layer0Url }}
|
|
style={{ width: "100%", height: "100%" }}
|
|
contentFit='cover'
|
|
/>
|
|
)}
|
|
</Animated.View>
|
|
{/* Layer 1 */}
|
|
<Animated.View
|
|
style={{
|
|
position: "absolute",
|
|
width: "100%",
|
|
height: "100%",
|
|
opacity: layer1Opacity,
|
|
}}
|
|
>
|
|
{layer1Url && (
|
|
<Image
|
|
source={{ uri: layer1Url }}
|
|
style={{ width: "100%", height: "100%" }}
|
|
contentFit='cover'
|
|
/>
|
|
)}
|
|
</Animated.View>
|
|
|
|
{/* Gradient overlays */}
|
|
<LinearGradient
|
|
colors={["transparent", "rgba(0,0,0,0.5)", "rgba(0,0,0,0.95)"]}
|
|
locations={[0, 0.5, 1]}
|
|
style={{
|
|
position: "absolute",
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
height: "70%",
|
|
}}
|
|
/>
|
|
<LinearGradient
|
|
colors={["rgba(0,0,0,0.4)", "transparent"]}
|
|
locations={[0, 1]}
|
|
style={{
|
|
position: "absolute",
|
|
left: 0,
|
|
right: 0,
|
|
top: 0,
|
|
height: "40%",
|
|
}}
|
|
/>
|
|
{/* Horizontal gradient for left side text contrast */}
|
|
<LinearGradient
|
|
colors={["rgba(0,0,0,0.9)", "rgba(0,0,0,0.6)", "transparent"]}
|
|
locations={[0, 0.5, 0.85]}
|
|
start={{ x: 0, y: 0 }}
|
|
end={{ x: 1, y: 0 }}
|
|
style={{
|
|
position: "absolute",
|
|
left: 0,
|
|
top: 0,
|
|
bottom: 0,
|
|
width: "70%",
|
|
}}
|
|
/>
|
|
</View>
|
|
|
|
{/* Content overlay */}
|
|
<View
|
|
style={{
|
|
position: "absolute",
|
|
left: insets.left + sizes.padding.horizontal,
|
|
right: insets.right + sizes.padding.horizontal,
|
|
bottom: 40,
|
|
}}
|
|
>
|
|
{/* Logo or Title */}
|
|
{logoUrl ? (
|
|
<Image
|
|
source={{ uri: logoUrl }}
|
|
style={{
|
|
height: 100,
|
|
width: SCREEN_WIDTH * 0.35,
|
|
marginBottom: 16,
|
|
}}
|
|
contentFit='contain'
|
|
contentPosition='left'
|
|
/>
|
|
) : (
|
|
<Text
|
|
style={{
|
|
fontSize: typography.display,
|
|
fontWeight: "bold",
|
|
color: "#FFFFFF",
|
|
marginBottom: 12,
|
|
}}
|
|
numberOfLines={1}
|
|
>
|
|
{displayTitle}
|
|
</Text>
|
|
)}
|
|
|
|
{/* Episode subtitle */}
|
|
{episodeSubtitle && (
|
|
<Text
|
|
style={{
|
|
fontSize: typography.body,
|
|
color: "rgba(255,255,255,0.9)",
|
|
marginBottom: 12,
|
|
}}
|
|
numberOfLines={1}
|
|
>
|
|
{episodeSubtitle}
|
|
</Text>
|
|
)}
|
|
|
|
{/* Description */}
|
|
{activeItem?.Overview && (
|
|
<Text
|
|
style={{
|
|
fontSize: typography.body,
|
|
color: "rgba(255,255,255,0.8)",
|
|
marginBottom: 16,
|
|
maxWidth: SCREEN_WIDTH * 0.5,
|
|
lineHeight: typography.body * 1.4,
|
|
}}
|
|
numberOfLines={2}
|
|
>
|
|
{activeItem.Overview}
|
|
</Text>
|
|
)}
|
|
|
|
{/* Metadata badges */}
|
|
<View
|
|
style={{
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 16,
|
|
marginBottom: 20,
|
|
}}
|
|
>
|
|
{year && (
|
|
<Text
|
|
style={{
|
|
fontSize: typography.callout,
|
|
color: "rgba(255,255,255,0.8)",
|
|
}}
|
|
>
|
|
{year}
|
|
</Text>
|
|
)}
|
|
{duration && (
|
|
<Text
|
|
style={{
|
|
fontSize: typography.callout,
|
|
color: "rgba(255,255,255,0.8)",
|
|
}}
|
|
>
|
|
{duration}
|
|
</Text>
|
|
)}
|
|
{activeItem?.OfficialRating && (
|
|
<View
|
|
style={{
|
|
paddingHorizontal: 8,
|
|
paddingVertical: 2,
|
|
borderRadius: 4,
|
|
borderWidth: 1,
|
|
borderColor: "rgba(255,255,255,0.5)",
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
fontSize: typography.callout,
|
|
color: "rgba(255,255,255,0.8)",
|
|
}}
|
|
>
|
|
{activeItem.OfficialRating}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
{hasProgress && (
|
|
<View
|
|
style={{
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 6,
|
|
}}
|
|
>
|
|
<View
|
|
style={{
|
|
width: 60,
|
|
height: 4,
|
|
backgroundColor: "rgba(255,255,255,0.3)",
|
|
borderRadius: 2,
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
<View
|
|
style={{
|
|
width: `${playedPercent}%`,
|
|
height: "100%",
|
|
backgroundColor: "#FFFFFF",
|
|
borderRadius: 2,
|
|
}}
|
|
/>
|
|
</View>
|
|
<Text
|
|
style={{
|
|
fontSize: typography.callout,
|
|
color: "rgba(255,255,255,0.8)",
|
|
}}
|
|
>
|
|
{Math.round(playedPercent)}%
|
|
</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
{/* Thumbnail carousel */}
|
|
<FlatList
|
|
horizontal
|
|
data={heroItems}
|
|
keyExtractor={keyExtractor}
|
|
showsHorizontalScrollIndicator={false}
|
|
style={{ overflow: "visible" }}
|
|
contentContainerStyle={{ paddingVertical: sizes.gaps.small }}
|
|
renderItem={renderHeroCard}
|
|
removeClippedSubviews={false}
|
|
initialNumToRender={8}
|
|
maxToRenderPerBatch={8}
|
|
windowSize={3}
|
|
/>
|
|
</View>
|
|
</View>
|
|
);
|
|
};
|