maintain song order in album view

This commit is contained in:
jeffvli
2026-02-11 20:00:46 -08:00
parent 7f5742119b
commit 0a4d789f08
4 changed files with 278 additions and 286 deletions
@@ -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<Partial<ItemControls>>(() => {
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 (
<ItemDetailList
enableHeader={detail?.enableHeader}
items={albumsToRender}
listKey={ItemListKey.PLAYLIST_ALBUM}
onColumnReordered={handleDetailColumnReordered}
onColumnResized={handleDetailColumnResized}
onScrollEnd={handleOnScrollEnd}
onSongRowDoubleClick={({ internalState, item }) => {
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 (
<ItemGridList
data={albumsToRender}
enableExpansion
enableMultiSelect={enableGridMultiSelect}
gap={grid.itemGap}
initialTop={{
to: scrollOffset ?? 0,
type: 'offset',
}}
itemsPerRow={grid.itemsPerRowEnabled ? grid.itemsPerRow : undefined}
itemType={LibraryItem.ALBUM}
onScrollEnd={handleOnScrollEnd}
overrideControls={albumControlOverrides}
rows={rows}
size={grid.size}
/>
);
case ListDisplayType.TABLE:
return (
<ItemTableList
autoFitColumns={table.autoFitColumns}
CellComponent={ItemTableListColumn}
columns={table.columns}
data={albumsToRender}
enableAlternateRowColors={table.enableAlternateRowColors}
enableHeader={table.enableHeader}
enableHorizontalBorders={table.enableHorizontalBorders}
enableRowHoverHighlight={table.enableRowHoverHighlight}
enableSelection
enableVerticalBorders={table.enableVerticalBorders}
initialTop={{
to: scrollOffset ?? 0,
type: 'offset',
}}
itemType={LibraryItem.ALBUM}
onColumnReordered={handleColumnReordered}
onColumnResized={handleColumnResized}
onScrollEnd={handleOnScrollEnd}
overrideControls={albumControlOverrides}
size={table.size}
/>
);
default:
return null;
}
};
if (isPaginated) {
return (
<ItemListWithPagination
currentPage={currentPage}
itemsPerPage={itemsPerPage}
onChange={onPageChange}
pageCount={albumPageCount}
totalItemCount={totalAlbumCount}
>
{renderAlbumList()}
</ItemListWithPagination>
);
}
return renderAlbumList();
};
@@ -2,45 +2,25 @@ import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query';
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router';
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,
ItemListHandle,
} from '/@/renderer/components/item-list/types';
import { ItemListHandle } from '/@/renderer/components/item-list/types';
import { useListContext } from '/@/renderer/context/list-context';
import { eventEmitter } from '/@/renderer/events/event-emitter';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
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 { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-filter';
import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter';
import { useCurrentServer, useGeneralSettings, useListSettings } from '/@/renderer/store';
import { sortAlbumList } from '/@/shared/api/utils';
import { useCurrentServer, useListSettings } from '/@/renderer/store';
import { Spinner } from '/@/shared/components/spinner/spinner';
import {
Album,
AlbumListSort,
LibraryItem,
PlaylistSongListQuery,
PlaylistSongListResponse,
Song,
SortOrder,
} from '/@/shared/types/domain-types';
import {
ItemListKey,
ListDisplayType,
ListPaginationType,
Play,
TableColumn,
} from '/@/shared/types/types';
@@ -303,237 +283,6 @@ export const PlaylistDetailSongListEdit = ({ data }: { data: PlaylistSongListRes
}
};
export type PlaylistAlbumRow = Album & { _playlistSongs?: Song[] };
export function playlistSongsToAlbums(songs: Song[]): PlaylistAlbumRow[] {
if (songs.length === 0) return [];
const rows: PlaylistAlbumRow[] = [];
let group: Song[] = [songs[0]];
let prevAlbumId = songs[0].albumId;
const pushRow = (song: Song, groupSongs: Song[]) => {
rows.push({
_itemType: LibraryItem.ALBUM,
_playlistSongs: groupSongs,
_serverId: song._serverId,
_serverType: song._serverType,
albumArtistName: song.albumArtistName,
albumArtists: song.albumArtists,
artists: song.artists,
comment: song.comment,
createdAt: song.createdAt,
duration: null,
explicitStatus: song.explicitStatus,
genres: song.genres,
id: song.albumId,
imageId: song.imageId,
imageUrl: song.imageUrl,
isCompilation: song.compilation,
lastPlayedAt: song.lastPlayedAt,
mbzId: null,
mbzReleaseGroupId: null,
name: song.album ?? '',
originalDate: null,
originalYear: null,
participants: song.participants,
playCount: null,
recordLabels: [],
releaseDate: song.releaseDate,
releaseType: null,
releaseTypes: [],
releaseYear: song.releaseYear,
size: null,
songCount: null,
sortName: song.album ?? '',
tags: song.tags,
updatedAt: song.updatedAt,
userFavorite: false,
userRating: null,
version: null,
});
};
for (let i = 1; i < songs.length; i++) {
const song = songs[i];
if (song.albumId === prevAlbumId) {
group.push(song);
} else {
pushRow(group[0], group);
group = [song];
prevAlbumId = song.albumId;
}
}
pushRow(group[0], group);
return rows;
}
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 { sortBy } = useSortByFilter<AlbumListSort>(AlbumListSort.ID, ItemListKey.PLAYLIST_ALBUM);
const { sortOrder } = useSortOrderFilter(SortOrder.ASC, ItemListKey.PLAYLIST_ALBUM);
const albums = useMemo(() => playlistSongsToAlbums(data?.items ?? []), [data?.items]);
const sortedAlbums = useMemo(
() =>
sortAlbumList(
albums,
(sortBy as AlbumListSort) ?? AlbumListSort.ID,
sortOrder ?? SortOrder.ASC,
),
[albums, sortBy, 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<Partial<ItemControls>>(() => {
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 (
<ItemDetailList
enableHeader={detail?.enableHeader}
items={albumsToRender}
listKey={ItemListKey.PLAYLIST_ALBUM}
onColumnReordered={handleDetailColumnReordered}
onColumnResized={handleDetailColumnResized}
onScrollEnd={handleOnScrollEnd}
onSongRowDoubleClick={({ internalState, item }) => {
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 (
<ItemGridList
data={albumsToRender}
enableExpansion
enableMultiSelect={enableGridMultiSelect}
gap={grid.itemGap}
initialTop={{
to: scrollOffset ?? 0,
type: 'offset',
}}
itemsPerRow={grid.itemsPerRowEnabled ? grid.itemsPerRow : undefined}
itemType={LibraryItem.ALBUM}
onScrollEnd={handleOnScrollEnd}
overrideControls={albumControlOverrides}
rows={rows}
size={grid.size}
/>
);
case ListDisplayType.TABLE:
return (
<ItemTableList
autoFitColumns={table.autoFitColumns}
CellComponent={ItemTableListColumn}
columns={table.columns}
data={albumsToRender}
enableAlternateRowColors={table.enableAlternateRowColors}
enableHeader={table.enableHeader}
enableHorizontalBorders={table.enableHorizontalBorders}
enableRowHoverHighlight={table.enableRowHoverHighlight}
enableSelection
enableVerticalBorders={table.enableVerticalBorders}
initialTop={{
to: scrollOffset ?? 0,
type: 'offset',
}}
itemType={LibraryItem.ALBUM}
onColumnReordered={handleColumnReordered}
onColumnResized={handleColumnResized}
onScrollEnd={handleOnScrollEnd}
overrideControls={albumControlOverrides}
size={table.size}
/>
);
default:
return null;
}
};
if (isPaginated) {
return (
<ItemListWithPagination
currentPage={currentPage}
itemsPerPage={itemsPerPage}
onChange={onPageChange}
pageCount={albumPageCount}
totalItemCount={totalAlbumCount}
>
{renderAlbumList()}
</ItemListWithPagination>
);
}
return renderAlbumList();
};
/** Track view: view mode uses centralized list derivation; edit mode uses local reorder state. */
const PlaylistDetailTrackView = ({ data }: { data: PlaylistSongListResponse }) => {
const { isSmartPlaylist, mode } = useListContext();
@@ -35,7 +35,7 @@ import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
import { AlbumListSort, LibraryItem, SongListSort, SortOrder } from '/@/shared/types/domain-types';
import { LibraryItem, SongListSort, SortOrder } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
interface PlaylistDetailSongListHeaderFiltersProps {
@@ -103,37 +103,18 @@ export const PlaylistDetailSongListHeaderFilters = ({
{toggleChoice}
</Button>
<Divider orientation="vertical" />
{isAlbumMode ? (
<>
<ListSortByDropdown
defaultSortByValue={AlbumListSort.ID}
disabled={isEditMode}
itemType={LibraryItem.ALBUM}
listKey={listKey}
/>
<Divider orientation="vertical" />
<ListSortOrderToggleButton
defaultSortOrder={SortOrder.ASC}
disabled={isEditMode}
listKey={listKey}
/>
</>
) : (
<>
<ListSortByDropdown
defaultSortByValue={SongListSort.ID}
disabled={isEditMode}
itemType={LibraryItem.PLAYLIST_SONG}
listKey={listKey}
/>
<Divider orientation="vertical" />
<ListSortOrderToggleButton
defaultSortOrder={SortOrder.ASC}
disabled={isEditMode}
listKey={listKey}
/>
</>
)}
<ListSortByDropdown
defaultSortByValue={SongListSort.ID}
disabled={isEditMode}
itemType={LibraryItem.PLAYLIST_SONG}
listKey={ItemListKey.PLAYLIST_SONG}
/>
<Divider orientation="vertical" />
<ListSortOrderToggleButton
defaultSortOrder={SortOrder.ASC}
disabled={isEditMode}
listKey={ItemListKey.PLAYLIST_SONG}
/>
{!collapsed && <ListSearchInput />}
<ListRefreshButton disabled={isEditMode} listKey={listKey} />
<MoreButton onClick={handleMore} />
+67
View File
@@ -1,8 +1,75 @@
import { nanoid } from 'nanoid/non-secure';
import { NDSongQueryFields } from '/@/shared/api/navidrome/navidrome-types';
import { Album, LibraryItem, Song } from '/@/shared/types/domain-types';
import { QueryBuilderGroup } from '/@/shared/types/types';
export type PlaylistAlbumRow = Album & { _playlistSongs?: Song[] };
export function playlistSongsToAlbums(songs: Song[]): PlaylistAlbumRow[] {
if (songs.length === 0) return [];
const rows: PlaylistAlbumRow[] = [];
let group: Song[] = [songs[0]];
let prevAlbumId = songs[0].albumId;
const pushRow = (song: Song, groupSongs: Song[]) => {
rows.push({
_itemType: LibraryItem.ALBUM,
_playlistSongs: groupSongs,
_serverId: song._serverId,
_serverType: song._serverType,
albumArtistName: song.albumArtistName,
albumArtists: song.albumArtists,
artists: song.artists,
comment: song.comment,
createdAt: song.createdAt,
duration: null,
explicitStatus: song.explicitStatus,
genres: song.genres,
id: song.albumId,
imageId: song.imageId,
imageUrl: song.imageUrl,
isCompilation: song.compilation,
lastPlayedAt: song.lastPlayedAt,
mbzId: null,
mbzReleaseGroupId: null,
name: song.album ?? '',
originalDate: null,
originalYear: null,
participants: song.participants,
playCount: null,
recordLabels: [],
releaseDate: song.releaseDate,
releaseType: null,
releaseTypes: [],
releaseYear: song.releaseYear,
size: null,
songCount: null,
sortName: song.album ?? '',
tags: song.tags,
updatedAt: song.updatedAt,
userFavorite: false,
userRating: null,
version: null,
});
};
for (let i = 1; i < songs.length; i++) {
const song = songs[i];
if (song.albumId === prevAlbumId) {
group.push(song);
} else {
pushRow(group[0], group);
group = [song];
prevAlbumId = song.albumId;
}
}
pushRow(group[0], group);
return rows;
}
export const parseQueryBuilderChildren = (groups: QueryBuilderGroup[], data: any[]) => {
if (groups.length === 0) {
return data;