mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-28 14:08:29 +00:00
396 lines
11 KiB
TypeScript
396 lines
11 KiB
TypeScript
import { Ionicons } from "@expo/vector-icons";
|
|
import type {
|
|
BaseItemDto,
|
|
CollectionType,
|
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
import { getItemsApi, getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { BlurView } from "expo-blur";
|
|
import { Image } from "expo-image";
|
|
import { LinearGradient } from "expo-linear-gradient";
|
|
import { useAtom } from "jotai";
|
|
import { useCallback, useMemo, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Animated, Easing, FlatList, Pressable, View } from "react-native";
|
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
import { Text } from "@/components/common/Text";
|
|
import { Loader } from "@/components/Loader";
|
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
|
import useRouter from "@/hooks/useAppRouter";
|
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
import { useSettings } from "@/utils/atoms/settings";
|
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
|
|
|
const HORIZONTAL_PADDING = 80;
|
|
const CARD_HEIGHT = 220;
|
|
const CARD_GAP = 24;
|
|
const SCALE_PADDING = 20;
|
|
|
|
type IconName = React.ComponentProps<typeof Ionicons>["name"];
|
|
|
|
const icons: Record<CollectionType, IconName> = {
|
|
movies: "film",
|
|
tvshows: "tv",
|
|
music: "musical-notes",
|
|
books: "book",
|
|
homevideos: "videocam",
|
|
boxsets: "albums",
|
|
playlists: "list",
|
|
folders: "folder",
|
|
livetv: "tv",
|
|
musicvideos: "musical-notes",
|
|
photos: "images",
|
|
trailers: "videocam",
|
|
unknown: "help-circle",
|
|
} as const;
|
|
|
|
interface LibraryWithPreview extends BaseItemDto {
|
|
previewItems?: BaseItemDto[];
|
|
itemCount?: number;
|
|
}
|
|
|
|
const TVLibraryRow: React.FC<{
|
|
library: LibraryWithPreview;
|
|
isFirst: boolean;
|
|
onPress: () => void;
|
|
}> = ({ library, isFirst, onPress }) => {
|
|
const [api] = useAtom(apiAtom);
|
|
const { t } = useTranslation();
|
|
const typography = useScaledTVTypography();
|
|
const [focused, setFocused] = useState(false);
|
|
const scale = useRef(new Animated.Value(1)).current;
|
|
const opacity = useRef(new Animated.Value(0.7)).current;
|
|
|
|
const animateTo = (toScale: number, toOpacity: number) => {
|
|
Animated.parallel([
|
|
Animated.timing(scale, {
|
|
toValue: toScale,
|
|
duration: 200,
|
|
easing: Easing.out(Easing.quad),
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.timing(opacity, {
|
|
toValue: toOpacity,
|
|
duration: 200,
|
|
easing: Easing.out(Easing.quad),
|
|
useNativeDriver: true,
|
|
}),
|
|
]).start();
|
|
};
|
|
|
|
const backdropUrl = useMemo(() => {
|
|
// Try to get backdrop from library or first preview item
|
|
if (library.previewItems?.[0]) {
|
|
return getBackdropUrl({
|
|
api,
|
|
item: library.previewItems[0],
|
|
width: 1920,
|
|
});
|
|
}
|
|
return getBackdropUrl({
|
|
api,
|
|
item: library,
|
|
width: 1920,
|
|
});
|
|
}, [api, library]);
|
|
|
|
const iconName = icons[library.CollectionType!] || "folder";
|
|
|
|
const itemTypeName = useMemo(() => {
|
|
if (library.CollectionType === "movies")
|
|
return t("library.item_types.movies");
|
|
if (library.CollectionType === "tvshows")
|
|
return t("library.item_types.series");
|
|
if (library.CollectionType === "boxsets")
|
|
return t("library.item_types.boxsets");
|
|
if (library.CollectionType === "music")
|
|
return t("library.item_types.items");
|
|
return t("library.item_types.items");
|
|
}, [library.CollectionType, t]);
|
|
|
|
return (
|
|
<Pressable
|
|
onPress={onPress}
|
|
onFocus={() => {
|
|
setFocused(true);
|
|
animateTo(1.02, 1);
|
|
}}
|
|
onBlur={() => {
|
|
setFocused(false);
|
|
animateTo(1, 0.7);
|
|
}}
|
|
hasTVPreferredFocus={isFirst}
|
|
>
|
|
<Animated.View
|
|
style={{
|
|
transform: [{ scale }],
|
|
opacity,
|
|
height: CARD_HEIGHT,
|
|
borderRadius: 20,
|
|
overflow: "hidden",
|
|
backgroundColor: "#1a1a1a",
|
|
borderWidth: focused ? 4 : 0,
|
|
borderColor: "#FFFFFF",
|
|
shadowColor: "#FFFFFF",
|
|
shadowOffset: { width: 0, height: 0 },
|
|
shadowOpacity: focused ? 0.3 : 0,
|
|
shadowRadius: focused ? 30 : 0,
|
|
}}
|
|
>
|
|
{/* Background Image */}
|
|
{backdropUrl && (
|
|
<Image
|
|
source={{ uri: backdropUrl }}
|
|
style={{
|
|
position: "absolute",
|
|
width: "100%",
|
|
height: "100%",
|
|
}}
|
|
contentFit='cover'
|
|
cachePolicy='memory-disk'
|
|
/>
|
|
)}
|
|
|
|
{/* Gradient Overlay */}
|
|
<LinearGradient
|
|
colors={["rgba(0,0,0,0.3)", "rgba(0,0,0,0.8)"]}
|
|
style={{
|
|
position: "absolute",
|
|
width: "100%",
|
|
height: "100%",
|
|
}}
|
|
/>
|
|
|
|
{/* Content */}
|
|
<View
|
|
style={{
|
|
flex: 1,
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
paddingHorizontal: 40,
|
|
}}
|
|
>
|
|
{/* Icon Container */}
|
|
<BlurView
|
|
intensity={60}
|
|
tint='dark'
|
|
style={{
|
|
width: 80,
|
|
height: 80,
|
|
borderRadius: 20,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
overflow: "hidden",
|
|
backgroundColor: "rgba(255,255,255,0.1)",
|
|
}}
|
|
>
|
|
<Ionicons name={iconName} size={40} color='#FFFFFF' />
|
|
</BlurView>
|
|
|
|
{/* Text Content */}
|
|
<View style={{ marginLeft: 24, flex: 1 }}>
|
|
<Text
|
|
numberOfLines={1}
|
|
style={{
|
|
fontSize: typography.heading,
|
|
fontWeight: "700",
|
|
color: "#FFFFFF",
|
|
textShadowColor: "rgba(0,0,0,0.8)",
|
|
textShadowOffset: { width: 0, height: 2 },
|
|
textShadowRadius: 4,
|
|
}}
|
|
>
|
|
{library.Name}
|
|
</Text>
|
|
{library.itemCount !== undefined && (
|
|
<Text
|
|
style={{
|
|
fontSize: typography.body,
|
|
color: "rgba(255,255,255,0.7)",
|
|
marginTop: 4,
|
|
textShadowColor: "rgba(0,0,0,0.8)",
|
|
textShadowOffset: { width: 0, height: 1 },
|
|
textShadowRadius: 2,
|
|
}}
|
|
>
|
|
{library.itemCount} {itemTypeName}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
|
|
{/* Arrow Indicator */}
|
|
<Animated.View
|
|
style={{
|
|
opacity: focused ? 1 : 0.5,
|
|
}}
|
|
>
|
|
<Ionicons name='chevron-forward' size={32} color='#FFFFFF' />
|
|
</Animated.View>
|
|
</View>
|
|
</Animated.View>
|
|
</Pressable>
|
|
);
|
|
};
|
|
|
|
export const TVLibraries: React.FC = () => {
|
|
const [api] = useAtom(apiAtom);
|
|
const [user] = useAtom(userAtom);
|
|
const { settings } = useSettings();
|
|
const insets = useSafeAreaInsets();
|
|
const router = useRouter();
|
|
const { t } = useTranslation();
|
|
const typography = useScaledTVTypography();
|
|
|
|
const { data: userViews, isLoading: viewsLoading } = useQuery({
|
|
queryKey: ["user-views", user?.Id],
|
|
queryFn: async () => {
|
|
const response = await getUserViewsApi(api!).getUserViews({
|
|
userId: user?.Id,
|
|
});
|
|
return response.data.Items || [];
|
|
},
|
|
staleTime: 60 * 1000,
|
|
enabled: !!api && !!user?.Id,
|
|
});
|
|
|
|
const libraries = useMemo(
|
|
() =>
|
|
userViews
|
|
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
|
|
.filter((l) => l.CollectionType !== "books")
|
|
.filter((l) => l.CollectionType !== "music")
|
|
.filter((l) => l.CollectionType !== "playlists") || [],
|
|
[userViews, settings?.hiddenLibraries],
|
|
);
|
|
|
|
// Fetch item counts and preview items for each library
|
|
const { data: librariesWithData, isLoading: dataLoading } = useQuery({
|
|
queryKey: ["library-data", libraries.map((l) => l.Id).join(",")],
|
|
queryFn: async () => {
|
|
const results: LibraryWithPreview[] = await Promise.all(
|
|
libraries.map(async (library) => {
|
|
let itemType: string | undefined;
|
|
if (library.CollectionType === "movies") itemType = "Movie";
|
|
else if (library.CollectionType === "tvshows") itemType = "Series";
|
|
else if (library.CollectionType === "boxsets") itemType = "BoxSet";
|
|
|
|
// Fetch count
|
|
const countResponse = await getItemsApi(api!).getItems({
|
|
userId: user?.Id,
|
|
parentId: library.Id,
|
|
recursive: true,
|
|
limit: 0,
|
|
includeItemTypes: itemType ? [itemType as any] : undefined,
|
|
});
|
|
|
|
// Fetch preview items with backdrops
|
|
const previewResponse = await getItemsApi(api!).getItems({
|
|
userId: user?.Id,
|
|
parentId: library.Id,
|
|
recursive: true,
|
|
limit: 1,
|
|
sortBy: ["Random"],
|
|
includeItemTypes: itemType ? [itemType as any] : undefined,
|
|
imageTypes: ["Backdrop"],
|
|
});
|
|
|
|
return {
|
|
...library,
|
|
itemCount: countResponse.data.TotalRecordCount,
|
|
previewItems: previewResponse.data.Items || [],
|
|
};
|
|
}),
|
|
);
|
|
return results;
|
|
},
|
|
enabled: !!api && !!user?.Id && libraries.length > 0,
|
|
staleTime: 60 * 1000,
|
|
});
|
|
|
|
const handleLibraryPress = useCallback(
|
|
(library: BaseItemDto) => {
|
|
if (library.CollectionType === "music") {
|
|
router.push({
|
|
pathname: `/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions`,
|
|
params: { libraryId: library.Id! },
|
|
});
|
|
} else {
|
|
router.push({
|
|
pathname: "/(auth)/(tabs)/(libraries)/[libraryId]",
|
|
params: { libraryId: library.Id! },
|
|
});
|
|
}
|
|
},
|
|
[router],
|
|
);
|
|
|
|
const renderItem = useCallback(
|
|
({ item, index }: { item: LibraryWithPreview; index: number }) => (
|
|
<View style={{ marginBottom: CARD_GAP, paddingHorizontal: 8 }}>
|
|
<TVLibraryRow
|
|
library={item}
|
|
isFirst={index === 0}
|
|
onPress={() => handleLibraryPress(item)}
|
|
/>
|
|
</View>
|
|
),
|
|
[handleLibraryPress],
|
|
);
|
|
|
|
const isLoading = viewsLoading || dataLoading;
|
|
const displayLibraries = librariesWithData || libraries;
|
|
|
|
if (isLoading && libraries.length === 0) {
|
|
return (
|
|
<View
|
|
style={{
|
|
flex: 1,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<Loader />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (!displayLibraries || displayLibraries.length === 0) {
|
|
return (
|
|
<View
|
|
style={{
|
|
flex: 1,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<Text style={{ fontSize: typography.body, color: "#737373" }}>
|
|
{t("library.no_libraries_found")}
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View
|
|
style={{
|
|
flex: 1,
|
|
paddingTop: insets.top + 80,
|
|
paddingBottom: insets.bottom + 40,
|
|
}}
|
|
>
|
|
<FlatList
|
|
data={displayLibraries}
|
|
keyExtractor={(item) => item.Id || ""}
|
|
renderItem={renderItem}
|
|
showsVerticalScrollIndicator={false}
|
|
removeClippedSubviews={false}
|
|
contentContainerStyle={{
|
|
paddingBottom: 40,
|
|
paddingHorizontal: insets.left + HORIZONTAL_PADDING,
|
|
paddingVertical: SCALE_PADDING,
|
|
}}
|
|
/>
|
|
</View>
|
|
);
|
|
};
|