add loading placeholder cards to grid carousel

This commit is contained in:
jeffvli
2026-01-16 01:31:48 -08:00
parent b6a670689c
commit 46b80b9a18
6 changed files with 339 additions and 126 deletions
@@ -6,10 +6,24 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import styles from './grid-carousel.module.css';
import { DataRow, MemoizedItemCard } from '/@/renderer/components/item-card/item-card';
import { useContainerQuery } from '/@/renderer/hooks';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Group } from '/@/shared/components/group/group';
import { TextTitle } from '/@/shared/components/text-title/text-title';
import { LibraryItem } from '/@/shared/types/domain-types';
export const useGridCarouselContainerQuery = () => {
return useContainerQuery({
'2xl': 1280,
'3xl': 1440,
lg: 960,
md: 720,
sm: 520,
xl: 1152,
xs: 360,
});
};
interface Card {
content: ReactNode;
@@ -18,12 +32,16 @@ interface Card {
interface GridCarouselProps {
cards: Card[];
containerQuery?: ReturnType<typeof useGridCarouselContainerQuery>;
enableRefresh?: boolean;
hasNextPage?: boolean;
isFetchingNextPage?: boolean;
loadNextPage?: () => void;
onNextPage: (page: number) => void;
onPrevPage: (page: number) => void;
onRefresh?: () => void;
placeholderItemType?: LibraryItem;
placeholderRows?: DataRow[];
rowCount?: number;
title?: ReactNode | string;
}
@@ -47,24 +65,22 @@ const pageVariants: Variants = {
function BaseGridCarousel(props: GridCarouselProps) {
const {
cards,
containerQuery: providedContainerQuery,
enableRefresh = false,
hasNextPage,
isFetchingNextPage,
loadNextPage,
onNextPage,
onPrevPage,
onRefresh,
placeholderItemType,
placeholderRows,
rowCount = 1,
title,
} = props;
const { ref, ...cq } = useContainerQuery({
'2xl': 1280,
'3xl': 1440,
lg: 960,
md: 720,
sm: 520,
xl: 1152,
xs: 360,
});
const defaultContainerQuery = useGridCarouselContainerQuery();
const containerQuery = providedContainerQuery || defaultContainerQuery;
const { ref, ...cq } = containerQuery;
const [currentPage, setCurrentPage] = useState({
isNext: false,
@@ -97,11 +113,48 @@ function BaseGridCarousel(props: GridCarouselProps) {
});
const visibleCards = useMemo(() => {
return cards.slice(
currentPage.page * cardsToShow * rowCount,
(currentPage.page + 1) * cardsToShow * rowCount,
);
}, [cards, currentPage, cardsToShow, rowCount]);
const startIndex = currentPage.page * cardsToShow * rowCount;
const endIndex = (currentPage.page + 1) * cardsToShow * rowCount;
const slicedCards = cards.slice(startIndex, endIndex);
const expectedCardCount = cardsToShow * rowCount;
const missingCardCount = expectedCardCount - slicedCards.length;
// Add placeholder cards during loading state
if (
missingCardCount > 0 &&
hasNextPage &&
isFetchingNextPage &&
placeholderItemType &&
placeholderRows
) {
const placeholderCards: Card[] = Array.from(
{ length: missingCardCount },
(_, index) => ({
content: (
<MemoizedItemCard
data={undefined}
itemType={placeholderItemType}
rows={placeholderRows}
type="poster"
/>
),
id: `placeholder-${startIndex + slicedCards.length + index}`,
}),
);
return [...slicedCards, ...placeholderCards];
}
return slicedCards;
}, [
currentPage.page,
cardsToShow,
rowCount,
cards,
hasNextPage,
isFetchingNextPage,
placeholderItemType,
placeholderRows,
]);
const shouldLoadNextPage = visibleCards.length < cardsToShow * rowCount;
@@ -249,6 +302,74 @@ export const GridCarousel = memo(BaseGridCarousel);
GridCarousel.displayName = 'GridCarousel';
interface GridCarouselSkeletonProps {
containerQuery?: ReturnType<typeof useGridCarouselContainerQuery>;
enableRefresh?: boolean;
placeholderItemType: LibraryItem;
placeholderRows: DataRow[];
rowCount?: number;
title?: ReactNode | string;
}
const GridCarouselSkeleton = (props: GridCarouselSkeletonProps) => {
const {
containerQuery: providedContainerQuery,
enableRefresh = false,
placeholderItemType,
placeholderRows,
rowCount = 1,
title,
} = props;
const { ...cq } = providedContainerQuery;
const cardsToShow = cq.isCalculated
? getCardsToShow({
isLargerThan2xl: cq.is2xl,
isLargerThan3xl: cq.is3xl,
isLargerThanLg: cq.isLg,
isLargerThanMd: cq.isMd,
isLargerThanSm: cq.isSm,
isLargerThanXl: cq.isXl,
})
: 6;
const placeholderCards = useMemo(() => {
const cardCount = cardsToShow * rowCount;
return Array.from({ length: cardCount }, (_, index) => ({
content: (
<MemoizedItemCard
data={undefined}
itemType={placeholderItemType}
rows={placeholderRows}
type="poster"
/>
),
id: `skeleton-${index}`,
}));
}, [cardsToShow, rowCount, placeholderItemType, placeholderRows]);
return (
<GridCarousel
cards={placeholderCards}
containerQuery={providedContainerQuery}
enableRefresh={enableRefresh}
hasNextPage={false}
isFetchingNextPage={false}
onNextPage={() => {}}
onPrevPage={() => {}}
placeholderItemType={placeholderItemType}
placeholderRows={placeholderRows}
rowCount={rowCount}
title={title}
/>
);
};
export const GridCarouselSkeletonFallback = memo(GridCarouselSkeleton);
GridCarouselSkeletonFallback.displayName = 'GridCarouselSkeletonFallback';
function getCardsToShow(breakpoints: {
isLargerThan2xl: boolean;
isLargerThan3xl: boolean;
@@ -1,10 +1,11 @@
import { useQuery } from '@tanstack/react-query';
import { ReactNode, Suspense, useMemo, useRef, useState } from 'react';
import { useSuspenseQuery } from '@tanstack/react-query';
import { ReactNode, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { generatePath, useParams } from 'react-router';
import styles from './album-detail-content.module.css';
import { useGridCarouselContainerQuery } from '/@/renderer/components/grid-carousel/grid-carousel-v2';
import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';
import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
@@ -18,7 +19,6 @@ import { ListConfigMenu } from '/@/renderer/features/shared/components/list-conf
import { ListSortByDropdownControlled } from '/@/renderer/features/shared/components/list-sort-by-dropdown';
import { ListSortOrderToggleButtonControlled } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';
import { searchLibraryItems } from '/@/renderer/features/shared/utils';
import { useContainerQuery } from '/@/renderer/hooks';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer, usePlayerSong } from '/@/renderer/store';
import { useExternalLinks, useSettingsStore } from '/@/renderer/store/settings.store';
@@ -31,7 +31,6 @@ import { Checkbox } from '/@/shared/components/checkbox/checkbox';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { Pill, PillLink } from '/@/shared/components/pill/pill';
import { Spinner } from '/@/shared/components/spinner/spinner';
import { Spoiler } from '/@/shared/components/spoiler/spoiler';
import { Stack } from '/@/shared/components/stack/stack';
import { TextInput } from '/@/shared/components/text-input/text-input';
@@ -304,68 +303,14 @@ const AlbumMetadataExternalLinks = ({
};
export const AlbumDetailContent = () => {
const { t } = useTranslation();
const { albumId } = useParams() as { albumId: string };
const server = useCurrentServer();
const detailQuery = useQuery(
const detailQuery = useSuspenseQuery(
albumQueries.detail({ query: { id: albumId }, serverId: server.id }),
);
const { ref, ...cq } = useContainerQuery();
const { externalLinks, lastFM, musicBrainz } = useExternalLinks();
const genreCarousels = useMemo(() => {
const genreLimit = 2;
const selectedGenres = detailQuery?.data?.genres?.slice(0, genreLimit);
if (!selectedGenres || selectedGenres.length === 0) return [];
return selectedGenres
.map((genre) => {
const uniqueId = `moreFromGenre-${genre.id}`;
return {
enableRefresh: true,
excludeIds: detailQuery?.data?.id ? [detailQuery.data.id] : undefined,
isHidden: !genre,
query: {
genreIds: [genre.id],
},
rowCount: 1,
sortBy: AlbumListSort.RANDOM,
sortOrder: SortOrder.ASC,
title: sentenceCase(
t('page.albumDetail.moreFromGeneric', {
item: genre.name,
}),
),
uniqueId,
};
})
.filter((carousel) => !carousel.isHidden);
}, [detailQuery.data, t]);
const carousels = useMemo(() => {
const moreFromArtistUniqueId = 'moreFromArtist';
return [
{
enableRefresh: false,
excludeIds: detailQuery?.data?.id ? [detailQuery.data.id] : undefined,
isHidden: !detailQuery?.data?.albumArtists?.[0]?.id,
query: {
artistIds: detailQuery?.data?.albumArtists.length
? [detailQuery?.data?.albumArtists[0].id]
: undefined,
},
rowCount: 1,
sortBy: AlbumListSort.YEAR,
sortOrder: SortOrder.DESC,
title: t('page.albumDetail.moreFromArtist', { postProcess: 'sentenceCase' }),
uniqueId: moreFromArtistUniqueId,
},
...genreCarousels,
];
}, [detailQuery.data, genreCarousels, t]);
const comment = detailQuery?.data?.comment;
const releaseYear = detailQuery?.data?.releaseYear;
@@ -374,7 +319,7 @@ export const AlbumDetailContent = () => {
const mbzId = detailQuery?.data?.mbzId;
return (
<div className={styles.contentContainer} ref={ref}>
<div className={styles.contentContainer}>
<div className={styles.detailContainer}>
{comment && (
<Spoiler maxHeight={75}>
@@ -388,7 +333,6 @@ export const AlbumDetailContent = () => {
)}
</div>
<div className={styles.metadataColumn}>
{/* <AlbumMetadataArtists artists={detailQuery?.data?.albumArtists} /> */}
<AlbumMetadataGenres genres={detailQuery?.data?.genres} />
<AlbumMetadataTags album={detailQuery?.data} />
<AlbumMetadataExternalLinks
@@ -410,26 +354,7 @@ export const AlbumDetailContent = () => {
))}
</Stack>
)}
<Stack gap="lg" mt="3rem">
{cq.height || cq.width ? (
<Suspense fallback={<Spinner container />}>
{carousels
.filter((c) => !c.isHidden)
.map((carousel) => (
<AlbumInfiniteCarousel
enableRefresh={carousel.enableRefresh}
excludeIds={carousel.excludeIds}
key={`carousel-${carousel.uniqueId}`}
query={carousel.query}
rowCount={carousel.rowCount}
sortBy={carousel.sortBy}
sortOrder={carousel.sortOrder}
title={carousel.title}
/>
))}
</Suspense>
) : null}
</Stack>
<AlbumDetailCarousels data={detailQuery?.data} />
</div>
</div>
);
@@ -439,6 +364,82 @@ interface AlbumDetailSongsTableProps {
songs: Song[];
}
function AlbumDetailCarousels({ data }: { data: Album }) {
const { t } = useTranslation();
const genreCarousels = useMemo(() => {
const genreLimit = 2;
const selectedGenres = data?.genres?.slice(0, genreLimit);
if (!selectedGenres || selectedGenres.length === 0) return [];
return selectedGenres
.map((genre) => {
const uniqueId = `moreFromGenre-${genre.id}`;
return {
enableRefresh: true,
excludeIds: data?.id ? [data.id] : undefined,
isHidden: !genre,
query: {
genreIds: [genre.id],
},
rowCount: 1,
sortBy: AlbumListSort.RANDOM,
sortOrder: SortOrder.ASC,
title: sentenceCase(
t('page.albumDetail.moreFromGeneric', {
item: genre.name,
}),
),
uniqueId,
};
})
.filter((carousel) => !carousel.isHidden);
}, [data, t]);
const carousels = useMemo(() => {
const moreFromArtistUniqueId = 'moreFromArtist';
return [
{
enableRefresh: false,
excludeIds: data?.id ? [data.id] : undefined,
isHidden: !data?.albumArtists?.[0]?.id,
query: {
artistIds: data?.albumArtists.length ? [data?.albumArtists[0].id] : undefined,
},
rowCount: 1,
sortBy: AlbumListSort.YEAR,
sortOrder: SortOrder.DESC,
title: t('page.albumDetail.moreFromArtist', { postProcess: 'sentenceCase' }),
uniqueId: moreFromArtistUniqueId,
},
...genreCarousels,
];
}, [data.albumArtists, data.id, genreCarousels, t]);
const cq = useGridCarouselContainerQuery();
return (
<Stack gap="lg" mt="3rem" ref={cq.ref}>
{carousels
.filter((c) => !c.isHidden)
.map((carousel) => (
<AlbumInfiniteCarousel
containerQuery={cq}
enableRefresh={carousel.enableRefresh}
excludeIds={carousel.excludeIds}
key={`carousel-${carousel.uniqueId}`}
query={carousel.query}
rowCount={carousel.rowCount}
sortBy={carousel.sortBy}
sortOrder={carousel.sortOrder}
title={carousel.title}
/>
))}
</Stack>
);
}
const AlbumDetailSongsTable = ({ songs }: AlbumDetailSongsTableProps) => {
const { t } = useTranslation();
const [searchTerm, setSearchTerm] = useState('');
@@ -1,10 +1,14 @@
import { useSuspenseInfiniteQuery } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react';
import { Suspense, useCallback, useMemo } from 'react';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { GridCarousel } from '/@/renderer/components/grid-carousel/grid-carousel-v2';
import { MemoizedItemCard } from '/@/renderer/components/item-card/item-card';
import {
GridCarousel,
GridCarouselSkeletonFallback,
useGridCarouselContainerQuery,
} from '/@/renderer/components/grid-carousel/grid-carousel-v2';
import { DataRow, MemoizedItemCard } from '/@/renderer/components/item-card/item-card';
import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
import { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-rows';
import { useCurrentServerId } from '/@/renderer/store';
@@ -19,6 +23,7 @@ import {
import { ItemListKey } from '/@/shared/types/types';
interface AlbumCarouselProps {
containerQuery?: ReturnType<typeof useGridCarouselContainerQuery>;
enableRefresh?: boolean;
excludeIds?: string[];
query?: Partial<Omit<AlbumListQuery, 'startIndex'>>;
@@ -28,21 +33,23 @@ interface AlbumCarouselProps {
title: React.ReactNode | string;
}
const BaseAlbumInfiniteCarousel = (props: AlbumCarouselProps) => {
const BaseAlbumInfiniteCarousel = (props: AlbumCarouselProps & { rows: DataRow[] }) => {
const {
containerQuery,
enableRefresh,
excludeIds,
query: additionalQuery,
rowCount = 1,
rows,
sortBy,
sortOrder,
title,
} = props;
const rows = useGridRows(LibraryItem.ALBUM, ItemListKey.ALBUM);
const {
data: albums,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
refetch,
} = useAlbumListInfinite(sortBy, sortOrder, 20, additionalQuery);
@@ -50,7 +57,7 @@ const BaseAlbumInfiniteCarousel = (props: AlbumCarouselProps) => {
const cards = useMemo(() => {
// Flatten all pages and filter excluded IDs
const allItems = albums.pages.flatMap((page: AlbumListResponse) => page.items);
const allItems = albums?.pages.flatMap((page: AlbumListResponse) => page.items) || [];
const filteredItems = excludeIds
? allItems.filter((album) => !excludeIds.includes(album.id))
: allItems;
@@ -69,7 +76,7 @@ const BaseAlbumInfiniteCarousel = (props: AlbumCarouselProps) => {
),
id: album.id,
}));
}, [albums.pages, controls, excludeIds, rows]);
}, [albums, controls, excludeIds, rows]);
const handleNextPage = useCallback(() => {}, []);
@@ -80,8 +87,8 @@ const BaseAlbumInfiniteCarousel = (props: AlbumCarouselProps) => {
}, [refetch]);
const firstPageItems = excludeIds
? albums.pages[0]?.items.filter((album) => !excludeIds.includes(album.id)) || []
: albums.pages[0]?.items || [];
? albums?.pages[0]?.items.filter((album) => !excludeIds.includes(album.id)) || []
: albums?.pages[0]?.items || [];
if (firstPageItems.length === 0) {
return null;
@@ -90,12 +97,16 @@ const BaseAlbumInfiniteCarousel = (props: AlbumCarouselProps) => {
return (
<GridCarousel
cards={cards}
containerQuery={containerQuery}
enableRefresh={enableRefresh}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
loadNextPage={fetchNextPage}
onNextPage={handleNextPage}
onPrevPage={handlePrevPage}
onRefresh={handleRefresh}
placeholderItemType={LibraryItem.ALBUM}
placeholderRows={rows}
rowCount={rowCount}
title={title}
/>
@@ -103,7 +114,22 @@ const BaseAlbumInfiniteCarousel = (props: AlbumCarouselProps) => {
};
export const AlbumInfiniteCarousel = (props: AlbumCarouselProps) => {
return <BaseAlbumInfiniteCarousel {...props} />;
const rows = useGridRows(LibraryItem.ALBUM, ItemListKey.ALBUM);
return (
<Suspense
fallback={
<GridCarouselSkeletonFallback
containerQuery={props.containerQuery}
placeholderItemType={LibraryItem.ALBUM}
placeholderRows={rows}
title={props.title}
/>
}
>
<BaseAlbumInfiniteCarousel {...props} rows={rows} />
</Suspense>
);
};
function useAlbumListInfinite(
@@ -1,10 +1,14 @@
import { useSuspenseInfiniteQuery } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react';
import { Suspense, useCallback, useMemo } from 'react';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { GridCarousel } from '/@/renderer/components/grid-carousel/grid-carousel-v2';
import { MemoizedItemCard } from '/@/renderer/components/item-card/item-card';
import {
GridCarousel,
GridCarouselSkeletonFallback,
useGridCarouselContainerQuery,
} from '/@/renderer/components/grid-carousel/grid-carousel-v2';
import { DataRow, MemoizedItemCard } from '/@/renderer/components/item-card/item-card';
import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
import { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-rows';
import { useCurrentServerId } from '/@/renderer/store';
@@ -19,6 +23,7 @@ import {
import { ItemListKey } from '/@/shared/types/types';
interface AlbumArtistCarouselProps {
containerQuery?: ReturnType<typeof useGridCarouselContainerQuery>;
excludeIds?: string[];
query?: Partial<Omit<AlbumArtistListQuery, 'startIndex'>>;
rowCount?: number;
@@ -27,13 +32,22 @@ interface AlbumArtistCarouselProps {
title: React.ReactNode | string;
}
export const BaseAlbumArtistInfiniteCarousel = (props: AlbumArtistCarouselProps) => {
const { excludeIds, query: additionalQuery, rowCount = 1, sortBy, sortOrder, title } = props;
const rows = useGridRows(LibraryItem.ALBUM_ARTIST, ItemListKey.ALBUM_ARTIST);
const BaseAlbumArtistInfiniteCarousel = (props: AlbumArtistCarouselProps & { rows: DataRow[] }) => {
const {
containerQuery,
excludeIds,
query: additionalQuery,
rowCount = 1,
rows,
sortBy,
sortOrder,
title,
} = props;
const {
data: albumArtists,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
refetch,
} = useAlbumArtistListInfinite(sortBy, sortOrder, 20, additionalQuery);
@@ -41,7 +55,8 @@ export const BaseAlbumArtistInfiniteCarousel = (props: AlbumArtistCarouselProps)
const cards = useMemo(() => {
// Flatten all pages and filter excluded IDs
const allItems = albumArtists.pages.flatMap((page: AlbumArtistListResponse) => page.items);
const allItems =
albumArtists?.pages.flatMap((page: AlbumArtistListResponse) => page.items) || [];
const filteredItems = excludeIds
? allItems.filter((albumArtist) => !excludeIds.includes(albumArtist.id))
: allItems;
@@ -60,7 +75,7 @@ export const BaseAlbumArtistInfiniteCarousel = (props: AlbumArtistCarouselProps)
),
id: albumArtist.id,
}));
}, [albumArtists.pages, controls, excludeIds, rows]);
}, [albumArtists, controls, excludeIds, rows]);
const handleNextPage = useCallback(() => {}, []);
@@ -71,10 +86,10 @@ export const BaseAlbumArtistInfiniteCarousel = (props: AlbumArtistCarouselProps)
}, [refetch]);
const firstPageItems = excludeIds
? albumArtists.pages[0]?.items.filter(
? albumArtists?.pages[0]?.items.filter(
(albumArtist) => !excludeIds.includes(albumArtist.id),
) || []
: albumArtists.pages[0]?.items || [];
: albumArtists?.pages[0]?.items || [];
if (firstPageItems.length === 0) {
return null;
@@ -83,11 +98,15 @@ export const BaseAlbumArtistInfiniteCarousel = (props: AlbumArtistCarouselProps)
return (
<GridCarousel
cards={cards}
containerQuery={containerQuery}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
loadNextPage={fetchNextPage}
onNextPage={handleNextPage}
onPrevPage={handlePrevPage}
onRefresh={handleRefresh}
placeholderItemType={LibraryItem.ALBUM_ARTIST}
placeholderRows={rows}
rowCount={rowCount}
title={title}
/>
@@ -95,7 +114,22 @@ export const BaseAlbumArtistInfiniteCarousel = (props: AlbumArtistCarouselProps)
};
export const AlbumArtistInfiniteCarousel = (props: AlbumArtistCarouselProps) => {
return <BaseAlbumArtistInfiniteCarousel {...props} />;
const rows = useGridRows(LibraryItem.ALBUM_ARTIST, ItemListKey.ALBUM_ARTIST);
return (
<Suspense
fallback={
<GridCarouselSkeletonFallback
containerQuery={props.containerQuery}
placeholderItemType={LibraryItem.ALBUM_ARTIST}
placeholderRows={rows}
title={props.title}
/>
}
>
<BaseAlbumArtistInfiniteCarousel {...props} rows={rows} />
</Suspense>
);
};
function useAlbumArtistListInfinite(
@@ -1,6 +1,7 @@
import { Suspense, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useGridCarouselContainerQuery } from '/@/renderer/components/grid-carousel/grid-carousel-v2';
import { NativeScrollArea } from '/@/renderer/components/native-scroll-area/native-scroll-area';
import { AlbumInfiniteCarousel } from '/@/renderer/features/albums/components/album-infinite-carousel';
import { AlbumInfiniteSingleFeatureCarousel } from '/@/renderer/features/home/components/album-infinite-single-feature-carousel';
@@ -35,6 +36,7 @@ const HomeRoute = () => {
const { windowBarStyle } = useWindowSettings();
const homeFeature = useHomeFeature();
const homeItems = useHomeItems();
const containerQuery = useGridCarouselContainerQuery();
const isJellyfin = server?.type === ServerType.JELLYFIN;
@@ -112,6 +114,7 @@ const HomeRoute = () => {
mb="5rem"
pt={windowBarStyle === Platform.WEB ? '5rem' : '3rem'}
px="2rem"
ref={containerQuery.ref}
>
{homeFeature && <AlbumInfiniteSingleFeatureCarousel />}
{sortedItems.map((item) => {
@@ -127,6 +130,7 @@ const HomeRoute = () => {
if (carousel.itemType === LibraryItem.ALBUM) {
return (
<AlbumInfiniteCarousel
containerQuery={containerQuery}
enableRefresh={carousel.enableRefresh}
key={`carousel-${carousel.uniqueId}`}
rowCount={1}
@@ -140,6 +144,7 @@ const HomeRoute = () => {
if (carousel.itemType === LibraryItem.SONG) {
return (
<SongInfiniteCarousel
containerQuery={containerQuery}
enableRefresh={carousel.enableRefresh}
key={`carousel-${carousel.uniqueId}`}
rowCount={1}
@@ -1,10 +1,14 @@
import { useSuspenseInfiniteQuery } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react';
import { Suspense, useCallback, useMemo } from 'react';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { GridCarousel } from '/@/renderer/components/grid-carousel/grid-carousel-v2';
import { MemoizedItemCard } from '/@/renderer/components/item-card/item-card';
import {
GridCarousel,
GridCarouselSkeletonFallback,
useGridCarouselContainerQuery,
} from '/@/renderer/components/grid-carousel/grid-carousel-v2';
import { DataRow, MemoizedItemCard } from '/@/renderer/components/item-card/item-card';
import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
import { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-rows';
import { DefaultItemControlProps } from '/@/renderer/components/item-list/types';
@@ -21,6 +25,7 @@ import {
import { ItemListKey, Play } from '/@/shared/types/types';
interface SongCarouselProps {
containerQuery?: ReturnType<typeof useGridCarouselContainerQuery>;
enableRefresh?: boolean;
excludeIds?: string[];
query?: Partial<Omit<SongListQuery, 'startIndex'>>;
@@ -30,21 +35,23 @@ interface SongCarouselProps {
title: React.ReactNode | string;
}
const BaseSongInfiniteCarousel = (props: SongCarouselProps) => {
const BaseSongInfiniteCarousel = (props: SongCarouselProps & { rows: DataRow[] }) => {
const {
containerQuery,
enableRefresh,
excludeIds,
query: additionalQuery,
rowCount = 1,
rows,
sortBy,
sortOrder,
title,
} = props;
const rows = useGridRows(LibraryItem.SONG, ItemListKey.SONG);
const {
data: songs,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
refetch,
} = useSongListInfinite(sortBy, sortOrder, 20, additionalQuery);
@@ -66,7 +73,7 @@ const BaseSongInfiniteCarousel = (props: SongCarouselProps) => {
const cards = useMemo(() => {
// Flatten all pages and filter excluded IDs
const allItems = songs.pages.flatMap((page: SongListResponse) => page.items);
const allItems = songs?.pages.flatMap((page: SongListResponse) => page.items) || [];
const filteredItems = excludeIds
? allItems.filter((song) => !excludeIds.includes(song.id))
: allItems;
@@ -85,7 +92,7 @@ const BaseSongInfiniteCarousel = (props: SongCarouselProps) => {
),
id: song.id,
}));
}, [songs.pages, controls, excludeIds, rows]);
}, [songs, controls, excludeIds, rows]);
const handleNextPage = useCallback(() => {}, []);
@@ -96,8 +103,8 @@ const BaseSongInfiniteCarousel = (props: SongCarouselProps) => {
}, [refetch]);
const firstPageItems = excludeIds
? songs.pages[0]?.items.filter((song) => !excludeIds.includes(song.id)) || []
: songs.pages[0]?.items || [];
? songs?.pages[0]?.items.filter((song) => !excludeIds.includes(song.id)) || []
: songs?.pages[0]?.items || [];
if (firstPageItems.length === 0) {
return null;
@@ -106,12 +113,16 @@ const BaseSongInfiniteCarousel = (props: SongCarouselProps) => {
return (
<GridCarousel
cards={cards}
containerQuery={containerQuery}
enableRefresh={enableRefresh}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
loadNextPage={fetchNextPage}
onNextPage={handleNextPage}
onPrevPage={handlePrevPage}
onRefresh={handleRefresh}
placeholderItemType={LibraryItem.SONG}
placeholderRows={rows}
rowCount={rowCount}
title={title}
/>
@@ -119,7 +130,22 @@ const BaseSongInfiniteCarousel = (props: SongCarouselProps) => {
};
export const SongInfiniteCarousel = (props: SongCarouselProps) => {
return <BaseSongInfiniteCarousel {...props} />;
const rows = useGridRows(LibraryItem.SONG, ItemListKey.SONG);
return (
<Suspense
fallback={
<GridCarouselSkeletonFallback
containerQuery={props.containerQuery}
placeholderItemType={LibraryItem.SONG}
placeholderRows={rows}
title={props.title}
/>
}
>
<BaseSongInfiniteCarousel {...props} rows={rows} />
</Suspense>
);
};
function useSongListInfinite(