Add playlist list
This commit is contained in:
parent
00a21269dd
commit
ec79d91d30
21 changed files with 911 additions and 47 deletions
|
@ -199,6 +199,10 @@ const getArtistList = async (args: ArtistListArgs) => {
|
||||||
return (apiController('getArtistList') as ControllerEndpoint['getArtistList'])?.(args);
|
return (apiController('getArtistList') as ControllerEndpoint['getArtistList'])?.(args);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getPlaylistList = async (args: PlaylistListArgs) => {
|
||||||
|
return (apiController('getPlaylistList') as ControllerEndpoint['getPlaylistList'])?.(args);
|
||||||
|
};
|
||||||
|
|
||||||
export const controller = {
|
export const controller = {
|
||||||
getAlbumArtistList,
|
getAlbumArtistList,
|
||||||
getAlbumDetail,
|
getAlbumDetail,
|
||||||
|
@ -206,5 +210,6 @@ export const controller = {
|
||||||
getArtistList,
|
getArtistList,
|
||||||
getGenreList,
|
getGenreList,
|
||||||
getMusicFolderList,
|
getMusicFolderList,
|
||||||
|
getPlaylistList,
|
||||||
getSongList,
|
getSongList,
|
||||||
};
|
};
|
||||||
|
|
|
@ -22,6 +22,7 @@ import type {
|
||||||
JFGenreListResponse,
|
JFGenreListResponse,
|
||||||
JFMusicFolderList,
|
JFMusicFolderList,
|
||||||
JFMusicFolderListResponse,
|
JFMusicFolderListResponse,
|
||||||
|
JFPlaylist,
|
||||||
JFPlaylistDetail,
|
JFPlaylistDetail,
|
||||||
JFPlaylistDetailResponse,
|
JFPlaylistDetailResponse,
|
||||||
JFPlaylistList,
|
JFPlaylistList,
|
||||||
|
@ -32,7 +33,7 @@ import type {
|
||||||
JFSongListResponse,
|
JFSongListResponse,
|
||||||
} from '/@/renderer/api/jellyfin.types';
|
} from '/@/renderer/api/jellyfin.types';
|
||||||
import { JFCollectionType } from '/@/renderer/api/jellyfin.types';
|
import { JFCollectionType } from '/@/renderer/api/jellyfin.types';
|
||||||
import type {
|
import {
|
||||||
Album,
|
Album,
|
||||||
AlbumArtist,
|
AlbumArtist,
|
||||||
AlbumArtistDetailArgs,
|
AlbumArtistDetailArgs,
|
||||||
|
@ -48,13 +49,13 @@ import type {
|
||||||
FavoriteResponse,
|
FavoriteResponse,
|
||||||
GenreListArgs,
|
GenreListArgs,
|
||||||
MusicFolderListArgs,
|
MusicFolderListArgs,
|
||||||
|
Playlist,
|
||||||
PlaylistDetailArgs,
|
PlaylistDetailArgs,
|
||||||
PlaylistListArgs,
|
PlaylistListArgs,
|
||||||
|
playlistListSortMap,
|
||||||
PlaylistSongListArgs,
|
PlaylistSongListArgs,
|
||||||
Song,
|
Song,
|
||||||
SongListArgs,
|
SongListArgs,
|
||||||
} from '/@/renderer/api/types';
|
|
||||||
import {
|
|
||||||
songListSortMap,
|
songListSortMap,
|
||||||
albumListSortMap,
|
albumListSortMap,
|
||||||
artistListSortMap,
|
artistListSortMap,
|
||||||
|
@ -396,18 +397,20 @@ const getPlaylistSongList = async (args: PlaylistSongListArgs): Promise<JFSongLi
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPlaylistList = async (args: PlaylistListArgs): Promise<JFPlaylistList> => {
|
const getPlaylistList = async (args: PlaylistListArgs): Promise<JFPlaylistList> => {
|
||||||
const { server, signal } = args;
|
const { query, server, signal } = args;
|
||||||
|
|
||||||
const searchParams = {
|
const searchParams = {
|
||||||
fields: 'ChildCount, Genres, DateCreated, ParentId, Overview',
|
fields: 'ChildCount, Genres, DateCreated, ParentId, Overview',
|
||||||
includeItemTypes: 'Playlist',
|
includeItemTypes: 'Playlist',
|
||||||
|
limit: query.limit,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
sortBy: 'SortName',
|
sortBy: playlistListSortMap.jellyfin[query.sortBy],
|
||||||
sortOrder: 'Ascending',
|
sortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
||||||
|
startIndex: query.startIndex,
|
||||||
};
|
};
|
||||||
|
|
||||||
const data = await api
|
const data = await api
|
||||||
.get(`/users/${server?.userId}/items`, {
|
.get(`users/${server?.userId}/items`, {
|
||||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
headers: { 'X-MediaBrowser-Token': server?.credential },
|
||||||
prefixUrl: server?.url,
|
prefixUrl: server?.url,
|
||||||
searchParams: parseSearchParams(searchParams),
|
searchParams: parseSearchParams(searchParams),
|
||||||
|
@ -415,12 +418,12 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise<JFPlaylistList>
|
||||||
})
|
})
|
||||||
.json<JFPlaylistListResponse>();
|
.json<JFPlaylistListResponse>();
|
||||||
|
|
||||||
const playlistData = data.Items.filter((item) => item.MediaType === 'Audio');
|
const playlistItems = data.Items.filter((item) => item.MediaType === 'Audio');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
Items: playlistData,
|
items: playlistItems,
|
||||||
StartIndex: 0,
|
startIndex: 0,
|
||||||
TotalRecordCount: playlistData.length,
|
totalRecordCount: playlistItems.length,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -690,6 +693,20 @@ const normalizeAlbumArtist = (
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizePlaylist = (item: JFPlaylist): Playlist => {
|
||||||
|
return {
|
||||||
|
duration: item.RunTimeTicks / 10000000,
|
||||||
|
id: item.Id,
|
||||||
|
name: item.Name,
|
||||||
|
public: null,
|
||||||
|
rules: null,
|
||||||
|
size: null,
|
||||||
|
songCount: item?.ChildCount || null,
|
||||||
|
userId: null,
|
||||||
|
username: null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// const normalizeArtist = (item: any) => {
|
// const normalizeArtist = (item: any) => {
|
||||||
// return {
|
// return {
|
||||||
// album: (item.album || []).map((entry: any) => normalizeAlbum(entry)),
|
// album: (item.album || []).map((entry: any) => normalizeAlbum(entry)),
|
||||||
|
@ -710,24 +727,6 @@ const normalizeAlbumArtist = (
|
||||||
// };
|
// };
|
||||||
// };
|
// };
|
||||||
|
|
||||||
// const normalizePlaylist = (item: any) => {
|
|
||||||
// return {
|
|
||||||
// changed: item.DateLastMediaAdded,
|
|
||||||
// comment: item.Overview,
|
|
||||||
// created: item.DateCreated,
|
|
||||||
// duration: item.RunTimeTicks / 10000000,
|
|
||||||
// genre: item.GenreItems && item.GenreItems.map((entry: any) => normalizeItem(entry)),
|
|
||||||
// id: item.Id,
|
|
||||||
// image: getCoverArtUrl(item, 350),
|
|
||||||
// owner: undefined,
|
|
||||||
// public: undefined,
|
|
||||||
// song: [],
|
|
||||||
// songCount: item.ChildCount,
|
|
||||||
// title: item.Name,
|
|
||||||
// uniqueId: nanoid(),
|
|
||||||
// };
|
|
||||||
// };
|
|
||||||
|
|
||||||
// const normalizeGenre = (item: any) => {
|
// const normalizeGenre = (item: any) => {
|
||||||
// return {
|
// return {
|
||||||
// albumCount: undefined,
|
// albumCount: undefined,
|
||||||
|
@ -780,5 +779,6 @@ export const jellyfinApi = {
|
||||||
export const jfNormalize = {
|
export const jfNormalize = {
|
||||||
album: normalizeAlbum,
|
album: normalizeAlbum,
|
||||||
albumArtist: normalizeAlbumArtist,
|
albumArtist: normalizeAlbumArtist,
|
||||||
|
playlist: normalizePlaylist,
|
||||||
song: normalizeSong,
|
song: normalizeSong,
|
||||||
};
|
};
|
||||||
|
|
|
@ -63,7 +63,19 @@ export interface JFPlaylistListResponse extends JFBasePaginatedResponse {
|
||||||
Items: JFPlaylist[];
|
Items: JFPlaylist[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type JFPlaylistList = JFPlaylistListResponse;
|
export type JFPlaylistList = {
|
||||||
|
items: JFPlaylist[];
|
||||||
|
startIndex: number;
|
||||||
|
totalRecordCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export enum JFPlaylistListSort {
|
||||||
|
ALBUM_ARTIST = 'AlbumArtist,SortName',
|
||||||
|
DURATION = 'Runtime',
|
||||||
|
NAME = 'SortName',
|
||||||
|
RECENTLY_ADDED = 'DateCreated,SortName',
|
||||||
|
SONG_COUNT = 'ChildCount',
|
||||||
|
}
|
||||||
|
|
||||||
export type JFPlaylistDetailResponse = JFPlaylist;
|
export type JFPlaylistDetailResponse = JFPlaylist;
|
||||||
|
|
||||||
|
@ -485,6 +497,7 @@ type JFBaseParams = {
|
||||||
imageTypeLimit?: number;
|
imageTypeLimit?: number;
|
||||||
parentId?: string;
|
parentId?: string;
|
||||||
recursive?: boolean;
|
recursive?: boolean;
|
||||||
|
searchTerm?: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -31,6 +31,7 @@ import type {
|
||||||
NDSongList,
|
NDSongList,
|
||||||
NDSongListResponse,
|
NDSongListResponse,
|
||||||
NDAlbumArtist,
|
NDAlbumArtist,
|
||||||
|
NDPlaylist,
|
||||||
} from '/@/renderer/api/navidrome.types';
|
} from '/@/renderer/api/navidrome.types';
|
||||||
import { NDPlaylistListSort, NDSongListSort, NDSortOrder } from '/@/renderer/api/navidrome.types';
|
import { NDPlaylistListSort, NDSongListSort, NDSortOrder } from '/@/renderer/api/navidrome.types';
|
||||||
import type {
|
import type {
|
||||||
|
@ -51,6 +52,7 @@ import type {
|
||||||
CreatePlaylistResponse,
|
CreatePlaylistResponse,
|
||||||
PlaylistSongListArgs,
|
PlaylistSongListArgs,
|
||||||
AlbumArtist,
|
AlbumArtist,
|
||||||
|
Playlist,
|
||||||
} from '/@/renderer/api/types';
|
} from '/@/renderer/api/types';
|
||||||
import {
|
import {
|
||||||
playlistListSortMap,
|
playlistListSortMap,
|
||||||
|
@ -329,12 +331,13 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise<NDPlaylistList>
|
||||||
_order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : NDSortOrder.ASC,
|
_order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : NDSortOrder.ASC,
|
||||||
_sort: query.sortBy ? playlistListSortMap.navidrome[query.sortBy] : NDPlaylistListSort.NAME,
|
_sort: query.sortBy ? playlistListSortMap.navidrome[query.sortBy] : NDPlaylistListSort.NAME,
|
||||||
_start: query.startIndex,
|
_start: query.startIndex,
|
||||||
|
...query.ndParams,
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await api.get('api/playlist', {
|
const res = await api.get('api/playlist', {
|
||||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||||
prefixUrl: server?.url,
|
prefixUrl: server?.url,
|
||||||
searchParams,
|
searchParams: parseSearchParams(searchParams),
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -521,6 +524,20 @@ const normalizeAlbumArtist = (item: NDAlbumArtist): AlbumArtist => {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizePlaylist = (item: NDPlaylist): Playlist => {
|
||||||
|
return {
|
||||||
|
duration: item.duration,
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
public: item.public,
|
||||||
|
rules: item?.rules || null,
|
||||||
|
size: item.size,
|
||||||
|
songCount: item.songCount,
|
||||||
|
userId: item.ownerId,
|
||||||
|
username: item.ownerName,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const navidromeApi = {
|
export const navidromeApi = {
|
||||||
authenticate,
|
authenticate,
|
||||||
createPlaylist,
|
createPlaylist,
|
||||||
|
@ -540,5 +557,6 @@ export const navidromeApi = {
|
||||||
export const ndNormalize = {
|
export const ndNormalize = {
|
||||||
album: normalizeAlbum,
|
album: normalizeAlbum,
|
||||||
albumArtist: normalizeAlbumArtist,
|
albumArtist: normalizeAlbumArtist,
|
||||||
|
playlist: normalizePlaylist,
|
||||||
song: normalizeSong,
|
song: normalizeSong,
|
||||||
};
|
};
|
||||||
|
|
|
@ -287,7 +287,7 @@ export type NDPlaylist = {
|
||||||
ownerName: string;
|
ownerName: string;
|
||||||
path: string;
|
path: string;
|
||||||
public: boolean;
|
public: boolean;
|
||||||
rules: null;
|
rules: Record<string, any> | null;
|
||||||
size: number;
|
size: number;
|
||||||
songCount: number;
|
songCount: number;
|
||||||
sync: boolean;
|
sync: boolean;
|
||||||
|
@ -309,7 +309,7 @@ export type NDPlaylistListResponse = NDPlaylist[];
|
||||||
export enum NDPlaylistListSort {
|
export enum NDPlaylistListSort {
|
||||||
DURATION = 'duration',
|
DURATION = 'duration',
|
||||||
NAME = 'name',
|
NAME = 'name',
|
||||||
OWNER = 'owner',
|
OWNER = 'ownerName',
|
||||||
PUBLIC = 'public',
|
PUBLIC = 'public',
|
||||||
SONG_COUNT = 'songCount',
|
SONG_COUNT = 'songCount',
|
||||||
UPDATED_AT = 'updatedAt',
|
UPDATED_AT = 'updatedAt',
|
||||||
|
|
|
@ -4,10 +4,17 @@ import type {
|
||||||
JFAlbumArtist,
|
JFAlbumArtist,
|
||||||
JFGenreList,
|
JFGenreList,
|
||||||
JFMusicFolderList,
|
JFMusicFolderList,
|
||||||
|
JFPlaylist,
|
||||||
JFSong,
|
JFSong,
|
||||||
} from '/@/renderer/api/jellyfin.types';
|
} from '/@/renderer/api/jellyfin.types';
|
||||||
import { ndNormalize } from '/@/renderer/api/navidrome.api';
|
import { ndNormalize } from '/@/renderer/api/navidrome.api';
|
||||||
import type { NDAlbum, NDAlbumArtist, NDGenreList, NDSong } from '/@/renderer/api/navidrome.types';
|
import type {
|
||||||
|
NDAlbum,
|
||||||
|
NDAlbumArtist,
|
||||||
|
NDGenreList,
|
||||||
|
NDPlaylist,
|
||||||
|
NDSong,
|
||||||
|
} from '/@/renderer/api/navidrome.types';
|
||||||
import { SSGenreList, SSMusicFolderList } from '/@/renderer/api/subsonic.types';
|
import { SSGenreList, SSMusicFolderList } from '/@/renderer/api/subsonic.types';
|
||||||
import type {
|
import type {
|
||||||
Album,
|
Album,
|
||||||
|
@ -16,6 +23,7 @@ import type {
|
||||||
RawAlbumListResponse,
|
RawAlbumListResponse,
|
||||||
RawGenreListResponse,
|
RawGenreListResponse,
|
||||||
RawMusicFolderListResponse,
|
RawMusicFolderListResponse,
|
||||||
|
RawPlaylistListResponse,
|
||||||
RawSongListResponse,
|
RawSongListResponse,
|
||||||
} from '/@/renderer/api/types';
|
} from '/@/renderer/api/types';
|
||||||
import { ServerListItem } from '/@/renderer/types';
|
import { ServerListItem } from '/@/renderer/types';
|
||||||
|
@ -163,11 +171,32 @@ const albumArtistList = (
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const playlistList = (data: RawPlaylistListResponse | undefined, server: ServerListItem | null) => {
|
||||||
|
let playlists;
|
||||||
|
switch (server?.type) {
|
||||||
|
case 'jellyfin':
|
||||||
|
playlists = data?.items.map((item) => jfNormalize.playlist(item as JFPlaylist));
|
||||||
|
break;
|
||||||
|
case 'navidrome':
|
||||||
|
playlists = data?.items.map((item) => ndNormalize.playlist(item as NDPlaylist));
|
||||||
|
break;
|
||||||
|
case 'subsonic':
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: playlists,
|
||||||
|
startIndex: data?.startIndex,
|
||||||
|
totalRecordCount: data?.totalRecordCount,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const normalize = {
|
export const normalize = {
|
||||||
albumArtistList,
|
albumArtistList,
|
||||||
albumDetail,
|
albumDetail,
|
||||||
albumList,
|
albumList,
|
||||||
genreList,
|
genreList,
|
||||||
musicFolderList,
|
musicFolderList,
|
||||||
|
playlistList,
|
||||||
songList,
|
songList,
|
||||||
};
|
};
|
||||||
|
|
|
@ -4,6 +4,7 @@ import type {
|
||||||
AlbumDetailQuery,
|
AlbumDetailQuery,
|
||||||
AlbumArtistListQuery,
|
AlbumArtistListQuery,
|
||||||
ArtistListQuery,
|
ArtistListQuery,
|
||||||
|
PlaylistListQuery,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export const queryKeys = {
|
export const queryKeys = {
|
||||||
|
@ -34,6 +35,11 @@ export const queryKeys = {
|
||||||
musicFolders: {
|
musicFolders: {
|
||||||
list: (serverId: string) => [serverId, 'musicFolders', 'list'] as const,
|
list: (serverId: string) => [serverId, 'musicFolders', 'list'] as const,
|
||||||
},
|
},
|
||||||
|
playlists: {
|
||||||
|
list: (serverId: string, query?: PlaylistListQuery) =>
|
||||||
|
[serverId, 'playlists', 'list', query] as const,
|
||||||
|
root: (serverId: string) => [serverId, 'playlists'] as const,
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
root: (serverId: string) => [serverId] as const,
|
root: (serverId: string) => [serverId] as const,
|
||||||
},
|
},
|
||||||
|
|
|
@ -14,6 +14,7 @@ import {
|
||||||
JFPlaylistList,
|
JFPlaylistList,
|
||||||
JFPlaylistDetail,
|
JFPlaylistDetail,
|
||||||
JFMusicFolderList,
|
JFMusicFolderList,
|
||||||
|
JFPlaylistListSort,
|
||||||
} from '/@/renderer/api/jellyfin.types';
|
} from '/@/renderer/api/jellyfin.types';
|
||||||
import {
|
import {
|
||||||
NDSortOrder,
|
NDSortOrder,
|
||||||
|
@ -243,14 +244,15 @@ export type MusicFolder = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Playlist = {
|
export type Playlist = {
|
||||||
duration?: number;
|
duration: number | null;
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
public?: boolean;
|
public: boolean | null;
|
||||||
size?: number;
|
rules?: Record<string, any> | null;
|
||||||
songCount?: number;
|
size: number | null;
|
||||||
userId: string;
|
songCount: number | null;
|
||||||
username: string;
|
userId: string | null;
|
||||||
|
username: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GenresResponse = Genre[];
|
export type GenresResponse = Genre[];
|
||||||
|
@ -756,11 +758,21 @@ export type RawPlaylistListResponse = NDPlaylistList | JFPlaylistList | undefine
|
||||||
|
|
||||||
export type PlaylistListResponse = BasePaginatedResponse<Playlist[]>;
|
export type PlaylistListResponse = BasePaginatedResponse<Playlist[]>;
|
||||||
|
|
||||||
export type PlaylistListSort = NDPlaylistListSort;
|
export enum PlaylistListSort {
|
||||||
|
DURATION = 'duration',
|
||||||
|
NAME = 'name',
|
||||||
|
OWNER = 'owner',
|
||||||
|
PUBLIC = 'public',
|
||||||
|
SONG_COUNT = 'songCount',
|
||||||
|
UPDATED_AT = 'updatedAt',
|
||||||
|
}
|
||||||
|
|
||||||
export type PlaylistListQuery = {
|
export type PlaylistListQuery = {
|
||||||
limit?: number;
|
limit?: number;
|
||||||
musicFolderId?: string;
|
ndParams?: {
|
||||||
|
owner_id?: string;
|
||||||
|
};
|
||||||
|
searchTerm?: string;
|
||||||
sortBy: PlaylistListSort;
|
sortBy: PlaylistListSort;
|
||||||
sortOrder: SortOrder;
|
sortOrder: SortOrder;
|
||||||
startIndex: number;
|
startIndex: number;
|
||||||
|
@ -769,18 +781,18 @@ export type PlaylistListQuery = {
|
||||||
export type PlaylistListArgs = { query: PlaylistListQuery } & BaseEndpointArgs;
|
export type PlaylistListArgs = { query: PlaylistListQuery } & BaseEndpointArgs;
|
||||||
|
|
||||||
type PlaylistListSortMap = {
|
type PlaylistListSortMap = {
|
||||||
jellyfin: Record<PlaylistListSort, undefined>;
|
jellyfin: Record<PlaylistListSort, JFPlaylistListSort | undefined>;
|
||||||
navidrome: Record<PlaylistListSort, NDPlaylistListSort | undefined>;
|
navidrome: Record<PlaylistListSort, NDPlaylistListSort | undefined>;
|
||||||
subsonic: Record<PlaylistListSort, undefined>;
|
subsonic: Record<PlaylistListSort, undefined>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const playlistListSortMap: PlaylistListSortMap = {
|
export const playlistListSortMap: PlaylistListSortMap = {
|
||||||
jellyfin: {
|
jellyfin: {
|
||||||
duration: undefined,
|
duration: JFPlaylistListSort.DURATION,
|
||||||
name: undefined,
|
name: JFPlaylistListSort.NAME,
|
||||||
owner: undefined,
|
owner: undefined,
|
||||||
public: undefined,
|
public: undefined,
|
||||||
songCount: undefined,
|
songCount: JFPlaylistListSort.SONG_COUNT,
|
||||||
updatedAt: undefined,
|
updatedAt: undefined,
|
||||||
},
|
},
|
||||||
navidrome: {
|
navidrome: {
|
||||||
|
|
|
@ -62,6 +62,16 @@ export const ALBUMARTIST_TABLE_COLUMNS = [
|
||||||
{ label: 'Song Count', value: TableColumn.SONG_COUNT },
|
{ label: 'Song Count', value: TableColumn.SONG_COUNT },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const PLAYLIST_TABLE_COLUMNS = [
|
||||||
|
{ label: 'Row Index', value: TableColumn.ROW_INDEX },
|
||||||
|
{ label: 'Title', value: TableColumn.TITLE },
|
||||||
|
{ label: 'Title (Combined)', value: TableColumn.TITLE_COMBINED },
|
||||||
|
{ label: 'Duration', value: TableColumn.DURATION },
|
||||||
|
{ label: 'Owner', value: TableColumn.OWNER },
|
||||||
|
// { label: 'Genre', value: TableColumn.GENRE },
|
||||||
|
{ label: 'Song Count', value: TableColumn.SONG_COUNT },
|
||||||
|
];
|
||||||
|
|
||||||
interface TableConfigDropdownProps {
|
interface TableConfigDropdownProps {
|
||||||
type: TableType;
|
type: TableType;
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,3 +29,10 @@ export const ARTIST_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||||
{ disabled: true, id: 'removeFromFavorites' },
|
{ disabled: true, id: 'removeFromFavorites' },
|
||||||
{ disabled: true, id: 'setRating' },
|
{ disabled: true, id: 'setRating' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const PLAYLIST_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||||
|
{ id: 'play' },
|
||||||
|
{ id: 'playLast' },
|
||||||
|
{ divider: true, id: 'playNext' },
|
||||||
|
{ disabled: true, id: 'deletePlaylist' },
|
||||||
|
];
|
||||||
|
|
|
@ -106,6 +106,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||||
const contextMenuItems = {
|
const contextMenuItems = {
|
||||||
addToFavorites: { id: 'addToFavorites', label: 'Add to favorites', onClick: () => {} },
|
addToFavorites: { id: 'addToFavorites', label: 'Add to favorites', onClick: () => {} },
|
||||||
addToPlaylist: { id: 'addToPlaylist', label: 'Add to playlist', onClick: () => {} },
|
addToPlaylist: { id: 'addToPlaylist', label: 'Add to playlist', onClick: () => {} },
|
||||||
|
deletePlaylist: { id: 'deletePlaylist', label: 'Delete playlist', onClick: () => {} },
|
||||||
play: {
|
play: {
|
||||||
id: 'play',
|
id: 'play',
|
||||||
label: 'Play',
|
label: 'Play',
|
||||||
|
|
|
@ -21,7 +21,8 @@ export type ContextMenuItem =
|
||||||
| 'addToPlaylist'
|
| 'addToPlaylist'
|
||||||
| 'addToFavorites'
|
| 'addToFavorites'
|
||||||
| 'removeFromFavorites'
|
| 'removeFromFavorites'
|
||||||
| 'setRating';
|
| 'setRating'
|
||||||
|
| 'deletePlaylist';
|
||||||
|
|
||||||
export type SetContextMenuItems = {
|
export type SetContextMenuItems = {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
|
|
@ -0,0 +1,251 @@
|
||||||
|
import { MutableRefObject, useCallback, useMemo } from 'react';
|
||||||
|
import type {
|
||||||
|
BodyScrollEvent,
|
||||||
|
CellContextMenuEvent,
|
||||||
|
ColDef,
|
||||||
|
GridReadyEvent,
|
||||||
|
IDatasource,
|
||||||
|
PaginationChangedEvent,
|
||||||
|
RowDoubleClickedEvent,
|
||||||
|
} from '@ag-grid-community/core';
|
||||||
|
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||||
|
import { Stack } from '@mantine/core';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from '/@/renderer/api';
|
||||||
|
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||||
|
import {
|
||||||
|
getColumnDefs,
|
||||||
|
TablePagination,
|
||||||
|
VirtualGridAutoSizerContainer,
|
||||||
|
VirtualTable,
|
||||||
|
} from '/@/renderer/components';
|
||||||
|
import {
|
||||||
|
useCurrentServer,
|
||||||
|
usePlaylistListStore,
|
||||||
|
usePlaylistTablePagination,
|
||||||
|
useSetPlaylistTable,
|
||||||
|
useSetPlaylistTablePagination,
|
||||||
|
} from '/@/renderer/store';
|
||||||
|
import { LibraryItem, ListDisplayType } from '/@/renderer/types';
|
||||||
|
import { AnimatePresence } from 'framer-motion';
|
||||||
|
import debounce from 'lodash/debounce';
|
||||||
|
import { openContextMenu } from '/@/renderer/features/context-menu';
|
||||||
|
import { PLAYLIST_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
|
||||||
|
import sortBy from 'lodash/sortBy';
|
||||||
|
import { usePlaylistList } from '/@/renderer/features/playlists/queries/playlist-list-query';
|
||||||
|
import { generatePath, useNavigate } from 'react-router';
|
||||||
|
import { AppRoute } from '/@/renderer/router/routes';
|
||||||
|
|
||||||
|
interface PlaylistListContentProps {
|
||||||
|
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PlaylistListContent = ({ tableRef }: PlaylistListContentProps) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const server = useCurrentServer();
|
||||||
|
const page = usePlaylistListStore();
|
||||||
|
|
||||||
|
const pagination = usePlaylistTablePagination();
|
||||||
|
const setPagination = useSetPlaylistTablePagination();
|
||||||
|
const setTable = useSetPlaylistTable();
|
||||||
|
|
||||||
|
const isPaginationEnabled = page.display === ListDisplayType.TABLE_PAGINATED;
|
||||||
|
|
||||||
|
const checkPlaylistList = usePlaylistList({
|
||||||
|
limit: 1,
|
||||||
|
startIndex: 0,
|
||||||
|
...page.filter,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columnDefs: ColDef[] = useMemo(
|
||||||
|
() => getColumnDefs(page.table.columns),
|
||||||
|
[page.table.columns],
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultColumnDefs: ColDef = useMemo(() => {
|
||||||
|
return {
|
||||||
|
lockPinned: true,
|
||||||
|
lockVisible: true,
|
||||||
|
resizable: true,
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onGridReady = useCallback(
|
||||||
|
(params: GridReadyEvent) => {
|
||||||
|
const dataSource: IDatasource = {
|
||||||
|
getRows: async (params) => {
|
||||||
|
const limit = params.endRow - params.startRow;
|
||||||
|
const startIndex = params.startRow;
|
||||||
|
|
||||||
|
const queryKey = queryKeys.playlists.list(server?.id || '', {
|
||||||
|
limit,
|
||||||
|
startIndex,
|
||||||
|
...page.filter,
|
||||||
|
});
|
||||||
|
|
||||||
|
const playlistsRes = await queryClient.fetchQuery(queryKey, async ({ signal }) =>
|
||||||
|
api.controller.getPlaylistList({
|
||||||
|
query: {
|
||||||
|
limit,
|
||||||
|
startIndex,
|
||||||
|
...page.filter,
|
||||||
|
},
|
||||||
|
server,
|
||||||
|
signal,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const playlists = api.normalize.playlistList(playlistsRes, server);
|
||||||
|
params.successCallback(playlists?.items || [], playlistsRes?.totalRecordCount);
|
||||||
|
},
|
||||||
|
rowCount: undefined,
|
||||||
|
};
|
||||||
|
params.api.setDatasource(dataSource);
|
||||||
|
},
|
||||||
|
[page.filter, queryClient, server],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPaginationChanged = useCallback(
|
||||||
|
(event: PaginationChangedEvent) => {
|
||||||
|
if (!isPaginationEnabled || !event.api) return;
|
||||||
|
|
||||||
|
// Scroll to top of page on pagination change
|
||||||
|
const currentPageStartIndex = pagination.currentPage * pagination.itemsPerPage;
|
||||||
|
event.api?.ensureIndexVisible(currentPageStartIndex, 'top');
|
||||||
|
|
||||||
|
setPagination({
|
||||||
|
itemsPerPage: event.api.paginationGetPageSize(),
|
||||||
|
totalItems: event.api.paginationGetRowCount(),
|
||||||
|
totalPages: event.api.paginationGetTotalPages() + 1,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[isPaginationEnabled, pagination.currentPage, pagination.itemsPerPage, setPagination],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleGridSizeChange = () => {
|
||||||
|
if (page.table.autoFit) {
|
||||||
|
tableRef?.current?.api.sizeColumnsToFit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleColumnChange = useCallback(() => {
|
||||||
|
const { columnApi } = tableRef?.current || {};
|
||||||
|
const columnsOrder = columnApi?.getAllGridColumns();
|
||||||
|
|
||||||
|
if (!columnsOrder) return;
|
||||||
|
|
||||||
|
const columnsInSettings = page.table.columns;
|
||||||
|
const updatedColumns = [];
|
||||||
|
for (const column of columnsOrder) {
|
||||||
|
const columnInSettings = columnsInSettings.find((c) => c.column === column.getColDef().colId);
|
||||||
|
|
||||||
|
if (columnInSettings) {
|
||||||
|
updatedColumns.push({
|
||||||
|
...columnInSettings,
|
||||||
|
...(!page.table.autoFit && {
|
||||||
|
width: column.getActualWidth(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setTable({ columns: updatedColumns });
|
||||||
|
}, [page.table.autoFit, page.table.columns, setTable, tableRef]);
|
||||||
|
|
||||||
|
const debouncedColumnChange = debounce(handleColumnChange, 200);
|
||||||
|
|
||||||
|
const handleScroll = (e: BodyScrollEvent) => {
|
||||||
|
const scrollOffset = Number((e.top / page.table.rowHeight).toFixed(0));
|
||||||
|
setTable({ scrollOffset });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContextMenu = (e: CellContextMenuEvent) => {
|
||||||
|
if (!e.event) return;
|
||||||
|
const clickEvent = e.event as MouseEvent;
|
||||||
|
clickEvent.preventDefault();
|
||||||
|
|
||||||
|
const selectedNodes = e.api.getSelectedNodes();
|
||||||
|
const selectedIds = selectedNodes.map((node) => node.data.id);
|
||||||
|
let selectedRows = sortBy(selectedNodes, ['rowIndex']).map((node) => node.data);
|
||||||
|
|
||||||
|
if (!selectedIds.includes(e.data.id)) {
|
||||||
|
e.api.deselectAll();
|
||||||
|
e.node.setSelected(true);
|
||||||
|
selectedRows = [e.data];
|
||||||
|
}
|
||||||
|
|
||||||
|
openContextMenu({
|
||||||
|
data: selectedRows,
|
||||||
|
menuItems: PLAYLIST_CONTEXT_MENU_ITEMS,
|
||||||
|
type: LibraryItem.PLAYLIST,
|
||||||
|
xPos: clickEvent.clientX,
|
||||||
|
yPos: clickEvent.clientY,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRowDoubleClick = (e: RowDoubleClickedEvent) => {
|
||||||
|
if (!e.data) return;
|
||||||
|
navigate(generatePath(AppRoute.PLAYLISTS_DETAIL, { playlistId: e.data.id }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
h="100%"
|
||||||
|
spacing={0}
|
||||||
|
>
|
||||||
|
<VirtualGridAutoSizerContainer>
|
||||||
|
<VirtualTable
|
||||||
|
// https://github.com/ag-grid/ag-grid/issues/5284
|
||||||
|
// Key is used to force remount of table when display, rowHeight, or server changes
|
||||||
|
key={`table-${page.display}-${page.table.rowHeight}-${server?.id}`}
|
||||||
|
ref={tableRef}
|
||||||
|
alwaysShowHorizontalScroll
|
||||||
|
animateRows
|
||||||
|
maintainColumnOrder
|
||||||
|
suppressCopyRowsToClipboard
|
||||||
|
suppressMoveWhenRowDragging
|
||||||
|
suppressPaginationPanel
|
||||||
|
suppressRowDrag
|
||||||
|
suppressScrollOnNewData
|
||||||
|
blockLoadDebounceMillis={200}
|
||||||
|
cacheBlockSize={200}
|
||||||
|
cacheOverflowSize={1}
|
||||||
|
columnDefs={columnDefs}
|
||||||
|
defaultColDef={defaultColumnDefs}
|
||||||
|
enableCellChangeFlash={false}
|
||||||
|
getRowId={(data) => data.data.id}
|
||||||
|
infiniteInitialRowCount={checkPlaylistList.data?.totalRecordCount || 100}
|
||||||
|
pagination={isPaginationEnabled}
|
||||||
|
paginationAutoPageSize={isPaginationEnabled}
|
||||||
|
paginationPageSize={page.table.pagination.itemsPerPage || 100}
|
||||||
|
rowBuffer={20}
|
||||||
|
rowHeight={page.table.rowHeight || 40}
|
||||||
|
rowModelType="infinite"
|
||||||
|
rowSelection="multiple"
|
||||||
|
onBodyScrollEnd={handleScroll}
|
||||||
|
onCellContextMenu={handleContextMenu}
|
||||||
|
onColumnMoved={handleColumnChange}
|
||||||
|
onColumnResized={debouncedColumnChange}
|
||||||
|
onGridReady={onGridReady}
|
||||||
|
onGridSizeChanged={handleGridSizeChange}
|
||||||
|
onPaginationChanged={onPaginationChanged}
|
||||||
|
onRowDoubleClicked={handleRowDoubleClick}
|
||||||
|
/>
|
||||||
|
</VirtualGridAutoSizerContainer>
|
||||||
|
<AnimatePresence
|
||||||
|
presenceAffectsLayout
|
||||||
|
initial={false}
|
||||||
|
mode="wait"
|
||||||
|
>
|
||||||
|
{page.display === ListDisplayType.TABLE_PAGINATED && (
|
||||||
|
<TablePagination
|
||||||
|
pagination={pagination}
|
||||||
|
setPagination={setPagination}
|
||||||
|
tableRef={tableRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,334 @@
|
||||||
|
import type { IDatasource } from '@ag-grid-community/core';
|
||||||
|
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||||
|
import { Flex, Group, Stack } from '@mantine/core';
|
||||||
|
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback } from 'react';
|
||||||
|
import { RiArrowDownSLine, RiMoreFill, RiSortAsc, RiSortDesc } from 'react-icons/ri';
|
||||||
|
import { api } from '/@/renderer/api';
|
||||||
|
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||||
|
import { PlaylistListSort, SortOrder } from '/@/renderer/api/types';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
DropdownMenu,
|
||||||
|
PageHeader,
|
||||||
|
Slider,
|
||||||
|
TextTitle,
|
||||||
|
Switch,
|
||||||
|
MultiSelect,
|
||||||
|
Text,
|
||||||
|
PLAYLIST_TABLE_COLUMNS,
|
||||||
|
} from '/@/renderer/components';
|
||||||
|
import { useContainerQuery } from '/@/renderer/hooks';
|
||||||
|
import { queryClient } from '/@/renderer/lib/react-query';
|
||||||
|
import {
|
||||||
|
PlaylistListFilter,
|
||||||
|
useCurrentServer,
|
||||||
|
usePlaylistListStore,
|
||||||
|
useSetPlaylistFilters,
|
||||||
|
useSetPlaylistStore,
|
||||||
|
useSetPlaylistTable,
|
||||||
|
useSetPlaylistTablePagination,
|
||||||
|
} from '/@/renderer/store';
|
||||||
|
import { ListDisplayType, TableColumn } from '/@/renderer/types';
|
||||||
|
|
||||||
|
const FILTERS = {
|
||||||
|
jellyfin: [
|
||||||
|
{ defaultOrder: SortOrder.DESC, name: 'Duration', value: PlaylistListSort.DURATION },
|
||||||
|
{ defaultOrder: SortOrder.ASC, name: 'Name', value: PlaylistListSort.NAME },
|
||||||
|
{ defaultOrder: SortOrder.DESC, name: 'Song Count', value: PlaylistListSort.SONG_COUNT },
|
||||||
|
],
|
||||||
|
navidrome: [
|
||||||
|
{ defaultOrder: SortOrder.DESC, name: 'Duration', value: PlaylistListSort.DURATION },
|
||||||
|
{ defaultOrder: SortOrder.ASC, name: 'Name', value: PlaylistListSort.NAME },
|
||||||
|
{ defaultOrder: SortOrder.ASC, name: 'Owner', value: PlaylistListSort.OWNER },
|
||||||
|
{ defaultOrder: SortOrder.DESC, name: 'Public', value: PlaylistListSort.PUBLIC },
|
||||||
|
{ defaultOrder: SortOrder.DESC, name: 'Song Count', value: PlaylistListSort.SONG_COUNT },
|
||||||
|
{ defaultOrder: SortOrder.DESC, name: 'Updated At', value: PlaylistListSort.UPDATED_AT },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const ORDER = [
|
||||||
|
{ name: 'Ascending', value: SortOrder.ASC },
|
||||||
|
{ name: 'Descending', value: SortOrder.DESC },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface PlaylistListHeaderProps {
|
||||||
|
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PlaylistListHeader = ({ tableRef }: PlaylistListHeaderProps) => {
|
||||||
|
const server = useCurrentServer();
|
||||||
|
const page = usePlaylistListStore();
|
||||||
|
const setPage = useSetPlaylistStore();
|
||||||
|
const setFilter = useSetPlaylistFilters();
|
||||||
|
const setTable = useSetPlaylistTable();
|
||||||
|
const setPagination = useSetPlaylistTablePagination();
|
||||||
|
const cq = useContainerQuery();
|
||||||
|
|
||||||
|
const sortByLabel =
|
||||||
|
(server?.type &&
|
||||||
|
(FILTERS[server.type as keyof typeof FILTERS] as { name: string; value: string }[]).find(
|
||||||
|
(f) => f.value === page.filter.sortBy,
|
||||||
|
)?.name) ||
|
||||||
|
'Unknown';
|
||||||
|
|
||||||
|
const sortOrderLabel = ORDER.find((s) => s.value === page.filter.sortOrder)?.name;
|
||||||
|
|
||||||
|
const handleFilterChange = useCallback(
|
||||||
|
async (filters?: PlaylistListFilter) => {
|
||||||
|
const dataSource: IDatasource = {
|
||||||
|
getRows: async (params) => {
|
||||||
|
const limit = params.endRow - params.startRow;
|
||||||
|
const startIndex = params.startRow;
|
||||||
|
|
||||||
|
const pageFilters = filters || page.filter;
|
||||||
|
|
||||||
|
const queryKey = queryKeys.playlists.list(server?.id || '', {
|
||||||
|
limit,
|
||||||
|
startIndex,
|
||||||
|
...pageFilters,
|
||||||
|
});
|
||||||
|
|
||||||
|
const playlistsRes = await queryClient.fetchQuery(queryKey, async ({ signal }) =>
|
||||||
|
api.controller.getPlaylistList({
|
||||||
|
query: {
|
||||||
|
limit,
|
||||||
|
startIndex,
|
||||||
|
...pageFilters,
|
||||||
|
},
|
||||||
|
server,
|
||||||
|
signal,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const playlists = api.normalize.playlistList(playlistsRes, server);
|
||||||
|
params.successCallback(playlists?.items || [], playlistsRes?.totalRecordCount);
|
||||||
|
},
|
||||||
|
rowCount: undefined,
|
||||||
|
};
|
||||||
|
tableRef.current?.api.setDatasource(dataSource);
|
||||||
|
tableRef.current?.api.purgeInfiniteCache();
|
||||||
|
tableRef.current?.api.ensureIndexVisible(0, 'top');
|
||||||
|
setPagination({ currentPage: 0 });
|
||||||
|
},
|
||||||
|
[page.filter, server, setPagination, tableRef],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSetSortBy = useCallback(
|
||||||
|
(e: MouseEvent<HTMLButtonElement>) => {
|
||||||
|
if (!e.currentTarget?.value || !server?.type) return;
|
||||||
|
|
||||||
|
const sortOrder = FILTERS[server.type as keyof typeof FILTERS].find(
|
||||||
|
(f) => f.value === e.currentTarget.value,
|
||||||
|
)?.defaultOrder;
|
||||||
|
|
||||||
|
const updatedFilters = setFilter({
|
||||||
|
sortBy: e.currentTarget.value as PlaylistListSort,
|
||||||
|
sortOrder: sortOrder || SortOrder.ASC,
|
||||||
|
});
|
||||||
|
|
||||||
|
handleFilterChange(updatedFilters);
|
||||||
|
},
|
||||||
|
[handleFilterChange, server?.type, setFilter],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleToggleSortOrder = useCallback(() => {
|
||||||
|
const newSortOrder = page.filter.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;
|
||||||
|
const updatedFilters = setFilter({ sortOrder: newSortOrder });
|
||||||
|
handleFilterChange(updatedFilters);
|
||||||
|
}, [page.filter.sortOrder, handleFilterChange, setFilter]);
|
||||||
|
|
||||||
|
const handleSetViewType = useCallback(
|
||||||
|
(e: MouseEvent<HTMLButtonElement>) => {
|
||||||
|
if (!e.currentTarget?.value) return;
|
||||||
|
const display = e.currentTarget.value as ListDisplayType;
|
||||||
|
setPage({ list: { ...page, display: e.currentTarget.value as ListDisplayType } });
|
||||||
|
|
||||||
|
if (display === ListDisplayType.TABLE) {
|
||||||
|
tableRef.current?.api.paginationSetPageSize(tableRef.current.props.infiniteInitialRowCount);
|
||||||
|
setPagination({ currentPage: 0 });
|
||||||
|
} else if (display === ListDisplayType.TABLE_PAGINATED) {
|
||||||
|
setPagination({ currentPage: 0 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[page, setPage, setPagination, tableRef],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTableColumns = (values: TableColumn[]) => {
|
||||||
|
const existingColumns = page.table.columns;
|
||||||
|
|
||||||
|
if (values.length === 0) {
|
||||||
|
return setTable({
|
||||||
|
columns: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If adding a column
|
||||||
|
if (values.length > existingColumns.length) {
|
||||||
|
const newColumn = { column: values[values.length - 1], width: 100 };
|
||||||
|
|
||||||
|
return setTable({ columns: [...existingColumns, newColumn] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// If removing a column
|
||||||
|
const removed = existingColumns.filter((column) => !values.includes(column.column));
|
||||||
|
const newColumns = existingColumns.filter((column) => !removed.includes(column));
|
||||||
|
|
||||||
|
return setTable({ columns: newColumns });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setTable({ autoFit: e.currentTarget.checked });
|
||||||
|
|
||||||
|
if (e.currentTarget.checked) {
|
||||||
|
tableRef.current?.api.sizeColumnsToFit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRowHeight = (e: number) => {
|
||||||
|
setTable({ rowHeight: e });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageHeader>
|
||||||
|
<Flex
|
||||||
|
ref={cq.ref}
|
||||||
|
direction="row"
|
||||||
|
justify="space-between"
|
||||||
|
>
|
||||||
|
<Flex
|
||||||
|
align="center"
|
||||||
|
gap="md"
|
||||||
|
justify="center"
|
||||||
|
>
|
||||||
|
<DropdownMenu position="bottom-start">
|
||||||
|
<DropdownMenu.Target>
|
||||||
|
<Button
|
||||||
|
compact
|
||||||
|
rightIcon={<RiArrowDownSLine size={15} />}
|
||||||
|
size="xl"
|
||||||
|
sx={{ paddingLeft: 0, paddingRight: 0 }}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<TextTitle
|
||||||
|
fw="bold"
|
||||||
|
order={3}
|
||||||
|
>
|
||||||
|
Playlists
|
||||||
|
</TextTitle>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenu.Target>
|
||||||
|
<DropdownMenu.Dropdown>
|
||||||
|
<DropdownMenu.Label>Display type</DropdownMenu.Label>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
$isActive={page.display === ListDisplayType.TABLE}
|
||||||
|
value={ListDisplayType.TABLE}
|
||||||
|
onClick={handleSetViewType}
|
||||||
|
>
|
||||||
|
Table
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
$isActive={page.display === ListDisplayType.TABLE_PAGINATED}
|
||||||
|
value={ListDisplayType.TABLE_PAGINATED}
|
||||||
|
onClick={handleSetViewType}
|
||||||
|
>
|
||||||
|
Table (paginated)
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Divider />
|
||||||
|
<DropdownMenu.Label>Item Size</DropdownMenu.Label>
|
||||||
|
<DropdownMenu.Item closeMenuOnClick={false}>
|
||||||
|
<Slider
|
||||||
|
defaultValue={page.table.rowHeight || 0}
|
||||||
|
label={null}
|
||||||
|
max={100}
|
||||||
|
min={25}
|
||||||
|
onChangeEnd={handleRowHeight}
|
||||||
|
/>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Label>Table Columns</DropdownMenu.Label>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
closeMenuOnClick={false}
|
||||||
|
component="div"
|
||||||
|
sx={{ cursor: 'default' }}
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<MultiSelect
|
||||||
|
clearable
|
||||||
|
data={PLAYLIST_TABLE_COLUMNS}
|
||||||
|
defaultValue={page.table?.columns.map((column) => column.column)}
|
||||||
|
width={300}
|
||||||
|
onChange={handleTableColumns}
|
||||||
|
/>
|
||||||
|
<Group position="apart">
|
||||||
|
<Text>Auto Fit Columns</Text>
|
||||||
|
<Switch
|
||||||
|
defaultChecked={page.table.autoFit}
|
||||||
|
onChange={handleAutoFitColumns}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Dropdown>
|
||||||
|
</DropdownMenu>
|
||||||
|
<DropdownMenu position="bottom-start">
|
||||||
|
<DropdownMenu.Target>
|
||||||
|
<Button
|
||||||
|
compact
|
||||||
|
fw="600"
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
{sortByLabel}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenu.Target>
|
||||||
|
<DropdownMenu.Dropdown>
|
||||||
|
{FILTERS[server?.type as keyof typeof FILTERS].map((filter) => (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key={`filter-${filter.name}`}
|
||||||
|
$isActive={filter.value === page.filter.sortBy}
|
||||||
|
value={filter.value}
|
||||||
|
onClick={handleSetSortBy}
|
||||||
|
>
|
||||||
|
{filter.name}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
))}
|
||||||
|
</DropdownMenu.Dropdown>
|
||||||
|
</DropdownMenu>
|
||||||
|
<Button
|
||||||
|
compact
|
||||||
|
fw="600"
|
||||||
|
variant="subtle"
|
||||||
|
onClick={handleToggleSortOrder}
|
||||||
|
>
|
||||||
|
{cq.isMd ? (
|
||||||
|
sortOrderLabel
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{page.filter.sortOrder === SortOrder.ASC ? (
|
||||||
|
<RiSortAsc size={15} />
|
||||||
|
) : (
|
||||||
|
<RiSortDesc size={15} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu position="bottom-start">
|
||||||
|
<DropdownMenu.Target>
|
||||||
|
<Button
|
||||||
|
compact
|
||||||
|
fw="600"
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<RiMoreFill size={15} />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenu.Target>
|
||||||
|
<DropdownMenu.Dropdown>
|
||||||
|
<DropdownMenu.Item disabled>Play</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item disabled>Add to queue (last)</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item disabled>Add to queue (next)</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item disabled>Add to playlist</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Dropdown>
|
||||||
|
</DropdownMenu>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</PageHeader>
|
||||||
|
);
|
||||||
|
};
|
1
src/renderer/features/playlists/index.ts
Normal file
1
src/renderer/features/playlists/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export * from './queries/playlist-list-query';
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||||
|
import type { PlaylistListQuery, RawPlaylistListResponse } from '/@/renderer/api/types';
|
||||||
|
import type { QueryOptions } from '/@/renderer/lib/react-query';
|
||||||
|
import { useCurrentServer } from '/@/renderer/store';
|
||||||
|
import { api } from '/@/renderer/api';
|
||||||
|
|
||||||
|
export const usePlaylistList = (query: PlaylistListQuery, options?: QueryOptions) => {
|
||||||
|
const server = useCurrentServer();
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
cacheTime: 1000 * 60 * 60,
|
||||||
|
enabled: !!server?.id,
|
||||||
|
queryFn: ({ signal }) => api.controller.getPlaylistList({ query, server, signal }),
|
||||||
|
queryKey: queryKeys.playlists.list(server?.id || '', query),
|
||||||
|
select: useCallback(
|
||||||
|
(data: RawPlaylistListResponse | undefined) => api.normalize.playlistList(data, server),
|
||||||
|
[server],
|
||||||
|
),
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
|
@ -0,0 +1,18 @@
|
||||||
|
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||||
|
import { useRef } from 'react';
|
||||||
|
import { PlaylistListContent } from '/@/renderer/features/playlists/components/playlist-list-content';
|
||||||
|
import { PlaylistListHeader } from '/@/renderer/features/playlists/components/playlist-list-header';
|
||||||
|
import { AnimatedPage } from '/@/renderer/features/shared';
|
||||||
|
|
||||||
|
const PlaylistListRoute = () => {
|
||||||
|
const tableRef = useRef<AgGridReactType | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatedPage>
|
||||||
|
<PlaylistListHeader tableRef={tableRef} />
|
||||||
|
<PlaylistListContent tableRef={tableRef} />
|
||||||
|
</AnimatedPage>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PlaylistListRoute;
|
|
@ -22,6 +22,10 @@ const AlbumListRoute = lazy(() => import('/@/renderer/features/albums/routes/alb
|
||||||
|
|
||||||
const SongListRoute = lazy(() => import('/@/renderer/features/songs/routes/song-list-route'));
|
const SongListRoute = lazy(() => import('/@/renderer/features/songs/routes/song-list-route'));
|
||||||
|
|
||||||
|
const PlaylistListRoute = lazy(
|
||||||
|
() => import('/@/renderer/features/playlists/routes/playlist-list-route'),
|
||||||
|
);
|
||||||
|
|
||||||
const ActionRequiredRoute = lazy(
|
const ActionRequiredRoute = lazy(
|
||||||
() => import('/@/renderer/features/action-required/routes/action-required-route'),
|
() => import('/@/renderer/features/action-required/routes/action-required-route'),
|
||||||
);
|
);
|
||||||
|
@ -64,6 +68,10 @@ export const AppRouter = () => {
|
||||||
element={<SongListRoute />}
|
element={<SongListRoute />}
|
||||||
path={AppRoute.LIBRARY_SONGS}
|
path={AppRoute.LIBRARY_SONGS}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
element={<PlaylistListRoute />}
|
||||||
|
path={AppRoute.PLAYLISTS}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
element={<AlbumArtistListRoute />}
|
element={<AlbumArtistListRoute />}
|
||||||
path={AppRoute.LIBRARY_ALBUMARTISTS}
|
path={AppRoute.LIBRARY_ALBUMARTISTS}
|
||||||
|
|
|
@ -4,3 +4,4 @@ export * from './app.store';
|
||||||
export * from './album.store';
|
export * from './album.store';
|
||||||
export * from './song.store';
|
export * from './song.store';
|
||||||
export * from './album-artist.store';
|
export * from './album-artist.store';
|
||||||
|
export * from './playlist.store';
|
||||||
|
|
125
src/renderer/store/playlist.store.ts
Normal file
125
src/renderer/store/playlist.store.ts
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
import merge from 'lodash/merge';
|
||||||
|
import create from 'zustand';
|
||||||
|
import { devtools, persist } from 'zustand/middleware';
|
||||||
|
import { immer } from 'zustand/middleware/immer';
|
||||||
|
import { PlaylistListArgs, PlaylistListSort, SortOrder } from '/@/renderer/api/types';
|
||||||
|
import { DataTableProps } from '/@/renderer/store/settings.store';
|
||||||
|
import { ListDisplayType, TableColumn, TablePagination } from '/@/renderer/types';
|
||||||
|
|
||||||
|
type TableProps = {
|
||||||
|
pagination: TablePagination;
|
||||||
|
scrollOffset: number;
|
||||||
|
} & DataTableProps;
|
||||||
|
|
||||||
|
type ListProps<T> = {
|
||||||
|
display: ListDisplayType;
|
||||||
|
filter: T;
|
||||||
|
table: TableProps;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlaylistListFilter = Omit<PlaylistListArgs['query'], 'startIndex' | 'limit'>;
|
||||||
|
|
||||||
|
interface PlaylistState {
|
||||||
|
list: ListProps<PlaylistListFilter>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaylistSlice extends PlaylistState {
|
||||||
|
actions: {
|
||||||
|
setFilters: (data: Partial<PlaylistListFilter>) => PlaylistListFilter;
|
||||||
|
setStore: (data: Partial<PlaylistSlice>) => void;
|
||||||
|
setTable: (data: Partial<TableProps>) => void;
|
||||||
|
setTablePagination: (data: Partial<TableProps['pagination']>) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePlaylistStore = create<PlaylistSlice>()(
|
||||||
|
persist(
|
||||||
|
devtools(
|
||||||
|
immer((set, get) => ({
|
||||||
|
actions: {
|
||||||
|
setFilters: (data) => {
|
||||||
|
set((state) => {
|
||||||
|
state.list.filter = { ...state.list.filter, ...data };
|
||||||
|
});
|
||||||
|
|
||||||
|
return get().list.filter;
|
||||||
|
},
|
||||||
|
setStore: (data) => {
|
||||||
|
set({ ...get(), ...data });
|
||||||
|
},
|
||||||
|
setTable: (data) => {
|
||||||
|
set((state) => {
|
||||||
|
state.list.table = { ...state.list.table, ...data };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
setTablePagination: (data) => {
|
||||||
|
set((state) => {
|
||||||
|
state.list.table.pagination = { ...state.list.table.pagination, ...data };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
display: ListDisplayType.TABLE,
|
||||||
|
filter: {
|
||||||
|
musicFolderId: undefined,
|
||||||
|
sortBy: PlaylistListSort.NAME,
|
||||||
|
sortOrder: SortOrder.ASC,
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
autoFit: true,
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
column: TableColumn.ROW_INDEX,
|
||||||
|
width: 50,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
column: TableColumn.TITLE,
|
||||||
|
width: 500,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
column: TableColumn.SONG_COUNT,
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pagination: {
|
||||||
|
currentPage: 1,
|
||||||
|
itemsPerPage: 100,
|
||||||
|
totalItems: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
},
|
||||||
|
rowHeight: 40,
|
||||||
|
scrollOffset: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
{ name: 'store_playlist' },
|
||||||
|
),
|
||||||
|
{
|
||||||
|
merge: (persistedState, currentState) => {
|
||||||
|
return merge(currentState, persistedState);
|
||||||
|
},
|
||||||
|
name: 'store_playlist',
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const usePlaylistStoreActions = () => usePlaylistStore((state) => state.actions);
|
||||||
|
|
||||||
|
export const useSetPlaylistStore = () => usePlaylistStore((state) => state.actions.setStore);
|
||||||
|
|
||||||
|
export const useSetPlaylistFilters = () => usePlaylistStore((state) => state.actions.setFilters);
|
||||||
|
|
||||||
|
export const usePlaylistFilters = () => {
|
||||||
|
return usePlaylistStore((state) => [state.list.filter, state.actions.setFilters]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usePlaylistListStore = () => usePlaylistStore((state) => state.list);
|
||||||
|
|
||||||
|
export const usePlaylistTablePagination = () =>
|
||||||
|
usePlaylistStore((state) => state.list.table.pagination);
|
||||||
|
|
||||||
|
export const useSetPlaylistTablePagination = () =>
|
||||||
|
usePlaylistStore((state) => state.actions.setTablePagination);
|
||||||
|
|
||||||
|
export const useSetPlaylistTable = () => usePlaylistStore((state) => state.actions.setTable);
|
|
@ -146,6 +146,7 @@ export enum TableColumn {
|
||||||
FAVORITE = 'favorite',
|
FAVORITE = 'favorite',
|
||||||
GENRE = 'genre',
|
GENRE = 'genre',
|
||||||
LAST_PLAYED = 'lastPlayedAt',
|
LAST_PLAYED = 'lastPlayedAt',
|
||||||
|
OWNER = 'username',
|
||||||
PATH = 'path',
|
PATH = 'path',
|
||||||
PLAY_COUNT = 'playCount',
|
PLAY_COUNT = 'playCount',
|
||||||
RATING = 'rating',
|
RATING = 'rating',
|
||||||
|
|
Reference in a new issue