diff --git a/src/renderer/components/feature-carousel/feature-carousel.tsx b/src/renderer/components/feature-carousel/feature-carousel.tsx index 8a5f05864..17bdec962 100644 --- a/src/renderer/components/feature-carousel/feature-carousel.tsx +++ b/src/renderer/components/feature-carousel/feature-carousel.tsx @@ -1,7 +1,7 @@ import type { MouseEvent } from '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 styles from './feature-carousel.module.css'; @@ -71,6 +71,7 @@ const itemVariants = { interface FeatureCarouselProps { data: Album[] | undefined; + onNearEnd?: () => void; } 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 directionRef = useRef<{ isNext: boolean }>({ isNext: true }); const { @@ -208,6 +209,16 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => { return items; }, [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) => { e?.preventDefault(); e?.stopPropagation(); diff --git a/src/renderer/features/home/components/album-infinite-feature-carousel.tsx b/src/renderer/features/home/components/album-infinite-feature-carousel.tsx new file mode 100644 index 000000000..853980765 --- /dev/null +++ b/src/renderer/features/home/components/album-infinite-feature-carousel.tsx @@ -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({ + 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(); + 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 ; +}; diff --git a/src/renderer/features/home/routes/home-route.tsx b/src/renderer/features/home/routes/home-route.tsx index f81b1a772..4a989b0e0 100644 --- a/src/renderer/features/home/routes/home-route.tsx +++ b/src/renderer/features/home/routes/home-route.tsx @@ -1,11 +1,9 @@ -import { useSuspenseQuery } from '@tanstack/react-query'; -import { Suspense, useMemo, useRef } from 'react'; +import { Suspense, useRef } from 'react'; 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 { albumQueries } from '/@/renderer/features/albums/api/album-api'; 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 { AnimatedPage } from '/@/renderer/features/shared/components/animated-page'; import { LibraryContainer } from '/@/renderer/features/shared/components/library-container'; @@ -31,28 +29,6 @@ const HomeRoute = () => { 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 const carousels = { [HomeItem.MOST_PLAYED]: { @@ -126,7 +102,7 @@ const HomeRoute = () => { pt={windowBarStyle === Platform.WEB ? '5rem' : '3rem'} px="2rem" > - {homeFeature && } + {homeFeature && } {sortedCarousel.map((carousel) => { if (carousel.itemType === LibraryItem.ALBUM) {