diff --git a/package.json b/package.json index fefba49c0..04dee183d 100644 --- a/package.json +++ b/package.json @@ -115,7 +115,6 @@ "nuqs": "^2.7.1", "overlayscrollbars": "^2.11.1", "overlayscrollbars-react": "^0.5.6", - "postcss-simple-vars": "^7.0.1", "qs": "^6.14.0", "react": "^19.1.0", "react-call": "^1.8.1", @@ -167,6 +166,7 @@ "eslint-plugin-react-refresh": "^0.4.19", "i18next-parser": "^9.0.2", "postcss-preset-mantine": "^1.17.0", + "postcss-simple-vars": "^7.0.1", "prettier": "^3.5.3", "prettier-plugin-packagejson": "^2.5.14", "sass-embedded": "^1.89.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb72415fa..ca27fbe6d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -173,9 +173,6 @@ importers: overlayscrollbars-react: specifier: ^0.5.6 version: 0.5.6(overlayscrollbars@2.11.3)(react@19.1.0) - postcss-simple-vars: - specifier: ^7.0.1 - version: 7.0.1(postcss@8.5.3) qs: specifier: ^6.14.0 version: 6.14.0 @@ -324,6 +321,9 @@ importers: postcss-preset-mantine: specifier: ^1.17.0 version: 1.17.0(postcss@8.5.3) + postcss-simple-vars: + specifier: ^7.0.1 + version: 7.0.1(postcss@8.5.3) prettier: specifier: ^3.5.3 version: 3.5.3 diff --git a/postcss.config.cjs b/postcss.config.cjs index 62f77aa46..0a1980dc6 100644 --- a/postcss.config.cjs +++ b/postcss.config.cjs @@ -3,13 +3,13 @@ module.exports = { 'postcss-preset-mantine': {}, 'postcss-simple-vars': { variables: { - 'breakpoint-xs': '36em', - 'breakpoint-sm': '48em', - 'breakpoint-md': '62em', - 'breakpoint-lg': '75em', - 'breakpoint-xl': '88em', - 'breakpoint-2xl': '120em', - 'breakpoint-3xl': '160em', + 'mantine-breakpoint-xs': '36em', + 'mantine-breakpoint-sm': '48em', + 'mantine-breakpoint-md': '62em', + 'mantine-breakpoint-lg': '75em', + 'mantine-breakpoint-xl': '88em', + 'mantine-breakpoint-2xl': '120em', + 'mantine-breakpoint-3xl': '160em', }, }, }, diff --git a/src/renderer/components/grid-carousel/grid-carousel-v2.tsx b/src/renderer/components/grid-carousel/grid-carousel-v2.tsx new file mode 100644 index 000000000..72f63566e --- /dev/null +++ b/src/renderer/components/grid-carousel/grid-carousel-v2.tsx @@ -0,0 +1,223 @@ +import type { Variants } from 'motion/react'; +import type { ReactNode } from 'react'; + +import { AnimatePresence, motion, useMotionValue } from 'motion/react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import styles from './grid-carousel.module.css'; + +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'; + +interface Card { + content: ReactNode; + id: string; +} + +interface GridCarouselProps { + cards: Card[]; + hasNextPage?: boolean; + loadNextPage?: () => void; + onNextPage: (page: number) => void; + onPrevPage: (page: number) => void; + rowCount?: number; + title?: ReactNode | string; +} + +const MemoizedCard = memo(({ content }: { content: ReactNode }) => ( +
{content}
+)); + +MemoizedCard.displayName = 'MemoizedCard'; + +const pageVariants: Variants = { + animate: { opacity: 1, transition: { duration: 0.3, ease: 'easeOut' }, x: 0 }, + exit: (custom: { isNext: boolean }) => ({ + opacity: 0, + transition: { duration: 0.3, ease: 'easeIn' }, + x: custom.isNext ? -100 : 100, + }), + initial: (custom: { isNext: boolean }) => ({ opacity: 0, x: custom.isNext ? 100 : -100 }), +}; + +export function GridCarousel(props: GridCarouselProps) { + const { cards, hasNextPage, loadNextPage, onNextPage, onPrevPage, rowCount = 1, title } = props; + const cq = useContainerQuery({ + lg: 900, + md: 600, + sm: 360, + }); + + const [currentPage, setCurrentPage] = useState({ + isNext: false, + page: 0, + }); + + const handlePrevPage = useCallback(() => { + setCurrentPage((prev) => ({ + isNext: false, + page: prev.page > 0 ? prev.page - 1 : 0, + })); + onPrevPage(currentPage.page); + }, [currentPage, onPrevPage]); + + const handleNextPage = useCallback(() => { + setCurrentPage((prev) => ({ + isNext: true, + page: prev.page + 1, + })); + onNextPage(currentPage.page); + }, [currentPage, onNextPage]); + + const cardsToShow = getCardsToShow({ + isLargerThanLg: cq.isLg, + isLargerThanMd: cq.isMd, + isLargerThanSm: cq.isSm, + isLargerThanXl: cq.isXl, + isLargerThanXxl: cq.is2xl, + isLargerThanXxxl: cq.is3xl, + }); + + const visibleCards = useMemo(() => { + return cards.slice( + currentPage.page * cardsToShow * rowCount, + (currentPage.page + 1) * cardsToShow * rowCount, + ); + }, [cards, currentPage, cardsToShow, rowCount]); + + const shouldLoadNextPage = visibleCards.length < cardsToShow * rowCount; + + useEffect(() => { + if (shouldLoadNextPage) { + loadNextPage?.(); + } + }, [loadNextPage, shouldLoadNextPage]); + + const isPrevDisabled = currentPage.page === 0; + const hasMoreCards = (currentPage.page + 1) * cardsToShow * rowCount < cards.length; + const isNextDisabled = !hasMoreCards && (hasNextPage === false || hasNextPage === undefined); + + const indicatorRef = useRef(null); + const x = useMotionValue(0); + const dragThreshold = 1; + + const handleDragEnd = useCallback(() => { + const dragDistance = x.get(); + + if (Math.abs(dragDistance) > dragThreshold) { + if (dragDistance > 0 && !isPrevDisabled) { + // Dragged right, go to previous page + handlePrevPage(); + } else if (dragDistance < 0 && !isNextDisabled) { + // Dragged left, go to next page + handleNextPage(); + } + } + + x.set(0); + }, [handleNextPage, handlePrevPage, isNextDisabled, isPrevDisabled, x]); + + return ( +
+ {cq.isCalculated && ( + <> +
+ {typeof title === 'string' ? ( + {title} + ) : ( + title + )} + + + + +
+ + + {visibleCards.map((card) => ( + + ))} + + + + + + + )} +
+ ); +} + +function getCardsToShow(breakpoints: { + isLargerThanLg: boolean; + isLargerThanMd: boolean; + isLargerThanSm: boolean; + isLargerThanXl: boolean; + isLargerThanXxl: boolean; + isLargerThanXxxl: boolean; +}) { + if (breakpoints.isLargerThanXxxl) { + return 14; + } + + if (breakpoints.isLargerThanXxl) { + return 10; + } + + if (breakpoints.isLargerThanXl) { + return 8; + } + + if (breakpoints.isLargerThanLg) { + return 6; + } + + if (breakpoints.isLargerThanMd) { + return 4; + } + + if (breakpoints.isLargerThanSm) { + return 3; + } + + return 2; +} diff --git a/src/renderer/components/grid-carousel/grid-carousel.module.css b/src/renderer/components/grid-carousel/grid-carousel.module.css new file mode 100644 index 000000000..50a3d1350 --- /dev/null +++ b/src/renderer/components/grid-carousel/grid-carousel.module.css @@ -0,0 +1,56 @@ +.grid-carousel { + display: flex; + flex-direction: column; + gap: var(--theme-spacing-md); + width: 100%; + margin: 0 auto; + container-name: grid-carousel; + container-type: inline-size; +} + +.navigation { + display: flex; + align-items: center; + justify-content: space-between; +} + +.grid { + display: grid; + grid-template-columns: repeat(var(--cards-to-show, 2), minmax(0, 1fr)); + gap: var(--theme-spacing-md); + height: calc(var(--row-count) * (100cqw / var(--cards-to-show, 2) + 3rem)); + overflow: hidden; +} + +.page-indicator { + display: flex; + align-items: center; + justify-content: center; + padding: var(--theme-spacing-sm) 0; + cursor: grab; + user-select: none; +} + +.page-indicator:active { + cursor: grabbing; +} + +.indicator-track { + width: 20px; + height: 4px; + touch-action: none; + cursor: grab; + border-radius: 2px; + + @mixin light { + background-color: darken(var(--theme-colors-background), 10%); + } + + @mixin dark { + background-color: lighten(var(--theme-colors-background), 15%); + } +} + +.indicator-track:active { + cursor: grabbing; +} diff --git a/src/renderer/components/grid-carousel/grid-carousel.tsx b/src/renderer/components/grid-carousel/grid-carousel.tsx deleted file mode 100644 index 22326f555..000000000 --- a/src/renderer/components/grid-carousel/grid-carousel.tsx +++ /dev/null @@ -1,330 +0,0 @@ -import throttle from 'lodash/throttle'; -import { - isValidElement, - memo, - ReactNode, - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from 'react'; -import { SwiperOptions, Virtual } from 'swiper'; -import 'swiper/css'; -import { Swiper, SwiperSlide } from 'swiper/react'; -import { Swiper as SwiperCore } from 'swiper/types'; - -import { PosterCard } from '/@/renderer/components/card/poster-card'; -import { usePlayQueueAdd } from '/@/renderer/features/player/hooks/use-playqueue-add'; -import { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation'; -import { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation'; -import { usePlayButtonBehavior } from '/@/renderer/store'; -import { Button } from '/@/shared/components/button/button'; -import { Group } from '/@/shared/components/group/group'; -import { Icon } from '/@/shared/components/icon/icon'; -import { Stack } from '/@/shared/components/stack/stack'; -import { TextTitle } from '/@/shared/components/text-title/text-title'; -import { - Album, - AlbumArtist, - Artist, - LibraryItem, - RelatedArtist, -} from '/@/shared/types/domain-types'; -import { CardRoute, CardRow } from '/@/shared/types/types'; - -const getSlidesPerView = (windowWidth: number) => { - if (windowWidth < 400) return 2; - if (windowWidth < 700) return 3; - if (windowWidth < 900) return 4; - if (windowWidth < 1100) return 5; - if (windowWidth < 1300) return 6; - if (windowWidth < 1500) return 7; - if (windowWidth < 1920) return 8; - return 10; -}; - -interface TitleProps { - handleNext?: () => void; - handlePrev?: () => void; - label?: ReactNode | string; - pagination: { - hasNextPage: boolean; - hasPreviousPage: boolean; - }; -} - -const Title = ({ handleNext, handlePrev, label, pagination }: TitleProps) => { - return ( - - {isValidElement(label) ? ( - label - ) : ( - - {label} - - )} - - - - - - - ); -}; - -export interface SwiperGridCarouselProps { - cardRows: CardRow[] | CardRow[] | CardRow[]; - data: Album[] | AlbumArtist[] | Artist[] | RelatedArtist[] | undefined; - isLoading?: boolean; - itemType: LibraryItem; - route: CardRoute; - swiperProps?: SwiperOptions; - title?: { - children?: ReactNode; - hasPagination?: boolean; - icon?: ReactNode; - label: ReactNode | string; - }; - uniqueId: string; -} - -export const SwiperGridCarousel = ({ - cardRows, - data, - isLoading, - itemType, - route, - swiperProps, - title, - uniqueId, -}: SwiperGridCarouselProps) => { - const containerRef = useRef(null); - const swiperRef = useRef(null); - const playButtonBehavior = usePlayButtonBehavior(); - const handlePlayQueueAdd = usePlayQueueAdd(); - const [slideCount, setSlideCount] = useState(4); - - useEffect(() => { - swiperRef.current?.slideTo(0, 0); - }, [data]); - - const [pagination, setPagination] = useState({ - hasNextPage: (data?.length || 0) > Math.round(3), - hasPreviousPage: false, - }); - - const createFavoriteMutation = useCreateFavorite({}); - const deleteFavoriteMutation = useDeleteFavorite({}); - - const handleFavorite = useCallback( - (options: { - id: string[]; - isFavorite: boolean; - itemType: LibraryItem; - serverId: string; - }) => { - const { id, isFavorite, itemType, serverId } = options; - if (isFavorite) { - deleteFavoriteMutation.mutate({ - apiClientProps: { serverId }, - query: { - id, - type: itemType, - }, - }); - } else { - createFavoriteMutation.mutate({ - apiClientProps: { serverId }, - query: { - id, - type: itemType, - }, - }); - } - }, - [createFavoriteMutation, deleteFavoriteMutation], - ); - - const slides = useMemo(() => { - if (!data) return []; - - return data.map((el) => ( - - )); - }, [ - cardRows, - data, - handleFavorite, - handlePlayQueueAdd, - isLoading, - itemType, - playButtonBehavior, - route, - uniqueId, - ]); - - const handleNext = useCallback(() => { - const activeIndex = swiperRef?.current?.activeIndex || 0; - const slidesPerView = Math.round(Number(swiperProps?.slidesPerView || slideCount)); - swiperRef?.current?.slideTo(activeIndex + slidesPerView); - }, [slideCount, swiperProps?.slidesPerView]); - - const handlePrev = useCallback(() => { - const activeIndex = swiperRef?.current?.activeIndex || 0; - const slidesPerView = Math.round(Number(swiperProps?.slidesPerView || slideCount)); - swiperRef?.current?.slideTo(activeIndex - slidesPerView); - }, [slideCount, swiperProps?.slidesPerView]); - - const handleOnSlideChange = useCallback((e: SwiperCore) => { - const { isBeginning, isEnd, params, slides } = e; - if (isEnd || isBeginning) return; - - const slideCount = (params.slidesPerView as number | undefined) || 4; - setPagination({ - hasNextPage: slideCount < slides.length, - hasPreviousPage: slideCount < slides.length, - }); - }, []); - - const handleOnZoomChange = useCallback((e: SwiperCore) => { - const { isBeginning, isEnd, params, slides } = e; - if (isEnd || isBeginning) return; - - const slideCount = (params.slidesPerView as number | undefined) || 4; - setPagination({ - hasNextPage: slideCount < slides.length, - hasPreviousPage: slideCount < slides.length, - }); - }, []); - - const handleOnReachEnd = useCallback((e: SwiperCore) => { - const { params, slides } = e; - - const slideCount = (params.slidesPerView as number | undefined) || 4; - setPagination({ - hasNextPage: false, - hasPreviousPage: slideCount < slides.length, - }); - }, []); - - const handleOnReachBeginning = useCallback((e: SwiperCore) => { - const { params, slides } = e; - - const slideCount = (params.slidesPerView as number | undefined) || 4; - setPagination({ - hasNextPage: slideCount < slides.length, - hasPreviousPage: false, - }); - }, []); - - useLayoutEffect(() => { - const handleResize = () => { - // Use the container div ref and not swiper width, as this value is more accurate - const width = containerRef.current?.clientWidth; - const { activeIndex, params, slides } = - (swiperRef.current as SwiperCore | undefined) ?? {}; - - if (width) { - const slidesPerView = getSlidesPerView(width); - setSlideCount(slidesPerView); - } - - if (activeIndex !== undefined && slides && params?.slidesPerView) { - const slideCount = (params.slidesPerView as number | undefined) || 4; - setPagination({ - hasNextPage: activeIndex + slideCount < slides.length, - hasPreviousPage: activeIndex > 0, - }); - } - }; - - handleResize(); - - const throttledResize = throttle(handleResize, 200); - window.addEventListener('resize', throttledResize); - - return () => { - window.removeEventListener('resize', throttledResize); - }; - }, []); - - return ( - - {title ? ( - - ) : null} - <Swiper - modules={[Virtual]} - onBeforeInit={(swiper) => { - swiperRef.current = swiper; - }} - onReachBeginning={handleOnReachBeginning} - onReachEnd={handleOnReachEnd} - onSlideChange={handleOnSlideChange} - onZoomChange={handleOnZoomChange} - ref={swiperRef} - resizeObserver - slidesPerView={slideCount} - spaceBetween={20} - style={{ height: '100%', width: '100%' }} - {...swiperProps} - > - {slides.map((slideContent, index) => { - return ( - <SwiperSlide - key={`${uniqueId}-${slideContent?.props?.data?.id}-${index}`} - virtualIndex={index} - > - {slideContent} - </SwiperSlide> - ); - })} - </Swiper> - </Stack> - ); -}; - -export const MemoizedSwiperGridCarousel = memo( - function Carousel(props: SwiperGridCarouselProps) { - return <SwiperGridCarousel {...props} />; - }, - (oldProps, newProps) => { - const uniqueIdIsEqual = oldProps.uniqueId === newProps.uniqueId; - const dataIsEqual = oldProps.data === newProps.data; - return uniqueIdIsEqual && dataIsEqual; - }, -); diff --git a/src/renderer/components/item-card/item-card.tsx b/src/renderer/components/item-card/item-card.tsx index 8e1099268..16c1c969e 100644 --- a/src/renderer/components/item-card/item-card.tsx +++ b/src/renderer/components/item-card/item-card.tsx @@ -460,12 +460,14 @@ const PosterItemCard = ({ }, itemType, onDragStart: () => { - if (!data || !internalState) { + if (!data) { return; } const draggedItems = getDraggedItems(data, internalState); - internalState.setDragging(draggedItems); + if (internalState) { + internalState.setDragging(draggedItems); + } }, onDrop: () => { if (internalState) { diff --git a/src/renderer/components/item-list/helpers/get-dragged-items.ts b/src/renderer/components/item-list/helpers/get-dragged-items.ts index 7f830e197..4c6fac94a 100644 --- a/src/renderer/components/item-list/helpers/get-dragged-items.ts +++ b/src/renderer/components/item-list/helpers/get-dragged-items.ts @@ -26,9 +26,9 @@ const hasRequiredDragProperties = ( * Gets the items that should be dragged based on the current data and selection state. * If the current item is already selected, drag all selected items. * Otherwise, select and drag only the current item. + * If internalState is not provided, returns the single item wrapped in an array. * * @param data - The item data to drag (Album, AlbumArtist, Artist, Playlist, or Song) - * @param itemType - The type of library item * @param internalState - The item list state actions (optional) * @param updateSelection - Whether to update the selection state (default: true) * @returns Array of items that should be dragged (with original values, asserting id, itemType, and _serverId) @@ -38,7 +38,7 @@ export const getDraggedItems = ( internalState?: ItemListStateActions, updateSelection: boolean = true, ): ItemListStateItemWithRequiredProperties[] => { - if (!data || !internalState) { + if (!data) { return []; } @@ -46,14 +46,18 @@ export const getDraggedItems = ( return []; } + const draggedItem = data as ItemListStateItemWithRequiredProperties; + + if (!internalState) { + return [draggedItem]; + } + const rowId = internalState.extractRowId(data); if (!rowId) { - return []; + return [draggedItem]; } - const draggedItem = data as ItemListStateItemWithRequiredProperties; - const previouslySelected = internalState.getSelected(); const isDraggingSelectedItem = previouslySelected.some((selected) => { if (hasRequiredDragProperties(selected)) { diff --git a/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx b/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx index 2e38b3f24..7e855dc05 100644 --- a/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx +++ b/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx @@ -102,13 +102,14 @@ export const ItemTableListColumn = (props: ItemTableListColumn) => { }, itemType: props.itemType, onDragStart: () => { - if (!item || !isDataRow || !props.internalState) { + if (!item || !isDataRow) { return; } const draggedItems = getDraggedItems(item as any, props.internalState); - - props.internalState.setDragging(draggedItems); + if (props.internalState) { + props.internalState.setDragging(draggedItems); + } }, onDrop: () => { if (props.internalState) { diff --git a/src/renderer/features/albums/components/album-detail-content.tsx b/src/renderer/features/albums/components/album-detail-content.tsx index 90a029a4b..5fce0ef9c 100644 --- a/src/renderer/features/albums/components/album-detail-content.tsx +++ b/src/renderer/features/albums/components/album-detail-content.tsx @@ -1,75 +1,33 @@ -import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; - -import { RowDoubleClickedEvent, RowHeightParams, RowNode } from '@ag-grid-community/core'; -import { useSetState } from '@mantine/hooks'; import { useQuery } from '@tanstack/react-query'; -import { MutableRefObject, useCallback, useMemo } from 'react'; +import { Suspense, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { generatePath, useParams } from 'react-router'; -import { Link } from 'react-router'; +import { generatePath, Link, useParams } from 'react-router'; import styles from './album-detail-content.module.css'; -import { queryKeys } from '/@/renderer/api/query-keys'; -import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel/grid-carousel'; -import { - getColumnDefs, - TableConfigDropdown, - VirtualTable, -} from '/@/renderer/components/virtual-table'; -import { FullWidthDiscCell } from '/@/renderer/components/virtual-table/cells/full-width-disc-cell'; -import { useCurrentSongRowStyles } from '/@/renderer/components/virtual-table/hooks/use-current-song-row-styles'; import { albumQueries } from '/@/renderer/features/albums/api/album-api'; -import { - ALBUM_CONTEXT_MENU_ITEMS, - SONG_CONTEXT_MENU_ITEMS, -} from '/@/renderer/features/context-menu/context-menu-items'; -import { - useHandleGeneralContextMenu, - useHandleTableContextMenu, -} from '/@/renderer/features/context-menu/hooks/use-handle-context-menu'; -import { usePlayQueueAdd } from '/@/renderer/features/player/hooks/use-playqueue-add'; +import { AlbumInfiniteCarousel } from '/@/renderer/features/albums/components/album-infinite-carousel'; import { LibraryBackgroundOverlay } from '/@/renderer/features/shared/components/library-background-overlay'; import { PlayButton } from '/@/renderer/features/shared/components/play-button'; -import { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation'; -import { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation'; -import { useAppFocus, useContainerQuery } from '/@/renderer/hooks'; +import { useContainerQuery } from '/@/renderer/hooks'; import { useGenreRoute } from '/@/renderer/hooks/use-genre-route'; -import { AppRoute } from '/@/renderer/router/routes'; -import { useCurrentServer, usePlayerSong, usePlayerStatus } from '/@/renderer/store'; -import { - PersistedTableColumn, - useGeneralSettings, - usePlayButtonBehavior, - useSettingsStoreActions, - useTableSettings, -} from '/@/renderer/store/settings.store'; +import { useCurrentServer } from '/@/renderer/store'; +import { useGeneralSettings, usePlayButtonBehavior } from '/@/renderer/store/settings.store'; import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { Button } from '/@/shared/components/button/button'; import { Group } from '/@/shared/components/group/group'; -import { Popover } from '/@/shared/components/popover/popover'; +import { Spinner } from '/@/shared/components/spinner/spinner'; import { Spoiler } from '/@/shared/components/spoiler/spoiler'; import { Stack } from '/@/shared/components/stack/stack'; -import { - AlbumListQuery, - AlbumListSort, - LibraryItem, - QueueSong, - SortOrder, -} from '/@/shared/types/domain-types'; +import { AlbumListSort, SortOrder } from '/@/shared/types/domain-types'; import { Play } from '/@/shared/types/types'; -const isFullWidthRow = (node: RowNode) => { - return node.id?.startsWith('disc-'); -}; - interface AlbumDetailContentProps { background?: string; - tableRef: MutableRefObject<AgGridReactType | null>; } -export const AlbumDetailContent = ({ background, tableRef }: AlbumDetailContentProps) => { +export const AlbumDetailContent = ({ background }: AlbumDetailContentProps) => { const { t } = useTranslation(); const { albumId } = useParams() as { albumId: string }; const server = useCurrentServer(); @@ -82,267 +40,59 @@ export const AlbumDetailContent = ({ background, tableRef }: AlbumDetailContentP ); const cq = useContainerQuery(); - const handlePlayQueueAdd = usePlayQueueAdd(); - const tableConfig = useTableSettings('albumDetail'); - const { setTable } = useSettingsStoreActions(); - const status = usePlayerStatus(); - const isFocused = useAppFocus(); - const currentSong = usePlayerSong(); const { externalLinks, lastFM, musicBrainz } = useGeneralSettings(); const genreRoute = useGenreRoute(); - const columnDefs = useMemo( - () => getColumnDefs(tableConfig.columns, false, 'albumDetail'), - [tableConfig.columns], - ); - - const getRowHeight = useCallback( - (params: RowHeightParams) => { - if (isFullWidthRow(params.node)) { - return 45; - } - - return tableConfig.rowHeight; - }, - [tableConfig.rowHeight], - ); - - const songsRowData = useMemo(() => { - if (!detail?.songs) { - return []; - } - - let discNumber = -1; - let discSubtitle: null | string = null; - - const rowData: (QueueSong | { id: string; name: string })[] = []; - const discTranslated = t('common.disc', { postProcess: 'upperCase' }); - - for (const song of detail.songs) { - if (song.discNumber !== discNumber || song.discSubtitle !== discSubtitle) { - discNumber = song.discNumber; - discSubtitle = song.discSubtitle; - - let id = `disc-${discNumber}`; - let name = `${discTranslated} ${discNumber}`; - - if (discSubtitle) { - id += `-${discSubtitle}`; - name += `: ${discSubtitle}`; - } - - rowData.push({ id, name }); - } - rowData.push(song); - } - - return rowData; - }, [detail?.songs, t]); - - const [pagination, setPagination] = useSetState({ - artist: 0, - }); - - const handleNextPage = useCallback( - (key: 'artist') => { - setPagination({ - [key]: pagination[key as keyof typeof pagination] + 1, - }); - }, - [pagination, setPagination], - ); - - const handlePreviousPage = useCallback( - (key: 'artist') => { - setPagination({ - [key]: pagination[key as keyof typeof pagination] - 1, - }); - }, - [pagination, setPagination], - ); - - const artistQuery = useQuery( - albumQueries.list({ - query: { - _custom: { - jellyfin: { - ExcludeItemIds: detail?.id, + const carousels = useMemo( + () => [ + { + excludeIds: detail?.id ? [detail.id] : undefined, + isHidden: !detail?.albumArtists?.[0]?.id, + query: { + _custom: { + jellyfin: { + ExcludeItemIds: detail?.id, + }, }, + artistIds: detail?.albumArtists.length + ? [detail.albumArtists[0].id] + : undefined, }, - artistIds: detail?.albumArtists.length ? [detail?.albumArtists[0].id] : undefined, - limit: 15, sortBy: AlbumListSort.YEAR, sortOrder: SortOrder.DESC, - startIndex: 0, + title: t('page.albumDetail.moreFromArtist', { postProcess: 'sentenceCase' }), + uniqueId: 'moreFromArtist', }, - serverId: server.id, - }), + { + excludeIds: detail?.id ? [detail.id] : undefined, + isHidden: !detailQuery?.data?.genres?.[0], + query: { + genres: detailQuery.data?.genres.length + ? [detailQuery.data.genres[0].id] + : undefined, + }, + sortBy: AlbumListSort.RANDOM, + sortOrder: SortOrder.ASC, + title: `${t('page.albumDetail.moreFromGeneric', { + item: '', + postProcess: 'sentenceCase', + })} ${detailQuery?.data?.genres?.[0]?.name}`, + uniqueId: 'relatedGenres', + }, + ], + [detail?.id, detail?.albumArtists, detailQuery?.data?.genres, t], ); - - // const artistQuery = useAlbumList({ - // options: { - // enabled: detail?.albumArtists[0]?.id !== undefined, - // gcTime: 1000 * 60, - // placeholderData: true, - // }, - // query: { - // _custom: { - // jellyfin: { - // ExcludeItemIds: detailQuery?.data?.id, - // }, - // }, - // artistIds: detailQuery?.data?.albumArtists.length - // ? [detailQuery?.data?.albumArtists[0].id] - // : undefined, - // limit: 15, - // sortBy: AlbumListSort.YEAR, - // sortOrder: SortOrder.DESC, - // startIndex: 0, - // }, - // serverId: server?.id, - // }); - - const relatedAlbumGenresRequest: AlbumListQuery = { - genres: detailQuery.data?.genres.length ? [detailQuery.data.genres[0].id] : undefined, - limit: 15, - sortBy: AlbumListSort.RANDOM, - sortOrder: SortOrder.ASC, - startIndex: 0, - }; - - const relatedAlbumGenresQuery = useQuery( - albumQueries.list({ - options: { - enabled: !!detailQuery?.data?.genres?.[0], - gcTime: 1000 * 60, - queryKey: queryKeys.albums.related( - server?.id || '', - albumId, - relatedAlbumGenresRequest, - ), - }, - query: relatedAlbumGenresRequest, - serverId: server?.id, - }), - ); - - const carousels = [ - { - data: artistQuery?.data?.items.filter((a) => a.id !== detailQuery?.data?.id), - isHidden: !artistQuery?.data?.items.filter((a) => a.id !== detailQuery?.data?.id) - .length, - loading: artistQuery?.isLoading || artistQuery.isFetching, - pagination: { - handleNextPage: () => handleNextPage('artist'), - handlePreviousPage: () => handlePreviousPage('artist'), - hasPreviousPage: pagination.artist > 0, - }, - title: t('page.albumDetail.moreFromArtist', { postProcess: 'sentenceCase' }), - uniqueId: 'mostPlayed', - }, - { - data: relatedAlbumGenresQuery?.data?.items.filter( - (a) => a.id !== detailQuery?.data?.id, - ), - isHidden: !relatedAlbumGenresQuery?.data?.items.filter( - (a) => a.id !== detailQuery?.data?.id, - ).length, - loading: relatedAlbumGenresQuery?.isLoading || relatedAlbumGenresQuery.isFetching, - title: `${t('page.albumDetail.moreFromGeneric', { - item: '', - postProcess: 'sentenceCase', - })} ${detailQuery?.data?.genres?.[0]?.name}`, - uniqueId: 'relatedGenres', - }, - ]; const playButtonBehavior = usePlayButtonBehavior(); - const handlePlay = async (playType?: Play) => { - handlePlayQueueAdd?.({ - byData: detailQuery?.data?.songs, - playType: playType || playButtonBehavior, - }); - }; - - const onCellContextMenu = useHandleTableContextMenu(LibraryItem.SONG, SONG_CONTEXT_MENU_ITEMS); - - const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => { - if (!e.data || e.node.isFullWidthCell()) return; - - const rowData: QueueSong[] = []; - e.api.forEachNode((node) => { - if (!node.data || node.isFullWidthCell()) return; - rowData.push(node.data); - }); - - handlePlayQueueAdd?.({ - byData: rowData, - initialSongId: e.data.id, - playType: playButtonBehavior, - }); - }; - - const createFavoriteMutation = useCreateFavorite({}); - const deleteFavoriteMutation = useDeleteFavorite({}); + const handlePlay = async (playType?: Play) => {}; const handleFavorite = () => { if (!detailQuery?.data) return; - - if (detailQuery.data.userFavorite) { - deleteFavoriteMutation.mutate({ - apiClientProps: { serverId: detailQuery.data._serverId }, - query: { - id: [detailQuery.data.id], - type: LibraryItem.ALBUM, - }, - }); - } else { - createFavoriteMutation.mutate({ - apiClientProps: { serverId: detailQuery.data._serverId }, - query: { - id: [detailQuery.data.id], - type: LibraryItem.ALBUM, - }, - }); - } }; const showGenres = detailQuery?.data?.genres ? detailQuery?.data?.genres.length !== 0 : false; const comment = detailQuery?.data?.comment; - const handleGeneralContextMenu = useHandleGeneralContextMenu( - LibraryItem.ALBUM, - ALBUM_CONTEXT_MENU_ITEMS, - ); - - const onColumnMoved = useCallback(() => { - const { columnApi } = tableRef?.current || {}; - const columnsOrder = columnApi?.getAllGridColumns(); - - if (!columnsOrder) return; - - const columnsInSettings = tableConfig.columns; - const updatedColumns: PersistedTableColumn[] = []; - for (const column of columnsOrder) { - const columnInSettings = columnsInSettings.find( - (c) => c.column === column.getColDef().colId, - ); - - if (columnInSettings) { - updatedColumns.push({ - ...columnInSettings, - ...(!tableConfig.autoFit && { - width: column.getActualWidth(), - }), - }); - } - } - - setTable('albumDetail', { ...tableConfig, columns: updatedColumns }); - }, [setTable, tableConfig, tableRef]); - - const { rowClassRules } = useCurrentSongRowStyles({ tableRef }); - const mbzId = detailQuery?.data?.mbzId; return ( @@ -361,10 +111,6 @@ export const AlbumDetailContent = ({ background, tableRef }: AlbumDetailContentP ? 'primary' : undefined, }} - loading={ - createFavoriteMutation.isPending || - deleteFavoriteMutation.isPending - } onClick={handleFavorite} size="lg" variant="transparent" @@ -373,29 +119,12 @@ export const AlbumDetailContent = ({ background, tableRef }: AlbumDetailContentP icon="ellipsisHorizontal" onClick={(e) => { if (!detailQuery?.data) return; - handleGeneralContextMenu(e, [detailQuery.data!]); }} size="lg" variant="transparent" /> </Group> </Group> - <Popover position="bottom-end"> - <Popover.Target> - <ActionIcon - icon="settings" - onClick={(e) => { - if (!detailQuery?.data) return; - handleGeneralContextMenu(e, [detailQuery.data!]); - }} - size="lg" - variant="transparent" - /> - </Popover.Target> - <Popover.Dropdown> - <TableConfigDropdown type="albumDetail" /> - </Popover.Dropdown> - </Popover> </Group> </section> {showGenres && ( @@ -468,91 +197,26 @@ export const AlbumDetailContent = ({ background, tableRef }: AlbumDetailContentP <Spoiler maxHeight={75}>{replaceURLWithHTMLLinks(comment)}</Spoiler> </section> )} - <div style={{ minHeight: '300px' }}> - <VirtualTable - autoFitColumns={tableConfig.autoFit} - autoHeight - columnDefs={columnDefs} - context={{ - currentSong, - isFocused, - itemType: LibraryItem.SONG, - onCellContextMenu, - status, - }} - enableCellChangeFlash={false} - fullWidthCellRenderer={FullWidthDiscCell} - getRowHeight={getRowHeight} - getRowId={(data) => data.data.id} - isFullWidthRow={(data) => { - return isFullWidthRow(data.rowNode) || false; - }} - isRowSelectable={(data) => { - if (isFullWidthRow(data.data)) return false; - return true; - }} - key={`table-${tableConfig.rowHeight}`} - onCellContextMenu={onCellContextMenu} - onColumnMoved={onColumnMoved} - onRowDoubleClicked={handleRowDoubleClick} - ref={tableRef} - rowClassRules={rowClassRules} - rowData={songsRowData} - rowSelection="multiple" - shouldUpdateSong - stickyHeader - suppressCellFocus - suppressLoadingOverlay - suppressRowDrag - /> - </div> + <Stack gap="lg" mt="3rem" ref={cq.ref}> {cq.height || cq.width ? ( <> {carousels .filter((c) => !c.isHidden) - .map((carousel, index) => ( - <MemoizedSwiperGridCarousel - cardRows={[ - { - property: 'name', - route: { - route: AppRoute.LIBRARY_ALBUMS_DETAIL, - slugs: [ - { - idProperty: 'id', - slugProperty: 'albumId', - }, - ], - }, - }, - { - arrayProperty: 'name', - property: 'albumArtists', - route: { - route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, - slugs: [ - { - idProperty: 'id', - slugProperty: 'albumArtistId', - }, - ], - }, - }, - ]} - data={carousel.data} - isLoading={carousel.loading} - itemType={LibraryItem.ALBUM} - key={`carousel-${carousel.uniqueId}-${index}`} - route={{ - route: AppRoute.LIBRARY_ALBUMS_DETAIL, - slugs: [{ idProperty: 'id', slugProperty: 'albumId' }], - }} - title={{ - label: carousel.title, - }} - uniqueId={carousel.uniqueId} - /> + .map((carousel) => ( + <Suspense + fallback={<Spinner container />} + key={`carousel-${carousel.uniqueId}`} + > + <AlbumInfiniteCarousel + excludeIds={carousel.excludeIds} + query={carousel.query} + rowCount={1} + sortBy={carousel.sortBy} + sortOrder={carousel.sortOrder} + title={carousel.title} + /> + </Suspense> ))} </> ) : null} diff --git a/src/renderer/features/albums/components/album-grid-carousel.tsx b/src/renderer/features/albums/components/album-grid-carousel.tsx new file mode 100644 index 000000000..509461078 --- /dev/null +++ b/src/renderer/features/albums/components/album-grid-carousel.tsx @@ -0,0 +1,60 @@ +import { useMemo } from 'react'; + +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 { Album, LibraryItem } from '/@/shared/types/domain-types'; +import { ItemListKey } from '/@/shared/types/types'; + +interface AlbumGridCarouselProps { + data: Album[]; + excludeIds?: string[]; + rowCount?: number; + title: React.ReactNode | string; +} + +export function AlbumGridCarousel(props: AlbumGridCarouselProps) { + const { data, excludeIds, rowCount = 1, title } = props; + const rows = useGridRows(LibraryItem.ALBUM, ItemListKey.ALBUM); + const controls = useDefaultItemListControls(); + + const cards = useMemo(() => { + // Filter out excluded IDs if provided + const filteredItems = excludeIds + ? data.filter((album) => !excludeIds.includes(album.id)) + : data; + + return filteredItems.map((album: Album) => ({ + content: ( + <MemoizedItemCard + controls={controls} + data={album} + enableDrag + itemType={LibraryItem.ALBUM} + rows={rows} + type="poster" + withControls + /> + ), + id: album.id, + })); + }, [data, excludeIds, controls, rows]); + + const handleNextPage = () => {}; + const handlePrevPage = () => {}; + + if (cards.length === 0) { + return null; + } + + return ( + <GridCarousel + cards={cards} + onNextPage={handleNextPage} + onPrevPage={handlePrevPage} + rowCount={rowCount} + title={title} + /> + ); +} diff --git a/src/renderer/features/albums/components/album-infinite-carousel.tsx b/src/renderer/features/albums/components/album-infinite-carousel.tsx new file mode 100644 index 000000000..57716ba4f --- /dev/null +++ b/src/renderer/features/albums/components/album-infinite-carousel.tsx @@ -0,0 +1,128 @@ +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 { useCurrentServerId } from '/@/renderer/store'; +import { + Album, + AlbumListQuery, + AlbumListResponse, + AlbumListSort, + LibraryItem, + SortOrder, +} from '/@/shared/types/domain-types'; +import { ItemListKey } from '/@/shared/types/types'; + +interface AlbumCarouselProps { + excludeIds?: string[]; + query?: Partial<Omit<AlbumListQuery, 'startIndex'>>; + rowCount?: number; + sortBy: AlbumListSort; + sortOrder: SortOrder; + title: React.ReactNode | string; +} + +export function AlbumInfiniteCarousel(props: AlbumCarouselProps) { + const { excludeIds, query: additionalQuery, rowCount = 1, sortBy, sortOrder, title } = props; + const rows = useGridRows(LibraryItem.ALBUM, ItemListKey.ALBUM); + const { + data: albums, + fetchNextPage, + hasNextPage, + } = useAlbumListInfinite(sortBy, sortOrder, 20, additionalQuery); + + const controls = useDefaultItemListControls(); + + const cards = useMemo(() => { + // Flatten all pages and filter excluded IDs + const allItems = albums.pages.flatMap((page: AlbumListResponse) => page.items); + const filteredItems = excludeIds + ? allItems.filter((album) => !excludeIds.includes(album.id)) + : allItems; + + return filteredItems.map((album: Album) => ({ + content: ( + <MemoizedItemCard + controls={controls} + data={album} + enableDrag + itemType={LibraryItem.ALBUM} + rows={rows} + type="poster" + withControls + /> + ), + id: album.id, + })); + }, [albums.pages, controls, excludeIds, rows]); + + const handleNextPage = useCallback(() => {}, []); + + const handlePrevPage = useCallback(() => {}, []); + + const firstPageItems = excludeIds + ? albums.pages[0]?.items.filter((album) => !excludeIds.includes(album.id)) || [] + : albums.pages[0]?.items || []; + + if (firstPageItems.length === 0) { + return null; + } + + return ( + <GridCarousel + cards={cards} + hasNextPage={hasNextPage} + loadNextPage={fetchNextPage} + onNextPage={handleNextPage} + onPrevPage={handlePrevPage} + rowCount={rowCount} + title={title} + /> + ); +} + +function useAlbumListInfinite( + sortBy: AlbumListSort, + sortOrder: SortOrder, + itemLimit: number, + additionalQuery?: Partial<Omit<AlbumListQuery, 'startIndex'>>, +) { + const serverId = useCurrentServerId(); + + const query = useSuspenseInfiniteQuery<AlbumListResponse>({ + 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, + sortOrder, + startIndex: Number(pageParam), + ...additionalQuery, + }, + }); + }, + queryKey: queryKeys.albums.list(serverId, { + sortBy, + sortOrder, + ...additionalQuery, + }), + }); + + return query; +} diff --git a/src/renderer/features/albums/routes/dummy-album-detail-route.tsx b/src/renderer/features/albums/routes/dummy-album-detail-route.tsx index e3ed8a2ce..e16c264b6 100644 --- a/src/renderer/features/albums/routes/dummy-album-detail-route.tsx +++ b/src/renderer/features/albums/routes/dummy-album-detail-route.tsx @@ -8,8 +8,6 @@ import styles from './dummy-album-detail-route.module.css'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { SONG_ALBUM_PAGE } from '/@/renderer/features/context-menu/context-menu-items'; -import { useHandleGeneralContextMenu } from '/@/renderer/features/context-menu/hooks/use-handle-context-menu'; import { usePlayQueueAdd } from '/@/renderer/features/player/hooks/use-playqueue-add'; import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page'; import { LibraryHeader } from '/@/renderer/features/shared/components/library-header'; @@ -97,8 +95,6 @@ const DummyAlbumDetailRoute = () => { const showGenres = detailQuery?.data?.genres ? detailQuery?.data?.genres.length !== 0 : false; const comment = detailQuery?.data?.comment; - const handleGeneralContextMenu = useHandleGeneralContextMenu(LibraryItem.SONG, SONG_ALBUM_PAGE); - const handlePlay = () => { handlePlayQueueAdd?.({ byItemType: { @@ -190,7 +186,6 @@ const DummyAlbumDetailRoute = () => { icon="ellipsisHorizontal" onClick={(e) => { if (!detailQuery?.data) return; - handleGeneralContextMenu(e, [detailQuery.data!]); }} variant="subtle" /> diff --git a/src/renderer/features/artists/components/album-artist-detail-content.tsx b/src/renderer/features/artists/components/album-artist-detail-content.tsx index 9c85a5224..17a0e3606 100644 --- a/src/renderer/features/artists/components/album-artist-detail-content.tsx +++ b/src/renderer/features/artists/components/album-artist-detail-content.tsx @@ -1,24 +1,14 @@ -import { ColDef, RowDoubleClickedEvent } from '@ag-grid-community/core'; +import { RowDoubleClickedEvent } from '@ag-grid-community/core'; import { useQuery } from '@tanstack/react-query'; -import { useMemo } from 'react'; +import { Suspense, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { generatePath, useParams } from 'react-router'; -import { createSearchParams, Link } from 'react-router'; +import { createSearchParams, generatePath, Link, useParams } from 'react-router'; import styles from './album-artist-detail-content.module.css'; -import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel/grid-carousel'; -import { getColumnDefs, VirtualTable } from '/@/renderer/components/virtual-table'; -import { albumQueries } from '/@/renderer/features/albums/api/album-api'; +import { AlbumInfiniteCarousel } from '/@/renderer/features/albums/components/album-infinite-carousel'; import { artistsQueries } from '/@/renderer/features/artists/api/artists-api'; -import { - ARTIST_CONTEXT_MENU_ITEMS, - SONG_CONTEXT_MENU_ITEMS, -} from '/@/renderer/features/context-menu/context-menu-items'; -import { - useHandleGeneralContextMenu, - useHandleTableContextMenu, -} from '/@/renderer/features/context-menu/hooks/use-handle-context-menu'; +import { AlbumArtistGridCarousel } from '/@/renderer/features/artists/components/album-artist-grid-carousel'; import { usePlayQueueAdd } from '/@/renderer/features/player/hooks/use-playqueue-add'; import { LibraryBackgroundOverlay } from '/@/renderer/features/shared/components/library-background-overlay'; import { PlayButton } from '/@/renderer/features/shared/components/play-button'; @@ -34,11 +24,11 @@ import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { Button } from '/@/shared/components/button/button'; import { Grid } from '/@/shared/components/grid/grid'; import { Group } from '/@/shared/components/group/group'; +import { Spinner } from '/@/shared/components/spinner/spinner'; import { Spoiler } from '/@/shared/components/spoiler/spoiler'; import { Stack } from '/@/shared/components/stack/stack'; import { TextTitle } from '/@/shared/components/text-title/text-title'; import { - Album, AlbumArtist, AlbumListSort, LibraryItem, @@ -46,7 +36,7 @@ import { ServerType, SortOrder, } from '/@/shared/types/domain-types'; -import { CardRow, Play, TableColumn } from '/@/shared/types/types'; +import { Play } from '/@/shared/types/types'; interface AlbumArtistDetailContentProps { background?: string; @@ -101,40 +91,6 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten artistName: detailQuery?.data?.name || '', })}`; - const recentAlbumsQuery = useQuery( - albumQueries.list({ - options: { - enabled: enabledItem.recentAlbums, - }, - query: { - artistIds: [routeId], - compilation: false, - limit: 15, - sortBy: AlbumListSort.RELEASE_DATE, - sortOrder: SortOrder.DESC, - startIndex: 0, - }, - serverId: server?.id, - }), - ); - - const compilationAlbumsQuery = useQuery( - albumQueries.list({ - options: { - enabled: enabledItem.compilations && server?.type !== ServerType.SUBSONIC, - }, - query: { - artistIds: [routeId], - compilation: true, - limit: 15, - sortBy: AlbumListSort.RELEASE_DATE, - sortOrder: SortOrder.DESC, - startIndex: 0, - }, - serverId: server?.id, - }), - ); - const topSongsQuery = useQuery( artistsQueries.topSongs({ options: { @@ -148,68 +104,18 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten }), ); - const topSongsColumnDefs: ColDef[] = useMemo( - () => - getColumnDefs([ - { column: TableColumn.ROW_INDEX, width: 0 }, - { column: TableColumn.TITLE_COMBINED, width: 0 }, - { column: TableColumn.DURATION, width: 0 }, - { column: TableColumn.ALBUM, width: 0 }, - { column: TableColumn.YEAR, width: 0 }, - { column: TableColumn.PLAY_COUNT, width: 0 }, - { column: TableColumn.USER_FAVORITE, width: 0 }, - ]), - [], - ); - - const cardRows: Record<string, CardRow<Album>[] | CardRow<AlbumArtist>[]> = { - album: [ - { - property: 'name', - route: { - route: AppRoute.LIBRARY_ALBUMS_DETAIL, - slugs: [{ idProperty: 'id', slugProperty: 'albumId' }], - }, - }, - { - arrayProperty: 'name', - property: 'albumArtists', - route: { - route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, - slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }], - }, - }, - ], - albumArtist: [ - { - property: 'name', - route: { - route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, - slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }], - }, - }, - ], - }; - - const cardRoutes = { - album: { - route: AppRoute.LIBRARY_ALBUMS_DETAIL, - slugs: [{ idProperty: 'id', slugProperty: 'albumId' }], - }, - albumArtist: { - route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, - slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }], - }, - }; - const carousels = useMemo(() => { return [ { - data: recentAlbumsQuery?.data?.items, - isHidden: !recentAlbumsQuery?.data?.items?.length || !enabledItem.recentAlbums, + isHidden: !enabledItem.recentAlbums || !routeId, itemType: LibraryItem.ALBUM, - loading: recentAlbumsQuery?.isLoading || recentAlbumsQuery.isFetching, order: itemOrder.recentAlbums, + query: { + artistIds: routeId ? [routeId] : undefined, + compilation: false, + }, + sortBy: AlbumListSort.RELEASE_DATE, + sortOrder: SortOrder.DESC, title: ( <Group align="flex-end"> <TextTitle fw={700} order={2}> @@ -230,14 +136,16 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten uniqueId: 'recentReleases', }, { - data: compilationAlbumsQuery?.data?.items, isHidden: - !compilationAlbumsQuery?.data?.items?.length || - !enabledItem.compilations || - server?.type === ServerType.SUBSONIC, + !enabledItem.compilations || server?.type === ServerType.SUBSONIC || !routeId, itemType: LibraryItem.ALBUM, - loading: compilationAlbumsQuery?.isLoading || compilationAlbumsQuery.isFetching, order: itemOrder.compilations, + query: { + artistIds: routeId ? [routeId] : undefined, + compilation: true, + }, + sortBy: AlbumListSort.RELEASE_DATE, + sortOrder: SortOrder.DESC, title: ( <TextTitle fw={700} order={2}> {t('page.albumArtistDetail.appearsOn', { postProcess: 'sentenceCase' })} @@ -246,7 +154,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten uniqueId: 'compilationAlbums', }, { - data: detailQuery?.data?.similarArtists || [], + data: (detailQuery?.data?.similarArtists || []) as AlbumArtist[], isHidden: !detailQuery?.data?.similarArtists || !enabledItem.similarArtists, itemType: LibraryItem.ALBUM_ARTIST, order: itemOrder.similarArtists, @@ -262,9 +170,6 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten ]; }, [ artistDiscographyLink, - compilationAlbumsQuery?.data?.items, - compilationAlbumsQuery.isFetching, - compilationAlbumsQuery?.isLoading, detailQuery?.data?.similarArtists, enabledItem.compilations, enabledItem.recentAlbums, @@ -272,9 +177,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten itemOrder.compilations, itemOrder.recentAlbums, itemOrder.similarArtists, - recentAlbumsQuery?.data?.items, - recentAlbumsQuery.isFetching, - recentAlbumsQuery?.isLoading, + routeId, server?.type, t, ]); @@ -291,16 +194,8 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten }); }; - const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, SONG_CONTEXT_MENU_ITEMS); - const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => { if (!e.data || !topSongsQuery?.data) return; - - handlePlayQueueAdd?.({ - byData: topSongsQuery?.data?.items || [], - initialSongId: e.data.id, - playType: playButtonBehavior, - }); }; const createFavoriteMutation = useCreateFavorite({}); @@ -311,7 +206,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten if (detailQuery.data.userFavorite) { deleteFavoriteMutation.mutate({ - apiClientProps: { serverId: detailQuery.data.serverId }, + apiClientProps: { serverId: detailQuery.data._serverId }, query: { id: [detailQuery.data.id], type: LibraryItem.ALBUM_ARTIST, @@ -319,7 +214,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten }); } else { createFavoriteMutation.mutate({ - apiClientProps: { serverId: detailQuery.data.serverId }, + apiClientProps: { serverId: detailQuery.data._serverId }, query: { id: [detailQuery.data.id], type: LibraryItem.ALBUM_ARTIST, @@ -329,17 +224,6 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten }; const albumCount = detailQuery?.data?.albumCount; - const artistContextItems = - (albumCount ?? 1) > 0 - ? ARTIST_CONTEXT_MENU_ITEMS - : ARTIST_CONTEXT_MENU_ITEMS.filter((item) => !item.id.toLowerCase().includes('play')); - - const handleGeneralContextMenu = useHandleGeneralContextMenu( - LibraryItem.ALBUM_ARTIST, - artistContextItems, - ); - - const topSongs = topSongsQuery?.data?.items?.slice(0, 10); const biography = useMemo(() => { const bio = detailQuery?.data?.biography; @@ -384,7 +268,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten icon="ellipsisHorizontal" onClick={(e) => { if (!detailQuery?.data) return; - handleGeneralContextMenu(e, [detailQuery.data!]); + // handleGeneralContextMenu(e, [detailQuery.data!]); }} size="lg" variant="transparent" @@ -512,28 +396,6 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten </Button> </Group> </Group> - <VirtualTable - autoFitColumns - autoHeight - columnDefs={topSongsColumnDefs} - context={{ - itemType: LibraryItem.SONG, - }} - deselectOnClickOutside - enableCellChangeFlash={false} - getRowId={(data) => data.data.uniqueId} - onCellContextMenu={handleContextMenu} - onRowDoubleClicked={handleRowDoubleClick} - rowData={topSongs} - rowHeight={60} - rowSelection="multiple" - shouldUpdateSong - stickyHeader - suppressCellFocus - suppressHorizontalScroll - suppressLoadingOverlay - suppressRowDrag - /> </section> </Grid.Col> ) : null} @@ -548,28 +410,30 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten > <section> <Stack gap="xl"> - <MemoizedSwiperGridCarousel - cardRows={ - cardRows[carousel.itemType as keyof typeof cardRows] - } - data={carousel.data} - isLoading={carousel.loading} - itemType={carousel.itemType} - route={ - cardRoutes[ - carousel.itemType as keyof typeof cardRoutes - ] - } - swiperProps={{ - grid: { - rows: 2, - }, - }} - title={{ - label: carousel.title, - }} - uniqueId={carousel.uniqueId} - /> + {carousel.itemType === LibraryItem.ALBUM ? ( + 'query' in carousel && + carousel.query && + carousel.sortBy && + carousel.sortOrder ? ( + <Suspense fallback={<Spinner container />}> + <AlbumInfiniteCarousel + query={carousel.query} + rowCount={1} + sortBy={carousel.sortBy} + sortOrder={carousel.sortOrder} + title={carousel.title} + /> + </Suspense> + ) : null + ) : carousel.itemType === LibraryItem.ALBUM_ARTIST ? ( + 'data' in carousel && carousel.data ? ( + <AlbumArtistGridCarousel + data={carousel.data} + rowCount={1} + title={carousel.title} + /> + ) : null + ) : null} </Stack> </section> </Grid.Col> diff --git a/src/renderer/features/artists/components/album-artist-grid-carousel.tsx b/src/renderer/features/artists/components/album-artist-grid-carousel.tsx new file mode 100644 index 000000000..8f21a7838 --- /dev/null +++ b/src/renderer/features/artists/components/album-artist-grid-carousel.tsx @@ -0,0 +1,60 @@ +import { useMemo } from 'react'; + +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 { AlbumArtist, LibraryItem } from '/@/shared/types/domain-types'; +import { ItemListKey } from '/@/shared/types/types'; + +interface AlbumArtistGridCarouselProps { + data: AlbumArtist[]; + excludeIds?: string[]; + rowCount?: number; + title: React.ReactNode | string; +} + +export function AlbumArtistGridCarousel(props: AlbumArtistGridCarouselProps) { + const { data, excludeIds, rowCount = 1, title } = props; + const rows = useGridRows(LibraryItem.ALBUM_ARTIST, ItemListKey.ALBUM_ARTIST); + const controls = useDefaultItemListControls(); + + const cards = useMemo(() => { + // Filter out excluded IDs if provided + const filteredItems = excludeIds + ? data.filter((albumArtist) => !excludeIds.includes(albumArtist.id)) + : data; + + return filteredItems.map((albumArtist: AlbumArtist) => ({ + content: ( + <MemoizedItemCard + controls={controls} + data={albumArtist} + enableDrag + itemType={LibraryItem.ALBUM_ARTIST} + rows={rows} + type="poster" + withControls + /> + ), + id: albumArtist.id, + })); + }, [data, excludeIds, controls, rows]); + + const handleNextPage = () => {}; + const handlePrevPage = () => {}; + + if (cards.length === 0) { + return null; + } + + return ( + <GridCarousel + cards={cards} + onNextPage={handleNextPage} + onPrevPage={handlePrevPage} + rowCount={rowCount} + title={title} + /> + ); +} diff --git a/src/renderer/features/artists/components/album-artist-infinite-carousel.tsx b/src/renderer/features/artists/components/album-artist-infinite-carousel.tsx new file mode 100644 index 000000000..3a5208d36 --- /dev/null +++ b/src/renderer/features/artists/components/album-artist-infinite-carousel.tsx @@ -0,0 +1,130 @@ +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 { useCurrentServerId } from '/@/renderer/store'; +import { + AlbumArtist, + AlbumArtistListQuery, + AlbumArtistListResponse, + AlbumArtistListSort, + LibraryItem, + SortOrder, +} from '/@/shared/types/domain-types'; +import { ItemListKey } from '/@/shared/types/types'; + +interface AlbumArtistCarouselProps { + excludeIds?: string[]; + query?: Partial<Omit<AlbumArtistListQuery, 'startIndex'>>; + rowCount?: number; + sortBy: AlbumArtistListSort; + sortOrder: SortOrder; + title: React.ReactNode | string; +} + +export function AlbumArtistInfiniteCarousel(props: AlbumArtistCarouselProps) { + const { excludeIds, query: additionalQuery, rowCount = 1, sortBy, sortOrder, title } = props; + const rows = useGridRows(LibraryItem.ALBUM_ARTIST, ItemListKey.ALBUM_ARTIST); + const { + data: albumArtists, + fetchNextPage, + hasNextPage, + } = useAlbumArtistListInfinite(sortBy, sortOrder, 20, additionalQuery); + + const controls = useDefaultItemListControls(); + + const cards = useMemo(() => { + // Flatten all pages and filter excluded IDs + const allItems = albumArtists.pages.flatMap((page: AlbumArtistListResponse) => page.items); + const filteredItems = excludeIds + ? allItems.filter((albumArtist) => !excludeIds.includes(albumArtist.id)) + : allItems; + + return filteredItems.map((albumArtist: AlbumArtist) => ({ + content: ( + <MemoizedItemCard + controls={controls} + data={albumArtist} + enableDrag + itemType={LibraryItem.ALBUM_ARTIST} + rows={rows} + type="poster" + withControls + /> + ), + id: albumArtist.id, + })); + }, [albumArtists.pages, controls, excludeIds, rows]); + + const handleNextPage = useCallback(() => {}, []); + + const handlePrevPage = useCallback(() => {}, []); + + const firstPageItems = excludeIds + ? albumArtists.pages[0]?.items.filter( + (albumArtist) => !excludeIds.includes(albumArtist.id), + ) || [] + : albumArtists.pages[0]?.items || []; + + if (firstPageItems.length === 0) { + return null; + } + + return ( + <GridCarousel + cards={cards} + hasNextPage={hasNextPage} + loadNextPage={fetchNextPage} + onNextPage={handleNextPage} + onPrevPage={handlePrevPage} + rowCount={rowCount} + title={title} + /> + ); +} + +function useAlbumArtistListInfinite( + sortBy: AlbumArtistListSort, + sortOrder: SortOrder, + itemLimit: number, + additionalQuery?: Partial<Omit<AlbumArtistListQuery, 'startIndex'>>, +) { + const serverId = useCurrentServerId(); + + const query = useSuspenseInfiniteQuery<AlbumArtistListResponse>({ + 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.getAlbumArtistList({ + apiClientProps: { serverId, signal }, + query: { + limit: itemLimit, + sortBy, + sortOrder, + startIndex: Number(pageParam), + ...additionalQuery, + }, + }); + }, + queryKey: queryKeys.albumArtists.list(serverId, { + sortBy, + sortOrder, + ...additionalQuery, + }), + }); + + return query; +} diff --git a/src/renderer/features/home/routes/home-route.tsx b/src/renderer/features/home/routes/home-route.tsx index f5af8e358..a009aeab8 100644 --- a/src/renderer/features/home/routes/home-route.tsx +++ b/src/renderer/features/home/routes/home-route.tsx @@ -1,28 +1,22 @@ import { useQuery } from '@tanstack/react-query'; -import { useMemo, useRef } from 'react'; +import { Suspense, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { FeatureCarousel } from '/@/renderer/components/feature-carousel/feature-carousel'; -import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel/grid-carousel'; import { NativeScrollArea } from '/@/renderer/components/native-scroll-area/native-scroll-area'; import { albumQueries } from '/@/renderer/features/albums/api/album-api'; -import { homeQueries } from '/@/renderer/features/home/api/home-api'; +import { AlbumInfiniteCarousel } from '/@/renderer/features/albums/components/album-infinite-carousel'; import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page'; import { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar'; import { songsQueries } from '/@/renderer/features/songs/api/songs-api'; -import { AppRoute } from '/@/renderer/router/routes'; import { HomeItem, useCurrentServer, useGeneralSettings, useWindowSettings, } from '/@/renderer/store'; -import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; -import { Group } from '/@/shared/components/group/group'; -import { Icon } from '/@/shared/components/icon/icon'; import { Spinner } from '/@/shared/components/spinner/spinner'; import { Stack } from '/@/shared/components/stack/stack'; -import { TextTitle } from '/@/shared/components/text-title/text-title'; import { AlbumListSort, LibraryItem, @@ -32,12 +26,6 @@ import { } from '/@/shared/types/domain-types'; import { Platform } from '/@/shared/types/types'; -const BASE_QUERY_ARGS = { - limit: 15, - sortOrder: SortOrder.DESC, - startIndex: 0, -}; - const HomeRoute = () => { const { t } = useTranslation(); const scrollAreaRef = useRef<HTMLDivElement>(null); @@ -45,16 +33,9 @@ const HomeRoute = () => { const { windowBarStyle } = useWindowSettings(); const { homeFeature, homeItems } = useGeneralSettings(); - const queriesEnabled = useMemo(() => { - return homeItems.reduce( - (previous: Record<HomeItem, boolean>, current) => ({ - ...previous, - [current.id]: !current.disabled, - }), - {} as Record<HomeItem, boolean>, - ); - }, [homeItems]); + const isJellyfin = server?.type === ServerType.JELLYFIN; + // Only keep queries for FeatureCarousel and songs carousel (which still uses old carousel) const feature = useQuery( albumQueries.list({ options: { @@ -72,83 +53,15 @@ const HomeRoute = () => { }), ); - const isJellyfin = server?.type === ServerType.JELLYFIN; - - const featureItemsWithImage = useMemo(() => { - return feature.data?.items?.filter((item) => item.imageUrl) ?? []; - }, [feature.data?.items]); - - const random = useQuery( - albumQueries.list({ - options: { - staleTime: 1000 * 60 * 5, - }, - query: { - ...BASE_QUERY_ARGS, - sortBy: AlbumListSort.RANDOM, - sortOrder: SortOrder.ASC, - startIndex: 0, - }, - serverId: server?.id, - }), - ); - - const recentlyPlayed = useQuery( - homeQueries.recentlyPlayed({ - options: { - staleTime: 0, - }, - query: { - ...BASE_QUERY_ARGS, - sortBy: AlbumListSort.RECENTLY_PLAYED, - sortOrder: SortOrder.DESC, - startIndex: 0, - }, - serverId: server?.id, - }), - ); - - const recentlyAdded = useQuery( - albumQueries.list({ - options: { - staleTime: 1000 * 60 * 5, - }, - query: { - ...BASE_QUERY_ARGS, - sortBy: AlbumListSort.RECENTLY_ADDED, - sortOrder: SortOrder.DESC, - startIndex: 0, - }, - serverId: server?.id, - }), - ); - - const mostPlayedAlbums = useQuery( - albumQueries.list({ - options: { - enabled: - server?.type === ServerType.SUBSONIC || server?.type === ServerType.NAVIDROME, - staleTime: 1000 * 60 * 5, - }, - query: { - ...BASE_QUERY_ARGS, - sortBy: AlbumListSort.PLAY_COUNT, - sortOrder: SortOrder.DESC, - startIndex: 0, - }, - serverId: server?.id, - }), - ); - const mostPlayedSongs = useQuery( songsQueries.list( { options: { - enabled: server?.type === ServerType.JELLYFIN, + enabled: isJellyfin, staleTime: 1000 * 60 * 5, }, query: { - ...BASE_QUERY_ARGS, + limit: 15, sortBy: SongListSort.PLAY_COUNT, sortOrder: SortOrder.DESC, startIndex: 0, @@ -159,62 +72,42 @@ const HomeRoute = () => { ), ); - const recentlyReleased = useQuery( - albumQueries.list({ - options: { - enabled: queriesEnabled[HomeItem.RECENTLY_RELEASED], - staleTime: 1000 * 60 * 5, - }, - query: { - ...BASE_QUERY_ARGS, - sortBy: AlbumListSort.RELEASE_DATE, - }, - serverId: server?.id, - }), - ); - - const isLoading = - (random.isLoading && queriesEnabled[HomeItem.RANDOM]) || - (recentlyPlayed.isLoading && queriesEnabled[HomeItem.RECENTLY_PLAYED] && !isJellyfin) || - (recentlyAdded.isLoading && queriesEnabled[HomeItem.RECENTLY_ADDED]) || - (recentlyReleased.isLoading && queriesEnabled[HomeItem.RECENTLY_RELEASED]) || - (((isJellyfin && mostPlayedSongs.isLoading) || - (!isJellyfin && mostPlayedAlbums.isLoading)) && - queriesEnabled[HomeItem.MOST_PLAYED]); - - if (isLoading) { - return <Spinner container />; - } + 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]: { - data: isJellyfin ? mostPlayedSongs?.data?.items : mostPlayedAlbums?.data?.items, + data: mostPlayedSongs?.data?.items, itemType: isJellyfin ? LibraryItem.SONG : LibraryItem.ALBUM, - query: isJellyfin ? mostPlayedSongs : mostPlayedAlbums, + query: mostPlayedSongs, + sortBy: AlbumListSort.PLAY_COUNT, + sortOrder: SortOrder.DESC, title: t('page.home.mostPlayed', { postProcess: 'sentenceCase' }), }, [HomeItem.RANDOM]: { - data: random?.data?.items, itemType: LibraryItem.ALBUM, - query: random, + sortBy: AlbumListSort.RANDOM, + sortOrder: SortOrder.ASC, title: t('page.home.explore', { postProcess: 'sentenceCase' }), }, [HomeItem.RECENTLY_ADDED]: { - data: recentlyAdded?.data?.items, itemType: LibraryItem.ALBUM, - query: recentlyAdded, + sortBy: AlbumListSort.RECENTLY_ADDED, + sortOrder: SortOrder.DESC, title: t('page.home.newlyAdded', { postProcess: 'sentenceCase' }), }, [HomeItem.RECENTLY_PLAYED]: { - data: recentlyPlayed?.data?.items, itemType: LibraryItem.ALBUM, - query: recentlyPlayed, + sortBy: AlbumListSort.RECENTLY_PLAYED, + sortOrder: SortOrder.DESC, title: t('page.home.recentlyPlayed', { postProcess: 'sentenceCase' }), }, [HomeItem.RECENTLY_RELEASED]: { - data: recentlyReleased?.data?.items, itemType: LibraryItem.ALBUM, - query: recentlyReleased, + sortBy: AlbumListSort.RELEASE_DATE, + sortOrder: SortOrder.DESC, title: t('page.home.recentlyReleased', { postProcess: 'sentenceCase' }), }, }; @@ -257,70 +150,31 @@ const HomeRoute = () => { px="2rem" > {homeFeature && <FeatureCarousel data={featureItemsWithImage} />} - {sortedCarousel.map((carousel) => ( - <MemoizedSwiperGridCarousel - cardRows={[ - { - property: 'name', - route: { - route: AppRoute.LIBRARY_ALBUMS_DETAIL, - slugs: [ - { - idProperty: - isJellyfin && - carousel.itemType === LibraryItem.SONG - ? 'albumId' - : 'id', - slugProperty: 'albumId', - }, - ], - }, - }, - { - arrayProperty: 'name', - property: 'albumArtists', - route: { - route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, - slugs: [ - { - idProperty: 'id', - slugProperty: 'albumArtistId', - }, - ], - }, - }, - ]} - data={carousel.data} - itemType={carousel.itemType} - key={`carousel-${carousel.uniqueId}`} - route={{ - route: AppRoute.LIBRARY_ALBUMS_DETAIL, - slugs: [ - { - idProperty: - isJellyfin && carousel.itemType === LibraryItem.SONG - ? 'albumId' - : 'id', - slugProperty: 'albumId', - }, - ], - }} - title={{ - label: ( - <Group> - <TextTitle order={3}>{carousel.title}</TextTitle> - <ActionIcon - onClick={() => carousel.query.refetch()} - variant="transparent" - > - <Icon icon="refresh" /> - </ActionIcon> - </Group> - ), - }} - uniqueId={carousel.uniqueId} - /> - ))} + {sortedCarousel.map((carousel) => { + if (carousel.itemType === LibraryItem.ALBUM) { + return ( + <Suspense + fallback={<Spinner container />} + key={`carousel-${carousel.uniqueId}`} + > + <AlbumInfiniteCarousel + rowCount={1} + sortBy={carousel.sortBy} + sortOrder={carousel.sortOrder} + title={carousel.title} + /> + </Suspense> + ); + } + + // Songs carousel (only for Jellyfin most played) - keep using old carousel for now + if ('data' in carousel && 'query' in carousel) { + // TODO: Create SongInfiniteCarousel + return null; + } + + return null; + })} </Stack> </NativeScrollArea> </AnimatedPage> diff --git a/src/renderer/hooks/use-container-query.ts b/src/renderer/hooks/use-container-query.ts index d9ec53260..b1e9438cb 100644 --- a/src/renderer/hooks/use-container-query.ts +++ b/src/renderer/hooks/use-container-query.ts @@ -21,5 +21,7 @@ export const useContainerQuery = (props?: UseContainerQueryProps) => { const is2xl = width >= (xxl || 1920); const is3xl = width >= (xxxl || 2560); - return { height, is2xl, is3xl, isLg, isMd, isSm, isXl, isXs, ref, width }; + const isCalculated = width !== 0; + + return { height, is2xl, is3xl, isCalculated, isLg, isMd, isSm, isXl, isXs, ref, width }; };