mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 04:20:12 +02:00
Compare commits
76 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1048431742 | |||
| 633c9f59d9 | |||
| 0ed13c75af | |||
| b0bc4c3cf3 | |||
| b8b8ca9f66 | |||
| f2e6a418b0 | |||
| 7fef7e4689 | |||
| 21bf995335 | |||
| bd13fb63ae | |||
| 67ccc20147 | |||
| 83991cf5a1 | |||
| dfb0ff42b3 | |||
| 008c12626d | |||
| 19e3f435c4 | |||
| ac6242ea94 | |||
| b87d7778df | |||
| acb906aad9 | |||
| 196cb1bd48 | |||
| 3981ad3eb5 | |||
| d54131b34a | |||
| 6ad6617d88 | |||
| 52163534db | |||
| 5dd65b18b7 | |||
| 1e77e1074a | |||
| 9537309fe2 | |||
| 4dc8920ff4 | |||
| 26e6f479b7 | |||
| d93e6a612e | |||
| 0baa6f4488 | |||
| 6490118741 | |||
| 58827a1dcf | |||
| a3804808b4 | |||
| d49bba42ef | |||
| c56f6a355d | |||
| 7b13e24ce4 | |||
| 088f1d0f99 | |||
| d7d611c6d1 | |||
| 65465d6cae | |||
| 3d8ba2e808 | |||
| 152be5d7e6 | |||
| 4326f6cf91 | |||
| 90dec929f4 | |||
| d6dc880ef4 | |||
| d0e2a798fe | |||
| fecaa2e6b8 | |||
| cdbd3f8c7b | |||
| b037329377 | |||
| 8b04f70106 | |||
| 737a05e2c5 | |||
| 78a30c2db4 | |||
| 5cef23944f | |||
| aa1cd742ad | |||
| 0f364f7c5c | |||
| 11be5c811f | |||
| 6174dc128d | |||
| 81455602ef | |||
| d6936634db | |||
| 88f53c17db | |||
| 82f107d835 | |||
| 1fee4c1946 | |||
| ec79d91d30 | |||
| 00a21269dd | |||
| 58ed2f3706 | |||
| 0a9dcf36b9 | |||
| dc1e728a2e | |||
| 085a3856e0 | |||
| a693981333 | |||
| 2a797bd6c9 | |||
| 4a64f5fe9b | |||
| 1f232fa4da | |||
| b3d95f765c | |||
| f298e60929 | |||
| 4745c4a42d | |||
| 6fddea552d | |||
| 24af17b8fe | |||
| 185175aa89 |
Generated
+9
-19
@@ -34,7 +34,7 @@
|
||||
"electron-updater": "^4.6.5",
|
||||
"fast-average-color": "^9.2.0",
|
||||
"format-duration": "^2.0.0",
|
||||
"framer-motion": "^7.10.2",
|
||||
"framer-motion": "^8.1.3",
|
||||
"history": "^5.3.0",
|
||||
"i18next": "^21.6.16",
|
||||
"immer": "^9.0.15",
|
||||
@@ -10475,13 +10475,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "7.10.2",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-7.10.2.tgz",
|
||||
"integrity": "sha512-2OAII9hjpMNz2Nbl2w09uKo7A0bD6xRtGnCZbPzuGGueucWgmSBLjAwzPhXwzCQWMpL3LU8jmXHjxAwIyjslxg==",
|
||||
"version": "8.1.3",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-8.1.3.tgz",
|
||||
"integrity": "sha512-pvlAEwjl4W6EMRp0rvWABCk+YyrlnPMXYzgyhkLSnBq9TdACFNeJBql7E40pMS0ttaWMKfwmH1A/raFCtcMf/A==",
|
||||
"dependencies": {
|
||||
"@motionone/dom": "^10.15.3",
|
||||
"hey-listen": "^1.0.8",
|
||||
"tslib": "2.4.0"
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@emotion/is-prop-valid": "^0.8.2"
|
||||
@@ -10506,11 +10506,6 @@
|
||||
"integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/framer-motion/node_modules/tslib": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz",
|
||||
"integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ=="
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
@@ -31703,14 +31698,14 @@
|
||||
}
|
||||
},
|
||||
"framer-motion": {
|
||||
"version": "7.10.2",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-7.10.2.tgz",
|
||||
"integrity": "sha512-2OAII9hjpMNz2Nbl2w09uKo7A0bD6xRtGnCZbPzuGGueucWgmSBLjAwzPhXwzCQWMpL3LU8jmXHjxAwIyjslxg==",
|
||||
"version": "8.1.3",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-8.1.3.tgz",
|
||||
"integrity": "sha512-pvlAEwjl4W6EMRp0rvWABCk+YyrlnPMXYzgyhkLSnBq9TdACFNeJBql7E40pMS0ttaWMKfwmH1A/raFCtcMf/A==",
|
||||
"requires": {
|
||||
"@emotion/is-prop-valid": "^0.8.2",
|
||||
"@motionone/dom": "^10.15.3",
|
||||
"hey-listen": "^1.0.8",
|
||||
"tslib": "2.4.0"
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/is-prop-valid": {
|
||||
@@ -31727,11 +31722,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz",
|
||||
"integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==",
|
||||
"optional": true
|
||||
},
|
||||
"tslib": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz",
|
||||
"integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ=="
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
"name": "feishin",
|
||||
"productName": "Feishin",
|
||||
"description": "Feishin music server",
|
||||
"version": "0.0.1-alpha2",
|
||||
"version": "0.0.1-alpha3",
|
||||
"scripts": {
|
||||
"build": "concurrently \"npm run build:main\" \"npm run build:renderer\"",
|
||||
"build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts",
|
||||
@@ -271,7 +271,7 @@
|
||||
"electron-updater": "^4.6.5",
|
||||
"fast-average-color": "^9.2.0",
|
||||
"format-duration": "^2.0.0",
|
||||
"framer-motion": "^7.10.2",
|
||||
"framer-motion": "^8.1.3",
|
||||
"history": "^5.3.0",
|
||||
"i18next": "^21.6.16",
|
||||
"immer": "^9.0.15",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "feishin",
|
||||
"version": "0.0.1-alpha2",
|
||||
"version": "0.0.1-alpha3",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "feishin",
|
||||
"version": "0.0.1-alpha2",
|
||||
"version": "0.0.1-alpha3",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "feishin",
|
||||
"version": "0.0.1-alpha2",
|
||||
"version": "0.0.1-alpha3",
|
||||
"description": "",
|
||||
"main": "./dist/main/main.js",
|
||||
"author": {
|
||||
|
||||
@@ -33,6 +33,8 @@ import type {
|
||||
PlaylistSongListArgs,
|
||||
ArtistListArgs,
|
||||
RawArtistListResponse,
|
||||
UpdatePlaylistArgs,
|
||||
RawUpdatePlaylistResponse,
|
||||
} from '/@/renderer/api/types';
|
||||
import { subsonicApi } from '/@/renderer/api/subsonic.api';
|
||||
import { jellyfinApi } from '/@/renderer/api/jellyfin.api';
|
||||
@@ -60,7 +62,7 @@ export type ControllerEndpoint = Partial<{
|
||||
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<RawSongListResponse>;
|
||||
getSongDetail: (args: SongDetailArgs) => Promise<RawSongDetailResponse>;
|
||||
getSongList: (args: SongListArgs) => Promise<RawSongListResponse>;
|
||||
updatePlaylist: () => void;
|
||||
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<RawUpdatePlaylistResponse>;
|
||||
updateRating: (args: RatingArgs) => Promise<RawRatingResponse>;
|
||||
}>;
|
||||
|
||||
@@ -94,7 +96,7 @@ const endpoints: ApiController = {
|
||||
getPlaylistSongList: jellyfinApi.getPlaylistSongList,
|
||||
getSongDetail: undefined,
|
||||
getSongList: jellyfinApi.getSongList,
|
||||
updatePlaylist: undefined,
|
||||
updatePlaylist: jellyfinApi.updatePlaylist,
|
||||
updateRating: undefined,
|
||||
},
|
||||
navidrome: {
|
||||
@@ -120,7 +122,7 @@ const endpoints: ApiController = {
|
||||
getPlaylistSongList: navidromeApi.getPlaylistSongList,
|
||||
getSongDetail: navidromeApi.getSongDetail,
|
||||
getSongList: navidromeApi.getSongList,
|
||||
updatePlaylist: undefined,
|
||||
updatePlaylist: navidromeApi.updatePlaylist,
|
||||
updateRating: subsonicApi.updateRating,
|
||||
},
|
||||
subsonic: {
|
||||
@@ -191,10 +193,52 @@ const getGenreList = async (args: GenreListArgs) => {
|
||||
return (apiController('getGenreList') as ControllerEndpoint['getGenreList'])?.(args);
|
||||
};
|
||||
|
||||
const getAlbumArtistList = async (args: AlbumArtistListArgs) => {
|
||||
return (apiController('getAlbumArtistList') as ControllerEndpoint['getAlbumArtistList'])?.(args);
|
||||
};
|
||||
|
||||
const getArtistList = async (args: ArtistListArgs) => {
|
||||
return (apiController('getArtistList') as ControllerEndpoint['getArtistList'])?.(args);
|
||||
};
|
||||
|
||||
const getPlaylistList = async (args: PlaylistListArgs) => {
|
||||
return (apiController('getPlaylistList') as ControllerEndpoint['getPlaylistList'])?.(args);
|
||||
};
|
||||
|
||||
const createPlaylist = async (args: CreatePlaylistArgs) => {
|
||||
return (apiController('createPlaylist') as ControllerEndpoint['createPlaylist'])?.(args);
|
||||
};
|
||||
|
||||
const updatePlaylist = async (args: UpdatePlaylistArgs) => {
|
||||
return (apiController('updatePlaylist') as ControllerEndpoint['updatePlaylist'])?.(args);
|
||||
};
|
||||
|
||||
const deletePlaylist = async (args: DeletePlaylistArgs) => {
|
||||
return (apiController('deletePlaylist') as ControllerEndpoint['deletePlaylist'])?.(args);
|
||||
};
|
||||
|
||||
const getPlaylistDetail = async (args: PlaylistDetailArgs) => {
|
||||
return (apiController('getPlaylistDetail') as ControllerEndpoint['getPlaylistDetail'])?.(args);
|
||||
};
|
||||
|
||||
const getPlaylistSongList = async (args: PlaylistSongListArgs) => {
|
||||
return (apiController('getPlaylistSongList') as ControllerEndpoint['getPlaylistSongList'])?.(
|
||||
args,
|
||||
);
|
||||
};
|
||||
|
||||
export const controller = {
|
||||
createPlaylist,
|
||||
deletePlaylist,
|
||||
getAlbumArtistList,
|
||||
getAlbumDetail,
|
||||
getAlbumList,
|
||||
getArtistList,
|
||||
getGenreList,
|
||||
getMusicFolderList,
|
||||
getPlaylistDetail,
|
||||
getPlaylistList,
|
||||
getPlaylistSongList,
|
||||
getSongList,
|
||||
updatePlaylist,
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import ky from 'ky';
|
||||
import { nanoid } from 'nanoid/non-secure';
|
||||
import type {
|
||||
JFAlbum,
|
||||
JFAlbumArtist,
|
||||
JFAlbumArtistDetail,
|
||||
JFAlbumArtistDetailResponse,
|
||||
JFAlbumArtistList,
|
||||
@@ -21,6 +22,7 @@ import type {
|
||||
JFGenreListResponse,
|
||||
JFMusicFolderList,
|
||||
JFMusicFolderListResponse,
|
||||
JFPlaylist,
|
||||
JFPlaylistDetail,
|
||||
JFPlaylistDetailResponse,
|
||||
JFPlaylistList,
|
||||
@@ -31,8 +33,9 @@ import type {
|
||||
JFSongListResponse,
|
||||
} from '/@/renderer/api/jellyfin.types';
|
||||
import { JFCollectionType } from '/@/renderer/api/jellyfin.types';
|
||||
import type {
|
||||
import {
|
||||
Album,
|
||||
AlbumArtist,
|
||||
AlbumArtistDetailArgs,
|
||||
AlbumArtistListArgs,
|
||||
AlbumDetailArgs,
|
||||
@@ -46,18 +49,20 @@ import type {
|
||||
FavoriteResponse,
|
||||
GenreListArgs,
|
||||
MusicFolderListArgs,
|
||||
Playlist,
|
||||
PlaylistDetailArgs,
|
||||
PlaylistListArgs,
|
||||
playlistListSortMap,
|
||||
PlaylistSongListArgs,
|
||||
Song,
|
||||
SongListArgs,
|
||||
} from '/@/renderer/api/types';
|
||||
import {
|
||||
songListSortMap,
|
||||
albumListSortMap,
|
||||
artistListSortMap,
|
||||
sortOrderMap,
|
||||
albumArtistListSortMap,
|
||||
UpdatePlaylistArgs,
|
||||
UpdatePlaylistResponse,
|
||||
} from '/@/renderer/api/types';
|
||||
import { useAuthStore } from '/@/renderer/store';
|
||||
import { ServerListItem, ServerType } from '/@/renderer/types';
|
||||
@@ -82,7 +87,7 @@ const authenticate = async (
|
||||
.post(`${cleanServerUrl}/users/authenticatebyname`, {
|
||||
headers: {
|
||||
'X-Emby-Authorization':
|
||||
'MediaBrowser Client="Feishin", Device="PC", DeviceId="Feishin", Version="0.0.1-alpha2"',
|
||||
'MediaBrowser Client="Feishin", Device="PC", DeviceId="Feishin", Version="0.0.1"',
|
||||
},
|
||||
json: {
|
||||
pw: body.password,
|
||||
@@ -138,7 +143,7 @@ const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs): Promise<JFAlbu
|
||||
};
|
||||
|
||||
const data = await api
|
||||
.get(`/users/${server?.userId}/items/${query.id}`, {
|
||||
.get(`users/${server?.userId}/items/${query.id}`, {
|
||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
||||
prefixUrl: server?.url,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
@@ -170,12 +175,16 @@ const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<JFAlbumArt
|
||||
const { query, server, signal } = args;
|
||||
|
||||
const searchParams: JFAlbumArtistListParams = {
|
||||
fields: 'Genres, DateCreated, ExternalUrls, Overview',
|
||||
imageTypeLimit: 1,
|
||||
limit: query.limit,
|
||||
parentId: query.musicFolderId,
|
||||
recursive: true,
|
||||
searchTerm: query.searchTerm,
|
||||
sortBy: albumArtistListSortMap.jellyfin[query.sortBy],
|
||||
sortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
||||
startIndex: query.startIndex,
|
||||
userId: server?.userId || undefined,
|
||||
};
|
||||
|
||||
const data = await api
|
||||
@@ -187,7 +196,11 @@ const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<JFAlbumArt
|
||||
})
|
||||
.json<JFAlbumArtistListResponse>();
|
||||
|
||||
return data;
|
||||
return {
|
||||
items: data.Items,
|
||||
startIndex: query.startIndex,
|
||||
totalRecordCount: data.TotalRecordCount,
|
||||
};
|
||||
};
|
||||
|
||||
const getArtistList = async (args: ArtistListArgs): Promise<JFArtistList> => {
|
||||
@@ -303,9 +316,11 @@ const getSongList = async (args: SongListArgs): Promise<JFSongList> => {
|
||||
|
||||
const yearsFilter = yearsGroup.length ? getCommaDelimitedString(yearsGroup) : undefined;
|
||||
const albumIdsFilter = query.albumIds ? getCommaDelimitedString(query.albumIds) : undefined;
|
||||
const artistIdsFilter = query.artistIds ? getCommaDelimitedString(query.artistIds) : undefined;
|
||||
|
||||
const searchParams: JFSongListParams & { maxYear?: number; minYear?: number } = {
|
||||
albumIds: albumIdsFilter,
|
||||
artistIds: artistIdsFilter,
|
||||
fields: 'Genres, DateCreated, MediaSources, ParentId',
|
||||
includeItemTypes: 'Audio',
|
||||
limit: query.limit,
|
||||
@@ -363,8 +378,11 @@ const getPlaylistSongList = async (args: PlaylistSongListArgs): Promise<JFSongLi
|
||||
const searchParams: JFSongListParams = {
|
||||
fields: 'Genres, DateCreated, MediaSources, UserData, ParentId',
|
||||
includeItemTypes: 'Audio',
|
||||
limit: query.limit,
|
||||
sortBy: query.sortBy ? songListSortMap.jellyfin[query.sortBy] : undefined,
|
||||
sortOrder: query.sortOrder ? sortOrderMap.jellyfin[query.sortOrder] : undefined,
|
||||
startIndex: 0,
|
||||
userId: server?.userId || '',
|
||||
};
|
||||
|
||||
const data = await api
|
||||
@@ -384,18 +402,20 @@ const getPlaylistSongList = async (args: PlaylistSongListArgs): Promise<JFSongLi
|
||||
};
|
||||
|
||||
const getPlaylistList = async (args: PlaylistListArgs): Promise<JFPlaylistList> => {
|
||||
const { server, signal } = args;
|
||||
const { query, server, signal } = args;
|
||||
|
||||
const searchParams = {
|
||||
fields: 'ChildCount, Genres, DateCreated, ParentId, Overview',
|
||||
includeItemTypes: 'Playlist',
|
||||
limit: query.limit,
|
||||
recursive: true,
|
||||
sortBy: 'SortName',
|
||||
sortOrder: 'Ascending',
|
||||
sortBy: playlistListSortMap.jellyfin[query.sortBy],
|
||||
sortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
||||
startIndex: query.startIndex,
|
||||
};
|
||||
|
||||
const data = await api
|
||||
.get(`/users/${server?.userId}/items`, {
|
||||
.get(`users/${server?.userId}/items`, {
|
||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
||||
prefixUrl: server?.url,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
@@ -403,12 +423,12 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise<JFPlaylistList>
|
||||
})
|
||||
.json<JFPlaylistListResponse>();
|
||||
|
||||
const playlistData = data.Items.filter((item) => item.MediaType === 'Audio');
|
||||
const playlistItems = data.Items.filter((item) => item.MediaType === 'Audio');
|
||||
|
||||
return {
|
||||
Items: playlistData,
|
||||
StartIndex: 0,
|
||||
TotalRecordCount: playlistData.length,
|
||||
items: playlistItems,
|
||||
startIndex: 0,
|
||||
totalRecordCount: playlistItems.length,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -418,6 +438,7 @@ const createPlaylist = async (args: CreatePlaylistArgs): Promise<CreatePlaylistR
|
||||
const body = {
|
||||
MediaType: 'Audio',
|
||||
Name: query.name,
|
||||
Overview: query.comment || '',
|
||||
UserId: server?.userId,
|
||||
};
|
||||
|
||||
@@ -435,6 +456,33 @@ const createPlaylist = async (args: CreatePlaylistArgs): Promise<CreatePlaylistR
|
||||
};
|
||||
};
|
||||
|
||||
const updatePlaylist = async (args: UpdatePlaylistArgs): Promise<UpdatePlaylistResponse> => {
|
||||
const { query, body, server } = args;
|
||||
|
||||
const json = {
|
||||
Genres: body.genres?.map((item) => ({ Id: item.id, Name: item.name })) || [],
|
||||
MediaType: 'Audio',
|
||||
Name: body.name,
|
||||
Overview: body.comment || '',
|
||||
PremiereDate: null,
|
||||
ProviderIds: {},
|
||||
Tags: [],
|
||||
UserId: server?.userId, // Required
|
||||
};
|
||||
|
||||
await api
|
||||
.post(`items/${query.id}`, {
|
||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
||||
json,
|
||||
prefixUrl: server?.url,
|
||||
})
|
||||
.json<null>();
|
||||
|
||||
return {
|
||||
id: query.id,
|
||||
};
|
||||
};
|
||||
|
||||
const deletePlaylist = async (args: DeletePlaylistArgs): Promise<null> => {
|
||||
const { query, server } = args;
|
||||
|
||||
@@ -496,6 +544,26 @@ const getStreamUrl = (args: {
|
||||
);
|
||||
};
|
||||
|
||||
const getAlbumArtistCoverArtUrl = (args: {
|
||||
baseUrl: string;
|
||||
item: JFAlbumArtist;
|
||||
size: number;
|
||||
}) => {
|
||||
const size = args.size ? args.size : 300;
|
||||
|
||||
if (!args.item.ImageTags?.Primary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
`${args.baseUrl}/Items` +
|
||||
`/${args.item.Id}` +
|
||||
'/Images/Primary' +
|
||||
`?width=${size}&height=${size}` +
|
||||
'&quality=96'
|
||||
);
|
||||
};
|
||||
|
||||
const getAlbumCoverArtUrl = (args: { baseUrl: string; item: JFAlbum; size: number }) => {
|
||||
const size = args.size ? args.size : 300;
|
||||
|
||||
@@ -543,6 +611,22 @@ const getSongCoverArtUrl = (args: { baseUrl: string; item: JFSong; size: number
|
||||
);
|
||||
};
|
||||
|
||||
const getPlaylistCoverArtUrl = (args: { baseUrl: string; item: JFPlaylist; size: number }) => {
|
||||
const size = args.size ? args.size : 300;
|
||||
|
||||
if (!args.item.ImageTags?.Primary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
`${args.baseUrl}/Items` +
|
||||
`/${args.item.Id}` +
|
||||
'/Images/Primary' +
|
||||
`?width=${size}&height=${size}` +
|
||||
'&quality=96'
|
||||
);
|
||||
};
|
||||
|
||||
const normalizeSong = (
|
||||
item: JFSong,
|
||||
server: ServerListItem,
|
||||
@@ -565,6 +649,7 @@ const normalizeSong = (
|
||||
duration: item.RunTimeTicks / 10000000,
|
||||
genres: item.GenreItems.map((entry: any) => ({ id: entry.Id, name: entry.Name })),
|
||||
id: item.Id,
|
||||
imagePlaceholderUrl: null,
|
||||
imageUrl: getSongCoverArtUrl({ baseUrl: server.url, item, size: imageSize || 300 }),
|
||||
isFavorite: (item.UserData && item.UserData.IsFavorite) || false,
|
||||
lastPlayedAt: null,
|
||||
@@ -602,7 +687,7 @@ const normalizeAlbum = (item: JFAlbum, server: ServerListItem, imageSize?: numbe
|
||||
artists: item.ArtistItems?.map((entry) => ({ id: entry.Id, name: entry.Name })),
|
||||
backdropImageUrl: null,
|
||||
createdAt: item.DateCreated,
|
||||
duration: item.RunTimeTicks / 10000000,
|
||||
duration: item.RunTimeTicks / 10000,
|
||||
genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })),
|
||||
id: item.Id,
|
||||
imagePlaceholderUrl: null,
|
||||
@@ -628,6 +713,62 @@ const normalizeAlbum = (item: JFAlbum, server: ServerListItem, imageSize?: numbe
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeAlbumArtist = (
|
||||
item: JFAlbumArtist,
|
||||
server: ServerListItem,
|
||||
imageSize?: number,
|
||||
): AlbumArtist => {
|
||||
return {
|
||||
albumCount: null,
|
||||
backgroundImageUrl: null,
|
||||
biography: item.Overview || null,
|
||||
duration: item.RunTimeTicks / 10000,
|
||||
genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })),
|
||||
id: item.Id,
|
||||
imageUrl: getAlbumArtistCoverArtUrl({
|
||||
baseUrl: server.url,
|
||||
item,
|
||||
size: imageSize || 300,
|
||||
}),
|
||||
isFavorite: item.UserData.IsFavorite || false,
|
||||
lastPlayedAt: null,
|
||||
name: item.Name,
|
||||
playCount: item.UserData.PlayCount,
|
||||
rating: null,
|
||||
songCount: null,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizePlaylist = (
|
||||
item: JFPlaylist,
|
||||
server: ServerListItem,
|
||||
imageSize?: number,
|
||||
): Playlist => {
|
||||
const imageUrl = getPlaylistCoverArtUrl({
|
||||
baseUrl: server.url,
|
||||
item,
|
||||
size: imageSize || 300,
|
||||
});
|
||||
|
||||
const imagePlaceholderUrl = null;
|
||||
|
||||
return {
|
||||
description: item.Overview || null,
|
||||
duration: item.RunTimeTicks / 10000,
|
||||
genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })),
|
||||
id: item.Id,
|
||||
imagePlaceholderUrl,
|
||||
imageUrl: imageUrl || null,
|
||||
name: item.Name,
|
||||
public: null,
|
||||
rules: null,
|
||||
size: null,
|
||||
songCount: item?.ChildCount || null,
|
||||
userId: null,
|
||||
username: null,
|
||||
};
|
||||
};
|
||||
|
||||
// const normalizeArtist = (item: any) => {
|
||||
// return {
|
||||
// album: (item.album || []).map((entry: any) => normalizeAlbum(entry)),
|
||||
@@ -648,24 +789,6 @@ const normalizeAlbum = (item: JFAlbum, server: ServerListItem, imageSize?: numbe
|
||||
// };
|
||||
// };
|
||||
|
||||
// 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) => {
|
||||
// return {
|
||||
// albumCount: undefined,
|
||||
@@ -713,9 +836,12 @@ export const jellyfinApi = {
|
||||
getPlaylistList,
|
||||
getPlaylistSongList,
|
||||
getSongList,
|
||||
updatePlaylist,
|
||||
};
|
||||
|
||||
export const jfNormalize = {
|
||||
album: normalizeAlbum,
|
||||
albumArtist: normalizeAlbumArtist,
|
||||
playlist: normalizePlaylist,
|
||||
song: normalizeSong,
|
||||
};
|
||||
|
||||
@@ -23,7 +23,11 @@ export interface JFAlbumArtistListResponse extends JFBasePaginatedResponse {
|
||||
Items: JFAlbumArtist[];
|
||||
}
|
||||
|
||||
export type JFAlbumArtistList = JFAlbumArtistListResponse;
|
||||
export type JFAlbumArtistList = {
|
||||
items: JFAlbumArtist[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number;
|
||||
};
|
||||
|
||||
export interface JFArtistListResponse extends JFBasePaginatedResponse {
|
||||
Items: JFAlbumArtist[];
|
||||
@@ -59,7 +63,19 @@ export interface JFPlaylistListResponse extends JFBasePaginatedResponse {
|
||||
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;
|
||||
|
||||
@@ -79,6 +95,7 @@ export type JFPlaylist = {
|
||||
LocationType: string;
|
||||
MediaType: string;
|
||||
Name: string;
|
||||
Overview?: string;
|
||||
RunTimeTicks: number;
|
||||
ServerId: string;
|
||||
Type: string;
|
||||
@@ -149,6 +166,13 @@ export type JFAlbumArtist = {
|
||||
RunTimeTicks: number;
|
||||
ServerId: string;
|
||||
Type: string;
|
||||
UserData: {
|
||||
IsFavorite: boolean;
|
||||
Key: string;
|
||||
PlayCount: number;
|
||||
PlaybackPositionTicks: number;
|
||||
Played: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type JFArtist = {
|
||||
@@ -474,6 +498,8 @@ type JFBaseParams = {
|
||||
imageTypeLimit?: number;
|
||||
parentId?: string;
|
||||
recursive?: boolean;
|
||||
searchTerm?: string;
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
type JFPaginationParams = {
|
||||
|
||||
@@ -30,6 +30,13 @@ import type {
|
||||
NDPlaylistDetailResponse,
|
||||
NDSongList,
|
||||
NDSongListResponse,
|
||||
NDAlbumArtist,
|
||||
NDPlaylist,
|
||||
NDUpdatePlaylistParams,
|
||||
NDUpdatePlaylistResponse,
|
||||
NDPlaylistSongListResponse,
|
||||
NDPlaylistSongList,
|
||||
NDPlaylistSong,
|
||||
} from '/@/renderer/api/navidrome.types';
|
||||
import { NDPlaylistListSort, NDSongListSort, NDSortOrder } from '/@/renderer/api/navidrome.types';
|
||||
import type {
|
||||
@@ -49,6 +56,10 @@ import type {
|
||||
PlaylistDetailArgs,
|
||||
CreatePlaylistResponse,
|
||||
PlaylistSongListArgs,
|
||||
AlbumArtist,
|
||||
Playlist,
|
||||
UpdatePlaylistResponse,
|
||||
UpdatePlaylistArgs,
|
||||
} from '/@/renderer/api/types';
|
||||
import {
|
||||
playlistListSortMap,
|
||||
@@ -157,18 +168,25 @@ const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<NDAlbumArt
|
||||
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||
_sort: albumArtistListSortMap.navidrome[query.sortBy],
|
||||
_start: query.startIndex,
|
||||
name: query.searchTerm,
|
||||
...query.ndParams,
|
||||
};
|
||||
|
||||
const data = await api
|
||||
.get('api/artist', {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
searchParams,
|
||||
signal,
|
||||
})
|
||||
.json<NDArtistListResponse>();
|
||||
const res = await api.get('api/artist', {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
prefixUrl: server?.url,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
});
|
||||
|
||||
return data;
|
||||
const data = await res.json<NDArtistListResponse>();
|
||||
const itemCount = res.headers.get('x-total-count');
|
||||
|
||||
return {
|
||||
items: data,
|
||||
startIndex: query.startIndex,
|
||||
totalRecordCount: Number(itemCount),
|
||||
};
|
||||
};
|
||||
|
||||
const getAlbumDetail = async (args: AlbumDetailArgs): Promise<NDAlbumDetail> => {
|
||||
@@ -233,11 +251,12 @@ const getSongList = async (args: SongListArgs): Promise<NDSongList> => {
|
||||
const { query, server, signal } = args;
|
||||
|
||||
const searchParams: NDSongListParams = {
|
||||
_end: query.startIndex + (query.limit || 0),
|
||||
_end: query.startIndex + (query.limit || -1),
|
||||
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||
_sort: songListSortMap.navidrome[query.sortBy],
|
||||
_start: query.startIndex,
|
||||
album_id: query.albumIds,
|
||||
artist_id: query.artistIds,
|
||||
title: query.searchTerm,
|
||||
...query.ndParams,
|
||||
};
|
||||
@@ -274,7 +293,7 @@ const getSongDetail = async (args: SongDetailArgs): Promise<NDSongDetail> => {
|
||||
};
|
||||
|
||||
const createPlaylist = async (args: CreatePlaylistArgs): Promise<CreatePlaylistResponse> => {
|
||||
const { query, server, signal } = args;
|
||||
const { query, server } = args;
|
||||
|
||||
const json: NDCreatePlaylistParams = {
|
||||
comment: query.comment,
|
||||
@@ -287,7 +306,6 @@ const createPlaylist = async (args: CreatePlaylistArgs): Promise<CreatePlaylistR
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
json,
|
||||
prefixUrl: server?.url,
|
||||
signal,
|
||||
})
|
||||
.json<NDCreatePlaylistResponse>();
|
||||
|
||||
@@ -297,6 +315,29 @@ const createPlaylist = async (args: CreatePlaylistArgs): Promise<CreatePlaylistR
|
||||
};
|
||||
};
|
||||
|
||||
const updatePlaylist = async (args: UpdatePlaylistArgs): Promise<UpdatePlaylistResponse> => {
|
||||
const { query, body, server, signal } = args;
|
||||
|
||||
const json: NDUpdatePlaylistParams = {
|
||||
comment: body.comment || '',
|
||||
name: body.name,
|
||||
public: body.public || false,
|
||||
};
|
||||
|
||||
const data = await api
|
||||
.put(`api/playlist/${query.id}`, {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
json,
|
||||
prefixUrl: server?.url,
|
||||
signal,
|
||||
})
|
||||
.json<NDUpdatePlaylistResponse>();
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
};
|
||||
};
|
||||
|
||||
const deletePlaylist = async (args: DeletePlaylistArgs): Promise<NDDeletePlaylist> => {
|
||||
const { query, server, signal } = args;
|
||||
|
||||
@@ -319,12 +360,13 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise<NDPlaylistList>
|
||||
_order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : NDSortOrder.ASC,
|
||||
_sort: query.sortBy ? playlistListSortMap.navidrome[query.sortBy] : NDPlaylistListSort.NAME,
|
||||
_start: query.startIndex,
|
||||
...query.ndParams,
|
||||
};
|
||||
|
||||
const res = await api.get('api/playlist', {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
prefixUrl: server?.url,
|
||||
searchParams,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
});
|
||||
|
||||
@@ -352,7 +394,7 @@ const getPlaylistDetail = async (args: PlaylistDetailArgs): Promise<NDPlaylistDe
|
||||
return data;
|
||||
};
|
||||
|
||||
const getPlaylistSongList = async (args: PlaylistSongListArgs): Promise<NDSongList> => {
|
||||
const getPlaylistSongList = async (args: PlaylistSongListArgs): Promise<NDPlaylistSongList> => {
|
||||
const { query, server, signal } = args;
|
||||
|
||||
const searchParams: NDSongListParams & { playlist_id: string } = {
|
||||
@@ -363,19 +405,20 @@ const getPlaylistSongList = async (args: PlaylistSongListArgs): Promise<NDSongLi
|
||||
playlist_id: query.id,
|
||||
};
|
||||
|
||||
const data = await api
|
||||
.get(`api/playlist/${query.id}/tracks`, {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
prefixUrl: server?.url,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
})
|
||||
.json<NDSongListResponse>();
|
||||
const res = await api.get(`api/playlist/${query.id}/tracks`, {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
prefixUrl: server?.url,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
});
|
||||
|
||||
const data = await res.json<NDPlaylistSongListResponse>();
|
||||
const itemCount = res.headers.get('x-total-count');
|
||||
|
||||
return {
|
||||
items: data,
|
||||
startIndex: query?.startIndex || 0,
|
||||
totalRecordCount: data.length,
|
||||
totalRecordCount: Number(itemCount),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -402,18 +445,29 @@ const getCoverArtUrl = (args: {
|
||||
};
|
||||
|
||||
const normalizeSong = (
|
||||
item: NDSong,
|
||||
item: NDSong | NDPlaylistSong,
|
||||
server: ServerListItem,
|
||||
deviceId: string,
|
||||
imageSize?: number,
|
||||
): Song => {
|
||||
let id;
|
||||
|
||||
// Dynamically determine the id field based on whether or not the item is a playlist song
|
||||
if ('mediaFileId' in item) {
|
||||
id = item.mediaFileId;
|
||||
} else {
|
||||
id = item.id;
|
||||
}
|
||||
|
||||
const imageUrl = getCoverArtUrl({
|
||||
baseUrl: server.url,
|
||||
coverArtId: item.albumId,
|
||||
coverArtId: id,
|
||||
credential: server.credential,
|
||||
size: imageSize || 300,
|
||||
});
|
||||
|
||||
const imagePlaceholderUrl = null;
|
||||
|
||||
return {
|
||||
album: item.album,
|
||||
albumArtists: [{ id: item.artistId, name: item.artist }],
|
||||
@@ -429,7 +483,8 @@ const normalizeSong = (
|
||||
discNumber: item.discNumber,
|
||||
duration: item.duration,
|
||||
genres: item.genres,
|
||||
id: item.id,
|
||||
id,
|
||||
imagePlaceholderUrl,
|
||||
imageUrl,
|
||||
isFavorite: item.starred,
|
||||
lastPlayedAt: item.playDate ? item.playDate : null,
|
||||
@@ -441,7 +496,7 @@ const normalizeSong = (
|
||||
releaseYear: String(item.year),
|
||||
serverId: server.id,
|
||||
size: item.size,
|
||||
streamUrl: `${server.url}/rest/stream.view?id=${item.id}&v=1.13.0&c=feishin_${deviceId}&${server.credential}`,
|
||||
streamUrl: `${server.url}/rest/stream.view?id=${id}&v=1.13.0&c=feishin_${deviceId}&${server.credential}`,
|
||||
trackNumber: item.trackNumber,
|
||||
type: ServerType.NAVIDROME,
|
||||
uniqueId: nanoid(),
|
||||
@@ -452,12 +507,13 @@ const normalizeSong = (
|
||||
const normalizeAlbum = (item: NDAlbum, server: ServerListItem, imageSize?: number): Album => {
|
||||
const imageUrl = getCoverArtUrl({
|
||||
baseUrl: server.url,
|
||||
coverArtId: item.coverArtId,
|
||||
coverArtId: item.coverArtId || item.id,
|
||||
credential: server.credential,
|
||||
size: imageSize || 300,
|
||||
});
|
||||
|
||||
const imagePlaceholderUrl = imageUrl?.replace(/size=\d+/, 'size=50') || null;
|
||||
const imagePlaceholderUrl = null;
|
||||
|
||||
const imageBackdropUrl = imageUrl?.replace(/size=\d+/, 'size=1000') || null;
|
||||
|
||||
return {
|
||||
@@ -465,7 +521,7 @@ const normalizeAlbum = (item: NDAlbum, server: ServerListItem, imageSize?: numbe
|
||||
artists: [{ id: item.artistId, name: item.artist }],
|
||||
backdropImageUrl: imageBackdropUrl,
|
||||
createdAt: item.createdAt.split('T')[0],
|
||||
duration: item.duration || null,
|
||||
duration: item.duration * 1000 || null,
|
||||
genres: item.genres,
|
||||
id: item.id,
|
||||
imagePlaceholderUrl,
|
||||
@@ -487,6 +543,55 @@ const normalizeAlbum = (item: NDAlbum, server: ServerListItem, imageSize?: numbe
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeAlbumArtist = (item: NDAlbumArtist): AlbumArtist => {
|
||||
return {
|
||||
albumCount: item.albumCount,
|
||||
backgroundImageUrl: null,
|
||||
biography: item.biography,
|
||||
duration: null,
|
||||
genres: item.genres,
|
||||
id: item.id,
|
||||
imageUrl: item.largeImageUrl,
|
||||
isFavorite: item.starred,
|
||||
lastPlayedAt: item.playDate ? item.playDate.split('T')[0] : null,
|
||||
name: item.name,
|
||||
playCount: item.playCount,
|
||||
rating: item.rating,
|
||||
songCount: item.songCount,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizePlaylist = (
|
||||
item: NDPlaylist,
|
||||
server: ServerListItem,
|
||||
imageSize?: number,
|
||||
): Playlist => {
|
||||
const imageUrl = getCoverArtUrl({
|
||||
baseUrl: server.url,
|
||||
coverArtId: item.id,
|
||||
credential: server.credential,
|
||||
size: imageSize || 300,
|
||||
});
|
||||
|
||||
const imagePlaceholderUrl = null;
|
||||
|
||||
return {
|
||||
description: item.comment,
|
||||
duration: item.duration * 1000,
|
||||
genres: [],
|
||||
id: item.id,
|
||||
imagePlaceholderUrl,
|
||||
imageUrl,
|
||||
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 = {
|
||||
authenticate,
|
||||
createPlaylist,
|
||||
@@ -501,9 +606,12 @@ export const navidromeApi = {
|
||||
getPlaylistSongList,
|
||||
getSongDetail,
|
||||
getSongList,
|
||||
updatePlaylist,
|
||||
};
|
||||
|
||||
export const ndNormalize = {
|
||||
album: normalizeAlbum,
|
||||
albumArtist: normalizeAlbumArtist,
|
||||
playlist: normalizePlaylist,
|
||||
song: normalizeSong,
|
||||
};
|
||||
|
||||
@@ -20,8 +20,8 @@ export type NDAlbum = {
|
||||
artist: string;
|
||||
artistId: string;
|
||||
compilation: boolean;
|
||||
coverArtId: string;
|
||||
coverArtPath: string;
|
||||
coverArtId?: string; // Removed after v0.48.0
|
||||
coverArtPath?: string; // Removed after v0.48.0
|
||||
createdAt: string;
|
||||
duration: number;
|
||||
fullText: string;
|
||||
@@ -118,7 +118,11 @@ export type NDAlbumArtist = {
|
||||
|
||||
export type NDAuthenticationResponse = NDAuthenticate;
|
||||
|
||||
export type NDAlbumArtistList = NDAlbumArtist[];
|
||||
export type NDAlbumArtistList = {
|
||||
items: NDAlbumArtist[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number;
|
||||
};
|
||||
|
||||
export type NDAlbumArtistDetail = NDAlbumArtist;
|
||||
|
||||
@@ -211,6 +215,7 @@ export type NDAlbumListParams = {
|
||||
export enum NDSongListSort {
|
||||
ALBUM = 'album, order_album_artist_name, disc_number, track_number, title',
|
||||
ALBUM_ARTIST = 'albumArtist',
|
||||
ALBUM_SONGS = 'discNumber, trackNumber',
|
||||
ARTIST = 'artist',
|
||||
BPM = 'bpm',
|
||||
CHANNELS = 'channels',
|
||||
@@ -230,6 +235,7 @@ export enum NDSongListSort {
|
||||
export type NDSongListParams = {
|
||||
_sort?: NDSongListSort;
|
||||
album_id?: string[];
|
||||
artist_id?: string[];
|
||||
genre_id?: string;
|
||||
starred?: boolean;
|
||||
} & NDPagination &
|
||||
@@ -254,7 +260,8 @@ export type NDAlbumArtistListParams = {
|
||||
export type NDCreatePlaylistParams = {
|
||||
comment?: string;
|
||||
name: string;
|
||||
public: boolean;
|
||||
public?: boolean;
|
||||
rules?: Record<string, any> | null;
|
||||
};
|
||||
|
||||
export type NDCreatePlaylistResponse = {
|
||||
@@ -263,6 +270,10 @@ export type NDCreatePlaylistResponse = {
|
||||
|
||||
export type NDCreatePlaylist = NDCreatePlaylistResponse;
|
||||
|
||||
export type NDUpdatePlaylistParams = Partial<NDPlaylist>;
|
||||
|
||||
export type NDUpdatePlaylistResponse = NDPlaylist;
|
||||
|
||||
export type NDDeletePlaylistParams = {
|
||||
id: string;
|
||||
};
|
||||
@@ -282,7 +293,7 @@ export type NDPlaylist = {
|
||||
ownerName: string;
|
||||
path: string;
|
||||
public: boolean;
|
||||
rules: null;
|
||||
rules: Record<string, any> | null;
|
||||
size: number;
|
||||
songCount: number;
|
||||
sync: boolean;
|
||||
@@ -304,7 +315,7 @@ export type NDPlaylistListResponse = NDPlaylist[];
|
||||
export enum NDPlaylistListSort {
|
||||
DURATION = 'duration',
|
||||
NAME = 'name',
|
||||
OWNER = 'owner',
|
||||
OWNER = 'ownerName',
|
||||
PUBLIC = 'public',
|
||||
SONG_COUNT = 'songCount',
|
||||
UPDATED_AT = 'updatedAt',
|
||||
@@ -315,3 +326,16 @@ export type NDPlaylistListParams = {
|
||||
owner_id?: string;
|
||||
} & NDPagination &
|
||||
NDOrder;
|
||||
|
||||
export type NDPlaylistSong = NDSong & {
|
||||
mediaFileId: string;
|
||||
playlistId: string;
|
||||
};
|
||||
|
||||
export type NDPlaylistSongListResponse = NDPlaylistSong[];
|
||||
|
||||
export type NDPlaylistSongList = {
|
||||
items: NDPlaylistSong[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number;
|
||||
};
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
import { jfNormalize } from '/@/renderer/api/jellyfin.api';
|
||||
import type {
|
||||
JFAlbum,
|
||||
JFAlbumArtist,
|
||||
JFGenreList,
|
||||
JFMusicFolderList,
|
||||
JFPlaylist,
|
||||
JFSong,
|
||||
} from '/@/renderer/api/jellyfin.types';
|
||||
import { ndNormalize } from '/@/renderer/api/navidrome.api';
|
||||
import type { NDAlbum, 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 type {
|
||||
Album,
|
||||
RawAlbumArtistListResponse,
|
||||
RawAlbumDetailResponse,
|
||||
RawAlbumListResponse,
|
||||
RawGenreListResponse,
|
||||
RawMusicFolderListResponse,
|
||||
RawPlaylistDetailResponse,
|
||||
RawPlaylistListResponse,
|
||||
RawSongListResponse,
|
||||
} from '/@/renderer/api/types';
|
||||
import { ServerListItem } from '/@/renderer/types';
|
||||
@@ -136,10 +147,77 @@ const genreList = (data: RawGenreListResponse | undefined, server: ServerListIte
|
||||
return genres;
|
||||
};
|
||||
|
||||
const albumArtistList = (
|
||||
data: RawAlbumArtistListResponse | undefined,
|
||||
server: ServerListItem | null,
|
||||
) => {
|
||||
let albumArtists;
|
||||
switch (server?.type) {
|
||||
case 'jellyfin':
|
||||
albumArtists = data?.items.map((item) =>
|
||||
jfNormalize.albumArtist(item as JFAlbumArtist, server),
|
||||
);
|
||||
break;
|
||||
case 'navidrome':
|
||||
albumArtists = data?.items.map((item) => ndNormalize.albumArtist(item as NDAlbumArtist));
|
||||
break;
|
||||
case 'subsonic':
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
items: albumArtists,
|
||||
startIndex: data?.startIndex,
|
||||
totalRecordCount: data?.totalRecordCount,
|
||||
};
|
||||
};
|
||||
|
||||
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, server));
|
||||
break;
|
||||
case 'navidrome':
|
||||
playlists = data?.items.map((item) => ndNormalize.playlist(item as NDPlaylist, server));
|
||||
break;
|
||||
case 'subsonic':
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
items: playlists,
|
||||
startIndex: data?.startIndex,
|
||||
totalRecordCount: data?.totalRecordCount,
|
||||
};
|
||||
};
|
||||
|
||||
const playlistDetail = (
|
||||
data: RawPlaylistDetailResponse | undefined,
|
||||
server: ServerListItem | null,
|
||||
) => {
|
||||
let playlist;
|
||||
switch (server?.type) {
|
||||
case 'jellyfin':
|
||||
playlist = jfNormalize.playlist(data as JFPlaylist, server);
|
||||
break;
|
||||
case 'navidrome':
|
||||
playlist = ndNormalize.playlist(data as NDPlaylist, server);
|
||||
break;
|
||||
case 'subsonic':
|
||||
break;
|
||||
}
|
||||
|
||||
return playlist;
|
||||
};
|
||||
|
||||
export const normalize = {
|
||||
albumArtistList,
|
||||
albumDetail,
|
||||
albumList,
|
||||
genreList,
|
||||
musicFolderList,
|
||||
playlistDetail,
|
||||
playlistList,
|
||||
songList,
|
||||
};
|
||||
|
||||
@@ -1,15 +1,45 @@
|
||||
import type { AlbumListQuery, SongListQuery, AlbumDetailQuery } from './types';
|
||||
import type {
|
||||
AlbumListQuery,
|
||||
SongListQuery,
|
||||
AlbumDetailQuery,
|
||||
AlbumArtistListQuery,
|
||||
ArtistListQuery,
|
||||
PlaylistListQuery,
|
||||
PlaylistDetailQuery,
|
||||
PlaylistSongListQuery,
|
||||
} from './types';
|
||||
|
||||
export const queryKeys = {
|
||||
albumArtists: {
|
||||
detail: (serverId: string, query?: AlbumArtistListQuery) => {
|
||||
if (query) return [serverId, 'albumArtists', 'detail', query] as const;
|
||||
return [serverId, 'albumArtists', 'detail'] as const;
|
||||
},
|
||||
list: (serverId: string, query?: AlbumArtistListQuery) => {
|
||||
if (query) return [serverId, 'albumArtists', 'list', query] as const;
|
||||
return [serverId, 'albumArtists', 'list'] as const;
|
||||
},
|
||||
root: (serverId: string) => [serverId, 'albumArtists'] as const,
|
||||
},
|
||||
albums: {
|
||||
detail: (serverId: string, query: AlbumDetailQuery) =>
|
||||
detail: (serverId: string, query?: AlbumDetailQuery) =>
|
||||
[serverId, 'albums', 'detail', query] as const,
|
||||
list: (serverId: string, query: AlbumListQuery) => [serverId, 'albums', 'list', query] as const,
|
||||
root: ['albums'],
|
||||
list: (serverId: string, query?: AlbumListQuery) => {
|
||||
if (query) return [serverId, 'albums', 'list', query] as const;
|
||||
return [serverId, 'albums', 'list'] as const;
|
||||
},
|
||||
root: (serverId: string) => [serverId, 'albums'],
|
||||
serverRoot: (serverId: string) => [serverId, 'albums'],
|
||||
songs: (serverId: string, query: SongListQuery) =>
|
||||
[serverId, 'albums', 'songs', query] as const,
|
||||
},
|
||||
artists: {
|
||||
list: (serverId: string, query?: ArtistListQuery) => {
|
||||
if (query) return [serverId, 'artists', 'list', query] as const;
|
||||
return [serverId, 'artists', 'list'] as const;
|
||||
},
|
||||
root: (serverId: string) => [serverId, 'artists'] as const,
|
||||
},
|
||||
genres: {
|
||||
list: (serverId: string) => [serverId, 'genres', 'list'] as const,
|
||||
root: (serverId: string) => [serverId, 'genres'] as const,
|
||||
@@ -17,10 +47,36 @@ export const queryKeys = {
|
||||
musicFolders: {
|
||||
list: (serverId: string) => [serverId, 'musicFolders', 'list'] as const,
|
||||
},
|
||||
playlists: {
|
||||
detail: (serverId: string, id?: string, query?: PlaylistDetailQuery) => {
|
||||
if (query) return [serverId, 'playlists', id, 'detail', query] as const;
|
||||
if (id) return [serverId, 'playlists', id, 'detail'] as const;
|
||||
return [serverId, 'playlists', 'detail'] as const;
|
||||
},
|
||||
detailSongList: (serverId: string, id: string, query?: PlaylistSongListQuery) => {
|
||||
if (query) return [serverId, 'playlists', id, 'detailSongList', query] as const;
|
||||
if (id) return [serverId, 'playlists', id, 'detailSongList'] as const;
|
||||
return [serverId, 'playlists', 'detailSongList'] as const;
|
||||
},
|
||||
list: (serverId: string, query?: PlaylistListQuery) => {
|
||||
if (query) return [serverId, 'playlists', 'list', query] as const;
|
||||
return [serverId, 'playlists', 'list'] as const;
|
||||
},
|
||||
root: (serverId: string) => [serverId, 'playlists'] as const,
|
||||
songList: (serverId: string, id: string, query?: PlaylistSongListQuery) => {
|
||||
if (query) return [serverId, 'playlists', id, 'songList', query] as const;
|
||||
if (id) return [serverId, 'playlists', id, 'songList'] as const;
|
||||
return [serverId, 'playlists', 'songList'] as const;
|
||||
},
|
||||
},
|
||||
server: {
|
||||
root: (serverId: string) => [serverId] as const,
|
||||
},
|
||||
songs: {
|
||||
list: (serverId: string, query: SongListQuery) => [serverId, 'songs', 'list', query] as const,
|
||||
list: (serverId: string, query?: SongListQuery) => {
|
||||
if (query) return [serverId, 'songs', 'list', query] as const;
|
||||
return [serverId, 'songs', 'list'] as const;
|
||||
},
|
||||
root: (serverId: string) => [serverId, 'songs'] as const,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -177,7 +177,11 @@ const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<SSAlbumArt
|
||||
|
||||
const artists = (data.artists?.index || []).flatMap((index: SSArtistIndex) => index.artist);
|
||||
|
||||
return artists;
|
||||
return {
|
||||
items: artists,
|
||||
startIndex: query.startIndex,
|
||||
totalRecordCount: null,
|
||||
};
|
||||
};
|
||||
|
||||
const getGenreList = async (args: GenreListArgs): Promise<SSGenreList> => {
|
||||
|
||||
@@ -33,7 +33,11 @@ export type SSAlbumArtistDetailResponse = {
|
||||
};
|
||||
};
|
||||
|
||||
export type SSAlbumArtistList = SSAlbumArtistListEntry[];
|
||||
export type SSAlbumArtistList = {
|
||||
items: SSAlbumArtistListEntry[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number | null;
|
||||
};
|
||||
|
||||
export type SSAlbumArtistListResponse = {
|
||||
artists: {
|
||||
|
||||
+77
-30
@@ -14,6 +14,7 @@ import {
|
||||
JFPlaylistList,
|
||||
JFPlaylistDetail,
|
||||
JFMusicFolderList,
|
||||
JFPlaylistListSort,
|
||||
} from '/@/renderer/api/jellyfin.types';
|
||||
import {
|
||||
NDSortOrder,
|
||||
@@ -23,7 +24,6 @@ import {
|
||||
NDAlbumListSort,
|
||||
NDAlbumDetail,
|
||||
NDSongList,
|
||||
NDSongListSort,
|
||||
NDSongDetail,
|
||||
NDAlbumArtistList,
|
||||
NDAlbumArtistListSort,
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
NDPlaylistList,
|
||||
NDPlaylistListSort,
|
||||
NDPlaylistDetail,
|
||||
NDSongListSort,
|
||||
} from '/@/renderer/api/navidrome.types';
|
||||
import {
|
||||
SSAlbumList,
|
||||
@@ -119,16 +120,6 @@ export interface BasePaginatedResponse<T> {
|
||||
totalRecordCount: number;
|
||||
}
|
||||
|
||||
export type ApiError = {
|
||||
error: {
|
||||
message: string;
|
||||
path: string;
|
||||
trace: string[];
|
||||
};
|
||||
response: string;
|
||||
statusCode: number;
|
||||
};
|
||||
|
||||
export type AuthenticationResponse = {
|
||||
credential: string;
|
||||
ndCredential?: string;
|
||||
@@ -183,6 +174,7 @@ export type Song = {
|
||||
duration: number;
|
||||
genres: Genre[];
|
||||
id: string;
|
||||
imagePlaceholderUrl: string | null;
|
||||
imageUrl: string | null;
|
||||
isFavorite: boolean;
|
||||
lastPlayedAt: string | null;
|
||||
@@ -202,13 +194,19 @@ export type Song = {
|
||||
};
|
||||
|
||||
export type AlbumArtist = {
|
||||
albumCount: number | null;
|
||||
backgroundImageUrl: string | null;
|
||||
biography: string | null;
|
||||
createdAt: string;
|
||||
duration: number | null;
|
||||
genres: Genre[];
|
||||
id: string;
|
||||
imageUrl: string | null;
|
||||
isFavorite: boolean;
|
||||
lastPlayedAt: string | null;
|
||||
name: string;
|
||||
remoteCreatedAt: string | null;
|
||||
serverFolderId: string;
|
||||
updatedAt: string;
|
||||
playCount: number | null;
|
||||
rating: number | null;
|
||||
songCount: number | null;
|
||||
};
|
||||
|
||||
export type RelatedAlbumArtist = {
|
||||
@@ -237,14 +235,19 @@ export type MusicFolder = {
|
||||
};
|
||||
|
||||
export type Playlist = {
|
||||
duration?: number;
|
||||
description: string | null;
|
||||
duration: number | null;
|
||||
genres: Genre[];
|
||||
id: string;
|
||||
imagePlaceholderUrl: string | null;
|
||||
imageUrl: string | null;
|
||||
name: string;
|
||||
public?: boolean;
|
||||
size?: number;
|
||||
songCount?: number;
|
||||
userId: string;
|
||||
username: string;
|
||||
public: boolean | null;
|
||||
rules?: Record<string, any> | null;
|
||||
size: number | null;
|
||||
songCount: number | null;
|
||||
userId: string | null;
|
||||
username: string | null;
|
||||
};
|
||||
|
||||
export type GenresResponse = Genre[];
|
||||
@@ -406,6 +409,7 @@ export enum SongListSort {
|
||||
DURATION = 'duration',
|
||||
FAVORITED = 'favorited',
|
||||
GENRE = 'genre',
|
||||
ID = 'id',
|
||||
NAME = 'name',
|
||||
PLAY_COUNT = 'playCount',
|
||||
RANDOM = 'random',
|
||||
@@ -418,6 +422,7 @@ export enum SongListSort {
|
||||
|
||||
export type SongListQuery = {
|
||||
albumIds?: string[];
|
||||
artistIds?: string[];
|
||||
jfParams?: {
|
||||
filters?: string;
|
||||
genreIds?: string;
|
||||
@@ -432,7 +437,8 @@ export type SongListQuery = {
|
||||
limit?: number;
|
||||
musicFolderId?: string;
|
||||
ndParams?: {
|
||||
artist_id?: string;
|
||||
album_id?: string[];
|
||||
artist_id?: string[];
|
||||
compilation?: boolean;
|
||||
genre_id?: string;
|
||||
has_rating?: boolean;
|
||||
@@ -465,6 +471,7 @@ export const songListSortMap: SongListSortMap = {
|
||||
duration: JFSongListSort.DURATION,
|
||||
favorited: undefined,
|
||||
genre: undefined,
|
||||
id: undefined,
|
||||
name: JFSongListSort.NAME,
|
||||
playCount: JFSongListSort.PLAY_COUNT,
|
||||
random: JFSongListSort.RANDOM,
|
||||
@@ -475,7 +482,7 @@ export const songListSortMap: SongListSortMap = {
|
||||
year: undefined,
|
||||
},
|
||||
navidrome: {
|
||||
album: NDSongListSort.ALBUM,
|
||||
album: NDSongListSort.ALBUM_SONGS,
|
||||
albumArtist: NDSongListSort.ALBUM_ARTIST,
|
||||
artist: NDSongListSort.ARTIST,
|
||||
bpm: NDSongListSort.BPM,
|
||||
@@ -484,6 +491,7 @@ export const songListSortMap: SongListSortMap = {
|
||||
duration: NDSongListSort.DURATION,
|
||||
favorited: NDSongListSort.FAVORITED,
|
||||
genre: NDSongListSort.GENRE,
|
||||
id: NDSongListSort.ID,
|
||||
name: NDSongListSort.TITLE,
|
||||
playCount: NDSongListSort.PLAY_COUNT,
|
||||
random: undefined,
|
||||
@@ -503,6 +511,7 @@ export const songListSortMap: SongListSortMap = {
|
||||
duration: undefined,
|
||||
favorited: undefined,
|
||||
genre: undefined,
|
||||
id: undefined,
|
||||
name: undefined,
|
||||
playCount: undefined,
|
||||
random: undefined,
|
||||
@@ -554,6 +563,7 @@ export type AlbumArtistListQuery = {
|
||||
name?: string;
|
||||
starred?: boolean;
|
||||
};
|
||||
searchTerm?: string;
|
||||
sortBy: AlbumArtistListSort;
|
||||
sortOrder: SortOrder;
|
||||
startIndex: number;
|
||||
@@ -729,10 +739,37 @@ export type RawCreatePlaylistResponse = CreatePlaylistResponse | undefined;
|
||||
|
||||
export type CreatePlaylistResponse = { id: string; name: string };
|
||||
|
||||
export type CreatePlaylistQuery = { comment?: string; name: string; public?: boolean };
|
||||
export type CreatePlaylistQuery = {
|
||||
comment?: string;
|
||||
name: string;
|
||||
public?: boolean;
|
||||
rules?: Record<string, any>;
|
||||
};
|
||||
|
||||
export type CreatePlaylistArgs = { query: CreatePlaylistQuery } & BaseEndpointArgs;
|
||||
|
||||
// Update Playlist
|
||||
export type RawUpdatePlaylistResponse = UpdatePlaylistResponse | undefined;
|
||||
|
||||
export type UpdatePlaylistResponse = { id: string };
|
||||
|
||||
export type UpdatePlaylistQuery = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type UpdatePlaylistBody = {
|
||||
comment?: string;
|
||||
genres?: Genre[];
|
||||
name: string;
|
||||
public?: boolean;
|
||||
rules?: Record<string, any>;
|
||||
};
|
||||
|
||||
export type UpdatePlaylistArgs = {
|
||||
body: UpdatePlaylistBody;
|
||||
query: UpdatePlaylistQuery;
|
||||
} & BaseEndpointArgs;
|
||||
|
||||
// Delete Playlist
|
||||
export type RawDeletePlaylistResponse = NDDeletePlaylist | undefined;
|
||||
|
||||
@@ -747,11 +784,21 @@ export type RawPlaylistListResponse = NDPlaylistList | JFPlaylistList | undefine
|
||||
|
||||
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 = {
|
||||
limit?: number;
|
||||
musicFolderId?: string;
|
||||
ndParams?: {
|
||||
owner_id?: string;
|
||||
};
|
||||
searchTerm?: string;
|
||||
sortBy: PlaylistListSort;
|
||||
sortOrder: SortOrder;
|
||||
startIndex: number;
|
||||
@@ -760,18 +807,18 @@ export type PlaylistListQuery = {
|
||||
export type PlaylistListArgs = { query: PlaylistListQuery } & BaseEndpointArgs;
|
||||
|
||||
type PlaylistListSortMap = {
|
||||
jellyfin: Record<PlaylistListSort, undefined>;
|
||||
jellyfin: Record<PlaylistListSort, JFPlaylistListSort | undefined>;
|
||||
navidrome: Record<PlaylistListSort, NDPlaylistListSort | undefined>;
|
||||
subsonic: Record<PlaylistListSort, undefined>;
|
||||
};
|
||||
|
||||
export const playlistListSortMap: PlaylistListSortMap = {
|
||||
jellyfin: {
|
||||
duration: undefined,
|
||||
name: undefined,
|
||||
duration: JFPlaylistListSort.DURATION,
|
||||
name: JFPlaylistListSort.NAME,
|
||||
owner: undefined,
|
||||
public: undefined,
|
||||
songCount: undefined,
|
||||
songCount: JFPlaylistListSort.SONG_COUNT,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
navidrome: {
|
||||
|
||||
+73
-71
@@ -5,16 +5,16 @@ import { InfiniteRowModelModule } from '@ag-grid-community/infinite-row-model';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import { ModalsProvider } from '@mantine/modals';
|
||||
import { NotificationsProvider } from '@mantine/notifications';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { initSimpleImg } from 'react-simple-img';
|
||||
import { BaseContextModal } from './components';
|
||||
import { useTheme } from './hooks';
|
||||
import { queryClient } from './lib/react-query';
|
||||
import { AppRouter } from './router/app-router';
|
||||
import { useSettingsStore } from './store/settings.store';
|
||||
import './styles/global.scss';
|
||||
import '@ag-grid-community/styles/ag-grid.css';
|
||||
import { ContextMenuProvider } from '/@/renderer/features/context-menu';
|
||||
import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-handle-playqueue-add';
|
||||
import { PlayQueueHandlerContext } from '/@/renderer/features/player';
|
||||
|
||||
ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule]);
|
||||
|
||||
@@ -24,87 +24,89 @@ export const App = () => {
|
||||
const theme = useTheme();
|
||||
const contentFont = useSettingsStore((state) => state.general.fontContent);
|
||||
|
||||
const handlePlayQueueAdd = useHandlePlayQueueAdd();
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--content-font-family', contentFont);
|
||||
}, [contentFont]);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MantineProvider
|
||||
withGlobalStyles
|
||||
withNormalizeCSS
|
||||
theme={{
|
||||
breakpoints: {
|
||||
lg: 1200,
|
||||
md: 1000,
|
||||
sm: 800,
|
||||
xl: 1400,
|
||||
xs: 500,
|
||||
},
|
||||
colorScheme: theme as 'light' | 'dark',
|
||||
components: { Modal: { styles: { body: { padding: '.5rem' } } } },
|
||||
defaultRadius: 'xs',
|
||||
dir: 'ltr',
|
||||
focusRing: 'auto',
|
||||
focusRingStyles: {
|
||||
inputStyles: () => ({
|
||||
border: '1px solid var(--primary-color)',
|
||||
}),
|
||||
resetStyles: () => ({ outline: 'none' }),
|
||||
styles: () => ({
|
||||
outline: '1px solid var(--primary-color)',
|
||||
outlineOffset: '-1px',
|
||||
}),
|
||||
},
|
||||
fontFamily: 'var(--content-font-family)',
|
||||
fontSizes: {
|
||||
lg: 16,
|
||||
md: 14,
|
||||
sm: 12,
|
||||
xl: 18,
|
||||
xs: 10,
|
||||
},
|
||||
headings: { fontFamily: 'var(--content-font-family)' },
|
||||
other: {},
|
||||
spacing: {
|
||||
lg: 12,
|
||||
md: 8,
|
||||
sm: 4,
|
||||
xl: 16,
|
||||
xs: 2,
|
||||
},
|
||||
<MantineProvider
|
||||
withGlobalStyles
|
||||
withNormalizeCSS
|
||||
theme={{
|
||||
breakpoints: {
|
||||
lg: 1200,
|
||||
md: 1000,
|
||||
sm: 800,
|
||||
xl: 1400,
|
||||
xs: 500,
|
||||
},
|
||||
colorScheme: theme as 'light' | 'dark',
|
||||
components: { Modal: { styles: { body: { padding: '.5rem' } } } },
|
||||
defaultRadius: 'xs',
|
||||
dir: 'ltr',
|
||||
focusRing: 'auto',
|
||||
focusRingStyles: {
|
||||
inputStyles: () => ({
|
||||
border: '1px solid var(--primary-color)',
|
||||
}),
|
||||
resetStyles: () => ({ outline: 'none' }),
|
||||
styles: () => ({
|
||||
outline: '1px solid var(--primary-color)',
|
||||
outlineOffset: '-1px',
|
||||
}),
|
||||
},
|
||||
fontFamily: 'var(--content-font-family)',
|
||||
fontSizes: {
|
||||
lg: 16,
|
||||
md: 14,
|
||||
sm: 12,
|
||||
xl: 18,
|
||||
xs: 10,
|
||||
},
|
||||
headings: { fontFamily: 'var(--content-font-family)' },
|
||||
other: {},
|
||||
spacing: {
|
||||
lg: 12,
|
||||
md: 8,
|
||||
sm: 4,
|
||||
xl: 16,
|
||||
xs: 2,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<NotificationsProvider
|
||||
autoClose={1500}
|
||||
position="bottom-center"
|
||||
style={{
|
||||
marginBottom: '85px',
|
||||
opacity: '.8',
|
||||
userSelect: 'none',
|
||||
width: '300px',
|
||||
}}
|
||||
transitionDuration={200}
|
||||
>
|
||||
<NotificationsProvider
|
||||
autoClose={1500}
|
||||
position="bottom-right"
|
||||
style={{
|
||||
marginBottom: '85px',
|
||||
opacity: '.8',
|
||||
userSelect: 'none',
|
||||
width: '250px',
|
||||
<ModalsProvider
|
||||
modalProps={{
|
||||
centered: true,
|
||||
exitTransitionDuration: 300,
|
||||
overflow: 'inside',
|
||||
overlayBlur: 0,
|
||||
overlayOpacity: 0.8,
|
||||
transition: 'slide-down',
|
||||
transitionDuration: 300,
|
||||
}}
|
||||
transitionDuration={200}
|
||||
modals={{ base: BaseContextModal }}
|
||||
>
|
||||
<ModalsProvider
|
||||
modalProps={{
|
||||
centered: true,
|
||||
exitTransitionDuration: 300,
|
||||
overflow: 'inside',
|
||||
overlayBlur: 0,
|
||||
overlayOpacity: 0.8,
|
||||
transition: 'slide-down',
|
||||
transitionDuration: 300,
|
||||
}}
|
||||
modals={{ base: BaseContextModal }}
|
||||
>
|
||||
<PlayQueueHandlerContext.Provider value={{ handlePlayQueueAdd }}>
|
||||
<ContextMenuProvider>
|
||||
<AppRouter />
|
||||
</ContextMenuProvider>
|
||||
</ModalsProvider>
|
||||
</NotificationsProvider>
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>
|
||||
</PlayQueueHandlerContext.Provider>
|
||||
</ModalsProvider>
|
||||
</NotificationsProvider>
|
||||
</MantineProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -109,7 +109,7 @@ interface BaseGridCardProps {
|
||||
route: CardRoute;
|
||||
};
|
||||
data: any;
|
||||
handlePlayQueueAdd: (options: PlayQueueAddOptions) => void;
|
||||
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
|
||||
loading?: boolean;
|
||||
size: number;
|
||||
}
|
||||
@@ -151,7 +151,7 @@ export const AlbumCard = ({
|
||||
animationDuration={0.3}
|
||||
height={size}
|
||||
imgStyle={{ objectFit: 'cover' }}
|
||||
placeholder="var(--card-default-bg)"
|
||||
placeholder={data?.imagePlaceholderUrl || 'var(--card-default-bg)'}
|
||||
src={data?.imageUrl}
|
||||
width={size}
|
||||
/>
|
||||
|
||||
@@ -118,7 +118,7 @@ export const CardControls = ({
|
||||
itemType,
|
||||
handlePlayQueueAdd,
|
||||
}: {
|
||||
handlePlayQueueAdd: (options: PlayQueueAddOptions) => void;
|
||||
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
|
||||
itemData: any;
|
||||
itemType: LibraryItem;
|
||||
}) => {
|
||||
@@ -127,9 +127,9 @@ export const CardControls = ({
|
||||
const handlePlay = (e: MouseEvent<HTMLButtonElement>, playType?: Play) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handlePlayQueueAdd({
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: itemData.id,
|
||||
id: [itemData.id],
|
||||
type: itemType,
|
||||
},
|
||||
play: playType || playButtonBehavior,
|
||||
|
||||
@@ -178,3 +178,34 @@ export const ALBUM_CARD_ROWS: { [key: string]: CardRow<Album> } = {
|
||||
property: 'songCount',
|
||||
},
|
||||
};
|
||||
|
||||
export const ALBUMARTIST_CARD_ROWS: { [key: string]: CardRow<AlbumArtist> } = {
|
||||
albumCount: {
|
||||
property: 'albumCount',
|
||||
},
|
||||
duration: {
|
||||
property: 'duration',
|
||||
},
|
||||
genres: {
|
||||
property: 'genres',
|
||||
},
|
||||
lastPlayedAt: {
|
||||
property: 'lastPlayedAt',
|
||||
},
|
||||
name: {
|
||||
property: 'name',
|
||||
route: {
|
||||
route: AppRoute.LIBRARY_ALBUMARTISTS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
|
||||
},
|
||||
},
|
||||
playCount: {
|
||||
property: 'playCount',
|
||||
},
|
||||
rating: {
|
||||
property: 'rating',
|
||||
},
|
||||
songCount: {
|
||||
property: 'songCount',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { CardRow } from '/@/renderer/types';
|
||||
import { LibraryItem, Play } from '/@/renderer/types';
|
||||
import styled from 'styled-components';
|
||||
import { AlbumCard } from '/@/renderer/components/card';
|
||||
import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-handle-playqueue-add';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player/hooks/use-playqueue-add';
|
||||
|
||||
interface GridCarouselProps {
|
||||
cardRows: CardRow<any>[];
|
||||
@@ -80,7 +80,7 @@ const Carousel = ({ data, cardRows }: any) => {
|
||||
const { loading, pagination, gridHeight, imageSize, direction, uniqueId } =
|
||||
useContext(GridCarouselContext);
|
||||
|
||||
const handlePlayQueueAdd = useHandlePlayQueueAdd();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import React from 'react';
|
||||
import type { ModalProps as MantineModalProps } from '@mantine/core';
|
||||
import { Modal as MantineModal } from '@mantine/core';
|
||||
import type { ContextModalProps } from '@mantine/modals';
|
||||
import React, { ReactNode } from 'react';
|
||||
import {
|
||||
ModalProps as MantineModalProps,
|
||||
Stack,
|
||||
Modal as MantineModal,
|
||||
Flex,
|
||||
Group,
|
||||
} from '@mantine/core';
|
||||
import { closeAllModals, ContextModalProps } from '@mantine/modals';
|
||||
import { Button } from '/@/renderer/components/button';
|
||||
|
||||
export interface ModalProps extends Omit<MantineModalProps, 'onClose'> {
|
||||
children?: React.ReactNode;
|
||||
children?: ReactNode;
|
||||
handlers: {
|
||||
close: () => void;
|
||||
open: () => void;
|
||||
@@ -41,3 +47,55 @@ export const BaseContextModal = ({
|
||||
Modal.defaultProps = {
|
||||
children: undefined,
|
||||
};
|
||||
|
||||
interface ConfirmModalProps {
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
labels?: {
|
||||
cancel?: string;
|
||||
confirm?: string;
|
||||
};
|
||||
loading?: boolean;
|
||||
onCancel?: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export const ConfirmModal = ({
|
||||
loading,
|
||||
disabled,
|
||||
labels,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
children,
|
||||
}: ConfirmModalProps) => {
|
||||
const handleCancel = () => {
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
} else {
|
||||
closeAllModals();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Flex>{children}</Flex>
|
||||
<Group position="right">
|
||||
<Button
|
||||
data-focus
|
||||
variant="default"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
{labels?.cancel ? labels.cancel : 'Cancel'}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={disabled}
|
||||
loading={loading}
|
||||
variant="filled"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{labels?.confirm ? labels.confirm : 'Confirm'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Flex, FlexProps } from '@mantine/core';
|
||||
import { AnimatePresence, motion, Variants } from 'framer-motion';
|
||||
import { useRef } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { useShouldPadTitlebar } from '/@/renderer/hooks';
|
||||
import { useShouldPadTitlebar, useTheme } from '/@/renderer/hooks';
|
||||
|
||||
const Container = styled(motion.div)<{ $useOpacity?: boolean; height?: string; position?: string }>`
|
||||
position: ${(props) => props.position};
|
||||
z-index: 100;
|
||||
const Container = styled(motion(Flex))<{
|
||||
height?: string;
|
||||
position?: string;
|
||||
}>`
|
||||
position: ${(props) => props.position || 'relative'};
|
||||
z-index: 2000;
|
||||
width: 100%;
|
||||
height: ${(props) => props.height || '60px'};
|
||||
opacity: ${(props) => props.$useOpacity && 'var(--header-opacity)'};
|
||||
transition: opacity 0.3s ease-in-out;
|
||||
`;
|
||||
|
||||
const Header = styled(motion.div)<{ $padRight?: boolean }>`
|
||||
const Header = styled(motion.div)<{ $isHidden?: boolean; $padRight?: boolean }>`
|
||||
position: relative;
|
||||
z-index: 15;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin-right: ${(props) => props.$padRight && '170px'};
|
||||
padding: 1rem;
|
||||
user-select: ${(props) => (props.$isHidden ? 'none' : 'auto')};
|
||||
pointer-events: ${(props) => (props.$isHidden ? 'none' : 'auto')};
|
||||
-webkit-app-region: drag;
|
||||
|
||||
button {
|
||||
@@ -28,63 +33,90 @@ const Header = styled(motion.div)<{ $padRight?: boolean }>`
|
||||
}
|
||||
`;
|
||||
|
||||
// const BackgroundImage = styled.div<{ background: string }>`
|
||||
// position: absolute;
|
||||
// top: 0;
|
||||
// z-index: -1;
|
||||
// width: 100%;
|
||||
// height: 100%;
|
||||
// background: ${(props) => props.background};
|
||||
// `;
|
||||
const BackgroundImage = styled.div<{ background: string }>`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: ${(props) => props.background || 'var(--titlebar-bg)'};
|
||||
`;
|
||||
|
||||
// const BackgroundImageOverlay = styled.div`
|
||||
// position: absolute;
|
||||
// top: 0;
|
||||
// left: 0;
|
||||
// z-index: -1;
|
||||
// width: 100%;
|
||||
// height: 100%;
|
||||
// /* background: linear-gradient(180deg, rgba(25, 26, 28, 0%), var(--main-bg)); */
|
||||
// /* background: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMDAiIGhlaWdodD0iMzAwIj48ZmlsdGVyIGlkPSJhIiB4PSIwIiB5PSIwIj48ZmVUdXJidWxlbmNlIHR5cGU9ImZyYWN0YWxOb2lzZSIgYmFzZUZyZXF1ZW5jeT0iLjc1IiBzdGl0Y2hUaWxlcz0ic3RpdGNoIi8+PGZlQ29sb3JNYXRyaXggdHlwZT0ic2F0dXJhdGUiIHZhbHVlcz0iMCIvPjwvZmlsdGVyPjxwYXRoIGZpbHRlcj0idXJsKCNhKSIgb3BhY2l0eT0iLjA1IiBkPSJNMCAwaDMwMHYzMDBIMHoiLz48L3N2Zz4='); */
|
||||
// `;
|
||||
const BackgroundImageOverlay = styled.div<{ theme: 'light' | 'dark' }>`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: ${(props) =>
|
||||
props.theme === 'light'
|
||||
? 'linear-gradient(rgba(0, 0, 0, 20%), rgba(0, 0, 0, 20%))'
|
||||
: 'linear-gradient(rgba(0, 0, 0, 50%), rgba(0, 0, 0, 50%))'};
|
||||
`;
|
||||
|
||||
interface PageHeaderProps {
|
||||
export interface PageHeaderProps
|
||||
extends Omit<FlexProps, 'onAnimationStart' | 'onDragStart' | 'onDragEnd' | 'onDrag'> {
|
||||
backgroundColor?: string;
|
||||
children?: React.ReactNode;
|
||||
height?: string;
|
||||
isHidden?: boolean;
|
||||
position?: string;
|
||||
useOpacity?: boolean;
|
||||
}
|
||||
|
||||
const TitleWrapper = styled(motion.div)`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const variants: Variants = {
|
||||
animate: { opacity: 1 },
|
||||
exit: { opacity: 0 },
|
||||
initial: { opacity: 0 },
|
||||
};
|
||||
|
||||
export const PageHeader = ({
|
||||
position,
|
||||
height,
|
||||
backgroundColor,
|
||||
useOpacity,
|
||||
isHidden,
|
||||
children,
|
||||
...props
|
||||
}: PageHeaderProps) => {
|
||||
const ref = useRef(null);
|
||||
const padRight = useShouldPadTitlebar();
|
||||
|
||||
useEffect(() => {
|
||||
const rootElement = document.querySelector(':root') as HTMLElement;
|
||||
rootElement?.style?.setProperty('--header-opacity', '0');
|
||||
}, []);
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Container
|
||||
ref={ref}
|
||||
$useOpacity={useOpacity}
|
||||
animate={{
|
||||
backgroundColor,
|
||||
transition: { duration: 1.5 },
|
||||
}}
|
||||
height={height}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<Header $padRight={padRight}>{children}</Header>
|
||||
{/* <BackgroundImage background={backgroundColor} /> */}
|
||||
{/* <BackgroundImageOverlay /> */}
|
||||
<Header
|
||||
$isHidden={isHidden}
|
||||
$padRight={padRight}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{!isHidden && (
|
||||
<TitleWrapper
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
initial="initial"
|
||||
variants={variants}
|
||||
>
|
||||
{children}
|
||||
</TitleWrapper>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Header>
|
||||
{backgroundColor && (
|
||||
<>
|
||||
<BackgroundImage background={backgroundColor || 'var(--titlebar-bg)'} />
|
||||
<BackgroundImageOverlay theme={theme} />
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { forwardRef, Ref, useEffect, useRef, useState } from 'react';
|
||||
import type { ScrollAreaProps as MantineScrollAreaProps } from '@mantine/core';
|
||||
import { ScrollArea as MantineScrollArea } from '@mantine/core';
|
||||
import { useMergedRef, useTimeout } from '@mantine/hooks';
|
||||
import { motion, useScroll } from 'framer-motion';
|
||||
import styled from 'styled-components';
|
||||
import { PageHeader, PageHeaderProps } from '/@/renderer/components/page-header';
|
||||
|
||||
interface ScrollAreaProps extends MantineScrollAreaProps {
|
||||
children: React.ReactNode;
|
||||
@@ -13,19 +17,129 @@ const StyledScrollArea = styled(MantineScrollArea)`
|
||||
}
|
||||
|
||||
& .mantine-ScrollArea-scrollbar {
|
||||
width: 12px;
|
||||
padding: 0;
|
||||
background: var(--scrollbar-track-bg);
|
||||
}
|
||||
|
||||
& .mantine-ScrollArea-viewport > div {
|
||||
display: block !important;
|
||||
}
|
||||
`;
|
||||
|
||||
export const ScrollArea = ({ children, ...props }: ScrollAreaProps) => {
|
||||
const StyledNativeScrollArea = styled.div`
|
||||
height: 100%;
|
||||
overflow-y: overlay;
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
margin-top: 35px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
margin-top: 35px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const ScrollArea = forwardRef(({ children, ...props }: ScrollAreaProps, ref: Ref<any>) => {
|
||||
return (
|
||||
<StyledScrollArea
|
||||
offsetScrollbars
|
||||
ref={ref}
|
||||
scrollbarSize={12}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</StyledScrollArea>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
interface NativeScrollAreaProps {
|
||||
children: React.ReactNode;
|
||||
debugScrollPosition?: boolean;
|
||||
pageHeaderProps?: PageHeaderProps & { offset?: any; target?: any };
|
||||
}
|
||||
|
||||
export const NativeScrollArea = forwardRef(
|
||||
(
|
||||
{ children, pageHeaderProps, debugScrollPosition, ...props }: NativeScrollAreaProps,
|
||||
ref: Ref<HTMLDivElement>,
|
||||
) => {
|
||||
const [hideScrollbar, setHideScrollbar] = useState(false);
|
||||
const [hideHeader, setHideHeader] = useState(true);
|
||||
const { start, clear } = useTimeout(() => setHideScrollbar(true), 1000);
|
||||
|
||||
const containerRef = useRef(null);
|
||||
const mergedRef = useMergedRef(ref, containerRef);
|
||||
|
||||
const { scrollYProgress } = useScroll({
|
||||
container: containerRef,
|
||||
offset: pageHeaderProps?.offset || ['center start', 'end start'],
|
||||
target: pageHeaderProps?.target,
|
||||
});
|
||||
|
||||
// Automatically hide the scrollbar after the timeout duration
|
||||
useEffect(() => {
|
||||
start();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const setHeaderVisibility = (v: number) => {
|
||||
if (v === 1) {
|
||||
return setHideHeader(false);
|
||||
}
|
||||
|
||||
if (hideHeader === false) {
|
||||
return setHideHeader(true);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const unsubscribe = scrollYProgress.on('change', setHeaderVisibility);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [hideHeader, scrollYProgress]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
isHidden={hideHeader}
|
||||
position="absolute"
|
||||
style={{ opacity: scrollYProgress as any }}
|
||||
{...pageHeaderProps}
|
||||
/>
|
||||
<StyledNativeScrollArea
|
||||
ref={mergedRef}
|
||||
className={hideScrollbar ? 'hide-scrollbar' : undefined}
|
||||
onMouseEnter={() => {
|
||||
setHideScrollbar(false);
|
||||
clear();
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
start();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</StyledNativeScrollArea>
|
||||
{debugScrollPosition && (
|
||||
<motion.div
|
||||
style={{
|
||||
background: 'red',
|
||||
height: '10px',
|
||||
left: 0,
|
||||
position: 'fixed',
|
||||
right: 0,
|
||||
scaleX: scrollYProgress,
|
||||
top: 0,
|
||||
transformOrigin: '0%',
|
||||
width: '100%',
|
||||
zIndex: 5000,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -17,11 +17,12 @@ interface TextTitleProps extends MantineTextTitleDivProps {
|
||||
}
|
||||
|
||||
const StyledTextTitle = styled(MantineHeader)<TextTitleProps>`
|
||||
overflow: ${(props) => props.overflow};
|
||||
color: ${(props) => (props.$secondary ? 'var(--main-fg-secondary)' : 'var(--main-fg)')};
|
||||
cursor: ${(props) => props.$link && 'cursor'};
|
||||
transition: color 0.2s ease-in-out;
|
||||
user-select: ${(props) => (props.$noSelect ? 'none' : 'auto')};
|
||||
${(props) => props.overflow === 'hidden' && textEllipsis}
|
||||
${(props) => props.overflow === 'hidden' && !props.lineClamp && textEllipsis}
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.$link && 'var(--main-fg)'};
|
||||
|
||||
@@ -19,12 +19,13 @@ interface TextProps extends MantineTextDivProps {
|
||||
}
|
||||
|
||||
const StyledText = styled(MantineText)<TextProps>`
|
||||
overflow: ${(props) => props.overflow};
|
||||
color: ${(props) => (props.$secondary ? 'var(--main-fg-secondary)' : 'var(--main-fg)')};
|
||||
font-family: ${(props) => props.font};
|
||||
cursor: ${(props) => props.$link && 'cursor'};
|
||||
transition: color 0.2s ease-in-out;
|
||||
user-select: ${(props) => (props.$noSelect ? 'none' : 'auto')};
|
||||
${(props) => props.overflow === 'hidden' && textEllipsis}
|
||||
${(props) => props.overflow === 'hidden' && !props.lineClamp && textEllipsis}
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.$link && 'var(--main-fg)'};
|
||||
|
||||
@@ -158,7 +158,7 @@ export const DefaultCard = ({
|
||||
animationDuration={0.3}
|
||||
height={cardSize}
|
||||
imgStyle={{ objectFit: 'cover' }}
|
||||
placeholder="var(--card-default-bg)"
|
||||
placeholder={data?.imagePlaceholderUrl || 'var(--card-default-bg)'}
|
||||
src={data?.imageUrl}
|
||||
width={cardSize}
|
||||
/>
|
||||
|
||||
@@ -153,7 +153,7 @@ export const PosterCard = ({
|
||||
animationDuration={0.3}
|
||||
height={sizes.itemWidth}
|
||||
importance="auto"
|
||||
placeholder="var(--card-default-bg)"
|
||||
placeholder={data?.imagePlaceholderUrl || 'var(--card-default-bg)'}
|
||||
src={data?.imageUrl}
|
||||
width={sizes.itemWidth}
|
||||
/>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { motion } from 'framer-motion';
|
||||
import { RiAlbumFill } from 'react-icons/ri';
|
||||
import { generatePath } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { SimpleImg } from 'react-simple-img';
|
||||
import styled from 'styled-components';
|
||||
import type { AlbumArtist, Artist } from '/@/renderer/api/types';
|
||||
import { Text } from '/@/renderer/components/text';
|
||||
@@ -40,8 +41,10 @@ const MetadataWrapper = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledImage = styled.img`
|
||||
object-fit: cover;
|
||||
const StyledImage = styled(SimpleImg)`
|
||||
img {
|
||||
object-fit: cover;
|
||||
}
|
||||
`;
|
||||
|
||||
export const CombinedTitleCell = ({ value, rowIndex, node }: ICellRendererParams) => {
|
||||
@@ -78,6 +81,7 @@ export const CombinedTitleCell = ({ value, rowIndex, node }: ICellRendererParams
|
||||
<StyledImage
|
||||
alt="cover"
|
||||
height={(node.rowHeight || 40) - 10}
|
||||
placeholder={value.imagePlaceholderUrl || 'var(--placeholder-bg)'}
|
||||
src={value.imageUrl}
|
||||
style={{}}
|
||||
width={(node.rowHeight || 40) - 10}
|
||||
|
||||
@@ -57,12 +57,28 @@ const tableColumns: { [key: string]: ColDef } = {
|
||||
valueGetter: (params: ValueGetterParams) =>
|
||||
params.data ? params.data.albumArtists : undefined,
|
||||
},
|
||||
albumCount: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
colId: TableColumn.ALBUM_COUNT,
|
||||
field: 'albumCount',
|
||||
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: 'Albums',
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.albumCount : undefined),
|
||||
},
|
||||
artist: {
|
||||
cellRenderer: ArtistCell,
|
||||
colId: TableColumn.ARTIST,
|
||||
headerName: 'Artist',
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.artists : undefined),
|
||||
},
|
||||
biography: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'left' }),
|
||||
colId: TableColumn.BIOGRAPHY,
|
||||
field: 'biography',
|
||||
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'left' }),
|
||||
headerName: 'Biography',
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.biography : undefined),
|
||||
},
|
||||
bitRate: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'left' }),
|
||||
colId: TableColumn.BIT_RATE,
|
||||
@@ -175,6 +191,14 @@ const tableColumns: { [key: string]: ColDef } = {
|
||||
return (params.node?.rowIndex || 0) + 1;
|
||||
},
|
||||
},
|
||||
songCount: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
colId: TableColumn.SONG_COUNT,
|
||||
field: 'songCount',
|
||||
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: 'Songs',
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.songCount : undefined),
|
||||
},
|
||||
title: {
|
||||
cellRenderer: (params: ICellRendererParams) =>
|
||||
GenericCell(params, { position: 'left', primary: true }),
|
||||
@@ -193,6 +217,7 @@ const tableColumns: { [key: string]: ColDef } = {
|
||||
? {
|
||||
albumArtists: params.data?.albumArtists,
|
||||
artists: params.data?.artists,
|
||||
imagePlaceholderUrl: params.data?.imagePlaceholderUrl,
|
||||
imageUrl: params.data?.imageUrl,
|
||||
name: params.data?.name,
|
||||
rowHeight: params.node?.rowHeight,
|
||||
|
||||
@@ -49,6 +49,29 @@ export const ALBUM_TABLE_COLUMNS = [
|
||||
{ label: 'Plays', value: TableColumn.PLAY_COUNT },
|
||||
];
|
||||
|
||||
export const ALBUMARTIST_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: 'Biography', value: TableColumn.BIOGRAPHY },
|
||||
{ label: 'Genre', value: TableColumn.GENRE },
|
||||
{ label: 'Last Played', value: TableColumn.LAST_PLAYED },
|
||||
{ label: 'Plays', value: TableColumn.PLAY_COUNT },
|
||||
{ label: 'Album Count', value: TableColumn.ALBUM_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 {
|
||||
type: TableType;
|
||||
}
|
||||
|
||||
@@ -14,12 +14,20 @@ import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { TablePagination as TablePaginationType } from '/@/renderer/types';
|
||||
|
||||
interface TablePaginationProps {
|
||||
id?: string;
|
||||
pagination: TablePaginationType;
|
||||
setPagination: (pagination: Partial<TablePaginationType>) => void;
|
||||
setIdPagination?: (id: string, pagination: Partial<TablePaginationType>) => void;
|
||||
setPagination?: (pagination: Partial<TablePaginationType>) => void;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const TablePagination = ({ tableRef, pagination, setPagination }: TablePaginationProps) => {
|
||||
export const TablePagination = ({
|
||||
id,
|
||||
tableRef,
|
||||
pagination,
|
||||
setPagination,
|
||||
setIdPagination,
|
||||
}: TablePaginationProps) => {
|
||||
const [isGoToPageOpen, handlers] = useDisclosure(false);
|
||||
const containerQuery = useContainerQuery();
|
||||
|
||||
@@ -32,7 +40,8 @@ export const TablePagination = ({ tableRef, pagination, setPagination }: TablePa
|
||||
const handlePagination = (index: number) => {
|
||||
const newPage = index - 1;
|
||||
tableRef.current?.api.paginationGoToPage(newPage);
|
||||
setPagination({ currentPage: newPage });
|
||||
setPagination?.({ currentPage: newPage });
|
||||
setIdPagination?.(id || '', { currentPage: newPage });
|
||||
};
|
||||
|
||||
const handleGoSubmit = goToForm.onSubmit((values) => {
|
||||
@@ -43,11 +52,14 @@ export const TablePagination = ({ tableRef, pagination, setPagination }: TablePa
|
||||
|
||||
const newPage = values.pageNumber - 1;
|
||||
tableRef.current?.api.paginationGoToPage(newPage);
|
||||
setPagination({ currentPage: newPage });
|
||||
setPagination?.({ currentPage: newPage });
|
||||
setIdPagination?.(id || '', { currentPage: newPage });
|
||||
});
|
||||
|
||||
const currentPageStartIndex = pagination.currentPage * pagination.itemsPerPage + 1;
|
||||
const currentPageStopIndex = (pagination.currentPage + 1) * pagination.itemsPerPage;
|
||||
const currentPageMaxIndex = (pagination.currentPage + 1) * pagination.itemsPerPage;
|
||||
const currentPageStopIndex =
|
||||
currentPageMaxIndex > pagination.totalItems ? pagination.totalItems : currentPageMaxIndex;
|
||||
|
||||
return (
|
||||
<MotionFlex
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
import { Center, Group, Stack } from '@mantine/core';
|
||||
import isElectron from 'is-electron';
|
||||
import { RiCheckFill } from 'react-icons/ri';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link, Navigate } from 'react-router-dom';
|
||||
import { Button, PageHeader, Text } from '/@/renderer/components';
|
||||
import { ActionRequiredContainer } from '/@/renderer/features/action-required/components/action-required-container';
|
||||
import { MpvRequired } from '/@/renderer/features/action-required/components/mpv-required';
|
||||
@@ -69,6 +69,7 @@ const ActionRequiredRoute = () => {
|
||||
<Stack mt="2rem">
|
||||
{canReturnHome && (
|
||||
<>
|
||||
<Navigate to={AppRoute.HOME} />
|
||||
<Group
|
||||
noWrap
|
||||
position="center"
|
||||
|
||||
@@ -19,7 +19,6 @@ import { useSongListStore } from '/@/renderer/store';
|
||||
import styled from 'styled-components';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-handle-playqueue-add';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { openContextMenu } from '/@/renderer/features/context-menu';
|
||||
import { LibraryItem, Play } from '/@/renderer/types';
|
||||
@@ -27,6 +26,7 @@ import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/conte
|
||||
import { PlayButton, PLAY_TYPES } from '/@/renderer/features/shared';
|
||||
import { useAlbumList } from '/@/renderer/features/albums/queries/album-list-query';
|
||||
import { AlbumListSort, SortOrder } from '/@/renderer/api/types';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
|
||||
const ContentContainer = styled.div`
|
||||
display: flex;
|
||||
@@ -59,7 +59,7 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
|
||||
const { albumId } = useParams() as { albumId: string };
|
||||
const detailQuery = useAlbumDetail({ id: albumId });
|
||||
const cq = useContainerQuery();
|
||||
const handlePlayQueueAdd = useHandlePlayQueueAdd();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
|
||||
const page = useSongListStore();
|
||||
|
||||
@@ -237,7 +237,6 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
|
||||
rowHeight={60}
|
||||
rowSelection="multiple"
|
||||
onCellContextMenu={handleContextMenu}
|
||||
onColumnResized={() => console.log('resize')}
|
||||
onGridReady={(params) => {
|
||||
params.api.setDomLayout('autoHeight');
|
||||
params.api.sizeColumnsToFit();
|
||||
|
||||
@@ -1,182 +1,97 @@
|
||||
import { Center, Group } from '@mantine/core';
|
||||
import { Fragment } from 'react';
|
||||
import { RiAlbumFill } from 'react-icons/ri';
|
||||
import { Group, Stack } from '@mantine/core';
|
||||
import { forwardRef, Fragment, Ref } from 'react';
|
||||
import { generatePath, useParams } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { Text, TextTitle } from '/@/renderer/components';
|
||||
import { Text } from '/@/renderer/components';
|
||||
import { useAlbumDetail } from '/@/renderer/features/albums/queries/album-detail-query';
|
||||
import { LibraryHeader } from '/@/renderer/features/shared';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
|
||||
const HeaderContainer = styled.div`
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-auto-columns: 1fr;
|
||||
grid-template-areas: 'image info';
|
||||
grid-template-rows: 1fr;
|
||||
grid-template-columns: 250px minmax(0, 1fr);
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 30vh;
|
||||
min-height: 340px;
|
||||
max-height: 500px;
|
||||
padding: 5rem 2rem 2rem;
|
||||
`;
|
||||
|
||||
const CoverImageWrapper = styled.div`
|
||||
z-index: 15;
|
||||
display: flex;
|
||||
grid-area: image;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
filter: drop-shadow(0 0 8px rgb(0, 0, 0, 50%));
|
||||
`;
|
||||
|
||||
const MetadataWrapper = styled.div`
|
||||
z-index: 15;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-area: info;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledImage = styled.img`
|
||||
object-fit: cover;
|
||||
`;
|
||||
|
||||
const BackgroundImage = styled.div<{ background: string }>`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: ${(props) => props.background};
|
||||
`;
|
||||
|
||||
const BackgroundImageOverlay = styled.div`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(180deg, rgba(25, 26, 28, 5%), var(--main-bg));
|
||||
`;
|
||||
import { LibraryItem } from '/@/renderer/types';
|
||||
import { formatDurationString } from '/@/renderer/utils';
|
||||
|
||||
interface AlbumDetailHeaderProps {
|
||||
background: string;
|
||||
}
|
||||
|
||||
export const AlbumDetailHeader = ({ background }: AlbumDetailHeaderProps) => {
|
||||
const { albumId } = useParams() as { albumId: string };
|
||||
const detailQuery = useAlbumDetail({ id: albumId });
|
||||
const cq = useContainerQuery();
|
||||
export const AlbumDetailHeader = forwardRef(
|
||||
({ background }: AlbumDetailHeaderProps, ref: Ref<HTMLDivElement>) => {
|
||||
const { albumId } = useParams() as { albumId: string };
|
||||
const detailQuery = useAlbumDetail({ id: albumId });
|
||||
const cq = useContainerQuery();
|
||||
|
||||
const titleSize = cq.isXl
|
||||
? '6rem'
|
||||
: cq.isLg
|
||||
? '5.5rem'
|
||||
: cq.isMd
|
||||
? '4.5rem'
|
||||
: cq.isSm
|
||||
? '3.5rem'
|
||||
: '2rem';
|
||||
const metadataItems = [
|
||||
{
|
||||
id: 'releaseYear',
|
||||
secondary: false,
|
||||
value: detailQuery?.data?.releaseYear,
|
||||
},
|
||||
{
|
||||
id: 'songCount',
|
||||
secondary: false,
|
||||
value: `${detailQuery?.data?.songCount} songs`,
|
||||
},
|
||||
{
|
||||
id: 'duration',
|
||||
secondary: true,
|
||||
value: detailQuery?.data?.duration && formatDurationString(detailQuery.data.duration),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<HeaderContainer ref={cq.ref}>
|
||||
<BackgroundImage background={background} />
|
||||
<BackgroundImageOverlay />
|
||||
<CoverImageWrapper>
|
||||
{detailQuery?.data?.imageUrl ? (
|
||||
<StyledImage
|
||||
alt="cover"
|
||||
height={225}
|
||||
src={detailQuery?.data.imageUrl}
|
||||
width={225}
|
||||
/>
|
||||
) : (
|
||||
<Center
|
||||
sx={{
|
||||
background: 'var(--placeholder-bg)',
|
||||
borderRadius: 'var(--card-default-radius)',
|
||||
height: `${80}px`,
|
||||
width: `${80}px`,
|
||||
}}
|
||||
>
|
||||
<RiAlbumFill
|
||||
color="var(--placeholder-fg)"
|
||||
size={35}
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
</CoverImageWrapper>
|
||||
<MetadataWrapper>
|
||||
<Group>
|
||||
<Text
|
||||
$link
|
||||
component={Link}
|
||||
fw="600"
|
||||
sx={{ textTransform: 'uppercase' }}
|
||||
to={AppRoute.LIBRARY_ALBUMS}
|
||||
>
|
||||
Album
|
||||
</Text>
|
||||
{detailQuery?.data?.releaseYear && (
|
||||
<>
|
||||
<Text>•</Text>
|
||||
<Text>{detailQuery?.data?.releaseYear}</Text>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
<TextTitle
|
||||
fw="900"
|
||||
lh="1"
|
||||
mb="0.12em"
|
||||
mt=".08em"
|
||||
sx={{ fontSize: titleSize }}
|
||||
return (
|
||||
<Stack ref={cq.ref}>
|
||||
<LibraryHeader
|
||||
ref={ref}
|
||||
background={background}
|
||||
imageUrl={detailQuery?.data?.imageUrl}
|
||||
item={{ route: AppRoute.PLAYLISTS, type: LibraryItem.ALBUM }}
|
||||
title={detailQuery?.data?.name || ''}
|
||||
>
|
||||
{detailQuery?.data?.name}
|
||||
</TextTitle>
|
||||
<Group
|
||||
spacing="xs"
|
||||
sx={{
|
||||
WebkitBoxOrient: 'vertical',
|
||||
WebkitLineClamp: 2,
|
||||
display: '-webkit-box',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{detailQuery?.data?.albumArtists.map((artist, index) => (
|
||||
<Fragment key={`artist-${artist.id}`}>
|
||||
{index > 0 && (
|
||||
<Text
|
||||
$noSelect
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
padding: '0 0.5rem',
|
||||
}}
|
||||
>
|
||||
•
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
$link
|
||||
component={Link}
|
||||
fw="600"
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUMARTISTS_DETAIL, {
|
||||
albumArtistId: artist.id,
|
||||
})}
|
||||
>
|
||||
{artist.name}
|
||||
</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
</Group>
|
||||
</MetadataWrapper>
|
||||
</HeaderContainer>
|
||||
);
|
||||
};
|
||||
<Stack mt="1rem">
|
||||
<Group>
|
||||
{metadataItems.map((item, index) => (
|
||||
<Fragment key={`item-${item.id}-${index}`}>
|
||||
{index > 0 && <Text $noSelect>•</Text>}
|
||||
<Text $secondary={item.secondary}>{item.value}</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
</Group>
|
||||
<Group
|
||||
sx={{
|
||||
WebkitBoxOrient: 'vertical',
|
||||
WebkitLineClamp: 2,
|
||||
display: '-webkit-box',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{detailQuery?.data?.albumArtists.map((artist, index) => (
|
||||
<Fragment key={`artist-${artist.id}`}>
|
||||
{index > 0 && (
|
||||
<Text
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
padding: '0 0.5rem',
|
||||
}}
|
||||
>
|
||||
•
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
$link
|
||||
component={Link}
|
||||
fw="600"
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUMARTISTS_DETAIL, {
|
||||
albumArtistId: artist.id,
|
||||
})}
|
||||
>
|
||||
{artist.name}
|
||||
</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
</Group>
|
||||
</Stack>
|
||||
</LibraryHeader>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -17,7 +17,6 @@ import { controller } from '/@/renderer/api/controller';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { Album, AlbumListSort } from '/@/renderer/api/types';
|
||||
import { useAlbumList } from '/@/renderer/features/albums/queries/album-list-query';
|
||||
import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-handle-playqueue-add';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
useCurrentServer,
|
||||
@@ -43,6 +42,7 @@ import { openContextMenu } from '/@/renderer/features/context-menu';
|
||||
import { ALBUM_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
|
||||
import sortBy from 'lodash/sortBy';
|
||||
import { generatePath, useNavigate } from 'react-router';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
|
||||
interface AlbumListContentProps {
|
||||
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
|
||||
@@ -55,7 +55,7 @@ export const AlbumListContent = ({ gridRef, tableRef }: AlbumListContentProps) =
|
||||
const server = useCurrentServer();
|
||||
const page = useAlbumListStore();
|
||||
const setPage = useSetAlbumStore();
|
||||
const handlePlayQueueAdd = useHandlePlayQueueAdd();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
|
||||
const pagination = useAlbumTablePagination();
|
||||
const setPagination = useSetAlbumTablePagination();
|
||||
|
||||
@@ -296,7 +296,7 @@ export const AlbumListHeader = ({ gridRef, tableRef }: AlbumListHeaderProps) =>
|
||||
};
|
||||
|
||||
return (
|
||||
<PageHeader>
|
||||
<PageHeader p="1rem">
|
||||
<HeaderItems ref={cq.ref}>
|
||||
<Flex
|
||||
align="center"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PageHeader, ScrollArea } from '/@/renderer/components';
|
||||
import { AnimatedPage } from '/@/renderer/features/shared';
|
||||
import { NativeScrollArea } from '/@/renderer/components';
|
||||
import { AnimatedPage, LibraryHeaderBar } from '/@/renderer/features/shared';
|
||||
import { useRef } from 'react';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { useAlbumDetail } from '/@/renderer/features/albums/queries/album-detail-query';
|
||||
@@ -7,32 +7,54 @@ import { useParams } from 'react-router';
|
||||
import { useFastAverageColor } from '/@/renderer/hooks';
|
||||
import { AlbumDetailContent } from '/@/renderer/features/albums/components/album-detail-content';
|
||||
import { AlbumDetailHeader } from '/@/renderer/features/albums/components/album-detail-header';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { LibraryItem } from '/@/renderer/types';
|
||||
|
||||
const AlbumDetailRoute = () => {
|
||||
const tableRef = useRef<AgGridReactType | null>(null);
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { albumId } = useParams() as { albumId: string };
|
||||
const detailQuery = useAlbumDetail({ id: albumId });
|
||||
const background = useFastAverageColor(detailQuery.data?.imageUrl);
|
||||
const background = useFastAverageColor(detailQuery.data?.imageUrl, !detailQuery.isLoading);
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
const handlePlay = () => {
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: [albumId],
|
||||
type: LibraryItem.ALBUM,
|
||||
},
|
||||
play: playButtonBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
if (!background) return null;
|
||||
|
||||
return (
|
||||
<AnimatedPage>
|
||||
<PageHeader
|
||||
useOpacity
|
||||
position="absolute"
|
||||
/>
|
||||
|
||||
<ScrollArea
|
||||
h="100%"
|
||||
offsetScrollbars={false}
|
||||
styles={{
|
||||
scrollbar: {
|
||||
marginTop: '35px',
|
||||
},
|
||||
<AnimatedPage key={`album-detail-${albumId}`}>
|
||||
<NativeScrollArea
|
||||
ref={scrollAreaRef}
|
||||
pageHeaderProps={{
|
||||
backgroundColor: background,
|
||||
children: (
|
||||
<LibraryHeaderBar>
|
||||
<LibraryHeaderBar.PlayButton onClick={handlePlay} />
|
||||
<LibraryHeaderBar.Title>{detailQuery?.data?.name}</LibraryHeaderBar.Title>
|
||||
</LibraryHeaderBar>
|
||||
),
|
||||
target: headerRef,
|
||||
}}
|
||||
>
|
||||
<AlbumDetailHeader background={background} />
|
||||
<AlbumDetailHeader
|
||||
ref={headerRef}
|
||||
background={background}
|
||||
/>
|
||||
<AlbumDetailContent tableRef={tableRef} />
|
||||
</ScrollArea>
|
||||
</NativeScrollArea>
|
||||
</AnimatedPage>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
import {
|
||||
ALBUMARTIST_CARD_ROWS,
|
||||
getColumnDefs,
|
||||
TablePagination,
|
||||
VirtualGridAutoSizerContainer,
|
||||
VirtualInfiniteGrid,
|
||||
VirtualInfiniteGridRef,
|
||||
VirtualTable,
|
||||
} from '/@/renderer/components';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { ListDisplayType, CardRow, LibraryItem } from '/@/renderer/types';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { MutableRefObject, useCallback, useMemo } from 'react';
|
||||
import { ListOnScrollProps } from 'react-window';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { AlbumArtist, AlbumArtistListSort } from '/@/renderer/api/types';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
useCurrentServer,
|
||||
useAlbumArtistListStore,
|
||||
useAlbumArtistTablePagination,
|
||||
useSetAlbumArtistStore,
|
||||
useSetAlbumArtistTable,
|
||||
useSetAlbumArtistTablePagination,
|
||||
} from '/@/renderer/store';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import {
|
||||
BodyScrollEvent,
|
||||
CellContextMenuEvent,
|
||||
ColDef,
|
||||
GridReadyEvent,
|
||||
IDatasource,
|
||||
PaginationChangedEvent,
|
||||
RowDoubleClickedEvent,
|
||||
} from '@ag-grid-community/core';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { openContextMenu } from '/@/renderer/features/context-menu';
|
||||
import { ALBUM_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
|
||||
import sortBy from 'lodash/sortBy';
|
||||
import { generatePath, useNavigate } from 'react-router';
|
||||
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
|
||||
interface AlbumArtistListContentProps {
|
||||
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const AlbumArtistListContent = ({ gridRef, tableRef }: AlbumArtistListContentProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const server = useCurrentServer();
|
||||
const page = useAlbumArtistListStore();
|
||||
const setPage = useSetAlbumArtistStore();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
|
||||
const pagination = useAlbumArtistTablePagination();
|
||||
const setPagination = useSetAlbumArtistTablePagination();
|
||||
const setTable = useSetAlbumArtistTable();
|
||||
|
||||
const isPaginationEnabled = page.display === ListDisplayType.TABLE_PAGINATED;
|
||||
|
||||
const checkAlbumArtistList = useAlbumArtistList(
|
||||
{
|
||||
limit: 1,
|
||||
startIndex: 0,
|
||||
...page.filter,
|
||||
},
|
||||
{
|
||||
cacheTime: Infinity,
|
||||
staleTime: 60 * 1000 * 5,
|
||||
},
|
||||
);
|
||||
|
||||
const columnDefs: ColDef[] = useMemo(
|
||||
() => getColumnDefs(page.table.columns),
|
||||
[page.table.columns],
|
||||
);
|
||||
|
||||
const defaultColumnDefs: ColDef = useMemo(() => {
|
||||
return {
|
||||
lockPinned: true,
|
||||
lockVisible: true,
|
||||
resizable: true,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onTableReady = useCallback(
|
||||
(params: GridReadyEvent) => {
|
||||
const dataSource: IDatasource = {
|
||||
getRows: async (params) => {
|
||||
const limit = params.endRow - params.startRow;
|
||||
const startIndex = params.startRow;
|
||||
|
||||
const queryKey = queryKeys.albumArtists.list(server?.id || '', {
|
||||
limit,
|
||||
startIndex,
|
||||
...page.filter,
|
||||
});
|
||||
|
||||
const albumArtistsRes = await queryClient.fetchQuery(queryKey, async ({ signal }) =>
|
||||
api.controller.getAlbumArtistList({
|
||||
query: {
|
||||
limit,
|
||||
startIndex,
|
||||
...page.filter,
|
||||
},
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
|
||||
const albums = api.normalize.albumArtistList(albumArtistsRes, server);
|
||||
params.successCallback(
|
||||
albums?.items || [],
|
||||
albumArtistsRes?.totalRecordCount || undefined,
|
||||
);
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
params.api.setDatasource(dataSource);
|
||||
params.api.ensureIndexVisible(page.table.scrollOffset || 0, 'top');
|
||||
},
|
||||
[page.filter, page.table.scrollOffset, queryClient, server],
|
||||
);
|
||||
|
||||
const onTablePaginationChanged = 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 handleTableSizeChange = () => {
|
||||
if (page.table.autoFit) {
|
||||
tableRef?.current?.api.sizeColumnsToFit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleTableColumnChange = 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.getColDef().width,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setTable({ columns: updatedColumns });
|
||||
}, [page.table.autoFit, page.table.columns, setTable, tableRef]);
|
||||
|
||||
const debouncedTableColumnChange = debounce(handleTableColumnChange, 200);
|
||||
|
||||
const handleTableScroll = (e: BodyScrollEvent) => {
|
||||
const scrollOffset = Number((e.top / page.table.rowHeight).toFixed(0));
|
||||
setTable({ scrollOffset });
|
||||
};
|
||||
|
||||
const fetch = useCallback(
|
||||
async ({ skip: startIndex, take: limit }: { skip: number; take: number }) => {
|
||||
const queryKey = queryKeys.albumArtists.list(server?.id || '', {
|
||||
limit,
|
||||
startIndex,
|
||||
...page.filter,
|
||||
});
|
||||
|
||||
const albumArtistsRes = await queryClient.fetchQuery(queryKey, async ({ signal }) =>
|
||||
api.controller.getAlbumArtistList({
|
||||
query: {
|
||||
limit,
|
||||
startIndex,
|
||||
...page.filter,
|
||||
},
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
|
||||
return api.normalize.albumArtistList(albumArtistsRes, server);
|
||||
},
|
||||
[page.filter, queryClient, server],
|
||||
);
|
||||
|
||||
const handleGridScroll = useCallback(
|
||||
(e: ListOnScrollProps) => {
|
||||
setPage({
|
||||
list: {
|
||||
...page,
|
||||
grid: {
|
||||
...page.grid,
|
||||
scrollOffset: e.scrollOffset,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
[page, setPage],
|
||||
);
|
||||
|
||||
const cardRows = useMemo(() => {
|
||||
const rows: CardRow<AlbumArtist>[] = [ALBUMARTIST_CARD_ROWS.name];
|
||||
|
||||
switch (page.filter.sortBy) {
|
||||
case AlbumArtistListSort.DURATION:
|
||||
rows.push(ALBUMARTIST_CARD_ROWS.duration);
|
||||
break;
|
||||
case AlbumArtistListSort.FAVORITED:
|
||||
break;
|
||||
case AlbumArtistListSort.NAME:
|
||||
break;
|
||||
case AlbumArtistListSort.ALBUM_COUNT:
|
||||
rows.push(ALBUMARTIST_CARD_ROWS.albumCount);
|
||||
break;
|
||||
case AlbumArtistListSort.PLAY_COUNT:
|
||||
rows.push(ALBUMARTIST_CARD_ROWS.playCount);
|
||||
break;
|
||||
case AlbumArtistListSort.RANDOM:
|
||||
break;
|
||||
case AlbumArtistListSort.RATING:
|
||||
rows.push(ALBUMARTIST_CARD_ROWS.rating);
|
||||
break;
|
||||
case AlbumArtistListSort.RECENTLY_ADDED:
|
||||
break;
|
||||
case AlbumArtistListSort.SONG_COUNT:
|
||||
rows.push(ALBUMARTIST_CARD_ROWS.songCount);
|
||||
break;
|
||||
case AlbumArtistListSort.RELEASE_DATE:
|
||||
break;
|
||||
}
|
||||
|
||||
return rows;
|
||||
}, [page.filter.sortBy]);
|
||||
|
||||
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: ALBUM_CONTEXT_MENU_ITEMS,
|
||||
type: LibraryItem.ALBUM_ARTIST,
|
||||
xPos: clickEvent.clientX,
|
||||
yPos: clickEvent.clientY,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRowDoubleClick = (e: RowDoubleClickedEvent) => {
|
||||
navigate(generatePath(AppRoute.LIBRARY_ALBUMARTISTS_DETAIL, { albumArtistId: e.data.id }));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<VirtualGridAutoSizerContainer>
|
||||
{page.display === ListDisplayType.CARD || page.display === ListDisplayType.POSTER ? (
|
||||
<AutoSizer>
|
||||
{({ height, width }) => (
|
||||
<VirtualInfiniteGrid
|
||||
key={`albumartist-list-${server?.id}-${page.display}`}
|
||||
ref={gridRef}
|
||||
cardRows={cardRows}
|
||||
display={page.display || ListDisplayType.CARD}
|
||||
fetchFn={fetch}
|
||||
handlePlayQueueAdd={handlePlayQueueAdd}
|
||||
height={height}
|
||||
initialScrollOffset={page?.grid.scrollOffset || 0}
|
||||
itemCount={checkAlbumArtistList?.data?.totalRecordCount || 0}
|
||||
itemGap={20}
|
||||
itemSize={150 + page.grid?.size}
|
||||
itemType={LibraryItem.ALBUM_ARTIST}
|
||||
minimumBatchSize={40}
|
||||
route={{
|
||||
route: AppRoute.LIBRARY_ALBUMARTISTS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
|
||||
}}
|
||||
width={width}
|
||||
onScroll={handleGridScroll}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
) : (
|
||||
<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={500}
|
||||
cacheOverflowSize={1}
|
||||
columnDefs={columnDefs}
|
||||
defaultColDef={defaultColumnDefs}
|
||||
enableCellChangeFlash={false}
|
||||
getRowId={(data) => data.data.id}
|
||||
infiniteInitialRowCount={checkAlbumArtistList.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={handleTableScroll}
|
||||
onCellContextMenu={handleContextMenu}
|
||||
onColumnMoved={handleTableColumnChange}
|
||||
onColumnResized={debouncedTableColumnChange}
|
||||
onGridReady={onTableReady}
|
||||
onGridSizeChanged={handleTableSizeChange}
|
||||
onPaginationChanged={onTablePaginationChanged}
|
||||
onRowDoubleClicked={handleRowDoubleClick}
|
||||
/>
|
||||
)}
|
||||
</VirtualGridAutoSizerContainer>
|
||||
{isPaginationEnabled && (
|
||||
<AnimatePresence
|
||||
presenceAffectsLayout
|
||||
initial={false}
|
||||
mode="wait"
|
||||
>
|
||||
{page.display === ListDisplayType.TABLE_PAGINATED && (
|
||||
<TablePagination
|
||||
pagination={pagination}
|
||||
setPagination={setPagination}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,502 @@
|
||||
import type { ChangeEvent, MouseEvent, MutableRefObject } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { 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 { useQueryClient } from '@tanstack/react-query';
|
||||
import debounce from 'lodash/debounce';
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiFilter3Line,
|
||||
RiFolder2Line,
|
||||
RiMoreFill,
|
||||
RiSortAsc,
|
||||
RiSortDesc,
|
||||
} from 'react-icons/ri';
|
||||
import styled from 'styled-components';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { AlbumArtistListSort, ServerType, SortOrder } from '/@/renderer/api/types';
|
||||
import {
|
||||
ALBUMARTIST_TABLE_COLUMNS,
|
||||
Button,
|
||||
DropdownMenu,
|
||||
MultiSelect,
|
||||
PageHeader,
|
||||
Popover,
|
||||
SearchInput,
|
||||
Slider,
|
||||
Switch,
|
||||
Text,
|
||||
TextTitle,
|
||||
VirtualInfiniteGridRef,
|
||||
} from '/@/renderer/components';
|
||||
import { useMusicFolders } from '/@/renderer/features/shared';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import {
|
||||
AlbumArtistListFilter,
|
||||
useAlbumArtistListStore,
|
||||
useCurrentServer,
|
||||
useSetAlbumArtistFilters,
|
||||
useSetAlbumArtistStore,
|
||||
useSetAlbumArtistTable,
|
||||
useSetAlbumArtistTablePagination,
|
||||
} from '/@/renderer/store';
|
||||
import { ListDisplayType, TableColumn } from '/@/renderer/types';
|
||||
|
||||
const FILTERS = {
|
||||
jellyfin: [
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Album', value: AlbumArtistListSort.ALBUM },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Duration', value: AlbumArtistListSort.DURATION },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Name', value: AlbumArtistListSort.NAME },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Random', value: AlbumArtistListSort.RANDOM },
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: 'Recently Added',
|
||||
value: AlbumArtistListSort.RECENTLY_ADDED,
|
||||
},
|
||||
// { defaultOrder: SortOrder.DESC, name: 'Release Date', value: AlbumArtistListSort.RELEASE_DATE },
|
||||
],
|
||||
navidrome: [
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Album Count', value: AlbumArtistListSort.ALBUM_COUNT },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Favorited', value: AlbumArtistListSort.FAVORITED },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Most Played', value: AlbumArtistListSort.PLAY_COUNT },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Name', value: AlbumArtistListSort.NAME },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Rating', value: AlbumArtistListSort.RATING },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Song Count', value: AlbumArtistListSort.SONG_COUNT },
|
||||
],
|
||||
};
|
||||
|
||||
const ORDER = [
|
||||
{ name: 'Ascending', value: SortOrder.ASC },
|
||||
{ name: 'Descending', value: SortOrder.DESC },
|
||||
];
|
||||
|
||||
const HeaderItems = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
interface AlbumArtistListHeaderProps {
|
||||
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const AlbumArtistListHeader = ({ gridRef, tableRef }: AlbumArtistListHeaderProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
const setPage = useSetAlbumArtistStore();
|
||||
const setFilter = useSetAlbumArtistFilters();
|
||||
const page = useAlbumArtistListStore();
|
||||
const filters = page.filter;
|
||||
const cq = useContainerQuery();
|
||||
|
||||
const musicFoldersQuery = useMusicFolders();
|
||||
|
||||
const setPagination = useSetAlbumArtistTablePagination();
|
||||
const setTable = useSetAlbumArtistTable();
|
||||
|
||||
const sortByLabel =
|
||||
(server?.type &&
|
||||
FILTERS[server.type as keyof typeof FILTERS].find((f) => f.value === filters.sortBy)?.name) ||
|
||||
'Unknown';
|
||||
|
||||
const sortOrderLabel = ORDER.find((o) => o.value === filters.sortOrder)?.name || 'Unknown';
|
||||
|
||||
const handleItemSize = (e: number) => {
|
||||
if (
|
||||
page.display === ListDisplayType.TABLE ||
|
||||
page.display === ListDisplayType.TABLE_PAGINATED
|
||||
) {
|
||||
setTable({ rowHeight: e });
|
||||
} else {
|
||||
setPage({ list: { ...page, grid: { ...page.grid, size: e } } });
|
||||
}
|
||||
};
|
||||
|
||||
const fetch = useCallback(
|
||||
async (startIndex: number, limit: number, filters: AlbumArtistListFilter) => {
|
||||
const queryKey = queryKeys.albumArtists.list(server?.id || '', {
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
});
|
||||
|
||||
const albums = await queryClient.fetchQuery(queryKey, async ({ signal }) =>
|
||||
api.controller.getAlbumArtistList({
|
||||
query: {
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
},
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
|
||||
return api.normalize.albumArtistList(albums, server);
|
||||
},
|
||||
[queryClient, server],
|
||||
);
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
async (filters: AlbumArtistListFilter) => {
|
||||
if (
|
||||
page.display === ListDisplayType.TABLE ||
|
||||
page.display === ListDisplayType.TABLE_PAGINATED
|
||||
) {
|
||||
const dataSource: IDatasource = {
|
||||
getRows: async (params) => {
|
||||
const limit = params.endRow - params.startRow;
|
||||
const startIndex = params.startRow;
|
||||
|
||||
const queryKey = queryKeys.albumArtists.list(server?.id || '', {
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
});
|
||||
|
||||
const albumArtistsRes = await queryClient.fetchQuery(queryKey, async ({ signal }) =>
|
||||
api.controller.getAlbumArtistList({
|
||||
query: {
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
},
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
|
||||
const albumArtists = api.normalize.albumArtistList(albumArtistsRes, server);
|
||||
params.successCallback(
|
||||
albumArtists?.items || [],
|
||||
albumArtistsRes?.totalRecordCount || undefined,
|
||||
);
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
tableRef.current?.api.setDatasource(dataSource);
|
||||
tableRef.current?.api.purgeInfiniteCache();
|
||||
tableRef.current?.api.ensureIndexVisible(0, 'top');
|
||||
|
||||
if (page.display === ListDisplayType.TABLE_PAGINATED) {
|
||||
setPagination({ currentPage: 0 });
|
||||
}
|
||||
} else {
|
||||
gridRef.current?.scrollTo(0);
|
||||
gridRef.current?.resetLoadMoreItemsCache();
|
||||
|
||||
// Refetching within the virtualized grid may be inconsistent due to it refetching
|
||||
// using an outdated set of filters. To avoid this, we fetch using the updated filters
|
||||
// and then set the grid's data here.
|
||||
const data = await fetch(0, 200, filters);
|
||||
|
||||
if (!data?.items) return;
|
||||
gridRef.current?.setItemData(data.items);
|
||||
}
|
||||
},
|
||||
[page.display, tableRef, setPagination, server, queryClient, gridRef, fetch],
|
||||
);
|
||||
|
||||
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 AlbumArtistListSort,
|
||||
sortOrder: sortOrder || SortOrder.ASC,
|
||||
});
|
||||
|
||||
handleFilterChange(updatedFilters);
|
||||
},
|
||||
[handleFilterChange, server?.type, setFilter],
|
||||
);
|
||||
|
||||
const handleSetMusicFolder = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value) return;
|
||||
|
||||
let updatedFilters = null;
|
||||
if (e.currentTarget.value === String(page.filter.musicFolderId)) {
|
||||
updatedFilters = setFilter({ musicFolderId: undefined });
|
||||
} else {
|
||||
updatedFilters = setFilter({ musicFolderId: e.currentTarget.value });
|
||||
}
|
||||
|
||||
handleFilterChange(updatedFilters);
|
||||
},
|
||||
[handleFilterChange, page.filter.musicFolderId, setFilter],
|
||||
);
|
||||
|
||||
const handleToggleSortOrder = useCallback(() => {
|
||||
const newSortOrder = filters.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;
|
||||
const updatedFilters = setFilter({ sortOrder: newSortOrder });
|
||||
handleFilterChange(updatedFilters);
|
||||
}, [filters.sortOrder, handleFilterChange, setFilter]);
|
||||
|
||||
const handleSetViewType = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value) return;
|
||||
setPage({ list: { ...page, display: e.currentTarget.value as ListDisplayType } });
|
||||
},
|
||||
[page, setPage],
|
||||
);
|
||||
|
||||
const handleSearch = debounce((e: ChangeEvent<HTMLInputElement>) => {
|
||||
const previousSearchTerm = page.filter.searchTerm;
|
||||
const searchTerm = e.target.value === '' ? undefined : e.target.value;
|
||||
const updatedFilters = setFilter({ searchTerm });
|
||||
if (previousSearchTerm !== searchTerm) handleFilterChange(updatedFilters);
|
||||
}, 500);
|
||||
|
||||
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 };
|
||||
|
||||
setTable({ columns: [...existingColumns, newColumn] });
|
||||
} else {
|
||||
// If removing a column
|
||||
const removed = existingColumns.filter((column) => !values.includes(column.column));
|
||||
const newColumns = existingColumns.filter((column) => !removed.includes(column));
|
||||
|
||||
setTable({ columns: newColumns });
|
||||
}
|
||||
|
||||
return tableRef.current?.api.sizeColumnsToFit();
|
||||
};
|
||||
|
||||
const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setTable({ autoFit: e.currentTarget.checked });
|
||||
|
||||
if (e.currentTarget.checked) {
|
||||
tableRef.current?.api.sizeColumnsToFit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageHeader p="1rem">
|
||||
<HeaderItems ref={cq.ref}>
|
||||
<Flex
|
||||
align="center"
|
||||
gap="md"
|
||||
justify="center"
|
||||
>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
px={0}
|
||||
rightIcon={<RiArrowDownSLine size={15} />}
|
||||
size="xl"
|
||||
variant="subtle"
|
||||
>
|
||||
<TextTitle
|
||||
fw="bold"
|
||||
order={3}
|
||||
>
|
||||
Album Artists
|
||||
</TextTitle>
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Label>Display type</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
$isActive={page.display === ListDisplayType.CARD}
|
||||
value={ListDisplayType.CARD}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Card
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={page.display === ListDisplayType.POSTER}
|
||||
value={ListDisplayType.POSTER}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Poster
|
||||
</DropdownMenu.Item>
|
||||
<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.display === ListDisplayType.CARD || page.display === ListDisplayType.POSTER
|
||||
? page.grid.size
|
||||
: page.table.rowHeight
|
||||
}
|
||||
label={null}
|
||||
max={100}
|
||||
min={25}
|
||||
onChangeEnd={handleItemSize}
|
||||
/>
|
||||
</DropdownMenu.Item>
|
||||
{(page.display === ListDisplayType.TABLE ||
|
||||
page.display === ListDisplayType.TABLE_PAGINATED) && (
|
||||
<>
|
||||
<DropdownMenu.Label>Table Columns</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
closeMenuOnClick={false}
|
||||
component="div"
|
||||
sx={{ cursor: 'default' }}
|
||||
>
|
||||
<Stack>
|
||||
<MultiSelect
|
||||
clearable
|
||||
data={ALBUMARTIST_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 === filters.sortBy}
|
||||
value={filter.value}
|
||||
onClick={handleSetSortBy}
|
||||
>
|
||||
{filter.name}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
compact
|
||||
fw="600"
|
||||
variant="subtle"
|
||||
onClick={handleToggleSortOrder}
|
||||
>
|
||||
{cq.isMd ? (
|
||||
sortOrderLabel
|
||||
) : (
|
||||
<>
|
||||
{filters.sortOrder === SortOrder.ASC ? (
|
||||
<RiSortAsc size={15} />
|
||||
) : (
|
||||
<RiSortDesc size={15} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{server?.type === ServerType.JELLYFIN && (
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw="600"
|
||||
variant="subtle"
|
||||
>
|
||||
{cq.isMd ? 'Folder' : <RiFolder2Line size={15} />}
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{musicFoldersQuery.data?.map((folder) => (
|
||||
<DropdownMenu.Item
|
||||
key={`musicFolder-${folder.id}`}
|
||||
$isActive={filters.musicFolderId === folder.id}
|
||||
value={folder.id}
|
||||
onClick={handleSetMusicFolder}
|
||||
>
|
||||
{folder.name}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<Popover position="bottom-start">
|
||||
<Popover.Target>
|
||||
<Button
|
||||
compact
|
||||
fw="600"
|
||||
variant="subtle"
|
||||
>
|
||||
{cq.isMd ? 'Filters' : <RiFilter3Line size={15} />}
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
{/* {server?.type === ServerType.NAVIDROME ? (
|
||||
<NavidromeAlbumFilters handleFilterChange={handleFilterChange} />
|
||||
) : (
|
||||
<JellyfinAlbumFilters handleFilterChange={handleFilterChange} />
|
||||
)} */}
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
variant="subtle"
|
||||
>
|
||||
<RiMoreFill size={15} />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Item disabled>Play</DropdownMenu.Item>
|
||||
<DropdownMenu.Item disabled>Add to queue (next)</DropdownMenu.Item>
|
||||
<DropdownMenu.Item disabled>Add to queue (last)</DropdownMenu.Item>
|
||||
<DropdownMenu.Item disabled>Add to playlist</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
</Flex>
|
||||
<Flex gap="md">
|
||||
<SearchInput
|
||||
defaultValue={page.filter.searchTerm}
|
||||
openedWidth={cq.isLg ? 300 : cq.isMd ? 250 : cq.isSm ? 150 : 75}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
</Flex>
|
||||
</HeaderItems>
|
||||
</PageHeader>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import type { AlbumArtistListQuery, RawAlbumArtistListResponse } from '/@/renderer/api/types';
|
||||
import type { QueryOptions } from '/@/renderer/lib/react-query';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { api } from '/@/renderer/api';
|
||||
|
||||
export const useAlbumArtistList = (query: AlbumArtistListQuery, options?: QueryOptions) => {
|
||||
const server = useCurrentServer();
|
||||
|
||||
return useQuery({
|
||||
enabled: !!server?.id,
|
||||
queryFn: ({ signal }) => api.controller.getAlbumArtistList({ query, server, signal }),
|
||||
queryKey: queryKeys.albumArtists.list(server?.id || '', query),
|
||||
select: useCallback(
|
||||
(data: RawAlbumArtistListResponse | undefined) => api.normalize.albumArtistList(data, server),
|
||||
[server],
|
||||
),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { VirtualGridContainer, VirtualInfiniteGridRef } from '/@/renderer/components';
|
||||
import { AlbumArtistListHeader } from '/@/renderer/features/artists/components/album-artist-list-header';
|
||||
import { AnimatedPage } from '/@/renderer/features/shared';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { useRef } from 'react';
|
||||
import { AlbumArtistListContent } from '/@/renderer/features/artists/components/album-artist-list-content';
|
||||
|
||||
const AlbumArtistListRoute = () => {
|
||||
const gridRef = useRef<VirtualInfiniteGridRef | null>(null);
|
||||
const tableRef = useRef<AgGridReactType | null>(null);
|
||||
|
||||
return (
|
||||
<AnimatedPage>
|
||||
<VirtualGridContainer>
|
||||
<AlbumArtistListHeader
|
||||
gridRef={gridRef}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
<AlbumArtistListContent
|
||||
gridRef={gridRef}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
</VirtualGridContainer>
|
||||
</AnimatedPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlbumArtistListRoute;
|
||||
@@ -29,3 +29,10 @@ export const ARTIST_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||
{ disabled: true, id: 'removeFromFavorites' },
|
||||
{ disabled: true, id: 'setRating' },
|
||||
];
|
||||
|
||||
export const PLAYLIST_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||
{ id: 'play' },
|
||||
{ id: 'playLast' },
|
||||
{ divider: true, id: 'playNext' },
|
||||
{ id: 'deletePlaylist' },
|
||||
];
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { Divider, Stack } from '@mantine/core';
|
||||
import { Divider, Group, Stack } from '@mantine/core';
|
||||
import { useClickOutside, useResizeObserver, useSetState, useViewportSize } from '@mantine/hooks';
|
||||
import { closeAllModals, openModal } from '@mantine/modals';
|
||||
import { createContext, Fragment, useState } from 'react';
|
||||
import { ContextMenu, ContextMenuButton } from '/@/renderer/components';
|
||||
import { ConfirmModal, ContextMenu, ContextMenuButton, Text, toast } from '/@/renderer/components';
|
||||
import {
|
||||
OpenContextMenuProps,
|
||||
SetContextMenuItems,
|
||||
useContextMenuEvents,
|
||||
} from '/@/renderer/features/context-menu/events';
|
||||
import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-handle-playqueue-add';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { useDeletePlaylist } from '/@/renderer/features/playlists';
|
||||
import { LibraryItem, Play } from '/@/renderer/types';
|
||||
|
||||
type ContextMenuContextProps = {
|
||||
@@ -45,7 +47,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
yPos: 0,
|
||||
});
|
||||
|
||||
const handlePlayQueueAdd = useHandlePlayQueueAdd();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
|
||||
const openContextMenu = (args: OpenContextMenuProps) => {
|
||||
const { xPos, yPos, menuItems, data, type } = args;
|
||||
@@ -70,42 +72,96 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
});
|
||||
|
||||
const handlePlay = (play: Play) => {
|
||||
console.log('ctx', ctx);
|
||||
|
||||
switch (ctx.type) {
|
||||
case LibraryItem.ALBUM:
|
||||
handlePlayQueueAdd({
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: { id: ctx.data.map((item) => item.id), type: ctx.type },
|
||||
play,
|
||||
});
|
||||
break;
|
||||
case LibraryItem.ARTIST:
|
||||
handlePlayQueueAdd({
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: { id: ctx.data.map((item) => item.id), type: ctx.type },
|
||||
play,
|
||||
});
|
||||
break;
|
||||
case LibraryItem.ALBUM_ARTIST:
|
||||
handlePlayQueueAdd({
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: { id: ctx.data.map((item) => item.id), type: ctx.type },
|
||||
play,
|
||||
});
|
||||
break;
|
||||
case LibraryItem.SONG:
|
||||
handlePlayQueueAdd({ byData: ctx.data, play });
|
||||
handlePlayQueueAdd?.({ byData: ctx.data, play });
|
||||
break;
|
||||
case LibraryItem.PLAYLIST:
|
||||
handlePlayQueueAdd({
|
||||
byItemType: { id: ctx.data.map((item) => item.id), type: ctx.type },
|
||||
play,
|
||||
});
|
||||
for (const item of ctx.data) {
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: { id: [item.id], type: ctx.type },
|
||||
play,
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const deletePlaylistMutation = useDeletePlaylist();
|
||||
|
||||
const handleDeletePlaylist = () => {
|
||||
for (const item of ctx.data) {
|
||||
deletePlaylistMutation?.mutate(
|
||||
{ query: { id: item.id } },
|
||||
{
|
||||
onError: (err) => {
|
||||
toast.error({
|
||||
message: err.message,
|
||||
title: 'Error deleting playlist',
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success({
|
||||
message: `${item.name} was successfully deleted`,
|
||||
title: 'Playlist deleted',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
closeAllModals();
|
||||
};
|
||||
|
||||
const openDeletePlaylistModal = () => {
|
||||
openModal({
|
||||
children: (
|
||||
<ConfirmModal onConfirm={handleDeletePlaylist}>
|
||||
<Stack>
|
||||
<Text>Are you sure you want to delete the following playlist(s)?</Text>
|
||||
<ul>
|
||||
{ctx.data.map((item) => (
|
||||
<li key={item.id}>
|
||||
<Group>
|
||||
—<Text $secondary>{item.name}</Text>
|
||||
</Group>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Stack>
|
||||
</ConfirmModal>
|
||||
),
|
||||
title: 'Delete playlist(s)',
|
||||
});
|
||||
};
|
||||
|
||||
const contextMenuItems = {
|
||||
addToFavorites: { id: 'addToFavorites', label: 'Add to favorites', onClick: () => {} },
|
||||
addToPlaylist: { id: 'addToPlaylist', label: 'Add to playlist', onClick: () => {} },
|
||||
createPlaylist: { id: 'createPlaylist', label: 'Create playlist', onClick: () => {} },
|
||||
deletePlaylist: {
|
||||
id: 'deletePlaylist',
|
||||
label: 'Delete playlist',
|
||||
onClick: openDeletePlaylistModal,
|
||||
},
|
||||
play: {
|
||||
id: 'play',
|
||||
label: 'Play',
|
||||
|
||||
@@ -21,7 +21,9 @@ export type ContextMenuItem =
|
||||
| 'addToPlaylist'
|
||||
| 'addToFavorites'
|
||||
| 'removeFromFavorites'
|
||||
| 'setRating';
|
||||
| 'setRating'
|
||||
| 'deletePlaylist'
|
||||
| 'createPlaylist';
|
||||
|
||||
export type SetContextMenuItems = {
|
||||
disabled?: boolean;
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { Box, Stack } from '@mantine/core';
|
||||
import { useSetState } from '@mantine/hooks';
|
||||
import { AlbumListSort, ServerType, SortOrder } from '/@/renderer/api/types';
|
||||
import { TextTitle, PageHeader, FeatureCarousel, GridCarousel } from '/@/renderer/components';
|
||||
import { TextTitle, FeatureCarousel, GridCarousel, NativeScrollArea } from '/@/renderer/components';
|
||||
import { useAlbumList } from '/@/renderer/features/albums';
|
||||
import { useRecentlyPlayed } from '/@/renderer/features/home/queries/recently-played-query';
|
||||
import { AnimatedPage } from '/@/renderer/features/shared';
|
||||
import { AnimatedPage, LibraryHeaderBar } from '/@/renderer/features/shared';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
|
||||
const HomeRoute = () => {
|
||||
// const rootElement = document.querySelector(':root') as HTMLElement;
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
const server = useCurrentServer();
|
||||
const cq = useContainerQuery();
|
||||
const itemsPerPage = cq.isXl ? 9 : cq.isLg ? 7 : cq.isMd ? 5 : cq.isSm ? 4 : 3;
|
||||
@@ -111,16 +111,6 @@ const HomeRoute = () => {
|
||||
[pagination, setPagination],
|
||||
);
|
||||
|
||||
// const handleScroll = (position: { x: number; y: number }) => {
|
||||
// if (position.y <= 15) {
|
||||
// return rootElement?.style?.setProperty('--header-opacity', '0');
|
||||
// }
|
||||
|
||||
// return rootElement?.style?.setProperty('--header-opacity', '1');
|
||||
// };
|
||||
|
||||
// const throttledScroll = throttle(handleScroll, 200);
|
||||
|
||||
const carousels = [
|
||||
{
|
||||
data: random?.data?.items,
|
||||
@@ -202,74 +192,73 @@ const HomeRoute = () => {
|
||||
|
||||
return (
|
||||
<AnimatedPage>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<PageHeader
|
||||
useOpacity
|
||||
backgroundColor="var(--sidebar-bg)"
|
||||
/>
|
||||
<NativeScrollArea
|
||||
ref={scrollAreaRef}
|
||||
pageHeaderProps={{
|
||||
backgroundColor: 'var(--titlebar-bg)',
|
||||
children: (
|
||||
<LibraryHeaderBar>
|
||||
<LibraryHeaderBar.Title>Home</LibraryHeaderBar.Title>
|
||||
</LibraryHeaderBar>
|
||||
),
|
||||
offset: ['0px', '200px'],
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
mb="1rem"
|
||||
mt="-1.5rem"
|
||||
px="1rem"
|
||||
ref={cq.ref}
|
||||
pt="3rem"
|
||||
px="2rem"
|
||||
sx={{
|
||||
height: '100%',
|
||||
overflow: 'auto',
|
||||
maxWidth: '1920px',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
ref={cq.ref}
|
||||
sx={{
|
||||
height: '100%',
|
||||
maxWidth: '1920px',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Stack spacing={35}>
|
||||
<FeatureCarousel data={featureItemsWithImage} />
|
||||
{carousels
|
||||
.filter((carousel) => {
|
||||
if (
|
||||
server?.type === ServerType.JELLYFIN &&
|
||||
carousel.uniqueId === 'recentlyPlayed'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
<Stack spacing={35}>
|
||||
<FeatureCarousel data={featureItemsWithImage} />
|
||||
{carousels
|
||||
.filter((carousel) => {
|
||||
if (
|
||||
server?.type === ServerType.JELLYFIN &&
|
||||
carousel.uniqueId === 'recentlyPlayed'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return carousel;
|
||||
})
|
||||
.map((carousel, index) => (
|
||||
<GridCarousel
|
||||
key={`carousel-${carousel.uniqueId}-${index}`}
|
||||
cardRows={[
|
||||
{
|
||||
property: 'name',
|
||||
route: {
|
||||
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
|
||||
},
|
||||
return carousel;
|
||||
})
|
||||
.map((carousel, index) => (
|
||||
<GridCarousel
|
||||
key={`carousel-${carousel.uniqueId}-${index}`}
|
||||
cardRows={[
|
||||
{
|
||||
property: 'name',
|
||||
route: {
|
||||
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
|
||||
},
|
||||
{
|
||||
arrayProperty: 'name',
|
||||
property: 'albumArtists',
|
||||
route: {
|
||||
route: AppRoute.LIBRARY_ALBUMARTISTS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
arrayProperty: 'name',
|
||||
property: 'albumArtists',
|
||||
route: {
|
||||
route: AppRoute.LIBRARY_ALBUMARTISTS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
|
||||
},
|
||||
]}
|
||||
containerWidth={cq.width}
|
||||
data={carousel.data}
|
||||
loading={carousel.loading}
|
||||
pagination={carousel.pagination}
|
||||
uniqueId={carousel.uniqueId}
|
||||
>
|
||||
<GridCarousel.Title>{carousel.title}</GridCarousel.Title>
|
||||
</GridCarousel>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
},
|
||||
]}
|
||||
containerWidth={cq.width}
|
||||
data={carousel.data}
|
||||
loading={carousel.loading}
|
||||
pagination={carousel.pagination}
|
||||
uniqueId={carousel.uniqueId}
|
||||
>
|
||||
<GridCarousel.Title>{carousel.title}</GridCarousel.Title>
|
||||
</GridCarousel>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
</NativeScrollArea>
|
||||
</AnimatedPage>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,50 +1,13 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Group } from '@mantine/core';
|
||||
import { FastAverageColor } from 'fast-average-color';
|
||||
import { PageHeader, TextTitle } from '/@/renderer/components';
|
||||
import { useCurrentSong } from '/@/renderer/store';
|
||||
import { getHeaderColor } from '/@/renderer/utils';
|
||||
import { useTheme } from '/@/renderer/hooks';
|
||||
|
||||
export const NowPlayingHeader = () => {
|
||||
const [headerColor, setHeaderColor] = useState({ isDark: false, value: 'rgba(0, 0, 0, 0)' });
|
||||
const currentSong = useCurrentSong();
|
||||
const theme = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
const fac = new FastAverageColor();
|
||||
const url = currentSong?.imageUrl;
|
||||
|
||||
if (url) {
|
||||
fac
|
||||
.getColorAsync(currentSong?.imageUrl, {
|
||||
algorithm: 'simple',
|
||||
ignoredColor: [
|
||||
[255, 255, 255, 255], // White
|
||||
[0, 0, 0, 255], // Black
|
||||
],
|
||||
mode: 'precision',
|
||||
})
|
||||
.then((color) => {
|
||||
const isDark = color.isDark;
|
||||
return setHeaderColor({
|
||||
isDark,
|
||||
value: getHeaderColor(color.rgb, theme === 'dark' ? 0.5 : 0.8),
|
||||
});
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
fac.destroy();
|
||||
};
|
||||
}, [currentSong?.imageUrl, theme]);
|
||||
// const currentSong = useCurrentSong();
|
||||
// const theme = useTheme();
|
||||
|
||||
return (
|
||||
<PageHeader backgroundColor={headerColor.value}>
|
||||
<Group>
|
||||
<PageHeader>
|
||||
<Group p="1rem">
|
||||
<TextTitle
|
||||
fw="bold"
|
||||
order={3}
|
||||
|
||||
@@ -1,35 +1,58 @@
|
||||
import { useRef } from 'react';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { Box, Stack } from '@mantine/core';
|
||||
import { Flex } from '@mantine/core';
|
||||
import { PlayQueue } from '/@/renderer/features/now-playing/components/play-queue';
|
||||
import styled from 'styled-components';
|
||||
import { PlayQueueListControls } from './play-queue-list-controls';
|
||||
import { Song } from '/@/renderer/api/types';
|
||||
|
||||
const BackgroundImageOverlay = styled.div`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 30%), var(--background-noise);
|
||||
`;
|
||||
|
||||
export const SidebarPlayQueue = () => {
|
||||
const queueRef = useRef<{ grid: AgGridReactType<Song> } | null>(null);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
h="100%"
|
||||
spacing={0}
|
||||
sx={{ borderLeft: '2px solid var(--generic-border-color)' }}
|
||||
>
|
||||
<Box
|
||||
h="50px"
|
||||
mr="160px"
|
||||
sx={{
|
||||
WebkitAppRegion: 'drag',
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
<PlayQueue
|
||||
ref={queueRef}
|
||||
type="sideQueue"
|
||||
/>
|
||||
<PlayQueueListControls
|
||||
tableRef={queueRef}
|
||||
type="sideQueue"
|
||||
/>
|
||||
</Stack>
|
||||
<>
|
||||
<Flex
|
||||
bg="var(--titlebar-bg)"
|
||||
h="60px"
|
||||
sx={{ position: 'relative' }}
|
||||
w="100%"
|
||||
>
|
||||
<BackgroundImageOverlay />
|
||||
<Flex
|
||||
h="100%"
|
||||
mr="160px"
|
||||
sx={{
|
||||
WebkitAppRegion: 'drag',
|
||||
background: 'var(--titlebar-bg)',
|
||||
}}
|
||||
w="100%"
|
||||
/>
|
||||
</Flex>
|
||||
<Flex
|
||||
direction="column"
|
||||
h="calc(100% - 60px)"
|
||||
sx={{ borderLeft: '2px solid var(--generic-border-color)' }}
|
||||
w="100%"
|
||||
>
|
||||
<PlayQueue
|
||||
ref={queueRef}
|
||||
type="sideQueue"
|
||||
/>
|
||||
<PlayQueueListControls
|
||||
tableRef={queueRef}
|
||||
type="sideQueue"
|
||||
/>
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createContext } from 'react';
|
||||
import { PlayQueueAddOptions } from '/@/renderer/types';
|
||||
|
||||
export const PlayQueueHandlerContext = createContext<{
|
||||
handlePlayQueueAdd: ((options: PlayQueueAddOptions) => void) | undefined;
|
||||
}>({
|
||||
handlePlayQueueAdd: undefined,
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '/@/renderer/api/index';
|
||||
import { jfNormalize } from '/@/renderer/api/jellyfin.api';
|
||||
@@ -5,7 +6,12 @@ import { JFSong } from '/@/renderer/api/jellyfin.types';
|
||||
import { ndNormalize } from '/@/renderer/api/navidrome.api';
|
||||
import { NDSong } from '/@/renderer/api/navidrome.types';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { useAuthStore, usePlayerStore } from '/@/renderer/store';
|
||||
import {
|
||||
useAuthStore,
|
||||
useCurrentServer,
|
||||
usePlayerControls,
|
||||
usePlayerStore,
|
||||
} from '/@/renderer/store';
|
||||
import { usePlayerType } from '/@/renderer/store/settings.store';
|
||||
import { PlayQueueAddOptions, LibraryItem, Play, PlaybackType } from '/@/renderer/types';
|
||||
import { toast } from '/@/renderer/components/toast';
|
||||
@@ -19,43 +25,69 @@ export const useHandlePlayQueueAdd = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const playerType = usePlayerType();
|
||||
const deviceId = useAuthStore.getState().deviceId;
|
||||
const server = useAuthStore.getState().currentServer;
|
||||
const server = useCurrentServer();
|
||||
const { play } = usePlayerControls();
|
||||
|
||||
const handlePlayQueueAdd = async (options: PlayQueueAddOptions) => {
|
||||
if (!server) return toast.error({ message: 'No server selected', type: 'error' });
|
||||
let songs = null;
|
||||
const handlePlayQueueAdd = useCallback(
|
||||
async (options: PlayQueueAddOptions) => {
|
||||
if (!server) return toast.error({ message: 'No server selected', type: 'error' });
|
||||
let songs = null;
|
||||
|
||||
if (options.byItemType) {
|
||||
if (options.byItemType.type === LibraryItem.ALBUM) {
|
||||
// const albumDetail = await queryClient.fetchQuery(
|
||||
// queryKeys.albums.detail(server?.id, { id: options.byItemType.id }),
|
||||
// async ({ signal }) =>
|
||||
// api.controller.getAlbumDetail({
|
||||
// query: { id: options.byItemType!.id },
|
||||
// server,
|
||||
// signal,
|
||||
// }),
|
||||
// );
|
||||
|
||||
// if (!albumDetail) return null;
|
||||
|
||||
const queryFilter = {
|
||||
albumIds: options.byItemType?.id || [],
|
||||
sortBy: SongListSort.ALBUM,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.songs.list(server?.id, queryFilter);
|
||||
if (options.byItemType) {
|
||||
let songsList;
|
||||
try {
|
||||
songsList = await queryClient.fetchQuery(queryKey, async ({ signal }) =>
|
||||
api.controller.getSongList({
|
||||
query: queryFilter,
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
let queryFilter: any;
|
||||
let queryKey: any;
|
||||
if (options.byItemType.type === LibraryItem.PLAYLIST) {
|
||||
queryFilter = {
|
||||
id: options.byItemType?.id || [],
|
||||
sortBy: 'id',
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
};
|
||||
|
||||
queryKey = queryKeys.playlists.songList(
|
||||
server?.id,
|
||||
options.byItemType?.id?.[0] || '',
|
||||
queryFilter,
|
||||
);
|
||||
} else if (options.byItemType.type === LibraryItem.ALBUM) {
|
||||
queryFilter = {
|
||||
albumIds: options.byItemType?.id || [],
|
||||
sortBy: SongListSort.ALBUM,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
};
|
||||
|
||||
queryKey = queryKeys.songs.list(server?.id, queryFilter);
|
||||
} else if (options.byItemType.type === LibraryItem.ALBUM_ARTIST) {
|
||||
queryFilter = {
|
||||
artistIds: options.byItemType?.id || [],
|
||||
sortBy: SongListSort.ALBUM,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
};
|
||||
|
||||
queryKey = queryKeys.songs.list(server?.id, queryFilter);
|
||||
}
|
||||
|
||||
try {
|
||||
if (options.byItemType?.type === LibraryItem.PLAYLIST) {
|
||||
songsList = await queryClient.fetchQuery(queryKey, async ({ signal }) =>
|
||||
api.controller.getPlaylistSongList({
|
||||
query: queryFilter,
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
songsList = await queryClient.fetchQuery(queryKey, async ({ signal }) =>
|
||||
api.controller.getSongList({
|
||||
query: queryFilter,
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
return toast.error({
|
||||
message: err.message,
|
||||
@@ -79,34 +111,33 @@ export const useHandlePlayQueueAdd = () => {
|
||||
case 'subsonic':
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options.byData) {
|
||||
songs = options.byData.map((song) => ({ ...song, uniqueId: nanoid() }));
|
||||
}
|
||||
|
||||
if (!songs) return toast.warn({ message: 'No songs found' });
|
||||
|
||||
const playerData = usePlayerStore.getState().actions.addToQueue(songs, options.play);
|
||||
|
||||
if (options.play === Play.NEXT || options.play === Play.LAST) {
|
||||
if (playerType === PlaybackType.LOCAL) {
|
||||
mpvPlayer.setQueueNext(playerData);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.play === Play.NOW) {
|
||||
if (playerType === PlaybackType.LOCAL) {
|
||||
mpvPlayer.setQueue(playerData);
|
||||
mpvPlayer.play();
|
||||
} else if (options.byData) {
|
||||
songs = options.byData.map((song) => ({ ...song, uniqueId: nanoid() }));
|
||||
}
|
||||
|
||||
usePlayerStore.getState().actions.play();
|
||||
}
|
||||
if (!songs) return toast.warn({ message: 'No songs found' });
|
||||
|
||||
return null;
|
||||
};
|
||||
const playerData = usePlayerStore.getState().actions.addToQueue(songs, options.play);
|
||||
|
||||
if (options.play === Play.NEXT || options.play === Play.LAST) {
|
||||
if (playerType === PlaybackType.LOCAL) {
|
||||
mpvPlayer.setQueueNext(playerData);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.play === Play.NOW) {
|
||||
if (playerType === PlaybackType.LOCAL) {
|
||||
mpvPlayer.setQueue(playerData);
|
||||
mpvPlayer.play();
|
||||
}
|
||||
|
||||
play();
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[deviceId, play, playerType, queryClient, server],
|
||||
);
|
||||
|
||||
return handlePlayQueueAdd;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { useContext } from 'react';
|
||||
import { PlayQueueHandlerContext } from '/@/renderer/features/player/context/play-queue-handler-context';
|
||||
|
||||
export const usePlayQueueAdd = () => {
|
||||
const { handlePlayQueueAdd } = useContext(PlayQueueHandlerContext);
|
||||
return handlePlayQueueAdd;
|
||||
};
|
||||
@@ -2,3 +2,5 @@ export * from './components/center-controls';
|
||||
export * from './components/left-controls';
|
||||
export * from './components/playerbar';
|
||||
export * from './components/slider';
|
||||
export * from './context/play-queue-handler-context';
|
||||
export * from './hooks/use-playqueue-add';
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Group, Stack } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { CreatePlaylistQuery, ServerType } from '/@/renderer/api/types';
|
||||
import { Button, Switch, TextInput, toast } from '/@/renderer/components';
|
||||
import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/create-playlist-mutation';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
|
||||
interface CreatePlaylistFormProps {
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
|
||||
const mutation = useCreatePlaylist();
|
||||
const server = useCurrentServer();
|
||||
|
||||
const form = useForm<CreatePlaylistQuery>({
|
||||
initialValues: {
|
||||
comment: '',
|
||||
name: '',
|
||||
public: false,
|
||||
rules: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = form.onSubmit((values) => {
|
||||
mutation.mutate(
|
||||
{ query: values },
|
||||
{
|
||||
onError: (err) => {
|
||||
toast.error({ message: err.message, title: 'Error creating playlist' });
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success({ message: 'Playlist created successfully' });
|
||||
onCancel();
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const isPublicDisplayed = server?.type === ServerType.NAVIDROME;
|
||||
const isSubmitDisabled = !form.values.name || mutation.isLoading;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
data-autofocus
|
||||
required
|
||||
label="Name"
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Description"
|
||||
{...form.getInputProps('comment')}
|
||||
/>
|
||||
{isPublicDisplayed && (
|
||||
<Switch
|
||||
label="Is Public?"
|
||||
{...form.getInputProps('public')}
|
||||
/>
|
||||
)}
|
||||
<Group position="right">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isSubmitDisabled}
|
||||
loading={mutation.isLoading}
|
||||
type="submit"
|
||||
variant="filled"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
import { CellContextMenuEvent, ColDef } from '@ag-grid-community/core';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { Group } from '@mantine/core';
|
||||
import { sortBy } from 'lodash';
|
||||
import { MutableRefObject, useMemo } from 'react';
|
||||
import { generatePath, useParams } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { Button, getColumnDefs, Text, VirtualTable } from '/@/renderer/components';
|
||||
import { openContextMenu } from '/@/renderer/features/context-menu';
|
||||
import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
|
||||
import { usePlaylistSongListInfinite } from '/@/renderer/features/playlists/queries/playlist-song-list-query';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useSongListStore } from '/@/renderer/store';
|
||||
import { LibraryItem } from '/@/renderer/types';
|
||||
|
||||
const ContentContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 1920px;
|
||||
padding: 1rem 2rem 5rem;
|
||||
overflow: hidden;
|
||||
|
||||
.ag-theme-alpine-dark {
|
||||
--ag-header-background-color: rgba(0, 0, 0, 0%);
|
||||
}
|
||||
|
||||
.ag-header-container {
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.ag-header-cell-resize {
|
||||
top: 25%;
|
||||
width: 7px;
|
||||
height: 50%;
|
||||
background-color: rgb(70, 70, 70, 20%);
|
||||
}
|
||||
`;
|
||||
|
||||
interface PlaylistDetailContentProps {
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const PlaylistDetailContent = ({ tableRef }: PlaylistDetailContentProps) => {
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
const page = useSongListStore();
|
||||
|
||||
const playlistSongsQueryInfinite = usePlaylistSongListInfinite(
|
||||
{
|
||||
id: playlistId,
|
||||
limit: 50,
|
||||
startIndex: 0,
|
||||
},
|
||||
{ keepPreviousData: false },
|
||||
);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
playlistSongsQueryInfinite.fetchNextPage();
|
||||
};
|
||||
|
||||
const columnDefs: ColDef[] = useMemo(
|
||||
() =>
|
||||
getColumnDefs(page.table.columns).filter((c) => c.colId !== 'album' && c.colId !== 'artist'),
|
||||
[page.table.columns],
|
||||
);
|
||||
|
||||
const defaultColumnDefs: ColDef = useMemo(() => {
|
||||
return {
|
||||
lockPinned: true,
|
||||
lockVisible: true,
|
||||
resizable: true,
|
||||
};
|
||||
}, []);
|
||||
|
||||
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: SONG_CONTEXT_MENU_ITEMS,
|
||||
type: LibraryItem.SONG,
|
||||
xPos: clickEvent.clientX,
|
||||
yPos: clickEvent.clientY,
|
||||
});
|
||||
};
|
||||
|
||||
const playlistSongData = useMemo(
|
||||
() => playlistSongsQueryInfinite.data?.pages.flatMap((p) => p.items),
|
||||
[playlistSongsQueryInfinite.data?.pages],
|
||||
);
|
||||
|
||||
return (
|
||||
<ContentContainer>
|
||||
<VirtualTable
|
||||
ref={tableRef}
|
||||
animateRows
|
||||
detailRowAutoHeight
|
||||
maintainColumnOrder
|
||||
suppressCellFocus
|
||||
suppressCopyRowsToClipboard
|
||||
suppressLoadingOverlay
|
||||
suppressMoveWhenRowDragging
|
||||
suppressPaginationPanel
|
||||
suppressRowDrag
|
||||
suppressScrollOnNewData
|
||||
columnDefs={columnDefs}
|
||||
defaultColDef={defaultColumnDefs}
|
||||
enableCellChangeFlash={false}
|
||||
getRowId={(data) => data.data.uniqueId}
|
||||
rowData={playlistSongData}
|
||||
rowHeight={60}
|
||||
rowSelection="multiple"
|
||||
onCellContextMenu={handleContextMenu}
|
||||
onGridReady={(params) => {
|
||||
params.api.setDomLayout('autoHeight');
|
||||
params.api.sizeColumnsToFit();
|
||||
}}
|
||||
onGridSizeChanged={(params) => {
|
||||
params.api.sizeColumnsToFit();
|
||||
}}
|
||||
/>
|
||||
<Group
|
||||
p="2rem"
|
||||
position="center"
|
||||
>
|
||||
<Button
|
||||
compact
|
||||
disabled={!playlistSongsQueryInfinite.hasNextPage}
|
||||
loading={playlistSongsQueryInfinite.isFetchingNextPage}
|
||||
variant="subtle"
|
||||
onClick={handleLoadMore}
|
||||
>
|
||||
Load more
|
||||
</Button>
|
||||
<Text>or</Text>
|
||||
<Button
|
||||
compact
|
||||
component={Link}
|
||||
to={generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId })}
|
||||
variant="subtle"
|
||||
>
|
||||
View full playlist
|
||||
</Button>
|
||||
</Group>
|
||||
</ContentContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,183 @@
|
||||
import { Group, Stack } from '@mantine/core';
|
||||
import { closeAllModals, openModal } from '@mantine/modals';
|
||||
import { forwardRef, Fragment, Ref } from 'react';
|
||||
import { RiMoreFill } from 'react-icons/ri';
|
||||
import { generatePath, useNavigate, useParams } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { DropdownMenu, Button, ConfirmModal, toast, Text } from '/@/renderer/components';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { UpdatePlaylistForm } from './update-playlist-form';
|
||||
import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation';
|
||||
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
|
||||
import { LibraryHeader, PlayButton, PLAY_TYPES } from '/@/renderer/features/shared';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { LibraryItem, Play } from '/@/renderer/types';
|
||||
import { formatDurationString } from '/@/renderer/utils';
|
||||
|
||||
interface PlaylistDetailHeaderProps {
|
||||
background: string;
|
||||
imagePlaceholderUrl?: string | null;
|
||||
imageUrl?: string | null;
|
||||
}
|
||||
|
||||
export const PlaylistDetailHeader = forwardRef(
|
||||
(
|
||||
{ background, imageUrl, imagePlaceholderUrl }: PlaylistDetailHeaderProps,
|
||||
ref: Ref<HTMLDivElement>,
|
||||
) => {
|
||||
const navigate = useNavigate();
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
const detailQuery = usePlaylistDetail({ id: playlistId });
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
const handlePlay = (playType?: Play) => {
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: [playlistId],
|
||||
type: LibraryItem.PLAYLIST,
|
||||
},
|
||||
play: playType || playButtonBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
const openUpdatePlaylistModal = () => {
|
||||
openModal({
|
||||
children: (
|
||||
<UpdatePlaylistForm
|
||||
body={{
|
||||
comment: detailQuery?.data?.description || undefined,
|
||||
genres: detailQuery?.data?.genres,
|
||||
name: detailQuery?.data?.name,
|
||||
public: detailQuery?.data?.public || false,
|
||||
rules: detailQuery?.data?.rules || undefined,
|
||||
}}
|
||||
query={{ id: playlistId }}
|
||||
onCancel={closeAllModals}
|
||||
/>
|
||||
),
|
||||
title: 'Edit playlist',
|
||||
});
|
||||
};
|
||||
|
||||
const deletePlaylistMutation = useDeletePlaylist();
|
||||
|
||||
const handleDeletePlaylist = () => {
|
||||
deletePlaylistMutation.mutate(
|
||||
{ query: { id: playlistId } },
|
||||
{
|
||||
onError: (err) => {
|
||||
toast.error({
|
||||
message: err.message,
|
||||
title: 'Error deleting playlist',
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success({
|
||||
message: `${detailQuery?.data?.name} was successfully deleted`,
|
||||
title: 'Playlist deleted',
|
||||
});
|
||||
closeAllModals();
|
||||
navigate(AppRoute.PLAYLISTS);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const openDeletePlaylist = () => {
|
||||
openModal({
|
||||
children: (
|
||||
<ConfirmModal
|
||||
loading={deletePlaylistMutation.isLoading}
|
||||
onConfirm={handleDeletePlaylist}
|
||||
>
|
||||
Are you sure you want to delete this playlist?
|
||||
</ConfirmModal>
|
||||
),
|
||||
title: 'Delete playlist',
|
||||
});
|
||||
};
|
||||
|
||||
const metadataItems = [
|
||||
{
|
||||
id: 'songCount',
|
||||
secondary: false,
|
||||
value: `${detailQuery?.data?.songCount || 0} songs`,
|
||||
},
|
||||
{
|
||||
id: 'duration',
|
||||
secondary: true,
|
||||
value: detailQuery?.data?.duration && formatDurationString(detailQuery.data.duration),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<LibraryHeader
|
||||
ref={ref}
|
||||
background={background}
|
||||
imagePlaceholderUrl={imagePlaceholderUrl}
|
||||
imageUrl={imageUrl}
|
||||
item={{ route: AppRoute.PLAYLISTS, type: LibraryItem.PLAYLIST }}
|
||||
title={detailQuery?.data?.name || ''}
|
||||
>
|
||||
<Stack mt="1rem">
|
||||
<Group>
|
||||
{metadataItems.map((item, index) => (
|
||||
<Fragment key={`item-${item.id}-${index}`}>
|
||||
{index > 0 && <Text $noSelect>•</Text>}
|
||||
<Text $secondary={item.secondary}>{item.value}</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
</Group>
|
||||
<Text lineClamp={3}>{detailQuery?.data?.description}</Text>
|
||||
</Stack>
|
||||
</LibraryHeader>
|
||||
<Group
|
||||
maw="1920px"
|
||||
p="1rem"
|
||||
position="apart"
|
||||
>
|
||||
<Group>
|
||||
<PlayButton onClick={() => handlePlay()} />
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
variant="subtle"
|
||||
>
|
||||
<RiMoreFill size={20} />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{PLAY_TYPES.filter((type) => type.play !== playButtonBehavior).map((type) => (
|
||||
<DropdownMenu.Item
|
||||
key={`playtype-${type.play}`}
|
||||
onClick={() => handlePlay(type.play)}
|
||||
>
|
||||
{type.label}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Item onClick={openUpdatePlaylistModal}>
|
||||
Edit playlist
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onClick={openDeletePlaylist}>Delete playlist</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
compact
|
||||
component={Link}
|
||||
to={generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId })}
|
||||
variant="subtle"
|
||||
>
|
||||
View full playlist
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,271 @@
|
||||
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 { getColumnDefs, TablePagination, VirtualTable } from '/@/renderer/components';
|
||||
import {
|
||||
useCurrentServer,
|
||||
usePlaylistDetailStore,
|
||||
usePlaylistDetailTablePagination,
|
||||
useSetPlaylistDetailTable,
|
||||
useSetPlaylistDetailTablePagination,
|
||||
} from '/@/renderer/store';
|
||||
import { LibraryItem, ListDisplayType } from '/@/renderer/types';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { openContextMenu } from '/@/renderer/features/context-menu';
|
||||
import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
|
||||
import sortBy from 'lodash/sortBy';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { PlaylistSongListQuery, QueueSong, SongListSort, SortOrder } from '/@/renderer/api/types';
|
||||
import { usePlaylistSongList } from '/@/renderer/features/playlists/queries/playlist-song-list-query';
|
||||
import { useParams } from 'react-router';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
|
||||
interface PlaylistDetailContentProps {
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailContentProps) => {
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
const page = usePlaylistDetailStore();
|
||||
const filters: Partial<PlaylistSongListQuery> = useMemo(() => {
|
||||
return {
|
||||
sortBy: page?.table.id[playlistId]?.filter?.sortBy || SongListSort.ID,
|
||||
sortOrder: page?.table.id[playlistId]?.filter?.sortOrder || SortOrder.ASC,
|
||||
};
|
||||
}, [page?.table.id, playlistId]);
|
||||
|
||||
const p = usePlaylistDetailTablePagination(playlistId);
|
||||
const pagination = {
|
||||
currentPage: p?.currentPage || 0,
|
||||
itemsPerPage: p?.itemsPerPage || 100,
|
||||
scrollOffset: p?.scrollOffset || 0,
|
||||
totalItems: p?.totalItems || 1,
|
||||
totalPages: p?.totalPages || 1,
|
||||
};
|
||||
|
||||
const setPagination = useSetPlaylistDetailTablePagination();
|
||||
const setTable = useSetPlaylistDetailTable();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
const isPaginationEnabled = page.display === ListDisplayType.TABLE_PAGINATED;
|
||||
|
||||
const checkPlaylistList = usePlaylistSongList({
|
||||
id: playlistId,
|
||||
limit: 1,
|
||||
startIndex: 0,
|
||||
});
|
||||
|
||||
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.songList(server?.id || '', playlistId, {
|
||||
id: playlistId,
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
});
|
||||
|
||||
const songsRes = await queryClient.fetchQuery(queryKey, async ({ signal }) =>
|
||||
api.controller.getPlaylistSongList({
|
||||
query: {
|
||||
id: playlistId,
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
},
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
|
||||
const songs = api.normalize.songList(songsRes, server);
|
||||
params.successCallback(songs?.items || [], songsRes?.totalRecordCount);
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
params.api.setDatasource(dataSource);
|
||||
params.api.ensureIndexVisible(pagination.scrollOffset, 'top');
|
||||
},
|
||||
[filters, pagination.scrollOffset, playlistId, 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(playlistId, {
|
||||
itemsPerPage: event.api.paginationGetPageSize(),
|
||||
totalItems: event.api.paginationGetRowCount(),
|
||||
totalPages: event.api.paginationGetTotalPages() + 1,
|
||||
});
|
||||
},
|
||||
[
|
||||
isPaginationEnabled,
|
||||
pagination.currentPage,
|
||||
pagination.itemsPerPage,
|
||||
playlistId,
|
||||
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));
|
||||
setPagination(playlistId, { 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: SONG_CONTEXT_MENU_ITEMS,
|
||||
type: LibraryItem.SONG,
|
||||
xPos: clickEvent.clientX,
|
||||
yPos: clickEvent.clientY,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
|
||||
if (!e.data) return;
|
||||
handlePlayQueueAdd?.({
|
||||
byData: [e.data],
|
||||
play: playButtonBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<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={500}
|
||||
cacheOverflowSize={1}
|
||||
columnDefs={columnDefs}
|
||||
defaultColDef={defaultColumnDefs}
|
||||
enableCellChangeFlash={false}
|
||||
getRowId={(data) => data.data.uniqueId}
|
||||
infiniteInitialRowCount={checkPlaylistList.data?.totalRecordCount || 100}
|
||||
pagination={isPaginationEnabled}
|
||||
paginationAutoPageSize={isPaginationEnabled}
|
||||
paginationPageSize={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}
|
||||
/>
|
||||
<AnimatePresence
|
||||
presenceAffectsLayout
|
||||
initial={false}
|
||||
mode="wait"
|
||||
>
|
||||
{page.display === ListDisplayType.TABLE_PAGINATED && (
|
||||
<TablePagination
|
||||
id={playlistId}
|
||||
pagination={pagination}
|
||||
setIdPagination={setPagination}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,386 @@
|
||||
import { 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 { useQueryClient } from '@tanstack/react-query';
|
||||
import { ChangeEvent, MutableRefObject, useCallback, MouseEvent } from 'react';
|
||||
import { RiArrowDownSLine, RiMoreFill, RiSortAsc, RiSortDesc } from 'react-icons/ri';
|
||||
import { useParams } from 'react-router';
|
||||
import styled from 'styled-components';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { PlaylistSongListQuery, SongListSort, SortOrder } from '/@/renderer/api/types';
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
MultiSelect,
|
||||
PageHeader,
|
||||
Slider,
|
||||
SONG_TABLE_COLUMNS,
|
||||
Switch,
|
||||
Text,
|
||||
TextTitle,
|
||||
} from '/@/renderer/components';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import {
|
||||
useCurrentServer,
|
||||
usePlaylistDetailStore,
|
||||
useSetPlaylistTablePagination,
|
||||
useSetPlaylistDetailTable,
|
||||
SongListFilter,
|
||||
useSetPlaylistDetailFilters,
|
||||
useSetPlaylistStore,
|
||||
} from '/@/renderer/store';
|
||||
import { LibraryItem, ListDisplayType, Play, TableColumn } from '/@/renderer/types';
|
||||
|
||||
const FILTERS = {
|
||||
jellyfin: [
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Album', value: SongListSort.ALBUM },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Album Artist', value: SongListSort.ALBUM_ARTIST },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Artist', value: SongListSort.ARTIST },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Duration', value: SongListSort.DURATION },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Most Played', value: SongListSort.PLAY_COUNT },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Name', value: SongListSort.NAME },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Random', value: SongListSort.RANDOM },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Recently Added', value: SongListSort.RECENTLY_ADDED },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Recently Played', value: SongListSort.RECENTLY_PLAYED },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Release Date', value: SongListSort.RELEASE_DATE },
|
||||
],
|
||||
navidrome: [
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Album', value: SongListSort.ALBUM },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Album Artist', value: SongListSort.ALBUM_ARTIST },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Artist', value: SongListSort.ARTIST },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'BPM', value: SongListSort.BPM },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Channels', value: SongListSort.CHANNELS },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Comment', value: SongListSort.COMMENT },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Duration', value: SongListSort.DURATION },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Favorited', value: SongListSort.FAVORITED },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Genre', value: SongListSort.GENRE },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Id', value: SongListSort.ID },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Name', value: SongListSort.NAME },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Play Count', value: SongListSort.PLAY_COUNT },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Rating', value: SongListSort.RATING },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Recently Added', value: SongListSort.RECENTLY_ADDED },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Recently Played', value: SongListSort.RECENTLY_PLAYED },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Year', value: SongListSort.YEAR },
|
||||
],
|
||||
};
|
||||
|
||||
const ORDER = [
|
||||
{ name: 'Ascending', value: SortOrder.ASC },
|
||||
{ name: 'Descending', value: SortOrder.DESC },
|
||||
];
|
||||
|
||||
const HeaderItems = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
interface PlaylistDetailHeaderProps {
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const PlaylistDetailSongListHeader = ({ tableRef }: PlaylistDetailHeaderProps) => {
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
const setPage = useSetPlaylistStore();
|
||||
const setFilter = useSetPlaylistDetailFilters();
|
||||
const page = usePlaylistDetailStore();
|
||||
const filters: Partial<PlaylistSongListQuery> = {
|
||||
sortBy: page?.table.id[playlistId]?.filter?.sortBy || SongListSort.ID,
|
||||
sortOrder: page?.table.id[playlistId]?.filter?.sortOrder || SortOrder.ASC,
|
||||
};
|
||||
|
||||
const detailQuery = usePlaylistDetail({ id: playlistId });
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
|
||||
const cq = useContainerQuery();
|
||||
|
||||
const setPagination = useSetPlaylistTablePagination();
|
||||
const setTable = useSetPlaylistDetailTable();
|
||||
|
||||
const sortByLabel =
|
||||
(server?.type &&
|
||||
FILTERS[server.type as keyof typeof FILTERS].find((f) => f.value === filters.sortBy)?.name) ||
|
||||
'Unknown';
|
||||
|
||||
const sortOrderLabel = ORDER.find((o) => o.value === filters.sortOrder)?.name || 'Unknown';
|
||||
|
||||
const handleItemSize = (e: number) => {
|
||||
setTable({ rowHeight: e });
|
||||
};
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
async (filters: SongListFilter) => {
|
||||
const dataSource: IDatasource = {
|
||||
getRows: async (params) => {
|
||||
const limit = params.endRow - params.startRow;
|
||||
const startIndex = params.startRow;
|
||||
|
||||
const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId, {
|
||||
id: playlistId,
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
});
|
||||
|
||||
const songsRes = await queryClient.fetchQuery(queryKey, async ({ signal }) =>
|
||||
api.controller.getPlaylistSongList({
|
||||
query: {
|
||||
id: playlistId,
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
},
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
|
||||
const songs = api.normalize.songList(songsRes, server);
|
||||
params.successCallback(songs?.items || [], songsRes?.totalRecordCount || undefined);
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
tableRef.current?.api.setDatasource(dataSource);
|
||||
tableRef.current?.api.purgeInfiniteCache();
|
||||
tableRef.current?.api.ensureIndexVisible(0, 'top');
|
||||
|
||||
if (page.display === ListDisplayType.TABLE_PAGINATED) {
|
||||
setPagination({ currentPage: 0 });
|
||||
}
|
||||
},
|
||||
[tableRef, page.display, server, playlistId, queryClient, setPagination],
|
||||
);
|
||||
|
||||
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(playlistId, {
|
||||
sortBy: e.currentTarget.value as SongListSort,
|
||||
sortOrder: sortOrder || SortOrder.ASC,
|
||||
});
|
||||
|
||||
handleFilterChange(updatedFilters);
|
||||
},
|
||||
[handleFilterChange, playlistId, server?.type, setFilter],
|
||||
);
|
||||
|
||||
const handleToggleSortOrder = useCallback(() => {
|
||||
const newSortOrder = filters.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;
|
||||
const updatedFilters = setFilter(playlistId, { sortOrder: newSortOrder });
|
||||
handleFilterChange(updatedFilters);
|
||||
}, [filters.sortOrder, handleFilterChange, playlistId, setFilter]);
|
||||
|
||||
const handleSetViewType = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value) return;
|
||||
setPage({ detail: { ...page, display: e.currentTarget.value as ListDisplayType } });
|
||||
},
|
||||
[page, setPage],
|
||||
);
|
||||
|
||||
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 };
|
||||
|
||||
setTable({ columns: [...existingColumns, newColumn] });
|
||||
} else {
|
||||
// If removing a column
|
||||
const removed = existingColumns.filter((column) => !values.includes(column.column));
|
||||
const newColumns = existingColumns.filter((column) => !removed.includes(column));
|
||||
|
||||
setTable({ columns: newColumns });
|
||||
}
|
||||
|
||||
return tableRef.current?.api.sizeColumnsToFit();
|
||||
};
|
||||
|
||||
const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setTable({ autoFit: e.currentTarget.checked });
|
||||
|
||||
if (e.currentTarget.checked) {
|
||||
tableRef.current?.api.sizeColumnsToFit();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlay = async (playType: Play) => {
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: { id: [playlistId], type: LibraryItem.PLAYLIST },
|
||||
play: playType,
|
||||
});
|
||||
};
|
||||
|
||||
if (detailQuery.isLoading) return null;
|
||||
|
||||
return (
|
||||
<PageHeader p="1rem">
|
||||
<HeaderItems ref={cq.ref}>
|
||||
<Flex
|
||||
align="center"
|
||||
gap="md"
|
||||
justify="center"
|
||||
>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
px={0}
|
||||
rightIcon={<RiArrowDownSLine size={15} />}
|
||||
size="xl"
|
||||
variant="subtle"
|
||||
>
|
||||
<TextTitle
|
||||
fw="bold"
|
||||
order={3}
|
||||
>
|
||||
{detailQuery?.data?.name}
|
||||
</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}
|
||||
label={null}
|
||||
max={100}
|
||||
min={25}
|
||||
onChangeEnd={handleItemSize}
|
||||
/>
|
||||
</DropdownMenu.Item>
|
||||
{(page.display === ListDisplayType.TABLE ||
|
||||
page.display === ListDisplayType.TABLE_PAGINATED) && (
|
||||
<>
|
||||
<DropdownMenu.Label>Table Columns</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
closeMenuOnClick={false}
|
||||
component="div"
|
||||
sx={{ cursor: 'default' }}
|
||||
>
|
||||
<Stack>
|
||||
<MultiSelect
|
||||
clearable
|
||||
data={SONG_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 === filters.sortBy}
|
||||
value={filter.value}
|
||||
onClick={handleSetSortBy}
|
||||
>
|
||||
{filter.name}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
compact
|
||||
fw="600"
|
||||
variant="subtle"
|
||||
onClick={handleToggleSortOrder}
|
||||
>
|
||||
{cq.isMd ? (
|
||||
sortOrderLabel
|
||||
) : (
|
||||
<>
|
||||
{filters.sortOrder === SortOrder.ASC ? (
|
||||
<RiSortAsc size={15} />
|
||||
) : (
|
||||
<RiSortDesc size={15} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
variant="subtle"
|
||||
>
|
||||
<RiMoreFill size={15} />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Item onClick={() => handlePlay(Play.NOW)}>Play</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
disabled
|
||||
onClick={() => handlePlay(Play.NEXT)}
|
||||
>
|
||||
Add to queue (last)
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
disabled
|
||||
onClick={() => handlePlay(Play.LAST)}
|
||||
>
|
||||
Add to queue (next)
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
</Flex>
|
||||
</HeaderItems>
|
||||
</PageHeader>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,252 @@
|
||||
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);
|
||||
params.api.ensureIndexVisible(page.table.scrollOffset, 'top');
|
||||
},
|
||||
[page.filter, page.table.scrollOffset, 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 p="1rem">
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Group, Stack } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { ServerType, UpdatePlaylistBody, UpdatePlaylistQuery } from '/@/renderer/api/types';
|
||||
import { Button, Switch, TextInput, toast } from '/@/renderer/components';
|
||||
import { useUpdatePlaylist } from '/@/renderer/features/playlists/mutations/update-playlist-mutation';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
|
||||
interface CreatePlaylistFormProps {
|
||||
body: Partial<UpdatePlaylistBody>;
|
||||
onCancel: () => void;
|
||||
query: UpdatePlaylistQuery;
|
||||
}
|
||||
|
||||
export const UpdatePlaylistForm = ({ query, body, onCancel }: CreatePlaylistFormProps) => {
|
||||
const mutation = useUpdatePlaylist();
|
||||
const server = useCurrentServer();
|
||||
|
||||
const form = useForm<UpdatePlaylistBody>({
|
||||
initialValues: {
|
||||
comment: '',
|
||||
name: '',
|
||||
public: false,
|
||||
rules: undefined,
|
||||
...body,
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = form.onSubmit((values) => {
|
||||
mutation.mutate(
|
||||
{ body: values, query },
|
||||
{
|
||||
onError: (err) => {
|
||||
toast.error({ message: err.message, title: 'Error updating playlist' });
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success({ message: 'Playlist updated successfully' });
|
||||
onCancel();
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const isPublicDisplayed = server?.type === ServerType.NAVIDROME;
|
||||
const isSubmitDisabled = !form.values.name || mutation.isLoading;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
data-autofocus
|
||||
required
|
||||
label="Name"
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Description"
|
||||
{...form.getInputProps('comment')}
|
||||
/>
|
||||
{isPublicDisplayed && (
|
||||
<Switch
|
||||
label="Is Public?"
|
||||
{...form.getInputProps('public')}
|
||||
/>
|
||||
)}
|
||||
<Group position="right">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isSubmitDisabled}
|
||||
loading={mutation.isLoading}
|
||||
type="submit"
|
||||
variant="filled"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './queries/playlist-list-query';
|
||||
export * from './components/create-playlist-form';
|
||||
export * from './mutations/delete-playlist-mutation';
|
||||
export * from './mutations/create-playlist-mutation';
|
||||
export * from './mutations/update-playlist-mutation';
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { HTTPError } from 'ky';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { CreatePlaylistArgs, RawCreatePlaylistResponse } from '/@/renderer/api/types';
|
||||
import { MutationOptions } from '/@/renderer/lib/react-query';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
|
||||
export const useCreatePlaylist = (options?: MutationOptions) => {
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
|
||||
return useMutation<
|
||||
RawCreatePlaylistResponse,
|
||||
HTTPError,
|
||||
Omit<CreatePlaylistArgs, 'server'>,
|
||||
null
|
||||
>({
|
||||
mutationFn: (args) => api.controller.createPlaylist({ ...args, server }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(queryKeys.playlists.list(server?.id || ''), { exact: false });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { HTTPError } from 'ky';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { DeletePlaylistArgs, RawDeletePlaylistResponse } from '/@/renderer/api/types';
|
||||
import { MutationOptions } from '/@/renderer/lib/react-query';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
|
||||
export const useDeletePlaylist = (options?: MutationOptions) => {
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
|
||||
return useMutation<
|
||||
RawDeletePlaylistResponse,
|
||||
HTTPError,
|
||||
Omit<DeletePlaylistArgs, 'server'>,
|
||||
null
|
||||
>({
|
||||
mutationFn: (args) => api.controller.deletePlaylist({ ...args, server }),
|
||||
onMutate: () => {
|
||||
queryClient.cancelQueries(queryKeys.playlists.list(server?.id || ''));
|
||||
return null;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(queryKeys.playlists.list(server?.id || ''));
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { HTTPError } from 'ky';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { RawUpdatePlaylistResponse, UpdatePlaylistArgs } from '/@/renderer/api/types';
|
||||
import { MutationOptions } from '/@/renderer/lib/react-query';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
|
||||
export const useUpdatePlaylist = (options?: MutationOptions) => {
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
|
||||
return useMutation<
|
||||
RawUpdatePlaylistResponse,
|
||||
HTTPError,
|
||||
Omit<UpdatePlaylistArgs, 'server'>,
|
||||
null
|
||||
>({
|
||||
mutationFn: (args) => api.controller.updatePlaylist({ ...args, server }),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries(queryKeys.playlists.list(server?.id || ''));
|
||||
|
||||
if (data?.id) {
|
||||
queryClient.invalidateQueries(queryKeys.playlists.detail(server?.id || '', data.id));
|
||||
}
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import type { PlaylistDetailQuery, RawPlaylistDetailResponse } from '/@/renderer/api/types';
|
||||
import type { QueryOptions } from '/@/renderer/lib/react-query';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { api } from '/@/renderer/api';
|
||||
|
||||
export const usePlaylistDetail = (query: PlaylistDetailQuery, options?: QueryOptions) => {
|
||||
const server = useCurrentServer();
|
||||
|
||||
return useQuery({
|
||||
enabled: !!server?.id,
|
||||
queryFn: ({ signal }) => api.controller.getPlaylistDetail({ query, server, signal }),
|
||||
queryKey: queryKeys.playlists.detail(server?.id || '', query.id, query),
|
||||
select: useCallback(
|
||||
(data: RawPlaylistDetailResponse | undefined) => api.normalize.playlistDetail(data, server),
|
||||
[server],
|
||||
),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
@@ -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,61 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQuery, useInfiniteQuery, InfiniteData } from '@tanstack/react-query';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import type { PlaylistSongListQuery, RawSongListResponse } from '/@/renderer/api/types';
|
||||
import type { InfiniteQueryOptions, QueryOptions } from '/@/renderer/lib/react-query';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { api } from '/@/renderer/api';
|
||||
|
||||
export const usePlaylistSongList = (query: PlaylistSongListQuery, options?: QueryOptions) => {
|
||||
const server = useCurrentServer();
|
||||
|
||||
return useQuery({
|
||||
enabled: !!server?.id,
|
||||
queryFn: ({ signal }) => api.controller.getPlaylistSongList({ query, server, signal }),
|
||||
queryKey: queryKeys.playlists.songList(server?.id || '', query.id, query),
|
||||
select: useCallback(
|
||||
(data: RawSongListResponse | undefined) => api.normalize.songList(data, server),
|
||||
[server],
|
||||
),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const usePlaylistSongListInfinite = (
|
||||
query: PlaylistSongListQuery,
|
||||
options?: InfiniteQueryOptions,
|
||||
) => {
|
||||
const server = useCurrentServer();
|
||||
|
||||
return useInfiniteQuery({
|
||||
enabled: !!server?.id,
|
||||
getNextPageParam: (lastPage: RawSongListResponse, allPages) => {
|
||||
if (!lastPage?.items) return undefined;
|
||||
if (lastPage?.items?.length >= (query?.limit || 50)) {
|
||||
return allPages?.length;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
queryFn: ({ pageParam = 0, signal }) => {
|
||||
return api.controller.getPlaylistSongList({
|
||||
query: { ...query, limit: query.limit || 50, startIndex: pageParam * (query.limit || 50) },
|
||||
server,
|
||||
signal,
|
||||
});
|
||||
},
|
||||
queryKey: queryKeys.playlists.detailSongList(server?.id || '', query.id, query),
|
||||
select: useCallback(
|
||||
(data: InfiniteData<RawSongListResponse | undefined>) => {
|
||||
return {
|
||||
...data,
|
||||
pages: data.pages.map((page) => {
|
||||
return api.normalize.songList(page, server);
|
||||
}),
|
||||
};
|
||||
},
|
||||
[server],
|
||||
),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { useRef } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { NativeScrollArea } from '/@/renderer/components';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { PlaylistDetailContent } from '/@/renderer/features/playlists/components/playlist-detail-content';
|
||||
import { PlaylistDetailHeader } from '/@/renderer/features/playlists/components/playlist-detail-header';
|
||||
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
|
||||
import { AnimatedPage, LibraryHeaderBar } from '/@/renderer/features/shared';
|
||||
import { useFastAverageColor } from '/@/renderer/hooks';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { LibraryItem } from '/@/renderer/types';
|
||||
|
||||
const PlaylistDetailRoute = () => {
|
||||
const tableRef = useRef<AgGridReactType | null>(null);
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
|
||||
const detailQuery = usePlaylistDetail({ id: playlistId });
|
||||
const background = useFastAverageColor(
|
||||
detailQuery?.data?.imageUrl,
|
||||
!detailQuery?.isLoading,
|
||||
'dominant',
|
||||
);
|
||||
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
const handlePlay = () => {
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: [playlistId],
|
||||
type: LibraryItem.PLAYLIST,
|
||||
},
|
||||
play: playButtonBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
if (!background) return null;
|
||||
|
||||
return (
|
||||
<AnimatedPage key={`playlist-detail-${playlistId}`}>
|
||||
<NativeScrollArea
|
||||
ref={scrollAreaRef}
|
||||
pageHeaderProps={{
|
||||
backgroundColor: background,
|
||||
children: (
|
||||
<LibraryHeaderBar>
|
||||
<LibraryHeaderBar.PlayButton onClick={handlePlay} />
|
||||
<LibraryHeaderBar.Title>{detailQuery?.data?.name}</LibraryHeaderBar.Title>
|
||||
</LibraryHeaderBar>
|
||||
),
|
||||
target: headerRef,
|
||||
}}
|
||||
>
|
||||
<PlaylistDetailHeader
|
||||
ref={headerRef}
|
||||
background={background}
|
||||
imagePlaceholderUrl={detailQuery?.data?.imageUrl}
|
||||
imageUrl={detailQuery?.data?.imageUrl}
|
||||
/>
|
||||
<PlaylistDetailContent tableRef={tableRef} />
|
||||
</NativeScrollArea>
|
||||
</AnimatedPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlaylistDetailRoute;
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { useRef } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { VirtualGridContainer } from '/@/renderer/components';
|
||||
import { PlaylistDetailSongListContent } from '../components/playlist-detail-song-list-content';
|
||||
import { PlaylistDetailSongListHeader } from '../components/playlist-detail-song-list-header';
|
||||
import { AnimatedPage } from '/@/renderer/features/shared';
|
||||
|
||||
const PlaylistDetailSongListRoute = () => {
|
||||
const tableRef = useRef<AgGridReactType | null>(null);
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
|
||||
return (
|
||||
<AnimatedPage key={`playlist-detail-songList-${playlistId}`}>
|
||||
<VirtualGridContainer>
|
||||
<PlaylistDetailSongListHeader tableRef={tableRef} />
|
||||
<PlaylistDetailSongListContent tableRef={tableRef} />
|
||||
</VirtualGridContainer>
|
||||
</AnimatedPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlaylistDetailSongListRoute;
|
||||
@@ -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;
|
||||
@@ -9,7 +9,7 @@ import { jellyfinApi } from '/@/renderer/api/jellyfin.api';
|
||||
import { navidromeApi } from '/@/renderer/api/navidrome.api';
|
||||
import { subsonicApi } from '/@/renderer/api/subsonic.api';
|
||||
import { AuthenticationResponse } from '/@/renderer/api/types';
|
||||
import { useAuthStoreActions } from '/@/renderer/store';
|
||||
import { useAuthStore, useAuthStoreActions } from '/@/renderer/store';
|
||||
import { ServerType } from '/@/renderer/types';
|
||||
|
||||
const SERVER_TYPES = [
|
||||
@@ -31,7 +31,8 @@ interface AddServerFormProps {
|
||||
export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
|
||||
const focusTrapRef = useFocusTrap(true);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { addServer } = useAuthStoreActions();
|
||||
const { addServer, setCurrentServer } = useAuthStoreActions();
|
||||
const serverList = useAuthStore((state) => state.serverList);
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
@@ -62,7 +63,7 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
|
||||
username: values.username,
|
||||
});
|
||||
|
||||
addServer({
|
||||
const serverItem = {
|
||||
credential: data.credential,
|
||||
id: nanoid(),
|
||||
name: values.name,
|
||||
@@ -71,10 +72,18 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
|
||||
url: values.url.replace(/\/$/, ''),
|
||||
userId: data.userId,
|
||||
username: data.username,
|
||||
});
|
||||
};
|
||||
|
||||
toast.success({ message: 'Server added' });
|
||||
addServer(serverItem);
|
||||
setCurrentServer(serverItem);
|
||||
closeAllModals();
|
||||
|
||||
if (serverList.length === 0) {
|
||||
toast.success({ message: 'Server added, reloading...' });
|
||||
setTimeout(() => window.location.reload(), 2000);
|
||||
} else {
|
||||
toast.success({ message: 'Server added' });
|
||||
}
|
||||
} catch (err: any) {
|
||||
setIsLoading(false);
|
||||
return toast.error({ message: err?.message });
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Group } from '@mantine/core';
|
||||
import { TextTitle } from '/@/renderer/components';
|
||||
import { PlayButton as PlayBtn } from '/@/renderer/features/shared/components/play-button';
|
||||
import { useShouldPadTitlebar } from '/@/renderer/hooks';
|
||||
|
||||
interface LibraryHeaderBarProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const LibraryHeaderBar = ({ children }: LibraryHeaderBarProps) => {
|
||||
const padRight = useShouldPadTitlebar();
|
||||
|
||||
return (
|
||||
<Group
|
||||
noWrap
|
||||
align="center"
|
||||
h="100%"
|
||||
mr={padRight ? '170px' : 0}
|
||||
px="1rem"
|
||||
spacing="xl"
|
||||
>
|
||||
{children}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
interface TitleProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const Title = ({ children }: TitleProps) => {
|
||||
return (
|
||||
<TextTitle
|
||||
fw="bold"
|
||||
order={2}
|
||||
overflow="hidden"
|
||||
>
|
||||
{children}
|
||||
</TextTitle>
|
||||
);
|
||||
};
|
||||
|
||||
interface PlayButtonProps {
|
||||
onClick: () => void;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const PlayButton = ({ size, onClick }: PlayButtonProps) => {
|
||||
return (
|
||||
<PlayBtn
|
||||
h={size || 45}
|
||||
w={size || 45}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
LibraryHeaderBar.Title = Title;
|
||||
LibraryHeaderBar.PlayButton = PlayButton;
|
||||
@@ -0,0 +1,159 @@
|
||||
import { Center, Group } from '@mantine/core';
|
||||
import { useMergedRef } from '@mantine/hooks';
|
||||
import { forwardRef, ReactNode, Ref } from 'react';
|
||||
import { RiAlbumFill } from 'react-icons/ri';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { SimpleImg } from 'react-simple-img';
|
||||
import styled from 'styled-components';
|
||||
import { Text, TextTitle } from '/@/renderer/components';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { LibraryItem } from '/@/renderer/types';
|
||||
|
||||
const HeaderContainer = styled.div<{ imageSize: number }>`
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-auto-columns: 1fr;
|
||||
grid-template-areas: 'image info';
|
||||
grid-template-rows: 1fr;
|
||||
grid-template-columns: ${(props) => props.imageSize + 25}px minmax(0, 1fr);
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 30vh;
|
||||
min-height: 340px;
|
||||
max-height: 500px;
|
||||
padding: 5rem 2rem 2rem;
|
||||
`;
|
||||
|
||||
const CoverImageWrapper = styled.div`
|
||||
z-index: 15;
|
||||
display: flex;
|
||||
grid-area: image;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
filter: drop-shadow(0 0 8px rgb(0, 0, 0, 50%));
|
||||
`;
|
||||
|
||||
const MetadataWrapper = styled.div`
|
||||
z-index: 15;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-area: info;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledImage = styled(SimpleImg)`
|
||||
img {
|
||||
object-fit: cover;
|
||||
}
|
||||
`;
|
||||
|
||||
const BackgroundImage = styled.div<{ background: string }>`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: ${(props) => props.background};
|
||||
`;
|
||||
|
||||
const BackgroundImageOverlay = styled.div`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(180deg, rgba(25, 26, 28, 5%), var(--main-bg)), var(--background-noise);
|
||||
`;
|
||||
|
||||
interface LibraryHeaderProps {
|
||||
background: string;
|
||||
children?: ReactNode;
|
||||
imagePlaceholderUrl?: string | null;
|
||||
imageUrl?: string | null;
|
||||
item: { route: string; type: LibraryItem };
|
||||
title: string;
|
||||
}
|
||||
|
||||
export const LibraryHeader = forwardRef(
|
||||
(
|
||||
{ imageUrl, imagePlaceholderUrl, background, title, item, children }: LibraryHeaderProps,
|
||||
ref: Ref<HTMLDivElement>,
|
||||
) => {
|
||||
const cq = useContainerQuery();
|
||||
const mergedRef = useMergedRef(ref, cq.ref);
|
||||
const titleSize = cq.isXl
|
||||
? '6rem'
|
||||
: cq.isLg
|
||||
? '5.5rem'
|
||||
: cq.isMd
|
||||
? '4.5rem'
|
||||
: cq.isSm
|
||||
? '3.5rem'
|
||||
: '2rem';
|
||||
|
||||
const imageSize = cq.isLg ? 225 : cq.isMd ? 200 : cq.isSm ? 175 : 150;
|
||||
|
||||
return (
|
||||
<HeaderContainer
|
||||
ref={mergedRef}
|
||||
imageSize={imageSize}
|
||||
>
|
||||
<BackgroundImage background={background} />
|
||||
<BackgroundImageOverlay />
|
||||
<CoverImageWrapper>
|
||||
{imageUrl ? (
|
||||
<StyledImage
|
||||
alt="cover"
|
||||
height={imageSize}
|
||||
placeholder={imagePlaceholderUrl || 'var(--placeholder-bg)'}
|
||||
src={imageUrl}
|
||||
width={imageSize}
|
||||
/>
|
||||
) : (
|
||||
<Center
|
||||
sx={{
|
||||
background: 'var(--placeholder-bg)',
|
||||
borderRadius: 'var(--card-default-radius)',
|
||||
height: `${imageSize}px`,
|
||||
width: `${imageSize}px`,
|
||||
}}
|
||||
>
|
||||
<RiAlbumFill
|
||||
color="var(--placeholder-fg)"
|
||||
size={35}
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
</CoverImageWrapper>
|
||||
<MetadataWrapper>
|
||||
<Group>
|
||||
<Text
|
||||
$link
|
||||
component={Link}
|
||||
fw="600"
|
||||
sx={{ textTransform: 'uppercase' }}
|
||||
to={item.route}
|
||||
>
|
||||
{item.type}
|
||||
</Text>
|
||||
</Group>
|
||||
<TextTitle
|
||||
fw="900"
|
||||
lh="1"
|
||||
lineClamp={2}
|
||||
mt=".08em"
|
||||
overflow="hidden"
|
||||
sx={{ fontSize: titleSize, overflow: 'hidden' }}
|
||||
>
|
||||
{title}
|
||||
</TextTitle>
|
||||
{children}
|
||||
</MetadataWrapper>
|
||||
</HeaderContainer>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -7,8 +7,6 @@ const MotionButton = styled(motion(_Button))`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
background-color: var(--primary-color);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
@@ -19,7 +17,9 @@ const MotionButton = styled(motion(_Button))`
|
||||
export const PlayButton = ({ ...props }: Omit<ButtonProps, 'children'>) => {
|
||||
return (
|
||||
<MotionButton
|
||||
h={50}
|
||||
variant="filled"
|
||||
w={50}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
{...props}
|
||||
|
||||
@@ -2,3 +2,5 @@ export * from './components/animated-page';
|
||||
export * from './queries/music-folders-query';
|
||||
export * from './components/play-button';
|
||||
export * from './utils';
|
||||
export * from './components/library-header';
|
||||
export * from './components/library-header-bar';
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { createPolymorphicComponent, Flex, FlexProps, Group } from '@mantine/core';
|
||||
import type { LinkProps } from 'react-router-dom';
|
||||
import { Link } from 'react-router-dom';
|
||||
import styled, { css } from 'styled-components';
|
||||
import { textEllipsis } from '/@/renderer/styles';
|
||||
|
||||
interface ListItemProps {
|
||||
interface ListItemProps extends FlexProps {
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
to?: string;
|
||||
}
|
||||
|
||||
const StyledItem = styled.div`
|
||||
display: flex;
|
||||
const StyledItem = styled(Flex)`
|
||||
width: 100%;
|
||||
font-family: var(--content-font-family);
|
||||
|
||||
@@ -32,11 +33,7 @@ const ItemStyle = css`
|
||||
}
|
||||
`;
|
||||
|
||||
const Box = styled.div`
|
||||
${ItemStyle}
|
||||
`;
|
||||
|
||||
const ItemLink = styled(Link)<LinkProps & { disabled?: boolean }>`
|
||||
const _ItemLink = styled(StyledItem)<LinkProps & { disabled?: boolean }>`
|
||||
opacity: ${(props) => props.disabled && 0.6};
|
||||
pointer-events: ${(props) => props.disabled && 'none'};
|
||||
|
||||
@@ -47,12 +44,15 @@ const ItemLink = styled(Link)<LinkProps & { disabled?: boolean }>`
|
||||
${ItemStyle}
|
||||
`;
|
||||
|
||||
export const SidebarItem = ({ to, children, ...rest }: ListItemProps) => {
|
||||
const ItemLink = createPolymorphicComponent<'a', ListItemProps>(_ItemLink);
|
||||
|
||||
export const SidebarItem = ({ to, children, ...props }: ListItemProps) => {
|
||||
if (to) {
|
||||
return (
|
||||
<ItemLink
|
||||
component={Link}
|
||||
to={to}
|
||||
{...rest}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ItemLink>
|
||||
@@ -61,18 +61,73 @@ export const SidebarItem = ({ to, children, ...rest }: ListItemProps) => {
|
||||
return (
|
||||
<StyledItem
|
||||
tabIndex={0}
|
||||
{...rest}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</StyledItem>
|
||||
);
|
||||
};
|
||||
|
||||
SidebarItem.Box = Box;
|
||||
|
||||
SidebarItem.Link = ItemLink;
|
||||
|
||||
SidebarItem.defaultProps = {
|
||||
disabled: false,
|
||||
to: undefined,
|
||||
};
|
||||
|
||||
const _PlaylistItemLink = styled(StyledItem)<LinkProps & { disabled?: boolean }>`
|
||||
display: block;
|
||||
padding: 0.3rem 0;
|
||||
overflow: hidden;
|
||||
color: var(--sidebar-fg);
|
||||
cursor: default;
|
||||
opacity: ${(props) => (props.disabled ? 0.6 : 0.8)};
|
||||
transition: color 0.2s ease-in-out;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
pointer-events: ${(props) => props.disabled && 'none'};
|
||||
${textEllipsis}
|
||||
|
||||
&:hover {
|
||||
color: var(--sidebar-fg-hover);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
border: 1px solid var(--primary-color);
|
||||
}
|
||||
`;
|
||||
|
||||
const PlaylistItemLink = createPolymorphicComponent<'a', ListItemProps>(_PlaylistItemLink);
|
||||
|
||||
export const PlaylistSidebarItem = ({
|
||||
handlePlay,
|
||||
to,
|
||||
children,
|
||||
...props
|
||||
}: ListItemProps & { handlePlay: () => void }) => {
|
||||
if (to) {
|
||||
return (
|
||||
<Group
|
||||
noWrap
|
||||
position="apart"
|
||||
>
|
||||
<PlaylistItemLink
|
||||
component={Link}
|
||||
to={to}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</PlaylistItemLink>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledItem
|
||||
tabIndex={0}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</StyledItem>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { MouseEvent } from 'react';
|
||||
import { Stack, Grid, Accordion, Center, Group } from '@mantine/core';
|
||||
import { closeAllModals, openModal } from '@mantine/modals';
|
||||
import { SpotlightProvider } from '@mantine/spotlight';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { BsCollection } from 'react-icons/bs';
|
||||
import { Button, TextInput } from '/@/renderer/components';
|
||||
import { Button, ScrollArea, TextInput } from '/@/renderer/components';
|
||||
import {
|
||||
RiAddFill,
|
||||
RiAlbumFill,
|
||||
RiAlbumLine,
|
||||
RiArrowDownSLine,
|
||||
@@ -16,22 +19,32 @@ import {
|
||||
RiFolder3Line,
|
||||
RiHome5Fill,
|
||||
RiHome5Line,
|
||||
RiMenuUnfoldLine,
|
||||
RiMusicFill,
|
||||
RiMusicLine,
|
||||
RiPlayListLine,
|
||||
RiSearchLine,
|
||||
RiUserVoiceFill,
|
||||
RiUserVoiceLine,
|
||||
} from 'react-icons/ri';
|
||||
import { useNavigate, Link, useLocation } from 'react-router-dom';
|
||||
import { useNavigate, Link, useLocation, generatePath } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { SidebarItem } from '/@/renderer/features/sidebar/components/sidebar-item';
|
||||
import {
|
||||
PlaylistSidebarItem,
|
||||
SidebarItem,
|
||||
} from '/@/renderer/features/sidebar/components/sidebar-item';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useSidebarStore, useAppStoreActions, useCurrentSong } from '/@/renderer/store';
|
||||
import { fadeIn } from '/@/renderer/styles';
|
||||
import { CreatePlaylistForm, usePlaylistList } from '/@/renderer/features/playlists';
|
||||
import { PlaylistListSort, SortOrder } from '/@/renderer/api/types';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { LibraryItem } from '/@/renderer/types';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
|
||||
const SidebarContainer = styled.div`
|
||||
height: 100%;
|
||||
max-height: calc(100vh - 85px); // Account for and playerbar
|
||||
max-height: calc(100vh - 85px); // Account for playerbar
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
@@ -68,20 +81,47 @@ export const Sidebar = () => {
|
||||
const sidebar = useSidebarStore();
|
||||
const { setSidebar } = useAppStoreActions();
|
||||
const imageUrl = useCurrentSong()?.imageUrl;
|
||||
|
||||
const showImage = sidebar.image;
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
const handlePlayPlaylist = (id: string) => {
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: [id],
|
||||
type: LibraryItem.PLAYLIST,
|
||||
},
|
||||
play: playButtonBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
const playlistsQuery = usePlaylistList({
|
||||
limit: 100,
|
||||
sortBy: PlaylistListSort.NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
});
|
||||
|
||||
const handleCreatePlaylistModal = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
|
||||
openModal({
|
||||
children: <CreatePlaylistForm onCancel={() => closeAllModals()} />,
|
||||
size: 'sm',
|
||||
title: 'Create Playlist',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarContainer>
|
||||
<Stack
|
||||
h="100%"
|
||||
justify="space-between"
|
||||
spacing={0}
|
||||
sx={{ height: '100%' }}
|
||||
>
|
||||
<Stack
|
||||
sx={{
|
||||
maxHeight: showImage ? `calc(100% - ${sidebar.leftWidth})` : '100%',
|
||||
}}
|
||||
spacing={0}
|
||||
sx={{ maxHeight: showImage ? `calc(100% - ${sidebar.leftWidth})` : '100%' }}
|
||||
>
|
||||
<ActionsContainer p={10}>
|
||||
<Grid.Col span={8}>
|
||||
@@ -120,124 +160,167 @@ export const Sidebar = () => {
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
</ActionsContainer>
|
||||
<Stack
|
||||
spacing={0}
|
||||
sx={{ overflowY: 'auto' }}
|
||||
|
||||
<ScrollArea
|
||||
offsetScrollbars={false}
|
||||
scrollbarSize={6}
|
||||
>
|
||||
<SidebarItem to={AppRoute.HOME}>
|
||||
<Group>
|
||||
{location.pathname === AppRoute.HOME ? (
|
||||
<RiHome5Fill size={15} />
|
||||
) : (
|
||||
<RiHome5Line size={15} />
|
||||
)}
|
||||
Home
|
||||
</Group>
|
||||
</SidebarItem>
|
||||
<Accordion
|
||||
multiple
|
||||
styles={{
|
||||
control: {
|
||||
'&:hover': { background: 'none', color: 'var(--sidebar-fg-hover)' },
|
||||
color: 'var(--sidebar-fg)',
|
||||
transition: 'color 0.2s ease-in-out',
|
||||
},
|
||||
item: { borderBottom: 'none', color: 'var(--sidebar-fg)' },
|
||||
itemTitle: { color: 'var(--sidebar-fg)' },
|
||||
panel: {
|
||||
marginLeft: '1rem',
|
||||
},
|
||||
}}
|
||||
value={sidebar.expanded}
|
||||
onChange={(e) => setSidebar({ expanded: e })}
|
||||
>
|
||||
<Accordion.Item value="library">
|
||||
<Accordion.Control p="1rem">
|
||||
<Group>
|
||||
{location.pathname.includes('/library/') ? (
|
||||
<RiDatabaseFill size={15} />
|
||||
) : (
|
||||
<RiDatabaseLine size={15} />
|
||||
)}
|
||||
Library
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<SidebarItem to={AppRoute.LIBRARY_ALBUMS}>
|
||||
<Group>
|
||||
{location.pathname === AppRoute.LIBRARY_ALBUMS ? (
|
||||
<RiAlbumFill />
|
||||
<Stack spacing={0}>
|
||||
<SidebarItem
|
||||
px="1rem"
|
||||
py="0.5rem"
|
||||
to={AppRoute.HOME}
|
||||
>
|
||||
<Group fw="600">
|
||||
{location.pathname === AppRoute.HOME ? (
|
||||
<RiHome5Fill size={15} />
|
||||
) : (
|
||||
<RiHome5Line size={15} />
|
||||
)}
|
||||
Home
|
||||
</Group>
|
||||
</SidebarItem>
|
||||
<Accordion
|
||||
multiple
|
||||
styles={{
|
||||
control: {
|
||||
'&:hover': { background: 'none', color: 'var(--sidebar-fg-hover)' },
|
||||
color: 'var(--sidebar-fg)',
|
||||
padding: '1rem 1rem',
|
||||
transition: 'color 0.2s ease-in-out',
|
||||
},
|
||||
item: { borderBottom: 'none', color: 'var(--sidebar-fg)' },
|
||||
itemTitle: { color: 'var(--sidebar-fg)' },
|
||||
label: { fontWeight: 600 },
|
||||
panel: { padding: '0 1rem' },
|
||||
}}
|
||||
value={sidebar.expanded}
|
||||
onChange={(e) => setSidebar({ expanded: e })}
|
||||
>
|
||||
<Accordion.Item value="library">
|
||||
<Accordion.Control>
|
||||
<Group fw="600">
|
||||
{location.pathname.includes('/library/') ? (
|
||||
<RiDatabaseFill size={15} />
|
||||
) : (
|
||||
<RiAlbumLine />
|
||||
<RiDatabaseLine size={15} />
|
||||
)}
|
||||
Albums
|
||||
Library
|
||||
</Group>
|
||||
</SidebarItem>
|
||||
<SidebarItem to={AppRoute.LIBRARY_SONGS}>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<SidebarItem to={AppRoute.LIBRARY_ALBUMS}>
|
||||
<Group fw="600">
|
||||
{location.pathname === AppRoute.LIBRARY_ALBUMS ? (
|
||||
<RiAlbumFill />
|
||||
) : (
|
||||
<RiAlbumLine />
|
||||
)}
|
||||
Albums
|
||||
</Group>
|
||||
</SidebarItem>
|
||||
<SidebarItem to={AppRoute.LIBRARY_SONGS}>
|
||||
<Group fw="600">
|
||||
{location.pathname === AppRoute.LIBRARY_SONGS ? (
|
||||
<RiMusicFill />
|
||||
) : (
|
||||
<RiMusicLine />
|
||||
)}
|
||||
Tracks
|
||||
</Group>
|
||||
</SidebarItem>
|
||||
<SidebarItem to={AppRoute.LIBRARY_ALBUMARTISTS}>
|
||||
<Group fw="600">
|
||||
{location.pathname === AppRoute.LIBRARY_ALBUMARTISTS ? (
|
||||
<RiUserVoiceFill />
|
||||
) : (
|
||||
<RiUserVoiceLine />
|
||||
)}
|
||||
Album Artists
|
||||
</Group>
|
||||
</SidebarItem>
|
||||
<SidebarItem
|
||||
disabled
|
||||
to={AppRoute.LIBRARY_FOLDERS}
|
||||
>
|
||||
<Group fw="600">
|
||||
<RiFlag2Line />
|
||||
Genres
|
||||
</Group>
|
||||
</SidebarItem>
|
||||
<SidebarItem
|
||||
disabled
|
||||
to={AppRoute.LIBRARY_FOLDERS}
|
||||
>
|
||||
<Group fw="600">
|
||||
<RiFolder3Line />
|
||||
Folders
|
||||
</Group>
|
||||
</SidebarItem>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="collections">
|
||||
<Accordion.Control disabled>
|
||||
<Group>
|
||||
{location.pathname === AppRoute.LIBRARY_SONGS ? (
|
||||
<RiMusicFill />
|
||||
) : (
|
||||
<RiMusicLine />
|
||||
)}
|
||||
Tracks
|
||||
<BsCollection size={15} />
|
||||
Collections
|
||||
</Group>
|
||||
</SidebarItem>
|
||||
<SidebarItem
|
||||
disabled
|
||||
to={AppRoute.LIBRARY_ALBUMARTISTS}
|
||||
>
|
||||
<Group>
|
||||
<RiUserVoiceLine />
|
||||
Artists
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel />
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="playlists">
|
||||
<Accordion.Control>
|
||||
<Group
|
||||
noWrap
|
||||
position="apart"
|
||||
>
|
||||
<Group noWrap>
|
||||
<RiPlayListLine size={15} />
|
||||
Playlists
|
||||
</Group>
|
||||
<Group
|
||||
noWrap
|
||||
spacing="xs"
|
||||
>
|
||||
<Button
|
||||
compact
|
||||
component="div"
|
||||
h={13}
|
||||
tooltip={{ label: 'Create playlist', openDelay: 500 }}
|
||||
variant="subtle"
|
||||
onClick={handleCreatePlaylistModal}
|
||||
>
|
||||
<RiAddFill size={13} />
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
component={Link}
|
||||
h={13}
|
||||
to={AppRoute.PLAYLISTS}
|
||||
tooltip={{ label: 'Playlist list', openDelay: 500 }}
|
||||
variant="subtle"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<RiMenuUnfoldLine size={13} />
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</SidebarItem>
|
||||
<SidebarItem
|
||||
disabled
|
||||
to={AppRoute.LIBRARY_FOLDERS}
|
||||
>
|
||||
<Group>
|
||||
<RiFlag2Line />
|
||||
Genres
|
||||
</Group>
|
||||
</SidebarItem>
|
||||
<SidebarItem
|
||||
disabled
|
||||
to={AppRoute.LIBRARY_FOLDERS}
|
||||
>
|
||||
<Group>
|
||||
<RiFolder3Line />
|
||||
Folders
|
||||
</Group>
|
||||
</SidebarItem>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="collections">
|
||||
<Accordion.Control
|
||||
disabled
|
||||
p="1rem"
|
||||
>
|
||||
<Group>
|
||||
<BsCollection size={15} />
|
||||
Collections
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel />
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="playlists">
|
||||
<Accordion.Control
|
||||
disabled
|
||||
p="1rem"
|
||||
>
|
||||
<Group>
|
||||
<RiPlayListLine size={15} />
|
||||
Playlists
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel />
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
</Stack>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
{playlistsQuery?.data?.items?.map((playlist) => (
|
||||
<PlaylistSidebarItem
|
||||
key={`sidebar-playlist-${playlist.id}`}
|
||||
handlePlay={() => handlePlayPlaylist(playlist.id)}
|
||||
to={generatePath(AppRoute.PLAYLISTS_DETAIL, { playlistId: playlist.id })}
|
||||
>
|
||||
{playlist.name}
|
||||
</PlaylistSidebarItem>
|
||||
))}
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
<AnimatePresence
|
||||
initial={false}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ChangeEvent } from 'react';
|
||||
import { ChangeEvent, useMemo } from 'react';
|
||||
import { Divider, Group, Stack } from '@mantine/core';
|
||||
import { NumberInput, Switch, Text } from '/@/renderer/components';
|
||||
import { NumberInput, Select, Switch, Text } from '/@/renderer/components';
|
||||
import { SongListFilter, useSetSongFilters, useSongListStore } from '/@/renderer/store';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useGenreList } from '/@/renderer/features/genres';
|
||||
|
||||
interface NavidromeSongFiltersProps {
|
||||
handleFilterChange: (filters: SongListFilter) => void;
|
||||
@@ -12,6 +13,26 @@ export const NavidromeSongFilters = ({ handleFilterChange }: NavidromeSongFilter
|
||||
const { filter } = useSongListStore();
|
||||
const setFilters = useSetSongFilters();
|
||||
|
||||
const genreListQuery = useGenreList(null);
|
||||
|
||||
const genreList = useMemo(() => {
|
||||
if (!genreListQuery?.data) return [];
|
||||
return genreListQuery.data.map((genre) => ({
|
||||
label: genre.name,
|
||||
value: genre.id,
|
||||
}));
|
||||
}, [genreListQuery.data]);
|
||||
|
||||
const handleGenresFilter = debounce((e: string | null) => {
|
||||
const updatedFilters = setFilters({
|
||||
ndParams: {
|
||||
...filter.ndParams,
|
||||
genre_id: e || undefined,
|
||||
},
|
||||
});
|
||||
handleFilterChange(updatedFilters);
|
||||
}, 250);
|
||||
|
||||
const toggleFilters = [
|
||||
{
|
||||
label: 'Is favorited',
|
||||
@@ -38,6 +59,29 @@ export const NavidromeSongFilters = ({ handleFilterChange }: NavidromeSongFilter
|
||||
|
||||
return (
|
||||
<Stack p="0.8rem">
|
||||
<Group position="apart">
|
||||
<Text>Year</Text>
|
||||
<NumberInput
|
||||
max={5000}
|
||||
min={0}
|
||||
value={filter.ndParams?.year}
|
||||
width={50}
|
||||
onChange={handleYearFilter}
|
||||
/>
|
||||
</Group>
|
||||
<Divider my="0.5rem" />
|
||||
<Group position="apart">
|
||||
<Text>Genre</Text>
|
||||
<Select
|
||||
clearable
|
||||
searchable
|
||||
data={genreList}
|
||||
defaultValue={filter.ndParams?.genre_id}
|
||||
width={150}
|
||||
onChange={handleGenresFilter}
|
||||
/>
|
||||
</Group>
|
||||
<Divider my="0.5rem" />
|
||||
{toggleFilters.map((filter) => (
|
||||
<Group
|
||||
key={`nd-filter-${filter.label}`}
|
||||
@@ -51,18 +95,6 @@ export const NavidromeSongFilters = ({ handleFilterChange }: NavidromeSongFilter
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
<Divider my="0.5rem" />
|
||||
<Group position="apart">
|
||||
<Text>Year</Text>
|
||||
<NumberInput
|
||||
max={5000}
|
||||
min={0}
|
||||
size="xs"
|
||||
value={filter.ndParams?.year}
|
||||
width={50}
|
||||
onChange={handleYearFilter}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
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';
|
||||
@@ -32,6 +33,9 @@ import debounce from 'lodash/debounce';
|
||||
import { openContextMenu } from '/@/renderer/features/context-menu';
|
||||
import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
|
||||
import sortBy from 'lodash/sortBy';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { QueueSong } from '/@/renderer/api/types';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
|
||||
interface SongListContentProps {
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
@@ -45,6 +49,8 @@ export const SongListContent = ({ tableRef }: SongListContentProps) => {
|
||||
const pagination = useSongTablePagination();
|
||||
const setPagination = useSetSongTablePagination();
|
||||
const setTable = useSetSongTable();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
const isPaginationEnabled = page.display === ListDisplayType.TABLE_PAGINATED;
|
||||
|
||||
@@ -98,8 +104,9 @@ export const SongListContent = ({ tableRef }: SongListContentProps) => {
|
||||
rowCount: undefined,
|
||||
};
|
||||
params.api.setDatasource(dataSource);
|
||||
params.api.ensureIndexVisible(page.table.scrollOffset, 'top');
|
||||
},
|
||||
[page.filter, queryClient, server],
|
||||
[page.filter, page.table.scrollOffset, queryClient, server],
|
||||
);
|
||||
|
||||
const onPaginationChanged = useCallback(
|
||||
@@ -180,6 +187,14 @@ export const SongListContent = ({ tableRef }: SongListContentProps) => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
|
||||
if (!e.data) return;
|
||||
handlePlayQueueAdd?.({
|
||||
byData: [e.data],
|
||||
play: playButtonBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack
|
||||
h="100%"
|
||||
@@ -221,6 +236,7 @@ export const SongListContent = ({ tableRef }: SongListContentProps) => {
|
||||
onGridReady={onGridReady}
|
||||
onGridSizeChanged={handleGridSizeChange}
|
||||
onPaginationChanged={onPaginationChanged}
|
||||
onRowDoubleClicked={handleRowDoubleClick}
|
||||
/>
|
||||
</VirtualGridAutoSizerContainer>
|
||||
<AnimatePresence
|
||||
|
||||
@@ -242,7 +242,7 @@ export const SongListHeader = ({ tableRef }: SongListHeaderProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<PageHeader>
|
||||
<PageHeader p="1rem">
|
||||
<Flex
|
||||
ref={cq.ref}
|
||||
direction="row"
|
||||
|
||||
@@ -8,19 +8,23 @@ import {
|
||||
RiLockLine,
|
||||
RiMenuFill,
|
||||
} from 'react-icons/ri';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { DropdownMenu, Text, Button } from '/@/renderer/components';
|
||||
import { ServerList } from '/@/renderer/features/servers';
|
||||
import { EditServerForm } from '/@/renderer/features/servers/components/edit-server-form';
|
||||
import { Settings } from '/@/renderer/features/settings';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useCurrentServer, useServerList, useAuthStoreActions } from '/@/renderer/store';
|
||||
import { ServerListItem, ServerType } from '/@/renderer/types';
|
||||
|
||||
export const AppMenu = () => {
|
||||
const navigate = useNavigate();
|
||||
const currentServer = useCurrentServer();
|
||||
const serverList = useServerList();
|
||||
const { setCurrentServer } = useAuthStoreActions();
|
||||
|
||||
const handleSetCurrentServer = (server: ServerListItem) => {
|
||||
navigate(AppRoute.HOME);
|
||||
setCurrentServer(server);
|
||||
};
|
||||
|
||||
|
||||
@@ -3,14 +3,15 @@ import { FastAverageColor } from 'fast-average-color';
|
||||
|
||||
export const useFastAverageColor = (
|
||||
src?: string | null,
|
||||
srcLoaded?: boolean,
|
||||
aglorithm?: 'dominant' | 'simple' | 'sqrt',
|
||||
) => {
|
||||
const [color, setColor] = useState('rgba(0, 0, 0, 0)');
|
||||
const [color, setColor] = useState<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const fac = new FastAverageColor();
|
||||
|
||||
if (src) {
|
||||
if (src && srcLoaded) {
|
||||
fac
|
||||
.getColorAsync(src, {
|
||||
algorithm: aglorithm || 'dominant',
|
||||
@@ -24,14 +25,17 @@ export const useFastAverageColor = (
|
||||
return setColor(color.rgb);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e);
|
||||
console.log('Error fetching average color', e);
|
||||
return setColor('rgba(0, 0, 0, 0)');
|
||||
});
|
||||
} else if (srcLoaded) {
|
||||
return setColor('var(--placeholder-bg)');
|
||||
}
|
||||
|
||||
return () => {
|
||||
fac.destroy();
|
||||
};
|
||||
}, [aglorithm, src]);
|
||||
}, [aglorithm, srcLoaded, src]);
|
||||
|
||||
return color;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './app';
|
||||
import { queryClient } from './lib/react-query';
|
||||
|
||||
const container = document.getElementById('root')! as HTMLElement;
|
||||
const root = createRoot(container);
|
||||
|
||||
root.render(<App />);
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
@@ -63,6 +63,7 @@ const SidebarContainer = styled.div`
|
||||
const RightSidebarContainer = styled(motion.div)`
|
||||
position: relative;
|
||||
grid-area: right-sidebar;
|
||||
height: 100%;
|
||||
background: var(--sidebar-bg);
|
||||
border-left: var(--sidebar-border);
|
||||
`;
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { UseQueryOptions, DefaultOptions } from '@tanstack/react-query';
|
||||
import type {
|
||||
UseQueryOptions,
|
||||
DefaultOptions,
|
||||
UseMutationOptions,
|
||||
UseInfiniteQueryOptions,
|
||||
} from '@tanstack/react-query';
|
||||
import { QueryClient, QueryCache } from '@tanstack/react-query';
|
||||
import { toast } from '/@/renderer/components';
|
||||
|
||||
@@ -39,6 +44,7 @@ export type QueryOptions = {
|
||||
onError?: (err: any) => void;
|
||||
onSettled?: any;
|
||||
onSuccess?: any;
|
||||
queryKey?: UseQueryOptions['queryKey'];
|
||||
refetchInterval?: number;
|
||||
refetchIntervalInBackground?: UseQueryOptions['refetchIntervalInBackground'];
|
||||
refetchOnWindowFocus?: boolean;
|
||||
@@ -48,3 +54,32 @@ export type QueryOptions = {
|
||||
suspense?: UseQueryOptions['suspense'];
|
||||
useErrorBoundary?: boolean;
|
||||
};
|
||||
|
||||
export type MutationOptions = {
|
||||
mutationKey: UseMutationOptions['mutationKey'];
|
||||
onError?: (err: any) => void;
|
||||
onSettled?: any;
|
||||
onSuccess?: any;
|
||||
retry?: UseQueryOptions['retry'];
|
||||
retryDelay?: UseQueryOptions['retryDelay'];
|
||||
useErrorBoundary?: boolean;
|
||||
};
|
||||
|
||||
export type InfiniteQueryOptions = {
|
||||
cacheTime?: UseInfiniteQueryOptions['cacheTime'];
|
||||
enabled?: UseInfiniteQueryOptions['enabled'];
|
||||
keepPreviousData?: UseInfiniteQueryOptions['keepPreviousData'];
|
||||
meta?: UseInfiniteQueryOptions['meta'];
|
||||
onError?: (err: any) => void;
|
||||
onSettled?: any;
|
||||
onSuccess?: any;
|
||||
queryKey?: UseInfiniteQueryOptions['queryKey'];
|
||||
refetchInterval?: number;
|
||||
refetchIntervalInBackground?: UseInfiniteQueryOptions['refetchIntervalInBackground'];
|
||||
refetchOnWindowFocus?: boolean;
|
||||
retry?: UseInfiniteQueryOptions['retry'];
|
||||
retryDelay?: UseInfiniteQueryOptions['retryDelay'];
|
||||
staleTime?: UseInfiniteQueryOptions['staleTime'];
|
||||
suspense?: UseInfiniteQueryOptions['suspense'];
|
||||
useErrorBoundary?: boolean;
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { AppRoute } from './routes';
|
||||
import { RouteErrorBoundary } from '/@/renderer/features/action-required';
|
||||
import AlbumDetailRoute from '/@/renderer/features/albums/routes/album-detail-route';
|
||||
import AlbumArtistListRoute from '/@/renderer/features/artists/routes/album-artist-list-route';
|
||||
import HomeRoute from '/@/renderer/features/home/routes/home-route';
|
||||
import { DefaultLayout } from '/@/renderer/layouts';
|
||||
import { AppOutlet } from '/@/renderer/router/app-outlet';
|
||||
@@ -21,6 +22,18 @@ const AlbumListRoute = lazy(() => import('/@/renderer/features/albums/routes/alb
|
||||
|
||||
const SongListRoute = lazy(() => import('/@/renderer/features/songs/routes/song-list-route'));
|
||||
|
||||
const PlaylistDetailRoute = lazy(
|
||||
() => import('/@/renderer/features/playlists/routes/playlist-detail-route'),
|
||||
);
|
||||
|
||||
const PlaylistDetailSongListRoute = lazy(
|
||||
() => import('/@/renderer/features/playlists/routes/playlist-detail-song-list-route'),
|
||||
);
|
||||
|
||||
const PlaylistListRoute = lazy(
|
||||
() => import('/@/renderer/features/playlists/routes/playlist-list-route'),
|
||||
);
|
||||
|
||||
const ActionRequiredRoute = lazy(
|
||||
() => import('/@/renderer/features/action-required/routes/action-required-route'),
|
||||
);
|
||||
@@ -64,8 +77,20 @@ export const AppRouter = () => {
|
||||
path={AppRoute.LIBRARY_SONGS}
|
||||
/>
|
||||
<Route
|
||||
element={<></>}
|
||||
path={AppRoute.LIBRARY_ARTISTS}
|
||||
element={<PlaylistListRoute />}
|
||||
path={AppRoute.PLAYLISTS}
|
||||
/>
|
||||
<Route
|
||||
element={<PlaylistDetailRoute />}
|
||||
path={AppRoute.PLAYLISTS_DETAIL}
|
||||
/>
|
||||
<Route
|
||||
element={<PlaylistDetailSongListRoute />}
|
||||
path={AppRoute.PLAYLISTS_DETAIL_SONGS}
|
||||
/>
|
||||
<Route
|
||||
element={<AlbumArtistListRoute />}
|
||||
path={AppRoute.LIBRARY_ALBUMARTISTS}
|
||||
/>
|
||||
<Route
|
||||
element={<InvalidRoute />}
|
||||
|
||||
@@ -16,6 +16,7 @@ export enum AppRoute {
|
||||
PLAYING = '/playing',
|
||||
PLAYLISTS = '/playlists',
|
||||
PLAYLISTS_DETAIL = '/playlists/:playlistId',
|
||||
PLAYLISTS_DETAIL_SONGS = '/playlists/:playlistId/songs',
|
||||
SEARCH = '/search',
|
||||
SERVERS = '/servers',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import merge from 'lodash/merge';
|
||||
import create from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import { immer } from 'zustand/middleware/immer';
|
||||
import { AlbumArtistListArgs, AlbumArtistListSort, 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;
|
||||
grid: {
|
||||
scrollOffset: number;
|
||||
size: number;
|
||||
};
|
||||
table: TableProps;
|
||||
};
|
||||
|
||||
export type AlbumArtistListFilter = Omit<AlbumArtistListArgs['query'], 'startIndex' | 'limit'>;
|
||||
|
||||
export interface AlbumArtistState {
|
||||
list: ListProps<AlbumArtistListFilter>;
|
||||
}
|
||||
|
||||
export interface AlbumArtistSlice extends AlbumArtistState {
|
||||
actions: {
|
||||
setFilters: (data: Partial<AlbumArtistListFilter>) => AlbumArtistListFilter;
|
||||
setStore: (data: Partial<AlbumArtistSlice>) => void;
|
||||
setTable: (data: Partial<TableProps>) => void;
|
||||
setTablePagination: (data: Partial<TableProps['pagination']>) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export const useAlbumArtistStore = create<AlbumArtistSlice>()(
|
||||
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: AlbumArtistListSort.NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
},
|
||||
grid: {
|
||||
scrollOffset: 0,
|
||||
size: 50,
|
||||
},
|
||||
table: {
|
||||
autoFit: true,
|
||||
columns: [
|
||||
{
|
||||
column: TableColumn.ROW_INDEX,
|
||||
width: 50,
|
||||
},
|
||||
{
|
||||
column: TableColumn.TITLE_COMBINED,
|
||||
width: 500,
|
||||
},
|
||||
],
|
||||
pagination: {
|
||||
currentPage: 1,
|
||||
itemsPerPage: 100,
|
||||
totalItems: 1,
|
||||
totalPages: 1,
|
||||
},
|
||||
rowHeight: 60,
|
||||
scrollOffset: 0,
|
||||
},
|
||||
},
|
||||
})),
|
||||
{ name: 'store_album_artist' },
|
||||
),
|
||||
{
|
||||
merge: (persistedState, currentState) => {
|
||||
return merge(currentState, persistedState);
|
||||
},
|
||||
name: 'store_album_artist',
|
||||
version: 1,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export const useAlbumArtistStoreActions = () => useAlbumArtistStore((state) => state.actions);
|
||||
|
||||
export const useSetAlbumArtistStore = () => useAlbumArtistStore((state) => state.actions.setStore);
|
||||
|
||||
export const useSetAlbumArtistFilters = () =>
|
||||
useAlbumArtistStore((state) => state.actions.setFilters);
|
||||
|
||||
export const useAlbumArtistListStore = () => useAlbumArtistStore((state) => state.list);
|
||||
|
||||
export const useAlbumArtistTablePagination = () =>
|
||||
useAlbumArtistStore((state) => state.list.table.pagination);
|
||||
|
||||
export const useSetAlbumArtistTablePagination = () =>
|
||||
useAlbumArtistStore((state) => state.actions.setTablePagination);
|
||||
|
||||
export const useSetAlbumArtistTable = () => useAlbumArtistStore((state) => state.actions.setTable);
|
||||
@@ -3,3 +3,5 @@ export * from './player.store';
|
||||
export * from './app.store';
|
||||
export * from './album.store';
|
||||
export * from './song.store';
|
||||
export * from './album-artist.store';
|
||||
export * from './playlist.store';
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
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 { SongListFilter } from '/@/renderer/store/song.store';
|
||||
import { ListDisplayType, TableColumn, TablePagination } from '/@/renderer/types';
|
||||
|
||||
type TableProps = {
|
||||
pagination: TablePagination;
|
||||
scrollOffset: number;
|
||||
} & DataTableProps;
|
||||
|
||||
type ListProps<T> = {
|
||||
display: ListDisplayType;
|
||||
filter: T;
|
||||
table: TableProps;
|
||||
};
|
||||
|
||||
type DetailPaginationProps = TablePagination & {
|
||||
scrollOffset: number;
|
||||
};
|
||||
|
||||
type DetailTableProps = DataTableProps & {
|
||||
id: {
|
||||
[key: string]: DetailPaginationProps & { filter: SongListFilter };
|
||||
};
|
||||
};
|
||||
|
||||
type DetailProps = {
|
||||
display: ListDisplayType;
|
||||
table: DetailTableProps;
|
||||
};
|
||||
|
||||
export type PlaylistListFilter = Omit<PlaylistListArgs['query'], 'startIndex' | 'limit'>;
|
||||
|
||||
interface PlaylistState {
|
||||
detail: DetailProps;
|
||||
list: ListProps<PlaylistListFilter>;
|
||||
}
|
||||
|
||||
export interface PlaylistSlice extends PlaylistState {
|
||||
actions: {
|
||||
setDetailFilters: (id: string, data: Partial<SongListFilter>) => SongListFilter;
|
||||
setDetailTable: (data: Partial<DetailTableProps>) => void;
|
||||
setDetailTablePagination: (id: string, data: Partial<DetailPaginationProps>) => void;
|
||||
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: {
|
||||
setDetailFilters: (id, data) => {
|
||||
set((state) => {
|
||||
state.detail.table.id[id] = {
|
||||
...state.detail.table.id[id],
|
||||
filter: {
|
||||
...state.detail.table.id[id].filter,
|
||||
...data,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return get().detail.table.id[id].filter;
|
||||
},
|
||||
setDetailTable: (data) => {
|
||||
set((state) => {
|
||||
state.detail.table = { ...state.detail.table, ...data };
|
||||
});
|
||||
},
|
||||
setDetailTablePagination: (id, data) => {
|
||||
set((state) => {
|
||||
state.detail.table.id[id] = {
|
||||
...state.detail.table.id[id],
|
||||
...data,
|
||||
};
|
||||
});
|
||||
},
|
||||
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 };
|
||||
});
|
||||
},
|
||||
},
|
||||
detail: {
|
||||
display: ListDisplayType.TABLE,
|
||||
table: {
|
||||
autoFit: true,
|
||||
columns: [
|
||||
{
|
||||
column: TableColumn.ROW_INDEX,
|
||||
width: 50,
|
||||
},
|
||||
{
|
||||
column: TableColumn.TITLE_COMBINED,
|
||||
width: 500,
|
||||
},
|
||||
{
|
||||
column: TableColumn.DURATION,
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
column: TableColumn.ALBUM,
|
||||
width: 500,
|
||||
},
|
||||
],
|
||||
id: {},
|
||||
rowHeight: 60,
|
||||
},
|
||||
},
|
||||
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);
|
||||
|
||||
export const usePlaylistDetailStore = () => usePlaylistStore((state) => state.detail);
|
||||
|
||||
export const usePlaylistDetailTablePagination = (id: string) =>
|
||||
usePlaylistStore((state) => state.detail.table.id[id]);
|
||||
|
||||
export const useSetPlaylistDetailTablePagination = () =>
|
||||
usePlaylistStore((state) => state.actions.setDetailTablePagination);
|
||||
|
||||
export const useSetPlaylistDetailTable = () =>
|
||||
usePlaylistStore((state) => state.actions.setDetailTable);
|
||||
|
||||
export const useSetPlaylistDetailFilters = () =>
|
||||
usePlaylistStore((state) => state.actions.setDetailFilters);
|
||||
@@ -83,7 +83,7 @@ export const useSettingsStore = create<SettingsSlice>()(
|
||||
},
|
||||
general: {
|
||||
followSystemTheme: false,
|
||||
fontContent: 'Sora',
|
||||
fontContent: 'Poppins',
|
||||
showQueueDrawerButton: false,
|
||||
sideQueueType: 'sideDrawerQueue',
|
||||
theme: AppTheme.DEFAULT_DARK,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { css } from 'styled-components';
|
||||
|
||||
export const textEllipsis = css`
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
--titlebar-bg: rgb(7, 7, 7);
|
||||
|
||||
--sidebar-bg: rgb(0, 0, 0);
|
||||
--sidebar-fg: rgb(255, 255, 255);
|
||||
--sidebar-fg-hover: #dddddd;
|
||||
--sidebar-fg: rgb(210, 210, 210);
|
||||
--sidebar-fg-hover: rgb(255, 255, 255);
|
||||
--sidebar-handle-bg: #4d4d4d;
|
||||
--sidebar-border: none;
|
||||
|
||||
@@ -98,6 +98,8 @@
|
||||
--card-poster-bg-hover: transparent;
|
||||
--card-poster-radius: 3px;
|
||||
|
||||
--background-noise: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMDAiIGhlaWdodD0iMzAwIj48ZmlsdGVyIGlkPSJhIiB4PSIwIiB5PSIwIj48ZmVUdXJidWxlbmNlIHR5cGU9ImZyYWN0YWxOb2lzZSIgYmFzZUZyZXF1ZW5jeT0iLjc1IiBzdGl0Y2hUaWxlcz0ic3RpdGNoIi8+PGZlQ29sb3JNYXRyaXggdHlwZT0ic2F0dXJhdGUiIHZhbHVlcz0iMCIvPjwvZmlsdGVyPjxwYXRoIGZpbHRlcj0idXJsKCNhKSIgb3BhY2l0eT0iLjA1IiBkPSJNMCAwaDMwMHYzMDBIMHoiLz48L3N2Zz4=');
|
||||
|
||||
.ag-theme-alpine-dark {
|
||||
--ag-font-family: var(--content-font-family);
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ body[data-theme='defaultLight'] {
|
||||
|
||||
--sidebar-bg: rgb(240, 241, 242);
|
||||
--sidebar-fg: rgb(0, 0, 0);
|
||||
--sidebar-fg-hover: rgb(0, 0, 0);
|
||||
--sidebar-fg-hover: rgb(85, 85, 85);
|
||||
--sidebar-handle-bg: #4d4d4d;
|
||||
|
||||
--playerbar-bg: linear-gradient(
|
||||
|
||||
@@ -133,7 +133,9 @@ export type AdvancedFilterGroup = {
|
||||
export enum TableColumn {
|
||||
ALBUM = 'album',
|
||||
ALBUM_ARTIST = 'albumArtist',
|
||||
ALBUM_COUNT = 'albumCount',
|
||||
ARTIST = 'artist',
|
||||
BIOGRAPHY = 'biography',
|
||||
BIT_RATE = 'bitRate',
|
||||
BPM = 'bpm',
|
||||
CHANNELS = 'channels',
|
||||
@@ -144,11 +146,13 @@ export enum TableColumn {
|
||||
FAVORITE = 'favorite',
|
||||
GENRE = 'genre',
|
||||
LAST_PLAYED = 'lastPlayedAt',
|
||||
OWNER = 'username',
|
||||
PATH = 'path',
|
||||
PLAY_COUNT = 'playCount',
|
||||
RATING = 'rating',
|
||||
RELEASE_DATE = 'releaseDate',
|
||||
ROW_INDEX = 'rowIndex',
|
||||
SONG_COUNT = 'songCount',
|
||||
// SKIP = 'skip',
|
||||
TITLE = 'title',
|
||||
TITLE_COMBINED = 'titleCombined',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const constrainSidebarWidth = (num: number) => {
|
||||
if (num < 200) {
|
||||
return 200;
|
||||
if (num < 225) {
|
||||
return 225;
|
||||
}
|
||||
|
||||
if (num > 400) {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import formatDuration from 'format-duration';
|
||||
|
||||
export const formatDurationString = (duration: number) => {
|
||||
const rawDuration = formatDuration(duration).split(':');
|
||||
|
||||
let string;
|
||||
|
||||
switch (rawDuration.length) {
|
||||
case 1:
|
||||
string = `${rawDuration[0]} sec`;
|
||||
break;
|
||||
case 2:
|
||||
string = `${rawDuration[0]} min ${rawDuration[1]} sec`;
|
||||
break;
|
||||
case 3:
|
||||
string = `${rawDuration[0]} hr ${rawDuration[1]} min ${rawDuration[2]} sec`;
|
||||
break;
|
||||
case 4:
|
||||
string = `${rawDuration[0]} day ${rawDuration[1]} hr ${rawDuration[2]} min ${rawDuration[3]} sec`;
|
||||
break;
|
||||
}
|
||||
|
||||
return string;
|
||||
};
|
||||
@@ -5,3 +5,4 @@ export * from './constrain-sidebar-width';
|
||||
export * from './title-case';
|
||||
export * from './get-header-color';
|
||||
export * from './parse-search-params';
|
||||
export * from './format-duration-string';
|
||||
|
||||
Reference in New Issue
Block a user