From b00f9795bf5601b8df9de5973b31822ec6f80abd Mon Sep 17 00:00:00 2001 From: jeffvli Date: Wed, 31 Dec 2025 01:20:04 -0800 Subject: [PATCH] add song infinite carousel (#1464) --- src/renderer/api/query-keys.ts | 28 ++- .../features/home/routes/home-route.tsx | 28 ++- .../components/song-infinite-carousel.tsx | 164 ++++++++++++++++++ 3 files changed, 205 insertions(+), 15 deletions(-) create mode 100644 src/renderer/features/songs/components/song-infinite-carousel.tsx 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; +}