mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-03-12 12:26:17 +00:00
350 lines
9.5 KiB
TypeScript
350 lines
9.5 KiB
TypeScript
import type {
|
|
BaseItemDto,
|
|
PublicSystemInfo,
|
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
import { getItemsApi, getSystemApi } from "@jellyfin/sdk/lib/utils/api";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { useSegments } from "expo-router";
|
|
import { useAtomValue } from "jotai";
|
|
import { useCallback, useMemo } from "react";
|
|
import { 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 SeriesPoster from "@/components/posters/SeriesPoster.tv";
|
|
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
|
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
|
import useRouter from "@/hooks/useAppRouter";
|
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
import { useSettings } from "@/utils/atoms/settings";
|
|
import { createStreamystatsApi } from "@/utils/streamystats/api";
|
|
import type { StreamystatsWatchlist } from "@/utils/streamystats/types";
|
|
|
|
const ITEM_GAP = 16;
|
|
const SCALE_PADDING = 20;
|
|
|
|
type Typography = ReturnType<typeof useScaledTVTypography>;
|
|
|
|
const TVItemCardText: React.FC<{
|
|
item: BaseItemDto;
|
|
typography: Typography;
|
|
}> = ({ item, typography }) => {
|
|
return (
|
|
<View style={{ marginTop: 12, flexDirection: "column" }}>
|
|
<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>
|
|
);
|
|
};
|
|
|
|
interface WatchlistSectionProps extends ViewProps {
|
|
watchlist: StreamystatsWatchlist;
|
|
jellyfinServerId: string;
|
|
onItemFocus?: (item: BaseItemDto) => void;
|
|
}
|
|
|
|
const WatchlistSection: React.FC<WatchlistSectionProps> = ({
|
|
watchlist,
|
|
jellyfinServerId,
|
|
onItemFocus,
|
|
...props
|
|
}) => {
|
|
const typography = useScaledTVTypography();
|
|
const api = useAtomValue(apiAtom);
|
|
const user = useAtomValue(userAtom);
|
|
const { settings } = useSettings();
|
|
const router = useRouter();
|
|
const segments = useSegments();
|
|
const from = (segments as string[])[2] || "(home)";
|
|
|
|
const { data: items, isLoading } = useQuery({
|
|
queryKey: [
|
|
"streamystats",
|
|
"watchlist",
|
|
watchlist.id,
|
|
jellyfinServerId,
|
|
settings?.streamyStatsServerUrl,
|
|
],
|
|
queryFn: async (): Promise<BaseItemDto[]> => {
|
|
if (!settings?.streamyStatsServerUrl || !api?.accessToken || !user?.Id) {
|
|
return [];
|
|
}
|
|
|
|
const streamystatsApi = createStreamystatsApi({
|
|
serverUrl: settings.streamyStatsServerUrl,
|
|
jellyfinToken: api.accessToken,
|
|
});
|
|
|
|
const watchlistDetail = await streamystatsApi.getWatchlistItemIds({
|
|
watchlistId: watchlist.id,
|
|
jellyfinServerId,
|
|
});
|
|
|
|
const itemIds = watchlistDetail.data?.items;
|
|
if (!itemIds?.length) {
|
|
return [];
|
|
}
|
|
|
|
const response = await getItemsApi(api).getItems({
|
|
userId: user.Id,
|
|
ids: itemIds,
|
|
fields: ["PrimaryImageAspectRatio", "Genres"],
|
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
|
});
|
|
|
|
return response.data.Items || [];
|
|
},
|
|
enabled:
|
|
Boolean(settings?.streamyStatsServerUrl) &&
|
|
Boolean(api?.accessToken) &&
|
|
Boolean(user?.Id),
|
|
staleTime: 60 * 1000,
|
|
refetchInterval: 60 * 1000,
|
|
refetchOnWindowFocus: false,
|
|
});
|
|
|
|
const handleItemPress = useCallback(
|
|
(item: BaseItemDto) => {
|
|
const navigation = getItemNavigation(item, from);
|
|
router.push(navigation as any);
|
|
},
|
|
[from, router],
|
|
);
|
|
|
|
const getItemLayout = useCallback(
|
|
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
|
|
length: TV_POSTER_WIDTH + ITEM_GAP,
|
|
offset: (TV_POSTER_WIDTH + ITEM_GAP) * index,
|
|
index,
|
|
}),
|
|
[],
|
|
);
|
|
|
|
const renderItem = useCallback(
|
|
({ item }: { item: BaseItemDto }) => {
|
|
return (
|
|
<View style={{ marginRight: ITEM_GAP, width: TV_POSTER_WIDTH }}>
|
|
<TVFocusablePoster
|
|
onPress={() => handleItemPress(item)}
|
|
onFocus={() => onItemFocus?.(item)}
|
|
hasTVPreferredFocus={false}
|
|
>
|
|
{item.Type === "Movie" && <MoviePoster item={item} />}
|
|
{item.Type === "Series" && <SeriesPoster item={item} />}
|
|
</TVFocusablePoster>
|
|
<TVItemCardText item={item} typography={typography} />
|
|
</View>
|
|
);
|
|
},
|
|
[handleItemPress, onItemFocus, typography],
|
|
);
|
|
|
|
if (!isLoading && (!items || items.length === 0)) return null;
|
|
|
|
return (
|
|
<View style={{ overflow: "visible" }} {...props}>
|
|
<Text
|
|
style={{
|
|
fontSize: typography.heading,
|
|
fontWeight: "700",
|
|
color: "#FFFFFF",
|
|
marginBottom: 20,
|
|
marginLeft: SCALE_PADDING,
|
|
letterSpacing: 0.5,
|
|
}}
|
|
>
|
|
{watchlist.name}
|
|
</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: TV_POSTER_WIDTH }}>
|
|
<View
|
|
style={{
|
|
backgroundColor: "#262626",
|
|
width: TV_POSTER_WIDTH,
|
|
aspectRatio: 10 / 15,
|
|
borderRadius: 12,
|
|
marginBottom: 8,
|
|
}}
|
|
/>
|
|
</View>
|
|
))}
|
|
</View>
|
|
) : (
|
|
<FlatList
|
|
horizontal
|
|
data={items}
|
|
keyExtractor={(item) => item.Id!}
|
|
renderItem={renderItem}
|
|
showsHorizontalScrollIndicator={false}
|
|
initialNumToRender={5}
|
|
maxToRenderPerBatch={3}
|
|
windowSize={5}
|
|
removeClippedSubviews={false}
|
|
getItemLayout={getItemLayout}
|
|
style={{ overflow: "visible" }}
|
|
contentContainerStyle={{
|
|
paddingVertical: SCALE_PADDING,
|
|
paddingHorizontal: SCALE_PADDING,
|
|
}}
|
|
/>
|
|
)}
|
|
</View>
|
|
);
|
|
};
|
|
|
|
interface StreamystatsPromotedWatchlistsProps extends ViewProps {
|
|
enabled?: boolean;
|
|
onItemFocus?: (item: BaseItemDto) => void;
|
|
}
|
|
|
|
export const StreamystatsPromotedWatchlists: React.FC<
|
|
StreamystatsPromotedWatchlistsProps
|
|
> = ({ enabled = true, onItemFocus, ...props }) => {
|
|
const api = useAtomValue(apiAtom);
|
|
const user = useAtomValue(userAtom);
|
|
const { settings } = useSettings();
|
|
|
|
const streamyStatsEnabled = useMemo(() => {
|
|
return Boolean(settings?.streamyStatsServerUrl);
|
|
}, [settings?.streamyStatsServerUrl]);
|
|
|
|
const { data: serverInfo } = useQuery({
|
|
queryKey: ["jellyfin", "serverInfo"],
|
|
queryFn: async (): Promise<PublicSystemInfo | null> => {
|
|
if (!api) return null;
|
|
const response = await getSystemApi(api).getPublicSystemInfo();
|
|
return response.data;
|
|
},
|
|
enabled: enabled && Boolean(api) && streamyStatsEnabled,
|
|
staleTime: 60 * 60 * 1000,
|
|
});
|
|
|
|
const jellyfinServerId = serverInfo?.Id;
|
|
|
|
const {
|
|
data: watchlists,
|
|
isLoading,
|
|
isError,
|
|
} = useQuery({
|
|
queryKey: [
|
|
"streamystats",
|
|
"promotedWatchlists",
|
|
jellyfinServerId,
|
|
settings?.streamyStatsServerUrl,
|
|
],
|
|
queryFn: async (): Promise<StreamystatsWatchlist[]> => {
|
|
if (
|
|
!settings?.streamyStatsServerUrl ||
|
|
!api?.accessToken ||
|
|
!jellyfinServerId
|
|
) {
|
|
return [];
|
|
}
|
|
|
|
const streamystatsApi = createStreamystatsApi({
|
|
serverUrl: settings.streamyStatsServerUrl,
|
|
jellyfinToken: api.accessToken,
|
|
});
|
|
|
|
const response = await streamystatsApi.getPromotedWatchlists({
|
|
jellyfinServerId,
|
|
includePreview: false,
|
|
});
|
|
|
|
return response.data || [];
|
|
},
|
|
enabled:
|
|
enabled &&
|
|
streamyStatsEnabled &&
|
|
Boolean(api?.accessToken) &&
|
|
Boolean(jellyfinServerId) &&
|
|
Boolean(user?.Id),
|
|
staleTime: 60 * 1000,
|
|
refetchInterval: 60 * 1000,
|
|
refetchOnWindowFocus: false,
|
|
});
|
|
|
|
if (!streamyStatsEnabled) return null;
|
|
if (isError) return null;
|
|
if (!isLoading && (!watchlists || watchlists.length === 0)) return null;
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<View style={{ overflow: "visible" }} {...props}>
|
|
<View
|
|
style={{
|
|
height: 16,
|
|
width: 128,
|
|
backgroundColor: "#262626",
|
|
borderRadius: 4,
|
|
marginLeft: SCALE_PADDING,
|
|
marginBottom: 16,
|
|
}}
|
|
/>
|
|
<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: TV_POSTER_WIDTH }}>
|
|
<View
|
|
style={{
|
|
backgroundColor: "#262626",
|
|
width: TV_POSTER_WIDTH,
|
|
aspectRatio: 10 / 15,
|
|
borderRadius: 12,
|
|
marginBottom: 8,
|
|
}}
|
|
/>
|
|
</View>
|
|
))}
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{watchlists?.map((watchlist) => (
|
|
<WatchlistSection
|
|
key={watchlist.id}
|
|
watchlist={watchlist}
|
|
jellyfinServerId={jellyfinServerId!}
|
|
onItemFocus={onItemFocus}
|
|
{...props}
|
|
/>
|
|
))}
|
|
</>
|
|
);
|
|
};
|