mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-02-24 21:12:23 +00:00
499 lines
14 KiB
TypeScript
499 lines
14 KiB
TypeScript
import { Ionicons } from "@expo/vector-icons";
|
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
import {
|
|
type QueryFunction,
|
|
type QueryKey,
|
|
useInfiniteQuery,
|
|
} from "@tanstack/react-query";
|
|
import { useSegments } from "expo-router";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
ActivityIndicator,
|
|
FlatList,
|
|
View,
|
|
type ViewProps,
|
|
} from "react-native";
|
|
import { Text } from "@/components/common/Text";
|
|
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
|
|
import MoviePoster, {
|
|
TV_POSTER_WIDTH,
|
|
} from "@/components/posters/MoviePoster.tv";
|
|
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
|
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
|
import useRouter from "@/hooks/useAppRouter";
|
|
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
|
|
import ContinueWatchingPoster, {
|
|
TV_LANDSCAPE_WIDTH,
|
|
} from "../ContinueWatchingPoster.tv";
|
|
import SeriesPoster from "../posters/SeriesPoster.tv";
|
|
|
|
const ITEM_GAP = 24;
|
|
// Extra padding to accommodate scale animation (1.05x) and glow shadow
|
|
const SCALE_PADDING = 20;
|
|
|
|
interface Props extends ViewProps {
|
|
title?: string | null;
|
|
orientation?: "horizontal" | "vertical";
|
|
disabled?: boolean;
|
|
queryKey: QueryKey;
|
|
queryFn: QueryFunction<BaseItemDto[], QueryKey, number>;
|
|
hideIfEmpty?: boolean;
|
|
pageSize?: number;
|
|
onPressSeeAll?: () => void;
|
|
enabled?: boolean;
|
|
onLoaded?: () => void;
|
|
isFirstSection?: boolean;
|
|
onItemFocus?: (item: BaseItemDto) => void;
|
|
parentId?: string;
|
|
}
|
|
|
|
type Typography = ReturnType<typeof useScaledTVTypography>;
|
|
|
|
// TV-specific ItemCardText with larger fonts
|
|
const TVItemCardText: React.FC<{
|
|
item: BaseItemDto;
|
|
typography: Typography;
|
|
}> = ({ item, typography }) => {
|
|
return (
|
|
<View style={{ marginTop: 12, flexDirection: "column" }}>
|
|
{item.Type === "Episode" ? (
|
|
<>
|
|
<Text
|
|
numberOfLines={1}
|
|
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
|
>
|
|
{item.Name}
|
|
</Text>
|
|
<Text
|
|
numberOfLines={1}
|
|
style={{
|
|
fontSize: typography.callout,
|
|
color: "#9CA3AF",
|
|
marginTop: 2,
|
|
}}
|
|
>
|
|
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
|
|
{" - "}
|
|
{item.SeriesName}
|
|
</Text>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Text
|
|
numberOfLines={1}
|
|
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
|
>
|
|
{item.Name}
|
|
</Text>
|
|
<Text
|
|
style={{
|
|
fontSize: typography.callout,
|
|
color: "#9CA3AF",
|
|
marginTop: 2,
|
|
}}
|
|
>
|
|
{item.ProductionYear}
|
|
</Text>
|
|
</>
|
|
)}
|
|
</View>
|
|
);
|
|
};
|
|
|
|
// TV-specific "See All" card for end of lists
|
|
const TVSeeAllCard: React.FC<{
|
|
onPress: () => void;
|
|
orientation: "horizontal" | "vertical";
|
|
disabled?: boolean;
|
|
onFocus?: () => void;
|
|
onBlur?: () => void;
|
|
typography: Typography;
|
|
}> = ({ onPress, orientation, disabled, onFocus, onBlur, typography }) => {
|
|
const { t } = useTranslation();
|
|
const width =
|
|
orientation === "horizontal" ? TV_LANDSCAPE_WIDTH : TV_POSTER_WIDTH;
|
|
const aspectRatio = orientation === "horizontal" ? 16 / 9 : 10 / 15;
|
|
|
|
return (
|
|
<View style={{ width }}>
|
|
<TVFocusablePoster
|
|
onPress={onPress}
|
|
disabled={disabled}
|
|
onFocus={onFocus}
|
|
onBlur={onBlur}
|
|
>
|
|
<View
|
|
style={{
|
|
width,
|
|
aspectRatio,
|
|
borderRadius: 24,
|
|
backgroundColor: "rgba(255, 255, 255, 0.08)",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
borderWidth: 1,
|
|
borderColor: "rgba(255, 255, 255, 0.15)",
|
|
}}
|
|
>
|
|
<Ionicons
|
|
name='arrow-forward'
|
|
size={32}
|
|
color='white'
|
|
style={{ marginBottom: 8 }}
|
|
/>
|
|
<Text
|
|
style={{
|
|
fontSize: typography.callout,
|
|
color: "#FFFFFF",
|
|
fontWeight: "600",
|
|
}}
|
|
>
|
|
{t("common.seeAll", { defaultValue: "See all" })}
|
|
</Text>
|
|
</View>
|
|
</TVFocusablePoster>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|
title,
|
|
orientation = "vertical",
|
|
disabled = false,
|
|
queryFn,
|
|
queryKey,
|
|
hideIfEmpty = false,
|
|
pageSize = 10,
|
|
enabled = true,
|
|
onLoaded,
|
|
isFirstSection = false,
|
|
onItemFocus,
|
|
parentId,
|
|
...props
|
|
}) => {
|
|
const typography = useScaledTVTypography();
|
|
const effectivePageSize = Math.max(1, pageSize);
|
|
const hasCalledOnLoaded = useRef(false);
|
|
const router = useRouter();
|
|
const segments = useSegments();
|
|
const from = (segments as string[])[2] || "(home)";
|
|
|
|
// Track focus within section for item focus/blur callbacks
|
|
const flatListRef = useRef<FlatList<BaseItemDto>>(null);
|
|
const [_focusedCount, setFocusedCount] = useState(0);
|
|
|
|
const handleItemFocus = useCallback(
|
|
(item: BaseItemDto) => {
|
|
setFocusedCount((c) => c + 1);
|
|
onItemFocus?.(item);
|
|
},
|
|
[onItemFocus],
|
|
);
|
|
|
|
const handleItemBlur = useCallback(() => {
|
|
setFocusedCount((c) => Math.max(0, c - 1));
|
|
}, []);
|
|
|
|
// Focus handler for See All card (doesn't need item parameter)
|
|
const handleSeeAllFocus = useCallback(() => {
|
|
setFocusedCount((c) => c + 1);
|
|
}, []);
|
|
|
|
const {
|
|
data,
|
|
isLoading,
|
|
isFetchingNextPage,
|
|
hasNextPage,
|
|
fetchNextPage,
|
|
isSuccess,
|
|
} = useInfiniteQuery({
|
|
queryKey: queryKey,
|
|
queryFn: ({ pageParam = 0, ...context }) =>
|
|
queryFn({ ...context, queryKey, pageParam }),
|
|
getNextPageParam: (lastPage, allPages) => {
|
|
if (lastPage.length < effectivePageSize) {
|
|
return undefined;
|
|
}
|
|
return allPages.reduce((acc, page) => acc + page.length, 0);
|
|
},
|
|
initialPageParam: 0,
|
|
staleTime: 60 * 1000,
|
|
refetchInterval: 60 * 1000,
|
|
refetchOnWindowFocus: false,
|
|
refetchOnReconnect: true,
|
|
enabled,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (isSuccess && !hasCalledOnLoaded.current && onLoaded) {
|
|
hasCalledOnLoaded.current = true;
|
|
onLoaded();
|
|
}
|
|
}, [isSuccess, onLoaded]);
|
|
|
|
const { t } = useTranslation();
|
|
|
|
const allItems = useMemo(() => {
|
|
const items = data?.pages.flat() ?? [];
|
|
const seen = new Set<string>();
|
|
const deduped: BaseItemDto[] = [];
|
|
|
|
for (const item of items) {
|
|
const id = item.Id;
|
|
if (!id) continue;
|
|
if (seen.has(id)) continue;
|
|
seen.add(id);
|
|
deduped.push(item);
|
|
}
|
|
|
|
return deduped;
|
|
}, [data]);
|
|
|
|
const itemWidth =
|
|
orientation === "horizontal" ? TV_LANDSCAPE_WIDTH : TV_POSTER_WIDTH;
|
|
|
|
const handleItemPress = useCallback(
|
|
(item: BaseItemDto) => {
|
|
const navigation = getItemNavigation(item, from);
|
|
router.push(navigation as any);
|
|
},
|
|
[from, router],
|
|
);
|
|
|
|
const handleEndReached = useCallback(() => {
|
|
if (hasNextPage && !isFetchingNextPage) {
|
|
fetchNextPage();
|
|
}
|
|
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
|
|
|
const handleSeeAllPress = useCallback(() => {
|
|
if (!parentId) return;
|
|
router.push({
|
|
pathname: "/(auth)/(tabs)/(libraries)/[libraryId]",
|
|
params: {
|
|
libraryId: parentId,
|
|
sortBy: SortByOption.DateCreated,
|
|
sortOrder: SortOrderOption.Descending,
|
|
},
|
|
} as any);
|
|
}, [router, parentId]);
|
|
|
|
const getItemLayout = useCallback(
|
|
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
|
|
length: itemWidth + ITEM_GAP,
|
|
offset: (itemWidth + ITEM_GAP) * index,
|
|
index,
|
|
}),
|
|
[itemWidth],
|
|
);
|
|
|
|
const renderItem = useCallback(
|
|
({ item, index }: { item: BaseItemDto; index: number }) => {
|
|
const isFirstItem = isFirstSection && index === 0;
|
|
const isHorizontal = orientation === "horizontal";
|
|
|
|
const renderPoster = () => {
|
|
if (item.Type === "Episode" && isHorizontal) {
|
|
return <ContinueWatchingPoster item={item} />;
|
|
}
|
|
if (item.Type === "Episode" && !isHorizontal) {
|
|
return <SeriesPoster item={item} />;
|
|
}
|
|
if (item.Type === "Movie" && isHorizontal) {
|
|
return <ContinueWatchingPoster item={item} />;
|
|
}
|
|
if (item.Type === "Movie" && !isHorizontal) {
|
|
return <MoviePoster item={item} />;
|
|
}
|
|
if (item.Type === "Series" && !isHorizontal) {
|
|
return <SeriesPoster item={item} />;
|
|
}
|
|
if (item.Type === "Series" && isHorizontal) {
|
|
return <ContinueWatchingPoster item={item} />;
|
|
}
|
|
if (item.Type === "Program") {
|
|
return <ContinueWatchingPoster item={item} />;
|
|
}
|
|
if (item.Type === "BoxSet" && !isHorizontal) {
|
|
return <MoviePoster item={item} />;
|
|
}
|
|
if (item.Type === "BoxSet" && isHorizontal) {
|
|
return <ContinueWatchingPoster item={item} />;
|
|
}
|
|
if (item.Type === "Playlist" && !isHorizontal) {
|
|
return <MoviePoster item={item} />;
|
|
}
|
|
if (item.Type === "Playlist" && isHorizontal) {
|
|
return <ContinueWatchingPoster item={item} />;
|
|
}
|
|
if (item.Type === "Video" && !isHorizontal) {
|
|
return <MoviePoster item={item} />;
|
|
}
|
|
if (item.Type === "Video" && isHorizontal) {
|
|
return <ContinueWatchingPoster item={item} />;
|
|
}
|
|
// Default fallback
|
|
return isHorizontal ? (
|
|
<ContinueWatchingPoster item={item} />
|
|
) : (
|
|
<MoviePoster item={item} />
|
|
);
|
|
};
|
|
|
|
return (
|
|
<View style={{ marginRight: ITEM_GAP, width: itemWidth }}>
|
|
<TVFocusablePoster
|
|
onPress={() => handleItemPress(item)}
|
|
hasTVPreferredFocus={isFirstItem}
|
|
onFocus={() => handleItemFocus(item)}
|
|
onBlur={handleItemBlur}
|
|
>
|
|
{renderPoster()}
|
|
</TVFocusablePoster>
|
|
<TVItemCardText item={item} typography={typography} />
|
|
</View>
|
|
);
|
|
},
|
|
[
|
|
orientation,
|
|
isFirstSection,
|
|
itemWidth,
|
|
handleItemPress,
|
|
handleItemFocus,
|
|
handleItemBlur,
|
|
typography,
|
|
],
|
|
);
|
|
|
|
if (hideIfEmpty === true && allItems.length === 0 && !isLoading) return null;
|
|
if (disabled || !title) return null;
|
|
|
|
return (
|
|
<View style={{ overflow: "visible" }} {...props}>
|
|
{/* Section Header */}
|
|
<Text
|
|
style={{
|
|
fontSize: typography.heading,
|
|
fontWeight: "700",
|
|
color: "#FFFFFF",
|
|
marginBottom: 20,
|
|
marginLeft: SCALE_PADDING,
|
|
letterSpacing: 0.5,
|
|
}}
|
|
>
|
|
{title}
|
|
</Text>
|
|
|
|
{isLoading === false && allItems.length === 0 && (
|
|
<Text
|
|
style={{
|
|
color: "#737373",
|
|
fontSize: typography.callout,
|
|
marginLeft: SCALE_PADDING,
|
|
}}
|
|
>
|
|
{t("home.no_items")}
|
|
</Text>
|
|
)}
|
|
|
|
{isLoading ? (
|
|
<View
|
|
style={{
|
|
flexDirection: "row",
|
|
gap: ITEM_GAP,
|
|
paddingHorizontal: SCALE_PADDING,
|
|
paddingVertical: SCALE_PADDING,
|
|
}}
|
|
>
|
|
{[1, 2, 3, 4, 5].map((i) => (
|
|
<View key={i} style={{ width: itemWidth }}>
|
|
<View
|
|
style={{
|
|
backgroundColor: "#262626",
|
|
width: itemWidth,
|
|
aspectRatio: orientation === "horizontal" ? 16 / 9 : 10 / 15,
|
|
borderRadius: 12,
|
|
marginBottom: 8,
|
|
}}
|
|
/>
|
|
<View
|
|
style={{
|
|
borderRadius: 6,
|
|
overflow: "hidden",
|
|
marginBottom: 4,
|
|
alignSelf: "flex-start",
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
color: "#262626",
|
|
backgroundColor: "#262626",
|
|
borderRadius: 6,
|
|
fontSize: typography.callout,
|
|
}}
|
|
numberOfLines={1}
|
|
>
|
|
Placeholder text here
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
))}
|
|
</View>
|
|
) : (
|
|
<FlatList
|
|
ref={flatListRef}
|
|
horizontal
|
|
data={allItems}
|
|
keyExtractor={(item) => item.Id!}
|
|
renderItem={renderItem}
|
|
showsHorizontalScrollIndicator={false}
|
|
onEndReached={handleEndReached}
|
|
onEndReachedThreshold={0.5}
|
|
initialNumToRender={5}
|
|
maxToRenderPerBatch={3}
|
|
windowSize={5}
|
|
removeClippedSubviews={false}
|
|
getItemLayout={getItemLayout}
|
|
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
|
|
style={{ overflow: "visible" }}
|
|
contentContainerStyle={{
|
|
paddingVertical: SCALE_PADDING,
|
|
paddingHorizontal: SCALE_PADDING,
|
|
}}
|
|
ListFooterComponent={
|
|
<View
|
|
style={{
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
{isFetchingNextPage && (
|
|
<View
|
|
style={{
|
|
marginLeft: itemWidth / 2,
|
|
marginRight: ITEM_GAP,
|
|
justifyContent: "center",
|
|
height: orientation === "horizontal" ? 191 : 315,
|
|
}}
|
|
>
|
|
<ActivityIndicator size='small' color='white' />
|
|
</View>
|
|
)}
|
|
{parentId && allItems.length > 0 && (
|
|
<TVSeeAllCard
|
|
onPress={handleSeeAllPress}
|
|
orientation={orientation}
|
|
disabled={disabled}
|
|
onFocus={handleSeeAllFocus}
|
|
onBlur={handleItemBlur}
|
|
typography={typography}
|
|
/>
|
|
)}
|
|
</View>
|
|
}
|
|
/>
|
|
)}
|
|
</View>
|
|
);
|
|
};
|