add song infinite carousel (#1464)

This commit is contained in:
jeffvli
2025-12-31 01:20:04 -08:00
parent 255b9a9c2d
commit b00f9795bf
3 changed files with 205 additions and 15 deletions
+20 -8
View File
@@ -364,6 +364,18 @@ export const queryKeys: Record<
return [serverId, 'songs', 'detail'] as const;
},
infiniteList: (serverId: string, query?: SongListQuery) => {
const { filter, pagination } = splitPaginatedQuery(query);
if (query && pagination) {
return [serverId, 'songs', 'infiniteList', filter, pagination] as const;
}
if (query) {
return [serverId, 'songs', 'infiniteList', filter] as const;
}
return [serverId, 'songs', 'infiniteList'] as const;
},
list: (serverId: string, query?: SongListQuery) => {
const { filter, pagination } = splitPaginatedQuery(query);
if (query && pagination) {
@@ -383,14 +395,6 @@ export const queryKeys: Record<
lyricsByRemoteId: (searchQuery: { remoteSongId: string; remoteSource: LyricSource }) => {
return ['song', 'lyrics', 'remote', searchQuery] as const;
},
remoteLyrics: (serverId: string, query?: LyricsQuery) => {
if (query) return [serverId, 'song', 'lyrics', 'remote', query] as const;
return [serverId, 'song', 'lyrics', 'remote'] as const;
},
serverLyrics: (serverId: string, query?: LyricsQuery) => {
if (query) return [serverId, 'song', 'lyrics', 'server', query] as const;
return [serverId, 'song', 'lyrics', 'server'] as const;
},
lyricsSearch: (query?: LyricSearchQuery) => {
if (query) return ['lyrics', 'search', query] as const;
return ['lyrics', 'search'] as const;
@@ -399,7 +403,15 @@ export const queryKeys: Record<
if (query) return [serverId, 'songs', 'randomSongList', query] as const;
return [serverId, 'songs', 'randomSongList'] as const;
},
remoteLyrics: (serverId: string, query?: LyricsQuery) => {
if (query) return [serverId, 'song', 'lyrics', 'remote', query] as const;
return [serverId, 'song', 'lyrics', 'remote'] as const;
},
root: (serverId: string) => [serverId, 'songs'] as const,
serverLyrics: (serverId: string, query?: LyricsQuery) => {
if (query) return [serverId, 'song', 'lyrics', 'server', query] as const;
return [serverId, 'song', 'lyrics', 'server'] as const;
},
similar: (serverId: string, query?: SimilarSongsQuery) => {
if (query) return [serverId, 'song', 'similar', query] as const;
return [serverId, 'song', 'similar'] as const;
@@ -9,6 +9,7 @@ import { AnimatedPage } from '/@/renderer/features/shared/components/animated-pa
import { LibraryContainer } from '/@/renderer/features/shared/components/library-container';
import { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
import { SongInfiniteCarousel } from '/@/renderer/features/songs/components/song-infinite-carousel';
import {
HomeItem,
useCurrentServer,
@@ -17,7 +18,13 @@ import {
} from '/@/renderer/store';
import { Spinner } from '/@/shared/components/spinner/spinner';
import { Stack } from '/@/shared/components/stack/stack';
import { AlbumListSort, LibraryItem, ServerType, SortOrder } from '/@/shared/types/domain-types';
import {
AlbumListSort,
LibraryItem,
ServerType,
SongListSort,
SortOrder,
} from '/@/shared/types/domain-types';
import { Platform } from '/@/shared/types/types';
const HomeRoute = () => {
@@ -29,11 +36,10 @@ const HomeRoute = () => {
const isJellyfin = server?.type === ServerType.JELLYFIN;
// Carousel configuration - queries are now handled inside AlbumInfiniteCarousel
const carousels = {
[HomeItem.MOST_PLAYED]: {
itemType: isJellyfin ? LibraryItem.SONG : LibraryItem.ALBUM,
sortBy: AlbumListSort.PLAY_COUNT,
sortBy: isJellyfin ? SongListSort.PLAY_COUNT : AlbumListSort.PLAY_COUNT,
sortOrder: SortOrder.DESC,
title: t('page.home.mostPlayed', { postProcess: 'sentenceCase' }),
},
@@ -122,16 +128,24 @@ const HomeRoute = () => {
enableRefresh={carousel.enableRefresh}
key={`carousel-${carousel.uniqueId}`}
rowCount={1}
sortBy={carousel.sortBy}
sortBy={carousel.sortBy as AlbumListSort}
sortOrder={carousel.sortOrder}
title={carousel.title}
/>
);
}
if ('data' in carousel && 'query' in carousel) {
// TODO: Create SongInfiniteCarousel
return null;
if (carousel.itemType === LibraryItem.SONG) {
return (
<SongInfiniteCarousel
enableRefresh={carousel.enableRefresh}
key={`carousel-${carousel.uniqueId}`}
rowCount={1}
sortBy={carousel.sortBy as SongListSort}
sortOrder={carousel.sortOrder}
title={carousel.title}
/>
);
}
return null;
@@ -0,0 +1,164 @@
import { useSuspenseInfiniteQuery } from '@tanstack/react-query';
import { 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 { 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';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { useCurrentServerId } from '/@/renderer/store';
import {
LibraryItem,
Song,
SongListQuery,
SongListResponse,
SongListSort,
SortOrder,
} from '/@/shared/types/domain-types';
import { ItemListKey, Play } from '/@/shared/types/types';
interface SongCarouselProps {
enableRefresh?: boolean;
excludeIds?: string[];
query?: Partial<Omit<SongListQuery, 'startIndex'>>;
rowCount?: number;
sortBy: SongListSort;
sortOrder: SortOrder;
title: React.ReactNode | string;
}
const BaseSongInfiniteCarousel = (props: SongCarouselProps) => {
const {
enableRefresh,
excludeIds,
query: additionalQuery,
rowCount = 1,
sortBy,
sortOrder,
title,
} = props;
const rows = useGridRows(LibraryItem.SONG, ItemListKey.SONG);
const {
data: songs,
fetchNextPage,
hasNextPage,
refetch,
} = useSongListInfinite(sortBy, sortOrder, 20, additionalQuery);
const player = usePlayer();
const baseControls = useDefaultItemListControls();
const controls = useMemo(() => {
return {
...baseControls,
onPlay: ({ item, playType }: DefaultItemControlProps & { playType: Play }) => {
if (!item) {
return;
}
player.addToQueueByData([item as Song], playType);
},
};
}, [baseControls, player]);
const cards = useMemo(() => {
// Flatten all pages and filter excluded IDs
const allItems = songs.pages.flatMap((page: SongListResponse) => page.items);
const filteredItems = excludeIds
? allItems.filter((song) => !excludeIds.includes(song.id))
: allItems;
return filteredItems.map((song: Song) => ({
content: (
<MemoizedItemCard
controls={controls}
data={song}
enableDrag
itemType={LibraryItem.SONG}
rows={rows}
type="poster"
withControls
/>
),
id: song.id,
}));
}, [songs.pages, controls, excludeIds, rows]);
const handleNextPage = useCallback(() => {}, []);
const handlePrevPage = useCallback(() => {}, []);
const handleRefresh = useCallback(() => {
refetch();
}, [refetch]);
const firstPageItems = excludeIds
? songs.pages[0]?.items.filter((song) => !excludeIds.includes(song.id)) || []
: songs.pages[0]?.items || [];
if (firstPageItems.length === 0) {
return null;
}
return (
<GridCarousel
cards={cards}
enableRefresh={enableRefresh}
hasNextPage={hasNextPage}
loadNextPage={fetchNextPage}
onNextPage={handleNextPage}
onPrevPage={handlePrevPage}
onRefresh={handleRefresh}
rowCount={rowCount}
title={title}
/>
);
};
export const SongInfiniteCarousel = (props: SongCarouselProps) => {
return <BaseSongInfiniteCarousel {...props} />;
};
function useSongListInfinite(
sortBy: SongListSort,
sortOrder: SortOrder,
itemLimit: number,
additionalQuery?: Partial<Omit<SongListQuery, 'startIndex'>>,
) {
const serverId = useCurrentServerId();
const query = useSuspenseInfiniteQuery<SongListResponse>({
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.getSongList({
apiClientProps: { serverId, signal },
query: {
limit: itemLimit,
sortBy,
sortOrder,
startIndex: Number(pageParam),
...additionalQuery,
},
});
},
queryKey: queryKeys.songs.infiniteList(serverId, {
sortBy,
sortOrder,
...additionalQuery,
}),
});
return query;
}