add new grid carousels

This commit is contained in:
jeffvli
2025-11-15 19:24:31 -08:00
parent 60cc564743
commit 2fc14ecd0e
18 changed files with 843 additions and 1130 deletions
@@ -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}
@@ -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}
/>
);
}
@@ -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;
}
@@ -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"
/>
@@ -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>
@@ -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}
/>
);
}
@@ -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;
}
+47 -193
View File
@@ -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>