Compare commits

..

76 Commits

Author SHA1 Message Date
jeffvli 1048431742 Bump to v0.0.1-alpha3 2023-01-03 03:29:14 -08:00
jeffvli 633c9f59d9 Add update playlist for jellyfin 2023-01-03 03:25:21 -08:00
jeffvli 0ed13c75af Fix stale state on playqueue when switching server 2023-01-03 03:16:53 -08:00
jeffvli b0bc4c3cf3 Wait for load before setting background color 2023-01-03 03:15:51 -08:00
jeffvli b8b8ca9f66 Add separate filter for album song list fetch 2023-01-03 03:15:09 -08:00
jeffvli f2e6a418b0 Add fallback to average color calculation 2023-01-03 02:28:59 -08:00
jeffvli 7fef7e4689 Adjust sidebar theme 2023-01-03 02:28:40 -08:00
jeffvli 21bf995335 Move toast notifications to bottom-center 2023-01-03 02:28:10 -08:00
jeffvli bd13fb63ae Add window reload on first server add
- Fixes controller server type
2023-01-03 02:27:28 -08:00
jeffvli 67ccc20147 Adjust duration normalization to ms 2023-01-03 02:27:00 -08:00
jeffvli 83991cf5a1 Remove placeholder 2023-01-03 02:26:43 -08:00
jeffvli dfb0ff42b3 Navigate home after switching servers 2023-01-03 02:13:40 -08:00
jeffvli 008c12626d Add play controls to playlist song list 2023-01-03 02:13:21 -08:00
jeffvli 19e3f435c4 Fix add from card 2023-01-03 02:13:04 -08:00
jeffvli ac6242ea94 Navigate to home if no issues resolved 2023-01-03 02:00:21 -08:00
jeffvli b87d7778df Remove image placeholders (performance issues?) 2023-01-03 01:49:01 -08:00
jeffvli acb906aad9 Remove sidebar play button, increase fw for labels 2023-01-03 01:48:07 -08:00
jeffvli 196cb1bd48 Fix scroll area display type 2023-01-03 01:34:18 -08:00
jeffvli 3981ad3eb5 Adjust sidebar playlist styles 2023-01-03 01:34:00 -08:00
jeffvli d54131b34a Remove console logs 2023-01-03 00:51:24 -08:00
jeffvli 6ad6617d88 Add delete playlist to context menu 2023-01-03 00:50:09 -08:00
jeffvli 52163534db Add update/delete playlist forms 2023-01-03 00:28:09 -08:00
jeffvli 5dd65b18b7 Add description property to playlist 2023-01-03 00:27:11 -08:00
jeffvli 1e77e1074a Add loading/disabled props for confirm modal 2023-01-03 00:19:33 -08:00
jeffvli 9537309fe2 Add custom confirm modal component 2023-01-03 00:12:07 -08:00
jeffvli 4dc8920ff4 Set overlay opacity based on theme 2023-01-02 18:54:48 -08:00
jeffvli 26e6f479b7 Implement new header on home page 2023-01-02 18:20:45 -08:00
jeffvli d93e6a612e Lighten overlay on header bg
- Increase support for light styles
2023-01-02 18:20:17 -08:00
jeffvli 0baa6f4488 Adjust header styles 2023-01-02 18:17:06 -08:00
jeffvli 6490118741 Fix ellipsis overflow styles 2023-01-02 18:14:44 -08:00
jeffvli 58827a1dcf Set header target to optional 2023-01-02 18:08:19 -08:00
jeffvli a3804808b4 Update album/playlist headers with shared styles 2023-01-02 17:57:49 -08:00
jeffvli d49bba42ef Bump framer motion to v8 2023-01-02 17:57:24 -08:00
jeffvli c56f6a355d Add duration string util 2023-01-02 17:56:09 -08:00
jeffvli 7b13e24ce4 Calculate duration playlist duration in ms 2023-01-02 17:55:50 -08:00
jeffvli 088f1d0f99 Adjust title style 2023-01-02 17:55:14 -08:00
jeffvli d7d611c6d1 Add missing overflow style value 2023-01-02 17:53:27 -08:00
jeffvli 65465d6cae Support dynamic page headers 2023-01-02 17:03:33 -08:00
jeffvli 3d8ba2e808 Add native scroll area component 2023-01-02 16:59:21 -08:00
jeffvli 152be5d7e6 Add library detail header component 2023-01-02 03:47:05 -08:00
jeffvli 4326f6cf91 Various cleanup 2023-01-02 02:05:30 -08:00
jeffvli 90dec929f4 Add playlist detail page 2023-01-02 02:04:23 -08:00
jeffvli d6dc880ef4 Add playlist image to type 2023-01-02 01:58:31 -08:00
jeffvli d0e2a798fe Account for playlist items in cover art url 2023-01-01 15:05:26 -08:00
jeffvli fecaa2e6b8 Use song-specific cover and add placeholder (#6) 2023-01-01 14:16:57 -08:00
jeffvli cdbd3f8c7b Remove dynamic queue header color 2023-01-01 14:04:16 -08:00
jeffvli b037329377 Handle jellyfin playlist creation 2023-01-01 14:02:03 -08:00
jeffvli 8b04f70106 Add dedicated playlist song list page 2023-01-01 13:58:05 -08:00
jeffvli 737a05e2c5 Update pagination
- Support id pages
- Set proper list max
2023-01-01 03:16:27 -08:00
jeffvli 78a30c2db4 Add ND playlist song type 2022-12-31 20:08:39 -08:00
jeffvli 5cef23944f Add playlist queue handler 2022-12-31 20:07:44 -08:00
jeffvli aa1cd742ad Move play queue handler to context 2022-12-31 19:26:58 -08:00
jeffvli 0f364f7c5c Add initial playlist detail page 2022-12-31 18:03:26 -08:00
jeffvli 11be5c811f Use size props for play button 2022-12-31 17:50:22 -08:00
jeffvli 6174dc128d Adjust base page headers 2022-12-31 17:50:05 -08:00
jeffvli 81455602ef Forward scrollarea ref 2022-12-31 16:50:20 -08:00
jeffvli d6936634db Update querykeys 2022-12-31 12:43:32 -08:00
jeffvli 88f53c17db Add create/update playlist mutations and form 2022-12-31 12:40:11 -08:00
jeffvli 82f107d835 Fix store name 2022-12-31 04:03:05 -08:00
jeffvli 1fee4c1946 Restore scroll on infinite lists 2022-12-31 04:02:47 -08:00
jeffvli ec79d91d30 Add playlist list 2022-12-31 03:46:12 -08:00
jeffvli 00a21269dd Set default color to undefined 2022-12-31 03:41:18 -08:00
jeffvli 58ed2f3706 Wait for background color before rendering content 2022-12-31 03:16:05 -08:00
jeffvli 0a9dcf36b9 Use prop for scrollbar width 2022-12-31 03:15:11 -08:00
jeffvli dc1e728a2e Increase minimum width from 200 -> 225 2022-12-31 01:00:51 -08:00
jeffvli 085a3856e0 Add search param to album artist list 2022-12-30 22:54:00 -08:00
jeffvli a693981333 Add query key to custom query options 2022-12-30 22:35:49 -08:00
jeffvli 2a797bd6c9 Add genre filter to navidrome song list 2022-12-30 22:34:59 -08:00
jeffvli 4a64f5fe9b Add play on double click for song list rows 2022-12-30 21:31:35 -08:00
jeffvli 1f232fa4da Add card placeholder images 2022-12-30 21:31:13 -08:00
jeffvli b3d95f765c Add page key for album detail page
- Fixes animation render when switching between detail pages
2022-12-30 21:12:27 -08:00
jeffvli f298e60929 Fix context menu add 2022-12-30 21:11:35 -08:00
jeffvli 4745c4a42d Add card/table types for album artists 2022-12-30 21:11:09 -08:00
jeffvli 6fddea552d Change default font to poppins 2022-12-30 21:04:30 -08:00
jeffvli 24af17b8fe Add album artist list route 2022-12-30 21:04:06 -08:00
jeffvli 185175aa89 Handle album artist play 2022-12-30 21:02:17 -08:00
96 changed files with 5526 additions and 853 deletions
+9 -19
View File
@@ -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
View File
@@ -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",
+2 -2
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "feishin",
"version": "0.0.1-alpha2",
"version": "0.0.1-alpha3",
"description": "",
"main": "./dist/main/main.js",
"author": {
+47 -3
View File
@@ -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,
};
+159 -33
View File
@@ -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,
};
+28 -2
View File
@@ -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 = {
+137 -29
View File
@@ -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,
};
+30 -6
View File
@@ -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;
};
+79 -1
View File
@@ -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,
};
+61 -5
View File
@@ -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,
},
};
+5 -1
View File
@@ -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> => {
+5 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
};
+2 -2
View File
@@ -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>
+63 -5
View File
@@ -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>
);
};
+76 -44
View File
@@ -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>
);
};
+118 -4
View File
@@ -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,
}}
/>
)}
</>
);
},
);
+2 -1
View File
@@ -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)'};
+2 -1
View File
@@ -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',
+3 -1
View File
@@ -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
View File
@@ -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>
);
};
+5
View File
@@ -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
View File
@@ -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;
};
+7 -1
View File
@@ -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>,
);
+1
View File
@@ -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);
`;
+36 -1
View File
@@ -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;
};
+27 -2
View File
@@ -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 />}
+1
View File
@@ -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',
}
+126
View File
@@ -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);
+2
View File
@@ -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';
+211
View File
@@ -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);
+1 -1
View File
@@ -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
View File
@@ -1,7 +1,6 @@
import { css } from 'styled-components';
export const textEllipsis = css`
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
`;
+4 -2
View File
@@ -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);
+1 -1
View File
@@ -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(
+4
View File
@@ -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;
};
+1
View File
@@ -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';