mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-09 20:29:36 +02:00
add infinite loader for feature carousel
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import type { MouseEvent } from 'react';
|
import type { MouseEvent } from 'react';
|
||||||
|
|
||||||
import { AnimatePresence, motion } from 'motion/react';
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
import { useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { generatePath, Link } from 'react-router';
|
import { generatePath, Link } from 'react-router';
|
||||||
|
|
||||||
import styles from './feature-carousel.module.css';
|
import styles from './feature-carousel.module.css';
|
||||||
@@ -71,6 +71,7 @@ const itemVariants = {
|
|||||||
|
|
||||||
interface FeatureCarouselProps {
|
interface FeatureCarouselProps {
|
||||||
data: Album[] | undefined;
|
data: Album[] | undefined;
|
||||||
|
onNearEnd?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getItemsPerRow = (breakpoints: {
|
const getItemsPerRow = (breakpoints: {
|
||||||
@@ -173,7 +174,7 @@ const CarouselItem = ({ album }: CarouselItemProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
|
export const FeatureCarousel = ({ data, onNearEnd }: FeatureCarouselProps) => {
|
||||||
const [startIndex, setStartIndex] = useState(0);
|
const [startIndex, setStartIndex] = useState(0);
|
||||||
const directionRef = useRef<{ isNext: boolean }>({ isNext: true });
|
const directionRef = useRef<{ isNext: boolean }>({ isNext: true });
|
||||||
const {
|
const {
|
||||||
@@ -208,6 +209,16 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
|
|||||||
return items;
|
return items;
|
||||||
}, [data, startIndex, itemsPerRow]);
|
}, [data, startIndex, itemsPerRow]);
|
||||||
|
|
||||||
|
// Check if we're near the end and trigger loading more
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data || !onNearEnd) return;
|
||||||
|
const remainingItems = data.length - startIndex;
|
||||||
|
// Trigger when we have less than 2 rows worth of items remaining
|
||||||
|
if (remainingItems < itemsPerRow * 2) {
|
||||||
|
onNearEnd();
|
||||||
|
}
|
||||||
|
}, [data, startIndex, itemsPerRow, onNearEnd]);
|
||||||
|
|
||||||
const handleNext = (e?: MouseEvent<HTMLButtonElement>) => {
|
const handleNext = (e?: MouseEvent<HTMLButtonElement>) => {
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
e?.stopPropagation();
|
e?.stopPropagation();
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { useSuspenseInfiniteQuery } from '@tanstack/react-query';
|
||||||
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
|
import { api } from '/@/renderer/api';
|
||||||
|
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||||
|
import { FeatureCarousel } from '/@/renderer/components/feature-carousel/feature-carousel';
|
||||||
|
import { useCurrentServerId } from '/@/renderer/store';
|
||||||
|
import { Album, AlbumListResponse, AlbumListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
|
interface InfiniteAlbumFeatureCarouselProps {
|
||||||
|
itemLimit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AlbumInfiniteFeatureCarousel = ({
|
||||||
|
itemLimit = 20,
|
||||||
|
}: InfiniteAlbumFeatureCarouselProps) => {
|
||||||
|
const serverId = useCurrentServerId();
|
||||||
|
const loadMoreTriggeredRef = useRef(false);
|
||||||
|
|
||||||
|
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
|
||||||
|
useSuspenseInfiniteQuery<AlbumListResponse>({
|
||||||
|
getNextPageParam: (lastPage, _allPages, lastPageParam) => {
|
||||||
|
if (lastPage.items.length < itemLimit) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextPageParam = Number(lastPageParam) + itemLimit;
|
||||||
|
|
||||||
|
return String(nextPageParam);
|
||||||
|
},
|
||||||
|
initialPageParam: '0',
|
||||||
|
queryFn: ({ pageParam, signal }) => {
|
||||||
|
return api.controller.getAlbumList({
|
||||||
|
apiClientProps: { serverId, signal },
|
||||||
|
query: {
|
||||||
|
limit: itemLimit,
|
||||||
|
sortBy: AlbumListSort.RANDOM,
|
||||||
|
sortOrder: SortOrder.DESC,
|
||||||
|
startIndex: Number(pageParam),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
queryKey: queryKeys.albums.infiniteList(serverId, {
|
||||||
|
sortBy: AlbumListSort.RANDOM,
|
||||||
|
sortOrder: SortOrder.DESC,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Flatten all pages and filter for albums with images
|
||||||
|
const albumsWithImages = useMemo(() => {
|
||||||
|
const allAlbums = data.pages.flatMap((page: AlbumListResponse) => page.items);
|
||||||
|
// Filter for albums with images and remove duplicates by ID
|
||||||
|
const uniqueAlbums = new Map<string, Album>();
|
||||||
|
for (const album of allAlbums) {
|
||||||
|
if (album.imageUrl && !uniqueAlbums.has(album.id)) {
|
||||||
|
uniqueAlbums.set(album.id, album);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(uniqueAlbums.values());
|
||||||
|
}, [data.pages]);
|
||||||
|
|
||||||
|
const handleNearEnd = () => {
|
||||||
|
if (hasNextPage && !isFetchingNextPage && !loadMoreTriggeredRef.current) {
|
||||||
|
loadMoreTriggeredRef.current = true;
|
||||||
|
fetchNextPage().finally(() => {
|
||||||
|
loadMoreTriggeredRef.current = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
albumsWithImages.length < itemLimit * 2 &&
|
||||||
|
hasNextPage &&
|
||||||
|
!isFetchingNextPage &&
|
||||||
|
!loadMoreTriggeredRef.current
|
||||||
|
) {
|
||||||
|
loadMoreTriggeredRef.current = true;
|
||||||
|
fetchNextPage().finally(() => {
|
||||||
|
loadMoreTriggeredRef.current = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [albumsWithImages.length, hasNextPage, isFetchingNextPage, fetchNextPage, itemLimit]);
|
||||||
|
|
||||||
|
if (albumsWithImages.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <FeatureCarousel data={albumsWithImages} onNearEnd={handleNearEnd} />;
|
||||||
|
};
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
import { Suspense, useRef } from 'react';
|
||||||
import { Suspense, useMemo, useRef } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { FeatureCarousel } from '/@/renderer/components/feature-carousel/feature-carousel';
|
|
||||||
import { NativeScrollArea } from '/@/renderer/components/native-scroll-area/native-scroll-area';
|
import { NativeScrollArea } from '/@/renderer/components/native-scroll-area/native-scroll-area';
|
||||||
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
|
|
||||||
import { AlbumInfiniteCarousel } from '/@/renderer/features/albums/components/album-infinite-carousel';
|
import { AlbumInfiniteCarousel } from '/@/renderer/features/albums/components/album-infinite-carousel';
|
||||||
|
import { AlbumInfiniteFeatureCarousel } from '/@/renderer/features/home/components/album-infinite-feature-carousel';
|
||||||
import { FeaturedGenres } from '/@/renderer/features/home/components/featured-genres';
|
import { FeaturedGenres } from '/@/renderer/features/home/components/featured-genres';
|
||||||
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
|
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
|
||||||
import { LibraryContainer } from '/@/renderer/features/shared/components/library-container';
|
import { LibraryContainer } from '/@/renderer/features/shared/components/library-container';
|
||||||
@@ -31,28 +29,6 @@ const HomeRoute = () => {
|
|||||||
|
|
||||||
const isJellyfin = server?.type === ServerType.JELLYFIN;
|
const isJellyfin = server?.type === ServerType.JELLYFIN;
|
||||||
|
|
||||||
const feature = useSuspenseQuery({
|
|
||||||
...albumQueries.list({
|
|
||||||
options: {
|
|
||||||
enabled: homeFeature,
|
|
||||||
gcTime: 1000 * 30,
|
|
||||||
staleTime: 1000 * 30,
|
|
||||||
},
|
|
||||||
query: {
|
|
||||||
limit: 20,
|
|
||||||
sortBy: AlbumListSort.RANDOM,
|
|
||||||
sortOrder: SortOrder.DESC,
|
|
||||||
startIndex: 0,
|
|
||||||
},
|
|
||||||
serverId: server?.id,
|
|
||||||
}),
|
|
||||||
queryKey: ['home', 'feature'],
|
|
||||||
});
|
|
||||||
|
|
||||||
const featureItemsWithImage = useMemo(() => {
|
|
||||||
return feature.data?.items?.filter((item) => item.imageUrl) ?? [];
|
|
||||||
}, [feature.data?.items]);
|
|
||||||
|
|
||||||
// Carousel configuration - queries are now handled inside AlbumInfiniteCarousel
|
// Carousel configuration - queries are now handled inside AlbumInfiniteCarousel
|
||||||
const carousels = {
|
const carousels = {
|
||||||
[HomeItem.MOST_PLAYED]: {
|
[HomeItem.MOST_PLAYED]: {
|
||||||
@@ -126,7 +102,7 @@ const HomeRoute = () => {
|
|||||||
pt={windowBarStyle === Platform.WEB ? '5rem' : '3rem'}
|
pt={windowBarStyle === Platform.WEB ? '5rem' : '3rem'}
|
||||||
px="2rem"
|
px="2rem"
|
||||||
>
|
>
|
||||||
{homeFeature && <FeatureCarousel data={featureItemsWithImage} />}
|
{homeFeature && <AlbumInfiniteFeatureCarousel />}
|
||||||
<FeaturedGenres />
|
<FeaturedGenres />
|
||||||
{sortedCarousel.map((carousel) => {
|
{sortedCarousel.map((carousel) => {
|
||||||
if (carousel.itemType === LibraryItem.ALBUM) {
|
if (carousel.itemType === LibraryItem.ALBUM) {
|
||||||
|
|||||||
Reference in New Issue
Block a user