diff --git a/src/renderer/api/query-keys.ts b/src/renderer/api/query-keys.ts
index 17ca1d0bb..c3767992d 100644
--- a/src/renderer/api/query-keys.ts
+++ b/src/renderer/api/query-keys.ts
@@ -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;
diff --git a/src/renderer/features/home/routes/home-route.tsx b/src/renderer/features/home/routes/home-route.tsx
index cc3401bff..fc088c102 100644
--- a/src/renderer/features/home/routes/home-route.tsx
+++ b/src/renderer/features/home/routes/home-route.tsx
@@ -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 (
+
+ );
}
return null;
diff --git a/src/renderer/features/songs/components/song-infinite-carousel.tsx b/src/renderer/features/songs/components/song-infinite-carousel.tsx
new file mode 100644
index 000000000..74c2f4680
--- /dev/null
+++ b/src/renderer/features/songs/components/song-infinite-carousel.tsx
@@ -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>;
+ 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: (
+
+ ),
+ 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 (
+
+ );
+};
+
+export const SongInfiniteCarousel = (props: SongCarouselProps) => {
+ return ;
+};
+
+function useSongListInfinite(
+ sortBy: SongListSort,
+ sortOrder: SortOrder,
+ itemLimit: number,
+ additionalQuery?: Partial>,
+) {
+ const serverId = useCurrentServerId();
+
+ const query = 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.getSongList({
+ apiClientProps: { serverId, signal },
+ query: {
+ limit: itemLimit,
+ sortBy,
+ sortOrder,
+ startIndex: Number(pageParam),
+ ...additionalQuery,
+ },
+ });
+ },
+ queryKey: queryKeys.songs.infiniteList(serverId, {
+ sortBy,
+ sortOrder,
+ ...additionalQuery,
+ }),
+ });
+
+ return query;
+}