diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index 8caa00416..1caa79411 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -38,6 +38,7 @@ const ALBUM_LIST_SORT_MAPPING: Record void; onRangeChanged?: (range: { startIndex: number; stopIndex: number }) => Promise | void; onScrollEnd?: (rowIndex: number) => void; + onSongRowDoubleClick?: (params: { + index: number; + internalState: ItemListStateActions; + item: Song; + }) => void; + overrideControls?: Partial; rowHeight?: number; scrollOffset?: number; + songsByAlbumId?: Record; tableId?: string; } @@ -109,7 +117,13 @@ interface RowData { getItem?: (index: number) => unknown; internalState: ItemListStateActions; isMutatingFavorite: boolean; + onSongRowDoubleClick?: (params: { + index: number; + internalState: ItemListStateActions; + item: Song; + }) => void; registerSongs: (albumId: string, songs: Song[]) => void; + songsByAlbumId?: Record; trackColumns: ItemTableListColumnConfig[]; trackTableSize: 'compact' | 'default' | 'large'; } @@ -126,6 +140,11 @@ interface TrackRowProps { internalState: ItemListStateActions; isMutatingFavorite: boolean; isSongsLoading?: boolean; + onSongRowDoubleClick?: (params: { + index: number; + internalState: ItemListStateActions; + item: Song; + }) => void; rowIndex: number; size: 'compact' | 'default' | 'large'; song: Song; @@ -147,6 +166,7 @@ const TrackRow = memo( internalState, isMutatingFavorite, isSongsLoading, + onSongRowDoubleClick, rowIndex, size, song, @@ -167,11 +187,37 @@ const TrackRow = memo( (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); + if (onSongRowDoubleClick) { + onSongRowDoubleClick({ + index: internalState.findItemIndex(song.id), + internalState, + item: song, + }); + return; + } + if (controls?.onDoubleClick) { + controls.onDoubleClick({ + event: e, + index: internalState.findItemIndex(song.id), + internalState, + item: song, + itemType: LibraryItem.SONG, + }); + return; + } if (isSongsLoading || albumSongs.length === 0) return; internalState.setSelected([song]); playerContext.addToQueueByData(albumSongs, Play.NOW, song.id); }, - [albumSongs, internalState, isSongsLoading, playerContext, song], + [ + albumSongs, + controls, + internalState, + isSongsLoading, + onSongRowDoubleClick, + playerContext, + song, + ], ); const handleRowClick = useCallback( @@ -610,7 +656,9 @@ const RowContent = memo( index, internalState, isMutatingFavorite, + onSongRowDoubleClick, registerSongs, + songsByAlbumId, trackColumns, trackTableSize, }: RowContentProps) => { @@ -622,8 +670,10 @@ const RowContent = memo( return (data?.[index] as Album | undefined) || undefined; }, [data, getItem, index]); + const useClientSideSongs = Boolean(songsByAlbumId); + const songListQuery = useMemo(() => { - if (!item?.id || !item?._serverId) return null; + if (useClientSideSongs || !item?.id || !item?._serverId) return null; return { query: { albumIds: [item.id], @@ -634,7 +684,7 @@ const RowContent = memo( }, serverId: item?._serverId || '', }; - }, [item]); + }, [item, useClientSideSongs]); const { data: songListData, isLoading: isSongsQueryLoading } = useQuery({ enabled: !!songListQuery, @@ -646,8 +696,17 @@ const RowContent = memo( }), }); - const songItems = songListData?.items; - const isSongsLoading = !!item && isSongsQueryLoading && !songItems?.length; + const songItemsFromQuery = songListData?.items; + const songItemsFromClient = useMemo(() => { + const rowSongs = (item as { _playlistSongs?: Song[] })?._playlistSongs; + if (rowSongs?.length) return rowSongs; + if (!songsByAlbumId || !item?.id) return undefined; + return songsByAlbumId[item.id]; + }, [item, songsByAlbumId]); + + const songItems = useClientSideSongs ? songItemsFromClient : songItemsFromQuery; + const isSongsLoading = + !useClientSideSongs && !!item && isSongsQueryLoading && !songItemsFromQuery?.length; const songs = useMemo(() => { return ( @@ -705,6 +764,7 @@ const RowContent = memo( isMutatingFavorite={isMutatingFavorite} isSongsLoading={isSongsLoading} key={song.id} + onSongRowDoubleClick={onSongRowDoubleClick} rowIndex={rowIndex} size={trackTableSize} song={song as Song} @@ -729,6 +789,7 @@ const RowContent = memo( prev.isMutatingFavorite === next.isMutatingFavorite && prev.controls === next.controls && prev.registerSongs === next.registerSongs && + prev.songsByAlbumId === next.songsByAlbumId && prev.trackColumns === next.trackColumns && prev.trackTableSize === next.trackTableSize, ); @@ -1113,10 +1174,14 @@ export const ItemDetailList = ({ getItem, itemCount: externalItemCount, items, + listKey = ItemListKey.ALBUM, onColumnReordered, onColumnResized, onRangeChanged, onScrollEnd, + onSongRowDoubleClick, + overrideControls, + songsByAlbumId, tableId = DEFAULT_DETAIL_TABLE_ID, }: ItemDetailListProps) => { const containerRef = useRef(null); @@ -1127,6 +1192,7 @@ export const ItemDetailList = ({ const controls = useDefaultItemListControls({ onColumnReordered, onColumnResized, + overrides: overrideControls, }); const isMutatingCreateFavorite = useIsMutatingCreateFavorite(); const isMutatingDeleteFavorite = useIsMutatingDeleteFavorite(); @@ -1172,7 +1238,7 @@ export const ItemDetailList = ({ const internalState = useItemListState(getDataFn, extractRowIdSong); - const tableConfig = useSettingsStore((state) => state.lists[ItemListKey.ALBUM]?.detail); + const tableConfig = useSettingsStore((state) => state.lists[listKey]?.detail); const trackColumns = useMemo((): ItemTableListColumnConfig[] => { const raw = tableConfig?.columns; if (raw && raw.length > 0) { @@ -1263,8 +1329,10 @@ export const ItemDetailList = ({ getItem, internalState, isMutatingFavorite, + onSongRowDoubleClick, queryClient, registerSongs, + songsByAlbumId, trackColumns, trackTableSize, }), @@ -1279,8 +1347,10 @@ export const ItemDetailList = ({ getItem, internalState, isMutatingFavorite, + onSongRowDoubleClick, queryClient, registerSongs, + songsByAlbumId, trackColumns, trackTableSize, ], diff --git a/src/renderer/context/list-context.tsx b/src/renderer/context/list-context.tsx index 89ec02867..f07cad07a 100644 --- a/src/renderer/context/list-context.tsx +++ b/src/renderer/context/list-context.tsx @@ -1,16 +1,22 @@ import { createContext, useContext } from 'react'; +import { LibraryItem } from '/@/shared/types/domain-types'; import { ItemListKey } from '/@/shared/types/types'; +export type ListDisplayMode = LibraryItem.ALBUM | LibraryItem.SONG; + interface ListContextProps { customFilters?: Record; + displayMode?: ListDisplayMode; id?: string; isSidebarOpen?: boolean; isSmartPlaylist?: boolean; itemCount?: number; listData?: unknown[]; + listKey?: ItemListKey; mode?: 'edit' | 'view'; pageKey: ItemListKey | string; + setDisplayMode?: (displayMode: ListDisplayMode) => void; setIsSidebarOpen?: (isSidebarOpen: boolean) => void; setItemCount?: (itemCount: number) => void; setListData?: (items: unknown[]) => void; diff --git a/src/renderer/features/playlists/components/playlist-detail-album-view.tsx b/src/renderer/features/playlists/components/playlist-detail-album-view.tsx new file mode 100644 index 000000000..fb247ba77 --- /dev/null +++ b/src/renderer/features/playlists/components/playlist-detail-album-view.tsx @@ -0,0 +1,195 @@ +import { useEffect, useMemo } from 'react'; + +import { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-rows'; +import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder'; +import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize'; +import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist'; +import { ItemDetailList } from '/@/renderer/components/item-list/item-detail-list/item-detail-list'; +import { ItemGridList } from '/@/renderer/components/item-list/item-grid-list/item-grid-list'; +import { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination'; +import { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination'; +import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list'; +import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column'; +import { DefaultItemControlProps, ItemControls } from '/@/renderer/components/item-list/types'; +import { useListContext } from '/@/renderer/context/list-context'; +import { usePlayer } from '/@/renderer/features/player/context/player-context'; +import { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters'; +import { type PlaylistAlbumRow, playlistSongsToAlbums } from '/@/renderer/features/playlists/utils'; +import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter'; +import { searchLibraryItems } from '/@/renderer/features/shared/utils'; +import { useGeneralSettings, useListSettings } from '/@/renderer/store'; +import { sortSongList } from '/@/shared/api/utils'; +import { + LibraryItem, + PlaylistSongListResponse, + SongListSort, + SortOrder, +} from '/@/shared/types/domain-types'; +import { ItemListKey, ListDisplayType, ListPaginationType, Play } from '/@/shared/types/types'; + +export const PlaylistDetailAlbumView = ({ data }: { data: PlaylistSongListResponse }) => { + const player = usePlayer(); + const { setItemCount, setListData } = useListContext(); + const { detail, display, grid, itemsPerPage, pagination, table } = useListSettings( + ItemListKey.PLAYLIST_ALBUM, + ); + const { enableGridMultiSelect } = useGeneralSettings(); + const { currentPage, onChange: onPageChange } = useItemListPagination(); + const { searchTerm } = useSearchTermFilter(); + const { query } = usePlaylistSongListFilters(); + + const sortedAlbums = useMemo(() => { + let songs = data?.items ?? []; + if (searchTerm?.trim()) { + songs = searchLibraryItems(songs, searchTerm, LibraryItem.SONG); + } + const sortedSongs = sortSongList( + songs, + (query.sortBy as SongListSort) ?? SongListSort.ID, + (query.sortOrder as SortOrder) ?? SortOrder.ASC, + ); + return playlistSongsToAlbums(sortedSongs); + }, [data?.items, searchTerm, query.sortBy, query.sortOrder]); + + const isPaginated = pagination === ListPaginationType.PAGINATED; + const totalAlbumCount = sortedAlbums.length; + const albumPageCount = Math.max(1, Math.ceil(totalAlbumCount / itemsPerPage)); + const paginatedAlbums = useMemo(() => { + if (!isPaginated) return sortedAlbums; + const start = currentPage * itemsPerPage; + return sortedAlbums.slice(start, start + itemsPerPage); + }, [isPaginated, currentPage, itemsPerPage, sortedAlbums]); + const albumsToRender = isPaginated ? paginatedAlbums : sortedAlbums; + + const playlistSongs = useMemo(() => data?.items ?? [], [data?.items]); + + const albumControlOverrides = useMemo>(() => { + return { + onPlay: ({ + item, + itemType, + playType, + }: DefaultItemControlProps & { playType: Play }) => { + if (!item) return; + const rowSongs = (item as PlaylistAlbumRow)._playlistSongs; + if (itemType === LibraryItem.ALBUM && rowSongs?.length) { + player.addToQueueByData(rowSongs, playType); + return; + } + player.addToQueueByFetch(item._serverId, [item.id], itemType, playType); + }, + }; + }, [player]); + + useEffect(() => { + setItemCount?.(totalAlbumCount); + }, [setItemCount, totalAlbumCount]); + + useEffect(() => { + setListData?.(data?.items ?? []); + }, [data?.items, setListData]); + + const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({ enabled: true }); + const { handleColumnReordered } = useItemListColumnReorder({ + itemListKey: ItemListKey.PLAYLIST_ALBUM, + }); + const { handleColumnResized } = useItemListColumnResize({ + itemListKey: ItemListKey.PLAYLIST_ALBUM, + }); + const { handleColumnReordered: handleDetailColumnReordered } = useItemListColumnReorder({ + itemListKey: ItemListKey.PLAYLIST_ALBUM, + tableKey: 'detail', + }); + const { handleColumnResized: handleDetailColumnResized } = useItemListColumnResize({ + itemListKey: ItemListKey.PLAYLIST_ALBUM, + tableKey: 'detail', + }); + const rows = useGridRows(LibraryItem.ALBUM, ItemListKey.PLAYLIST_ALBUM, grid.size); + + const renderAlbumList = () => { + switch (display) { + case ListDisplayType.DETAIL: + return ( + { + if (playlistSongs.length === 0) return; + internalState?.setSelected([item]); + player.addToQueueByData(playlistSongs, Play.NOW, item.id); + }} + overrideControls={albumControlOverrides} + scrollOffset={scrollOffset ?? 0} + songsByAlbumId={{}} + tableId="album-detail" + /> + ); + case ListDisplayType.GRID: + return ( + + ); + case ListDisplayType.TABLE: + return ( + + ); + default: + return null; + } + }; + + if (isPaginated) { + return ( + + {renderAlbumList()} + + ); + } + + return renderAlbumList(); +}; diff --git a/src/renderer/features/playlists/components/playlist-detail-song-list-content.tsx b/src/renderer/features/playlists/components/playlist-detail-song-list-content.tsx index b0910b311..2b95ed4d5 100644 --- a/src/renderer/features/playlists/components/playlist-detail-song-list-content.tsx +++ b/src/renderer/features/playlists/components/playlist-detail-song-list-content.tsx @@ -2,14 +2,27 @@ import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query'; import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react'; import { useParams } from 'react-router'; +import { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination'; import { ItemListHandle } from '/@/renderer/components/item-list/types'; import { useListContext } from '/@/renderer/context/list-context'; import { eventEmitter } from '/@/renderer/events/event-emitter'; import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api'; +import { PlaylistDetailAlbumView } from '/@/renderer/features/playlists/components/playlist-detail-album-view'; +import { usePlaylistTrackList } from '/@/renderer/features/playlists/hooks/use-playlist-track-list'; import { useCurrentServer, useListSettings } from '/@/renderer/store'; import { Spinner } from '/@/shared/components/spinner/spinner'; -import { PlaylistSongListQuery, PlaylistSongListResponse } from '/@/shared/types/domain-types'; -import { ItemListKey, ListDisplayType, TableColumn } from '/@/shared/types/types'; +import { + LibraryItem, + PlaylistSongListQuery, + PlaylistSongListResponse, + Song, +} from '/@/shared/types/domain-types'; +import { + ItemListKey, + ListDisplayType, + ListPaginationType, + TableColumn, +} from '/@/shared/types/types'; const PlaylistDetailSongListTable = lazy(() => import('/@/renderer/features/playlists/components/playlist-detail-song-list-table').then( @@ -38,7 +51,6 @@ const PlaylistDetailSongListGrid = lazy(() => export const PlaylistDetailSongListContent = () => { const { playlistId } = useParams() as { playlistId: string }; const server = useCurrentServer(); - const { setItemCount } = useListContext(); const queryClient = useQueryClient(); const playlistSongsQuery = useSuspenseQuery( @@ -50,18 +62,12 @@ export const PlaylistDetailSongListContent = () => { }), ); - useEffect(() => { - if ( - playlistSongsQuery.data?.totalRecordCount !== undefined && - playlistSongsQuery.data.totalRecordCount !== null - ) { - setItemCount?.(playlistSongsQuery.data.totalRecordCount); - } - }, [playlistSongsQuery.data?.totalRecordCount, setItemCount]); - useEffect(() => { const handleRefresh = async (payload: { key: string }) => { - if (payload.key !== ItemListKey.PLAYLIST_SONG) { + if ( + payload.key !== ItemListKey.PLAYLIST_SONG && + payload.key !== ItemListKey.PLAYLIST_ALBUM + ) { return; } @@ -81,7 +87,7 @@ export const PlaylistDetailSongListContent = () => { return () => { eventEmitter.off('ITEM_LIST_REFRESH', handleRefresh); }; - }, [playlistId, queryClient, server.id]); + }, [playlistId, queryClient, server?.id]); return ( }> @@ -92,13 +98,36 @@ export const PlaylistDetailSongListContent = () => { export type OverridePlaylistSongListQuery = Omit, 'id'>; -export const PlaylistDetailSongListView = ({ data }: { data: PlaylistSongListResponse }) => { +interface PlaylistDetailSongListViewProps { + data: PlaylistSongListResponse; + /** When provided, table/grid use this instead of computing from data (avoids duplicate filter/sort). */ + items?: Song[]; +} + +export const PlaylistDetailSongListView = ({ data, items }: PlaylistDetailSongListViewProps) => { const server = useCurrentServer(); - const { display, table } = useListSettings(ItemListKey.PLAYLIST_SONG); + const { display, itemsPerPage, pagination, table } = useListSettings(ItemListKey.PLAYLIST_SONG); + const { currentPage, onChange: onPageChange } = useItemListPagination(); + const isPaginated = pagination === ListPaginationType.PAGINATED; + + const paginationProps = isPaginated + ? { + currentPage, + itemsPerPage, + onPageChange, + } + : undefined; switch (display) { case ListDisplayType.GRID: { - return ; + return ( + + ); } case ListDisplayType.TABLE: { return ( @@ -111,8 +140,10 @@ export const PlaylistDetailSongListView = ({ data }: { data: PlaylistSongListRes enableHorizontalBorders={table.enableHorizontalBorders} enableRowHoverHighlight={table.enableRowHoverHighlight} enableVerticalBorders={table.enableVerticalBorders} + items={items} serverId={server.id} size={table.size} + {...paginationProps} /> ); } @@ -252,19 +283,33 @@ export const PlaylistDetailSongListEdit = ({ data }: { data: PlaylistSongListRes } }; -const PlaylistDetailSongList = ({ data }: { data: PlaylistSongListResponse }) => { +/** Track view: view mode uses centralized list derivation; edit mode uses local reorder state. */ +const PlaylistDetailTrackView = ({ data }: { data: PlaylistSongListResponse }) => { const { isSmartPlaylist, mode } = useListContext(); if (isSmartPlaylist) { - return ; + return ; } - switch (mode) { - case 'edit': - return ; - case 'view': - return ; - default: - return null; + if (mode === 'edit') { + return ; } + + return ; +}; + +/** Uses usePlaylistTrackList once and passes derived items to the list view. */ +const PlaylistDetailTrackViewContent = ({ data }: { data: PlaylistSongListResponse }) => { + const { sortedAndFilteredSongs } = usePlaylistTrackList(data); + return ; +}; + +const PlaylistDetailSongList = ({ data }: { data: PlaylistSongListResponse }) => { + const { displayMode } = useListContext(); + + if (displayMode === LibraryItem.ALBUM) { + return ; + } + + return ; }; diff --git a/src/renderer/features/playlists/components/playlist-detail-song-list-grid.tsx b/src/renderer/features/playlists/components/playlist-detail-song-list-grid.tsx index e4c5b919c..2500df74b 100644 --- a/src/renderer/features/playlists/components/playlist-detail-song-list-grid.tsx +++ b/src/renderer/features/playlists/components/playlist-detail-song-list-grid.tsx @@ -4,6 +4,7 @@ import { useEffect } from 'react'; import { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-rows'; import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist'; import { ItemGridList } from '/@/renderer/components/item-list/item-grid-list/item-grid-list'; +import { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination'; import { ItemListGridComponentProps } from '/@/renderer/components/item-list/types'; import { useListContext } from '/@/renderer/context/list-context'; import { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters'; @@ -15,40 +16,52 @@ import { LibraryItem, PlaylistSongListQuery, PlaylistSongListResponse, + Song, } from '/@/shared/types/domain-types'; import { ItemListKey } from '/@/shared/types/types'; interface PlaylistDetailSongListGridProps extends Omit, 'query'> { + currentPage?: number; data: PlaylistSongListResponse; + items?: Song[]; + itemsPerPage?: number; + onPageChange?: (page: number) => void; } export const PlaylistDetailSongListGrid = forwardRef( - ({ data, saveScrollOffset = true }) => { + ({ + currentPage, + data, + items: itemsProp, + itemsPerPage, + onPageChange, + saveScrollOffset = true, + }) => { const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({ enabled: saveScrollOffset, }); const { searchTerm } = useSearchTermFilter(); const { query } = usePlaylistSongListFilters(); - const { setListData } = useListContext(); - - const songData = useMemo(() => { - let items = data?.items || []; + const songDataFromData = useMemo(() => { + let list = data?.items || []; if (searchTerm) { - items = searchLibraryItems(items, searchTerm, LibraryItem.SONG); - return items; + list = searchLibraryItems(list, searchTerm, LibraryItem.SONG); + return list; } - - return sortSongList(items, query.sortBy, query.sortOrder); + return sortSongList(list, query.sortBy, query.sortOrder); }, [data?.items, searchTerm, query.sortBy, query.sortOrder]); + const { setListData } = useListContext(); + const songData = itemsProp ?? songDataFromData; + useEffect(() => { - if (setListData) { - setListData(songData); + if (itemsProp == null && setListData) { + setListData(songDataFromData); } - }, [songData, setListData]); + }, [itemsProp, songDataFromData, setListData]); const gridProps = useListSettings(ItemListKey.PLAYLIST_SONG).grid; @@ -59,9 +72,22 @@ export const PlaylistDetailSongListGrid = forwardRef { + if (!isPaginated || currentPage == null || itemsPerPage == null) return songData; + const start = currentPage * itemsPerPage; + return songData.slice(start, start + itemsPerPage); + }, [currentPage, isPaginated, itemsPerPage, songData]); + const dataToRender = isPaginated ? paginatedData : songData; + + const grid = ( ); + + if (isPaginated && itemsPerPage != null) { + return ( + + {grid} + + ); + } + + return grid; }, ); diff --git a/src/renderer/features/playlists/components/playlist-detail-song-list-header-filters.tsx b/src/renderer/features/playlists/components/playlist-detail-song-list-header-filters.tsx index 23303aa02..ea00bba78 100644 --- a/src/renderer/features/playlists/components/playlist-detail-song-list-header-filters.tsx +++ b/src/renderer/features/playlists/components/playlist-detail-song-list-header-filters.tsx @@ -5,19 +5,27 @@ import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router'; import i18n from '/@/i18n/i18n'; -import { PLAYLIST_SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns'; +import { + ALBUM_TABLE_COLUMNS, + PLAYLIST_SONG_TABLE_COLUMNS, + SONG_TABLE_COLUMNS, +} from '/@/renderer/components/item-list/item-table-list/default-columns'; import { useListContext } from '/@/renderer/context/list-context'; import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller'; import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api'; import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu'; import { ListDisplayTypeToggleButton } from '/@/renderer/features/shared/components/list-display-type-toggle-button'; import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button'; -import { ListSearchInput } from '/@/renderer/features/shared/components/list-search-input'; import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown'; import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button'; import { MoreButton } from '/@/renderer/features/shared/components/more-button'; import { useContainerQuery } from '/@/renderer/hooks'; -import { useCurrentServerId } from '/@/renderer/store'; +import { + PlaylistTarget, + useCurrentServerId, + usePlaylistTarget, + useSettingsStoreActions, +} from '/@/renderer/store'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { Button } from '/@/shared/components/button/button'; import { Divider } from '/@/shared/components/divider/divider'; @@ -37,8 +45,10 @@ export const PlaylistDetailSongListHeaderFilters = ({ isSmartPlaylist, }: PlaylistDetailSongListHeaderFiltersProps) => { const { t } = useTranslation(); - const { mode, setMode } = useListContext(); + const { listKey: listKeyFromContext, mode, setMode } = useListContext(); const { playlistId } = useParams() as { playlistId: string }; + const playlistTarget = usePlaylistTarget(); + const { setPlaylistBehavior } = useSettingsStoreActions(); const serverId = useCurrentServerId(); const detailQuery = useQuery(playlistsQueries.detail({ query: { id: playlistId }, serverId })); @@ -55,9 +65,25 @@ export const PlaylistDetailSongListHeaderFilters = ({ }); }; + const listKey = + listKeyFromContext ?? + (playlistTarget === PlaylistTarget.ALBUM + ? ItemListKey.PLAYLIST_ALBUM + : ItemListKey.PLAYLIST_SONG); + const isAlbumMode = listKey === ItemListKey.PLAYLIST_ALBUM; + const toggleChoice = isAlbumMode + ? t('entity.album', { count: 2, postProcess: 'titleCase' }) + : t('entity.track', { count: 2, postProcess: 'titleCase' }); + + const handleToggleDisplayMode = useCallback(() => { + setPlaylistBehavior( + playlistTarget === PlaylistTarget.ALBUM ? PlaylistTarget.TRACK : PlaylistTarget.ALBUM, + ); + }, [playlistTarget, setPlaylistBehavior]); + const { ref: containerRef, ...breakpoints } = useContainerQuery(); - const isViewEditMode = !isSmartPlaylist && breakpoints.isSm; + const isViewEditMode = !isSmartPlaylist && (breakpoints.isSm || isAlbumMode); const isEditMode = mode === 'edit'; const [collapsed, setCollapsed] = useLocalStorage({ @@ -68,6 +94,14 @@ export const PlaylistDetailSongListHeaderFilters = ({ return ( + + - {!collapsed && } - + @@ -109,11 +142,25 @@ export const PlaylistDetailSongListHeaderFilters = ({ variant="subtle" /> - - + + {isAlbumMode ? ( + + ) : ( + + )} ); diff --git a/src/renderer/features/playlists/components/playlist-detail-song-list-header.tsx b/src/renderer/features/playlists/components/playlist-detail-song-list-header.tsx index 43e71adaa..5f539cf60 100644 --- a/src/renderer/features/playlists/components/playlist-detail-song-list-header.tsx +++ b/src/renderer/features/playlists/components/playlist-detail-song-list-header.tsx @@ -93,6 +93,7 @@ export const PlaylistDetailSongListHeader = ({ ) : ( } > handlePlay(type)} diff --git a/src/renderer/features/playlists/components/playlist-detail-song-list-table.tsx b/src/renderer/features/playlists/components/playlist-detail-song-list-table.tsx index 8cf431454..14989fe15 100644 --- a/src/renderer/features/playlists/components/playlist-detail-song-list-table.tsx +++ b/src/renderer/features/playlists/components/playlist-detail-song-list-table.tsx @@ -4,6 +4,7 @@ import { useEffect } from 'react'; import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder'; import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize'; import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist'; +import { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination'; import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list'; import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column'; import { ItemControls, ItemListTableComponentProps } from '/@/renderer/components/item-list/types'; @@ -24,7 +25,11 @@ import { ItemListKey, Play } from '/@/shared/types/types'; interface PlaylistDetailSongListTableProps extends Omit, 'query'> { + currentPage?: number; data: PlaylistSongListResponse; + items?: Song[]; + itemsPerPage?: number; + onPageChange?: (page: number) => void; } export const PlaylistDetailSongListTable = forwardRef( @@ -32,6 +37,7 @@ export const PlaylistDetailSongListTable = forwardRef { - let items = data?.items || []; + const songDataFromData = useMemo(() => { + let list = data?.items || []; if (searchTerm) { - items = searchLibraryItems(items, searchTerm, LibraryItem.SONG); - return items; + list = searchLibraryItems(list, searchTerm, LibraryItem.SONG); + return list; } - - return sortSongList(items, query.sortBy, query.sortOrder); + return sortSongList(list, query.sortBy, query.sortOrder); }, [data?.items, searchTerm, query.sortBy, query.sortOrder]); + const { setListData } = useListContext(); + const songData = itemsProp ?? songDataFromData; + useEffect(() => { - if (setListData) { - setListData(songData); + if (itemsProp == null && setListData) { + setListData(songDataFromData); } - }, [songData, setListData]); + }, [itemsProp, songDataFromData, setListData]); const player = usePlayer(); @@ -108,13 +117,26 @@ export const PlaylistDetailSongListTable = forwardRef { + if (!isPaginated || currentPage == null || itemsPerPage == null) return songData; + const start = currentPage * itemsPerPage; + return songData.slice(start, start + itemsPerPage); + }, [isPaginated, currentPage, itemsPerPage, songData]); + const dataToRender = isPaginated ? paginatedData : songData; + + const table = ( ); + + if (isPaginated && itemsPerPage != null) { + return ( + + {table} + + ); + } + + return table; }, ); diff --git a/src/renderer/features/playlists/hooks/use-playlist-track-list.ts b/src/renderer/features/playlists/hooks/use-playlist-track-list.ts new file mode 100644 index 000000000..c5f961cd4 --- /dev/null +++ b/src/renderer/features/playlists/hooks/use-playlist-track-list.ts @@ -0,0 +1,36 @@ +import { useEffect, useMemo } from 'react'; + +import { useListContext } from '/@/renderer/context/list-context'; +import { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters'; +import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter'; +import { searchLibraryItems } from '/@/renderer/features/shared/utils'; +import { sortSongList } from '/@/shared/api/utils'; +import { LibraryItem, PlaylistSongListResponse, Song } from '/@/shared/types/domain-types'; + +export function usePlaylistTrackList(data: PlaylistSongListResponse | undefined): { + sortedAndFilteredSongs: Song[]; + totalCount: number; +} { + const { setItemCount, setListData } = useListContext(); + const { searchTerm } = useSearchTermFilter(); + const { query } = usePlaylistSongListFilters(); + + const sortedAndFilteredSongs = useMemo(() => { + const raw = data?.items ?? []; + + if (searchTerm) { + return searchLibraryItems(raw, searchTerm, LibraryItem.SONG); + } + + return sortSongList(raw, query.sortBy, query.sortOrder); + }, [data?.items, searchTerm, query.sortBy, query.sortOrder]); + + const totalCount = sortedAndFilteredSongs.length; + + useEffect(() => { + setListData?.(sortedAndFilteredSongs); + setItemCount?.(totalCount); + }, [sortedAndFilteredSongs, totalCount, setListData, setItemCount]); + + return { sortedAndFilteredSongs, totalCount }; +} diff --git a/src/renderer/features/playlists/routes/playlist-detail-song-list-route.tsx b/src/renderer/features/playlists/routes/playlist-detail-song-list-route.tsx index ae29d349c..fbf839785 100644 --- a/src/renderer/features/playlists/routes/playlist-detail-song-list-route.tsx +++ b/src/renderer/features/playlists/routes/playlist-detail-song-list-route.tsx @@ -20,7 +20,7 @@ import { AnimatedPage } from '/@/renderer/features/shared/components/animated-pa import { JsonPreview } from '/@/renderer/features/shared/components/json-preview'; import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary'; import { AppRoute } from '/@/renderer/router/routes'; -import { useCurrentServer } from '/@/renderer/store'; +import { PlaylistTarget, useCurrentServer, usePlaylistTarget } from '/@/renderer/store'; import { Button } from '/@/shared/components/button/button'; import { Group } from '/@/shared/components/group/group'; import { Icon } from '/@/shared/components/icon/icon'; @@ -29,7 +29,7 @@ import { Spinner } from '/@/shared/components/spinner/spinner'; import { Stack } from '/@/shared/components/stack/stack'; import { Text } from '/@/shared/components/text/text'; import { toast } from '/@/shared/components/toast/toast'; -import { ServerType, SongListSort } from '/@/shared/types/domain-types'; +import { LibraryItem, ServerType, SongListSort } from '/@/shared/types/domain-types'; import { ItemListKey } from '/@/shared/types/types'; interface PlaylistQueryEditorProps { @@ -154,14 +154,17 @@ const PlaylistQueryEditor = ({ }, [detailQuery?.data?.rules?.order, detailQuery?.data?.rules?.sort]); return ( -
- - +
+ +