mirror of
https://github.com/antebudimir/feishin.git
synced 2026-01-01 02:13:33 +00:00
Lint all files
This commit is contained in:
parent
22af76b4d6
commit
30e52ebb54
334 changed files with 76519 additions and 75932 deletions
|
|
@ -1,53 +1,53 @@
|
|||
import { useAuthStore } from '/@/renderer/store';
|
||||
import { toast } from '/@/renderer/components/toast/index';
|
||||
import type {
|
||||
AlbumDetailArgs,
|
||||
AlbumListArgs,
|
||||
SongListArgs,
|
||||
SongDetailArgs,
|
||||
AlbumArtistDetailArgs,
|
||||
AlbumArtistListArgs,
|
||||
SetRatingArgs,
|
||||
GenreListArgs,
|
||||
CreatePlaylistArgs,
|
||||
DeletePlaylistArgs,
|
||||
PlaylistDetailArgs,
|
||||
PlaylistListArgs,
|
||||
MusicFolderListArgs,
|
||||
PlaylistSongListArgs,
|
||||
ArtistListArgs,
|
||||
UpdatePlaylistArgs,
|
||||
UserListArgs,
|
||||
FavoriteArgs,
|
||||
TopSongListArgs,
|
||||
AddToPlaylistArgs,
|
||||
AddToPlaylistResponse,
|
||||
RemoveFromPlaylistArgs,
|
||||
RemoveFromPlaylistResponse,
|
||||
ScrobbleArgs,
|
||||
ScrobbleResponse,
|
||||
AlbumArtistDetailResponse,
|
||||
FavoriteResponse,
|
||||
CreatePlaylistResponse,
|
||||
AlbumArtistListResponse,
|
||||
AlbumDetailResponse,
|
||||
AlbumListResponse,
|
||||
ArtistListResponse,
|
||||
GenreListResponse,
|
||||
MusicFolderListResponse,
|
||||
PlaylistDetailResponse,
|
||||
PlaylistListResponse,
|
||||
RatingResponse,
|
||||
SongDetailResponse,
|
||||
SongListResponse,
|
||||
TopSongListResponse,
|
||||
UpdatePlaylistResponse,
|
||||
UserListResponse,
|
||||
AuthenticationResponse,
|
||||
SearchArgs,
|
||||
SearchResponse,
|
||||
LyricsArgs,
|
||||
LyricsResponse,
|
||||
AlbumDetailArgs,
|
||||
AlbumListArgs,
|
||||
SongListArgs,
|
||||
SongDetailArgs,
|
||||
AlbumArtistDetailArgs,
|
||||
AlbumArtistListArgs,
|
||||
SetRatingArgs,
|
||||
GenreListArgs,
|
||||
CreatePlaylistArgs,
|
||||
DeletePlaylistArgs,
|
||||
PlaylistDetailArgs,
|
||||
PlaylistListArgs,
|
||||
MusicFolderListArgs,
|
||||
PlaylistSongListArgs,
|
||||
ArtistListArgs,
|
||||
UpdatePlaylistArgs,
|
||||
UserListArgs,
|
||||
FavoriteArgs,
|
||||
TopSongListArgs,
|
||||
AddToPlaylistArgs,
|
||||
AddToPlaylistResponse,
|
||||
RemoveFromPlaylistArgs,
|
||||
RemoveFromPlaylistResponse,
|
||||
ScrobbleArgs,
|
||||
ScrobbleResponse,
|
||||
AlbumArtistDetailResponse,
|
||||
FavoriteResponse,
|
||||
CreatePlaylistResponse,
|
||||
AlbumArtistListResponse,
|
||||
AlbumDetailResponse,
|
||||
AlbumListResponse,
|
||||
ArtistListResponse,
|
||||
GenreListResponse,
|
||||
MusicFolderListResponse,
|
||||
PlaylistDetailResponse,
|
||||
PlaylistListResponse,
|
||||
RatingResponse,
|
||||
SongDetailResponse,
|
||||
SongListResponse,
|
||||
TopSongListResponse,
|
||||
UpdatePlaylistResponse,
|
||||
UserListResponse,
|
||||
AuthenticationResponse,
|
||||
SearchArgs,
|
||||
SearchResponse,
|
||||
LyricsArgs,
|
||||
LyricsResponse,
|
||||
} from '/@/renderer/api/types';
|
||||
import { ServerType } from '/@/renderer/types';
|
||||
import { DeletePlaylistResponse, RandomSongListArgs } from './types';
|
||||
|
|
@ -56,436 +56,445 @@ import { ssController } from '/@/renderer/api/subsonic/subsonic-controller';
|
|||
import { jfController } from '/@/renderer/api/jellyfin/jellyfin-controller';
|
||||
|
||||
export type ControllerEndpoint = Partial<{
|
||||
addToPlaylist: (args: AddToPlaylistArgs) => Promise<AddToPlaylistResponse>;
|
||||
authenticate: (
|
||||
url: string,
|
||||
body: { password: string; username: string },
|
||||
) => Promise<AuthenticationResponse>;
|
||||
clearPlaylist: () => void;
|
||||
createFavorite: (args: FavoriteArgs) => Promise<FavoriteResponse>;
|
||||
createPlaylist: (args: CreatePlaylistArgs) => Promise<CreatePlaylistResponse>;
|
||||
deleteFavorite: (args: FavoriteArgs) => Promise<FavoriteResponse>;
|
||||
deletePlaylist: (args: DeletePlaylistArgs) => Promise<DeletePlaylistResponse>;
|
||||
getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise<AlbumArtistDetailResponse>;
|
||||
getAlbumArtistList: (args: AlbumArtistListArgs) => Promise<AlbumArtistListResponse>;
|
||||
getAlbumDetail: (args: AlbumDetailArgs) => Promise<AlbumDetailResponse>;
|
||||
getAlbumList: (args: AlbumListArgs) => Promise<AlbumListResponse>;
|
||||
getArtistDetail: () => void;
|
||||
getArtistInfo: (args: any) => void;
|
||||
getArtistList: (args: ArtistListArgs) => Promise<ArtistListResponse>;
|
||||
getFavoritesList: () => void;
|
||||
getFolderItemList: () => void;
|
||||
getFolderList: () => void;
|
||||
getFolderSongs: () => void;
|
||||
getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;
|
||||
getLyrics: (args: LyricsArgs) => Promise<LyricsResponse>;
|
||||
getMusicFolderList: (args: MusicFolderListArgs) => Promise<MusicFolderListResponse>;
|
||||
getPlaylistDetail: (args: PlaylistDetailArgs) => Promise<PlaylistDetailResponse>;
|
||||
getPlaylistList: (args: PlaylistListArgs) => Promise<PlaylistListResponse>;
|
||||
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<SongListResponse>;
|
||||
getRandomSongList: (args: RandomSongListArgs) => Promise<SongListResponse>;
|
||||
getSongDetail: (args: SongDetailArgs) => Promise<SongDetailResponse>;
|
||||
getSongList: (args: SongListArgs) => Promise<SongListResponse>;
|
||||
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
|
||||
getUserList: (args: UserListArgs) => Promise<UserListResponse>;
|
||||
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
|
||||
scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>;
|
||||
search: (args: SearchArgs) => Promise<SearchResponse>;
|
||||
setRating: (args: SetRatingArgs) => Promise<RatingResponse>;
|
||||
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<UpdatePlaylistResponse>;
|
||||
addToPlaylist: (args: AddToPlaylistArgs) => Promise<AddToPlaylistResponse>;
|
||||
authenticate: (
|
||||
url: string,
|
||||
body: { password: string; username: string },
|
||||
) => Promise<AuthenticationResponse>;
|
||||
clearPlaylist: () => void;
|
||||
createFavorite: (args: FavoriteArgs) => Promise<FavoriteResponse>;
|
||||
createPlaylist: (args: CreatePlaylistArgs) => Promise<CreatePlaylistResponse>;
|
||||
deleteFavorite: (args: FavoriteArgs) => Promise<FavoriteResponse>;
|
||||
deletePlaylist: (args: DeletePlaylistArgs) => Promise<DeletePlaylistResponse>;
|
||||
getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise<AlbumArtistDetailResponse>;
|
||||
getAlbumArtistList: (args: AlbumArtistListArgs) => Promise<AlbumArtistListResponse>;
|
||||
getAlbumDetail: (args: AlbumDetailArgs) => Promise<AlbumDetailResponse>;
|
||||
getAlbumList: (args: AlbumListArgs) => Promise<AlbumListResponse>;
|
||||
getArtistDetail: () => void;
|
||||
getArtistInfo: (args: any) => void;
|
||||
getArtistList: (args: ArtistListArgs) => Promise<ArtistListResponse>;
|
||||
getFavoritesList: () => void;
|
||||
getFolderItemList: () => void;
|
||||
getFolderList: () => void;
|
||||
getFolderSongs: () => void;
|
||||
getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;
|
||||
getLyrics: (args: LyricsArgs) => Promise<LyricsResponse>;
|
||||
getMusicFolderList: (args: MusicFolderListArgs) => Promise<MusicFolderListResponse>;
|
||||
getPlaylistDetail: (args: PlaylistDetailArgs) => Promise<PlaylistDetailResponse>;
|
||||
getPlaylistList: (args: PlaylistListArgs) => Promise<PlaylistListResponse>;
|
||||
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<SongListResponse>;
|
||||
getRandomSongList: (args: RandomSongListArgs) => Promise<SongListResponse>;
|
||||
getSongDetail: (args: SongDetailArgs) => Promise<SongDetailResponse>;
|
||||
getSongList: (args: SongListArgs) => Promise<SongListResponse>;
|
||||
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
|
||||
getUserList: (args: UserListArgs) => Promise<UserListResponse>;
|
||||
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
|
||||
scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>;
|
||||
search: (args: SearchArgs) => Promise<SearchResponse>;
|
||||
setRating: (args: SetRatingArgs) => Promise<RatingResponse>;
|
||||
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<UpdatePlaylistResponse>;
|
||||
}>;
|
||||
|
||||
type ApiController = {
|
||||
jellyfin: ControllerEndpoint;
|
||||
navidrome: ControllerEndpoint;
|
||||
subsonic: ControllerEndpoint;
|
||||
jellyfin: ControllerEndpoint;
|
||||
navidrome: ControllerEndpoint;
|
||||
subsonic: ControllerEndpoint;
|
||||
};
|
||||
|
||||
const endpoints: ApiController = {
|
||||
jellyfin: {
|
||||
addToPlaylist: jfController.addToPlaylist,
|
||||
authenticate: jfController.authenticate,
|
||||
clearPlaylist: undefined,
|
||||
createFavorite: jfController.createFavorite,
|
||||
createPlaylist: jfController.createPlaylist,
|
||||
deleteFavorite: jfController.deleteFavorite,
|
||||
deletePlaylist: jfController.deletePlaylist,
|
||||
getAlbumArtistDetail: jfController.getAlbumArtistDetail,
|
||||
getAlbumArtistList: jfController.getAlbumArtistList,
|
||||
getAlbumDetail: jfController.getAlbumDetail,
|
||||
getAlbumList: jfController.getAlbumList,
|
||||
getArtistDetail: undefined,
|
||||
getArtistInfo: undefined,
|
||||
getArtistList: undefined,
|
||||
getFavoritesList: undefined,
|
||||
getFolderItemList: undefined,
|
||||
getFolderList: undefined,
|
||||
getFolderSongs: undefined,
|
||||
getGenreList: jfController.getGenreList,
|
||||
getLyrics: jfController.getLyrics,
|
||||
getMusicFolderList: jfController.getMusicFolderList,
|
||||
getPlaylistDetail: jfController.getPlaylistDetail,
|
||||
getPlaylistList: jfController.getPlaylistList,
|
||||
getPlaylistSongList: jfController.getPlaylistSongList,
|
||||
getRandomSongList: jfController.getRandomSongList,
|
||||
getSongDetail: undefined,
|
||||
getSongList: jfController.getSongList,
|
||||
getTopSongs: jfController.getTopSongList,
|
||||
getUserList: undefined,
|
||||
removeFromPlaylist: jfController.removeFromPlaylist,
|
||||
scrobble: jfController.scrobble,
|
||||
search: jfController.search,
|
||||
setRating: undefined,
|
||||
updatePlaylist: jfController.updatePlaylist,
|
||||
},
|
||||
navidrome: {
|
||||
addToPlaylist: ndController.addToPlaylist,
|
||||
authenticate: ndController.authenticate,
|
||||
clearPlaylist: undefined,
|
||||
createFavorite: ssController.createFavorite,
|
||||
createPlaylist: ndController.createPlaylist,
|
||||
deleteFavorite: ssController.removeFavorite,
|
||||
deletePlaylist: ndController.deletePlaylist,
|
||||
getAlbumArtistDetail: ndController.getAlbumArtistDetail,
|
||||
getAlbumArtistList: ndController.getAlbumArtistList,
|
||||
getAlbumDetail: ndController.getAlbumDetail,
|
||||
getAlbumList: ndController.getAlbumList,
|
||||
getArtistDetail: undefined,
|
||||
getArtistInfo: undefined,
|
||||
getArtistList: undefined,
|
||||
getFavoritesList: undefined,
|
||||
getFolderItemList: undefined,
|
||||
getFolderList: undefined,
|
||||
getFolderSongs: undefined,
|
||||
getGenreList: ndController.getGenreList,
|
||||
getLyrics: undefined,
|
||||
getMusicFolderList: ssController.getMusicFolderList,
|
||||
getPlaylistDetail: ndController.getPlaylistDetail,
|
||||
getPlaylistList: ndController.getPlaylistList,
|
||||
getPlaylistSongList: ndController.getPlaylistSongList,
|
||||
getRandomSongList: ssController.getRandomSongList,
|
||||
getSongDetail: ndController.getSongDetail,
|
||||
getSongList: ndController.getSongList,
|
||||
getTopSongs: ssController.getTopSongList,
|
||||
getUserList: ndController.getUserList,
|
||||
removeFromPlaylist: ndController.removeFromPlaylist,
|
||||
scrobble: ssController.scrobble,
|
||||
search: ssController.search3,
|
||||
setRating: ssController.setRating,
|
||||
updatePlaylist: ndController.updatePlaylist,
|
||||
},
|
||||
subsonic: {
|
||||
authenticate: ssController.authenticate,
|
||||
clearPlaylist: undefined,
|
||||
createFavorite: ssController.createFavorite,
|
||||
createPlaylist: undefined,
|
||||
deleteFavorite: ssController.removeFavorite,
|
||||
deletePlaylist: undefined,
|
||||
getAlbumArtistDetail: undefined,
|
||||
getAlbumArtistList: undefined,
|
||||
getAlbumDetail: undefined,
|
||||
getAlbumList: undefined,
|
||||
getArtistDetail: undefined,
|
||||
getArtistInfo: undefined,
|
||||
getArtistList: undefined,
|
||||
getFavoritesList: undefined,
|
||||
getFolderItemList: undefined,
|
||||
getFolderList: undefined,
|
||||
getFolderSongs: undefined,
|
||||
getGenreList: undefined,
|
||||
getLyrics: undefined,
|
||||
getMusicFolderList: ssController.getMusicFolderList,
|
||||
getPlaylistDetail: undefined,
|
||||
getPlaylistList: undefined,
|
||||
getSongDetail: undefined,
|
||||
getSongList: undefined,
|
||||
getTopSongs: ssController.getTopSongList,
|
||||
getUserList: undefined,
|
||||
scrobble: ssController.scrobble,
|
||||
search: ssController.search3,
|
||||
setRating: undefined,
|
||||
updatePlaylist: undefined,
|
||||
},
|
||||
jellyfin: {
|
||||
addToPlaylist: jfController.addToPlaylist,
|
||||
authenticate: jfController.authenticate,
|
||||
clearPlaylist: undefined,
|
||||
createFavorite: jfController.createFavorite,
|
||||
createPlaylist: jfController.createPlaylist,
|
||||
deleteFavorite: jfController.deleteFavorite,
|
||||
deletePlaylist: jfController.deletePlaylist,
|
||||
getAlbumArtistDetail: jfController.getAlbumArtistDetail,
|
||||
getAlbumArtistList: jfController.getAlbumArtistList,
|
||||
getAlbumDetail: jfController.getAlbumDetail,
|
||||
getAlbumList: jfController.getAlbumList,
|
||||
getArtistDetail: undefined,
|
||||
getArtistInfo: undefined,
|
||||
getArtistList: undefined,
|
||||
getFavoritesList: undefined,
|
||||
getFolderItemList: undefined,
|
||||
getFolderList: undefined,
|
||||
getFolderSongs: undefined,
|
||||
getGenreList: jfController.getGenreList,
|
||||
getLyrics: jfController.getLyrics,
|
||||
getMusicFolderList: jfController.getMusicFolderList,
|
||||
getPlaylistDetail: jfController.getPlaylistDetail,
|
||||
getPlaylistList: jfController.getPlaylistList,
|
||||
getPlaylistSongList: jfController.getPlaylistSongList,
|
||||
getRandomSongList: jfController.getRandomSongList,
|
||||
getSongDetail: undefined,
|
||||
getSongList: jfController.getSongList,
|
||||
getTopSongs: jfController.getTopSongList,
|
||||
getUserList: undefined,
|
||||
removeFromPlaylist: jfController.removeFromPlaylist,
|
||||
scrobble: jfController.scrobble,
|
||||
search: jfController.search,
|
||||
setRating: undefined,
|
||||
updatePlaylist: jfController.updatePlaylist,
|
||||
},
|
||||
navidrome: {
|
||||
addToPlaylist: ndController.addToPlaylist,
|
||||
authenticate: ndController.authenticate,
|
||||
clearPlaylist: undefined,
|
||||
createFavorite: ssController.createFavorite,
|
||||
createPlaylist: ndController.createPlaylist,
|
||||
deleteFavorite: ssController.removeFavorite,
|
||||
deletePlaylist: ndController.deletePlaylist,
|
||||
getAlbumArtistDetail: ndController.getAlbumArtistDetail,
|
||||
getAlbumArtistList: ndController.getAlbumArtistList,
|
||||
getAlbumDetail: ndController.getAlbumDetail,
|
||||
getAlbumList: ndController.getAlbumList,
|
||||
getArtistDetail: undefined,
|
||||
getArtistInfo: undefined,
|
||||
getArtistList: undefined,
|
||||
getFavoritesList: undefined,
|
||||
getFolderItemList: undefined,
|
||||
getFolderList: undefined,
|
||||
getFolderSongs: undefined,
|
||||
getGenreList: ndController.getGenreList,
|
||||
getLyrics: undefined,
|
||||
getMusicFolderList: ssController.getMusicFolderList,
|
||||
getPlaylistDetail: ndController.getPlaylistDetail,
|
||||
getPlaylistList: ndController.getPlaylistList,
|
||||
getPlaylistSongList: ndController.getPlaylistSongList,
|
||||
getRandomSongList: ssController.getRandomSongList,
|
||||
getSongDetail: ndController.getSongDetail,
|
||||
getSongList: ndController.getSongList,
|
||||
getTopSongs: ssController.getTopSongList,
|
||||
getUserList: ndController.getUserList,
|
||||
removeFromPlaylist: ndController.removeFromPlaylist,
|
||||
scrobble: ssController.scrobble,
|
||||
search: ssController.search3,
|
||||
setRating: ssController.setRating,
|
||||
updatePlaylist: ndController.updatePlaylist,
|
||||
},
|
||||
subsonic: {
|
||||
authenticate: ssController.authenticate,
|
||||
clearPlaylist: undefined,
|
||||
createFavorite: ssController.createFavorite,
|
||||
createPlaylist: undefined,
|
||||
deleteFavorite: ssController.removeFavorite,
|
||||
deletePlaylist: undefined,
|
||||
getAlbumArtistDetail: undefined,
|
||||
getAlbumArtistList: undefined,
|
||||
getAlbumDetail: undefined,
|
||||
getAlbumList: undefined,
|
||||
getArtistDetail: undefined,
|
||||
getArtistInfo: undefined,
|
||||
getArtistList: undefined,
|
||||
getFavoritesList: undefined,
|
||||
getFolderItemList: undefined,
|
||||
getFolderList: undefined,
|
||||
getFolderSongs: undefined,
|
||||
getGenreList: undefined,
|
||||
getLyrics: undefined,
|
||||
getMusicFolderList: ssController.getMusicFolderList,
|
||||
getPlaylistDetail: undefined,
|
||||
getPlaylistList: undefined,
|
||||
getSongDetail: undefined,
|
||||
getSongList: undefined,
|
||||
getTopSongs: ssController.getTopSongList,
|
||||
getUserList: undefined,
|
||||
scrobble: ssController.scrobble,
|
||||
search: ssController.search3,
|
||||
setRating: undefined,
|
||||
updatePlaylist: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const apiController = (endpoint: keyof ControllerEndpoint, type?: ServerType) => {
|
||||
const serverType = type || useAuthStore.getState().currentServer?.type;
|
||||
const serverType = type || useAuthStore.getState().currentServer?.type;
|
||||
|
||||
if (!serverType) {
|
||||
toast.error({ message: 'No server selected', title: 'Unable to route request' });
|
||||
throw new Error(`No server selected`);
|
||||
}
|
||||
if (!serverType) {
|
||||
toast.error({ message: 'No server selected', title: 'Unable to route request' });
|
||||
throw new Error(`No server selected`);
|
||||
}
|
||||
|
||||
const controllerFn = endpoints?.[serverType]?.[endpoint];
|
||||
const controllerFn = endpoints?.[serverType]?.[endpoint];
|
||||
|
||||
if (typeof controllerFn !== 'function') {
|
||||
toast.error({
|
||||
message: `Endpoint ${endpoint} is not implemented for ${serverType}`,
|
||||
title: 'Unable to route request',
|
||||
});
|
||||
if (typeof controllerFn !== 'function') {
|
||||
toast.error({
|
||||
message: `Endpoint ${endpoint} is not implemented for ${serverType}`,
|
||||
title: 'Unable to route request',
|
||||
});
|
||||
|
||||
throw new Error(`Endpoint ${endpoint} is not implemented for ${serverType}`);
|
||||
}
|
||||
throw new Error(`Endpoint ${endpoint} is not implemented for ${serverType}`);
|
||||
}
|
||||
|
||||
return endpoints[serverType][endpoint];
|
||||
return endpoints[serverType][endpoint];
|
||||
};
|
||||
|
||||
const authenticate = async (
|
||||
url: string,
|
||||
body: { legacy?: boolean; password: string; username: string },
|
||||
type: ServerType,
|
||||
url: string,
|
||||
body: { legacy?: boolean; password: string; username: string },
|
||||
type: ServerType,
|
||||
) => {
|
||||
return (apiController('authenticate', type) as ControllerEndpoint['authenticate'])?.(url, body);
|
||||
return (apiController('authenticate', type) as ControllerEndpoint['authenticate'])?.(url, body);
|
||||
};
|
||||
|
||||
const getAlbumList = async (args: AlbumListArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'getAlbumList',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getAlbumList']
|
||||
)?.(args);
|
||||
return (
|
||||
apiController(
|
||||
'getAlbumList',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getAlbumList']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getAlbumDetail = async (args: AlbumDetailArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'getAlbumDetail',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getAlbumDetail']
|
||||
)?.(args);
|
||||
return (
|
||||
apiController(
|
||||
'getAlbumDetail',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getAlbumDetail']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getSongList = async (args: SongListArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'getSongList',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getSongList']
|
||||
)?.(args);
|
||||
return (
|
||||
apiController(
|
||||
'getSongList',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getSongList']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getSongDetail = async (args: SongDetailArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'getSongDetail',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getSongDetail']
|
||||
)?.(args);
|
||||
return (
|
||||
apiController(
|
||||
'getSongDetail',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getSongDetail']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getMusicFolderList = async (args: MusicFolderListArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'getMusicFolderList',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getMusicFolderList']
|
||||
)?.(args);
|
||||
return (
|
||||
apiController(
|
||||
'getMusicFolderList',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getMusicFolderList']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getGenreList = async (args: GenreListArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'getGenreList',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getGenreList']
|
||||
)?.(args);
|
||||
return (
|
||||
apiController(
|
||||
'getGenreList',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getGenreList']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'getAlbumArtistDetail',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getAlbumArtistDetail']
|
||||
)?.(args);
|
||||
return (
|
||||
apiController(
|
||||
'getAlbumArtistDetail',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getAlbumArtistDetail']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getAlbumArtistList = async (args: AlbumArtistListArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'getAlbumArtistList',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getAlbumArtistList']
|
||||
)?.(args);
|
||||
return (
|
||||
apiController(
|
||||
'getAlbumArtistList',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getAlbumArtistList']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getArtistList = async (args: ArtistListArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'getArtistList',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getArtistList']
|
||||
)?.(args);
|
||||
return (
|
||||
apiController(
|
||||
'getArtistList',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getArtistList']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getPlaylistList = async (args: PlaylistListArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'getPlaylistList',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getPlaylistList']
|
||||
)?.(args);
|
||||
return (
|
||||
apiController(
|
||||
'getPlaylistList',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getPlaylistList']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const createPlaylist = async (args: CreatePlaylistArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'createPlaylist',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['createPlaylist']
|
||||
)?.(args);
|
||||
return (
|
||||
apiController(
|
||||
'createPlaylist',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['createPlaylist']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const updatePlaylist = async (args: UpdatePlaylistArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'updatePlaylist',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['updatePlaylist']
|
||||
)?.(args);
|
||||
return (
|
||||
apiController(
|
||||
'updatePlaylist',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['updatePlaylist']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const deletePlaylist = async (args: DeletePlaylistArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'deletePlaylist',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['deletePlaylist']
|
||||
)?.(args);
|
||||
return (
|
||||
apiController(
|
||||
'deletePlaylist',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['deletePlaylist']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const addToPlaylist = async (args: AddToPlaylistArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'addToPlaylist',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['addToPlaylist']
|
||||
)?.(args);
|
||||
return (
|
||||
apiController(
|
||||
'addToPlaylist',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['addToPlaylist']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const removeFromPlaylist = async (args: RemoveFromPlaylistArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'removeFromPlaylist',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['removeFromPlaylist']
|
||||
)?.(args);
|
||||
return (
|
||||
apiController(
|
||||
'removeFromPlaylist',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['removeFromPlaylist']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getPlaylistDetail = async (args: PlaylistDetailArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'getPlaylistDetail',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getPlaylistDetail']
|
||||
)?.(args);
|
||||
return (
|
||||
apiController(
|
||||
'getPlaylistDetail',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getPlaylistDetail']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getPlaylistSongList = async (args: PlaylistSongListArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'getPlaylistSongList',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getPlaylistSongList']
|
||||
)?.(args);
|
||||
return (
|
||||
apiController(
|
||||
'getPlaylistSongList',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getPlaylistSongList']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getUserList = async (args: UserListArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'getUserList',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getUserList']
|
||||
)?.(args);
|
||||
return (
|
||||
apiController(
|
||||
'getUserList',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getUserList']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const createFavorite = async (args: FavoriteArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'createFavorite',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['createFavorite']
|
||||
)?.(args);
|
||||
return (
|
||||
apiController(
|
||||
'createFavorite',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['createFavorite']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const deleteFavorite = async (args: FavoriteArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'deleteFavorite',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['deleteFavorite']
|
||||
)?.(args);
|
||||
return (
|
||||
apiController(
|
||||
'deleteFavorite',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['deleteFavorite']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const updateRating = async (args: SetRatingArgs) => {
|
||||
return (
|
||||
apiController('setRating', args.apiClientProps.server?.type) as ControllerEndpoint['setRating']
|
||||
)?.(args);
|
||||
return (
|
||||
apiController(
|
||||
'setRating',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['setRating']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getTopSongList = async (args: TopSongListArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'getTopSongs',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getTopSongs']
|
||||
)?.(args);
|
||||
return (
|
||||
apiController(
|
||||
'getTopSongs',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getTopSongs']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const scrobble = async (args: ScrobbleArgs) => {
|
||||
return (
|
||||
apiController('scrobble', args.apiClientProps.server?.type) as ControllerEndpoint['scrobble']
|
||||
)?.(args);
|
||||
return (
|
||||
apiController(
|
||||
'scrobble',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['scrobble']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const search = async (args: SearchArgs) => {
|
||||
return (
|
||||
apiController('search', args.apiClientProps.server?.type) as ControllerEndpoint['search']
|
||||
)?.(args);
|
||||
return (
|
||||
apiController('search', args.apiClientProps.server?.type) as ControllerEndpoint['search']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getRandomSongList = async (args: RandomSongListArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'getRandomSongList',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getRandomSongList']
|
||||
)?.(args);
|
||||
return (
|
||||
apiController(
|
||||
'getRandomSongList',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getRandomSongList']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getLyrics = async (args: LyricsArgs) => {
|
||||
return (
|
||||
apiController('getLyrics', args.apiClientProps.server?.type) as ControllerEndpoint['getLyrics']
|
||||
)?.(args);
|
||||
return (
|
||||
apiController(
|
||||
'getLyrics',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getLyrics']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
export const controller = {
|
||||
addToPlaylist,
|
||||
authenticate,
|
||||
createFavorite,
|
||||
createPlaylist,
|
||||
deleteFavorite,
|
||||
deletePlaylist,
|
||||
getAlbumArtistDetail,
|
||||
getAlbumArtistList,
|
||||
getAlbumDetail,
|
||||
getAlbumList,
|
||||
getArtistList,
|
||||
getGenreList,
|
||||
getLyrics,
|
||||
getMusicFolderList,
|
||||
getPlaylistDetail,
|
||||
getPlaylistList,
|
||||
getPlaylistSongList,
|
||||
getRandomSongList,
|
||||
getSongDetail,
|
||||
getSongList,
|
||||
getTopSongList,
|
||||
getUserList,
|
||||
removeFromPlaylist,
|
||||
scrobble,
|
||||
search,
|
||||
updatePlaylist,
|
||||
updateRating,
|
||||
addToPlaylist,
|
||||
authenticate,
|
||||
createFavorite,
|
||||
createPlaylist,
|
||||
deleteFavorite,
|
||||
deletePlaylist,
|
||||
getAlbumArtistDetail,
|
||||
getAlbumArtistList,
|
||||
getAlbumDetail,
|
||||
getAlbumList,
|
||||
getArtistList,
|
||||
getGenreList,
|
||||
getLyrics,
|
||||
getMusicFolderList,
|
||||
getPlaylistDetail,
|
||||
getPlaylistList,
|
||||
getPlaylistSongList,
|
||||
getRandomSongList,
|
||||
getSongDetail,
|
||||
getSongList,
|
||||
getTopSongList,
|
||||
getUserList,
|
||||
removeFromPlaylist,
|
||||
scrobble,
|
||||
search,
|
||||
updatePlaylist,
|
||||
updateRating,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { controller } from '/@/renderer/api/controller';
|
||||
|
||||
export const api = {
|
||||
controller,
|
||||
controller,
|
||||
};
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -11,340 +11,340 @@ import { authenticationFailure } from '/@/renderer/api/utils';
|
|||
const c = initContract();
|
||||
|
||||
export const contract = c.router({
|
||||
addToPlaylist: {
|
||||
body: z.null(),
|
||||
method: 'POST',
|
||||
path: 'playlists/:id/items',
|
||||
query: jfType._parameters.addToPlaylist,
|
||||
responses: {
|
||||
204: jfType._response.addToPlaylist,
|
||||
400: jfType._response.error,
|
||||
addToPlaylist: {
|
||||
body: z.null(),
|
||||
method: 'POST',
|
||||
path: 'playlists/:id/items',
|
||||
query: jfType._parameters.addToPlaylist,
|
||||
responses: {
|
||||
204: jfType._response.addToPlaylist,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
},
|
||||
authenticate: {
|
||||
body: jfType._parameters.authenticate,
|
||||
headers: z.object({
|
||||
'X-Emby-Authorization': z.string(),
|
||||
}),
|
||||
method: 'POST',
|
||||
path: 'users/authenticatebyname',
|
||||
responses: {
|
||||
200: jfType._response.authenticate,
|
||||
400: jfType._response.error,
|
||||
authenticate: {
|
||||
body: jfType._parameters.authenticate,
|
||||
headers: z.object({
|
||||
'X-Emby-Authorization': z.string(),
|
||||
}),
|
||||
method: 'POST',
|
||||
path: 'users/authenticatebyname',
|
||||
responses: {
|
||||
200: jfType._response.authenticate,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
},
|
||||
createFavorite: {
|
||||
body: jfType._parameters.favorite,
|
||||
method: 'POST',
|
||||
path: 'users/:userId/favoriteitems/:id',
|
||||
responses: {
|
||||
200: jfType._response.favorite,
|
||||
400: jfType._response.error,
|
||||
createFavorite: {
|
||||
body: jfType._parameters.favorite,
|
||||
method: 'POST',
|
||||
path: 'users/:userId/favoriteitems/:id',
|
||||
responses: {
|
||||
200: jfType._response.favorite,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
},
|
||||
createPlaylist: {
|
||||
body: jfType._parameters.createPlaylist,
|
||||
method: 'POST',
|
||||
path: 'playlists',
|
||||
responses: {
|
||||
200: jfType._response.createPlaylist,
|
||||
400: jfType._response.error,
|
||||
createPlaylist: {
|
||||
body: jfType._parameters.createPlaylist,
|
||||
method: 'POST',
|
||||
path: 'playlists',
|
||||
responses: {
|
||||
200: jfType._response.createPlaylist,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
},
|
||||
deletePlaylist: {
|
||||
body: null,
|
||||
method: 'DELETE',
|
||||
path: 'items/:id',
|
||||
responses: {
|
||||
204: jfType._response.deletePlaylist,
|
||||
400: jfType._response.error,
|
||||
deletePlaylist: {
|
||||
body: null,
|
||||
method: 'DELETE',
|
||||
path: 'items/:id',
|
||||
responses: {
|
||||
204: jfType._response.deletePlaylist,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
},
|
||||
getAlbumArtistDetail: {
|
||||
method: 'GET',
|
||||
path: 'users/:userId/items/:id',
|
||||
query: jfType._parameters.albumArtistDetail,
|
||||
responses: {
|
||||
200: jfType._response.albumArtist,
|
||||
400: jfType._response.error,
|
||||
getAlbumArtistDetail: {
|
||||
method: 'GET',
|
||||
path: 'users/:userId/items/:id',
|
||||
query: jfType._parameters.albumArtistDetail,
|
||||
responses: {
|
||||
200: jfType._response.albumArtist,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
},
|
||||
getAlbumArtistList: {
|
||||
method: 'GET',
|
||||
path: 'artists/albumArtists',
|
||||
query: jfType._parameters.albumArtistList,
|
||||
responses: {
|
||||
200: jfType._response.albumArtistList,
|
||||
400: jfType._response.error,
|
||||
getAlbumArtistList: {
|
||||
method: 'GET',
|
||||
path: 'artists/albumArtists',
|
||||
query: jfType._parameters.albumArtistList,
|
||||
responses: {
|
||||
200: jfType._response.albumArtistList,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
},
|
||||
getAlbumDetail: {
|
||||
method: 'GET',
|
||||
path: 'users/:userId/items/:id',
|
||||
query: jfType._parameters.albumDetail,
|
||||
responses: {
|
||||
200: jfType._response.album,
|
||||
400: jfType._response.error,
|
||||
getAlbumDetail: {
|
||||
method: 'GET',
|
||||
path: 'users/:userId/items/:id',
|
||||
query: jfType._parameters.albumDetail,
|
||||
responses: {
|
||||
200: jfType._response.album,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
},
|
||||
getAlbumList: {
|
||||
method: 'GET',
|
||||
path: 'users/:userId/items',
|
||||
query: jfType._parameters.albumList,
|
||||
responses: {
|
||||
200: jfType._response.albumList,
|
||||
400: jfType._response.error,
|
||||
getAlbumList: {
|
||||
method: 'GET',
|
||||
path: 'users/:userId/items',
|
||||
query: jfType._parameters.albumList,
|
||||
responses: {
|
||||
200: jfType._response.albumList,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
},
|
||||
getArtistList: {
|
||||
method: 'GET',
|
||||
path: 'artists',
|
||||
query: jfType._parameters.albumArtistList,
|
||||
responses: {
|
||||
200: jfType._response.albumArtistList,
|
||||
400: jfType._response.error,
|
||||
getArtistList: {
|
||||
method: 'GET',
|
||||
path: 'artists',
|
||||
query: jfType._parameters.albumArtistList,
|
||||
responses: {
|
||||
200: jfType._response.albumArtistList,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
},
|
||||
getGenreList: {
|
||||
method: 'GET',
|
||||
path: 'genres',
|
||||
responses: {
|
||||
200: jfType._response.genreList,
|
||||
400: jfType._response.error,
|
||||
getGenreList: {
|
||||
method: 'GET',
|
||||
path: 'genres',
|
||||
responses: {
|
||||
200: jfType._response.genreList,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
},
|
||||
getMusicFolderList: {
|
||||
method: 'GET',
|
||||
path: 'users/:userId/items',
|
||||
responses: {
|
||||
200: jfType._response.musicFolderList,
|
||||
400: jfType._response.error,
|
||||
getMusicFolderList: {
|
||||
method: 'GET',
|
||||
path: 'users/:userId/items',
|
||||
responses: {
|
||||
200: jfType._response.musicFolderList,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
},
|
||||
getPlaylistDetail: {
|
||||
method: 'GET',
|
||||
path: 'users/:userId/items/:id',
|
||||
query: jfType._parameters.playlistDetail,
|
||||
responses: {
|
||||
200: jfType._response.playlist,
|
||||
400: jfType._response.error,
|
||||
getPlaylistDetail: {
|
||||
method: 'GET',
|
||||
path: 'users/:userId/items/:id',
|
||||
query: jfType._parameters.playlistDetail,
|
||||
responses: {
|
||||
200: jfType._response.playlist,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
},
|
||||
getPlaylistList: {
|
||||
method: 'GET',
|
||||
path: 'users/:userId/items',
|
||||
query: jfType._parameters.playlistList,
|
||||
responses: {
|
||||
200: jfType._response.playlistList,
|
||||
400: jfType._response.error,
|
||||
getPlaylistList: {
|
||||
method: 'GET',
|
||||
path: 'users/:userId/items',
|
||||
query: jfType._parameters.playlistList,
|
||||
responses: {
|
||||
200: jfType._response.playlistList,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
},
|
||||
getPlaylistSongList: {
|
||||
method: 'GET',
|
||||
path: 'playlists/:id/items',
|
||||
query: jfType._parameters.songList,
|
||||
responses: {
|
||||
200: jfType._response.playlistSongList,
|
||||
400: jfType._response.error,
|
||||
getPlaylistSongList: {
|
||||
method: 'GET',
|
||||
path: 'playlists/:id/items',
|
||||
query: jfType._parameters.songList,
|
||||
responses: {
|
||||
200: jfType._response.playlistSongList,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
},
|
||||
getSimilarArtistList: {
|
||||
method: 'GET',
|
||||
path: 'artists/:id/similar',
|
||||
query: jfType._parameters.similarArtistList,
|
||||
responses: {
|
||||
200: jfType._response.albumArtistList,
|
||||
400: jfType._response.error,
|
||||
getSimilarArtistList: {
|
||||
method: 'GET',
|
||||
path: 'artists/:id/similar',
|
||||
query: jfType._parameters.similarArtistList,
|
||||
responses: {
|
||||
200: jfType._response.albumArtistList,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
},
|
||||
getSongDetail: {
|
||||
method: 'GET',
|
||||
path: 'song/:id',
|
||||
responses: {
|
||||
200: jfType._response.song,
|
||||
400: jfType._response.error,
|
||||
getSongDetail: {
|
||||
method: 'GET',
|
||||
path: 'song/:id',
|
||||
responses: {
|
||||
200: jfType._response.song,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
},
|
||||
getSongList: {
|
||||
method: 'GET',
|
||||
path: 'users/:userId/items',
|
||||
query: jfType._parameters.songList,
|
||||
responses: {
|
||||
200: jfType._response.songList,
|
||||
400: jfType._response.error,
|
||||
getSongList: {
|
||||
method: 'GET',
|
||||
path: 'users/:userId/items',
|
||||
query: jfType._parameters.songList,
|
||||
responses: {
|
||||
200: jfType._response.songList,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
},
|
||||
getSongLyrics: {
|
||||
method: 'GET',
|
||||
path: 'users/:userId/Items/:id/Lyrics',
|
||||
responses: {
|
||||
200: jfType._response.lyrics,
|
||||
404: jfType._response.error,
|
||||
getSongLyrics: {
|
||||
method: 'GET',
|
||||
path: 'users/:userId/Items/:id/Lyrics',
|
||||
responses: {
|
||||
200: jfType._response.lyrics,
|
||||
404: jfType._response.error,
|
||||
},
|
||||
},
|
||||
},
|
||||
getTopSongsList: {
|
||||
method: 'GET',
|
||||
path: 'users/:userId/items',
|
||||
query: jfType._parameters.songList,
|
||||
responses: {
|
||||
200: jfType._response.topSongsList,
|
||||
400: jfType._response.error,
|
||||
getTopSongsList: {
|
||||
method: 'GET',
|
||||
path: 'users/:userId/items',
|
||||
query: jfType._parameters.songList,
|
||||
responses: {
|
||||
200: jfType._response.topSongsList,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
},
|
||||
removeFavorite: {
|
||||
body: jfType._parameters.favorite,
|
||||
method: 'DELETE',
|
||||
path: 'users/:userId/favoriteitems/:id',
|
||||
responses: {
|
||||
200: jfType._response.favorite,
|
||||
400: jfType._response.error,
|
||||
removeFavorite: {
|
||||
body: jfType._parameters.favorite,
|
||||
method: 'DELETE',
|
||||
path: 'users/:userId/favoriteitems/:id',
|
||||
responses: {
|
||||
200: jfType._response.favorite,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
},
|
||||
removeFromPlaylist: {
|
||||
body: null,
|
||||
method: 'DELETE',
|
||||
path: 'playlists/:id/items',
|
||||
query: jfType._parameters.removeFromPlaylist,
|
||||
responses: {
|
||||
200: jfType._response.removeFromPlaylist,
|
||||
400: jfType._response.error,
|
||||
removeFromPlaylist: {
|
||||
body: null,
|
||||
method: 'DELETE',
|
||||
path: 'playlists/:id/items',
|
||||
query: jfType._parameters.removeFromPlaylist,
|
||||
responses: {
|
||||
200: jfType._response.removeFromPlaylist,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
},
|
||||
scrobblePlaying: {
|
||||
body: jfType._parameters.scrobble,
|
||||
method: 'POST',
|
||||
path: 'sessions/playing',
|
||||
responses: {
|
||||
200: jfType._response.scrobble,
|
||||
400: jfType._response.error,
|
||||
scrobblePlaying: {
|
||||
body: jfType._parameters.scrobble,
|
||||
method: 'POST',
|
||||
path: 'sessions/playing',
|
||||
responses: {
|
||||
200: jfType._response.scrobble,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
},
|
||||
scrobbleProgress: {
|
||||
body: jfType._parameters.scrobble,
|
||||
method: 'POST',
|
||||
path: 'sessions/playing/progress',
|
||||
responses: {
|
||||
200: jfType._response.scrobble,
|
||||
400: jfType._response.error,
|
||||
scrobbleProgress: {
|
||||
body: jfType._parameters.scrobble,
|
||||
method: 'POST',
|
||||
path: 'sessions/playing/progress',
|
||||
responses: {
|
||||
200: jfType._response.scrobble,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
},
|
||||
scrobbleStopped: {
|
||||
body: jfType._parameters.scrobble,
|
||||
method: 'POST',
|
||||
path: 'sessions/playing/stopped',
|
||||
responses: {
|
||||
200: jfType._response.scrobble,
|
||||
400: jfType._response.error,
|
||||
scrobbleStopped: {
|
||||
body: jfType._parameters.scrobble,
|
||||
method: 'POST',
|
||||
path: 'sessions/playing/stopped',
|
||||
responses: {
|
||||
200: jfType._response.scrobble,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
},
|
||||
search: {
|
||||
method: 'GET',
|
||||
path: 'users/:userId/items',
|
||||
query: jfType._parameters.search,
|
||||
responses: {
|
||||
200: jfType._response.search,
|
||||
400: jfType._response.error,
|
||||
search: {
|
||||
method: 'GET',
|
||||
path: 'users/:userId/items',
|
||||
query: jfType._parameters.search,
|
||||
responses: {
|
||||
200: jfType._response.search,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
},
|
||||
updatePlaylist: {
|
||||
body: jfType._parameters.updatePlaylist,
|
||||
method: 'PUT',
|
||||
path: 'items/:id',
|
||||
responses: {
|
||||
200: jfType._response.updatePlaylist,
|
||||
400: jfType._response.error,
|
||||
updatePlaylist: {
|
||||
body: jfType._parameters.updatePlaylist,
|
||||
method: 'PUT',
|
||||
path: 'items/:id',
|
||||
responses: {
|
||||
200: jfType._response.updatePlaylist,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const axiosClient = axios.create({});
|
||||
|
||||
axiosClient.defaults.paramsSerializer = (params) => {
|
||||
return qs.stringify(params, { arrayFormat: 'repeat' });
|
||||
return qs.stringify(params, { arrayFormat: 'repeat' });
|
||||
};
|
||||
|
||||
axiosClient.interceptors.response.use(
|
||||
(response) => {
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
if (error.response && error.response.status === 401) {
|
||||
const currentServer = useAuthStore.getState().currentServer;
|
||||
(response) => {
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
if (error.response && error.response.status === 401) {
|
||||
const currentServer = useAuthStore.getState().currentServer;
|
||||
|
||||
authenticationFailure(currentServer);
|
||||
}
|
||||
authenticationFailure(currentServer);
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
const parsePath = (fullPath: string) => {
|
||||
const [path, params] = fullPath.split('?');
|
||||
const [path, params] = fullPath.split('?');
|
||||
|
||||
const parsedParams = qs.parse(params);
|
||||
const notNilParams = omitBy(parsedParams, (value) => value === 'undefined' || value === 'null');
|
||||
const parsedParams = qs.parse(params);
|
||||
const notNilParams = omitBy(parsedParams, (value) => value === 'undefined' || value === 'null');
|
||||
|
||||
return {
|
||||
params: notNilParams,
|
||||
path,
|
||||
};
|
||||
return {
|
||||
params: notNilParams,
|
||||
path,
|
||||
};
|
||||
};
|
||||
|
||||
export const jfApiClient = (args: {
|
||||
server: ServerListItem | null;
|
||||
signal?: AbortSignal;
|
||||
url?: string;
|
||||
server: ServerListItem | null;
|
||||
signal?: AbortSignal;
|
||||
url?: string;
|
||||
}) => {
|
||||
const { server, url, signal } = args;
|
||||
const { server, url, signal } = args;
|
||||
|
||||
return initClient(contract, {
|
||||
api: async ({ path, method, headers, body }) => {
|
||||
let baseUrl: string | undefined;
|
||||
let token: string | undefined;
|
||||
return initClient(contract, {
|
||||
api: async ({ path, method, headers, body }) => {
|
||||
let baseUrl: string | undefined;
|
||||
let token: string | undefined;
|
||||
|
||||
const { params, path: api } = parsePath(path);
|
||||
const { params, path: api } = parsePath(path);
|
||||
|
||||
if (server) {
|
||||
baseUrl = `${server?.url}`;
|
||||
token = server?.credential;
|
||||
} else {
|
||||
baseUrl = url;
|
||||
}
|
||||
if (server) {
|
||||
baseUrl = `${server?.url}`;
|
||||
token = server?.credential;
|
||||
} else {
|
||||
baseUrl = url;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await axiosClient.request({
|
||||
data: body,
|
||||
headers: {
|
||||
...headers,
|
||||
...(token && { 'X-MediaBrowser-Token': token }),
|
||||
},
|
||||
method: method as Method,
|
||||
params,
|
||||
signal,
|
||||
url: `${baseUrl}/${api}`,
|
||||
});
|
||||
return {
|
||||
body: result.data,
|
||||
headers: result.headers as any,
|
||||
status: result.status,
|
||||
};
|
||||
} catch (e: Error | AxiosError | any) {
|
||||
if (isAxiosError(e)) {
|
||||
const error = e as AxiosError;
|
||||
const response = error.response as AxiosResponse;
|
||||
return {
|
||||
body: response?.data,
|
||||
headers: response?.headers as any,
|
||||
status: response.status,
|
||||
};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
baseHeaders: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
baseUrl: '',
|
||||
jsonQuery: false,
|
||||
});
|
||||
try {
|
||||
const result = await axiosClient.request({
|
||||
data: body,
|
||||
headers: {
|
||||
...headers,
|
||||
...(token && { 'X-MediaBrowser-Token': token }),
|
||||
},
|
||||
method: method as Method,
|
||||
params,
|
||||
signal,
|
||||
url: `${baseUrl}/${api}`,
|
||||
});
|
||||
return {
|
||||
body: result.data,
|
||||
headers: result.headers as any,
|
||||
status: result.status,
|
||||
};
|
||||
} catch (e: Error | AxiosError | any) {
|
||||
if (isAxiosError(e)) {
|
||||
const error = e as AxiosError;
|
||||
const response = error.response as AxiosResponse;
|
||||
return {
|
||||
body: response?.data,
|
||||
headers: response?.headers as any,
|
||||
status: response.status,
|
||||
};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
baseHeaders: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
baseUrl: '',
|
||||
jsonQuery: false,
|
||||
});
|
||||
};
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -3,312 +3,316 @@ import { z } from 'zod';
|
|||
import { JFAlbum, JFPlaylist, JFMusicFolder, JFGenre } from '/@/renderer/api/jellyfin.types';
|
||||
import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
|
||||
import {
|
||||
Song,
|
||||
LibraryItem,
|
||||
Album,
|
||||
AlbumArtist,
|
||||
Playlist,
|
||||
MusicFolder,
|
||||
Genre,
|
||||
Song,
|
||||
LibraryItem,
|
||||
Album,
|
||||
AlbumArtist,
|
||||
Playlist,
|
||||
MusicFolder,
|
||||
Genre,
|
||||
} from '/@/renderer/api/types';
|
||||
import { ServerListItem, ServerType } from '/@/renderer/types';
|
||||
|
||||
const getStreamUrl = (args: {
|
||||
container?: string;
|
||||
deviceId: string;
|
||||
eTag?: string;
|
||||
id: string;
|
||||
mediaSourceId?: string;
|
||||
server: ServerListItem | null;
|
||||
container?: string;
|
||||
deviceId: string;
|
||||
eTag?: string;
|
||||
id: string;
|
||||
mediaSourceId?: string;
|
||||
server: ServerListItem | null;
|
||||
}) => {
|
||||
const { id, server, deviceId } = args;
|
||||
const { id, server, deviceId } = args;
|
||||
|
||||
return (
|
||||
`${server?.url}/audio` +
|
||||
`/${id}/universal` +
|
||||
`?userId=${server?.userId}` +
|
||||
`&deviceId=${deviceId}` +
|
||||
'&audioCodec=aac' +
|
||||
`&api_key=${server?.credential}` +
|
||||
`&playSessionId=${deviceId}` +
|
||||
'&container=opus,mp3,aac,m4a,m4b,flac,wav,ogg' +
|
||||
'&transcodingContainer=ts' +
|
||||
'&transcodingProtocol=hls'
|
||||
);
|
||||
return (
|
||||
`${server?.url}/audio` +
|
||||
`/${id}/universal` +
|
||||
`?userId=${server?.userId}` +
|
||||
`&deviceId=${deviceId}` +
|
||||
'&audioCodec=aac' +
|
||||
`&api_key=${server?.credential}` +
|
||||
`&playSessionId=${deviceId}` +
|
||||
'&container=opus,mp3,aac,m4a,m4b,flac,wav,ogg' +
|
||||
'&transcodingContainer=ts' +
|
||||
'&transcodingProtocol=hls'
|
||||
);
|
||||
};
|
||||
|
||||
const getAlbumArtistCoverArtUrl = (args: {
|
||||
baseUrl: string;
|
||||
item: z.infer<typeof jfType._response.albumArtist>;
|
||||
size: number;
|
||||
baseUrl: string;
|
||||
item: z.infer<typeof jfType._response.albumArtist>;
|
||||
size: number;
|
||||
}) => {
|
||||
const size = args.size ? args.size : 300;
|
||||
const size = args.size ? args.size : 300;
|
||||
|
||||
if (!args.item.ImageTags?.Primary) {
|
||||
return null;
|
||||
}
|
||||
if (!args.item.ImageTags?.Primary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
`${args.baseUrl}/Items` +
|
||||
`/${args.item.Id}` +
|
||||
'/Images/Primary' +
|
||||
`?width=${size}&height=${size}` +
|
||||
'&quality=96'
|
||||
);
|
||||
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;
|
||||
const size = args.size ? args.size : 300;
|
||||
|
||||
if (!args.item.ImageTags?.Primary && !args.item?.AlbumPrimaryImageTag) {
|
||||
return null;
|
||||
}
|
||||
if (!args.item.ImageTags?.Primary && !args.item?.AlbumPrimaryImageTag) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
`${args.baseUrl}/Items` +
|
||||
`/${args.item.Id}` +
|
||||
'/Images/Primary' +
|
||||
`?width=${size}&height=${size}` +
|
||||
'&quality=96'
|
||||
);
|
||||
return (
|
||||
`${args.baseUrl}/Items` +
|
||||
`/${args.item.Id}` +
|
||||
'/Images/Primary' +
|
||||
`?width=${size}&height=${size}` +
|
||||
'&quality=96'
|
||||
);
|
||||
};
|
||||
|
||||
const getSongCoverArtUrl = (args: {
|
||||
baseUrl: string;
|
||||
item: z.infer<typeof jfType._response.song>;
|
||||
size: number;
|
||||
baseUrl: string;
|
||||
item: z.infer<typeof jfType._response.song>;
|
||||
size: number;
|
||||
}) => {
|
||||
const size = args.size ? args.size : 100;
|
||||
const size = args.size ? args.size : 100;
|
||||
|
||||
if (args.item.ImageTags.Primary) {
|
||||
return (
|
||||
`${args.baseUrl}/Items` +
|
||||
`/${args.item.Id}` +
|
||||
'/Images/Primary' +
|
||||
`?width=${size}&height=${size}` +
|
||||
'&quality=96'
|
||||
);
|
||||
}
|
||||
if (args.item.ImageTags.Primary) {
|
||||
return (
|
||||
`${args.baseUrl}/Items` +
|
||||
`/${args.item.Id}` +
|
||||
'/Images/Primary' +
|
||||
`?width=${size}&height=${size}` +
|
||||
'&quality=96'
|
||||
);
|
||||
}
|
||||
|
||||
if (args.item?.AlbumPrimaryImageTag) {
|
||||
// Fall back to album art if no image embedded
|
||||
return (
|
||||
`${args.baseUrl}/Items` +
|
||||
`/${args.item?.AlbumId}` +
|
||||
'/Images/Primary' +
|
||||
`?width=${size}&height=${size}` +
|
||||
'&quality=96'
|
||||
);
|
||||
}
|
||||
if (args.item?.AlbumPrimaryImageTag) {
|
||||
// Fall back to album art if no image embedded
|
||||
return (
|
||||
`${args.baseUrl}/Items` +
|
||||
`/${args.item?.AlbumId}` +
|
||||
'/Images/Primary' +
|
||||
`?width=${size}&height=${size}` +
|
||||
'&quality=96'
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
return null;
|
||||
};
|
||||
|
||||
const getPlaylistCoverArtUrl = (args: { baseUrl: string; item: JFPlaylist; size: number }) => {
|
||||
const size = args.size ? args.size : 300;
|
||||
const size = args.size ? args.size : 300;
|
||||
|
||||
if (!args.item.ImageTags?.Primary) {
|
||||
return null;
|
||||
}
|
||||
if (!args.item.ImageTags?.Primary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
`${args.baseUrl}/Items` +
|
||||
`/${args.item.Id}` +
|
||||
'/Images/Primary' +
|
||||
`?width=${size}&height=${size}` +
|
||||
'&quality=96'
|
||||
);
|
||||
return (
|
||||
`${args.baseUrl}/Items` +
|
||||
`/${args.item.Id}` +
|
||||
'/Images/Primary' +
|
||||
`?width=${size}&height=${size}` +
|
||||
'&quality=96'
|
||||
);
|
||||
};
|
||||
|
||||
const normalizeSong = (
|
||||
item: z.infer<typeof jfType._response.song>,
|
||||
server: ServerListItem | null,
|
||||
deviceId: string,
|
||||
imageSize?: number,
|
||||
item: z.infer<typeof jfType._response.song>,
|
||||
server: ServerListItem | null,
|
||||
deviceId: string,
|
||||
imageSize?: number,
|
||||
): Song => {
|
||||
return {
|
||||
album: item.Album,
|
||||
albumArtists: item.AlbumArtists?.map((entry) => ({
|
||||
id: entry.Id,
|
||||
imageUrl: null,
|
||||
name: entry.Name,
|
||||
})),
|
||||
albumId: item.AlbumId,
|
||||
artistName: item?.ArtistItems?.[0]?.Name,
|
||||
artists: item?.ArtistItems?.map((entry) => ({
|
||||
id: entry.Id,
|
||||
imageUrl: null,
|
||||
name: entry.Name,
|
||||
})),
|
||||
bitRate: item.MediaSources && Number(Math.trunc(item.MediaSources[0]?.Bitrate / 1000)),
|
||||
bpm: null,
|
||||
channels: null,
|
||||
comment: null,
|
||||
compilation: null,
|
||||
container: (item.MediaSources && item.MediaSources[0]?.Container) || null,
|
||||
createdAt: item.DateCreated,
|
||||
discNumber: (item.ParentIndexNumber && item.ParentIndexNumber) || 1,
|
||||
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 || 100 }),
|
||||
itemType: LibraryItem.SONG,
|
||||
lastPlayedAt: null,
|
||||
lyrics: null,
|
||||
name: item.Name,
|
||||
path: (item.MediaSources && item.MediaSources[0]?.Path) || null,
|
||||
playCount: (item.UserData && item.UserData.PlayCount) || 0,
|
||||
playlistItemId: item.PlaylistItemId,
|
||||
// releaseDate: (item.ProductionYear && new Date(item.ProductionYear, 0, 1).toISOString()) || null,
|
||||
releaseDate: null,
|
||||
releaseYear: item.ProductionYear ? String(item.ProductionYear) : null,
|
||||
serverId: server?.id || '',
|
||||
serverType: ServerType.JELLYFIN,
|
||||
size: item.MediaSources && item.MediaSources[0]?.Size,
|
||||
streamUrl: getStreamUrl({
|
||||
container: item.MediaSources?.[0]?.Container,
|
||||
deviceId,
|
||||
eTag: item.MediaSources?.[0]?.ETag,
|
||||
id: item.Id,
|
||||
mediaSourceId: item.MediaSources?.[0]?.Id,
|
||||
server,
|
||||
}),
|
||||
trackNumber: item.IndexNumber,
|
||||
uniqueId: nanoid(),
|
||||
updatedAt: item.DateCreated,
|
||||
userFavorite: (item.UserData && item.UserData.IsFavorite) || false,
|
||||
userRating: null,
|
||||
};
|
||||
return {
|
||||
album: item.Album,
|
||||
albumArtists: item.AlbumArtists?.map((entry) => ({
|
||||
id: entry.Id,
|
||||
imageUrl: null,
|
||||
name: entry.Name,
|
||||
})),
|
||||
albumId: item.AlbumId,
|
||||
artistName: item?.ArtistItems?.[0]?.Name,
|
||||
artists: item?.ArtistItems?.map((entry) => ({
|
||||
id: entry.Id,
|
||||
imageUrl: null,
|
||||
name: entry.Name,
|
||||
})),
|
||||
bitRate: item.MediaSources && Number(Math.trunc(item.MediaSources[0]?.Bitrate / 1000)),
|
||||
bpm: null,
|
||||
channels: null,
|
||||
comment: null,
|
||||
compilation: null,
|
||||
container: (item.MediaSources && item.MediaSources[0]?.Container) || null,
|
||||
createdAt: item.DateCreated,
|
||||
discNumber: (item.ParentIndexNumber && item.ParentIndexNumber) || 1,
|
||||
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 || 100 }),
|
||||
itemType: LibraryItem.SONG,
|
||||
lastPlayedAt: null,
|
||||
lyrics: null,
|
||||
name: item.Name,
|
||||
path: (item.MediaSources && item.MediaSources[0]?.Path) || null,
|
||||
playCount: (item.UserData && item.UserData.PlayCount) || 0,
|
||||
playlistItemId: item.PlaylistItemId,
|
||||
// releaseDate: (item.ProductionYear && new Date(item.ProductionYear, 0, 1).toISOString()) || null,
|
||||
releaseDate: null,
|
||||
releaseYear: item.ProductionYear ? String(item.ProductionYear) : null,
|
||||
serverId: server?.id || '',
|
||||
serverType: ServerType.JELLYFIN,
|
||||
size: item.MediaSources && item.MediaSources[0]?.Size,
|
||||
streamUrl: getStreamUrl({
|
||||
container: item.MediaSources?.[0]?.Container,
|
||||
deviceId,
|
||||
eTag: item.MediaSources?.[0]?.ETag,
|
||||
id: item.Id,
|
||||
mediaSourceId: item.MediaSources?.[0]?.Id,
|
||||
server,
|
||||
}),
|
||||
trackNumber: item.IndexNumber,
|
||||
uniqueId: nanoid(),
|
||||
updatedAt: item.DateCreated,
|
||||
userFavorite: (item.UserData && item.UserData.IsFavorite) || false,
|
||||
userRating: null,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeAlbum = (
|
||||
item: z.infer<typeof jfType._response.album>,
|
||||
server: ServerListItem | null,
|
||||
imageSize?: number,
|
||||
item: z.infer<typeof jfType._response.album>,
|
||||
server: ServerListItem | null,
|
||||
imageSize?: number,
|
||||
): Album => {
|
||||
return {
|
||||
albumArtists:
|
||||
item.AlbumArtists.map((entry) => ({
|
||||
id: entry.Id,
|
||||
imageUrl: null,
|
||||
name: entry.Name,
|
||||
})) || [],
|
||||
artists: item.ArtistItems?.map((entry) => ({ id: entry.Id, imageUrl: null, name: entry.Name })),
|
||||
backdropImageUrl: null,
|
||||
createdAt: item.DateCreated,
|
||||
duration: item.RunTimeTicks / 10000,
|
||||
genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })),
|
||||
id: item.Id,
|
||||
imagePlaceholderUrl: null,
|
||||
imageUrl: getAlbumCoverArtUrl({
|
||||
baseUrl: server?.url || '',
|
||||
item,
|
||||
size: imageSize || 300,
|
||||
}),
|
||||
isCompilation: null,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
lastPlayedAt: null,
|
||||
name: item.Name,
|
||||
playCount: item.UserData?.PlayCount || 0,
|
||||
releaseDate: item.PremiereDate?.split('T')[0] || null,
|
||||
releaseYear: item.ProductionYear || null,
|
||||
serverId: server?.id || '',
|
||||
serverType: ServerType.JELLYFIN,
|
||||
size: null,
|
||||
songCount: item?.ChildCount || null,
|
||||
songs: item.Songs?.map((song) => normalizeSong(song, server, '', imageSize)),
|
||||
uniqueId: nanoid(),
|
||||
updatedAt: item?.DateLastMediaAdded || item.DateCreated,
|
||||
userFavorite: item.UserData?.IsFavorite || false,
|
||||
userRating: null,
|
||||
};
|
||||
return {
|
||||
albumArtists:
|
||||
item.AlbumArtists.map((entry) => ({
|
||||
id: entry.Id,
|
||||
imageUrl: null,
|
||||
name: entry.Name,
|
||||
})) || [],
|
||||
artists: item.ArtistItems?.map((entry) => ({
|
||||
id: entry.Id,
|
||||
imageUrl: null,
|
||||
name: entry.Name,
|
||||
})),
|
||||
backdropImageUrl: null,
|
||||
createdAt: item.DateCreated,
|
||||
duration: item.RunTimeTicks / 10000,
|
||||
genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })),
|
||||
id: item.Id,
|
||||
imagePlaceholderUrl: null,
|
||||
imageUrl: getAlbumCoverArtUrl({
|
||||
baseUrl: server?.url || '',
|
||||
item,
|
||||
size: imageSize || 300,
|
||||
}),
|
||||
isCompilation: null,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
lastPlayedAt: null,
|
||||
name: item.Name,
|
||||
playCount: item.UserData?.PlayCount || 0,
|
||||
releaseDate: item.PremiereDate?.split('T')[0] || null,
|
||||
releaseYear: item.ProductionYear || null,
|
||||
serverId: server?.id || '',
|
||||
serverType: ServerType.JELLYFIN,
|
||||
size: null,
|
||||
songCount: item?.ChildCount || null,
|
||||
songs: item.Songs?.map((song) => normalizeSong(song, server, '', imageSize)),
|
||||
uniqueId: nanoid(),
|
||||
updatedAt: item?.DateLastMediaAdded || item.DateCreated,
|
||||
userFavorite: item.UserData?.IsFavorite || false,
|
||||
userRating: null,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeAlbumArtist = (
|
||||
item: z.infer<typeof jfType._response.albumArtist> & {
|
||||
similarArtists?: z.infer<typeof jfType._response.albumArtistList>;
|
||||
},
|
||||
server: ServerListItem | null,
|
||||
imageSize?: number,
|
||||
item: z.infer<typeof jfType._response.albumArtist> & {
|
||||
similarArtists?: z.infer<typeof jfType._response.albumArtistList>;
|
||||
},
|
||||
server: ServerListItem | null,
|
||||
imageSize?: number,
|
||||
): AlbumArtist => {
|
||||
const similarArtists =
|
||||
item.similarArtists?.Items?.filter((entry) => entry.Name !== 'Various Artists').map(
|
||||
(entry) => ({
|
||||
id: entry.Id,
|
||||
imageUrl: getAlbumArtistCoverArtUrl({
|
||||
baseUrl: server?.url || '',
|
||||
item: entry,
|
||||
size: imageSize || 300,
|
||||
}),
|
||||
name: entry.Name,
|
||||
}),
|
||||
) || [];
|
||||
const similarArtists =
|
||||
item.similarArtists?.Items?.filter((entry) => entry.Name !== 'Various Artists').map(
|
||||
(entry) => ({
|
||||
id: entry.Id,
|
||||
imageUrl: getAlbumArtistCoverArtUrl({
|
||||
baseUrl: server?.url || '',
|
||||
item: entry,
|
||||
size: imageSize || 300,
|
||||
}),
|
||||
name: entry.Name,
|
||||
}),
|
||||
) || [];
|
||||
|
||||
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,
|
||||
}),
|
||||
itemType: LibraryItem.ALBUM_ARTIST,
|
||||
lastPlayedAt: null,
|
||||
name: item.Name,
|
||||
playCount: item.UserData?.PlayCount || 0,
|
||||
serverId: server?.id || '',
|
||||
serverType: ServerType.JELLYFIN,
|
||||
similarArtists,
|
||||
songCount: null,
|
||||
userFavorite: item.UserData?.IsFavorite || false,
|
||||
userRating: null,
|
||||
};
|
||||
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,
|
||||
}),
|
||||
itemType: LibraryItem.ALBUM_ARTIST,
|
||||
lastPlayedAt: null,
|
||||
name: item.Name,
|
||||
playCount: item.UserData?.PlayCount || 0,
|
||||
serverId: server?.id || '',
|
||||
serverType: ServerType.JELLYFIN,
|
||||
similarArtists,
|
||||
songCount: null,
|
||||
userFavorite: item.UserData?.IsFavorite || false,
|
||||
userRating: null,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizePlaylist = (
|
||||
item: z.infer<typeof jfType._response.playlist>,
|
||||
server: ServerListItem | null,
|
||||
imageSize?: number,
|
||||
item: z.infer<typeof jfType._response.playlist>,
|
||||
server: ServerListItem | null,
|
||||
imageSize?: number,
|
||||
): Playlist => {
|
||||
const imageUrl = getPlaylistCoverArtUrl({
|
||||
baseUrl: server?.url || '',
|
||||
item,
|
||||
size: imageSize || 300,
|
||||
});
|
||||
const imageUrl = getPlaylistCoverArtUrl({
|
||||
baseUrl: server?.url || '',
|
||||
item,
|
||||
size: imageSize || 300,
|
||||
});
|
||||
|
||||
const imagePlaceholderUrl = null;
|
||||
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,
|
||||
itemType: LibraryItem.PLAYLIST,
|
||||
name: item.Name,
|
||||
owner: null,
|
||||
ownerId: null,
|
||||
public: null,
|
||||
rules: null,
|
||||
serverId: server?.id || '',
|
||||
serverType: ServerType.JELLYFIN,
|
||||
size: null,
|
||||
songCount: item?.ChildCount || null,
|
||||
sync: 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,
|
||||
itemType: LibraryItem.PLAYLIST,
|
||||
name: item.Name,
|
||||
owner: null,
|
||||
ownerId: null,
|
||||
public: null,
|
||||
rules: null,
|
||||
serverId: server?.id || '',
|
||||
serverType: ServerType.JELLYFIN,
|
||||
size: null,
|
||||
songCount: item?.ChildCount || null,
|
||||
sync: null,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeMusicFolder = (item: JFMusicFolder): MusicFolder => {
|
||||
return {
|
||||
id: item.Id,
|
||||
name: item.Name,
|
||||
};
|
||||
return {
|
||||
id: item.Id,
|
||||
name: item.Name,
|
||||
};
|
||||
};
|
||||
|
||||
// const normalizeArtist = (item: any) => {
|
||||
|
|
@ -332,12 +336,12 @@ const normalizeMusicFolder = (item: JFMusicFolder): MusicFolder => {
|
|||
// };
|
||||
|
||||
const normalizeGenre = (item: JFGenre): Genre => {
|
||||
return {
|
||||
albumCount: undefined,
|
||||
id: item.Id,
|
||||
name: item.Name,
|
||||
songCount: undefined,
|
||||
};
|
||||
return {
|
||||
albumCount: undefined,
|
||||
id: item.Id,
|
||||
name: item.Name,
|
||||
songCount: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
// const normalizeFolder = (item: any) => {
|
||||
|
|
@ -360,10 +364,10 @@ const normalizeGenre = (item: JFGenre): Genre => {
|
|||
// };
|
||||
|
||||
export const jfNormalize = {
|
||||
album: normalizeAlbum,
|
||||
albumArtist: normalizeAlbumArtist,
|
||||
genre: normalizeGenre,
|
||||
musicFolder: normalizeMusicFolder,
|
||||
playlist: normalizePlaylist,
|
||||
song: normalizeSong,
|
||||
album: normalizeAlbum,
|
||||
albumArtist: normalizeAlbumArtist,
|
||||
genre: normalizeGenre,
|
||||
musicFolder: normalizeMusicFolder,
|
||||
playlist: normalizePlaylist,
|
||||
song: normalizeSong,
|
||||
};
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,143 +1,143 @@
|
|||
import { SSArtistInfo } from '/@/renderer/api/subsonic.types';
|
||||
|
||||
export type NDAuthenticate = {
|
||||
id: string;
|
||||
isAdmin: boolean;
|
||||
name: string;
|
||||
subsonicSalt: string;
|
||||
subsonicToken: string;
|
||||
token: string;
|
||||
username: string;
|
||||
id: string;
|
||||
isAdmin: boolean;
|
||||
name: string;
|
||||
subsonicSalt: string;
|
||||
subsonicToken: string;
|
||||
token: string;
|
||||
username: string;
|
||||
};
|
||||
|
||||
export type NDUser = {
|
||||
createdAt: string;
|
||||
email: string;
|
||||
id: string;
|
||||
isAdmin: boolean;
|
||||
lastAccessAt: string;
|
||||
lastLoginAt: string;
|
||||
name: string;
|
||||
updatedAt: string;
|
||||
userName: string;
|
||||
createdAt: string;
|
||||
email: string;
|
||||
id: string;
|
||||
isAdmin: boolean;
|
||||
lastAccessAt: string;
|
||||
lastLoginAt: string;
|
||||
name: string;
|
||||
updatedAt: string;
|
||||
userName: string;
|
||||
};
|
||||
|
||||
export type NDGenre = {
|
||||
id: string;
|
||||
name: string;
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type NDAlbum = {
|
||||
albumArtist: string;
|
||||
albumArtistId: string;
|
||||
allArtistIds: string;
|
||||
artist: string;
|
||||
artistId: string;
|
||||
compilation: boolean;
|
||||
coverArtId?: string; // Removed after v0.48.0
|
||||
coverArtPath?: string; // Removed after v0.48.0
|
||||
createdAt: string;
|
||||
duration: number;
|
||||
fullText: string;
|
||||
genre: string;
|
||||
genres: NDGenre[];
|
||||
id: string;
|
||||
maxYear: number;
|
||||
mbzAlbumArtistId: string;
|
||||
mbzAlbumId: string;
|
||||
minYear: number;
|
||||
name: string;
|
||||
orderAlbumArtistName: string;
|
||||
orderAlbumName: string;
|
||||
playCount: number;
|
||||
playDate: string;
|
||||
rating: number;
|
||||
size: number;
|
||||
songCount: number;
|
||||
sortAlbumArtistName: string;
|
||||
sortArtistName: string;
|
||||
starred: boolean;
|
||||
starredAt: string;
|
||||
updatedAt: string;
|
||||
albumArtist: string;
|
||||
albumArtistId: string;
|
||||
allArtistIds: string;
|
||||
artist: string;
|
||||
artistId: string;
|
||||
compilation: boolean;
|
||||
coverArtId?: string; // Removed after v0.48.0
|
||||
coverArtPath?: string; // Removed after v0.48.0
|
||||
createdAt: string;
|
||||
duration: number;
|
||||
fullText: string;
|
||||
genre: string;
|
||||
genres: NDGenre[];
|
||||
id: string;
|
||||
maxYear: number;
|
||||
mbzAlbumArtistId: string;
|
||||
mbzAlbumId: string;
|
||||
minYear: number;
|
||||
name: string;
|
||||
orderAlbumArtistName: string;
|
||||
orderAlbumName: string;
|
||||
playCount: number;
|
||||
playDate: string;
|
||||
rating: number;
|
||||
size: number;
|
||||
songCount: number;
|
||||
sortAlbumArtistName: string;
|
||||
sortArtistName: string;
|
||||
starred: boolean;
|
||||
starredAt: string;
|
||||
updatedAt: string;
|
||||
} & { songs?: NDSong[] };
|
||||
|
||||
export type NDSong = {
|
||||
album: string;
|
||||
albumArtist: string;
|
||||
albumArtistId: string;
|
||||
albumId: string;
|
||||
artist: string;
|
||||
artistId: string;
|
||||
bitRate: number;
|
||||
bookmarkPosition: number;
|
||||
bpm?: number;
|
||||
channels?: number;
|
||||
comment?: string;
|
||||
compilation: boolean;
|
||||
createdAt: string;
|
||||
discNumber: number;
|
||||
duration: number;
|
||||
fullText: string;
|
||||
genre: string;
|
||||
genres: NDGenre[];
|
||||
hasCoverArt: boolean;
|
||||
id: string;
|
||||
lyrics?: string;
|
||||
mbzAlbumArtistId: string;
|
||||
mbzAlbumId: string;
|
||||
mbzArtistId: string;
|
||||
mbzTrackId: string;
|
||||
orderAlbumArtistName: string;
|
||||
orderAlbumName: string;
|
||||
orderArtistName: string;
|
||||
orderTitle: string;
|
||||
path: string;
|
||||
playCount: number;
|
||||
playDate: string;
|
||||
rating: number;
|
||||
size: number;
|
||||
sortAlbumArtistName: string;
|
||||
sortArtistName: string;
|
||||
starred: boolean;
|
||||
starredAt: string;
|
||||
suffix: string;
|
||||
title: string;
|
||||
trackNumber: number;
|
||||
updatedAt: string;
|
||||
year: number;
|
||||
album: string;
|
||||
albumArtist: string;
|
||||
albumArtistId: string;
|
||||
albumId: string;
|
||||
artist: string;
|
||||
artistId: string;
|
||||
bitRate: number;
|
||||
bookmarkPosition: number;
|
||||
bpm?: number;
|
||||
channels?: number;
|
||||
comment?: string;
|
||||
compilation: boolean;
|
||||
createdAt: string;
|
||||
discNumber: number;
|
||||
duration: number;
|
||||
fullText: string;
|
||||
genre: string;
|
||||
genres: NDGenre[];
|
||||
hasCoverArt: boolean;
|
||||
id: string;
|
||||
lyrics?: string;
|
||||
mbzAlbumArtistId: string;
|
||||
mbzAlbumId: string;
|
||||
mbzArtistId: string;
|
||||
mbzTrackId: string;
|
||||
orderAlbumArtistName: string;
|
||||
orderAlbumName: string;
|
||||
orderArtistName: string;
|
||||
orderTitle: string;
|
||||
path: string;
|
||||
playCount: number;
|
||||
playDate: string;
|
||||
rating: number;
|
||||
size: number;
|
||||
sortAlbumArtistName: string;
|
||||
sortArtistName: string;
|
||||
starred: boolean;
|
||||
starredAt: string;
|
||||
suffix: string;
|
||||
title: string;
|
||||
trackNumber: number;
|
||||
updatedAt: string;
|
||||
year: number;
|
||||
};
|
||||
|
||||
export type NDAlbumArtist = {
|
||||
albumCount: number;
|
||||
biography: string;
|
||||
externalInfoUpdatedAt: string;
|
||||
externalUrl: string;
|
||||
fullText: string;
|
||||
genres: NDGenre[];
|
||||
id: string;
|
||||
largeImageUrl?: string;
|
||||
mbzArtistId: string;
|
||||
mediumImageUrl?: string;
|
||||
name: string;
|
||||
orderArtistName: string;
|
||||
playCount: number;
|
||||
playDate: string;
|
||||
rating: number;
|
||||
size: number;
|
||||
smallImageUrl?: string;
|
||||
songCount: number;
|
||||
starred: boolean;
|
||||
starredAt: string;
|
||||
albumCount: number;
|
||||
biography: string;
|
||||
externalInfoUpdatedAt: string;
|
||||
externalUrl: string;
|
||||
fullText: string;
|
||||
genres: NDGenre[];
|
||||
id: string;
|
||||
largeImageUrl?: string;
|
||||
mbzArtistId: string;
|
||||
mediumImageUrl?: string;
|
||||
name: string;
|
||||
orderArtistName: string;
|
||||
playCount: number;
|
||||
playDate: string;
|
||||
rating: number;
|
||||
size: number;
|
||||
smallImageUrl?: string;
|
||||
songCount: number;
|
||||
starred: boolean;
|
||||
starredAt: string;
|
||||
} & {
|
||||
similarArtists?: SSArtistInfo['similarArtist'];
|
||||
similarArtists?: SSArtistInfo['similarArtist'];
|
||||
};
|
||||
|
||||
export type NDAuthenticationResponse = NDAuthenticate;
|
||||
|
||||
export type NDAlbumArtistList = {
|
||||
items: NDAlbumArtist[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number;
|
||||
items: NDAlbumArtist[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number;
|
||||
};
|
||||
|
||||
export type NDAlbumArtistDetail = NDAlbumArtist;
|
||||
|
|
@ -155,9 +155,9 @@ export type NDAlbumDetail = NDAlbum & { songs?: NDSongListResponse };
|
|||
export type NDAlbumListResponse = NDAlbum[];
|
||||
|
||||
export type NDAlbumList = {
|
||||
items: NDAlbum[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number;
|
||||
items: NDAlbum[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number;
|
||||
};
|
||||
|
||||
export type NDSongDetail = NDSong;
|
||||
|
|
@ -167,142 +167,142 @@ export type NDSongDetailResponse = NDSong;
|
|||
export type NDSongListResponse = NDSong[];
|
||||
|
||||
export type NDSongList = {
|
||||
items: NDSong[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number;
|
||||
items: NDSong[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number;
|
||||
};
|
||||
|
||||
export type NDArtistListResponse = NDAlbumArtist[];
|
||||
|
||||
export type NDPagination = {
|
||||
_end?: number;
|
||||
_start?: number;
|
||||
_end?: number;
|
||||
_start?: number;
|
||||
};
|
||||
|
||||
export enum NDSortOrder {
|
||||
ASC = 'ASC',
|
||||
DESC = 'DESC',
|
||||
ASC = 'ASC',
|
||||
DESC = 'DESC',
|
||||
}
|
||||
|
||||
export type NDOrder = {
|
||||
_order?: NDSortOrder;
|
||||
_order?: NDSortOrder;
|
||||
};
|
||||
|
||||
export enum NDGenreListSort {
|
||||
NAME = 'name',
|
||||
NAME = 'name',
|
||||
}
|
||||
|
||||
export type NDGenreListParams = {
|
||||
_sort?: NDGenreListSort;
|
||||
id?: string;
|
||||
_sort?: NDGenreListSort;
|
||||
id?: string;
|
||||
} & NDPagination &
|
||||
NDOrder;
|
||||
NDOrder;
|
||||
|
||||
export enum NDAlbumListSort {
|
||||
ALBUM_ARTIST = 'albumArtist',
|
||||
ARTIST = 'artist',
|
||||
DURATION = 'duration',
|
||||
NAME = 'name',
|
||||
PLAY_COUNT = 'playCount',
|
||||
PLAY_DATE = 'play_date',
|
||||
RANDOM = 'random',
|
||||
RATING = 'rating',
|
||||
RECENTLY_ADDED = 'recently_added',
|
||||
SONG_COUNT = 'songCount',
|
||||
STARRED = 'starred',
|
||||
YEAR = 'max_year',
|
||||
ALBUM_ARTIST = 'albumArtist',
|
||||
ARTIST = 'artist',
|
||||
DURATION = 'duration',
|
||||
NAME = 'name',
|
||||
PLAY_COUNT = 'playCount',
|
||||
PLAY_DATE = 'play_date',
|
||||
RANDOM = 'random',
|
||||
RATING = 'rating',
|
||||
RECENTLY_ADDED = 'recently_added',
|
||||
SONG_COUNT = 'songCount',
|
||||
STARRED = 'starred',
|
||||
YEAR = 'max_year',
|
||||
}
|
||||
|
||||
export type NDAlbumListParams = {
|
||||
_sort?: NDAlbumListSort;
|
||||
album_id?: string;
|
||||
artist_id?: string;
|
||||
compilation?: boolean;
|
||||
genre_id?: string;
|
||||
has_rating?: boolean;
|
||||
id?: string;
|
||||
name?: string;
|
||||
recently_played?: boolean;
|
||||
starred?: boolean;
|
||||
year?: number;
|
||||
_sort?: NDAlbumListSort;
|
||||
album_id?: string;
|
||||
artist_id?: string;
|
||||
compilation?: boolean;
|
||||
genre_id?: string;
|
||||
has_rating?: boolean;
|
||||
id?: string;
|
||||
name?: string;
|
||||
recently_played?: boolean;
|
||||
starred?: boolean;
|
||||
year?: number;
|
||||
} & NDPagination &
|
||||
NDOrder;
|
||||
NDOrder;
|
||||
|
||||
export enum NDSongListSort {
|
||||
ALBUM = 'album, order_album_artist_name, disc_number, track_number, title',
|
||||
ALBUM_ARTIST = 'order_album_artist_name, album, disc_number, track_number, title',
|
||||
ALBUM_SONGS = 'album, discNumber, trackNumber',
|
||||
ARTIST = 'artist',
|
||||
BPM = 'bpm',
|
||||
CHANNELS = 'channels',
|
||||
COMMENT = 'comment',
|
||||
DURATION = 'duration',
|
||||
FAVORITED = 'starred ASC, starredAt ASC',
|
||||
GENRE = 'genre',
|
||||
ID = 'id',
|
||||
PLAY_COUNT = 'playCount',
|
||||
PLAY_DATE = 'playDate',
|
||||
RATING = 'rating',
|
||||
RECENTLY_ADDED = 'createdAt',
|
||||
TITLE = 'title',
|
||||
TRACK = 'track',
|
||||
YEAR = 'year, album, discNumber, trackNumber',
|
||||
ALBUM = 'album, order_album_artist_name, disc_number, track_number, title',
|
||||
ALBUM_ARTIST = 'order_album_artist_name, album, disc_number, track_number, title',
|
||||
ALBUM_SONGS = 'album, discNumber, trackNumber',
|
||||
ARTIST = 'artist',
|
||||
BPM = 'bpm',
|
||||
CHANNELS = 'channels',
|
||||
COMMENT = 'comment',
|
||||
DURATION = 'duration',
|
||||
FAVORITED = 'starred ASC, starredAt ASC',
|
||||
GENRE = 'genre',
|
||||
ID = 'id',
|
||||
PLAY_COUNT = 'playCount',
|
||||
PLAY_DATE = 'playDate',
|
||||
RATING = 'rating',
|
||||
RECENTLY_ADDED = 'createdAt',
|
||||
TITLE = 'title',
|
||||
TRACK = 'track',
|
||||
YEAR = 'year, album, discNumber, trackNumber',
|
||||
}
|
||||
|
||||
export type NDSongListParams = {
|
||||
_sort?: NDSongListSort;
|
||||
album_id?: string[];
|
||||
artist_id?: string[];
|
||||
genre_id?: string;
|
||||
starred?: boolean;
|
||||
_sort?: NDSongListSort;
|
||||
album_id?: string[];
|
||||
artist_id?: string[];
|
||||
genre_id?: string;
|
||||
starred?: boolean;
|
||||
} & NDPagination &
|
||||
NDOrder;
|
||||
NDOrder;
|
||||
|
||||
export enum NDAlbumArtistListSort {
|
||||
ALBUM_COUNT = 'albumCount',
|
||||
FAVORITED = 'starred ASC, starredAt ASC',
|
||||
NAME = 'name',
|
||||
PLAY_COUNT = 'playCount',
|
||||
RATING = 'rating',
|
||||
SONG_COUNT = 'songCount',
|
||||
ALBUM_COUNT = 'albumCount',
|
||||
FAVORITED = 'starred ASC, starredAt ASC',
|
||||
NAME = 'name',
|
||||
PLAY_COUNT = 'playCount',
|
||||
RATING = 'rating',
|
||||
SONG_COUNT = 'songCount',
|
||||
}
|
||||
|
||||
export type NDAlbumArtistListParams = {
|
||||
_sort?: NDAlbumArtistListSort;
|
||||
genre_id?: string;
|
||||
starred?: boolean;
|
||||
_sort?: NDAlbumArtistListSort;
|
||||
genre_id?: string;
|
||||
starred?: boolean;
|
||||
} & NDPagination &
|
||||
NDOrder;
|
||||
NDOrder;
|
||||
|
||||
export type NDAddToPlaylistResponse = {
|
||||
added: number;
|
||||
added: number;
|
||||
};
|
||||
|
||||
export type NDAddToPlaylistBody = {
|
||||
ids: string[];
|
||||
ids: string[];
|
||||
};
|
||||
|
||||
export type NDAddToPlaylist = null;
|
||||
|
||||
export type NDRemoveFromPlaylistResponse = {
|
||||
ids: string[];
|
||||
ids: string[];
|
||||
};
|
||||
|
||||
export type NDRemoveFromPlaylistParams = {
|
||||
id: string[];
|
||||
id: string[];
|
||||
};
|
||||
|
||||
export type NDRemoveFromPlaylist = null;
|
||||
|
||||
export type NDCreatePlaylistParams = {
|
||||
comment?: string;
|
||||
name: string;
|
||||
public?: boolean;
|
||||
rules?: Record<string, any> | null;
|
||||
comment?: string;
|
||||
name: string;
|
||||
public?: boolean;
|
||||
rules?: Record<string, any> | null;
|
||||
};
|
||||
|
||||
export type NDCreatePlaylistResponse = {
|
||||
id: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type NDCreatePlaylist = NDCreatePlaylistResponse;
|
||||
|
|
@ -312,7 +312,7 @@ export type NDUpdatePlaylistParams = Partial<NDPlaylist>;
|
|||
export type NDUpdatePlaylistResponse = NDPlaylist;
|
||||
|
||||
export type NDDeletePlaylistParams = {
|
||||
id: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type NDDeletePlaylistResponse = null;
|
||||
|
|
@ -320,21 +320,21 @@ export type NDDeletePlaylistResponse = null;
|
|||
export type NDDeletePlaylist = NDDeletePlaylistResponse;
|
||||
|
||||
export type NDPlaylist = {
|
||||
comment: string;
|
||||
createdAt: string;
|
||||
duration: number;
|
||||
evaluatedAt: string;
|
||||
id: string;
|
||||
name: string;
|
||||
ownerId: string;
|
||||
ownerName: string;
|
||||
path: string;
|
||||
public: boolean;
|
||||
rules: Record<string, any> | null;
|
||||
size: number;
|
||||
songCount: number;
|
||||
sync: boolean;
|
||||
updatedAt: string;
|
||||
comment: string;
|
||||
createdAt: string;
|
||||
duration: number;
|
||||
evaluatedAt: string;
|
||||
id: string;
|
||||
name: string;
|
||||
ownerId: string;
|
||||
ownerName: string;
|
||||
path: string;
|
||||
public: boolean;
|
||||
rules: Record<string, any> | null;
|
||||
size: number;
|
||||
songCount: number;
|
||||
sync: boolean;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type NDPlaylistDetail = NDPlaylist;
|
||||
|
|
@ -342,125 +342,125 @@ export type NDPlaylistDetail = NDPlaylist;
|
|||
export type NDPlaylistDetailResponse = NDPlaylist;
|
||||
|
||||
export type NDPlaylistList = {
|
||||
items: NDPlaylist[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number;
|
||||
items: NDPlaylist[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number;
|
||||
};
|
||||
|
||||
export type NDPlaylistListResponse = NDPlaylist[];
|
||||
|
||||
export enum NDPlaylistListSort {
|
||||
DURATION = 'duration',
|
||||
NAME = 'name',
|
||||
OWNER = 'ownerName',
|
||||
PUBLIC = 'public',
|
||||
SONG_COUNT = 'songCount',
|
||||
UPDATED_AT = 'updatedAt',
|
||||
DURATION = 'duration',
|
||||
NAME = 'name',
|
||||
OWNER = 'ownerName',
|
||||
PUBLIC = 'public',
|
||||
SONG_COUNT = 'songCount',
|
||||
UPDATED_AT = 'updatedAt',
|
||||
}
|
||||
|
||||
export type NDPlaylistListParams = {
|
||||
_sort?: NDPlaylistListSort;
|
||||
owner_id?: string;
|
||||
_sort?: NDPlaylistListSort;
|
||||
owner_id?: string;
|
||||
} & NDPagination &
|
||||
NDOrder;
|
||||
NDOrder;
|
||||
|
||||
export type NDPlaylistSong = NDSong & {
|
||||
mediaFileId: string;
|
||||
playlistId: string;
|
||||
mediaFileId: string;
|
||||
playlistId: string;
|
||||
};
|
||||
|
||||
export type NDPlaylistSongListResponse = NDPlaylistSong[];
|
||||
|
||||
export type NDPlaylistSongList = {
|
||||
items: NDPlaylistSong[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number;
|
||||
items: NDPlaylistSong[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number;
|
||||
};
|
||||
|
||||
export const NDSongQueryFields = [
|
||||
{ label: 'Album', type: 'string', value: 'album' },
|
||||
{ label: 'Album Artist', type: 'string', value: 'albumartist' },
|
||||
{ label: 'Album Comment', type: 'string', value: 'albumcomment' },
|
||||
{ label: 'Album Type', type: 'string', value: 'albumtype' },
|
||||
{ label: 'Artist', type: 'string', value: 'artist' },
|
||||
{ label: 'Bitrate', type: 'number', value: 'bitrate' },
|
||||
{ label: 'BPM', type: 'number', value: 'bpm' },
|
||||
{ label: 'Catalog Number', type: 'string', value: 'catalognumber' },
|
||||
{ label: 'Channels', type: 'number', value: 'channels' },
|
||||
{ label: 'Comment', type: 'string', value: 'comment' },
|
||||
{ label: 'Date Added', type: 'date', value: 'dateadded' },
|
||||
{ label: 'Date Favorited', type: 'date', value: 'dateloved' },
|
||||
{ label: 'Date Last Played', type: 'date', value: 'lastplayed' },
|
||||
{ label: 'Date Modified', type: 'date', value: 'datemodified' },
|
||||
{ label: 'Disc Subtitle', type: 'string', value: 'discsubtitle' },
|
||||
{ label: 'Disc Number', type: 'number', value: 'discnumber' },
|
||||
{ label: 'Duration', type: 'number', value: 'duration' },
|
||||
{ label: 'File Path', type: 'string', value: 'filepath' },
|
||||
{ label: 'File Type', type: 'string', value: 'filetype' },
|
||||
{ label: 'Genre', type: 'string', value: 'genre' },
|
||||
{ label: 'Has CoverArt', type: 'boolean', value: 'hascoverart' },
|
||||
{ label: 'Is Compilation', type: 'boolean', value: 'compilation' },
|
||||
{ label: 'Is Favorite', type: 'boolean', value: 'loved' },
|
||||
{ label: 'Lyrics', type: 'string', value: 'lyrics' },
|
||||
{ label: 'Name', type: 'string', value: 'title' },
|
||||
{ label: 'Play Count', type: 'number', value: 'playcount' },
|
||||
{ label: 'Rating', type: 'number', value: 'rating' },
|
||||
{ label: 'Size', type: 'number', value: 'size' },
|
||||
{ label: 'Sort Album', type: 'string', value: 'sortalbum' },
|
||||
{ label: 'Sort Album Artist', type: 'string', value: 'sortalbumartist' },
|
||||
{ label: 'Sort Artist', type: 'string', value: 'sortartist' },
|
||||
{ label: 'Sort Name', type: 'string', value: 'sorttitle' },
|
||||
{ label: 'Track Number', type: 'number', value: 'tracknumber' },
|
||||
{ label: 'Year', type: 'number', value: 'year' },
|
||||
{ label: 'Album', type: 'string', value: 'album' },
|
||||
{ label: 'Album Artist', type: 'string', value: 'albumartist' },
|
||||
{ label: 'Album Comment', type: 'string', value: 'albumcomment' },
|
||||
{ label: 'Album Type', type: 'string', value: 'albumtype' },
|
||||
{ label: 'Artist', type: 'string', value: 'artist' },
|
||||
{ label: 'Bitrate', type: 'number', value: 'bitrate' },
|
||||
{ label: 'BPM', type: 'number', value: 'bpm' },
|
||||
{ label: 'Catalog Number', type: 'string', value: 'catalognumber' },
|
||||
{ label: 'Channels', type: 'number', value: 'channels' },
|
||||
{ label: 'Comment', type: 'string', value: 'comment' },
|
||||
{ label: 'Date Added', type: 'date', value: 'dateadded' },
|
||||
{ label: 'Date Favorited', type: 'date', value: 'dateloved' },
|
||||
{ label: 'Date Last Played', type: 'date', value: 'lastplayed' },
|
||||
{ label: 'Date Modified', type: 'date', value: 'datemodified' },
|
||||
{ label: 'Disc Subtitle', type: 'string', value: 'discsubtitle' },
|
||||
{ label: 'Disc Number', type: 'number', value: 'discnumber' },
|
||||
{ label: 'Duration', type: 'number', value: 'duration' },
|
||||
{ label: 'File Path', type: 'string', value: 'filepath' },
|
||||
{ label: 'File Type', type: 'string', value: 'filetype' },
|
||||
{ label: 'Genre', type: 'string', value: 'genre' },
|
||||
{ label: 'Has CoverArt', type: 'boolean', value: 'hascoverart' },
|
||||
{ label: 'Is Compilation', type: 'boolean', value: 'compilation' },
|
||||
{ label: 'Is Favorite', type: 'boolean', value: 'loved' },
|
||||
{ label: 'Lyrics', type: 'string', value: 'lyrics' },
|
||||
{ label: 'Name', type: 'string', value: 'title' },
|
||||
{ label: 'Play Count', type: 'number', value: 'playcount' },
|
||||
{ label: 'Rating', type: 'number', value: 'rating' },
|
||||
{ label: 'Size', type: 'number', value: 'size' },
|
||||
{ label: 'Sort Album', type: 'string', value: 'sortalbum' },
|
||||
{ label: 'Sort Album Artist', type: 'string', value: 'sortalbumartist' },
|
||||
{ label: 'Sort Artist', type: 'string', value: 'sortartist' },
|
||||
{ label: 'Sort Name', type: 'string', value: 'sorttitle' },
|
||||
{ label: 'Track Number', type: 'number', value: 'tracknumber' },
|
||||
{ label: 'Year', type: 'number', value: 'year' },
|
||||
];
|
||||
|
||||
export const NDSongQueryDateOperators = [
|
||||
{ label: 'is', value: 'is' },
|
||||
{ label: 'is not', value: 'isNot' },
|
||||
{ label: 'is before', value: 'before' },
|
||||
{ label: 'is after', value: 'after' },
|
||||
{ label: 'is in the last', value: 'inTheLast' },
|
||||
{ label: 'is not in the last', value: 'notInTheLast' },
|
||||
{ label: 'is in the range', value: 'inTheRange' },
|
||||
{ label: 'is', value: 'is' },
|
||||
{ label: 'is not', value: 'isNot' },
|
||||
{ label: 'is before', value: 'before' },
|
||||
{ label: 'is after', value: 'after' },
|
||||
{ label: 'is in the last', value: 'inTheLast' },
|
||||
{ label: 'is not in the last', value: 'notInTheLast' },
|
||||
{ label: 'is in the range', value: 'inTheRange' },
|
||||
];
|
||||
|
||||
export const NDSongQueryStringOperators = [
|
||||
{ label: 'is', value: 'is' },
|
||||
{ label: 'is not', value: 'isNot' },
|
||||
{ label: 'contains', value: 'contains' },
|
||||
{ label: 'does not contain', value: 'notContains' },
|
||||
{ label: 'starts with', value: 'startsWith' },
|
||||
{ label: 'ends with', value: 'endsWith' },
|
||||
{ label: 'is', value: 'is' },
|
||||
{ label: 'is not', value: 'isNot' },
|
||||
{ label: 'contains', value: 'contains' },
|
||||
{ label: 'does not contain', value: 'notContains' },
|
||||
{ label: 'starts with', value: 'startsWith' },
|
||||
{ label: 'ends with', value: 'endsWith' },
|
||||
];
|
||||
|
||||
export const NDSongQueryBooleanOperators = [
|
||||
{ label: 'is', value: 'is' },
|
||||
{ label: 'is not', value: 'isNot' },
|
||||
{ label: 'is', value: 'is' },
|
||||
{ label: 'is not', value: 'isNot' },
|
||||
];
|
||||
|
||||
export const NDSongQueryNumberOperators = [
|
||||
{ label: 'is', value: 'is' },
|
||||
{ label: 'is not', value: 'isNot' },
|
||||
{ label: 'contains', value: 'contains' },
|
||||
{ label: 'does not contain', value: 'notContains' },
|
||||
{ label: 'is greater than', value: 'gt' },
|
||||
{ label: 'is less than', value: 'lt' },
|
||||
{ label: 'is in the range', value: 'inTheRange' },
|
||||
{ label: 'is', value: 'is' },
|
||||
{ label: 'is not', value: 'isNot' },
|
||||
{ label: 'contains', value: 'contains' },
|
||||
{ label: 'does not contain', value: 'notContains' },
|
||||
{ label: 'is greater than', value: 'gt' },
|
||||
{ label: 'is less than', value: 'lt' },
|
||||
{ label: 'is in the range', value: 'inTheRange' },
|
||||
];
|
||||
|
||||
export type NDUserListParams = {
|
||||
_sort?: NDUserListSort;
|
||||
_sort?: NDUserListSort;
|
||||
} & NDPagination &
|
||||
NDOrder;
|
||||
NDOrder;
|
||||
|
||||
export type NDUserListResponse = NDUser[];
|
||||
|
||||
export type NDUserList = {
|
||||
items: NDUser[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number;
|
||||
items: NDUser[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number;
|
||||
};
|
||||
|
||||
export enum NDUserListSort {
|
||||
NAME = 'name',
|
||||
NAME = 'name',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,187 +15,188 @@ const localSettings = isElectron() ? window.electron.localSettings : null;
|
|||
const c = initContract();
|
||||
|
||||
export const contract = c.router({
|
||||
addToPlaylist: {
|
||||
body: ndType._parameters.addToPlaylist,
|
||||
method: 'POST',
|
||||
path: 'playlist/:id/tracks',
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.addToPlaylist),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
addToPlaylist: {
|
||||
body: ndType._parameters.addToPlaylist,
|
||||
method: 'POST',
|
||||
path: 'playlist/:id/tracks',
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.addToPlaylist),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
},
|
||||
authenticate: {
|
||||
body: ndType._parameters.authenticate,
|
||||
method: 'POST',
|
||||
path: 'auth/login',
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.authenticate),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
authenticate: {
|
||||
body: ndType._parameters.authenticate,
|
||||
method: 'POST',
|
||||
path: 'auth/login',
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.authenticate),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
},
|
||||
createPlaylist: {
|
||||
body: ndType._parameters.createPlaylist,
|
||||
method: 'POST',
|
||||
path: 'playlist',
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.createPlaylist),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
createPlaylist: {
|
||||
body: ndType._parameters.createPlaylist,
|
||||
method: 'POST',
|
||||
path: 'playlist',
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.createPlaylist),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
},
|
||||
deletePlaylist: {
|
||||
body: null,
|
||||
method: 'DELETE',
|
||||
path: 'playlist/:id',
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.deletePlaylist),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
deletePlaylist: {
|
||||
body: null,
|
||||
method: 'DELETE',
|
||||
path: 'playlist/:id',
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.deletePlaylist),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
},
|
||||
getAlbumArtistDetail: {
|
||||
method: 'GET',
|
||||
path: 'artist/:id',
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.albumArtist),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
getAlbumArtistDetail: {
|
||||
method: 'GET',
|
||||
path: 'artist/:id',
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.albumArtist),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
},
|
||||
getAlbumArtistList: {
|
||||
method: 'GET',
|
||||
path: 'artist',
|
||||
query: ndType._parameters.albumArtistList,
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.albumArtistList),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
getAlbumArtistList: {
|
||||
method: 'GET',
|
||||
path: 'artist',
|
||||
query: ndType._parameters.albumArtistList,
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.albumArtistList),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
},
|
||||
getAlbumDetail: {
|
||||
method: 'GET',
|
||||
path: 'album/:id',
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.album),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
getAlbumDetail: {
|
||||
method: 'GET',
|
||||
path: 'album/:id',
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.album),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
},
|
||||
getAlbumList: {
|
||||
method: 'GET',
|
||||
path: 'album',
|
||||
query: ndType._parameters.albumList,
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.albumList),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
getAlbumList: {
|
||||
method: 'GET',
|
||||
path: 'album',
|
||||
query: ndType._parameters.albumList,
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.albumList),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
},
|
||||
getGenreList: {
|
||||
method: 'GET',
|
||||
path: 'genre',
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.genreList),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
getGenreList: {
|
||||
method: 'GET',
|
||||
path: 'genre',
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.genreList),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
},
|
||||
getPlaylistDetail: {
|
||||
method: 'GET',
|
||||
path: 'playlist/:id',
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.playlist),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
getPlaylistDetail: {
|
||||
method: 'GET',
|
||||
path: 'playlist/:id',
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.playlist),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
},
|
||||
getPlaylistList: {
|
||||
method: 'GET',
|
||||
path: 'playlist',
|
||||
query: ndType._parameters.playlistList,
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.playlistList),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
getPlaylistList: {
|
||||
method: 'GET',
|
||||
path: 'playlist',
|
||||
query: ndType._parameters.playlistList,
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.playlistList),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
},
|
||||
getPlaylistSongList: {
|
||||
method: 'GET',
|
||||
path: 'playlist/:id/tracks',
|
||||
query: ndType._parameters.songList,
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.playlistSongList),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
getPlaylistSongList: {
|
||||
method: 'GET',
|
||||
path: 'playlist/:id/tracks',
|
||||
query: ndType._parameters.songList,
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.playlistSongList),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
},
|
||||
getSongDetail: {
|
||||
method: 'GET',
|
||||
path: 'song/:id',
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.song),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
getSongDetail: {
|
||||
method: 'GET',
|
||||
path: 'song/:id',
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.song),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
},
|
||||
getSongList: {
|
||||
method: 'GET',
|
||||
path: 'song',
|
||||
query: ndType._parameters.songList,
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.songList),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
getSongList: {
|
||||
method: 'GET',
|
||||
path: 'song',
|
||||
query: ndType._parameters.songList,
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.songList),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
},
|
||||
getUserList: {
|
||||
method: 'GET',
|
||||
path: 'user',
|
||||
query: ndType._parameters.userList,
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.userList),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
getUserList: {
|
||||
method: 'GET',
|
||||
path: 'user',
|
||||
query: ndType._parameters.userList,
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.userList),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
},
|
||||
removeFromPlaylist: {
|
||||
body: null,
|
||||
method: 'DELETE',
|
||||
path: 'playlist/:id/tracks',
|
||||
query: ndType._parameters.removeFromPlaylist,
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.removeFromPlaylist),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
removeFromPlaylist: {
|
||||
body: null,
|
||||
method: 'DELETE',
|
||||
path: 'playlist/:id/tracks',
|
||||
query: ndType._parameters.removeFromPlaylist,
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.removeFromPlaylist),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
},
|
||||
updatePlaylist: {
|
||||
body: ndType._parameters.updatePlaylist,
|
||||
method: 'PUT',
|
||||
path: 'playlist/:id',
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.updatePlaylist),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
updatePlaylist: {
|
||||
body: ndType._parameters.updatePlaylist,
|
||||
method: 'PUT',
|
||||
path: 'playlist/:id',
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.updatePlaylist),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const axiosClient = axios.create({});
|
||||
|
||||
axiosClient.defaults.paramsSerializer = (params) => {
|
||||
return qs.stringify(params, { arrayFormat: 'repeat' });
|
||||
return qs.stringify(params, { arrayFormat: 'repeat' });
|
||||
};
|
||||
|
||||
const parsePath = (fullPath: string) => {
|
||||
const [path, params] = fullPath.split('?');
|
||||
const [path, params] = fullPath.split('?');
|
||||
|
||||
const parsedParams = qs.parse(params);
|
||||
const parsedParams = qs.parse(params);
|
||||
|
||||
// Convert indexed object to array
|
||||
const newParams: Record<string, any> = {};
|
||||
Object.keys(parsedParams).forEach((key) => {
|
||||
const isIndexedArrayObject =
|
||||
typeof parsedParams[key] === 'object' && Object.keys(parsedParams[key] || {}).includes('0');
|
||||
// Convert indexed object to array
|
||||
const newParams: Record<string, any> = {};
|
||||
Object.keys(parsedParams).forEach((key) => {
|
||||
const isIndexedArrayObject =
|
||||
typeof parsedParams[key] === 'object' &&
|
||||
Object.keys(parsedParams[key] || {}).includes('0');
|
||||
|
||||
if (!isIndexedArrayObject) {
|
||||
newParams[key] = parsedParams[key];
|
||||
} else {
|
||||
newParams[key] = Object.values(parsedParams[key] || {});
|
||||
}
|
||||
});
|
||||
if (!isIndexedArrayObject) {
|
||||
newParams[key] = parsedParams[key];
|
||||
} else {
|
||||
newParams[key] = Object.values(parsedParams[key] || {});
|
||||
}
|
||||
});
|
||||
|
||||
const notNilParams = omitBy(newParams, (value) => value === 'undefined' || value === 'null');
|
||||
const notNilParams = omitBy(newParams, (value) => value === 'undefined' || value === 'null');
|
||||
|
||||
return {
|
||||
params: notNilParams,
|
||||
path,
|
||||
};
|
||||
return {
|
||||
params: notNilParams,
|
||||
path,
|
||||
};
|
||||
};
|
||||
|
||||
let authSuccess = true;
|
||||
|
|
@ -205,184 +206,186 @@ const RETRY_DELAY_MS = 1000;
|
|||
const MAX_RETRIES = 5;
|
||||
|
||||
const waitForResult = async (count = 0): Promise<void> => {
|
||||
return new Promise((resolve) => {
|
||||
if (count === MAX_RETRIES || !shouldDelay) resolve();
|
||||
return new Promise((resolve) => {
|
||||
if (count === MAX_RETRIES || !shouldDelay) resolve();
|
||||
|
||||
setTimeout(() => {
|
||||
waitForResult(count + 1)
|
||||
.then(resolve)
|
||||
.catch(resolve);
|
||||
}, RETRY_DELAY_MS);
|
||||
});
|
||||
setTimeout(() => {
|
||||
waitForResult(count + 1)
|
||||
.then(resolve)
|
||||
.catch(resolve);
|
||||
}, RETRY_DELAY_MS);
|
||||
});
|
||||
};
|
||||
|
||||
const limitedFail = debounce(authenticationFailure, RETRY_DELAY_MS);
|
||||
const TIMEOUT_ERROR = Error();
|
||||
|
||||
axiosClient.interceptors.response.use(
|
||||
(response) => {
|
||||
const serverId = useAuthStore.getState().currentServer?.id;
|
||||
(response) => {
|
||||
const serverId = useAuthStore.getState().currentServer?.id;
|
||||
|
||||
if (serverId) {
|
||||
const headerCredential = response.headers['x-nd-authorization'] as string | undefined;
|
||||
if (serverId) {
|
||||
const headerCredential = response.headers['x-nd-authorization'] as string | undefined;
|
||||
|
||||
if (headerCredential) {
|
||||
useAuthStore.getState().actions.updateServer(serverId, {
|
||||
ndCredential: headerCredential,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (headerCredential) {
|
||||
useAuthStore.getState().actions.updateServer(serverId, {
|
||||
ndCredential: headerCredential,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
authSuccess = true;
|
||||
authSuccess = true;
|
||||
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
if (error.response && error.response.status === 401) {
|
||||
const currentServer = useAuthStore.getState().currentServer;
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
if (error.response && error.response.status === 401) {
|
||||
const currentServer = useAuthStore.getState().currentServer;
|
||||
|
||||
if (localSettings && currentServer?.savePassword) {
|
||||
// eslint-disable-next-line promise/no-promise-in-callback
|
||||
return localSettings
|
||||
.passwordGet(currentServer.id)
|
||||
.then(async (password: string | null) => {
|
||||
authSuccess = false;
|
||||
if (localSettings && currentServer?.savePassword) {
|
||||
// eslint-disable-next-line promise/no-promise-in-callback
|
||||
return localSettings
|
||||
.passwordGet(currentServer.id)
|
||||
.then(async (password: string | null) => {
|
||||
authSuccess = false;
|
||||
|
||||
if (password === null) {
|
||||
throw error;
|
||||
if (password === null) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (shouldDelay) {
|
||||
await waitForResult();
|
||||
|
||||
// Hopefully the delay was sufficient for authentication.
|
||||
// Otherwise, it will require manual intervention
|
||||
if (authSuccess) {
|
||||
return axiosClient.request(error.config);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
shouldDelay = true;
|
||||
|
||||
// Do not use axiosClient. Instead, manually make a post
|
||||
const res = await axios.post(`${currentServer.url}/auth/login`, {
|
||||
password,
|
||||
username: currentServer.username,
|
||||
});
|
||||
|
||||
if (res.status === 429) {
|
||||
toast.error({
|
||||
message:
|
||||
'you have exceeded the number of allowed login requests. Please wait before logging, or consider tweaking AuthRequestLimit',
|
||||
title: 'Your session has expired.',
|
||||
});
|
||||
|
||||
const serverId = currentServer.id;
|
||||
useAuthStore
|
||||
.getState()
|
||||
.actions.updateServer(serverId, { ndCredential: undefined });
|
||||
useAuthStore.getState().actions.setCurrentServer(null);
|
||||
|
||||
// special error to prevent sending a second message, and stop other messages that could be enqueued
|
||||
limitedFail.cancel();
|
||||
throw TIMEOUT_ERROR;
|
||||
}
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to authenticate');
|
||||
}
|
||||
|
||||
const newCredential = res.data.token;
|
||||
const subsonicCredential = `u=${currentServer.username}&s=${res.data.subsonicSalt}&t=${res.data.subsonicToken}`;
|
||||
|
||||
useAuthStore.getState().actions.updateServer(currentServer.id, {
|
||||
credential: subsonicCredential,
|
||||
ndCredential: newCredential,
|
||||
});
|
||||
|
||||
error.config.headers['x-nd-authorization'] = `Bearer ${newCredential}`;
|
||||
|
||||
authSuccess = true;
|
||||
|
||||
return axiosClient.request(error.config);
|
||||
})
|
||||
.catch((newError: any) => {
|
||||
if (newError !== TIMEOUT_ERROR) {
|
||||
console.error('Error when trying to reauthenticate: ', newError);
|
||||
limitedFail(currentServer);
|
||||
}
|
||||
|
||||
// make sure to pass the error so axios will error later on
|
||||
throw newError;
|
||||
})
|
||||
.finally(() => {
|
||||
shouldDelay = false;
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldDelay) {
|
||||
await waitForResult();
|
||||
limitedFail(currentServer);
|
||||
}
|
||||
|
||||
// Hopefully the delay was sufficient for authentication.
|
||||
// Otherwise, it will require manual intervention
|
||||
if (authSuccess) {
|
||||
return axiosClient.request(error.config);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
shouldDelay = true;
|
||||
|
||||
// Do not use axiosClient. Instead, manually make a post
|
||||
const res = await axios.post(`${currentServer.url}/auth/login`, {
|
||||
password,
|
||||
username: currentServer.username,
|
||||
});
|
||||
|
||||
if (res.status === 429) {
|
||||
toast.error({
|
||||
message:
|
||||
'you have exceeded the number of allowed login requests. Please wait before logging, or consider tweaking AuthRequestLimit',
|
||||
title: 'Your session has expired.',
|
||||
});
|
||||
|
||||
const serverId = currentServer.id;
|
||||
useAuthStore.getState().actions.updateServer(serverId, { ndCredential: undefined });
|
||||
useAuthStore.getState().actions.setCurrentServer(null);
|
||||
|
||||
// special error to prevent sending a second message, and stop other messages that could be enqueued
|
||||
limitedFail.cancel();
|
||||
throw TIMEOUT_ERROR;
|
||||
}
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to authenticate');
|
||||
}
|
||||
|
||||
const newCredential = res.data.token;
|
||||
const subsonicCredential = `u=${currentServer.username}&s=${res.data.subsonicSalt}&t=${res.data.subsonicToken}`;
|
||||
|
||||
useAuthStore.getState().actions.updateServer(currentServer.id, {
|
||||
credential: subsonicCredential,
|
||||
ndCredential: newCredential,
|
||||
});
|
||||
|
||||
error.config.headers['x-nd-authorization'] = `Bearer ${newCredential}`;
|
||||
|
||||
authSuccess = true;
|
||||
|
||||
return axiosClient.request(error.config);
|
||||
})
|
||||
.catch((newError: any) => {
|
||||
if (newError !== TIMEOUT_ERROR) {
|
||||
console.error('Error when trying to reauthenticate: ', newError);
|
||||
limitedFail(currentServer);
|
||||
}
|
||||
|
||||
// make sure to pass the error so axios will error later on
|
||||
throw newError;
|
||||
})
|
||||
.finally(() => {
|
||||
shouldDelay = false;
|
||||
});
|
||||
}
|
||||
|
||||
limitedFail(currentServer);
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export const ndApiClient = (args: {
|
||||
server: ServerListItem | null;
|
||||
signal?: AbortSignal;
|
||||
url?: string;
|
||||
server: ServerListItem | null;
|
||||
signal?: AbortSignal;
|
||||
url?: string;
|
||||
}) => {
|
||||
const { server, url, signal } = args;
|
||||
const { server, url, signal } = args;
|
||||
|
||||
return initClient(contract, {
|
||||
api: async ({ path, method, headers, body }) => {
|
||||
let baseUrl: string | undefined;
|
||||
let token: string | undefined;
|
||||
return initClient(contract, {
|
||||
api: async ({ path, method, headers, body }) => {
|
||||
let baseUrl: string | undefined;
|
||||
let token: string | undefined;
|
||||
|
||||
const { params, path: api } = parsePath(path);
|
||||
const { params, path: api } = parsePath(path);
|
||||
|
||||
if (server) {
|
||||
baseUrl = `${server?.url}/api`;
|
||||
token = server?.ndCredential;
|
||||
} else {
|
||||
baseUrl = url;
|
||||
}
|
||||
if (server) {
|
||||
baseUrl = `${server?.url}/api`;
|
||||
token = server?.ndCredential;
|
||||
} else {
|
||||
baseUrl = url;
|
||||
}
|
||||
|
||||
try {
|
||||
if (shouldDelay) await waitForResult();
|
||||
try {
|
||||
if (shouldDelay) await waitForResult();
|
||||
|
||||
const result = await axiosClient.request({
|
||||
data: body,
|
||||
headers: {
|
||||
...headers,
|
||||
...(token && { 'x-nd-authorization': `Bearer ${token}` }),
|
||||
},
|
||||
method: method as Method,
|
||||
params,
|
||||
signal,
|
||||
url: `${baseUrl}/${api}`,
|
||||
});
|
||||
return {
|
||||
body: { data: result.data, headers: result.headers },
|
||||
headers: result.headers as any,
|
||||
status: result.status,
|
||||
};
|
||||
} catch (e: Error | AxiosError | any) {
|
||||
if (isAxiosError(e)) {
|
||||
const error = e as AxiosError;
|
||||
const response = error.response as AxiosResponse;
|
||||
return {
|
||||
body: { data: response.data, headers: response.headers },
|
||||
headers: response.headers as any,
|
||||
status: response.status,
|
||||
};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
baseHeaders: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
baseUrl: '',
|
||||
jsonQuery: false,
|
||||
});
|
||||
const result = await axiosClient.request({
|
||||
data: body,
|
||||
headers: {
|
||||
...headers,
|
||||
...(token && { 'x-nd-authorization': `Bearer ${token}` }),
|
||||
},
|
||||
method: method as Method,
|
||||
params,
|
||||
signal,
|
||||
url: `${baseUrl}/${api}`,
|
||||
});
|
||||
return {
|
||||
body: { data: result.data, headers: result.headers },
|
||||
headers: result.headers as any,
|
||||
status: result.status,
|
||||
};
|
||||
} catch (e: Error | AxiosError | any) {
|
||||
if (isAxiosError(e)) {
|
||||
const error = e as AxiosError;
|
||||
const response = error.response as AxiosResponse;
|
||||
return {
|
||||
body: { data: response.data, headers: response.headers },
|
||||
headers: response.headers as any,
|
||||
status: response.status,
|
||||
};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
baseHeaders: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
baseUrl: '',
|
||||
jsonQuery: false,
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,43 +1,43 @@
|
|||
import {
|
||||
AlbumArtistDetailArgs,
|
||||
AlbumArtistDetailResponse,
|
||||
AddToPlaylistArgs,
|
||||
AddToPlaylistResponse,
|
||||
CreatePlaylistResponse,
|
||||
CreatePlaylistArgs,
|
||||
DeletePlaylistArgs,
|
||||
DeletePlaylistResponse,
|
||||
AlbumArtistListResponse,
|
||||
AlbumArtistListArgs,
|
||||
albumArtistListSortMap,
|
||||
sortOrderMap,
|
||||
AuthenticationResponse,
|
||||
UserListResponse,
|
||||
UserListArgs,
|
||||
userListSortMap,
|
||||
GenreListArgs,
|
||||
GenreListResponse,
|
||||
AlbumDetailResponse,
|
||||
AlbumDetailArgs,
|
||||
AlbumListArgs,
|
||||
albumListSortMap,
|
||||
AlbumListResponse,
|
||||
SongListResponse,
|
||||
SongListArgs,
|
||||
songListSortMap,
|
||||
SongDetailResponse,
|
||||
SongDetailArgs,
|
||||
UpdatePlaylistArgs,
|
||||
UpdatePlaylistResponse,
|
||||
PlaylistListResponse,
|
||||
PlaylistDetailArgs,
|
||||
PlaylistListArgs,
|
||||
playlistListSortMap,
|
||||
PlaylistDetailResponse,
|
||||
PlaylistSongListArgs,
|
||||
PlaylistSongListResponse,
|
||||
RemoveFromPlaylistResponse,
|
||||
RemoveFromPlaylistArgs,
|
||||
AlbumArtistDetailArgs,
|
||||
AlbumArtistDetailResponse,
|
||||
AddToPlaylistArgs,
|
||||
AddToPlaylistResponse,
|
||||
CreatePlaylistResponse,
|
||||
CreatePlaylistArgs,
|
||||
DeletePlaylistArgs,
|
||||
DeletePlaylistResponse,
|
||||
AlbumArtistListResponse,
|
||||
AlbumArtistListArgs,
|
||||
albumArtistListSortMap,
|
||||
sortOrderMap,
|
||||
AuthenticationResponse,
|
||||
UserListResponse,
|
||||
UserListArgs,
|
||||
userListSortMap,
|
||||
GenreListArgs,
|
||||
GenreListResponse,
|
||||
AlbumDetailResponse,
|
||||
AlbumDetailArgs,
|
||||
AlbumListArgs,
|
||||
albumListSortMap,
|
||||
AlbumListResponse,
|
||||
SongListResponse,
|
||||
SongListArgs,
|
||||
songListSortMap,
|
||||
SongDetailResponse,
|
||||
SongDetailArgs,
|
||||
UpdatePlaylistArgs,
|
||||
UpdatePlaylistResponse,
|
||||
PlaylistListResponse,
|
||||
PlaylistDetailArgs,
|
||||
PlaylistListArgs,
|
||||
playlistListSortMap,
|
||||
PlaylistDetailResponse,
|
||||
PlaylistSongListArgs,
|
||||
PlaylistSongListResponse,
|
||||
RemoveFromPlaylistResponse,
|
||||
RemoveFromPlaylistArgs,
|
||||
} from '../types';
|
||||
import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api';
|
||||
import { ndNormalize } from '/@/renderer/api/navidrome/navidrome-normalize';
|
||||
|
|
@ -45,428 +45,430 @@ import { ndType } from '/@/renderer/api/navidrome/navidrome-types';
|
|||
import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
|
||||
|
||||
const authenticate = async (
|
||||
url: string,
|
||||
body: { password: string; username: string },
|
||||
url: string,
|
||||
body: { password: string; username: string },
|
||||
): Promise<AuthenticationResponse> => {
|
||||
const cleanServerUrl = url.replace(/\/$/, '');
|
||||
const cleanServerUrl = url.replace(/\/$/, '');
|
||||
|
||||
const res = await ndApiClient({ server: null, url: cleanServerUrl }).authenticate({
|
||||
body: {
|
||||
password: body.password,
|
||||
username: body.username,
|
||||
},
|
||||
});
|
||||
const res = await ndApiClient({ server: null, url: cleanServerUrl }).authenticate({
|
||||
body: {
|
||||
password: body.password,
|
||||
username: body.username,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to authenticate');
|
||||
}
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to authenticate');
|
||||
}
|
||||
|
||||
return {
|
||||
credential: `u=${body.username}&s=${res.body.data.subsonicSalt}&t=${res.body.data.subsonicToken}`,
|
||||
ndCredential: res.body.data.token,
|
||||
userId: res.body.data.id,
|
||||
username: res.body.data.username,
|
||||
};
|
||||
return {
|
||||
credential: `u=${body.username}&s=${res.body.data.subsonicSalt}&t=${res.body.data.subsonicToken}`,
|
||||
ndCredential: res.body.data.token,
|
||||
userId: res.body.data.id,
|
||||
username: res.body.data.username,
|
||||
};
|
||||
};
|
||||
|
||||
const getUserList = async (args: UserListArgs): Promise<UserListResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).getUserList({
|
||||
query: {
|
||||
_end: query.startIndex + (query.limit || 0),
|
||||
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||
_sort: userListSortMap.navidrome[query.sortBy],
|
||||
_start: query.startIndex,
|
||||
...query._custom?.navidrome,
|
||||
},
|
||||
});
|
||||
const res = await ndApiClient(apiClientProps).getUserList({
|
||||
query: {
|
||||
_end: query.startIndex + (query.limit || 0),
|
||||
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||
_sort: userListSortMap.navidrome[query.sortBy],
|
||||
_start: query.startIndex,
|
||||
...query._custom?.navidrome,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get user list');
|
||||
}
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get user list');
|
||||
}
|
||||
|
||||
return {
|
||||
items: res.body.data.map((user) => ndNormalize.user(user)),
|
||||
startIndex: query?.startIndex || 0,
|
||||
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
||||
};
|
||||
return {
|
||||
items: res.body.data.map((user) => ndNormalize.user(user)),
|
||||
startIndex: query?.startIndex || 0,
|
||||
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
||||
};
|
||||
};
|
||||
|
||||
const getGenreList = async (args: GenreListArgs): Promise<GenreListResponse> => {
|
||||
const { apiClientProps } = args;
|
||||
const { apiClientProps } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).getGenreList({});
|
||||
const res = await ndApiClient(apiClientProps).getGenreList({});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get genre list');
|
||||
}
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get genre list');
|
||||
}
|
||||
|
||||
return {
|
||||
items: res.body.data,
|
||||
startIndex: 0,
|
||||
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
||||
};
|
||||
return {
|
||||
items: res.body.data,
|
||||
startIndex: 0,
|
||||
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
||||
};
|
||||
};
|
||||
|
||||
const getAlbumArtistDetail = async (
|
||||
args: AlbumArtistDetailArgs,
|
||||
args: AlbumArtistDetailArgs,
|
||||
): Promise<AlbumArtistDetailResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).getAlbumArtistDetail({
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
});
|
||||
const res = await ndApiClient(apiClientProps).getAlbumArtistDetail({
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
});
|
||||
|
||||
const artistInfoRes = await ssApiClient(apiClientProps).getArtistInfo({
|
||||
query: {
|
||||
count: 10,
|
||||
id: query.id,
|
||||
},
|
||||
});
|
||||
const artistInfoRes = await ssApiClient(apiClientProps).getArtistInfo({
|
||||
query: {
|
||||
count: 10,
|
||||
id: query.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get album artist detail');
|
||||
}
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get album artist detail');
|
||||
}
|
||||
|
||||
if (!apiClientProps.server) {
|
||||
throw new Error('Server is required');
|
||||
}
|
||||
if (!apiClientProps.server) {
|
||||
throw new Error('Server is required');
|
||||
}
|
||||
|
||||
return ndNormalize.albumArtist(
|
||||
{
|
||||
...res.body.data,
|
||||
...(artistInfoRes.status === 200 && {
|
||||
similarArtists: artistInfoRes.body.artistInfo.similarArtist,
|
||||
...(!res.body.data.largeImageUrl && {
|
||||
largeImageUrl: artistInfoRes.body.artistInfo.largeImageUrl,
|
||||
}),
|
||||
...(!res.body.data.mediumImageUrl && {
|
||||
largeImageUrl: artistInfoRes.body.artistInfo.mediumImageUrl,
|
||||
}),
|
||||
...(!res.body.data.smallImageUrl && {
|
||||
largeImageUrl: artistInfoRes.body.artistInfo.smallImageUrl,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
apiClientProps.server,
|
||||
);
|
||||
return ndNormalize.albumArtist(
|
||||
{
|
||||
...res.body.data,
|
||||
...(artistInfoRes.status === 200 && {
|
||||
similarArtists: artistInfoRes.body.artistInfo.similarArtist,
|
||||
...(!res.body.data.largeImageUrl && {
|
||||
largeImageUrl: artistInfoRes.body.artistInfo.largeImageUrl,
|
||||
}),
|
||||
...(!res.body.data.mediumImageUrl && {
|
||||
largeImageUrl: artistInfoRes.body.artistInfo.mediumImageUrl,
|
||||
}),
|
||||
...(!res.body.data.smallImageUrl && {
|
||||
largeImageUrl: artistInfoRes.body.artistInfo.smallImageUrl,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
apiClientProps.server,
|
||||
);
|
||||
};
|
||||
|
||||
const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<AlbumArtistListResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).getAlbumArtistList({
|
||||
query: {
|
||||
_end: query.startIndex + (query.limit || 0),
|
||||
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||
_sort: albumArtistListSortMap.navidrome[query.sortBy],
|
||||
_start: query.startIndex,
|
||||
name: query.searchTerm,
|
||||
...query._custom?.navidrome,
|
||||
},
|
||||
});
|
||||
const res = await ndApiClient(apiClientProps).getAlbumArtistList({
|
||||
query: {
|
||||
_end: query.startIndex + (query.limit || 0),
|
||||
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||
_sort: albumArtistListSortMap.navidrome[query.sortBy],
|
||||
_start: query.startIndex,
|
||||
name: query.searchTerm,
|
||||
...query._custom?.navidrome,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get album artist list');
|
||||
}
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get album artist list');
|
||||
}
|
||||
|
||||
return {
|
||||
items: res.body.data.map((albumArtist) =>
|
||||
ndNormalize.albumArtist(albumArtist, apiClientProps.server),
|
||||
),
|
||||
startIndex: query.startIndex,
|
||||
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
||||
};
|
||||
return {
|
||||
items: res.body.data.map((albumArtist) =>
|
||||
ndNormalize.albumArtist(albumArtist, apiClientProps.server),
|
||||
),
|
||||
startIndex: query.startIndex,
|
||||
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
||||
};
|
||||
};
|
||||
|
||||
const getAlbumDetail = async (args: AlbumDetailArgs): Promise<AlbumDetailResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const albumRes = await ndApiClient(apiClientProps).getAlbumDetail({
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
});
|
||||
const albumRes = await ndApiClient(apiClientProps).getAlbumDetail({
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
});
|
||||
|
||||
const songsData = await ndApiClient(apiClientProps).getSongList({
|
||||
query: {
|
||||
_end: 0,
|
||||
_order: 'ASC',
|
||||
_sort: 'album',
|
||||
_start: 0,
|
||||
album_id: [query.id],
|
||||
},
|
||||
});
|
||||
const songsData = await ndApiClient(apiClientProps).getSongList({
|
||||
query: {
|
||||
_end: 0,
|
||||
_order: 'ASC',
|
||||
_sort: 'album',
|
||||
_start: 0,
|
||||
album_id: [query.id],
|
||||
},
|
||||
});
|
||||
|
||||
if (albumRes.status !== 200 || songsData.status !== 200) {
|
||||
throw new Error('Failed to get album detail');
|
||||
}
|
||||
if (albumRes.status !== 200 || songsData.status !== 200) {
|
||||
throw new Error('Failed to get album detail');
|
||||
}
|
||||
|
||||
return ndNormalize.album(
|
||||
{ ...albumRes.body.data, songs: songsData.body.data },
|
||||
apiClientProps.server,
|
||||
);
|
||||
return ndNormalize.album(
|
||||
{ ...albumRes.body.data, songs: songsData.body.data },
|
||||
apiClientProps.server,
|
||||
);
|
||||
};
|
||||
|
||||
const getAlbumList = async (args: AlbumListArgs): Promise<AlbumListResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).getAlbumList({
|
||||
query: {
|
||||
_end: query.startIndex + (query.limit || 0),
|
||||
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||
_sort: albumListSortMap.navidrome[query.sortBy],
|
||||
_start: query.startIndex,
|
||||
artist_id: query.artistIds?.[0],
|
||||
name: query.searchTerm,
|
||||
...query._custom?.navidrome,
|
||||
},
|
||||
});
|
||||
const res = await ndApiClient(apiClientProps).getAlbumList({
|
||||
query: {
|
||||
_end: query.startIndex + (query.limit || 0),
|
||||
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||
_sort: albumListSortMap.navidrome[query.sortBy],
|
||||
_start: query.startIndex,
|
||||
artist_id: query.artistIds?.[0],
|
||||
name: query.searchTerm,
|
||||
...query._custom?.navidrome,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get album list');
|
||||
}
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get album list');
|
||||
}
|
||||
|
||||
return {
|
||||
items: res.body.data.map((album) => ndNormalize.album(album, apiClientProps.server)),
|
||||
startIndex: query?.startIndex || 0,
|
||||
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
||||
};
|
||||
return {
|
||||
items: res.body.data.map((album) => ndNormalize.album(album, apiClientProps.server)),
|
||||
startIndex: query?.startIndex || 0,
|
||||
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
||||
};
|
||||
};
|
||||
|
||||
const getSongList = async (args: SongListArgs): Promise<SongListResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).getSongList({
|
||||
query: {
|
||||
_end: query.startIndex + (query.limit || -1),
|
||||
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||
_sort: songListSortMap.navidrome[query.sortBy],
|
||||
_start: query.startIndex,
|
||||
album_artist_id: query.artistIds,
|
||||
album_id: query.albumIds,
|
||||
title: query.searchTerm,
|
||||
...query._custom?.navidrome,
|
||||
},
|
||||
});
|
||||
const res = await ndApiClient(apiClientProps).getSongList({
|
||||
query: {
|
||||
_end: query.startIndex + (query.limit || -1),
|
||||
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||
_sort: songListSortMap.navidrome[query.sortBy],
|
||||
_start: query.startIndex,
|
||||
album_artist_id: query.artistIds,
|
||||
album_id: query.albumIds,
|
||||
title: query.searchTerm,
|
||||
...query._custom?.navidrome,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get song list');
|
||||
}
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get song list');
|
||||
}
|
||||
|
||||
return {
|
||||
items: res.body.data.map((song) => ndNormalize.song(song, apiClientProps.server, '')),
|
||||
startIndex: query?.startIndex || 0,
|
||||
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
||||
};
|
||||
return {
|
||||
items: res.body.data.map((song) => ndNormalize.song(song, apiClientProps.server, '')),
|
||||
startIndex: query?.startIndex || 0,
|
||||
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
||||
};
|
||||
};
|
||||
|
||||
const getSongDetail = async (args: SongDetailArgs): Promise<SongDetailResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).getSongDetail({
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
});
|
||||
const res = await ndApiClient(apiClientProps).getSongDetail({
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get song detail');
|
||||
}
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get song detail');
|
||||
}
|
||||
|
||||
return ndNormalize.song(res.body.data, apiClientProps.server, '');
|
||||
return ndNormalize.song(res.body.data, apiClientProps.server, '');
|
||||
};
|
||||
|
||||
const createPlaylist = async (args: CreatePlaylistArgs): Promise<CreatePlaylistResponse> => {
|
||||
const { body, apiClientProps } = args;
|
||||
const { body, apiClientProps } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).createPlaylist({
|
||||
body: {
|
||||
comment: body.comment,
|
||||
name: body.name,
|
||||
public: body._custom?.navidrome?.public,
|
||||
rules: body._custom?.navidrome?.rules,
|
||||
sync: body._custom?.navidrome?.sync,
|
||||
},
|
||||
});
|
||||
const res = await ndApiClient(apiClientProps).createPlaylist({
|
||||
body: {
|
||||
comment: body.comment,
|
||||
name: body.name,
|
||||
public: body._custom?.navidrome?.public,
|
||||
rules: body._custom?.navidrome?.rules,
|
||||
sync: body._custom?.navidrome?.sync,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to create playlist');
|
||||
}
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to create playlist');
|
||||
}
|
||||
|
||||
return {
|
||||
id: res.body.data.id,
|
||||
};
|
||||
return {
|
||||
id: res.body.data.id,
|
||||
};
|
||||
};
|
||||
|
||||
const updatePlaylist = async (args: UpdatePlaylistArgs): Promise<UpdatePlaylistResponse> => {
|
||||
const { query, body, apiClientProps } = args;
|
||||
const { query, body, apiClientProps } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).updatePlaylist({
|
||||
body: {
|
||||
comment: body.comment || '',
|
||||
name: body.name,
|
||||
public: body._custom?.navidrome?.public || false,
|
||||
rules: body._custom?.navidrome?.rules ? body._custom.navidrome.rules : undefined,
|
||||
sync: body._custom?.navidrome?.sync || undefined,
|
||||
},
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
});
|
||||
const res = await ndApiClient(apiClientProps).updatePlaylist({
|
||||
body: {
|
||||
comment: body.comment || '',
|
||||
name: body.name,
|
||||
public: body._custom?.navidrome?.public || false,
|
||||
rules: body._custom?.navidrome?.rules ? body._custom.navidrome.rules : undefined,
|
||||
sync: body._custom?.navidrome?.sync || undefined,
|
||||
},
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to update playlist');
|
||||
}
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to update playlist');
|
||||
}
|
||||
|
||||
return null;
|
||||
return null;
|
||||
};
|
||||
|
||||
const deletePlaylist = async (args: DeletePlaylistArgs): Promise<DeletePlaylistResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).deletePlaylist({
|
||||
body: null,
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
});
|
||||
const res = await ndApiClient(apiClientProps).deletePlaylist({
|
||||
body: null,
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to delete playlist');
|
||||
}
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to delete playlist');
|
||||
}
|
||||
|
||||
return null;
|
||||
return null;
|
||||
};
|
||||
|
||||
const getPlaylistList = async (args: PlaylistListArgs): Promise<PlaylistListResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).getPlaylistList({
|
||||
query: {
|
||||
_end: query.startIndex + (query.limit || 0),
|
||||
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||
_sort: query.sortBy ? playlistListSortMap.navidrome[query.sortBy] : undefined,
|
||||
_start: query.startIndex,
|
||||
...query._custom?.navidrome,
|
||||
},
|
||||
});
|
||||
const res = await ndApiClient(apiClientProps).getPlaylistList({
|
||||
query: {
|
||||
_end: query.startIndex + (query.limit || 0),
|
||||
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||
_sort: query.sortBy ? playlistListSortMap.navidrome[query.sortBy] : undefined,
|
||||
_start: query.startIndex,
|
||||
...query._custom?.navidrome,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get playlist list');
|
||||
}
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get playlist list');
|
||||
}
|
||||
|
||||
return {
|
||||
items: res.body.data.map((item) => ndNormalize.playlist(item, apiClientProps.server)),
|
||||
startIndex: query?.startIndex || 0,
|
||||
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
||||
};
|
||||
return {
|
||||
items: res.body.data.map((item) => ndNormalize.playlist(item, apiClientProps.server)),
|
||||
startIndex: query?.startIndex || 0,
|
||||
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
||||
};
|
||||
};
|
||||
|
||||
const getPlaylistDetail = async (args: PlaylistDetailArgs): Promise<PlaylistDetailResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).getPlaylistDetail({
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
});
|
||||
const res = await ndApiClient(apiClientProps).getPlaylistDetail({
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get playlist detail');
|
||||
}
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get playlist detail');
|
||||
}
|
||||
|
||||
return ndNormalize.playlist(res.body.data, apiClientProps.server);
|
||||
return ndNormalize.playlist(res.body.data, apiClientProps.server);
|
||||
};
|
||||
|
||||
const getPlaylistSongList = async (
|
||||
args: PlaylistSongListArgs,
|
||||
args: PlaylistSongListArgs,
|
||||
): Promise<PlaylistSongListResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).getPlaylistSongList({
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
query: {
|
||||
_end: query.startIndex + (query.limit || 0),
|
||||
_order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : 'ASC',
|
||||
_sort: query.sortBy ? songListSortMap.navidrome[query.sortBy] : ndType._enum.songList.ID,
|
||||
_start: query.startIndex,
|
||||
},
|
||||
});
|
||||
const res = await ndApiClient(apiClientProps).getPlaylistSongList({
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
query: {
|
||||
_end: query.startIndex + (query.limit || 0),
|
||||
_order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : 'ASC',
|
||||
_sort: query.sortBy
|
||||
? songListSortMap.navidrome[query.sortBy]
|
||||
: ndType._enum.songList.ID,
|
||||
_start: query.startIndex,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get playlist song list');
|
||||
}
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get playlist song list');
|
||||
}
|
||||
|
||||
return {
|
||||
items: res.body.data.map((item) => ndNormalize.song(item, apiClientProps.server, '')),
|
||||
startIndex: query?.startIndex || 0,
|
||||
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
||||
};
|
||||
return {
|
||||
items: res.body.data.map((item) => ndNormalize.song(item, apiClientProps.server, '')),
|
||||
startIndex: query?.startIndex || 0,
|
||||
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
||||
};
|
||||
};
|
||||
|
||||
const addToPlaylist = async (args: AddToPlaylistArgs): Promise<AddToPlaylistResponse> => {
|
||||
const { body, query, apiClientProps } = args;
|
||||
const { body, query, apiClientProps } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).addToPlaylist({
|
||||
body: {
|
||||
ids: body.songId,
|
||||
},
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
});
|
||||
const res = await ndApiClient(apiClientProps).addToPlaylist({
|
||||
body: {
|
||||
ids: body.songId,
|
||||
},
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to add to playlist');
|
||||
}
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to add to playlist');
|
||||
}
|
||||
|
||||
return null;
|
||||
return null;
|
||||
};
|
||||
|
||||
const removeFromPlaylist = async (
|
||||
args: RemoveFromPlaylistArgs,
|
||||
args: RemoveFromPlaylistArgs,
|
||||
): Promise<RemoveFromPlaylistResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).removeFromPlaylist({
|
||||
body: null,
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
query: {
|
||||
id: query.songId,
|
||||
},
|
||||
});
|
||||
const res = await ndApiClient(apiClientProps).removeFromPlaylist({
|
||||
body: null,
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
query: {
|
||||
id: query.songId,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to remove from playlist');
|
||||
}
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to remove from playlist');
|
||||
}
|
||||
|
||||
return null;
|
||||
return null;
|
||||
};
|
||||
|
||||
export const ndController = {
|
||||
addToPlaylist,
|
||||
authenticate,
|
||||
createPlaylist,
|
||||
deletePlaylist,
|
||||
getAlbumArtistDetail,
|
||||
getAlbumArtistList,
|
||||
getAlbumDetail,
|
||||
getAlbumList,
|
||||
getGenreList,
|
||||
getPlaylistDetail,
|
||||
getPlaylistList,
|
||||
getPlaylistSongList,
|
||||
getSongDetail,
|
||||
getSongList,
|
||||
getUserList,
|
||||
removeFromPlaylist,
|
||||
updatePlaylist,
|
||||
addToPlaylist,
|
||||
authenticate,
|
||||
createPlaylist,
|
||||
deletePlaylist,
|
||||
getAlbumArtistDetail,
|
||||
getAlbumArtistList,
|
||||
getAlbumDetail,
|
||||
getAlbumList,
|
||||
getGenreList,
|
||||
getPlaylistDetail,
|
||||
getPlaylistList,
|
||||
getPlaylistSongList,
|
||||
getSongDetail,
|
||||
getSongList,
|
||||
getUserList,
|
||||
removeFromPlaylist,
|
||||
updatePlaylist,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,226 +6,226 @@ import { ndType } from './navidrome-types';
|
|||
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
|
||||
|
||||
const getCoverArtUrl = (args: {
|
||||
baseUrl: string | undefined;
|
||||
coverArtId: string;
|
||||
credential: string | undefined;
|
||||
size: number;
|
||||
baseUrl: string | undefined;
|
||||
coverArtId: string;
|
||||
credential: string | undefined;
|
||||
size: number;
|
||||
}) => {
|
||||
const size = args.size ? args.size : 250;
|
||||
const size = args.size ? args.size : 250;
|
||||
|
||||
if (!args.coverArtId || args.coverArtId.match('2a96cbd8b46e442fc41c2b86b821562f')) {
|
||||
return null;
|
||||
}
|
||||
if (!args.coverArtId || args.coverArtId.match('2a96cbd8b46e442fc41c2b86b821562f')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
`${args.baseUrl}/rest/getCoverArt.view` +
|
||||
`?id=${args.coverArtId}` +
|
||||
`&${args.credential}` +
|
||||
'&v=1.13.0' +
|
||||
'&c=feishin' +
|
||||
`&size=${size}`
|
||||
);
|
||||
return (
|
||||
`${args.baseUrl}/rest/getCoverArt.view` +
|
||||
`?id=${args.coverArtId}` +
|
||||
`&${args.credential}` +
|
||||
'&v=1.13.0' +
|
||||
'&c=feishin' +
|
||||
`&size=${size}`
|
||||
);
|
||||
};
|
||||
|
||||
const normalizeSong = (
|
||||
item: z.infer<typeof ndType._response.song> | z.infer<typeof ndType._response.playlistSong>,
|
||||
server: ServerListItem | null,
|
||||
deviceId: string,
|
||||
imageSize?: number,
|
||||
item: z.infer<typeof ndType._response.song> | z.infer<typeof ndType._response.playlistSong>,
|
||||
server: ServerListItem | null,
|
||||
deviceId: string,
|
||||
imageSize?: number,
|
||||
): Song => {
|
||||
let id;
|
||||
let playlistItemId;
|
||||
let id;
|
||||
let playlistItemId;
|
||||
|
||||
// Dynamically determine the id field based on whether or not the item is a playlist song
|
||||
if ('mediaFileId' in item) {
|
||||
id = item.mediaFileId;
|
||||
playlistItemId = item.id;
|
||||
} else {
|
||||
id = item.id;
|
||||
}
|
||||
// Dynamically determine the id field based on whether or not the item is a playlist song
|
||||
if ('mediaFileId' in item) {
|
||||
id = item.mediaFileId;
|
||||
playlistItemId = item.id;
|
||||
} else {
|
||||
id = item.id;
|
||||
}
|
||||
|
||||
const imageUrl = getCoverArtUrl({
|
||||
baseUrl: server?.url,
|
||||
coverArtId: id,
|
||||
credential: server?.credential,
|
||||
size: imageSize || 100,
|
||||
});
|
||||
const imageUrl = getCoverArtUrl({
|
||||
baseUrl: server?.url,
|
||||
coverArtId: id,
|
||||
credential: server?.credential,
|
||||
size: imageSize || 100,
|
||||
});
|
||||
|
||||
const imagePlaceholderUrl = null;
|
||||
const imagePlaceholderUrl = null;
|
||||
|
||||
return {
|
||||
album: item.album,
|
||||
albumArtists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
|
||||
albumId: item.albumId,
|
||||
artistName: item.artist,
|
||||
artists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
|
||||
bitRate: item.bitRate,
|
||||
bpm: item.bpm ? item.bpm : null,
|
||||
channels: item.channels ? item.channels : null,
|
||||
comment: item.comment ? item.comment : null,
|
||||
compilation: item.compilation,
|
||||
container: item.suffix,
|
||||
createdAt: item.createdAt.split('T')[0],
|
||||
discNumber: item.discNumber,
|
||||
duration: item.duration,
|
||||
genres: item.genres,
|
||||
id,
|
||||
imagePlaceholderUrl,
|
||||
imageUrl,
|
||||
itemType: LibraryItem.SONG,
|
||||
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
|
||||
lyrics: item.lyrics ? item.lyrics : null,
|
||||
name: item.title,
|
||||
path: item.path,
|
||||
playCount: item.playCount,
|
||||
playlistItemId,
|
||||
releaseDate: new Date(item.year, 0, 1).toISOString(),
|
||||
releaseYear: String(item.year),
|
||||
serverId: server?.id || 'unknown',
|
||||
serverType: ServerType.NAVIDROME,
|
||||
size: item.size,
|
||||
streamUrl: `${server?.url}/rest/stream.view?id=${id}&v=1.13.0&c=feishin_${deviceId}&${server?.credential}`,
|
||||
trackNumber: item.trackNumber,
|
||||
uniqueId: nanoid(),
|
||||
updatedAt: item.updatedAt,
|
||||
userFavorite: item.starred || false,
|
||||
userRating: item.rating || null,
|
||||
};
|
||||
return {
|
||||
album: item.album,
|
||||
albumArtists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
|
||||
albumId: item.albumId,
|
||||
artistName: item.artist,
|
||||
artists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
|
||||
bitRate: item.bitRate,
|
||||
bpm: item.bpm ? item.bpm : null,
|
||||
channels: item.channels ? item.channels : null,
|
||||
comment: item.comment ? item.comment : null,
|
||||
compilation: item.compilation,
|
||||
container: item.suffix,
|
||||
createdAt: item.createdAt.split('T')[0],
|
||||
discNumber: item.discNumber,
|
||||
duration: item.duration,
|
||||
genres: item.genres,
|
||||
id,
|
||||
imagePlaceholderUrl,
|
||||
imageUrl,
|
||||
itemType: LibraryItem.SONG,
|
||||
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
|
||||
lyrics: item.lyrics ? item.lyrics : null,
|
||||
name: item.title,
|
||||
path: item.path,
|
||||
playCount: item.playCount,
|
||||
playlistItemId,
|
||||
releaseDate: new Date(item.year, 0, 1).toISOString(),
|
||||
releaseYear: String(item.year),
|
||||
serverId: server?.id || 'unknown',
|
||||
serverType: ServerType.NAVIDROME,
|
||||
size: item.size,
|
||||
streamUrl: `${server?.url}/rest/stream.view?id=${id}&v=1.13.0&c=feishin_${deviceId}&${server?.credential}`,
|
||||
trackNumber: item.trackNumber,
|
||||
uniqueId: nanoid(),
|
||||
updatedAt: item.updatedAt,
|
||||
userFavorite: item.starred || false,
|
||||
userRating: item.rating || null,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeAlbum = (
|
||||
item: z.infer<typeof ndType._response.album> & {
|
||||
songs?: z.infer<typeof ndType._response.songList>;
|
||||
},
|
||||
server: ServerListItem | null,
|
||||
imageSize?: number,
|
||||
item: z.infer<typeof ndType._response.album> & {
|
||||
songs?: z.infer<typeof ndType._response.songList>;
|
||||
},
|
||||
server: ServerListItem | null,
|
||||
imageSize?: number,
|
||||
): Album => {
|
||||
const imageUrl = getCoverArtUrl({
|
||||
baseUrl: server?.url,
|
||||
coverArtId: item.coverArtId || item.id,
|
||||
credential: server?.credential,
|
||||
size: imageSize || 300,
|
||||
});
|
||||
const imageUrl = getCoverArtUrl({
|
||||
baseUrl: server?.url,
|
||||
coverArtId: item.coverArtId || item.id,
|
||||
credential: server?.credential,
|
||||
size: imageSize || 300,
|
||||
});
|
||||
|
||||
const imagePlaceholderUrl = null;
|
||||
const imagePlaceholderUrl = null;
|
||||
|
||||
const imageBackdropUrl = imageUrl?.replace(/size=\d+/, 'size=1000') || null;
|
||||
const imageBackdropUrl = imageUrl?.replace(/size=\d+/, 'size=1000') || null;
|
||||
|
||||
return {
|
||||
albumArtists: [{ id: item.albumArtistId, imageUrl: null, name: item.albumArtist }],
|
||||
artists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
|
||||
backdropImageUrl: imageBackdropUrl,
|
||||
createdAt: item.createdAt.split('T')[0],
|
||||
duration: item.duration * 1000 || null,
|
||||
genres: item.genres,
|
||||
id: item.id,
|
||||
imagePlaceholderUrl,
|
||||
imageUrl,
|
||||
isCompilation: item.compilation,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
|
||||
name: item.name,
|
||||
playCount: item.playCount,
|
||||
releaseDate: new Date(item.minYear, 0, 1).toISOString(),
|
||||
releaseYear: item.minYear,
|
||||
serverId: server?.id || 'unknown',
|
||||
serverType: ServerType.NAVIDROME,
|
||||
size: item.size,
|
||||
songCount: item.songCount,
|
||||
songs: item.songs ? item.songs.map((song) => normalizeSong(song, server, '')) : undefined,
|
||||
uniqueId: nanoid(),
|
||||
updatedAt: item.updatedAt,
|
||||
userFavorite: item.starred,
|
||||
userRating: item.rating || null,
|
||||
};
|
||||
return {
|
||||
albumArtists: [{ id: item.albumArtistId, imageUrl: null, name: item.albumArtist }],
|
||||
artists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
|
||||
backdropImageUrl: imageBackdropUrl,
|
||||
createdAt: item.createdAt.split('T')[0],
|
||||
duration: item.duration * 1000 || null,
|
||||
genres: item.genres,
|
||||
id: item.id,
|
||||
imagePlaceholderUrl,
|
||||
imageUrl,
|
||||
isCompilation: item.compilation,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
|
||||
name: item.name,
|
||||
playCount: item.playCount,
|
||||
releaseDate: new Date(item.minYear, 0, 1).toISOString(),
|
||||
releaseYear: item.minYear,
|
||||
serverId: server?.id || 'unknown',
|
||||
serverType: ServerType.NAVIDROME,
|
||||
size: item.size,
|
||||
songCount: item.songCount,
|
||||
songs: item.songs ? item.songs.map((song) => normalizeSong(song, server, '')) : undefined,
|
||||
uniqueId: nanoid(),
|
||||
updatedAt: item.updatedAt,
|
||||
userFavorite: item.starred,
|
||||
userRating: item.rating || null,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeAlbumArtist = (
|
||||
item: z.infer<typeof ndType._response.albumArtist> & {
|
||||
similarArtists?: z.infer<typeof ssType._response.artistInfo>['artistInfo']['similarArtist'];
|
||||
},
|
||||
server: ServerListItem | null,
|
||||
item: z.infer<typeof ndType._response.albumArtist> & {
|
||||
similarArtists?: z.infer<typeof ssType._response.artistInfo>['artistInfo']['similarArtist'];
|
||||
},
|
||||
server: ServerListItem | null,
|
||||
): AlbumArtist => {
|
||||
const imageUrl =
|
||||
item.largeImageUrl === '/app/artist-placeholder.webp' ? null : item.largeImageUrl;
|
||||
const imageUrl =
|
||||
item.largeImageUrl === '/app/artist-placeholder.webp' ? null : item.largeImageUrl;
|
||||
|
||||
return {
|
||||
albumCount: item.albumCount,
|
||||
backgroundImageUrl: null,
|
||||
biography: item.biography || null,
|
||||
duration: null,
|
||||
genres: item.genres,
|
||||
id: item.id,
|
||||
imageUrl: imageUrl || null,
|
||||
itemType: LibraryItem.ALBUM_ARTIST,
|
||||
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
|
||||
name: item.name,
|
||||
playCount: item.playCount,
|
||||
serverId: server?.id || 'unknown',
|
||||
serverType: ServerType.NAVIDROME,
|
||||
similarArtists:
|
||||
item.similarArtists?.map((artist) => ({
|
||||
id: artist.id,
|
||||
imageUrl: artist?.artistImageUrl || null,
|
||||
name: artist.name,
|
||||
})) || null,
|
||||
songCount: item.songCount,
|
||||
userFavorite: item.starred,
|
||||
userRating: item.rating,
|
||||
};
|
||||
return {
|
||||
albumCount: item.albumCount,
|
||||
backgroundImageUrl: null,
|
||||
biography: item.biography || null,
|
||||
duration: null,
|
||||
genres: item.genres,
|
||||
id: item.id,
|
||||
imageUrl: imageUrl || null,
|
||||
itemType: LibraryItem.ALBUM_ARTIST,
|
||||
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
|
||||
name: item.name,
|
||||
playCount: item.playCount,
|
||||
serverId: server?.id || 'unknown',
|
||||
serverType: ServerType.NAVIDROME,
|
||||
similarArtists:
|
||||
item.similarArtists?.map((artist) => ({
|
||||
id: artist.id,
|
||||
imageUrl: artist?.artistImageUrl || null,
|
||||
name: artist.name,
|
||||
})) || null,
|
||||
songCount: item.songCount,
|
||||
userFavorite: item.starred,
|
||||
userRating: item.rating,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizePlaylist = (
|
||||
item: z.infer<typeof ndType._response.playlist>,
|
||||
server: ServerListItem | null,
|
||||
imageSize?: number,
|
||||
item: z.infer<typeof ndType._response.playlist>,
|
||||
server: ServerListItem | null,
|
||||
imageSize?: number,
|
||||
): Playlist => {
|
||||
const imageUrl = getCoverArtUrl({
|
||||
baseUrl: server?.url,
|
||||
coverArtId: item.id,
|
||||
credential: server?.credential,
|
||||
size: imageSize || 300,
|
||||
});
|
||||
const imageUrl = getCoverArtUrl({
|
||||
baseUrl: server?.url,
|
||||
coverArtId: item.id,
|
||||
credential: server?.credential,
|
||||
size: imageSize || 300,
|
||||
});
|
||||
|
||||
const imagePlaceholderUrl = null;
|
||||
const imagePlaceholderUrl = null;
|
||||
|
||||
return {
|
||||
description: item.comment,
|
||||
duration: item.duration * 1000,
|
||||
genres: [],
|
||||
id: item.id,
|
||||
imagePlaceholderUrl,
|
||||
imageUrl,
|
||||
itemType: LibraryItem.PLAYLIST,
|
||||
name: item.name,
|
||||
owner: item.ownerName,
|
||||
ownerId: item.ownerId,
|
||||
public: item.public,
|
||||
rules: item?.rules || null,
|
||||
serverId: server?.id || 'unknown',
|
||||
serverType: ServerType.NAVIDROME,
|
||||
size: item.size,
|
||||
songCount: item.songCount,
|
||||
sync: item.sync,
|
||||
};
|
||||
return {
|
||||
description: item.comment,
|
||||
duration: item.duration * 1000,
|
||||
genres: [],
|
||||
id: item.id,
|
||||
imagePlaceholderUrl,
|
||||
imageUrl,
|
||||
itemType: LibraryItem.PLAYLIST,
|
||||
name: item.name,
|
||||
owner: item.ownerName,
|
||||
ownerId: item.ownerId,
|
||||
public: item.public,
|
||||
rules: item?.rules || null,
|
||||
serverId: server?.id || 'unknown',
|
||||
serverType: ServerType.NAVIDROME,
|
||||
size: item.size,
|
||||
songCount: item.songCount,
|
||||
sync: item.sync,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeUser = (item: z.infer<typeof ndType._response.user>): User => {
|
||||
return {
|
||||
createdAt: item.createdAt,
|
||||
email: item.email || null,
|
||||
id: item.id,
|
||||
isAdmin: item.isAdmin,
|
||||
lastLoginAt: item.lastLoginAt,
|
||||
name: item.userName,
|
||||
updatedAt: item.updatedAt,
|
||||
};
|
||||
return {
|
||||
createdAt: item.createdAt,
|
||||
email: item.email || null,
|
||||
id: item.id,
|
||||
isAdmin: item.isAdmin,
|
||||
lastLoginAt: item.lastLoginAt,
|
||||
name: item.userName,
|
||||
updatedAt: item.updatedAt,
|
||||
};
|
||||
};
|
||||
|
||||
export const ndNormalize = {
|
||||
album: normalizeAlbum,
|
||||
albumArtist: normalizeAlbumArtist,
|
||||
playlist: normalizePlaylist,
|
||||
song: normalizeSong,
|
||||
user: normalizeUser,
|
||||
album: normalizeAlbum,
|
||||
albumArtist: normalizeAlbumArtist,
|
||||
playlist: normalizePlaylist,
|
||||
song: normalizeSong,
|
||||
user: normalizeUser,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,294 +5,294 @@ const sortOrderValues = ['ASC', 'DESC'] as const;
|
|||
const error = z.string();
|
||||
|
||||
const paginationParameters = z.object({
|
||||
_end: z.number().optional(),
|
||||
_order: z.enum(sortOrderValues),
|
||||
_start: z.number().optional(),
|
||||
_end: z.number().optional(),
|
||||
_order: z.enum(sortOrderValues),
|
||||
_start: z.number().optional(),
|
||||
});
|
||||
|
||||
const authenticate = z.object({
|
||||
id: z.string(),
|
||||
isAdmin: z.boolean(),
|
||||
name: z.string(),
|
||||
subsonicSalt: z.string(),
|
||||
subsonicToken: z.string(),
|
||||
token: z.string(),
|
||||
username: z.string(),
|
||||
id: z.string(),
|
||||
isAdmin: z.boolean(),
|
||||
name: z.string(),
|
||||
subsonicSalt: z.string(),
|
||||
subsonicToken: z.string(),
|
||||
token: z.string(),
|
||||
username: z.string(),
|
||||
});
|
||||
|
||||
const authenticateParameters = z.object({
|
||||
password: z.string(),
|
||||
username: z.string(),
|
||||
password: z.string(),
|
||||
username: z.string(),
|
||||
});
|
||||
|
||||
const user = z.object({
|
||||
createdAt: z.string(),
|
||||
email: z.string().optional(),
|
||||
id: z.string(),
|
||||
isAdmin: z.boolean(),
|
||||
lastAccessAt: z.string(),
|
||||
lastLoginAt: z.string(),
|
||||
name: z.string(),
|
||||
updatedAt: z.string(),
|
||||
userName: z.string(),
|
||||
createdAt: z.string(),
|
||||
email: z.string().optional(),
|
||||
id: z.string(),
|
||||
isAdmin: z.boolean(),
|
||||
lastAccessAt: z.string(),
|
||||
lastLoginAt: z.string(),
|
||||
name: z.string(),
|
||||
updatedAt: z.string(),
|
||||
userName: z.string(),
|
||||
});
|
||||
|
||||
const userList = z.array(user);
|
||||
|
||||
const ndUserListSort = {
|
||||
NAME: 'name',
|
||||
NAME: 'name',
|
||||
} as const;
|
||||
|
||||
const userListParameters = paginationParameters.extend({
|
||||
_sort: z.nativeEnum(ndUserListSort).optional(),
|
||||
_sort: z.nativeEnum(ndUserListSort).optional(),
|
||||
});
|
||||
|
||||
const genre = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
const genreList = z.array(genre);
|
||||
|
||||
const albumArtist = z.object({
|
||||
albumCount: z.number(),
|
||||
biography: z.string(),
|
||||
externalInfoUpdatedAt: z.string(),
|
||||
externalUrl: z.string(),
|
||||
fullText: z.string(),
|
||||
genres: z.array(genre),
|
||||
id: z.string(),
|
||||
largeImageUrl: z.string().optional(),
|
||||
mbzArtistId: z.string().optional(),
|
||||
mediumImageUrl: z.string().optional(),
|
||||
name: z.string(),
|
||||
orderArtistName: z.string(),
|
||||
playCount: z.number(),
|
||||
playDate: z.string(),
|
||||
rating: z.number(),
|
||||
size: z.number(),
|
||||
smallImageUrl: z.string().optional(),
|
||||
songCount: z.number(),
|
||||
starred: z.boolean(),
|
||||
starredAt: z.string(),
|
||||
albumCount: z.number(),
|
||||
biography: z.string(),
|
||||
externalInfoUpdatedAt: z.string(),
|
||||
externalUrl: z.string(),
|
||||
fullText: z.string(),
|
||||
genres: z.array(genre),
|
||||
id: z.string(),
|
||||
largeImageUrl: z.string().optional(),
|
||||
mbzArtistId: z.string().optional(),
|
||||
mediumImageUrl: z.string().optional(),
|
||||
name: z.string(),
|
||||
orderArtistName: z.string(),
|
||||
playCount: z.number(),
|
||||
playDate: z.string(),
|
||||
rating: z.number(),
|
||||
size: z.number(),
|
||||
smallImageUrl: z.string().optional(),
|
||||
songCount: z.number(),
|
||||
starred: z.boolean(),
|
||||
starredAt: z.string(),
|
||||
});
|
||||
|
||||
const albumArtistList = z.array(albumArtist);
|
||||
|
||||
const ndAlbumArtistListSort = {
|
||||
ALBUM_COUNT: 'albumCount',
|
||||
FAVORITED: 'starred ASC, starredAt ASC',
|
||||
NAME: 'name',
|
||||
PLAY_COUNT: 'playCount',
|
||||
RATING: 'rating',
|
||||
SONG_COUNT: 'songCount',
|
||||
ALBUM_COUNT: 'albumCount',
|
||||
FAVORITED: 'starred ASC, starredAt ASC',
|
||||
NAME: 'name',
|
||||
PLAY_COUNT: 'playCount',
|
||||
RATING: 'rating',
|
||||
SONG_COUNT: 'songCount',
|
||||
} as const;
|
||||
|
||||
const albumArtistListParameters = paginationParameters.extend({
|
||||
_sort: z.nativeEnum(ndAlbumArtistListSort).optional(),
|
||||
genre_id: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
starred: z.boolean().optional(),
|
||||
_sort: z.nativeEnum(ndAlbumArtistListSort).optional(),
|
||||
genre_id: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
starred: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const album = z.object({
|
||||
albumArtist: z.string(),
|
||||
albumArtistId: z.string(),
|
||||
allArtistIds: z.string(),
|
||||
artist: z.string(),
|
||||
artistId: z.string(),
|
||||
compilation: z.boolean(),
|
||||
coverArtId: z.string().optional(), // Removed after v0.48.0
|
||||
coverArtPath: z.string().optional(), // Removed after v0.48.0
|
||||
createdAt: z.string(),
|
||||
duration: z.number(),
|
||||
fullText: z.string(),
|
||||
genre: z.string(),
|
||||
genres: z.array(genre),
|
||||
id: z.string(),
|
||||
maxYear: z.number(),
|
||||
mbzAlbumArtistId: z.string().optional(),
|
||||
mbzAlbumId: z.string().optional(),
|
||||
minYear: z.number(),
|
||||
name: z.string(),
|
||||
orderAlbumArtistName: z.string(),
|
||||
orderAlbumName: z.string(),
|
||||
playCount: z.number(),
|
||||
playDate: z.string(),
|
||||
rating: z.number().optional(),
|
||||
size: z.number(),
|
||||
songCount: z.number(),
|
||||
sortAlbumArtistName: z.string(),
|
||||
sortArtistName: z.string(),
|
||||
starred: z.boolean(),
|
||||
starredAt: z.string().optional(),
|
||||
updatedAt: z.string(),
|
||||
albumArtist: z.string(),
|
||||
albumArtistId: z.string(),
|
||||
allArtistIds: z.string(),
|
||||
artist: z.string(),
|
||||
artistId: z.string(),
|
||||
compilation: z.boolean(),
|
||||
coverArtId: z.string().optional(), // Removed after v0.48.0
|
||||
coverArtPath: z.string().optional(), // Removed after v0.48.0
|
||||
createdAt: z.string(),
|
||||
duration: z.number(),
|
||||
fullText: z.string(),
|
||||
genre: z.string(),
|
||||
genres: z.array(genre),
|
||||
id: z.string(),
|
||||
maxYear: z.number(),
|
||||
mbzAlbumArtistId: z.string().optional(),
|
||||
mbzAlbumId: z.string().optional(),
|
||||
minYear: z.number(),
|
||||
name: z.string(),
|
||||
orderAlbumArtistName: z.string(),
|
||||
orderAlbumName: z.string(),
|
||||
playCount: z.number(),
|
||||
playDate: z.string(),
|
||||
rating: z.number().optional(),
|
||||
size: z.number(),
|
||||
songCount: z.number(),
|
||||
sortAlbumArtistName: z.string(),
|
||||
sortArtistName: z.string(),
|
||||
starred: z.boolean(),
|
||||
starredAt: z.string().optional(),
|
||||
updatedAt: z.string(),
|
||||
});
|
||||
|
||||
const albumList = z.array(album);
|
||||
|
||||
const ndAlbumListSort = {
|
||||
ALBUM_ARTIST: 'albumArtist',
|
||||
ARTIST: 'artist',
|
||||
DURATION: 'duration',
|
||||
NAME: 'name',
|
||||
PLAY_COUNT: 'playCount',
|
||||
PLAY_DATE: 'play_date',
|
||||
RANDOM: 'random',
|
||||
RATING: 'rating',
|
||||
RECENTLY_ADDED: 'recently_added',
|
||||
SONG_COUNT: 'songCount',
|
||||
STARRED: 'starred',
|
||||
YEAR: 'max_year',
|
||||
ALBUM_ARTIST: 'albumArtist',
|
||||
ARTIST: 'artist',
|
||||
DURATION: 'duration',
|
||||
NAME: 'name',
|
||||
PLAY_COUNT: 'playCount',
|
||||
PLAY_DATE: 'play_date',
|
||||
RANDOM: 'random',
|
||||
RATING: 'rating',
|
||||
RECENTLY_ADDED: 'recently_added',
|
||||
SONG_COUNT: 'songCount',
|
||||
STARRED: 'starred',
|
||||
YEAR: 'max_year',
|
||||
} as const;
|
||||
|
||||
const albumListParameters = paginationParameters.extend({
|
||||
_sort: z.nativeEnum(ndAlbumListSort).optional(),
|
||||
album_id: z.string().optional(),
|
||||
artist_id: z.string().optional(),
|
||||
compilation: z.boolean().optional(),
|
||||
genre_id: z.string().optional(),
|
||||
has_rating: z.boolean().optional(),
|
||||
id: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
recently_added: z.boolean().optional(),
|
||||
recently_played: z.boolean().optional(),
|
||||
starred: z.boolean().optional(),
|
||||
year: z.number().optional(),
|
||||
_sort: z.nativeEnum(ndAlbumListSort).optional(),
|
||||
album_id: z.string().optional(),
|
||||
artist_id: z.string().optional(),
|
||||
compilation: z.boolean().optional(),
|
||||
genre_id: z.string().optional(),
|
||||
has_rating: z.boolean().optional(),
|
||||
id: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
recently_added: z.boolean().optional(),
|
||||
recently_played: z.boolean().optional(),
|
||||
starred: z.boolean().optional(),
|
||||
year: z.number().optional(),
|
||||
});
|
||||
|
||||
const song = z.object({
|
||||
album: z.string(),
|
||||
albumArtist: z.string(),
|
||||
albumArtistId: z.string(),
|
||||
albumId: z.string(),
|
||||
artist: z.string(),
|
||||
artistId: z.string(),
|
||||
bitRate: z.number(),
|
||||
bookmarkPosition: z.number(),
|
||||
bpm: z.number().optional(),
|
||||
channels: z.number().optional(),
|
||||
comment: z.string().optional(),
|
||||
compilation: z.boolean(),
|
||||
createdAt: z.string(),
|
||||
discNumber: z.number(),
|
||||
duration: z.number(),
|
||||
fullText: z.string(),
|
||||
genre: z.string(),
|
||||
genres: z.array(genre),
|
||||
hasCoverArt: z.boolean(),
|
||||
id: z.string(),
|
||||
lyrics: z.string().optional(),
|
||||
mbzAlbumArtistId: z.string().optional(),
|
||||
mbzAlbumId: z.string().optional(),
|
||||
mbzArtistId: z.string().optional(),
|
||||
mbzTrackId: z.string().optional(),
|
||||
orderAlbumArtistName: z.string(),
|
||||
orderAlbumName: z.string(),
|
||||
orderArtistName: z.string(),
|
||||
orderTitle: z.string(),
|
||||
path: z.string(),
|
||||
playCount: z.number(),
|
||||
playDate: z.string(),
|
||||
rating: z.number().optional(),
|
||||
size: z.number(),
|
||||
sortAlbumArtistName: z.string(),
|
||||
sortArtistName: z.string(),
|
||||
starred: z.boolean(),
|
||||
starredAt: z.string().optional(),
|
||||
suffix: z.string(),
|
||||
title: z.string(),
|
||||
trackNumber: z.number(),
|
||||
updatedAt: z.string(),
|
||||
year: z.number(),
|
||||
album: z.string(),
|
||||
albumArtist: z.string(),
|
||||
albumArtistId: z.string(),
|
||||
albumId: z.string(),
|
||||
artist: z.string(),
|
||||
artistId: z.string(),
|
||||
bitRate: z.number(),
|
||||
bookmarkPosition: z.number(),
|
||||
bpm: z.number().optional(),
|
||||
channels: z.number().optional(),
|
||||
comment: z.string().optional(),
|
||||
compilation: z.boolean(),
|
||||
createdAt: z.string(),
|
||||
discNumber: z.number(),
|
||||
duration: z.number(),
|
||||
fullText: z.string(),
|
||||
genre: z.string(),
|
||||
genres: z.array(genre),
|
||||
hasCoverArt: z.boolean(),
|
||||
id: z.string(),
|
||||
lyrics: z.string().optional(),
|
||||
mbzAlbumArtistId: z.string().optional(),
|
||||
mbzAlbumId: z.string().optional(),
|
||||
mbzArtistId: z.string().optional(),
|
||||
mbzTrackId: z.string().optional(),
|
||||
orderAlbumArtistName: z.string(),
|
||||
orderAlbumName: z.string(),
|
||||
orderArtistName: z.string(),
|
||||
orderTitle: z.string(),
|
||||
path: z.string(),
|
||||
playCount: z.number(),
|
||||
playDate: z.string(),
|
||||
rating: z.number().optional(),
|
||||
size: z.number(),
|
||||
sortAlbumArtistName: z.string(),
|
||||
sortArtistName: z.string(),
|
||||
starred: z.boolean(),
|
||||
starredAt: z.string().optional(),
|
||||
suffix: z.string(),
|
||||
title: z.string(),
|
||||
trackNumber: z.number(),
|
||||
updatedAt: z.string(),
|
||||
year: z.number(),
|
||||
});
|
||||
|
||||
const songList = z.array(song);
|
||||
|
||||
const ndSongListSort = {
|
||||
ALBUM: 'album, order_album_artist_name, disc_number, track_number, title',
|
||||
ALBUM_ARTIST: 'order_album_artist_name, album, disc_number, track_number, title',
|
||||
ALBUM_SONGS: 'album, discNumber, trackNumber',
|
||||
ARTIST: 'artist',
|
||||
BPM: 'bpm',
|
||||
CHANNELS: 'channels',
|
||||
COMMENT: 'comment',
|
||||
DURATION: 'duration',
|
||||
FAVORITED: 'starred ASC, starredAt ASC',
|
||||
GENRE: 'genre',
|
||||
ID: 'id',
|
||||
PLAY_COUNT: 'playCount',
|
||||
PLAY_DATE: 'playDate',
|
||||
RATING: 'rating',
|
||||
RECENTLY_ADDED: 'createdAt',
|
||||
TITLE: 'title',
|
||||
TRACK: 'track',
|
||||
YEAR: 'year, album, discNumber, trackNumber',
|
||||
ALBUM: 'album, order_album_artist_name, disc_number, track_number, title',
|
||||
ALBUM_ARTIST: 'order_album_artist_name, album, disc_number, track_number, title',
|
||||
ALBUM_SONGS: 'album, discNumber, trackNumber',
|
||||
ARTIST: 'artist',
|
||||
BPM: 'bpm',
|
||||
CHANNELS: 'channels',
|
||||
COMMENT: 'comment',
|
||||
DURATION: 'duration',
|
||||
FAVORITED: 'starred ASC, starredAt ASC',
|
||||
GENRE: 'genre',
|
||||
ID: 'id',
|
||||
PLAY_COUNT: 'playCount',
|
||||
PLAY_DATE: 'playDate',
|
||||
RATING: 'rating',
|
||||
RECENTLY_ADDED: 'createdAt',
|
||||
TITLE: 'title',
|
||||
TRACK: 'track',
|
||||
YEAR: 'year, album, discNumber, trackNumber',
|
||||
};
|
||||
|
||||
const songListParameters = paginationParameters.extend({
|
||||
_sort: z.nativeEnum(ndSongListSort).optional(),
|
||||
album_artist_id: z.array(z.string()).optional(),
|
||||
album_id: z.array(z.string()).optional(),
|
||||
artist_id: z.array(z.string()).optional(),
|
||||
genre_id: z.string().optional(),
|
||||
starred: z.boolean().optional(),
|
||||
title: z.string().optional(),
|
||||
year: z.number().optional(),
|
||||
_sort: z.nativeEnum(ndSongListSort).optional(),
|
||||
album_artist_id: z.array(z.string()).optional(),
|
||||
album_id: z.array(z.string()).optional(),
|
||||
artist_id: z.array(z.string()).optional(),
|
||||
genre_id: z.string().optional(),
|
||||
starred: z.boolean().optional(),
|
||||
title: z.string().optional(),
|
||||
year: z.number().optional(),
|
||||
});
|
||||
|
||||
const playlist = z.object({
|
||||
comment: z.string(),
|
||||
createdAt: z.string(),
|
||||
duration: z.number(),
|
||||
evaluatedAt: z.string(),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
ownerId: z.string(),
|
||||
ownerName: z.string(),
|
||||
path: z.string(),
|
||||
public: z.boolean(),
|
||||
rules: z.record(z.string(), z.any()),
|
||||
size: z.number(),
|
||||
songCount: z.number(),
|
||||
sync: z.boolean(),
|
||||
updatedAt: z.string(),
|
||||
comment: z.string(),
|
||||
createdAt: z.string(),
|
||||
duration: z.number(),
|
||||
evaluatedAt: z.string(),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
ownerId: z.string(),
|
||||
ownerName: z.string(),
|
||||
path: z.string(),
|
||||
public: z.boolean(),
|
||||
rules: z.record(z.string(), z.any()),
|
||||
size: z.number(),
|
||||
songCount: z.number(),
|
||||
sync: z.boolean(),
|
||||
updatedAt: z.string(),
|
||||
});
|
||||
|
||||
const playlistList = z.array(playlist);
|
||||
|
||||
const ndPlaylistListSort = {
|
||||
DURATION: 'duration',
|
||||
NAME: 'name',
|
||||
OWNER: 'ownerName',
|
||||
PUBLIC: 'public',
|
||||
SONG_COUNT: 'songCount',
|
||||
UPDATED_AT: 'updatedAt',
|
||||
DURATION: 'duration',
|
||||
NAME: 'name',
|
||||
OWNER: 'ownerName',
|
||||
PUBLIC: 'public',
|
||||
SONG_COUNT: 'songCount',
|
||||
UPDATED_AT: 'updatedAt',
|
||||
} as const;
|
||||
|
||||
const playlistListParameters = paginationParameters.extend({
|
||||
_sort: z.nativeEnum(ndPlaylistListSort).optional(),
|
||||
owner_id: z.string().optional(),
|
||||
smart: z.boolean().optional(),
|
||||
_sort: z.nativeEnum(ndPlaylistListSort).optional(),
|
||||
owner_id: z.string().optional(),
|
||||
smart: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const playlistSong = song.extend({
|
||||
mediaFileId: z.string(),
|
||||
playlistId: z.string(),
|
||||
mediaFileId: z.string(),
|
||||
playlistId: z.string(),
|
||||
});
|
||||
|
||||
const playlistSongList = z.array(playlistSong);
|
||||
|
||||
const createPlaylist = playlist.pick({
|
||||
id: true,
|
||||
id: true,
|
||||
});
|
||||
|
||||
const createPlaylistParameters = z.object({
|
||||
comment: z.string().optional(),
|
||||
name: z.string(),
|
||||
public: z.boolean().optional(),
|
||||
rules: z.record(z.any()).optional(),
|
||||
sync: z.boolean().optional(),
|
||||
comment: z.string().optional(),
|
||||
name: z.string(),
|
||||
public: z.boolean().optional(),
|
||||
rules: z.record(z.any()).optional(),
|
||||
sync: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const updatePlaylist = playlist;
|
||||
|
|
@ -302,62 +302,62 @@ const updatePlaylistParameters = createPlaylistParameters.partial();
|
|||
const deletePlaylist = z.null();
|
||||
|
||||
const addToPlaylist = z.object({
|
||||
added: z.number(),
|
||||
added: z.number(),
|
||||
});
|
||||
|
||||
const addToPlaylistParameters = z.object({
|
||||
ids: z.array(z.string()),
|
||||
ids: z.array(z.string()),
|
||||
});
|
||||
|
||||
const removeFromPlaylist = z.object({
|
||||
ids: z.array(z.string()),
|
||||
ids: z.array(z.string()),
|
||||
});
|
||||
|
||||
const removeFromPlaylistParameters = z.object({
|
||||
id: z.array(z.string()),
|
||||
id: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const ndType = {
|
||||
_enum: {
|
||||
albumArtistList: ndAlbumArtistListSort,
|
||||
albumList: ndAlbumListSort,
|
||||
playlistList: ndPlaylistListSort,
|
||||
songList: ndSongListSort,
|
||||
userList: ndUserListSort,
|
||||
},
|
||||
_parameters: {
|
||||
addToPlaylist: addToPlaylistParameters,
|
||||
albumArtistList: albumArtistListParameters,
|
||||
albumList: albumListParameters,
|
||||
authenticate: authenticateParameters,
|
||||
createPlaylist: createPlaylistParameters,
|
||||
playlistList: playlistListParameters,
|
||||
removeFromPlaylist: removeFromPlaylistParameters,
|
||||
songList: songListParameters,
|
||||
updatePlaylist: updatePlaylistParameters,
|
||||
userList: userListParameters,
|
||||
},
|
||||
_response: {
|
||||
addToPlaylist,
|
||||
album,
|
||||
albumArtist,
|
||||
albumArtistList,
|
||||
albumList,
|
||||
authenticate,
|
||||
createPlaylist,
|
||||
deletePlaylist,
|
||||
error,
|
||||
genre,
|
||||
genreList,
|
||||
playlist,
|
||||
playlistList,
|
||||
playlistSong,
|
||||
playlistSongList,
|
||||
removeFromPlaylist,
|
||||
song,
|
||||
songList,
|
||||
updatePlaylist,
|
||||
user,
|
||||
userList,
|
||||
},
|
||||
_enum: {
|
||||
albumArtistList: ndAlbumArtistListSort,
|
||||
albumList: ndAlbumListSort,
|
||||
playlistList: ndPlaylistListSort,
|
||||
songList: ndSongListSort,
|
||||
userList: ndUserListSort,
|
||||
},
|
||||
_parameters: {
|
||||
addToPlaylist: addToPlaylistParameters,
|
||||
albumArtistList: albumArtistListParameters,
|
||||
albumList: albumListParameters,
|
||||
authenticate: authenticateParameters,
|
||||
createPlaylist: createPlaylistParameters,
|
||||
playlistList: playlistListParameters,
|
||||
removeFromPlaylist: removeFromPlaylistParameters,
|
||||
songList: songListParameters,
|
||||
updatePlaylist: updatePlaylistParameters,
|
||||
userList: userListParameters,
|
||||
},
|
||||
_response: {
|
||||
addToPlaylist,
|
||||
album,
|
||||
albumArtist,
|
||||
albumArtistList,
|
||||
albumList,
|
||||
authenticate,
|
||||
createPlaylist,
|
||||
deletePlaylist,
|
||||
error,
|
||||
genre,
|
||||
genreList,
|
||||
playlist,
|
||||
playlistList,
|
||||
playlistSong,
|
||||
playlistSongList,
|
||||
removeFromPlaylist,
|
||||
song,
|
||||
songList,
|
||||
updatePlaylist,
|
||||
user,
|
||||
userList,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,132 +1,132 @@
|
|||
import { QueryFunctionContext } from '@tanstack/react-query';
|
||||
import { LyricSource } from './types';
|
||||
import type {
|
||||
AlbumListQuery,
|
||||
SongListQuery,
|
||||
AlbumDetailQuery,
|
||||
AlbumArtistListQuery,
|
||||
ArtistListQuery,
|
||||
PlaylistListQuery,
|
||||
PlaylistDetailQuery,
|
||||
PlaylistSongListQuery,
|
||||
UserListQuery,
|
||||
AlbumArtistDetailQuery,
|
||||
TopSongListQuery,
|
||||
SearchQuery,
|
||||
SongDetailQuery,
|
||||
RandomSongListQuery,
|
||||
LyricsQuery,
|
||||
LyricSearchQuery,
|
||||
AlbumListQuery,
|
||||
SongListQuery,
|
||||
AlbumDetailQuery,
|
||||
AlbumArtistListQuery,
|
||||
ArtistListQuery,
|
||||
PlaylistListQuery,
|
||||
PlaylistDetailQuery,
|
||||
PlaylistSongListQuery,
|
||||
UserListQuery,
|
||||
AlbumArtistDetailQuery,
|
||||
TopSongListQuery,
|
||||
SearchQuery,
|
||||
SongDetailQuery,
|
||||
RandomSongListQuery,
|
||||
LyricsQuery,
|
||||
LyricSearchQuery,
|
||||
} from './types';
|
||||
|
||||
export const queryKeys: Record<
|
||||
string,
|
||||
Record<string, (...props: any) => QueryFunctionContext['queryKey']>
|
||||
string,
|
||||
Record<string, (...props: any) => QueryFunctionContext['queryKey']>
|
||||
> = {
|
||||
albumArtists: {
|
||||
detail: (serverId: string, query?: AlbumArtistDetailQuery) => {
|
||||
if (query) return [serverId, 'albumArtists', 'detail', query] as const;
|
||||
return [serverId, 'albumArtists', 'detail'] as const;
|
||||
albumArtists: {
|
||||
detail: (serverId: string, query?: AlbumArtistDetailQuery) => {
|
||||
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,
|
||||
topSongs: (serverId: string, query?: TopSongListQuery) => {
|
||||
if (query) return [serverId, 'albumArtists', 'topSongs', query] as const;
|
||||
return [serverId, 'albumArtists', 'topSongs'] as const;
|
||||
},
|
||||
},
|
||||
list: (serverId: string, query?: AlbumArtistListQuery) => {
|
||||
if (query) return [serverId, 'albumArtists', 'list', query] as const;
|
||||
return [serverId, 'albumArtists', 'list'] as const;
|
||||
albums: {
|
||||
detail: (serverId: string, query?: AlbumDetailQuery) =>
|
||||
[serverId, 'albums', 'detail', query] as const,
|
||||
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,
|
||||
},
|
||||
root: (serverId: string) => [serverId, 'albumArtists'] as const,
|
||||
topSongs: (serverId: string, query?: TopSongListQuery) => {
|
||||
if (query) return [serverId, 'albumArtists', 'topSongs', query] as const;
|
||||
return [serverId, 'albumArtists', 'topSongs'] 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,
|
||||
},
|
||||
},
|
||||
albums: {
|
||||
detail: (serverId: string, query?: AlbumDetailQuery) =>
|
||||
[serverId, 'albums', 'detail', query] as const,
|
||||
list: (serverId: string, query?: AlbumListQuery) => {
|
||||
if (query) return [serverId, 'albums', 'list', query] as const;
|
||||
return [serverId, 'albums', 'list'] as const;
|
||||
genres: {
|
||||
list: (serverId: string) => [serverId, 'genres', 'list'] as const,
|
||||
root: (serverId: string) => [serverId, 'genres'] 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;
|
||||
musicFolders: {
|
||||
list: (serverId: string) => [serverId, 'musicFolders', '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,
|
||||
},
|
||||
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;
|
||||
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 && id) return [serverId, 'playlists', id, 'songList', query] as const;
|
||||
if (id) return [serverId, 'playlists', id, 'songList'] as const;
|
||||
return [serverId, 'playlists', 'songList'] 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;
|
||||
search: {
|
||||
list: (serverId: string, query?: SearchQuery) => {
|
||||
if (query) return [serverId, 'search', 'list', query] as const;
|
||||
return [serverId, 'search', 'list'] as const;
|
||||
},
|
||||
root: (serverId: string) => [serverId, 'search'] as const,
|
||||
},
|
||||
list: (serverId: string, query?: PlaylistListQuery) => {
|
||||
if (query) return [serverId, 'playlists', 'list', query] as const;
|
||||
return [serverId, 'playlists', 'list'] as const;
|
||||
server: {
|
||||
root: (serverId: string) => [serverId] as const,
|
||||
},
|
||||
root: (serverId: string) => [serverId, 'playlists'] as const,
|
||||
songList: (serverId: string, id?: string, query?: PlaylistSongListQuery) => {
|
||||
if (query && id) return [serverId, 'playlists', id, 'songList', query] as const;
|
||||
if (id) return [serverId, 'playlists', id, 'songList'] as const;
|
||||
return [serverId, 'playlists', 'songList'] as const;
|
||||
songs: {
|
||||
detail: (serverId: string, query?: SongDetailQuery) => {
|
||||
if (query) return [serverId, 'songs', 'detail', query] as const;
|
||||
return [serverId, 'songs', 'detail'] as const;
|
||||
},
|
||||
list: (serverId: string, query?: SongListQuery) => {
|
||||
if (query) return [serverId, 'songs', 'list', query] as const;
|
||||
return [serverId, 'songs', 'list'] as const;
|
||||
},
|
||||
lyrics: (serverId: string, query?: LyricsQuery) => {
|
||||
if (query) return [serverId, 'song', 'lyrics', query] as const;
|
||||
return [serverId, 'song', 'lyrics'] as const;
|
||||
},
|
||||
lyricsByRemoteId: (searchQuery: { remoteSongId: string; remoteSource: LyricSource }) => {
|
||||
return ['song', 'lyrics', 'remote', searchQuery] as const;
|
||||
},
|
||||
lyricsSearch: (query?: LyricSearchQuery) => {
|
||||
if (query) return ['lyrics', 'search', query] as const;
|
||||
return ['lyrics', 'search'] as const;
|
||||
},
|
||||
randomSongList: (serverId: string, query?: RandomSongListQuery) => {
|
||||
if (query) return [serverId, 'songs', 'randomSongList', query] as const;
|
||||
return [serverId, 'songs', 'randomSongList'] as const;
|
||||
},
|
||||
root: (serverId: string) => [serverId, 'songs'] as const,
|
||||
},
|
||||
},
|
||||
search: {
|
||||
list: (serverId: string, query?: SearchQuery) => {
|
||||
if (query) return [serverId, 'search', 'list', query] as const;
|
||||
return [serverId, 'search', 'list'] as const;
|
||||
users: {
|
||||
list: (serverId: string, query?: UserListQuery) => {
|
||||
if (query) return [serverId, 'users', 'list', query] as const;
|
||||
return [serverId, 'users', 'list'] as const;
|
||||
},
|
||||
root: (serverId: string) => [serverId, 'users'] as const,
|
||||
},
|
||||
root: (serverId: string) => [serverId, 'search'] as const,
|
||||
},
|
||||
server: {
|
||||
root: (serverId: string) => [serverId] as const,
|
||||
},
|
||||
songs: {
|
||||
detail: (serverId: string, query?: SongDetailQuery) => {
|
||||
if (query) return [serverId, 'songs', 'detail', query] as const;
|
||||
return [serverId, 'songs', 'detail'] as const;
|
||||
},
|
||||
list: (serverId: string, query?: SongListQuery) => {
|
||||
if (query) return [serverId, 'songs', 'list', query] as const;
|
||||
return [serverId, 'songs', 'list'] as const;
|
||||
},
|
||||
lyrics: (serverId: string, query?: LyricsQuery) => {
|
||||
if (query) return [serverId, 'song', 'lyrics', query] as const;
|
||||
return [serverId, 'song', 'lyrics'] as const;
|
||||
},
|
||||
lyricsByRemoteId: (searchQuery: { remoteSongId: string; remoteSource: LyricSource }) => {
|
||||
return ['song', 'lyrics', 'remote', searchQuery] as const;
|
||||
},
|
||||
lyricsSearch: (query?: LyricSearchQuery) => {
|
||||
if (query) return ['lyrics', 'search', query] as const;
|
||||
return ['lyrics', 'search'] as const;
|
||||
},
|
||||
randomSongList: (serverId: string, query?: RandomSongListQuery) => {
|
||||
if (query) return [serverId, 'songs', 'randomSongList', query] as const;
|
||||
return [serverId, 'songs', 'randomSongList'] as const;
|
||||
},
|
||||
root: (serverId: string) => [serverId, 'songs'] as const,
|
||||
},
|
||||
users: {
|
||||
list: (serverId: string, query?: UserListQuery) => {
|
||||
if (query) return [serverId, 'users', 'list', query] as const;
|
||||
return [serverId, 'users', 'list'] as const;
|
||||
},
|
||||
root: (serverId: string) => [serverId, 'users'] as const,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,190 +1,190 @@
|
|||
export type SSBaseResponse = {
|
||||
serverVersion?: 'string';
|
||||
status: 'string';
|
||||
type?: 'string';
|
||||
version: 'string';
|
||||
serverVersion?: 'string';
|
||||
status: 'string';
|
||||
type?: 'string';
|
||||
version: 'string';
|
||||
};
|
||||
|
||||
export type SSMusicFolderList = SSMusicFolder[];
|
||||
|
||||
export type SSMusicFolderListResponse = {
|
||||
musicFolders: {
|
||||
musicFolder: SSMusicFolder[];
|
||||
};
|
||||
musicFolders: {
|
||||
musicFolder: SSMusicFolder[];
|
||||
};
|
||||
};
|
||||
|
||||
export type SSGenreList = SSGenre[];
|
||||
|
||||
export type SSGenreListResponse = {
|
||||
genres: {
|
||||
genre: SSGenre[];
|
||||
};
|
||||
genres: {
|
||||
genre: SSGenre[];
|
||||
};
|
||||
};
|
||||
|
||||
export type SSAlbumArtistDetailParams = {
|
||||
id: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type SSAlbumArtistDetail = SSAlbumArtistListEntry & { album: SSAlbumListEntry[] };
|
||||
|
||||
export type SSAlbumArtistDetailResponse = {
|
||||
artist: SSAlbumArtistListEntry & {
|
||||
album: SSAlbumListEntry[];
|
||||
};
|
||||
artist: SSAlbumArtistListEntry & {
|
||||
album: SSAlbumListEntry[];
|
||||
};
|
||||
};
|
||||
|
||||
export type SSAlbumArtistList = {
|
||||
items: SSAlbumArtistListEntry[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number | null;
|
||||
items: SSAlbumArtistListEntry[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number | null;
|
||||
};
|
||||
|
||||
export type SSAlbumArtistListResponse = {
|
||||
artists: {
|
||||
ignoredArticles: string;
|
||||
index: SSArtistIndex[];
|
||||
lastModified: number;
|
||||
};
|
||||
artists: {
|
||||
ignoredArticles: string;
|
||||
index: SSArtistIndex[];
|
||||
lastModified: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type SSAlbumList = {
|
||||
items: SSAlbumListEntry[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number | null;
|
||||
items: SSAlbumListEntry[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number | null;
|
||||
};
|
||||
|
||||
export type SSAlbumListResponse = {
|
||||
albumList2: {
|
||||
album: SSAlbumListEntry[];
|
||||
};
|
||||
albumList2: {
|
||||
album: SSAlbumListEntry[];
|
||||
};
|
||||
};
|
||||
|
||||
export type SSAlbumDetail = Omit<SSAlbum, 'song'> & { songs: SSSong[] };
|
||||
|
||||
export type SSAlbumDetailResponse = {
|
||||
album: SSAlbum;
|
||||
album: SSAlbum;
|
||||
};
|
||||
|
||||
export type SSArtistInfoParams = {
|
||||
count?: number;
|
||||
id: string;
|
||||
includeNotPresent?: boolean;
|
||||
count?: number;
|
||||
id: string;
|
||||
includeNotPresent?: boolean;
|
||||
};
|
||||
|
||||
export type SSArtistInfoResponse = {
|
||||
artistInfo2: SSArtistInfo;
|
||||
artistInfo2: SSArtistInfo;
|
||||
};
|
||||
|
||||
export type SSArtistInfo = {
|
||||
biography: string;
|
||||
largeImageUrl?: string;
|
||||
lastFmUrl?: string;
|
||||
mediumImageUrl?: string;
|
||||
musicBrainzId?: string;
|
||||
similarArtist?: {
|
||||
biography: string;
|
||||
largeImageUrl?: string;
|
||||
lastFmUrl?: string;
|
||||
mediumImageUrl?: string;
|
||||
musicBrainzId?: string;
|
||||
similarArtist?: {
|
||||
albumCount: string;
|
||||
artistImageUrl?: string;
|
||||
coverArt?: string;
|
||||
id: string;
|
||||
name: string;
|
||||
}[];
|
||||
smallImageUrl?: string;
|
||||
};
|
||||
|
||||
export type SSMusicFolder = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type SSGenre = {
|
||||
albumCount?: number;
|
||||
songCount?: number;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type SSArtistIndex = {
|
||||
artist: SSAlbumArtistListEntry[];
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type SSAlbumArtistListEntry = {
|
||||
albumCount: string;
|
||||
artistImageUrl?: string;
|
||||
coverArt?: string;
|
||||
id: string;
|
||||
name: string;
|
||||
}[];
|
||||
smallImageUrl?: string;
|
||||
};
|
||||
|
||||
export type SSMusicFolder = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type SSGenre = {
|
||||
albumCount?: number;
|
||||
songCount?: number;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type SSArtistIndex = {
|
||||
artist: SSAlbumArtistListEntry[];
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type SSAlbumArtistListEntry = {
|
||||
albumCount: string;
|
||||
artistImageUrl?: string;
|
||||
coverArt?: string;
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type SSAlbumListEntry = {
|
||||
album: string;
|
||||
artist: string;
|
||||
artistId: string;
|
||||
coverArt: string;
|
||||
created: string;
|
||||
duration: number;
|
||||
genre?: string;
|
||||
id: string;
|
||||
isDir: boolean;
|
||||
isVideo: boolean;
|
||||
name: string;
|
||||
parent: string;
|
||||
songCount: number;
|
||||
starred?: boolean;
|
||||
title: string;
|
||||
userRating?: number;
|
||||
year: number;
|
||||
album: string;
|
||||
artist: string;
|
||||
artistId: string;
|
||||
coverArt: string;
|
||||
created: string;
|
||||
duration: number;
|
||||
genre?: string;
|
||||
id: string;
|
||||
isDir: boolean;
|
||||
isVideo: boolean;
|
||||
name: string;
|
||||
parent: string;
|
||||
songCount: number;
|
||||
starred?: boolean;
|
||||
title: string;
|
||||
userRating?: number;
|
||||
year: number;
|
||||
};
|
||||
|
||||
export type SSAlbum = {
|
||||
song: SSSong[];
|
||||
song: SSSong[];
|
||||
} & SSAlbumListEntry;
|
||||
|
||||
export type SSSong = {
|
||||
album: string;
|
||||
albumId: string;
|
||||
artist: string;
|
||||
artistId?: string;
|
||||
bitRate: number;
|
||||
contentType: string;
|
||||
coverArt: string;
|
||||
created: string;
|
||||
discNumber?: number;
|
||||
duration: number;
|
||||
genre: string;
|
||||
id: string;
|
||||
isDir: boolean;
|
||||
isVideo: boolean;
|
||||
parent: string;
|
||||
path: string;
|
||||
playCount: number;
|
||||
size: number;
|
||||
starred?: boolean;
|
||||
suffix: string;
|
||||
title: string;
|
||||
track: number;
|
||||
type: string;
|
||||
userRating?: number;
|
||||
year: number;
|
||||
album: string;
|
||||
albumId: string;
|
||||
artist: string;
|
||||
artistId?: string;
|
||||
bitRate: number;
|
||||
contentType: string;
|
||||
coverArt: string;
|
||||
created: string;
|
||||
discNumber?: number;
|
||||
duration: number;
|
||||
genre: string;
|
||||
id: string;
|
||||
isDir: boolean;
|
||||
isVideo: boolean;
|
||||
parent: string;
|
||||
path: string;
|
||||
playCount: number;
|
||||
size: number;
|
||||
starred?: boolean;
|
||||
suffix: string;
|
||||
title: string;
|
||||
track: number;
|
||||
type: string;
|
||||
userRating?: number;
|
||||
year: number;
|
||||
};
|
||||
|
||||
export type SSAlbumListParams = {
|
||||
fromYear?: number;
|
||||
genre?: string;
|
||||
musicFolderId?: string;
|
||||
offset?: number;
|
||||
size?: number;
|
||||
toYear?: number;
|
||||
type: string;
|
||||
fromYear?: number;
|
||||
genre?: string;
|
||||
musicFolderId?: string;
|
||||
offset?: number;
|
||||
size?: number;
|
||||
toYear?: number;
|
||||
type: string;
|
||||
};
|
||||
|
||||
export type SSAlbumArtistListParams = {
|
||||
musicFolderId?: string;
|
||||
musicFolderId?: string;
|
||||
};
|
||||
|
||||
export type SSFavoriteParams = {
|
||||
albumId?: string;
|
||||
artistId?: string;
|
||||
id?: string;
|
||||
albumId?: string;
|
||||
artistId?: string;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export type SSFavorite = null;
|
||||
|
|
@ -192,8 +192,8 @@ export type SSFavorite = null;
|
|||
export type SSFavoriteResponse = null;
|
||||
|
||||
export type SSRatingParams = {
|
||||
id: string;
|
||||
rating: number;
|
||||
id: string;
|
||||
rating: number;
|
||||
};
|
||||
|
||||
export type SSRating = null;
|
||||
|
|
@ -201,24 +201,24 @@ export type SSRating = null;
|
|||
export type SSRatingResponse = null;
|
||||
|
||||
export type SSTopSongListParams = {
|
||||
artist: string;
|
||||
count?: number;
|
||||
artist: string;
|
||||
count?: number;
|
||||
};
|
||||
|
||||
export type SSTopSongListResponse = {
|
||||
topSongs: {
|
||||
song: SSSong[];
|
||||
};
|
||||
topSongs: {
|
||||
song: SSSong[];
|
||||
};
|
||||
};
|
||||
|
||||
export type SSTopSongList = {
|
||||
items: SSSong[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number | null;
|
||||
items: SSSong[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number | null;
|
||||
};
|
||||
|
||||
export type SSScrobbleParams = {
|
||||
id: string;
|
||||
submission?: boolean;
|
||||
time?: number;
|
||||
id: string;
|
||||
submission?: boolean;
|
||||
time?: number;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,196 +10,198 @@ import { toast } from '/@/renderer/components/toast/index';
|
|||
const c = initContract();
|
||||
|
||||
export const contract = c.router({
|
||||
authenticate: {
|
||||
method: 'GET',
|
||||
path: 'ping.view',
|
||||
query: ssType._parameters.authenticate,
|
||||
responses: {
|
||||
200: ssType._response.authenticate,
|
||||
authenticate: {
|
||||
method: 'GET',
|
||||
path: 'ping.view',
|
||||
query: ssType._parameters.authenticate,
|
||||
responses: {
|
||||
200: ssType._response.authenticate,
|
||||
},
|
||||
},
|
||||
},
|
||||
createFavorite: {
|
||||
method: 'GET',
|
||||
path: 'star.view',
|
||||
query: ssType._parameters.createFavorite,
|
||||
responses: {
|
||||
200: ssType._response.createFavorite,
|
||||
createFavorite: {
|
||||
method: 'GET',
|
||||
path: 'star.view',
|
||||
query: ssType._parameters.createFavorite,
|
||||
responses: {
|
||||
200: ssType._response.createFavorite,
|
||||
},
|
||||
},
|
||||
},
|
||||
getArtistInfo: {
|
||||
method: 'GET',
|
||||
path: 'getArtistInfo.view',
|
||||
query: ssType._parameters.artistInfo,
|
||||
responses: {
|
||||
200: ssType._response.artistInfo,
|
||||
getArtistInfo: {
|
||||
method: 'GET',
|
||||
path: 'getArtistInfo.view',
|
||||
query: ssType._parameters.artistInfo,
|
||||
responses: {
|
||||
200: ssType._response.artistInfo,
|
||||
},
|
||||
},
|
||||
},
|
||||
getMusicFolderList: {
|
||||
method: 'GET',
|
||||
path: 'getMusicFolders.view',
|
||||
responses: {
|
||||
200: ssType._response.musicFolderList,
|
||||
getMusicFolderList: {
|
||||
method: 'GET',
|
||||
path: 'getMusicFolders.view',
|
||||
responses: {
|
||||
200: ssType._response.musicFolderList,
|
||||
},
|
||||
},
|
||||
},
|
||||
getRandomSongList: {
|
||||
method: 'GET',
|
||||
path: 'getRandomSongs.view',
|
||||
query: ssType._parameters.randomSongList,
|
||||
responses: {
|
||||
200: ssType._response.randomSongList,
|
||||
getRandomSongList: {
|
||||
method: 'GET',
|
||||
path: 'getRandomSongs.view',
|
||||
query: ssType._parameters.randomSongList,
|
||||
responses: {
|
||||
200: ssType._response.randomSongList,
|
||||
},
|
||||
},
|
||||
},
|
||||
getTopSongsList: {
|
||||
method: 'GET',
|
||||
path: 'getTopSongs.view',
|
||||
query: ssType._parameters.topSongsList,
|
||||
responses: {
|
||||
200: ssType._response.topSongsList,
|
||||
getTopSongsList: {
|
||||
method: 'GET',
|
||||
path: 'getTopSongs.view',
|
||||
query: ssType._parameters.topSongsList,
|
||||
responses: {
|
||||
200: ssType._response.topSongsList,
|
||||
},
|
||||
},
|
||||
},
|
||||
removeFavorite: {
|
||||
method: 'GET',
|
||||
path: 'unstar.view',
|
||||
query: ssType._parameters.removeFavorite,
|
||||
responses: {
|
||||
200: ssType._response.removeFavorite,
|
||||
removeFavorite: {
|
||||
method: 'GET',
|
||||
path: 'unstar.view',
|
||||
query: ssType._parameters.removeFavorite,
|
||||
responses: {
|
||||
200: ssType._response.removeFavorite,
|
||||
},
|
||||
},
|
||||
},
|
||||
scrobble: {
|
||||
method: 'GET',
|
||||
path: 'scrobble.view',
|
||||
query: ssType._parameters.scrobble,
|
||||
responses: {
|
||||
200: ssType._response.scrobble,
|
||||
scrobble: {
|
||||
method: 'GET',
|
||||
path: 'scrobble.view',
|
||||
query: ssType._parameters.scrobble,
|
||||
responses: {
|
||||
200: ssType._response.scrobble,
|
||||
},
|
||||
},
|
||||
},
|
||||
search3: {
|
||||
method: 'GET',
|
||||
path: 'search3.view',
|
||||
query: ssType._parameters.search3,
|
||||
responses: {
|
||||
200: ssType._response.search3,
|
||||
search3: {
|
||||
method: 'GET',
|
||||
path: 'search3.view',
|
||||
query: ssType._parameters.search3,
|
||||
responses: {
|
||||
200: ssType._response.search3,
|
||||
},
|
||||
},
|
||||
},
|
||||
setRating: {
|
||||
method: 'GET',
|
||||
path: 'setRating.view',
|
||||
query: ssType._parameters.setRating,
|
||||
responses: {
|
||||
200: ssType._response.setRating,
|
||||
setRating: {
|
||||
method: 'GET',
|
||||
path: 'setRating.view',
|
||||
query: ssType._parameters.setRating,
|
||||
responses: {
|
||||
200: ssType._response.setRating,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const axiosClient = axios.create({});
|
||||
|
||||
axiosClient.defaults.paramsSerializer = (params) => {
|
||||
return qs.stringify(params, { arrayFormat: 'repeat' });
|
||||
return qs.stringify(params, { arrayFormat: 'repeat' });
|
||||
};
|
||||
|
||||
axiosClient.interceptors.response.use(
|
||||
(response) => {
|
||||
const data = response.data;
|
||||
(response) => {
|
||||
const data = response.data;
|
||||
|
||||
if (data['subsonic-response'].status !== 'ok') {
|
||||
// Suppress code related to non-linked lastfm or spotify from Navidrome
|
||||
if (data['subsonic-response'].error.code !== 0) {
|
||||
toast.error({
|
||||
message: data['subsonic-response'].error.message,
|
||||
title: 'Issue from Subsonic API',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (data['subsonic-response'].status !== 'ok') {
|
||||
// Suppress code related to non-linked lastfm or spotify from Navidrome
|
||||
if (data['subsonic-response'].error.code !== 0) {
|
||||
toast.error({
|
||||
message: data['subsonic-response'].error.message,
|
||||
title: 'Issue from Subsonic API',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
},
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
const parsePath = (fullPath: string) => {
|
||||
const [path, params] = fullPath.split('?');
|
||||
const [path, params] = fullPath.split('?');
|
||||
|
||||
const parsedParams = qs.parse(params);
|
||||
const notNilParams = omitBy(parsedParams, (value) => value === 'undefined' || value === 'null');
|
||||
const parsedParams = qs.parse(params);
|
||||
const notNilParams = omitBy(parsedParams, (value) => value === 'undefined' || value === 'null');
|
||||
|
||||
return {
|
||||
params: notNilParams,
|
||||
path,
|
||||
};
|
||||
return {
|
||||
params: notNilParams,
|
||||
path,
|
||||
};
|
||||
};
|
||||
|
||||
export const ssApiClient = (args: {
|
||||
server: ServerListItem | null;
|
||||
signal?: AbortSignal;
|
||||
url?: string;
|
||||
server: ServerListItem | null;
|
||||
signal?: AbortSignal;
|
||||
url?: string;
|
||||
}) => {
|
||||
const { server, url, signal } = args;
|
||||
const { server, url, signal } = args;
|
||||
|
||||
return initClient(contract, {
|
||||
api: async ({ path, method, headers, body }) => {
|
||||
let baseUrl: string | undefined;
|
||||
const authParams: Record<string, any> = {};
|
||||
return initClient(contract, {
|
||||
api: async ({ path, method, headers, body }) => {
|
||||
let baseUrl: string | undefined;
|
||||
const authParams: Record<string, any> = {};
|
||||
|
||||
const { params, path: api } = parsePath(path);
|
||||
const { params, path: api } = parsePath(path);
|
||||
|
||||
if (server) {
|
||||
baseUrl = `${server.url}/rest`;
|
||||
const token = server.credential;
|
||||
const params = token.split(/&?\w=/gm);
|
||||
if (server) {
|
||||
baseUrl = `${server.url}/rest`;
|
||||
const token = server.credential;
|
||||
const params = token.split(/&?\w=/gm);
|
||||
|
||||
authParams.u = server.username;
|
||||
if (params?.length === 4) {
|
||||
authParams.s = params[2];
|
||||
authParams.t = params[3];
|
||||
} else if (params?.length === 3) {
|
||||
authParams.p = params[2];
|
||||
}
|
||||
} else {
|
||||
baseUrl = url;
|
||||
}
|
||||
authParams.u = server.username;
|
||||
if (params?.length === 4) {
|
||||
authParams.s = params[2];
|
||||
authParams.t = params[3];
|
||||
} else if (params?.length === 3) {
|
||||
authParams.p = params[2];
|
||||
}
|
||||
} else {
|
||||
baseUrl = url;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await axiosClient.request<z.infer<typeof ssType._response.baseResponse>>({
|
||||
data: body,
|
||||
headers,
|
||||
method: method as Method,
|
||||
params: {
|
||||
c: 'Feishin',
|
||||
f: 'json',
|
||||
v: '1.13.0',
|
||||
...authParams,
|
||||
...params,
|
||||
},
|
||||
signal,
|
||||
url: `${baseUrl}/${api}`,
|
||||
});
|
||||
try {
|
||||
const result = await axiosClient.request<
|
||||
z.infer<typeof ssType._response.baseResponse>
|
||||
>({
|
||||
data: body,
|
||||
headers,
|
||||
method: method as Method,
|
||||
params: {
|
||||
c: 'Feishin',
|
||||
f: 'json',
|
||||
v: '1.13.0',
|
||||
...authParams,
|
||||
...params,
|
||||
},
|
||||
signal,
|
||||
url: `${baseUrl}/${api}`,
|
||||
});
|
||||
|
||||
return {
|
||||
body: result.data['subsonic-response'],
|
||||
headers: result.headers as any,
|
||||
status: result.status,
|
||||
};
|
||||
} catch (e: Error | AxiosError | any) {
|
||||
console.log('CATCH ERR');
|
||||
return {
|
||||
body: result.data['subsonic-response'],
|
||||
headers: result.headers as any,
|
||||
status: result.status,
|
||||
};
|
||||
} catch (e: Error | AxiosError | any) {
|
||||
console.log('CATCH ERR');
|
||||
|
||||
if (isAxiosError(e)) {
|
||||
const error = e as AxiosError;
|
||||
const response = error.response as AxiosResponse;
|
||||
if (isAxiosError(e)) {
|
||||
const error = e as AxiosError;
|
||||
const response = error.response as AxiosResponse;
|
||||
|
||||
return {
|
||||
body: response?.data,
|
||||
headers: response.headers as any,
|
||||
status: response?.status,
|
||||
};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
baseHeaders: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
baseUrl: '',
|
||||
});
|
||||
return {
|
||||
body: response?.data,
|
||||
headers: response.headers as any,
|
||||
status: response?.status,
|
||||
};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
baseHeaders: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
baseUrl: '',
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,91 +4,91 @@ import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
|
|||
import { ssNormalize } from '/@/renderer/api/subsonic/subsonic-normalize';
|
||||
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
|
||||
import {
|
||||
ArtistInfoArgs,
|
||||
AuthenticationResponse,
|
||||
FavoriteArgs,
|
||||
FavoriteResponse,
|
||||
LibraryItem,
|
||||
MusicFolderListArgs,
|
||||
MusicFolderListResponse,
|
||||
SetRatingArgs,
|
||||
RatingResponse,
|
||||
ScrobbleArgs,
|
||||
ScrobbleResponse,
|
||||
SongListResponse,
|
||||
TopSongListArgs,
|
||||
SearchArgs,
|
||||
SearchResponse,
|
||||
RandomSongListResponse,
|
||||
RandomSongListArgs,
|
||||
ArtistInfoArgs,
|
||||
AuthenticationResponse,
|
||||
FavoriteArgs,
|
||||
FavoriteResponse,
|
||||
LibraryItem,
|
||||
MusicFolderListArgs,
|
||||
MusicFolderListResponse,
|
||||
SetRatingArgs,
|
||||
RatingResponse,
|
||||
ScrobbleArgs,
|
||||
ScrobbleResponse,
|
||||
SongListResponse,
|
||||
TopSongListArgs,
|
||||
SearchArgs,
|
||||
SearchResponse,
|
||||
RandomSongListResponse,
|
||||
RandomSongListArgs,
|
||||
} from '/@/renderer/api/types';
|
||||
import { randomString } from '/@/renderer/utils';
|
||||
|
||||
const authenticate = async (
|
||||
url: string,
|
||||
body: {
|
||||
legacy?: boolean;
|
||||
password: string;
|
||||
username: string;
|
||||
},
|
||||
): Promise<AuthenticationResponse> => {
|
||||
let credential: string;
|
||||
let credentialParams: {
|
||||
p?: string;
|
||||
s?: string;
|
||||
t?: string;
|
||||
u: string;
|
||||
};
|
||||
|
||||
const cleanServerUrl = url.replace(/\/$/, '');
|
||||
|
||||
if (body.legacy) {
|
||||
credential = `u=${body.username}&p=${body.password}`;
|
||||
credentialParams = {
|
||||
p: body.password,
|
||||
u: body.username,
|
||||
};
|
||||
} else {
|
||||
const salt = randomString(12);
|
||||
const hash = md5(body.password + salt);
|
||||
credential = `u=${body.username}&s=${salt}&t=${hash}`;
|
||||
credentialParams = {
|
||||
s: salt,
|
||||
t: hash,
|
||||
u: body.username,
|
||||
};
|
||||
}
|
||||
|
||||
await ssApiClient({ server: null, url: cleanServerUrl }).authenticate({
|
||||
query: {
|
||||
c: 'Feishin',
|
||||
f: 'json',
|
||||
v: '1.13.0',
|
||||
...credentialParams,
|
||||
url: string,
|
||||
body: {
|
||||
legacy?: boolean;
|
||||
password: string;
|
||||
username: string;
|
||||
},
|
||||
});
|
||||
): Promise<AuthenticationResponse> => {
|
||||
let credential: string;
|
||||
let credentialParams: {
|
||||
p?: string;
|
||||
s?: string;
|
||||
t?: string;
|
||||
u: string;
|
||||
};
|
||||
|
||||
return {
|
||||
credential,
|
||||
userId: null,
|
||||
username: body.username,
|
||||
};
|
||||
const cleanServerUrl = url.replace(/\/$/, '');
|
||||
|
||||
if (body.legacy) {
|
||||
credential = `u=${body.username}&p=${body.password}`;
|
||||
credentialParams = {
|
||||
p: body.password,
|
||||
u: body.username,
|
||||
};
|
||||
} else {
|
||||
const salt = randomString(12);
|
||||
const hash = md5(body.password + salt);
|
||||
credential = `u=${body.username}&s=${salt}&t=${hash}`;
|
||||
credentialParams = {
|
||||
s: salt,
|
||||
t: hash,
|
||||
u: body.username,
|
||||
};
|
||||
}
|
||||
|
||||
await ssApiClient({ server: null, url: cleanServerUrl }).authenticate({
|
||||
query: {
|
||||
c: 'Feishin',
|
||||
f: 'json',
|
||||
v: '1.13.0',
|
||||
...credentialParams,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
credential,
|
||||
userId: null,
|
||||
username: body.username,
|
||||
};
|
||||
};
|
||||
|
||||
const getMusicFolderList = async (args: MusicFolderListArgs): Promise<MusicFolderListResponse> => {
|
||||
const { apiClientProps } = args;
|
||||
const { apiClientProps } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).getMusicFolderList({});
|
||||
const res = await ssApiClient(apiClientProps).getMusicFolderList({});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get music folder list');
|
||||
}
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get music folder list');
|
||||
}
|
||||
|
||||
return {
|
||||
items: res.body.musicFolders.musicFolder,
|
||||
startIndex: 0,
|
||||
totalRecordCount: res.body.musicFolders.musicFolder.length,
|
||||
};
|
||||
return {
|
||||
items: res.body.musicFolders.musicFolder,
|
||||
startIndex: 0,
|
||||
totalRecordCount: res.body.musicFolders.musicFolder.length,
|
||||
};
|
||||
};
|
||||
|
||||
// export const getAlbumArtistDetail = async (
|
||||
|
|
@ -198,184 +198,185 @@ const getMusicFolderList = async (args: MusicFolderListArgs): Promise<MusicFolde
|
|||
// };
|
||||
|
||||
const createFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).createFavorite({
|
||||
query: {
|
||||
albumId: query.type === LibraryItem.ALBUM ? query.id : undefined,
|
||||
artistId: query.type === LibraryItem.ALBUM_ARTIST ? query.id : undefined,
|
||||
id: query.type === LibraryItem.SONG ? query.id : undefined,
|
||||
},
|
||||
});
|
||||
const res = await ssApiClient(apiClientProps).createFavorite({
|
||||
query: {
|
||||
albumId: query.type === LibraryItem.ALBUM ? query.id : undefined,
|
||||
artistId: query.type === LibraryItem.ALBUM_ARTIST ? query.id : undefined,
|
||||
id: query.type === LibraryItem.SONG ? query.id : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to create favorite');
|
||||
}
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to create favorite');
|
||||
}
|
||||
|
||||
return null;
|
||||
return null;
|
||||
};
|
||||
|
||||
const removeFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).removeFavorite({
|
||||
query: {
|
||||
albumId: query.type === LibraryItem.ALBUM ? query.id : undefined,
|
||||
artistId: query.type === LibraryItem.ALBUM_ARTIST ? query.id : undefined,
|
||||
id: query.type === LibraryItem.SONG ? query.id : undefined,
|
||||
},
|
||||
});
|
||||
const res = await ssApiClient(apiClientProps).removeFavorite({
|
||||
query: {
|
||||
albumId: query.type === LibraryItem.ALBUM ? query.id : undefined,
|
||||
artistId: query.type === LibraryItem.ALBUM_ARTIST ? query.id : undefined,
|
||||
id: query.type === LibraryItem.SONG ? query.id : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to delete favorite');
|
||||
}
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to delete favorite');
|
||||
}
|
||||
|
||||
return null;
|
||||
return null;
|
||||
};
|
||||
|
||||
const setRating = async (args: SetRatingArgs): Promise<RatingResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const itemIds = query.item.map((item) => item.id);
|
||||
const itemIds = query.item.map((item) => item.id);
|
||||
|
||||
for (const id of itemIds) {
|
||||
await ssApiClient(apiClientProps).setRating({
|
||||
query: {
|
||||
id,
|
||||
rating: query.rating,
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const id of itemIds) {
|
||||
await ssApiClient(apiClientProps).setRating({
|
||||
query: {
|
||||
id,
|
||||
rating: query.rating,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
return null;
|
||||
};
|
||||
|
||||
const getTopSongList = async (args: TopSongListArgs): Promise<SongListResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).getTopSongsList({
|
||||
query: {
|
||||
artist: query.artist,
|
||||
count: query.limit,
|
||||
},
|
||||
});
|
||||
const res = await ssApiClient(apiClientProps).getTopSongsList({
|
||||
query: {
|
||||
artist: query.artist,
|
||||
count: query.limit,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get top songs');
|
||||
}
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get top songs');
|
||||
}
|
||||
|
||||
return {
|
||||
items:
|
||||
res.body.topSongs?.song?.map((song) => ssNormalize.song(song, apiClientProps.server, '')) ||
|
||||
[],
|
||||
startIndex: 0,
|
||||
totalRecordCount: res.body.topSongs?.song?.length || 0,
|
||||
};
|
||||
return {
|
||||
items:
|
||||
res.body.topSongs?.song?.map((song) =>
|
||||
ssNormalize.song(song, apiClientProps.server, ''),
|
||||
) || [],
|
||||
startIndex: 0,
|
||||
totalRecordCount: res.body.topSongs?.song?.length || 0,
|
||||
};
|
||||
};
|
||||
|
||||
const getArtistInfo = async (
|
||||
args: ArtistInfoArgs,
|
||||
args: ArtistInfoArgs,
|
||||
): Promise<z.infer<typeof ssType._response.artistInfo>> => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).getArtistInfo({
|
||||
query: {
|
||||
count: query.limit,
|
||||
id: query.artistId,
|
||||
},
|
||||
});
|
||||
const res = await ssApiClient(apiClientProps).getArtistInfo({
|
||||
query: {
|
||||
count: query.limit,
|
||||
id: query.artistId,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get artist info');
|
||||
}
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get artist info');
|
||||
}
|
||||
|
||||
return res.body;
|
||||
return res.body;
|
||||
};
|
||||
|
||||
const scrobble = async (args: ScrobbleArgs): Promise<ScrobbleResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).scrobble({
|
||||
query: {
|
||||
id: query.id,
|
||||
submission: query.submission,
|
||||
},
|
||||
});
|
||||
const res = await ssApiClient(apiClientProps).scrobble({
|
||||
query: {
|
||||
id: query.id,
|
||||
submission: query.submission,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to scrobble');
|
||||
}
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to scrobble');
|
||||
}
|
||||
|
||||
return null;
|
||||
return null;
|
||||
};
|
||||
|
||||
const search3 = async (args: SearchArgs): Promise<SearchResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).search3({
|
||||
query: {
|
||||
albumCount: query.albumLimit,
|
||||
albumOffset: query.albumStartIndex,
|
||||
artistCount: query.albumArtistLimit,
|
||||
artistOffset: query.albumArtistStartIndex,
|
||||
query: query.query,
|
||||
songCount: query.songLimit,
|
||||
songOffset: query.songStartIndex,
|
||||
},
|
||||
});
|
||||
const res = await ssApiClient(apiClientProps).search3({
|
||||
query: {
|
||||
albumCount: query.albumLimit,
|
||||
albumOffset: query.albumStartIndex,
|
||||
artistCount: query.albumArtistLimit,
|
||||
artistOffset: query.albumArtistStartIndex,
|
||||
query: query.query,
|
||||
songCount: query.songLimit,
|
||||
songOffset: query.songStartIndex,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to search');
|
||||
}
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to search');
|
||||
}
|
||||
|
||||
return {
|
||||
albumArtists: res.body.searchResult3?.artist?.map((artist) =>
|
||||
ssNormalize.albumArtist(artist, apiClientProps.server),
|
||||
),
|
||||
albums: res.body.searchResult3?.album?.map((album) =>
|
||||
ssNormalize.album(album, apiClientProps.server),
|
||||
),
|
||||
songs: res.body.searchResult3?.song?.map((song) =>
|
||||
ssNormalize.song(song, apiClientProps.server, ''),
|
||||
),
|
||||
};
|
||||
return {
|
||||
albumArtists: res.body.searchResult3?.artist?.map((artist) =>
|
||||
ssNormalize.albumArtist(artist, apiClientProps.server),
|
||||
),
|
||||
albums: res.body.searchResult3?.album?.map((album) =>
|
||||
ssNormalize.album(album, apiClientProps.server),
|
||||
),
|
||||
songs: res.body.searchResult3?.song?.map((song) =>
|
||||
ssNormalize.song(song, apiClientProps.server, ''),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const getRandomSongList = async (args: RandomSongListArgs): Promise<RandomSongListResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).getRandomSongList({
|
||||
query: {
|
||||
fromYear: query.minYear,
|
||||
genre: query.genre,
|
||||
musicFolderId: query.musicFolderId,
|
||||
size: query.limit,
|
||||
toYear: query.maxYear,
|
||||
},
|
||||
});
|
||||
const res = await ssApiClient(apiClientProps).getRandomSongList({
|
||||
query: {
|
||||
fromYear: query.minYear,
|
||||
genre: query.genre,
|
||||
musicFolderId: query.musicFolderId,
|
||||
size: query.limit,
|
||||
toYear: query.maxYear,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get random songs');
|
||||
}
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get random songs');
|
||||
}
|
||||
|
||||
return {
|
||||
items: res.body.randomSongs?.song?.map((song) =>
|
||||
ssNormalize.song(song, apiClientProps.server, ''),
|
||||
),
|
||||
startIndex: 0,
|
||||
totalRecordCount: res.body.randomSongs?.song?.length || 0,
|
||||
};
|
||||
return {
|
||||
items: res.body.randomSongs?.song?.map((song) =>
|
||||
ssNormalize.song(song, apiClientProps.server, ''),
|
||||
),
|
||||
startIndex: 0,
|
||||
totalRecordCount: res.body.randomSongs?.song?.length || 0,
|
||||
};
|
||||
};
|
||||
|
||||
export const ssController = {
|
||||
authenticate,
|
||||
createFavorite,
|
||||
getArtistInfo,
|
||||
getMusicFolderList,
|
||||
getRandomSongList,
|
||||
getTopSongList,
|
||||
removeFavorite,
|
||||
scrobble,
|
||||
search3,
|
||||
setRating,
|
||||
authenticate,
|
||||
createFavorite,
|
||||
getArtistInfo,
|
||||
getMusicFolderList,
|
||||
getRandomSongList,
|
||||
getTopSongList,
|
||||
removeFavorite,
|
||||
scrobble,
|
||||
search3,
|
||||
setRating,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,176 +5,178 @@ import { QueueSong, LibraryItem, AlbumArtist, Album } from '/@/renderer/api/type
|
|||
import { ServerListItem, ServerType } from '/@/renderer/types';
|
||||
|
||||
const getCoverArtUrl = (args: {
|
||||
baseUrl: string | undefined;
|
||||
coverArtId?: string;
|
||||
credential: string | undefined;
|
||||
size: number;
|
||||
baseUrl: string | undefined;
|
||||
coverArtId?: string;
|
||||
credential: string | undefined;
|
||||
size: number;
|
||||
}) => {
|
||||
const size = args.size ? args.size : 250;
|
||||
const size = args.size ? args.size : 250;
|
||||
|
||||
if (!args.coverArtId || args.coverArtId.match('2a96cbd8b46e442fc41c2b86b821562f')) {
|
||||
return null;
|
||||
}
|
||||
if (!args.coverArtId || args.coverArtId.match('2a96cbd8b46e442fc41c2b86b821562f')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
`${args.baseUrl}/rest/getCoverArt.view` +
|
||||
`?id=${args.coverArtId}` +
|
||||
`&${args.credential}` +
|
||||
'&v=1.13.0' +
|
||||
'&c=feishin' +
|
||||
`&size=${size}`
|
||||
);
|
||||
return (
|
||||
`${args.baseUrl}/rest/getCoverArt.view` +
|
||||
`?id=${args.coverArtId}` +
|
||||
`&${args.credential}` +
|
||||
'&v=1.13.0' +
|
||||
'&c=feishin' +
|
||||
`&size=${size}`
|
||||
);
|
||||
};
|
||||
|
||||
const normalizeSong = (
|
||||
item: z.infer<typeof ssType._response.song>,
|
||||
server: ServerListItem | null,
|
||||
deviceId: string,
|
||||
item: z.infer<typeof ssType._response.song>,
|
||||
server: ServerListItem | null,
|
||||
deviceId: string,
|
||||
): QueueSong => {
|
||||
const imageUrl =
|
||||
getCoverArtUrl({
|
||||
baseUrl: server?.url,
|
||||
coverArtId: item.coverArt,
|
||||
credential: server?.credential,
|
||||
size: 100,
|
||||
}) || null;
|
||||
const imageUrl =
|
||||
getCoverArtUrl({
|
||||
baseUrl: server?.url,
|
||||
coverArtId: item.coverArt,
|
||||
credential: server?.credential,
|
||||
size: 100,
|
||||
}) || null;
|
||||
|
||||
const streamUrl = `${server?.url}/rest/stream.view?id=${item.id}&v=1.13.0&c=feishin_${deviceId}&${server?.credential}`;
|
||||
const streamUrl = `${server?.url}/rest/stream.view?id=${item.id}&v=1.13.0&c=feishin_${deviceId}&${server?.credential}`;
|
||||
|
||||
return {
|
||||
album: item.album || '',
|
||||
albumArtists: [
|
||||
{
|
||||
id: item.artistId || '',
|
||||
imageUrl: null,
|
||||
name: item.artist || '',
|
||||
},
|
||||
],
|
||||
albumId: item.albumId || '',
|
||||
artistName: item.artist || '',
|
||||
artists: [
|
||||
{
|
||||
id: item.artistId || '',
|
||||
imageUrl: null,
|
||||
name: item.artist || '',
|
||||
},
|
||||
],
|
||||
bitRate: item.bitRate || 0,
|
||||
bpm: null,
|
||||
channels: null,
|
||||
comment: null,
|
||||
compilation: null,
|
||||
container: item.contentType,
|
||||
createdAt: item.created,
|
||||
discNumber: item.discNumber || 1,
|
||||
duration: item.duration || 0,
|
||||
genres: item.genre
|
||||
? [
|
||||
{
|
||||
id: item.genre,
|
||||
name: item.genre,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
id: item.id,
|
||||
imagePlaceholderUrl: null,
|
||||
imageUrl,
|
||||
itemType: LibraryItem.SONG,
|
||||
lastPlayedAt: null,
|
||||
lyrics: null,
|
||||
name: item.title,
|
||||
path: item.path,
|
||||
playCount: item?.playCount || 0,
|
||||
releaseDate: null,
|
||||
releaseYear: item.year ? String(item.year) : null,
|
||||
serverId: server?.id || 'unknown',
|
||||
serverType: ServerType.SUBSONIC,
|
||||
size: item.size,
|
||||
streamUrl,
|
||||
trackNumber: item.track || 1,
|
||||
uniqueId: nanoid(),
|
||||
updatedAt: '',
|
||||
userFavorite: item.starred || false,
|
||||
userRating: item.userRating || null,
|
||||
};
|
||||
return {
|
||||
album: item.album || '',
|
||||
albumArtists: [
|
||||
{
|
||||
id: item.artistId || '',
|
||||
imageUrl: null,
|
||||
name: item.artist || '',
|
||||
},
|
||||
],
|
||||
albumId: item.albumId || '',
|
||||
artistName: item.artist || '',
|
||||
artists: [
|
||||
{
|
||||
id: item.artistId || '',
|
||||
imageUrl: null,
|
||||
name: item.artist || '',
|
||||
},
|
||||
],
|
||||
bitRate: item.bitRate || 0,
|
||||
bpm: null,
|
||||
channels: null,
|
||||
comment: null,
|
||||
compilation: null,
|
||||
container: item.contentType,
|
||||
createdAt: item.created,
|
||||
discNumber: item.discNumber || 1,
|
||||
duration: item.duration || 0,
|
||||
genres: item.genre
|
||||
? [
|
||||
{
|
||||
id: item.genre,
|
||||
name: item.genre,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
id: item.id,
|
||||
imagePlaceholderUrl: null,
|
||||
imageUrl,
|
||||
itemType: LibraryItem.SONG,
|
||||
lastPlayedAt: null,
|
||||
lyrics: null,
|
||||
name: item.title,
|
||||
path: item.path,
|
||||
playCount: item?.playCount || 0,
|
||||
releaseDate: null,
|
||||
releaseYear: item.year ? String(item.year) : null,
|
||||
serverId: server?.id || 'unknown',
|
||||
serverType: ServerType.SUBSONIC,
|
||||
size: item.size,
|
||||
streamUrl,
|
||||
trackNumber: item.track || 1,
|
||||
uniqueId: nanoid(),
|
||||
updatedAt: '',
|
||||
userFavorite: item.starred || false,
|
||||
userRating: item.userRating || null,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeAlbumArtist = (
|
||||
item: z.infer<typeof ssType._response.albumArtist>,
|
||||
server: ServerListItem | null,
|
||||
item: z.infer<typeof ssType._response.albumArtist>,
|
||||
server: ServerListItem | null,
|
||||
): AlbumArtist => {
|
||||
const imageUrl =
|
||||
getCoverArtUrl({
|
||||
baseUrl: server?.url,
|
||||
coverArtId: item.coverArt,
|
||||
credential: server?.credential,
|
||||
size: 100,
|
||||
}) || null;
|
||||
const imageUrl =
|
||||
getCoverArtUrl({
|
||||
baseUrl: server?.url,
|
||||
coverArtId: item.coverArt,
|
||||
credential: server?.credential,
|
||||
size: 100,
|
||||
}) || null;
|
||||
|
||||
return {
|
||||
albumCount: item.albumCount ? Number(item.albumCount) : 0,
|
||||
backgroundImageUrl: null,
|
||||
biography: null,
|
||||
duration: null,
|
||||
genres: [],
|
||||
id: item.id,
|
||||
imageUrl,
|
||||
itemType: LibraryItem.ALBUM_ARTIST,
|
||||
lastPlayedAt: null,
|
||||
name: item.name,
|
||||
playCount: null,
|
||||
serverId: server?.id || 'unknown',
|
||||
serverType: ServerType.SUBSONIC,
|
||||
similarArtists: [],
|
||||
songCount: null,
|
||||
userFavorite: false,
|
||||
userRating: null,
|
||||
};
|
||||
return {
|
||||
albumCount: item.albumCount ? Number(item.albumCount) : 0,
|
||||
backgroundImageUrl: null,
|
||||
biography: null,
|
||||
duration: null,
|
||||
genres: [],
|
||||
id: item.id,
|
||||
imageUrl,
|
||||
itemType: LibraryItem.ALBUM_ARTIST,
|
||||
lastPlayedAt: null,
|
||||
name: item.name,
|
||||
playCount: null,
|
||||
serverId: server?.id || 'unknown',
|
||||
serverType: ServerType.SUBSONIC,
|
||||
similarArtists: [],
|
||||
songCount: null,
|
||||
userFavorite: false,
|
||||
userRating: null,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeAlbum = (
|
||||
item: z.infer<typeof ssType._response.album>,
|
||||
server: ServerListItem | null,
|
||||
item: z.infer<typeof ssType._response.album>,
|
||||
server: ServerListItem | null,
|
||||
): Album => {
|
||||
const imageUrl =
|
||||
getCoverArtUrl({
|
||||
baseUrl: server?.url,
|
||||
coverArtId: item.coverArt,
|
||||
credential: server?.credential,
|
||||
size: 300,
|
||||
}) || null;
|
||||
const imageUrl =
|
||||
getCoverArtUrl({
|
||||
baseUrl: server?.url,
|
||||
coverArtId: item.coverArt,
|
||||
credential: server?.credential,
|
||||
size: 300,
|
||||
}) || null;
|
||||
|
||||
return {
|
||||
albumArtists: item.artistId ? [{ id: item.artistId, imageUrl: null, name: item.artist }] : [],
|
||||
artists: item.artistId ? [{ id: item.artistId, imageUrl: null, name: item.artist }] : [],
|
||||
backdropImageUrl: null,
|
||||
createdAt: item.created,
|
||||
duration: item.duration,
|
||||
genres: item.genre ? [{ id: item.genre, name: item.genre }] : [],
|
||||
id: item.id,
|
||||
imagePlaceholderUrl: null,
|
||||
imageUrl,
|
||||
isCompilation: null,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
lastPlayedAt: null,
|
||||
name: item.name,
|
||||
playCount: null,
|
||||
releaseDate: item.year ? new Date(item.year, 0, 1).toISOString() : null,
|
||||
releaseYear: item.year ? Number(item.year) : null,
|
||||
serverId: server?.id || 'unknown',
|
||||
serverType: ServerType.SUBSONIC,
|
||||
size: null,
|
||||
songCount: item.songCount,
|
||||
songs: [],
|
||||
uniqueId: nanoid(),
|
||||
updatedAt: item.created,
|
||||
userFavorite: item.starred || false,
|
||||
userRating: item.userRating || null,
|
||||
};
|
||||
return {
|
||||
albumArtists: item.artistId
|
||||
? [{ id: item.artistId, imageUrl: null, name: item.artist }]
|
||||
: [],
|
||||
artists: item.artistId ? [{ id: item.artistId, imageUrl: null, name: item.artist }] : [],
|
||||
backdropImageUrl: null,
|
||||
createdAt: item.created,
|
||||
duration: item.duration,
|
||||
genres: item.genre ? [{ id: item.genre, name: item.genre }] : [],
|
||||
id: item.id,
|
||||
imagePlaceholderUrl: null,
|
||||
imageUrl,
|
||||
isCompilation: null,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
lastPlayedAt: null,
|
||||
name: item.name,
|
||||
playCount: null,
|
||||
releaseDate: item.year ? new Date(item.year, 0, 1).toISOString() : null,
|
||||
releaseYear: item.year ? Number(item.year) : null,
|
||||
serverId: server?.id || 'unknown',
|
||||
serverType: ServerType.SUBSONIC,
|
||||
size: null,
|
||||
songCount: item.songCount,
|
||||
songs: [],
|
||||
uniqueId: nanoid(),
|
||||
updatedAt: item.created,
|
||||
userFavorite: item.starred || false,
|
||||
userRating: item.userRating || null,
|
||||
};
|
||||
};
|
||||
|
||||
export const ssNormalize = {
|
||||
album: normalizeAlbum,
|
||||
albumArtist: normalizeAlbumArtist,
|
||||
song: normalizeSong,
|
||||
album: normalizeAlbum,
|
||||
albumArtist: normalizeAlbumArtist,
|
||||
song: normalizeSong,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,240 +1,240 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
const baseResponse = z.object({
|
||||
'subsonic-response': z.object({
|
||||
status: z.string(),
|
||||
version: z.string(),
|
||||
}),
|
||||
'subsonic-response': z.object({
|
||||
status: z.string(),
|
||||
version: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const authenticate = z.null();
|
||||
|
||||
const authenticateParameters = z.object({
|
||||
c: z.string(),
|
||||
f: z.string(),
|
||||
p: z.string().optional(),
|
||||
s: z.string().optional(),
|
||||
t: z.string().optional(),
|
||||
u: z.string(),
|
||||
v: z.string(),
|
||||
c: z.string(),
|
||||
f: z.string(),
|
||||
p: z.string().optional(),
|
||||
s: z.string().optional(),
|
||||
t: z.string().optional(),
|
||||
u: z.string(),
|
||||
v: z.string(),
|
||||
});
|
||||
|
||||
const createFavoriteParameters = z.object({
|
||||
albumId: z.array(z.string()).optional(),
|
||||
artistId: z.array(z.string()).optional(),
|
||||
id: z.array(z.string()).optional(),
|
||||
albumId: z.array(z.string()).optional(),
|
||||
artistId: z.array(z.string()).optional(),
|
||||
id: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
const createFavorite = z.null();
|
||||
|
||||
const removeFavoriteParameters = z.object({
|
||||
albumId: z.array(z.string()).optional(),
|
||||
artistId: z.array(z.string()).optional(),
|
||||
id: z.array(z.string()).optional(),
|
||||
albumId: z.array(z.string()).optional(),
|
||||
artistId: z.array(z.string()).optional(),
|
||||
id: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
const removeFavorite = z.null();
|
||||
|
||||
const setRatingParameters = z.object({
|
||||
id: z.string(),
|
||||
rating: z.number(),
|
||||
id: z.string(),
|
||||
rating: z.number(),
|
||||
});
|
||||
|
||||
const setRating = z.null();
|
||||
|
||||
const musicFolder = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
const musicFolderList = z.object({
|
||||
musicFolders: z.object({
|
||||
musicFolder: z.array(musicFolder),
|
||||
}),
|
||||
musicFolders: z.object({
|
||||
musicFolder: z.array(musicFolder),
|
||||
}),
|
||||
});
|
||||
|
||||
const song = z.object({
|
||||
album: z.string().optional(),
|
||||
albumId: z.string().optional(),
|
||||
artist: z.string().optional(),
|
||||
artistId: z.string().optional(),
|
||||
averageRating: z.number().optional(),
|
||||
bitRate: z.number().optional(),
|
||||
contentType: z.string(),
|
||||
coverArt: z.string().optional(),
|
||||
created: z.string(),
|
||||
discNumber: z.number(),
|
||||
duration: z.number().optional(),
|
||||
genre: z.string().optional(),
|
||||
id: z.string(),
|
||||
isDir: z.boolean(),
|
||||
isVideo: z.boolean(),
|
||||
parent: z.string(),
|
||||
path: z.string(),
|
||||
playCount: z.number().optional(),
|
||||
size: z.number(),
|
||||
starred: z.boolean().optional(),
|
||||
suffix: z.string(),
|
||||
title: z.string(),
|
||||
track: z.number().optional(),
|
||||
type: z.string(),
|
||||
userRating: z.number().optional(),
|
||||
year: z.number().optional(),
|
||||
album: z.string().optional(),
|
||||
albumId: z.string().optional(),
|
||||
artist: z.string().optional(),
|
||||
artistId: z.string().optional(),
|
||||
averageRating: z.number().optional(),
|
||||
bitRate: z.number().optional(),
|
||||
contentType: z.string(),
|
||||
coverArt: z.string().optional(),
|
||||
created: z.string(),
|
||||
discNumber: z.number(),
|
||||
duration: z.number().optional(),
|
||||
genre: z.string().optional(),
|
||||
id: z.string(),
|
||||
isDir: z.boolean(),
|
||||
isVideo: z.boolean(),
|
||||
parent: z.string(),
|
||||
path: z.string(),
|
||||
playCount: z.number().optional(),
|
||||
size: z.number(),
|
||||
starred: z.boolean().optional(),
|
||||
suffix: z.string(),
|
||||
title: z.string(),
|
||||
track: z.number().optional(),
|
||||
type: z.string(),
|
||||
userRating: z.number().optional(),
|
||||
year: z.number().optional(),
|
||||
});
|
||||
|
||||
const album = z.object({
|
||||
album: z.string(),
|
||||
artist: z.string(),
|
||||
artistId: z.string(),
|
||||
coverArt: z.string(),
|
||||
created: z.string(),
|
||||
duration: z.number(),
|
||||
genre: z.string().optional(),
|
||||
id: z.string(),
|
||||
isDir: z.boolean(),
|
||||
isVideo: z.boolean(),
|
||||
name: z.string(),
|
||||
parent: z.string(),
|
||||
song: z.array(song),
|
||||
songCount: z.number(),
|
||||
starred: z.boolean().optional(),
|
||||
title: z.string(),
|
||||
userRating: z.number().optional(),
|
||||
year: z.number().optional(),
|
||||
album: z.string(),
|
||||
artist: z.string(),
|
||||
artistId: z.string(),
|
||||
coverArt: z.string(),
|
||||
created: z.string(),
|
||||
duration: z.number(),
|
||||
genre: z.string().optional(),
|
||||
id: z.string(),
|
||||
isDir: z.boolean(),
|
||||
isVideo: z.boolean(),
|
||||
name: z.string(),
|
||||
parent: z.string(),
|
||||
song: z.array(song),
|
||||
songCount: z.number(),
|
||||
starred: z.boolean().optional(),
|
||||
title: z.string(),
|
||||
userRating: z.number().optional(),
|
||||
year: z.number().optional(),
|
||||
});
|
||||
|
||||
const albumListParameters = z.object({
|
||||
fromYear: z.number().optional(),
|
||||
genre: z.string().optional(),
|
||||
musicFolderId: z.string().optional(),
|
||||
offset: z.number().optional(),
|
||||
size: z.number().optional(),
|
||||
toYear: z.number().optional(),
|
||||
type: z.string().optional(),
|
||||
fromYear: z.number().optional(),
|
||||
genre: z.string().optional(),
|
||||
musicFolderId: z.string().optional(),
|
||||
offset: z.number().optional(),
|
||||
size: z.number().optional(),
|
||||
toYear: z.number().optional(),
|
||||
type: z.string().optional(),
|
||||
});
|
||||
|
||||
const albumList = z.array(album.omit({ song: true }));
|
||||
|
||||
const albumArtist = z.object({
|
||||
albumCount: z.string(),
|
||||
artistImageUrl: z.string().optional(),
|
||||
coverArt: z.string().optional(),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
albumCount: z.string(),
|
||||
artistImageUrl: z.string().optional(),
|
||||
coverArt: z.string().optional(),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
const albumArtistList = z.object({
|
||||
artist: z.array(albumArtist),
|
||||
name: z.string(),
|
||||
artist: z.array(albumArtist),
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
const artistInfoParameters = z.object({
|
||||
count: z.number().optional(),
|
||||
id: z.string(),
|
||||
includeNotPresent: z.boolean().optional(),
|
||||
count: z.number().optional(),
|
||||
id: z.string(),
|
||||
includeNotPresent: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const artistInfo = z.object({
|
||||
artistInfo: z.object({
|
||||
biography: z.string().optional(),
|
||||
largeImageUrl: z.string().optional(),
|
||||
lastFmUrl: z.string().optional(),
|
||||
mediumImageUrl: z.string().optional(),
|
||||
musicBrainzId: z.string().optional(),
|
||||
similarArtist: z.array(
|
||||
z.object({
|
||||
albumCount: z.string(),
|
||||
artistImageUrl: z.string().optional(),
|
||||
coverArt: z.string().optional(),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
}),
|
||||
),
|
||||
smallImageUrl: z.string().optional(),
|
||||
}),
|
||||
artistInfo: z.object({
|
||||
biography: z.string().optional(),
|
||||
largeImageUrl: z.string().optional(),
|
||||
lastFmUrl: z.string().optional(),
|
||||
mediumImageUrl: z.string().optional(),
|
||||
musicBrainzId: z.string().optional(),
|
||||
similarArtist: z.array(
|
||||
z.object({
|
||||
albumCount: z.string(),
|
||||
artistImageUrl: z.string().optional(),
|
||||
coverArt: z.string().optional(),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
}),
|
||||
),
|
||||
smallImageUrl: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
const topSongsListParameters = z.object({
|
||||
artist: z.string(), // The name of the artist, not the artist ID
|
||||
count: z.number().optional(),
|
||||
artist: z.string(), // The name of the artist, not the artist ID
|
||||
count: z.number().optional(),
|
||||
});
|
||||
|
||||
const topSongsList = z.object({
|
||||
topSongs: z.object({
|
||||
song: z.array(song),
|
||||
}),
|
||||
topSongs: z.object({
|
||||
song: z.array(song),
|
||||
}),
|
||||
});
|
||||
|
||||
const scrobbleParameters = z.object({
|
||||
id: z.string(),
|
||||
submission: z.boolean().optional(),
|
||||
time: z.number().optional(), // The time (in milliseconds since 1 Jan 1970) at which the song was listened to.
|
||||
id: z.string(),
|
||||
submission: z.boolean().optional(),
|
||||
time: z.number().optional(), // The time (in milliseconds since 1 Jan 1970) at which the song was listened to.
|
||||
});
|
||||
|
||||
const scrobble = z.null();
|
||||
|
||||
const search3 = z.object({
|
||||
searchResult3: z.object({
|
||||
album: z.array(album),
|
||||
artist: z.array(albumArtist),
|
||||
song: z.array(song),
|
||||
}),
|
||||
searchResult3: z.object({
|
||||
album: z.array(album),
|
||||
artist: z.array(albumArtist),
|
||||
song: z.array(song),
|
||||
}),
|
||||
});
|
||||
|
||||
const search3Parameters = z.object({
|
||||
albumCount: z.number().optional(),
|
||||
albumOffset: z.number().optional(),
|
||||
artistCount: z.number().optional(),
|
||||
artistOffset: z.number().optional(),
|
||||
musicFolderId: z.string().optional(),
|
||||
query: z.string().optional(),
|
||||
songCount: z.number().optional(),
|
||||
songOffset: z.number().optional(),
|
||||
albumCount: z.number().optional(),
|
||||
albumOffset: z.number().optional(),
|
||||
artistCount: z.number().optional(),
|
||||
artistOffset: z.number().optional(),
|
||||
musicFolderId: z.string().optional(),
|
||||
query: z.string().optional(),
|
||||
songCount: z.number().optional(),
|
||||
songOffset: z.number().optional(),
|
||||
});
|
||||
|
||||
const randomSongListParameters = z.object({
|
||||
fromYear: z.number().optional(),
|
||||
genre: z.string().optional(),
|
||||
musicFolderId: z.string().optional(),
|
||||
size: z.number().optional(),
|
||||
toYear: z.number().optional(),
|
||||
fromYear: z.number().optional(),
|
||||
genre: z.string().optional(),
|
||||
musicFolderId: z.string().optional(),
|
||||
size: z.number().optional(),
|
||||
toYear: z.number().optional(),
|
||||
});
|
||||
|
||||
const randomSongList = z.object({
|
||||
randomSongs: z.object({
|
||||
song: z.array(song),
|
||||
}),
|
||||
randomSongs: z.object({
|
||||
song: z.array(song),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ssType = {
|
||||
_parameters: {
|
||||
albumList: albumListParameters,
|
||||
artistInfo: artistInfoParameters,
|
||||
authenticate: authenticateParameters,
|
||||
createFavorite: createFavoriteParameters,
|
||||
randomSongList: randomSongListParameters,
|
||||
removeFavorite: removeFavoriteParameters,
|
||||
scrobble: scrobbleParameters,
|
||||
search3: search3Parameters,
|
||||
setRating: setRatingParameters,
|
||||
topSongsList: topSongsListParameters,
|
||||
},
|
||||
_response: {
|
||||
album,
|
||||
albumArtist,
|
||||
albumArtistList,
|
||||
albumList,
|
||||
artistInfo,
|
||||
authenticate,
|
||||
baseResponse,
|
||||
createFavorite,
|
||||
musicFolderList,
|
||||
randomSongList,
|
||||
removeFavorite,
|
||||
scrobble,
|
||||
search3,
|
||||
setRating,
|
||||
song,
|
||||
topSongsList,
|
||||
},
|
||||
_parameters: {
|
||||
albumList: albumListParameters,
|
||||
artistInfo: artistInfoParameters,
|
||||
authenticate: authenticateParameters,
|
||||
createFavorite: createFavoriteParameters,
|
||||
randomSongList: randomSongListParameters,
|
||||
removeFavorite: removeFavoriteParameters,
|
||||
scrobble: scrobbleParameters,
|
||||
search3: search3Parameters,
|
||||
setRating: setRatingParameters,
|
||||
topSongsList: topSongsListParameters,
|
||||
},
|
||||
_response: {
|
||||
album,
|
||||
albumArtist,
|
||||
albumArtistList,
|
||||
albumList,
|
||||
artistInfo,
|
||||
authenticate,
|
||||
baseResponse,
|
||||
createFavorite,
|
||||
musicFolderList,
|
||||
randomSongList,
|
||||
removeFavorite,
|
||||
scrobble,
|
||||
search3,
|
||||
setRating,
|
||||
song,
|
||||
topSongsList,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -6,35 +6,35 @@ import { ServerListItem } from '/@/renderer/types';
|
|||
|
||||
// Since ts-rest client returns a strict response type, we need to add the headers to the body object
|
||||
export const resultWithHeaders = <ItemType extends z.ZodTypeAny>(itemSchema: ItemType) => {
|
||||
return z.object({
|
||||
data: itemSchema,
|
||||
headers: z.instanceof(AxiosHeaders),
|
||||
});
|
||||
return z.object({
|
||||
data: itemSchema,
|
||||
headers: z.instanceof(AxiosHeaders),
|
||||
});
|
||||
};
|
||||
|
||||
export const resultSubsonicBaseResponse = <ItemType extends z.ZodRawShape>(
|
||||
itemSchema: ItemType,
|
||||
itemSchema: ItemType,
|
||||
) => {
|
||||
return z.object({
|
||||
'subsonic-response': z
|
||||
.object({
|
||||
status: z.string(),
|
||||
version: z.string(),
|
||||
})
|
||||
.extend(itemSchema),
|
||||
});
|
||||
return z.object({
|
||||
'subsonic-response': z
|
||||
.object({
|
||||
status: z.string(),
|
||||
version: z.string(),
|
||||
})
|
||||
.extend(itemSchema),
|
||||
});
|
||||
};
|
||||
|
||||
export const authenticationFailure = (currentServer: ServerListItem | null) => {
|
||||
toast.error({
|
||||
message: 'Your session has expired.',
|
||||
});
|
||||
toast.error({
|
||||
message: 'Your session has expired.',
|
||||
});
|
||||
|
||||
if (currentServer) {
|
||||
const serverId = currentServer.id;
|
||||
const token = currentServer.ndCredential;
|
||||
console.log(`token is expired: ${token}`);
|
||||
useAuthStore.getState().actions.updateServer(serverId, { ndCredential: undefined });
|
||||
useAuthStore.getState().actions.setCurrentServer(null);
|
||||
}
|
||||
if (currentServer) {
|
||||
const serverId = currentServer.id;
|
||||
const token = currentServer.ndCredential;
|
||||
console.log(`token is expired: ${token}`);
|
||||
useAuthStore.getState().actions.updateServer(serverId, { ndCredential: undefined });
|
||||
useAuthStore.getState().actions.setCurrentServer(null);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -29,161 +29,161 @@ const mpvPlayerListener = isElectron() ? window.electron.mpvPlayerListener : nul
|
|||
const ipc = isElectron() ? window.electron.ipc : null;
|
||||
|
||||
export const App = () => {
|
||||
const theme = useTheme();
|
||||
const contentFont = useSettingsStore((state) => state.general.fontContent);
|
||||
const { type: playbackType } = usePlaybackSettings();
|
||||
const { bindings } = useHotkeySettings();
|
||||
const handlePlayQueueAdd = useHandlePlayQueueAdd();
|
||||
const { clearQueue, restoreQueue } = useQueueControls();
|
||||
const theme = useTheme();
|
||||
const contentFont = useSettingsStore((state) => state.general.fontContent);
|
||||
const { type: playbackType } = usePlaybackSettings();
|
||||
const { bindings } = useHotkeySettings();
|
||||
const handlePlayQueueAdd = useHandlePlayQueueAdd();
|
||||
const { clearQueue, restoreQueue } = useQueueControls();
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--content-font-family', contentFont);
|
||||
}, [contentFont]);
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--content-font-family', contentFont);
|
||||
}, [contentFont]);
|
||||
|
||||
// Start the mpv instance on startup
|
||||
useEffect(() => {
|
||||
const initializeMpv = async () => {
|
||||
const isRunning: boolean | undefined = await mpvPlayer?.isRunning();
|
||||
// Start the mpv instance on startup
|
||||
useEffect(() => {
|
||||
const initializeMpv = async () => {
|
||||
const isRunning: boolean | undefined = await mpvPlayer?.isRunning();
|
||||
|
||||
if (!isRunning) {
|
||||
const extraParameters = useSettingsStore.getState().playback.mpvExtraParameters;
|
||||
const properties = {
|
||||
...getMpvProperties(useSettingsStore.getState().playback.mpvProperties),
|
||||
if (!isRunning) {
|
||||
const extraParameters = useSettingsStore.getState().playback.mpvExtraParameters;
|
||||
const properties = {
|
||||
...getMpvProperties(useSettingsStore.getState().playback.mpvProperties),
|
||||
};
|
||||
|
||||
mpvPlayer?.initialize({
|
||||
extraParameters,
|
||||
properties,
|
||||
});
|
||||
|
||||
mpvPlayer?.volume(properties.volume);
|
||||
}
|
||||
};
|
||||
|
||||
mpvPlayer?.initialize({
|
||||
extraParameters,
|
||||
properties,
|
||||
});
|
||||
|
||||
mpvPlayer?.volume(properties.volume);
|
||||
}
|
||||
};
|
||||
|
||||
if (isElectron() && playbackType === PlaybackType.LOCAL) {
|
||||
initializeMpv();
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearQueue();
|
||||
mpvPlayer?.stop();
|
||||
mpvPlayer?.cleanup();
|
||||
};
|
||||
}, [clearQueue, playbackType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isElectron()) {
|
||||
ipc?.send('set-global-shortcuts', bindings);
|
||||
}
|
||||
}, [bindings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isElectron()) {
|
||||
mpvPlayer.restoreQueue();
|
||||
|
||||
mpvPlayerListener.rendererSaveQueue(() => {
|
||||
const { current, queue } = usePlayerStore.getState();
|
||||
const stateToSave: Partial<Pick<PlayerState, 'current' | 'queue'>> = {
|
||||
current: {
|
||||
...current,
|
||||
status: PlayerStatus.PAUSED,
|
||||
},
|
||||
queue,
|
||||
};
|
||||
mpvPlayer.saveQueue(stateToSave);
|
||||
});
|
||||
|
||||
mpvPlayerListener.rendererRestoreQueue((_event: any, data: Partial<PlayerState>) => {
|
||||
const playerData = restoreQueue(data);
|
||||
if (playbackType === PlaybackType.LOCAL) {
|
||||
mpvPlayer.setQueue(playerData, true);
|
||||
if (isElectron() && playbackType === PlaybackType.LOCAL) {
|
||||
initializeMpv();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
ipc?.removeAllListeners('renderer-player-restore-queue');
|
||||
ipc?.removeAllListeners('renderer-player-save-queue');
|
||||
};
|
||||
}, [playbackType, restoreQueue]);
|
||||
return () => {
|
||||
clearQueue();
|
||||
mpvPlayer?.stop();
|
||||
mpvPlayer?.cleanup();
|
||||
};
|
||||
}, [clearQueue, playbackType]);
|
||||
|
||||
return (
|
||||
<MantineProvider
|
||||
withGlobalStyles
|
||||
withNormalizeCSS
|
||||
theme={{
|
||||
colorScheme: theme as 'light' | 'dark',
|
||||
components: {
|
||||
Modal: {
|
||||
styles: {
|
||||
body: { background: 'var(--modal-bg)', padding: '1rem !important' },
|
||||
close: { marginRight: '0.5rem' },
|
||||
content: { borderRadius: '5px' },
|
||||
header: {
|
||||
background: 'var(--modal-header-bg)',
|
||||
paddingBottom: '1rem',
|
||||
},
|
||||
title: { fontSize: 'medium', fontWeight: 500 },
|
||||
},
|
||||
},
|
||||
},
|
||||
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: '1.1rem',
|
||||
md: '1rem',
|
||||
sm: '0.9rem',
|
||||
xl: '1.5rem',
|
||||
xs: '0.8rem',
|
||||
},
|
||||
headings: {
|
||||
fontFamily: 'var(--content-font-family)',
|
||||
fontWeight: 700,
|
||||
},
|
||||
other: {},
|
||||
spacing: {
|
||||
lg: '2rem',
|
||||
md: '1rem',
|
||||
sm: '0.5rem',
|
||||
xl: '4rem',
|
||||
xs: '0rem',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ModalsProvider
|
||||
modalProps={{
|
||||
centered: true,
|
||||
styles: {
|
||||
body: { position: 'relative' },
|
||||
content: { overflow: 'auto' },
|
||||
},
|
||||
transitionProps: {
|
||||
duration: 300,
|
||||
exitDuration: 300,
|
||||
transition: 'fade',
|
||||
},
|
||||
}}
|
||||
modals={{ addToPlaylist: AddToPlaylistContextModal, base: BaseContextModal }}
|
||||
>
|
||||
<PlayQueueHandlerContext.Provider value={{ handlePlayQueueAdd }}>
|
||||
<ContextMenuProvider>
|
||||
<AppRouter />
|
||||
</ContextMenuProvider>
|
||||
</PlayQueueHandlerContext.Provider>
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
);
|
||||
useEffect(() => {
|
||||
if (isElectron()) {
|
||||
ipc?.send('set-global-shortcuts', bindings);
|
||||
}
|
||||
}, [bindings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isElectron()) {
|
||||
mpvPlayer.restoreQueue();
|
||||
|
||||
mpvPlayerListener.rendererSaveQueue(() => {
|
||||
const { current, queue } = usePlayerStore.getState();
|
||||
const stateToSave: Partial<Pick<PlayerState, 'current' | 'queue'>> = {
|
||||
current: {
|
||||
...current,
|
||||
status: PlayerStatus.PAUSED,
|
||||
},
|
||||
queue,
|
||||
};
|
||||
mpvPlayer.saveQueue(stateToSave);
|
||||
});
|
||||
|
||||
mpvPlayerListener.rendererRestoreQueue((_event: any, data: Partial<PlayerState>) => {
|
||||
const playerData = restoreQueue(data);
|
||||
if (playbackType === PlaybackType.LOCAL) {
|
||||
mpvPlayer.setQueue(playerData, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
ipc?.removeAllListeners('renderer-player-restore-queue');
|
||||
ipc?.removeAllListeners('renderer-player-save-queue');
|
||||
};
|
||||
}, [playbackType, restoreQueue]);
|
||||
|
||||
return (
|
||||
<MantineProvider
|
||||
withGlobalStyles
|
||||
withNormalizeCSS
|
||||
theme={{
|
||||
colorScheme: theme as 'light' | 'dark',
|
||||
components: {
|
||||
Modal: {
|
||||
styles: {
|
||||
body: { background: 'var(--modal-bg)', padding: '1rem !important' },
|
||||
close: { marginRight: '0.5rem' },
|
||||
content: { borderRadius: '5px' },
|
||||
header: {
|
||||
background: 'var(--modal-header-bg)',
|
||||
paddingBottom: '1rem',
|
||||
},
|
||||
title: { fontSize: 'medium', fontWeight: 500 },
|
||||
},
|
||||
},
|
||||
},
|
||||
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: '1.1rem',
|
||||
md: '1rem',
|
||||
sm: '0.9rem',
|
||||
xl: '1.5rem',
|
||||
xs: '0.8rem',
|
||||
},
|
||||
headings: {
|
||||
fontFamily: 'var(--content-font-family)',
|
||||
fontWeight: 700,
|
||||
},
|
||||
other: {},
|
||||
spacing: {
|
||||
lg: '2rem',
|
||||
md: '1rem',
|
||||
sm: '0.5rem',
|
||||
xl: '4rem',
|
||||
xs: '0rem',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ModalsProvider
|
||||
modalProps={{
|
||||
centered: true,
|
||||
styles: {
|
||||
body: { position: 'relative' },
|
||||
content: { overflow: 'auto' },
|
||||
},
|
||||
transitionProps: {
|
||||
duration: 300,
|
||||
exitDuration: 300,
|
||||
transition: 'fade',
|
||||
},
|
||||
}}
|
||||
modals={{ addToPlaylist: AddToPlaylistContextModal, base: BaseContextModal }}
|
||||
>
|
||||
<PlayQueueHandlerContext.Provider value={{ handlePlayQueueAdd }}>
|
||||
<ContextMenuProvider>
|
||||
<AppRouter />
|
||||
</ContextMenuProvider>
|
||||
</PlayQueueHandlerContext.Provider>
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,17 +5,17 @@ import styled from 'styled-components';
|
|||
type AccordionProps = MantineAccordionProps;
|
||||
|
||||
const StyledAccordion = styled(MantineAccordion)`
|
||||
& .mantine-Accordion-panel {
|
||||
background: var(--paper-bg);
|
||||
}
|
||||
& .mantine-Accordion-panel {
|
||||
background: var(--paper-bg);
|
||||
}
|
||||
|
||||
.mantine-Accordion-control {
|
||||
background: var(--paper-bg);
|
||||
}
|
||||
.mantine-Accordion-control {
|
||||
background: var(--paper-bg);
|
||||
}
|
||||
`;
|
||||
|
||||
export const Accordion = ({ children, ...props }: AccordionProps) => {
|
||||
return <StyledAccordion {...props}>{children}</StyledAccordion>;
|
||||
return <StyledAccordion {...props}>{children}</StyledAccordion>;
|
||||
};
|
||||
|
||||
Accordion.Control = StyledAccordion.Control;
|
||||
|
|
|
|||
|
|
@ -4,188 +4,192 @@ import type { ReactPlayerProps } from 'react-player';
|
|||
import ReactPlayer from 'react-player';
|
||||
import type { Song } from '/@/renderer/api/types';
|
||||
import {
|
||||
crossfadeHandler,
|
||||
gaplessHandler,
|
||||
crossfadeHandler,
|
||||
gaplessHandler,
|
||||
} from '/@/renderer/components/audio-player/utils/list-handlers';
|
||||
import { useSettingsStore } from '/@/renderer/store/settings.store';
|
||||
import type { CrossfadeStyle } from '/@/renderer/types';
|
||||
import { PlaybackStyle, PlayerStatus } from '/@/renderer/types';
|
||||
|
||||
interface AudioPlayerProps extends ReactPlayerProps {
|
||||
crossfadeDuration: number;
|
||||
crossfadeStyle: CrossfadeStyle;
|
||||
currentPlayer: 1 | 2;
|
||||
playbackStyle: PlaybackStyle;
|
||||
player1: Song;
|
||||
player2: Song;
|
||||
status: PlayerStatus;
|
||||
volume: number;
|
||||
crossfadeDuration: number;
|
||||
crossfadeStyle: CrossfadeStyle;
|
||||
currentPlayer: 1 | 2;
|
||||
playbackStyle: PlaybackStyle;
|
||||
player1: Song;
|
||||
player2: Song;
|
||||
status: PlayerStatus;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
export type AudioPlayerProgress = {
|
||||
loaded: number;
|
||||
loadedSeconds: number;
|
||||
played: number;
|
||||
playedSeconds: number;
|
||||
loaded: number;
|
||||
loadedSeconds: number;
|
||||
played: number;
|
||||
playedSeconds: number;
|
||||
};
|
||||
|
||||
const getDuration = (ref: any) => {
|
||||
return ref.current?.player?.player?.player?.duration;
|
||||
return ref.current?.player?.player?.player?.duration;
|
||||
};
|
||||
|
||||
export const AudioPlayer = forwardRef(
|
||||
(
|
||||
{
|
||||
status,
|
||||
playbackStyle,
|
||||
crossfadeStyle,
|
||||
crossfadeDuration,
|
||||
currentPlayer,
|
||||
autoNext,
|
||||
player1,
|
||||
player2,
|
||||
muted,
|
||||
volume,
|
||||
}: AudioPlayerProps,
|
||||
ref: any,
|
||||
) => {
|
||||
const player1Ref = useRef<any>(null);
|
||||
const player2Ref = useRef<any>(null);
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
const audioDeviceId = useSettingsStore((state) => state.playback.audioDeviceId);
|
||||
(
|
||||
{
|
||||
status,
|
||||
playbackStyle,
|
||||
crossfadeStyle,
|
||||
crossfadeDuration,
|
||||
currentPlayer,
|
||||
autoNext,
|
||||
player1,
|
||||
player2,
|
||||
muted,
|
||||
volume,
|
||||
}: AudioPlayerProps,
|
||||
ref: any,
|
||||
) => {
|
||||
const player1Ref = useRef<any>(null);
|
||||
const player2Ref = useRef<any>(null);
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
const audioDeviceId = useSettingsStore((state) => state.playback.audioDeviceId);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
get player1() {
|
||||
return player1Ref?.current;
|
||||
},
|
||||
get player2() {
|
||||
return player2Ref?.current;
|
||||
},
|
||||
}));
|
||||
useImperativeHandle(ref, () => ({
|
||||
get player1() {
|
||||
return player1Ref?.current;
|
||||
},
|
||||
get player2() {
|
||||
return player2Ref?.current;
|
||||
},
|
||||
}));
|
||||
|
||||
const handleOnEnded = () => {
|
||||
autoNext();
|
||||
setIsTransitioning(false);
|
||||
};
|
||||
const handleOnEnded = () => {
|
||||
autoNext();
|
||||
setIsTransitioning(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (status === PlayerStatus.PLAYING) {
|
||||
if (currentPlayer === 1) {
|
||||
player1Ref.current?.getInternalPlayer()?.play();
|
||||
} else {
|
||||
player2Ref.current?.getInternalPlayer()?.play();
|
||||
}
|
||||
} else {
|
||||
player1Ref.current?.getInternalPlayer()?.pause();
|
||||
player2Ref.current?.getInternalPlayer()?.pause();
|
||||
}
|
||||
}, [currentPlayer, status]);
|
||||
useEffect(() => {
|
||||
if (status === PlayerStatus.PLAYING) {
|
||||
if (currentPlayer === 1) {
|
||||
player1Ref.current?.getInternalPlayer()?.play();
|
||||
} else {
|
||||
player2Ref.current?.getInternalPlayer()?.play();
|
||||
}
|
||||
} else {
|
||||
player1Ref.current?.getInternalPlayer()?.pause();
|
||||
player2Ref.current?.getInternalPlayer()?.pause();
|
||||
}
|
||||
}, [currentPlayer, status]);
|
||||
|
||||
const handleCrossfade1 = useCallback(
|
||||
(e: AudioPlayerProgress) => {
|
||||
return crossfadeHandler({
|
||||
currentPlayer,
|
||||
currentPlayerRef: player1Ref,
|
||||
currentTime: e.playedSeconds,
|
||||
duration: getDuration(player1Ref),
|
||||
fadeDuration: crossfadeDuration,
|
||||
fadeType: crossfadeStyle,
|
||||
isTransitioning,
|
||||
nextPlayerRef: player2Ref,
|
||||
player: 1,
|
||||
setIsTransitioning,
|
||||
volume,
|
||||
});
|
||||
},
|
||||
[crossfadeDuration, crossfadeStyle, currentPlayer, isTransitioning, volume],
|
||||
);
|
||||
const handleCrossfade1 = useCallback(
|
||||
(e: AudioPlayerProgress) => {
|
||||
return crossfadeHandler({
|
||||
currentPlayer,
|
||||
currentPlayerRef: player1Ref,
|
||||
currentTime: e.playedSeconds,
|
||||
duration: getDuration(player1Ref),
|
||||
fadeDuration: crossfadeDuration,
|
||||
fadeType: crossfadeStyle,
|
||||
isTransitioning,
|
||||
nextPlayerRef: player2Ref,
|
||||
player: 1,
|
||||
setIsTransitioning,
|
||||
volume,
|
||||
});
|
||||
},
|
||||
[crossfadeDuration, crossfadeStyle, currentPlayer, isTransitioning, volume],
|
||||
);
|
||||
|
||||
const handleCrossfade2 = useCallback(
|
||||
(e: AudioPlayerProgress) => {
|
||||
return crossfadeHandler({
|
||||
currentPlayer,
|
||||
currentPlayerRef: player2Ref,
|
||||
currentTime: e.playedSeconds,
|
||||
duration: getDuration(player2Ref),
|
||||
fadeDuration: crossfadeDuration,
|
||||
fadeType: crossfadeStyle,
|
||||
isTransitioning,
|
||||
nextPlayerRef: player1Ref,
|
||||
player: 2,
|
||||
setIsTransitioning,
|
||||
volume,
|
||||
});
|
||||
},
|
||||
[crossfadeDuration, crossfadeStyle, currentPlayer, isTransitioning, volume],
|
||||
);
|
||||
const handleCrossfade2 = useCallback(
|
||||
(e: AudioPlayerProgress) => {
|
||||
return crossfadeHandler({
|
||||
currentPlayer,
|
||||
currentPlayerRef: player2Ref,
|
||||
currentTime: e.playedSeconds,
|
||||
duration: getDuration(player2Ref),
|
||||
fadeDuration: crossfadeDuration,
|
||||
fadeType: crossfadeStyle,
|
||||
isTransitioning,
|
||||
nextPlayerRef: player1Ref,
|
||||
player: 2,
|
||||
setIsTransitioning,
|
||||
volume,
|
||||
});
|
||||
},
|
||||
[crossfadeDuration, crossfadeStyle, currentPlayer, isTransitioning, volume],
|
||||
);
|
||||
|
||||
const handleGapless1 = useCallback(
|
||||
(e: AudioPlayerProgress) => {
|
||||
return gaplessHandler({
|
||||
currentTime: e.playedSeconds,
|
||||
duration: getDuration(player1Ref),
|
||||
isFlac: player1?.container === 'flac',
|
||||
isTransitioning,
|
||||
nextPlayerRef: player2Ref,
|
||||
setIsTransitioning,
|
||||
});
|
||||
},
|
||||
[isTransitioning, player1?.container],
|
||||
);
|
||||
const handleGapless1 = useCallback(
|
||||
(e: AudioPlayerProgress) => {
|
||||
return gaplessHandler({
|
||||
currentTime: e.playedSeconds,
|
||||
duration: getDuration(player1Ref),
|
||||
isFlac: player1?.container === 'flac',
|
||||
isTransitioning,
|
||||
nextPlayerRef: player2Ref,
|
||||
setIsTransitioning,
|
||||
});
|
||||
},
|
||||
[isTransitioning, player1?.container],
|
||||
);
|
||||
|
||||
const handleGapless2 = useCallback(
|
||||
(e: AudioPlayerProgress) => {
|
||||
return gaplessHandler({
|
||||
currentTime: e.playedSeconds,
|
||||
duration: getDuration(player2Ref),
|
||||
isFlac: player2?.container === 'flac',
|
||||
isTransitioning,
|
||||
nextPlayerRef: player1Ref,
|
||||
setIsTransitioning,
|
||||
});
|
||||
},
|
||||
[isTransitioning, player2?.container],
|
||||
);
|
||||
const handleGapless2 = useCallback(
|
||||
(e: AudioPlayerProgress) => {
|
||||
return gaplessHandler({
|
||||
currentTime: e.playedSeconds,
|
||||
duration: getDuration(player2Ref),
|
||||
isFlac: player2?.container === 'flac',
|
||||
isTransitioning,
|
||||
nextPlayerRef: player1Ref,
|
||||
setIsTransitioning,
|
||||
});
|
||||
},
|
||||
[isTransitioning, player2?.container],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isElectron()) {
|
||||
if (audioDeviceId) {
|
||||
player1Ref.current?.getInternalPlayer()?.setSinkId(audioDeviceId);
|
||||
player2Ref.current?.getInternalPlayer()?.setSinkId(audioDeviceId);
|
||||
} else {
|
||||
player1Ref.current?.getInternalPlayer()?.setSinkId('');
|
||||
player2Ref.current?.getInternalPlayer()?.setSinkId('');
|
||||
}
|
||||
}
|
||||
}, [audioDeviceId]);
|
||||
useEffect(() => {
|
||||
if (isElectron()) {
|
||||
if (audioDeviceId) {
|
||||
player1Ref.current?.getInternalPlayer()?.setSinkId(audioDeviceId);
|
||||
player2Ref.current?.getInternalPlayer()?.setSinkId(audioDeviceId);
|
||||
} else {
|
||||
player1Ref.current?.getInternalPlayer()?.setSinkId('');
|
||||
player2Ref.current?.getInternalPlayer()?.setSinkId('');
|
||||
}
|
||||
}
|
||||
}, [audioDeviceId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ReactPlayer
|
||||
ref={player1Ref}
|
||||
height={0}
|
||||
muted={muted}
|
||||
playing={currentPlayer === 1 && status === PlayerStatus.PLAYING}
|
||||
progressInterval={isTransitioning ? 10 : 250}
|
||||
url={player1?.streamUrl}
|
||||
volume={volume}
|
||||
width={0}
|
||||
onEnded={handleOnEnded}
|
||||
onProgress={playbackStyle === PlaybackStyle.GAPLESS ? handleGapless1 : handleCrossfade1}
|
||||
/>
|
||||
<ReactPlayer
|
||||
ref={player2Ref}
|
||||
height={0}
|
||||
muted={muted}
|
||||
playing={currentPlayer === 2 && status === PlayerStatus.PLAYING}
|
||||
progressInterval={isTransitioning ? 10 : 250}
|
||||
url={player2?.streamUrl}
|
||||
volume={volume}
|
||||
width={0}
|
||||
onEnded={handleOnEnded}
|
||||
onProgress={playbackStyle === PlaybackStyle.GAPLESS ? handleGapless2 : handleCrossfade2}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
return (
|
||||
<>
|
||||
<ReactPlayer
|
||||
ref={player1Ref}
|
||||
height={0}
|
||||
muted={muted}
|
||||
playing={currentPlayer === 1 && status === PlayerStatus.PLAYING}
|
||||
progressInterval={isTransitioning ? 10 : 250}
|
||||
url={player1?.streamUrl}
|
||||
volume={volume}
|
||||
width={0}
|
||||
onEnded={handleOnEnded}
|
||||
onProgress={
|
||||
playbackStyle === PlaybackStyle.GAPLESS ? handleGapless1 : handleCrossfade1
|
||||
}
|
||||
/>
|
||||
<ReactPlayer
|
||||
ref={player2Ref}
|
||||
height={0}
|
||||
muted={muted}
|
||||
playing={currentPlayer === 2 && status === PlayerStatus.PLAYING}
|
||||
progressInterval={isTransitioning ? 10 : 250}
|
||||
url={player2?.streamUrl}
|
||||
volume={volume}
|
||||
width={0}
|
||||
onEnded={handleOnEnded}
|
||||
onProgress={
|
||||
playbackStyle === PlaybackStyle.GAPLESS ? handleGapless2 : handleCrossfade2
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,129 +3,131 @@ import type { Dispatch } from 'react';
|
|||
import { CrossfadeStyle } from '/@/renderer/types';
|
||||
|
||||
export const gaplessHandler = (args: {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
isFlac: boolean;
|
||||
isTransitioning: boolean;
|
||||
nextPlayerRef: any;
|
||||
setIsTransitioning: Dispatch<boolean>;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
isFlac: boolean;
|
||||
isTransitioning: boolean;
|
||||
nextPlayerRef: any;
|
||||
setIsTransitioning: Dispatch<boolean>;
|
||||
}) => {
|
||||
const { nextPlayerRef, currentTime, duration, isTransitioning, setIsTransitioning, isFlac } =
|
||||
args;
|
||||
const { nextPlayerRef, currentTime, duration, isTransitioning, setIsTransitioning, isFlac } =
|
||||
args;
|
||||
|
||||
if (!isTransitioning) {
|
||||
if (currentTime > duration - 2) {
|
||||
return setIsTransitioning(true);
|
||||
if (!isTransitioning) {
|
||||
if (currentTime > duration - 2) {
|
||||
return setIsTransitioning(true);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const durationPadding = isFlac ? 0.065 : 0.116;
|
||||
if (currentTime + durationPadding >= duration) {
|
||||
return nextPlayerRef.current.getInternalPlayer().play();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const durationPadding = isFlac ? 0.065 : 0.116;
|
||||
if (currentTime + durationPadding >= duration) {
|
||||
return nextPlayerRef.current.getInternalPlayer().play();
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const crossfadeHandler = (args: {
|
||||
currentPlayer: 1 | 2;
|
||||
currentPlayerRef: any;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
fadeDuration: number;
|
||||
fadeType: CrossfadeStyle;
|
||||
isTransitioning: boolean;
|
||||
nextPlayerRef: any;
|
||||
player: 1 | 2;
|
||||
setIsTransitioning: Dispatch<boolean>;
|
||||
volume: number;
|
||||
currentPlayer: 1 | 2;
|
||||
currentPlayerRef: any;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
fadeDuration: number;
|
||||
fadeType: CrossfadeStyle;
|
||||
isTransitioning: boolean;
|
||||
nextPlayerRef: any;
|
||||
player: 1 | 2;
|
||||
setIsTransitioning: Dispatch<boolean>;
|
||||
volume: number;
|
||||
}) => {
|
||||
const {
|
||||
currentTime,
|
||||
player,
|
||||
currentPlayer,
|
||||
currentPlayerRef,
|
||||
nextPlayerRef,
|
||||
fadeDuration,
|
||||
fadeType,
|
||||
duration,
|
||||
volume,
|
||||
isTransitioning,
|
||||
setIsTransitioning,
|
||||
} = args;
|
||||
const {
|
||||
currentTime,
|
||||
player,
|
||||
currentPlayer,
|
||||
currentPlayerRef,
|
||||
nextPlayerRef,
|
||||
fadeDuration,
|
||||
fadeType,
|
||||
duration,
|
||||
volume,
|
||||
isTransitioning,
|
||||
setIsTransitioning,
|
||||
} = args;
|
||||
|
||||
if (!isTransitioning || currentPlayer !== player) {
|
||||
const shouldBeginTransition = currentTime >= duration - fadeDuration;
|
||||
if (!isTransitioning || currentPlayer !== player) {
|
||||
const shouldBeginTransition = currentTime >= duration - fadeDuration;
|
||||
|
||||
if (shouldBeginTransition) {
|
||||
setIsTransitioning(true);
|
||||
return nextPlayerRef.current.getInternalPlayer().play();
|
||||
if (shouldBeginTransition) {
|
||||
setIsTransitioning(true);
|
||||
return nextPlayerRef.current.getInternalPlayer().play();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const timeLeft = duration - currentTime;
|
||||
let currentPlayerVolumeCalculation;
|
||||
let nextPlayerVolumeCalculation;
|
||||
let percentageOfFadeLeft;
|
||||
let n;
|
||||
switch (fadeType) {
|
||||
case 'equalPower':
|
||||
// https://dsp.stackexchange.com/a/14755
|
||||
percentageOfFadeLeft = (timeLeft / fadeDuration) * 2;
|
||||
currentPlayerVolumeCalculation = Math.sqrt(0.5 * percentageOfFadeLeft) * volume;
|
||||
nextPlayerVolumeCalculation = Math.sqrt(0.5 * (2 - percentageOfFadeLeft)) * volume;
|
||||
break;
|
||||
case 'linear':
|
||||
currentPlayerVolumeCalculation = (timeLeft / fadeDuration) * volume;
|
||||
nextPlayerVolumeCalculation = ((fadeDuration - timeLeft) / fadeDuration) * volume;
|
||||
break;
|
||||
case 'dipped':
|
||||
// https://math.stackexchange.com/a/4622
|
||||
percentageOfFadeLeft = timeLeft / fadeDuration;
|
||||
currentPlayerVolumeCalculation = percentageOfFadeLeft ** 2 * volume;
|
||||
nextPlayerVolumeCalculation = (percentageOfFadeLeft - 1) ** 2 * volume;
|
||||
break;
|
||||
case fadeType.match(/constantPower.*/)?.input:
|
||||
// https://math.stackexchange.com/a/26159
|
||||
n =
|
||||
fadeType === 'constantPower'
|
||||
? 0
|
||||
: fadeType === 'constantPowerSlowFade'
|
||||
? 1
|
||||
: fadeType === 'constantPowerSlowCut'
|
||||
? 3
|
||||
: 10;
|
||||
|
||||
percentageOfFadeLeft = timeLeft / fadeDuration;
|
||||
currentPlayerVolumeCalculation =
|
||||
Math.cos((Math.PI / 4) * ((2 * percentageOfFadeLeft - 1) ** (2 * n + 1) - 1)) *
|
||||
volume;
|
||||
nextPlayerVolumeCalculation =
|
||||
Math.cos((Math.PI / 4) * ((2 * percentageOfFadeLeft - 1) ** (2 * n + 1) + 1)) *
|
||||
volume;
|
||||
break;
|
||||
|
||||
default:
|
||||
currentPlayerVolumeCalculation = (timeLeft / fadeDuration) * volume;
|
||||
nextPlayerVolumeCalculation = ((fadeDuration - timeLeft) / fadeDuration) * volume;
|
||||
break;
|
||||
}
|
||||
|
||||
const currentPlayerVolume =
|
||||
currentPlayerVolumeCalculation >= 0 ? currentPlayerVolumeCalculation : 0;
|
||||
|
||||
const nextPlayerVolume =
|
||||
nextPlayerVolumeCalculation <= volume ? nextPlayerVolumeCalculation : volume;
|
||||
|
||||
if (currentPlayer === 1) {
|
||||
currentPlayerRef.current.getInternalPlayer().volume = currentPlayerVolume;
|
||||
nextPlayerRef.current.getInternalPlayer().volume = nextPlayerVolume;
|
||||
} else {
|
||||
currentPlayerRef.current.getInternalPlayer().volume = currentPlayerVolume;
|
||||
nextPlayerRef.current.getInternalPlayer().volume = nextPlayerVolume;
|
||||
}
|
||||
// }
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const timeLeft = duration - currentTime;
|
||||
let currentPlayerVolumeCalculation;
|
||||
let nextPlayerVolumeCalculation;
|
||||
let percentageOfFadeLeft;
|
||||
let n;
|
||||
switch (fadeType) {
|
||||
case 'equalPower':
|
||||
// https://dsp.stackexchange.com/a/14755
|
||||
percentageOfFadeLeft = (timeLeft / fadeDuration) * 2;
|
||||
currentPlayerVolumeCalculation = Math.sqrt(0.5 * percentageOfFadeLeft) * volume;
|
||||
nextPlayerVolumeCalculation = Math.sqrt(0.5 * (2 - percentageOfFadeLeft)) * volume;
|
||||
break;
|
||||
case 'linear':
|
||||
currentPlayerVolumeCalculation = (timeLeft / fadeDuration) * volume;
|
||||
nextPlayerVolumeCalculation = ((fadeDuration - timeLeft) / fadeDuration) * volume;
|
||||
break;
|
||||
case 'dipped':
|
||||
// https://math.stackexchange.com/a/4622
|
||||
percentageOfFadeLeft = timeLeft / fadeDuration;
|
||||
currentPlayerVolumeCalculation = percentageOfFadeLeft ** 2 * volume;
|
||||
nextPlayerVolumeCalculation = (percentageOfFadeLeft - 1) ** 2 * volume;
|
||||
break;
|
||||
case fadeType.match(/constantPower.*/)?.input:
|
||||
// https://math.stackexchange.com/a/26159
|
||||
n =
|
||||
fadeType === 'constantPower'
|
||||
? 0
|
||||
: fadeType === 'constantPowerSlowFade'
|
||||
? 1
|
||||
: fadeType === 'constantPowerSlowCut'
|
||||
? 3
|
||||
: 10;
|
||||
|
||||
percentageOfFadeLeft = timeLeft / fadeDuration;
|
||||
currentPlayerVolumeCalculation =
|
||||
Math.cos((Math.PI / 4) * ((2 * percentageOfFadeLeft - 1) ** (2 * n + 1) - 1)) * volume;
|
||||
nextPlayerVolumeCalculation =
|
||||
Math.cos((Math.PI / 4) * ((2 * percentageOfFadeLeft - 1) ** (2 * n + 1) + 1)) * volume;
|
||||
break;
|
||||
|
||||
default:
|
||||
currentPlayerVolumeCalculation = (timeLeft / fadeDuration) * volume;
|
||||
nextPlayerVolumeCalculation = ((fadeDuration - timeLeft) / fadeDuration) * volume;
|
||||
break;
|
||||
}
|
||||
|
||||
const currentPlayerVolume =
|
||||
currentPlayerVolumeCalculation >= 0 ? currentPlayerVolumeCalculation : 0;
|
||||
|
||||
const nextPlayerVolume =
|
||||
nextPlayerVolumeCalculation <= volume ? nextPlayerVolumeCalculation : volume;
|
||||
|
||||
if (currentPlayer === 1) {
|
||||
currentPlayerRef.current.getInternalPlayer().volume = currentPlayerVolume;
|
||||
nextPlayerRef.current.getInternalPlayer().volume = nextPlayerVolume;
|
||||
} else {
|
||||
currentPlayerRef.current.getInternalPlayer().volume = currentPlayerVolume;
|
||||
nextPlayerRef.current.getInternalPlayer().volume = nextPlayerVolume;
|
||||
}
|
||||
// }
|
||||
|
||||
return null;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,30 +5,30 @@ import styled from 'styled-components';
|
|||
export type BadgeProps = MantineBadgeProps;
|
||||
|
||||
const StyledBadge = styled(MantineBadge)<BadgeProps>`
|
||||
border-radius: var(--badge-radius);
|
||||
border-radius: var(--badge-radius);
|
||||
|
||||
.mantine-Badge-root {
|
||||
color: var(--badge-fg);
|
||||
}
|
||||
.mantine-Badge-root {
|
||||
color: var(--badge-fg);
|
||||
}
|
||||
|
||||
.mantine-Badge-inner {
|
||||
color: var(--badge-fg);
|
||||
}
|
||||
.mantine-Badge-inner {
|
||||
color: var(--badge-fg);
|
||||
}
|
||||
`;
|
||||
|
||||
const _Badge = ({ children, ...props }: BadgeProps) => {
|
||||
return (
|
||||
<StyledBadge
|
||||
radius="md"
|
||||
size="sm"
|
||||
styles={{
|
||||
root: { background: 'var(--badge-bg)' },
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</StyledBadge>
|
||||
);
|
||||
return (
|
||||
<StyledBadge
|
||||
radius="md"
|
||||
size="sm"
|
||||
styles={{
|
||||
root: { background: 'var(--badge-bg)' },
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</StyledBadge>
|
||||
);
|
||||
};
|
||||
|
||||
export const Badge = createPolymorphicComponent<'button', BadgeProps>(_Badge);
|
||||
|
|
|
|||
|
|
@ -8,177 +8,177 @@ import { Spinner } from '/@/renderer/components/spinner';
|
|||
import { Tooltip } from '/@/renderer/components/tooltip';
|
||||
|
||||
export interface ButtonProps extends MantineButtonProps {
|
||||
children: React.ReactNode;
|
||||
loading?: boolean;
|
||||
onClick?: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
onMouseDown?: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
tooltip?: Omit<TooltipProps, 'children'>;
|
||||
children: React.ReactNode;
|
||||
loading?: boolean;
|
||||
onClick?: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
onMouseDown?: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
tooltip?: Omit<TooltipProps, 'children'>;
|
||||
}
|
||||
|
||||
interface StyledButtonProps extends ButtonProps {
|
||||
ref: Ref<HTMLButtonElement>;
|
||||
ref: Ref<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
const StyledButton = styled(MantineButton)<StyledButtonProps>`
|
||||
color: ${(props) => `var(--btn-${props.variant}-fg)`};
|
||||
background: ${(props) => `var(--btn-${props.variant}-bg)`};
|
||||
border: ${(props) => `var(--btn-${props.variant}-border)`};
|
||||
border-radius: ${(props) => `var(--btn-${props.variant}-radius)`};
|
||||
transition: background 0.2s ease-in-out, color 0.2s ease-in-out, border 0.2s ease-in-out;
|
||||
|
||||
svg {
|
||||
transition: fill 0.2s ease-in-out;
|
||||
fill: ${(props) => `var(--btn-${props.variant}-fg)`};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: ${(props) => `var(--btn-${props.variant}-fg)`};
|
||||
background: ${(props) => `var(--btn-${props.variant}-bg)`};
|
||||
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&:not([data-disabled])&:hover {
|
||||
color: ${(props) => `var(--btn-${props.variant}-fg-hover) !important`};
|
||||
background: ${(props) => `var(--btn-${props.variant}-bg-hover)`};
|
||||
border: ${(props) => `var(--btn-${props.variant}-border-hover)`};
|
||||
border: ${(props) => `var(--btn-${props.variant}-border)`};
|
||||
border-radius: ${(props) => `var(--btn-${props.variant}-radius)`};
|
||||
transition: background 0.2s ease-in-out, color 0.2s ease-in-out, border 0.2s ease-in-out;
|
||||
|
||||
svg {
|
||||
fill: ${(props) => `var(--btn-${props.variant}-fg-hover)`};
|
||||
transition: fill 0.2s ease-in-out;
|
||||
fill: ${(props) => `var(--btn-${props.variant}-fg)`};
|
||||
}
|
||||
}
|
||||
|
||||
&:not([data-disabled])&:focus-visible {
|
||||
color: ${(props) => `var(--btn-${props.variant}-fg-hover)`};
|
||||
background: ${(props) => `var(--btn-${props.variant}-bg-hover)`};
|
||||
}
|
||||
&:disabled {
|
||||
color: ${(props) => `var(--btn-${props.variant}-fg)`};
|
||||
background: ${(props) => `var(--btn-${props.variant}-bg)`};
|
||||
|
||||
&:active {
|
||||
transform: none;
|
||||
}
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
& .mantine-Button-centerLoader {
|
||||
display: none;
|
||||
}
|
||||
&:not([data-disabled])&:hover {
|
||||
color: ${(props) => `var(--btn-${props.variant}-fg-hover) !important`};
|
||||
background: ${(props) => `var(--btn-${props.variant}-bg-hover)`};
|
||||
border: ${(props) => `var(--btn-${props.variant}-border-hover)`};
|
||||
|
||||
& .mantine-Button-leftIcon {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
margin-right: 0.5rem;
|
||||
transform: translateY(-0.1rem);
|
||||
}
|
||||
svg {
|
||||
fill: ${(props) => `var(--btn-${props.variant}-fg-hover)`};
|
||||
}
|
||||
}
|
||||
|
||||
.mantine-Button-rightIcon {
|
||||
display: flex;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
&:not([data-disabled])&:focus-visible {
|
||||
color: ${(props) => `var(--btn-${props.variant}-fg-hover)`};
|
||||
background: ${(props) => `var(--btn-${props.variant}-bg-hover)`};
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
& .mantine-Button-centerLoader {
|
||||
display: none;
|
||||
}
|
||||
|
||||
& .mantine-Button-leftIcon {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
margin-right: 0.5rem;
|
||||
transform: translateY(-0.1rem);
|
||||
}
|
||||
|
||||
.mantine-Button-rightIcon {
|
||||
display: flex;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
`;
|
||||
|
||||
const ButtonChildWrapper = styled.span<{ $loading?: boolean }>`
|
||||
color: ${(props) => props.$loading && 'transparent !important'};
|
||||
color: ${(props) => props.$loading && 'transparent !important'};
|
||||
`;
|
||||
|
||||
const SpinnerWrapper = styled.div`
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate3d(-50%, -50%, 0);
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate3d(-50%, -50%, 0);
|
||||
`;
|
||||
|
||||
export const _Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ children, tooltip, ...props }: ButtonProps, ref) => {
|
||||
if (tooltip) {
|
||||
return (
|
||||
<Tooltip
|
||||
withinPortal
|
||||
{...tooltip}
|
||||
>
|
||||
<StyledButton
|
||||
ref={ref}
|
||||
loaderPosition="center"
|
||||
{...props}
|
||||
>
|
||||
<ButtonChildWrapper $loading={props.loading}>{children}</ButtonChildWrapper>
|
||||
{props.loading && (
|
||||
<SpinnerWrapper>
|
||||
<Spinner />
|
||||
</SpinnerWrapper>
|
||||
)}
|
||||
</StyledButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
({ children, tooltip, ...props }: ButtonProps, ref) => {
|
||||
if (tooltip) {
|
||||
return (
|
||||
<Tooltip
|
||||
withinPortal
|
||||
{...tooltip}
|
||||
>
|
||||
<StyledButton
|
||||
ref={ref}
|
||||
loaderPosition="center"
|
||||
{...props}
|
||||
>
|
||||
<ButtonChildWrapper $loading={props.loading}>{children}</ButtonChildWrapper>
|
||||
{props.loading && (
|
||||
<SpinnerWrapper>
|
||||
<Spinner />
|
||||
</SpinnerWrapper>
|
||||
)}
|
||||
</StyledButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledButton
|
||||
ref={ref}
|
||||
loaderPosition="center"
|
||||
{...props}
|
||||
>
|
||||
<ButtonChildWrapper $loading={props.loading}>{children}</ButtonChildWrapper>
|
||||
{props.loading && (
|
||||
<SpinnerWrapper>
|
||||
<Spinner />
|
||||
</SpinnerWrapper>
|
||||
)}
|
||||
</StyledButton>
|
||||
);
|
||||
},
|
||||
return (
|
||||
<StyledButton
|
||||
ref={ref}
|
||||
loaderPosition="center"
|
||||
{...props}
|
||||
>
|
||||
<ButtonChildWrapper $loading={props.loading}>{children}</ButtonChildWrapper>
|
||||
{props.loading && (
|
||||
<SpinnerWrapper>
|
||||
<Spinner />
|
||||
</SpinnerWrapper>
|
||||
)}
|
||||
</StyledButton>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const Button = createPolymorphicComponent<'button', ButtonProps>(_Button);
|
||||
|
||||
_Button.defaultProps = {
|
||||
loading: undefined,
|
||||
onClick: undefined,
|
||||
tooltip: undefined,
|
||||
loading: undefined,
|
||||
onClick: undefined,
|
||||
tooltip: undefined,
|
||||
};
|
||||
|
||||
interface HoldButtonProps extends ButtonProps {
|
||||
timeoutProps: {
|
||||
callback: () => void;
|
||||
duration: number;
|
||||
};
|
||||
timeoutProps: {
|
||||
callback: () => void;
|
||||
duration: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const TimeoutButton = ({ timeoutProps, ...props }: HoldButtonProps) => {
|
||||
const [, setTimeoutRemaining] = useState(timeoutProps.duration);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const intervalRef = useRef(0);
|
||||
const [, setTimeoutRemaining] = useState(timeoutProps.duration);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const intervalRef = useRef(0);
|
||||
|
||||
const callback = () => {
|
||||
timeoutProps.callback();
|
||||
setTimeoutRemaining(timeoutProps.duration);
|
||||
clearInterval(intervalRef.current);
|
||||
setIsRunning(false);
|
||||
};
|
||||
const callback = () => {
|
||||
timeoutProps.callback();
|
||||
setTimeoutRemaining(timeoutProps.duration);
|
||||
clearInterval(intervalRef.current);
|
||||
setIsRunning(false);
|
||||
};
|
||||
|
||||
const { start, clear } = useTimeout(callback, timeoutProps.duration);
|
||||
const { start, clear } = useTimeout(callback, timeoutProps.duration);
|
||||
|
||||
const startTimeout = useCallback(() => {
|
||||
if (isRunning) {
|
||||
clearInterval(intervalRef.current);
|
||||
setIsRunning(false);
|
||||
clear();
|
||||
} else {
|
||||
setIsRunning(true);
|
||||
start();
|
||||
const startTimeout = useCallback(() => {
|
||||
if (isRunning) {
|
||||
clearInterval(intervalRef.current);
|
||||
setIsRunning(false);
|
||||
clear();
|
||||
} else {
|
||||
setIsRunning(true);
|
||||
start();
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
setTimeoutRemaining((prev) => prev - 100);
|
||||
}, 100);
|
||||
const intervalId = window.setInterval(() => {
|
||||
setTimeoutRemaining((prev) => prev - 100);
|
||||
}, 100);
|
||||
|
||||
intervalRef.current = intervalId;
|
||||
}
|
||||
}, [clear, isRunning, start]);
|
||||
intervalRef.current = intervalId;
|
||||
}
|
||||
}, [clear, isRunning, start]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
sx={{ color: 'var(--danger-color)' }}
|
||||
onClick={startTimeout}
|
||||
{...props}
|
||||
>
|
||||
{isRunning ? 'Cancel' : props.children}
|
||||
</Button>
|
||||
);
|
||||
return (
|
||||
<Button
|
||||
sx={{ color: 'var(--danger-color)' }}
|
||||
onClick={startTimeout}
|
||||
{...props}
|
||||
>
|
||||
{isRunning ? 'Cancel' : props.children}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,208 +11,208 @@ import { Album, AlbumArtist, Artist, LibraryItem } from '/@/renderer/api/types';
|
|||
import { CardRows } from '/@/renderer/components/card/card-rows';
|
||||
|
||||
const CardWrapper = styled.div<{
|
||||
link?: boolean;
|
||||
link?: boolean;
|
||||
}>`
|
||||
padding: 1rem;
|
||||
background: var(--card-default-bg);
|
||||
border-radius: var(--card-default-radius);
|
||||
cursor: ${({ link }) => link && 'pointer'};
|
||||
transition: border 0.2s ease-in-out, background 0.2s ease-in-out;
|
||||
padding: 1rem;
|
||||
background: var(--card-default-bg);
|
||||
border-radius: var(--card-default-radius);
|
||||
cursor: ${({ link }) => link && 'pointer'};
|
||||
transition: border 0.2s ease-in-out, background 0.2s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
background: var(--card-default-bg-hover);
|
||||
}
|
||||
|
||||
&:hover div {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover * {
|
||||
&::before {
|
||||
opacity: 0.5;
|
||||
&:hover {
|
||||
background: var(--card-default-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px solid #fff;
|
||||
}
|
||||
&:hover div {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover * {
|
||||
&::before {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px solid #fff;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledCard = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border-radius: var(--card-default-radius);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border-radius: var(--card-default-radius);
|
||||
`;
|
||||
|
||||
const ImageSection = styled.div`
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
border-radius: var(--card-default-radius);
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
border-radius: var(--card-default-radius);
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(0deg, rgba(0, 0, 0, 100%) 35%, rgba(0, 0, 0, 0%) 100%);
|
||||
opacity: 0;
|
||||
transition: all 0.2s ease-in-out;
|
||||
content: '';
|
||||
user-select: none;
|
||||
}
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(0deg, rgba(0, 0, 0, 100%) 35%, rgba(0, 0, 0, 0%) 100%);
|
||||
opacity: 0;
|
||||
transition: all 0.2s ease-in-out;
|
||||
content: '';
|
||||
user-select: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const Image = styled(SimpleImg)`
|
||||
border-radius: var(--card-default-radius);
|
||||
box-shadow: 2px 2px 10px 2px rgba(0, 0, 0, 20%);
|
||||
border-radius: var(--card-default-radius);
|
||||
box-shadow: 2px 2px 10px 2px rgba(0, 0, 0, 20%);
|
||||
`;
|
||||
|
||||
const ControlsContainer = styled.div`
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
z-index: 50;
|
||||
width: 100%;
|
||||
opacity: 0;
|
||||
transition: all 0.2s ease-in-out;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
z-index: 50;
|
||||
width: 100%;
|
||||
opacity: 0;
|
||||
transition: all 0.2s ease-in-out;
|
||||
`;
|
||||
|
||||
const DetailSection = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const Row = styled.div<{ $secondary?: boolean }>`
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 22px;
|
||||
padding: 0 0.2rem;
|
||||
overflow: hidden;
|
||||
color: ${({ $secondary }) => ($secondary ? 'var(--main-fg-secondary)' : 'var(--main-fg)')};
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
user-select: none;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 22px;
|
||||
padding: 0 0.2rem;
|
||||
overflow: hidden;
|
||||
color: ${({ $secondary }) => ($secondary ? 'var(--main-fg-secondary)' : 'var(--main-fg)')};
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
interface BaseGridCardProps {
|
||||
controls: {
|
||||
cardRows: CardRow<Album | Artist | AlbumArtist>[];
|
||||
itemType: LibraryItem;
|
||||
playButtonBehavior: Play;
|
||||
route: CardRoute;
|
||||
};
|
||||
data: any;
|
||||
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
|
||||
loading?: boolean;
|
||||
size: number;
|
||||
controls: {
|
||||
cardRows: CardRow<Album | Artist | AlbumArtist>[];
|
||||
itemType: LibraryItem;
|
||||
playButtonBehavior: Play;
|
||||
route: CardRoute;
|
||||
};
|
||||
data: any;
|
||||
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
|
||||
loading?: boolean;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export const AlbumCard = ({
|
||||
loading,
|
||||
size,
|
||||
handlePlayQueueAdd,
|
||||
data,
|
||||
controls,
|
||||
loading,
|
||||
size,
|
||||
handlePlayQueueAdd,
|
||||
data,
|
||||
controls,
|
||||
}: BaseGridCardProps) => {
|
||||
const navigate = useNavigate();
|
||||
const { itemType, cardRows, route } = controls;
|
||||
const navigate = useNavigate();
|
||||
const { itemType, cardRows, route } = controls;
|
||||
|
||||
const handleNavigate = useCallback(() => {
|
||||
navigate(
|
||||
generatePath(
|
||||
route.route,
|
||||
route.slugs?.reduce((acc, slug) => {
|
||||
return {
|
||||
...acc,
|
||||
[slug.slugProperty]: data[slug.idProperty],
|
||||
};
|
||||
}, {}),
|
||||
),
|
||||
);
|
||||
}, [data, navigate, route.route, route.slugs]);
|
||||
const handleNavigate = useCallback(() => {
|
||||
navigate(
|
||||
generatePath(
|
||||
route.route,
|
||||
route.slugs?.reduce((acc, slug) => {
|
||||
return {
|
||||
...acc,
|
||||
[slug.slugProperty]: data[slug.idProperty],
|
||||
};
|
||||
}, {}),
|
||||
),
|
||||
);
|
||||
}, [data, navigate, route.route, route.slugs]);
|
||||
|
||||
if (!loading) {
|
||||
return (
|
||||
<CardWrapper
|
||||
link
|
||||
onClick={handleNavigate}
|
||||
>
|
||||
<StyledCard>
|
||||
<ImageSection>
|
||||
{data?.imageUrl ? (
|
||||
<Image
|
||||
animationDuration={0.3}
|
||||
height={size}
|
||||
imgStyle={{ objectFit: 'cover' }}
|
||||
placeholder={data?.imagePlaceholderUrl || 'var(--card-default-bg)'}
|
||||
src={data?.imageUrl}
|
||||
width={size}
|
||||
/>
|
||||
) : (
|
||||
<Center
|
||||
sx={{
|
||||
background: 'var(--placeholder-bg)',
|
||||
borderRadius: 'var(--card-default-radius)',
|
||||
height: `${size}px`,
|
||||
width: `${size}px`,
|
||||
}}
|
||||
>
|
||||
<RiAlbumFill
|
||||
color="var(--placeholder-fg)"
|
||||
size={35}
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
<ControlsContainer>
|
||||
<CardControls
|
||||
handlePlayQueueAdd={handlePlayQueueAdd}
|
||||
itemData={data}
|
||||
itemType={itemType}
|
||||
/>
|
||||
</ControlsContainer>
|
||||
</ImageSection>
|
||||
<DetailSection>
|
||||
<CardRows
|
||||
data={data}
|
||||
rows={cardRows}
|
||||
/>
|
||||
</DetailSection>
|
||||
</StyledCard>
|
||||
</CardWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CardWrapper>
|
||||
<StyledCard style={{ alignItems: 'center', display: 'flex' }}>
|
||||
<Skeleton
|
||||
visible
|
||||
height={size}
|
||||
radius="sm"
|
||||
width={size}
|
||||
>
|
||||
<ImageSection />
|
||||
</Skeleton>
|
||||
<DetailSection style={{ width: '100%' }}>
|
||||
{cardRows.map((_row: CardRow<Album>, index: number) => (
|
||||
<Skeleton
|
||||
visible
|
||||
height={15}
|
||||
my={3}
|
||||
radius="md"
|
||||
width={!data ? (index > 0 ? '50%' : '90%') : '100%'}
|
||||
if (!loading) {
|
||||
return (
|
||||
<CardWrapper
|
||||
link
|
||||
onClick={handleNavigate}
|
||||
>
|
||||
<Row />
|
||||
</Skeleton>
|
||||
))}
|
||||
</DetailSection>
|
||||
</StyledCard>
|
||||
</CardWrapper>
|
||||
);
|
||||
<StyledCard>
|
||||
<ImageSection>
|
||||
{data?.imageUrl ? (
|
||||
<Image
|
||||
animationDuration={0.3}
|
||||
height={size}
|
||||
imgStyle={{ objectFit: 'cover' }}
|
||||
placeholder={data?.imagePlaceholderUrl || 'var(--card-default-bg)'}
|
||||
src={data?.imageUrl}
|
||||
width={size}
|
||||
/>
|
||||
) : (
|
||||
<Center
|
||||
sx={{
|
||||
background: 'var(--placeholder-bg)',
|
||||
borderRadius: 'var(--card-default-radius)',
|
||||
height: `${size}px`,
|
||||
width: `${size}px`,
|
||||
}}
|
||||
>
|
||||
<RiAlbumFill
|
||||
color="var(--placeholder-fg)"
|
||||
size={35}
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
<ControlsContainer>
|
||||
<CardControls
|
||||
handlePlayQueueAdd={handlePlayQueueAdd}
|
||||
itemData={data}
|
||||
itemType={itemType}
|
||||
/>
|
||||
</ControlsContainer>
|
||||
</ImageSection>
|
||||
<DetailSection>
|
||||
<CardRows
|
||||
data={data}
|
||||
rows={cardRows}
|
||||
/>
|
||||
</DetailSection>
|
||||
</StyledCard>
|
||||
</CardWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CardWrapper>
|
||||
<StyledCard style={{ alignItems: 'center', display: 'flex' }}>
|
||||
<Skeleton
|
||||
visible
|
||||
height={size}
|
||||
radius="sm"
|
||||
width={size}
|
||||
>
|
||||
<ImageSection />
|
||||
</Skeleton>
|
||||
<DetailSection style={{ width: '100%' }}>
|
||||
{cardRows.map((_row: CardRow<Album>, index: number) => (
|
||||
<Skeleton
|
||||
visible
|
||||
height={15}
|
||||
my={3}
|
||||
radius="md"
|
||||
width={!data ? (index > 0 ? '50%' : '90%') : '100%'}
|
||||
>
|
||||
<Row />
|
||||
</Skeleton>
|
||||
))}
|
||||
</DetailSection>
|
||||
</StyledCard>
|
||||
</CardWrapper>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,69 +11,69 @@ import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
|||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
import { useHandleGeneralContextMenu } from '/@/renderer/features/context-menu/hooks/use-handle-context-menu';
|
||||
import {
|
||||
ALBUM_CONTEXT_MENU_ITEMS,
|
||||
ARTIST_CONTEXT_MENU_ITEMS,
|
||||
ALBUM_CONTEXT_MENU_ITEMS,
|
||||
ARTIST_CONTEXT_MENU_ITEMS,
|
||||
} from '/@/renderer/features/context-menu/context-menu-items';
|
||||
|
||||
type PlayButtonType = UnstyledButtonProps & React.ComponentPropsWithoutRef<'button'>;
|
||||
|
||||
const PlayButton = styled.button<PlayButtonType>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
background-color: rgb(255, 255, 255);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
transition: scale 0.2s linear;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
background-color: rgb(255, 255, 255);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
transition: scale 0.2s linear;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
scale: 1.1;
|
||||
}
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
scale: 1.1;
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 1;
|
||||
scale: 1;
|
||||
}
|
||||
&:active {
|
||||
opacity: 1;
|
||||
scale: 1;
|
||||
}
|
||||
|
||||
svg {
|
||||
fill: rgb(0, 0, 0);
|
||||
stroke: rgb(0, 0, 0);
|
||||
}
|
||||
svg {
|
||||
fill: rgb(0, 0, 0);
|
||||
stroke: rgb(0, 0, 0);
|
||||
}
|
||||
`;
|
||||
|
||||
const SecondaryButton = styled(_Button)`
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
transition: scale 0.2s linear;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
transition: scale 0.2s linear;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
scale: 1.1;
|
||||
}
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
scale: 1.1;
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 1;
|
||||
scale: 1;
|
||||
}
|
||||
&:active {
|
||||
opacity: 1;
|
||||
scale: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const GridCardControlsContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const ControlsRow = styled.div`
|
||||
width: 100%;
|
||||
height: calc(100% / 3);
|
||||
width: 100%;
|
||||
height: calc(100% / 3);
|
||||
`;
|
||||
|
||||
// const TopControls = styled(ControlsRow)`
|
||||
|
|
@ -91,87 +91,87 @@ const ControlsRow = styled.div`
|
|||
// `;
|
||||
|
||||
const BottomControls = styled(ControlsRow)`
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 0.5rem;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 0.5rem;
|
||||
`;
|
||||
|
||||
const FavoriteWrapper = styled.span<{ isFavorite: boolean }>`
|
||||
svg {
|
||||
fill: ${(props) => props.isFavorite && 'var(--primary-color)'};
|
||||
}
|
||||
svg {
|
||||
fill: ${(props) => props.isFavorite && 'var(--primary-color)'};
|
||||
}
|
||||
`;
|
||||
|
||||
export const CardControls = ({
|
||||
itemData,
|
||||
itemType,
|
||||
handlePlayQueueAdd,
|
||||
}: {
|
||||
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
|
||||
itemData: any;
|
||||
itemType: LibraryItem;
|
||||
}) => {
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
const handlePlay = (e: MouseEvent<HTMLButtonElement>, playType?: Play) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: [itemData.id],
|
||||
type: itemType,
|
||||
},
|
||||
playType: playType || playButtonBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
const handleContextMenu = useHandleGeneralContextMenu(
|
||||
itemData,
|
||||
itemType,
|
||||
itemType === LibraryItem.ALBUM ? ALBUM_CONTEXT_MENU_ITEMS : ARTIST_CONTEXT_MENU_ITEMS,
|
||||
);
|
||||
handlePlayQueueAdd,
|
||||
}: {
|
||||
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
|
||||
itemData: any;
|
||||
itemType: LibraryItem;
|
||||
}) => {
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
return (
|
||||
<GridCardControlsContainer>
|
||||
<BottomControls>
|
||||
<PlayButton onClick={handlePlay}>
|
||||
<RiPlayFill size={25} />
|
||||
</PlayButton>
|
||||
<Group spacing="xs">
|
||||
<SecondaryButton
|
||||
disabled
|
||||
p={5}
|
||||
sx={{ svg: { fill: 'white !important' } }}
|
||||
variant="subtle"
|
||||
>
|
||||
<FavoriteWrapper isFavorite={itemData?.isFavorite}>
|
||||
{itemData?.isFavorite ? (
|
||||
<RiHeartFill size={20} />
|
||||
) : (
|
||||
<RiHeartLine
|
||||
color="white"
|
||||
size={20}
|
||||
/>
|
||||
)}
|
||||
</FavoriteWrapper>
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
p={5}
|
||||
sx={{ svg: { fill: 'white !important' } }}
|
||||
variant="subtle"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleContextMenu(e, [itemData]);
|
||||
}}
|
||||
>
|
||||
<RiMore2Fill
|
||||
color="white"
|
||||
size={20}
|
||||
/>
|
||||
</SecondaryButton>
|
||||
</Group>
|
||||
</BottomControls>
|
||||
</GridCardControlsContainer>
|
||||
);
|
||||
const handlePlay = (e: MouseEvent<HTMLButtonElement>, playType?: Play) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: [itemData.id],
|
||||
type: itemType,
|
||||
},
|
||||
playType: playType || playButtonBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
const handleContextMenu = useHandleGeneralContextMenu(
|
||||
itemType,
|
||||
itemType === LibraryItem.ALBUM ? ALBUM_CONTEXT_MENU_ITEMS : ARTIST_CONTEXT_MENU_ITEMS,
|
||||
);
|
||||
|
||||
return (
|
||||
<GridCardControlsContainer>
|
||||
<BottomControls>
|
||||
<PlayButton onClick={handlePlay}>
|
||||
<RiPlayFill size={25} />
|
||||
</PlayButton>
|
||||
<Group spacing="xs">
|
||||
<SecondaryButton
|
||||
disabled
|
||||
p={5}
|
||||
sx={{ svg: { fill: 'white !important' } }}
|
||||
variant="subtle"
|
||||
>
|
||||
<FavoriteWrapper isFavorite={itemData?.isFavorite}>
|
||||
{itemData?.isFavorite ? (
|
||||
<RiHeartFill size={20} />
|
||||
) : (
|
||||
<RiHeartLine
|
||||
color="white"
|
||||
size={20}
|
||||
/>
|
||||
)}
|
||||
</FavoriteWrapper>
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
p={5}
|
||||
sx={{ svg: { fill: 'white !important' } }}
|
||||
variant="subtle"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleContextMenu(e, [itemData]);
|
||||
}}
|
||||
>
|
||||
<RiMore2Fill
|
||||
color="white"
|
||||
size={20}
|
||||
/>
|
||||
</SecondaryButton>
|
||||
</Group>
|
||||
</BottomControls>
|
||||
</GridCardControlsContainer>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,205 +8,208 @@ import { AppRoute } from '/@/renderer/router/routes';
|
|||
import { CardRow } from '/@/renderer/types';
|
||||
|
||||
const Row = styled.div<{ $secondary?: boolean }>`
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 22px;
|
||||
padding: 0 0.2rem;
|
||||
overflow: hidden;
|
||||
color: ${({ $secondary }) => ($secondary ? 'var(--main-fg-secondary)' : 'var(--main-fg)')};
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
user-select: none;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 22px;
|
||||
padding: 0 0.2rem;
|
||||
overflow: hidden;
|
||||
color: ${({ $secondary }) => ($secondary ? 'var(--main-fg-secondary)' : 'var(--main-fg)')};
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
interface CardRowsProps {
|
||||
data: any;
|
||||
rows: CardRow<Album>[] | CardRow<Artist>[] | CardRow<AlbumArtist>[];
|
||||
data: any;
|
||||
rows: CardRow<Album>[] | CardRow<Artist>[] | CardRow<AlbumArtist>[];
|
||||
}
|
||||
|
||||
export const CardRows = ({ data, rows }: CardRowsProps) => {
|
||||
return (
|
||||
<>
|
||||
{rows.map((row, index: number) => {
|
||||
if (row.arrayProperty && row.route) {
|
||||
return (
|
||||
<Row
|
||||
key={`row-${row.property}-${index}`}
|
||||
$secondary={index > 0}
|
||||
>
|
||||
{data[row.property].map((item: any, itemIndex: number) => (
|
||||
<React.Fragment key={`${data.id}-${item.id}`}>
|
||||
{itemIndex > 0 && (
|
||||
<Text
|
||||
$noSelect
|
||||
$secondary
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
padding: '0 2px 0 1px',
|
||||
}}
|
||||
>
|
||||
,
|
||||
</Text>
|
||||
)}{' '}
|
||||
<Text
|
||||
$link
|
||||
$noSelect
|
||||
$secondary={index > 0}
|
||||
component={Link}
|
||||
overflow="hidden"
|
||||
size={index > 0 ? 'sm' : 'md'}
|
||||
to={generatePath(
|
||||
row.route!.route,
|
||||
row.route!.slugs?.reduce((acc, slug) => {
|
||||
return {
|
||||
...acc,
|
||||
[slug.slugProperty]: data[row.property][itemIndex][slug.idProperty],
|
||||
};
|
||||
}, {}),
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{row.arrayProperty && item[row.arrayProperty]}
|
||||
</Text>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{rows.map((row, index: number) => {
|
||||
if (row.arrayProperty && row.route) {
|
||||
return (
|
||||
<Row
|
||||
key={`row-${row.property}-${index}`}
|
||||
$secondary={index > 0}
|
||||
>
|
||||
{data[row.property].map((item: any, itemIndex: number) => (
|
||||
<React.Fragment key={`${data.id}-${item.id}`}>
|
||||
{itemIndex > 0 && (
|
||||
<Text
|
||||
$noSelect
|
||||
$secondary
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
padding: '0 2px 0 1px',
|
||||
}}
|
||||
>
|
||||
,
|
||||
</Text>
|
||||
)}{' '}
|
||||
<Text
|
||||
$link
|
||||
$noSelect
|
||||
$secondary={index > 0}
|
||||
component={Link}
|
||||
overflow="hidden"
|
||||
size={index > 0 ? 'sm' : 'md'}
|
||||
to={generatePath(
|
||||
row.route!.route,
|
||||
row.route!.slugs?.reduce((acc, slug) => {
|
||||
return {
|
||||
...acc,
|
||||
[slug.slugProperty]:
|
||||
data[row.property][itemIndex][
|
||||
slug.idProperty
|
||||
],
|
||||
};
|
||||
}, {}),
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{row.arrayProperty && item[row.arrayProperty]}
|
||||
</Text>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
if (row.arrayProperty) {
|
||||
return (
|
||||
<Row key={`row-${row.property}`}>
|
||||
{data[row.property].map((item: any) => (
|
||||
<Text
|
||||
key={`${data.id}-${item.id}`}
|
||||
$noSelect
|
||||
$secondary={index > 0}
|
||||
overflow="hidden"
|
||||
size={index > 0 ? 'sm' : 'md'}
|
||||
>
|
||||
{row.arrayProperty && item[row.arrayProperty]}
|
||||
</Text>
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
if (row.arrayProperty) {
|
||||
return (
|
||||
<Row key={`row-${row.property}`}>
|
||||
{data[row.property].map((item: any) => (
|
||||
<Text
|
||||
key={`${data.id}-${item.id}`}
|
||||
$noSelect
|
||||
$secondary={index > 0}
|
||||
overflow="hidden"
|
||||
size={index > 0 ? 'sm' : 'md'}
|
||||
>
|
||||
{row.arrayProperty && item[row.arrayProperty]}
|
||||
</Text>
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Row key={`row-${row.property}`}>
|
||||
{row.route ? (
|
||||
<Text
|
||||
$link
|
||||
$noSelect
|
||||
component={Link}
|
||||
overflow="hidden"
|
||||
to={generatePath(
|
||||
row.route.route,
|
||||
row.route.slugs?.reduce((acc, slug) => {
|
||||
return {
|
||||
...acc,
|
||||
[slug.slugProperty]: data[slug.idProperty],
|
||||
};
|
||||
}, {}),
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{data && data[row.property]}
|
||||
</Text>
|
||||
) : (
|
||||
<Text
|
||||
$noSelect
|
||||
$secondary={index > 0}
|
||||
overflow="hidden"
|
||||
size={index > 0 ? 'sm' : 'md'}
|
||||
>
|
||||
{data && data[row.property]}
|
||||
</Text>
|
||||
)}
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<Row key={`row-${row.property}`}>
|
||||
{row.route ? (
|
||||
<Text
|
||||
$link
|
||||
$noSelect
|
||||
component={Link}
|
||||
overflow="hidden"
|
||||
to={generatePath(
|
||||
row.route.route,
|
||||
row.route.slugs?.reduce((acc, slug) => {
|
||||
return {
|
||||
...acc,
|
||||
[slug.slugProperty]: data[slug.idProperty],
|
||||
};
|
||||
}, {}),
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{data && data[row.property]}
|
||||
</Text>
|
||||
) : (
|
||||
<Text
|
||||
$noSelect
|
||||
$secondary={index > 0}
|
||||
overflow="hidden"
|
||||
size={index > 0 ? 'sm' : 'md'}
|
||||
>
|
||||
{data && data[row.property]}
|
||||
</Text>
|
||||
)}
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ALBUM_CARD_ROWS: { [key: string]: CardRow<Album> } = {
|
||||
albumArtists: {
|
||||
arrayProperty: 'name',
|
||||
property: 'albumArtists',
|
||||
route: {
|
||||
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
|
||||
albumArtists: {
|
||||
arrayProperty: 'name',
|
||||
property: 'albumArtists',
|
||||
route: {
|
||||
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
artists: {
|
||||
arrayProperty: 'name',
|
||||
property: 'artists',
|
||||
route: {
|
||||
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
|
||||
artists: {
|
||||
arrayProperty: 'name',
|
||||
property: 'artists',
|
||||
route: {
|
||||
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
createdAt: {
|
||||
property: 'createdAt',
|
||||
},
|
||||
duration: {
|
||||
property: 'duration',
|
||||
},
|
||||
lastPlayedAt: {
|
||||
property: 'lastPlayedAt',
|
||||
},
|
||||
name: {
|
||||
property: 'name',
|
||||
route: {
|
||||
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
|
||||
createdAt: {
|
||||
property: 'createdAt',
|
||||
},
|
||||
duration: {
|
||||
property: 'duration',
|
||||
},
|
||||
lastPlayedAt: {
|
||||
property: 'lastPlayedAt',
|
||||
},
|
||||
name: {
|
||||
property: 'name',
|
||||
route: {
|
||||
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
|
||||
},
|
||||
},
|
||||
playCount: {
|
||||
property: 'playCount',
|
||||
},
|
||||
rating: {
|
||||
property: 'userRating',
|
||||
},
|
||||
releaseDate: {
|
||||
property: 'releaseDate',
|
||||
},
|
||||
releaseYear: {
|
||||
property: 'releaseYear',
|
||||
},
|
||||
songCount: {
|
||||
property: 'songCount',
|
||||
},
|
||||
},
|
||||
playCount: {
|
||||
property: 'playCount',
|
||||
},
|
||||
rating: {
|
||||
property: 'userRating',
|
||||
},
|
||||
releaseDate: {
|
||||
property: 'releaseDate',
|
||||
},
|
||||
releaseYear: {
|
||||
property: 'releaseYear',
|
||||
},
|
||||
songCount: {
|
||||
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_ALBUM_ARTISTS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
|
||||
albumCount: {
|
||||
property: 'albumCount',
|
||||
},
|
||||
duration: {
|
||||
property: 'duration',
|
||||
},
|
||||
genres: {
|
||||
property: 'genres',
|
||||
},
|
||||
lastPlayedAt: {
|
||||
property: 'lastPlayedAt',
|
||||
},
|
||||
name: {
|
||||
property: 'name',
|
||||
route: {
|
||||
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
|
||||
},
|
||||
},
|
||||
playCount: {
|
||||
property: 'playCount',
|
||||
},
|
||||
rating: {
|
||||
property: 'userRating',
|
||||
},
|
||||
songCount: {
|
||||
property: 'songCount',
|
||||
},
|
||||
},
|
||||
playCount: {
|
||||
property: 'playCount',
|
||||
},
|
||||
rating: {
|
||||
property: 'userRating',
|
||||
},
|
||||
songCount: {
|
||||
property: 'songCount',
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,197 +10,197 @@ import { GridCardControls } from '/@/renderer/components/virtual-grid/grid-card/
|
|||
import { CardRow, PlayQueueAddOptions, Play, CardRoute } from '/@/renderer/types';
|
||||
|
||||
interface BaseGridCardProps {
|
||||
controls: {
|
||||
cardRows: CardRow<Album>[] | CardRow<Artist>[] | CardRow<AlbumArtist>[];
|
||||
handleFavorite: (options: {
|
||||
id: string[];
|
||||
isFavorite: boolean;
|
||||
itemType: LibraryItem;
|
||||
serverId: string;
|
||||
}) => void;
|
||||
handlePlayQueueAdd: ((options: PlayQueueAddOptions) => void) | undefined;
|
||||
itemType: LibraryItem;
|
||||
playButtonBehavior: Play;
|
||||
route: CardRoute;
|
||||
};
|
||||
data: any;
|
||||
isLoading?: boolean;
|
||||
controls: {
|
||||
cardRows: CardRow<Album>[] | CardRow<Artist>[] | CardRow<AlbumArtist>[];
|
||||
handleFavorite: (options: {
|
||||
id: string[];
|
||||
isFavorite: boolean;
|
||||
itemType: LibraryItem;
|
||||
serverId: string;
|
||||
}) => void;
|
||||
handlePlayQueueAdd: ((options: PlayQueueAddOptions) => void) | undefined;
|
||||
itemType: LibraryItem;
|
||||
playButtonBehavior: Play;
|
||||
route: CardRoute;
|
||||
};
|
||||
data: any;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const PosterCardContainer = styled.div<{ $isHidden?: boolean }>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
opacity: ${({ $isHidden }) => ($isHidden ? 0 : 1)};
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
opacity: ${({ $isHidden }) => ($isHidden ? 0 : 1)};
|
||||
pointer-events: auto;
|
||||
|
||||
.card-controls {
|
||||
opacity: 0;
|
||||
}
|
||||
.card-controls {
|
||||
opacity: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const ImageContainerStyles = css`
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
aspect-ratio: 1/1;
|
||||
overflow: hidden;
|
||||
background: var(--card-default-bg);
|
||||
border-radius: var(--card-poster-radius);
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
aspect-ratio: 1/1;
|
||||
overflow: hidden;
|
||||
background: var(--card-default-bg);
|
||||
border-radius: var(--card-poster-radius);
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(0deg, rgba(0, 0, 0, 100%) 35%, rgba(0, 0, 0, 0%) 100%);
|
||||
opacity: 0;
|
||||
transition: all 0.2s ease-in-out;
|
||||
content: '';
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
&::before {
|
||||
opacity: 0.5;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(0deg, rgba(0, 0, 0, 100%) 35%, rgba(0, 0, 0, 0%) 100%);
|
||||
opacity: 0;
|
||||
transition: all 0.2s ease-in-out;
|
||||
content: '';
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .card-controls {
|
||||
opacity: 1;
|
||||
}
|
||||
&:hover {
|
||||
&::before {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .card-controls {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const ImageContainer = styled(Link)<{ $isFavorite?: boolean }>`
|
||||
${ImageContainerStyles}
|
||||
${ImageContainerStyles}
|
||||
`;
|
||||
|
||||
const ImageContainerSkeleton = styled.div`
|
||||
${ImageContainerStyles}
|
||||
${ImageContainerStyles}
|
||||
`;
|
||||
|
||||
const Image = styled(SimpleImg)`
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100% !important;
|
||||
max-height: 100%;
|
||||
border: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100% !important;
|
||||
max-height: 100%;
|
||||
border: 0;
|
||||
|
||||
img {
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
img {
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
`;
|
||||
|
||||
const DetailContainer = styled.div`
|
||||
margin-top: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
`;
|
||||
|
||||
export const PosterCard = ({
|
||||
data,
|
||||
controls,
|
||||
isLoading,
|
||||
uniqueId,
|
||||
data,
|
||||
controls,
|
||||
isLoading,
|
||||
uniqueId,
|
||||
}: BaseGridCardProps & { uniqueId: string }) => {
|
||||
if (!isLoading) {
|
||||
const path = generatePath(
|
||||
controls.route.route,
|
||||
controls.route.slugs?.reduce((acc, slug) => {
|
||||
return {
|
||||
...acc,
|
||||
[slug.slugProperty]: data[slug.idProperty],
|
||||
};
|
||||
}, {}),
|
||||
);
|
||||
if (!isLoading) {
|
||||
const path = generatePath(
|
||||
controls.route.route,
|
||||
controls.route.slugs?.reduce((acc, slug) => {
|
||||
return {
|
||||
...acc,
|
||||
[slug.slugProperty]: data[slug.idProperty],
|
||||
};
|
||||
}, {}),
|
||||
);
|
||||
|
||||
let Placeholder = RiAlbumFill;
|
||||
let Placeholder = RiAlbumFill;
|
||||
|
||||
switch (controls.itemType) {
|
||||
case LibraryItem.ALBUM:
|
||||
Placeholder = RiAlbumFill;
|
||||
break;
|
||||
case LibraryItem.ARTIST:
|
||||
Placeholder = RiUserVoiceFill;
|
||||
break;
|
||||
case LibraryItem.ALBUM_ARTIST:
|
||||
Placeholder = RiUserVoiceFill;
|
||||
break;
|
||||
case LibraryItem.PLAYLIST:
|
||||
Placeholder = RiPlayListFill;
|
||||
break;
|
||||
default:
|
||||
Placeholder = RiAlbumFill;
|
||||
break;
|
||||
switch (controls.itemType) {
|
||||
case LibraryItem.ALBUM:
|
||||
Placeholder = RiAlbumFill;
|
||||
break;
|
||||
case LibraryItem.ARTIST:
|
||||
Placeholder = RiUserVoiceFill;
|
||||
break;
|
||||
case LibraryItem.ALBUM_ARTIST:
|
||||
Placeholder = RiUserVoiceFill;
|
||||
break;
|
||||
case LibraryItem.PLAYLIST:
|
||||
Placeholder = RiPlayListFill;
|
||||
break;
|
||||
default:
|
||||
Placeholder = RiAlbumFill;
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<PosterCardContainer key={`${uniqueId}-${data.id}`}>
|
||||
<ImageContainer
|
||||
$isFavorite={data?.userFavorite}
|
||||
to={path}
|
||||
>
|
||||
{data?.imageUrl ? (
|
||||
<Image
|
||||
importance="auto"
|
||||
placeholder={data?.imagePlaceholderUrl || 'var(--card-default-bg)'}
|
||||
src={data?.imageUrl}
|
||||
/>
|
||||
) : (
|
||||
<Center
|
||||
sx={{
|
||||
background: 'var(--placeholder-bg)',
|
||||
borderRadius: 'var(--card-default-radius)',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Placeholder
|
||||
color="var(--placeholder-fg)"
|
||||
size={35}
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
<GridCardControls
|
||||
handleFavorite={controls.handleFavorite}
|
||||
handlePlayQueueAdd={controls.handlePlayQueueAdd}
|
||||
itemData={data}
|
||||
itemType={controls.itemType}
|
||||
/>
|
||||
</ImageContainer>
|
||||
<DetailContainer>
|
||||
<CardRows
|
||||
data={data}
|
||||
rows={controls.cardRows}
|
||||
/>
|
||||
</DetailContainer>
|
||||
</PosterCardContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PosterCardContainer key={`${uniqueId}-${data.id}`}>
|
||||
<ImageContainer
|
||||
$isFavorite={data?.userFavorite}
|
||||
to={path}
|
||||
>
|
||||
{data?.imageUrl ? (
|
||||
<Image
|
||||
importance="auto"
|
||||
placeholder={data?.imagePlaceholderUrl || 'var(--card-default-bg)'}
|
||||
src={data?.imageUrl}
|
||||
/>
|
||||
) : (
|
||||
<Center
|
||||
sx={{
|
||||
background: 'var(--placeholder-bg)',
|
||||
borderRadius: 'var(--card-default-radius)',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Placeholder
|
||||
color="var(--placeholder-fg)"
|
||||
size={35}
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
<GridCardControls
|
||||
handleFavorite={controls.handleFavorite}
|
||||
handlePlayQueueAdd={controls.handlePlayQueueAdd}
|
||||
itemData={data}
|
||||
itemType={controls.itemType}
|
||||
/>
|
||||
</ImageContainer>
|
||||
<DetailContainer>
|
||||
<CardRows
|
||||
data={data}
|
||||
rows={controls.cardRows}
|
||||
/>
|
||||
</DetailContainer>
|
||||
</PosterCardContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PosterCardContainer key={`placeholder-${uniqueId}-${data.id}`}>
|
||||
<Skeleton
|
||||
visible
|
||||
radius="sm"
|
||||
>
|
||||
<ImageContainerSkeleton />
|
||||
</Skeleton>
|
||||
<DetailContainer>
|
||||
<Stack spacing="sm">
|
||||
{controls.cardRows.map((row, index) => (
|
||||
<PosterCardContainer key={`placeholder-${uniqueId}-${data.id}`}>
|
||||
<Skeleton
|
||||
key={`${index}-${row.arrayProperty}`}
|
||||
visible
|
||||
height={14}
|
||||
radius="sm"
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</DetailContainer>
|
||||
</PosterCardContainer>
|
||||
);
|
||||
visible
|
||||
radius="sm"
|
||||
>
|
||||
<ImageContainerSkeleton />
|
||||
</Skeleton>
|
||||
<DetailContainer>
|
||||
<Stack spacing="sm">
|
||||
{controls.cardRows.map((row, index) => (
|
||||
<Skeleton
|
||||
key={`${index}-${row.arrayProperty}`}
|
||||
visible
|
||||
height={14}
|
||||
radius="sm"
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</DetailContainer>
|
||||
</PosterCardContainer>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,30 +3,30 @@ import { Checkbox as MantineCheckbox, CheckboxProps } from '@mantine/core';
|
|||
import styled from 'styled-components';
|
||||
|
||||
const StyledCheckbox = styled(MantineCheckbox)`
|
||||
& .mantine-Checkbox-input {
|
||||
background-color: var(--input-bg);
|
||||
& .mantine-Checkbox-input {
|
||||
background-color: var(--input-bg);
|
||||
|
||||
&:checked {
|
||||
background-color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
&:checked {
|
||||
background-color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
&:hover:not(:checked) {
|
||||
background-color: var(--primary-color);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
transition: none;
|
||||
}
|
||||
|
||||
&:hover:not(:checked) {
|
||||
background-color: var(--primary-color);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
transition: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
({ ...props }: CheckboxProps, ref) => {
|
||||
return (
|
||||
<StyledCheckbox
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
({ ...props }: CheckboxProps, ref) => {
|
||||
return (
|
||||
<StyledCheckbox
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,124 +4,124 @@ import { motion, Variants } from 'framer-motion';
|
|||
import styled from 'styled-components';
|
||||
|
||||
interface ContextMenuProps {
|
||||
children: ReactNode;
|
||||
maxWidth?: number;
|
||||
minWidth?: number;
|
||||
xPos: number;
|
||||
yPos: number;
|
||||
children: ReactNode;
|
||||
maxWidth?: number;
|
||||
minWidth?: number;
|
||||
xPos: number;
|
||||
yPos: number;
|
||||
}
|
||||
|
||||
const ContextMenuContainer = styled(motion.div)<Omit<ContextMenuProps, 'children'>>`
|
||||
position: absolute;
|
||||
top: ${({ yPos }) => yPos}px !important;
|
||||
left: ${({ xPos }) => xPos}px !important;
|
||||
z-index: 1000;
|
||||
min-width: ${({ minWidth }) => minWidth}px;
|
||||
max-width: ${({ maxWidth }) => maxWidth}px;
|
||||
background: var(--dropdown-menu-bg);
|
||||
border-radius: var(--dropdown-menu-border-radius);
|
||||
box-shadow: 2px 2px 10px 2px rgba(0, 0, 0, 40%);
|
||||
position: absolute;
|
||||
top: ${({ yPos }) => yPos}px !important;
|
||||
left: ${({ xPos }) => xPos}px !important;
|
||||
z-index: 1000;
|
||||
min-width: ${({ minWidth }) => minWidth}px;
|
||||
max-width: ${({ maxWidth }) => maxWidth}px;
|
||||
background: var(--dropdown-menu-bg);
|
||||
border-radius: var(--dropdown-menu-border-radius);
|
||||
box-shadow: 2px 2px 10px 2px rgba(0, 0, 0, 40%);
|
||||
|
||||
button:first-child {
|
||||
border-top-left-radius: var(--dropdown-menu-border-radius);
|
||||
border-top-right-radius: var(--dropdown-menu-border-radius);
|
||||
}
|
||||
button:first-child {
|
||||
border-top-left-radius: var(--dropdown-menu-border-radius);
|
||||
border-top-right-radius: var(--dropdown-menu-border-radius);
|
||||
}
|
||||
|
||||
button:last-child {
|
||||
border-bottom-right-radius: var(--dropdown-menu-border-radius);
|
||||
border-bottom-left-radius: var(--dropdown-menu-border-radius);
|
||||
}
|
||||
button:last-child {
|
||||
border-bottom-right-radius: var(--dropdown-menu-border-radius);
|
||||
border-bottom-left-radius: var(--dropdown-menu-border-radius);
|
||||
}
|
||||
`;
|
||||
|
||||
export const StyledContextMenuButton = styled(UnstyledButton)`
|
||||
padding: var(--dropdown-menu-item-padding);
|
||||
color: var(--dropdown-menu-fg);
|
||||
font-weight: 500;
|
||||
font-family: var(--content-font-family);
|
||||
text-align: left;
|
||||
background: var(--dropdown-menu-bg);
|
||||
border: none;
|
||||
cursor: default;
|
||||
padding: var(--dropdown-menu-item-padding);
|
||||
color: var(--dropdown-menu-fg);
|
||||
font-weight: 500;
|
||||
font-family: var(--content-font-family);
|
||||
text-align: left;
|
||||
background: var(--dropdown-menu-bg);
|
||||
border: none;
|
||||
cursor: default;
|
||||
|
||||
& .mantine-Button-inner {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
& .mantine-Button-inner {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--dropdown-menu-bg-hover);
|
||||
}
|
||||
&:hover {
|
||||
background: var(--dropdown-menu-bg-hover);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: transparent;
|
||||
opacity: 0.6;
|
||||
}
|
||||
&:disabled {
|
||||
background: transparent;
|
||||
opacity: 0.6;
|
||||
}
|
||||
`;
|
||||
|
||||
export const ContextMenuButton = forwardRef(
|
||||
(
|
||||
{
|
||||
children,
|
||||
rightIcon,
|
||||
leftIcon,
|
||||
...props
|
||||
}: UnstyledButtonProps &
|
||||
React.ComponentPropsWithoutRef<'button'> & {
|
||||
leftIcon?: ReactNode;
|
||||
rightIcon?: ReactNode;
|
||||
},
|
||||
ref: any,
|
||||
) => {
|
||||
return (
|
||||
<StyledContextMenuButton
|
||||
{...props}
|
||||
key={props.key}
|
||||
ref={ref}
|
||||
as="button"
|
||||
disabled={props.disabled}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<Group position="apart">
|
||||
<Group spacing="md">
|
||||
<Box>{leftIcon}</Box>
|
||||
<Box mr="2rem">{children}</Box>
|
||||
</Group>
|
||||
<Box>{rightIcon}</Box>
|
||||
</Group>
|
||||
</StyledContextMenuButton>
|
||||
);
|
||||
},
|
||||
(
|
||||
{
|
||||
children,
|
||||
rightIcon,
|
||||
leftIcon,
|
||||
...props
|
||||
}: UnstyledButtonProps &
|
||||
React.ComponentPropsWithoutRef<'button'> & {
|
||||
leftIcon?: ReactNode;
|
||||
rightIcon?: ReactNode;
|
||||
},
|
||||
ref: any,
|
||||
) => {
|
||||
return (
|
||||
<StyledContextMenuButton
|
||||
{...props}
|
||||
key={props.key}
|
||||
ref={ref}
|
||||
as="button"
|
||||
disabled={props.disabled}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<Group position="apart">
|
||||
<Group spacing="md">
|
||||
<Box>{leftIcon}</Box>
|
||||
<Box mr="2rem">{children}</Box>
|
||||
</Group>
|
||||
<Box>{rightIcon}</Box>
|
||||
</Group>
|
||||
</StyledContextMenuButton>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const variants: Variants = {
|
||||
closed: {
|
||||
opacity: 0,
|
||||
transition: {
|
||||
duration: 0.1,
|
||||
closed: {
|
||||
opacity: 0,
|
||||
transition: {
|
||||
duration: 0.1,
|
||||
},
|
||||
},
|
||||
},
|
||||
open: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 0.1,
|
||||
open: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 0.1,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ContextMenu = forwardRef(
|
||||
({ yPos, xPos, minWidth, maxWidth, children }: ContextMenuProps, ref: Ref<HTMLDivElement>) => {
|
||||
return (
|
||||
<ContextMenuContainer
|
||||
ref={ref}
|
||||
animate="open"
|
||||
initial="closed"
|
||||
maxWidth={maxWidth}
|
||||
minWidth={minWidth}
|
||||
variants={variants}
|
||||
xPos={xPos}
|
||||
yPos={yPos}
|
||||
>
|
||||
{children}
|
||||
</ContextMenuContainer>
|
||||
);
|
||||
},
|
||||
({ yPos, xPos, minWidth, maxWidth, children }: ContextMenuProps, ref: Ref<HTMLDivElement>) => {
|
||||
return (
|
||||
<ContextMenuContainer
|
||||
ref={ref}
|
||||
animate="open"
|
||||
initial="closed"
|
||||
maxWidth={maxWidth}
|
||||
minWidth={minWidth}
|
||||
variants={variants}
|
||||
xPos={xPos}
|
||||
yPos={yPos}
|
||||
>
|
||||
{children}
|
||||
</ContextMenuContainer>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,47 +3,47 @@ import { DatePicker as MantineDatePicker } from '@mantine/dates';
|
|||
import styled from 'styled-components';
|
||||
|
||||
interface DatePickerProps extends MantineDatePickerProps {
|
||||
maxWidth?: number | string;
|
||||
width?: number | string;
|
||||
maxWidth?: number | string;
|
||||
width?: number | string;
|
||||
}
|
||||
|
||||
const StyledDatePicker = styled(MantineDatePicker)<DatePickerProps>`
|
||||
& .mantine-DatePicker-input {
|
||||
color: var(--input-fg);
|
||||
background: var(--input-bg);
|
||||
& .mantine-DatePicker-input {
|
||||
color: var(--input-fg);
|
||||
background: var(--input-bg);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--input-placeholder-fg);
|
||||
&::placeholder {
|
||||
color: var(--input-placeholder-fg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .mantine-DatePicker-icon {
|
||||
color: var(--input-placeholder-fg);
|
||||
}
|
||||
& .mantine-DatePicker-icon {
|
||||
color: var(--input-placeholder-fg);
|
||||
}
|
||||
|
||||
& .mantine-DatePicker-required {
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
& .mantine-DatePicker-required {
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
& .mantine-DatePicker-label {
|
||||
font-family: var(--label-font-family);
|
||||
}
|
||||
& .mantine-DatePicker-label {
|
||||
font-family: var(--label-font-family);
|
||||
}
|
||||
|
||||
& .mantine-DateRangePicker-disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
& .mantine-DateRangePicker-disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
`;
|
||||
|
||||
export const DatePicker = ({ width, maxWidth, ...props }: DatePickerProps) => {
|
||||
return (
|
||||
<StyledDatePicker
|
||||
{...props}
|
||||
sx={{ maxWidth, width }}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<StyledDatePicker
|
||||
{...props}
|
||||
sx={{ maxWidth, width }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
DatePicker.defaultProps = {
|
||||
maxWidth: undefined,
|
||||
width: undefined,
|
||||
maxWidth: undefined,
|
||||
width: undefined,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import type {
|
||||
MenuProps as MantineMenuProps,
|
||||
MenuItemProps as MantineMenuItemProps,
|
||||
MenuLabelProps as MantineMenuLabelProps,
|
||||
MenuDividerProps as MantineMenuDividerProps,
|
||||
MenuDropdownProps as MantineMenuDropdownProps,
|
||||
MenuProps as MantineMenuProps,
|
||||
MenuItemProps as MantineMenuItemProps,
|
||||
MenuLabelProps as MantineMenuLabelProps,
|
||||
MenuDividerProps as MantineMenuDividerProps,
|
||||
MenuDropdownProps as MantineMenuDropdownProps,
|
||||
} from '@mantine/core';
|
||||
import { Menu as MantineMenu, createPolymorphicComponent } from '@mantine/core';
|
||||
import { RiArrowLeftSFill } from 'react-icons/ri';
|
||||
|
|
@ -12,9 +12,9 @@ import styled from 'styled-components';
|
|||
type MenuProps = MantineMenuProps;
|
||||
type MenuLabelProps = MantineMenuLabelProps;
|
||||
interface MenuItemProps extends MantineMenuItemProps {
|
||||
$danger?: boolean;
|
||||
$isActive?: boolean;
|
||||
children: React.ReactNode;
|
||||
$danger?: boolean;
|
||||
$isActive?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
type MenuDividerProps = MantineMenuDividerProps;
|
||||
type MenuDropdownProps = MantineMenuDropdownProps;
|
||||
|
|
@ -22,46 +22,46 @@ type MenuDropdownProps = MantineMenuDropdownProps;
|
|||
const StyledMenu = styled(MantineMenu)<MenuProps>``;
|
||||
|
||||
const StyledMenuLabel = styled(MantineMenu.Label)<MenuLabelProps>`
|
||||
padding: 0.5rem;
|
||||
font-family: var(--content-font-family);
|
||||
padding: 0.5rem;
|
||||
font-family: var(--content-font-family);
|
||||
`;
|
||||
|
||||
const StyledMenuItem = styled(MantineMenu.Item)<MenuItemProps>`
|
||||
position: relative;
|
||||
padding: var(--dropdown-menu-item-padding);
|
||||
font-size: var(--dropdown-menu-item-font-size);
|
||||
font-family: var(--content-font-family);
|
||||
position: relative;
|
||||
padding: var(--dropdown-menu-item-padding);
|
||||
font-size: var(--dropdown-menu-item-font-size);
|
||||
font-family: var(--content-font-family);
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--dropdown-menu-bg-hover);
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--dropdown-menu-bg-hover);
|
||||
}
|
||||
|
||||
& .mantine-Menu-itemLabel {
|
||||
margin-right: 2rem;
|
||||
margin-left: 1rem;
|
||||
color: ${(props) => (props.$danger ? 'var(--danger-color)' : 'var(--dropdown-menu-fg)')};
|
||||
}
|
||||
& .mantine-Menu-itemLabel {
|
||||
margin-right: 2rem;
|
||||
margin-left: 1rem;
|
||||
color: ${(props) => (props.$danger ? 'var(--danger-color)' : 'var(--dropdown-menu-fg)')};
|
||||
}
|
||||
|
||||
& .mantine-Menu-itemRightSection {
|
||||
display: flex;
|
||||
}
|
||||
& .mantine-Menu-itemRightSection {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
cursor: default;
|
||||
cursor: default;
|
||||
`;
|
||||
|
||||
const StyledMenuDropdown = styled(MantineMenu.Dropdown)`
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--dropdown-menu-bg);
|
||||
border: var(--dropdown-menu-border);
|
||||
border-radius: var(--dropdown-menu-border-radius);
|
||||
filter: drop-shadow(0 0 5px rgb(0, 0, 0, 50%));
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--dropdown-menu-bg);
|
||||
border: var(--dropdown-menu-border);
|
||||
border-radius: var(--dropdown-menu-border-radius);
|
||||
filter: drop-shadow(0 0 5px rgb(0, 0, 0, 50%));
|
||||
|
||||
/* *:first-child {
|
||||
/* *:first-child {
|
||||
border-top-left-radius: var(--dropdown-menu-border-radius);
|
||||
border-top-right-radius: var(--dropdown-menu-border-radius);
|
||||
}
|
||||
|
|
@ -73,51 +73,51 @@ const StyledMenuDropdown = styled(MantineMenu.Dropdown)`
|
|||
`;
|
||||
|
||||
const StyledMenuDivider = styled(MantineMenu.Divider)`
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
`;
|
||||
|
||||
export const DropdownMenu = ({ children, ...props }: MenuProps) => {
|
||||
return (
|
||||
<StyledMenu
|
||||
withinPortal
|
||||
styles={{
|
||||
dropdown: {
|
||||
filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 50%))',
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</StyledMenu>
|
||||
);
|
||||
return (
|
||||
<StyledMenu
|
||||
withinPortal
|
||||
styles={{
|
||||
dropdown: {
|
||||
filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 50%))',
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</StyledMenu>
|
||||
);
|
||||
};
|
||||
|
||||
const MenuLabel = ({ children, ...props }: MenuLabelProps) => {
|
||||
return <StyledMenuLabel {...props}>{children}</StyledMenuLabel>;
|
||||
return <StyledMenuLabel {...props}>{children}</StyledMenuLabel>;
|
||||
};
|
||||
|
||||
const pMenuItem = ({ $isActive, $danger, children, ...props }: MenuItemProps) => {
|
||||
return (
|
||||
<StyledMenuItem
|
||||
$danger={$danger}
|
||||
$isActive={$isActive}
|
||||
rightSection={$isActive && <RiArrowLeftSFill size={15} />}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</StyledMenuItem>
|
||||
);
|
||||
return (
|
||||
<StyledMenuItem
|
||||
$danger={$danger}
|
||||
$isActive={$isActive}
|
||||
rightSection={$isActive && <RiArrowLeftSFill size={15} />}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</StyledMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
const MenuDropdown = ({ children, ...props }: MenuDropdownProps) => {
|
||||
return <StyledMenuDropdown {...props}>{children}</StyledMenuDropdown>;
|
||||
return <StyledMenuDropdown {...props}>{children}</StyledMenuDropdown>;
|
||||
};
|
||||
|
||||
const MenuItem = createPolymorphicComponent<'button', MenuItemProps>(pMenuItem);
|
||||
|
||||
const MenuDivider = ({ ...props }: MenuDividerProps) => {
|
||||
return <StyledMenuDivider {...props} />;
|
||||
return <StyledMenuDivider {...props} />;
|
||||
};
|
||||
|
||||
DropdownMenu.Label = MenuLabel;
|
||||
|
|
|
|||
|
|
@ -15,245 +15,247 @@ import { usePlayQueueAdd } from '/@/renderer/features/player/hooks/use-playqueue
|
|||
import { Play } from '/@/renderer/types';
|
||||
|
||||
const Carousel = styled(motion.div)`
|
||||
position: relative;
|
||||
height: 35vh;
|
||||
min-height: 250px;
|
||||
padding: 2rem;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(180deg, var(--main-bg), rgba(25, 26, 28, 60%));
|
||||
border-radius: 1rem;
|
||||
position: relative;
|
||||
height: 35vh;
|
||||
min-height: 250px;
|
||||
padding: 2rem;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(180deg, var(--main-bg), rgba(25, 26, 28, 60%));
|
||||
border-radius: 1rem;
|
||||
`;
|
||||
|
||||
const Grid = styled.div`
|
||||
display: grid;
|
||||
grid-auto-columns: 1fr;
|
||||
grid-template-areas: 'image info';
|
||||
grid-template-rows: 1fr;
|
||||
grid-template-columns: 200px minmax(0, 1fr);
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-auto-columns: 1fr;
|
||||
grid-template-areas: 'image info';
|
||||
grid-template-rows: 1fr;
|
||||
grid-template-columns: 200px minmax(0, 1fr);
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const ImageColumn = styled.div`
|
||||
z-index: 15;
|
||||
display: flex;
|
||||
grid-area: image;
|
||||
align-items: flex-end;
|
||||
z-index: 15;
|
||||
display: flex;
|
||||
grid-area: image;
|
||||
align-items: flex-end;
|
||||
`;
|
||||
|
||||
const InfoColumn = styled.div`
|
||||
z-index: 15;
|
||||
display: flex;
|
||||
grid-area: info;
|
||||
align-items: flex-end;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding-left: 1rem;
|
||||
z-index: 15;
|
||||
display: flex;
|
||||
grid-area: info;
|
||||
align-items: flex-end;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding-left: 1rem;
|
||||
`;
|
||||
|
||||
const BackgroundImage = styled.img`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 0;
|
||||
width: 150%;
|
||||
height: 150%;
|
||||
object-fit: cover;
|
||||
object-position: 0 30%;
|
||||
filter: blur(24px);
|
||||
user-select: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 0;
|
||||
width: 150%;
|
||||
height: 150%;
|
||||
object-fit: cover;
|
||||
object-position: 0 30%;
|
||||
filter: blur(24px);
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
const BackgroundImageOverlay = styled.div`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(180deg, rgba(25, 26, 28, 30%), var(--main-bg));
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(180deg, rgba(25, 26, 28, 30%), var(--main-bg));
|
||||
`;
|
||||
|
||||
const Wrapper = styled(Link)`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const TitleWrapper = styled.div`
|
||||
/* stylelint-disable-next-line value-no-vendor-prefix */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
/* stylelint-disable-next-line value-no-vendor-prefix */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const variants: Variants = {
|
||||
animate: {
|
||||
opacity: 1,
|
||||
transition: { opacity: { duration: 0.5 } },
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
transition: { opacity: { duration: 0.5 } },
|
||||
},
|
||||
initial: {
|
||||
opacity: 0,
|
||||
},
|
||||
animate: {
|
||||
opacity: 1,
|
||||
transition: { opacity: { duration: 0.5 } },
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
transition: { opacity: { duration: 0.5 } },
|
||||
},
|
||||
initial: {
|
||||
opacity: 0,
|
||||
},
|
||||
};
|
||||
|
||||
interface FeatureCarouselProps {
|
||||
data: Album[] | undefined;
|
||||
data: Album[] | undefined;
|
||||
}
|
||||
|
||||
export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const [itemIndex, setItemIndex] = useState(0);
|
||||
const [direction, setDirection] = useState(0);
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const [itemIndex, setItemIndex] = useState(0);
|
||||
const [direction, setDirection] = useState(0);
|
||||
|
||||
const currentItem = data?.[itemIndex];
|
||||
const currentItem = data?.[itemIndex];
|
||||
|
||||
const handleNext = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
setDirection(1);
|
||||
if (itemIndex === (data?.length || 0) - 1 || 0) {
|
||||
setItemIndex(0);
|
||||
return;
|
||||
}
|
||||
const handleNext = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
setDirection(1);
|
||||
if (itemIndex === (data?.length || 0) - 1 || 0) {
|
||||
setItemIndex(0);
|
||||
return;
|
||||
}
|
||||
|
||||
setItemIndex((prev) => prev + 1);
|
||||
};
|
||||
setItemIndex((prev) => prev + 1);
|
||||
};
|
||||
|
||||
const handlePrevious = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
setDirection(-1);
|
||||
if (itemIndex === 0) {
|
||||
setItemIndex((data?.length || 0) - 1);
|
||||
return;
|
||||
}
|
||||
const handlePrevious = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
setDirection(-1);
|
||||
if (itemIndex === 0) {
|
||||
setItemIndex((data?.length || 0) - 1);
|
||||
return;
|
||||
}
|
||||
|
||||
setItemIndex((prev) => prev - 1);
|
||||
};
|
||||
setItemIndex((prev) => prev - 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Wrapper to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, { albumId: currentItem?.id || '' })}>
|
||||
<AnimatePresence
|
||||
custom={direction}
|
||||
initial={false}
|
||||
mode="popLayout"
|
||||
>
|
||||
{data && (
|
||||
<Carousel
|
||||
key={`image-${itemIndex}`}
|
||||
animate="animate"
|
||||
custom={direction}
|
||||
exit="exit"
|
||||
initial="initial"
|
||||
variants={variants}
|
||||
>
|
||||
<Grid>
|
||||
<ImageColumn>
|
||||
<Image
|
||||
height={225}
|
||||
placeholder="var(--card-default-bg)"
|
||||
radius="md"
|
||||
src={data[itemIndex]?.imageUrl}
|
||||
sx={{ objectFit: 'cover' }}
|
||||
width={225}
|
||||
/>
|
||||
</ImageColumn>
|
||||
<InfoColumn>
|
||||
<Stack
|
||||
spacing="md"
|
||||
sx={{ width: '100%' }}
|
||||
>
|
||||
<TitleWrapper>
|
||||
<TextTitle
|
||||
lh="3.5rem"
|
||||
order={1}
|
||||
overflow="hidden"
|
||||
sx={{ fontSize: '3.5rem' }}
|
||||
weight={900}
|
||||
return (
|
||||
<Wrapper
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, { albumId: currentItem?.id || '' })}
|
||||
>
|
||||
<AnimatePresence
|
||||
custom={direction}
|
||||
initial={false}
|
||||
mode="popLayout"
|
||||
>
|
||||
{data && (
|
||||
<Carousel
|
||||
key={`image-${itemIndex}`}
|
||||
animate="animate"
|
||||
custom={direction}
|
||||
exit="exit"
|
||||
initial="initial"
|
||||
variants={variants}
|
||||
>
|
||||
{currentItem?.name}
|
||||
</TextTitle>
|
||||
</TitleWrapper>
|
||||
<TitleWrapper>
|
||||
{currentItem?.albumArtists.slice(0, 1).map((artist) => (
|
||||
<TextTitle
|
||||
key={`carousel-artist-${artist.id}`}
|
||||
order={2}
|
||||
weight={600}
|
||||
>
|
||||
{artist.name}
|
||||
</TextTitle>
|
||||
))}
|
||||
</TitleWrapper>
|
||||
<Group>
|
||||
{currentItem?.genres?.slice(0, 1).map((genre) => (
|
||||
<Badge
|
||||
key={`carousel-genre-${genre.id}`}
|
||||
size="lg"
|
||||
>
|
||||
{genre.name}
|
||||
</Badge>
|
||||
))}
|
||||
<Badge size="lg">{currentItem?.releaseYear}</Badge>
|
||||
<Badge size="lg">{currentItem?.songCount} tracks</Badge>
|
||||
</Group>
|
||||
<Group position="apart">
|
||||
<Button
|
||||
size="lg"
|
||||
style={{ borderRadius: '5rem' }}
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!currentItem) return;
|
||||
<Grid>
|
||||
<ImageColumn>
|
||||
<Image
|
||||
height={225}
|
||||
placeholder="var(--card-default-bg)"
|
||||
radius="md"
|
||||
src={data[itemIndex]?.imageUrl}
|
||||
sx={{ objectFit: 'cover' }}
|
||||
width={225}
|
||||
/>
|
||||
</ImageColumn>
|
||||
<InfoColumn>
|
||||
<Stack
|
||||
spacing="md"
|
||||
sx={{ width: '100%' }}
|
||||
>
|
||||
<TitleWrapper>
|
||||
<TextTitle
|
||||
lh="3.5rem"
|
||||
order={1}
|
||||
overflow="hidden"
|
||||
sx={{ fontSize: '3.5rem' }}
|
||||
weight={900}
|
||||
>
|
||||
{currentItem?.name}
|
||||
</TextTitle>
|
||||
</TitleWrapper>
|
||||
<TitleWrapper>
|
||||
{currentItem?.albumArtists.slice(0, 1).map((artist) => (
|
||||
<TextTitle
|
||||
key={`carousel-artist-${artist.id}`}
|
||||
order={2}
|
||||
weight={600}
|
||||
>
|
||||
{artist.name}
|
||||
</TextTitle>
|
||||
))}
|
||||
</TitleWrapper>
|
||||
<Group>
|
||||
{currentItem?.genres?.slice(0, 1).map((genre) => (
|
||||
<Badge
|
||||
key={`carousel-genre-${genre.id}`}
|
||||
size="lg"
|
||||
>
|
||||
{genre.name}
|
||||
</Badge>
|
||||
))}
|
||||
<Badge size="lg">{currentItem?.releaseYear}</Badge>
|
||||
<Badge size="lg">{currentItem?.songCount} tracks</Badge>
|
||||
</Group>
|
||||
<Group position="apart">
|
||||
<Button
|
||||
size="lg"
|
||||
style={{ borderRadius: '5rem' }}
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!currentItem) return;
|
||||
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: [currentItem.id],
|
||||
type: LibraryItem.ALBUM,
|
||||
},
|
||||
playType: Play.NOW,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Play
|
||||
</Button>
|
||||
<Group spacing="sm">
|
||||
<Button
|
||||
radius="lg"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handlePrevious}
|
||||
>
|
||||
<RiArrowLeftSLine size="2rem" />
|
||||
</Button>
|
||||
<Button
|
||||
radius="lg"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleNext}
|
||||
>
|
||||
<RiArrowRightSLine size="2rem" />
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</InfoColumn>
|
||||
</Grid>
|
||||
<BackgroundImage
|
||||
draggable="false"
|
||||
src={currentItem?.imageUrl || undefined}
|
||||
/>
|
||||
<BackgroundImageOverlay />
|
||||
</Carousel>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Wrapper>
|
||||
);
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: [currentItem.id],
|
||||
type: LibraryItem.ALBUM,
|
||||
},
|
||||
playType: Play.NOW,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Play
|
||||
</Button>
|
||||
<Group spacing="sm">
|
||||
<Button
|
||||
radius="lg"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handlePrevious}
|
||||
>
|
||||
<RiArrowLeftSLine size="2rem" />
|
||||
</Button>
|
||||
<Button
|
||||
radius="lg"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleNext}
|
||||
>
|
||||
<RiArrowRightSLine size="2rem" />
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</InfoColumn>
|
||||
</Grid>
|
||||
<BackgroundImage
|
||||
draggable="false"
|
||||
src={currentItem?.imageUrl || undefined}
|
||||
/>
|
||||
<BackgroundImageOverlay />
|
||||
</Carousel>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -17,262 +17,267 @@ import { usePlayButtonBehavior } from '/@/renderer/store';
|
|||
import { CardRoute, CardRow } from '/@/renderer/types';
|
||||
|
||||
const getSlidesPerView = (windowWidth: number) => {
|
||||
if (windowWidth < 400) return 2;
|
||||
if (windowWidth < 700) return 3;
|
||||
if (windowWidth < 900) return 4;
|
||||
if (windowWidth < 1100) return 5;
|
||||
if (windowWidth < 1300) return 6;
|
||||
if (windowWidth < 1500) return 7;
|
||||
if (windowWidth < 1920) return 8;
|
||||
return 10;
|
||||
if (windowWidth < 400) return 2;
|
||||
if (windowWidth < 700) return 3;
|
||||
if (windowWidth < 900) return 4;
|
||||
if (windowWidth < 1100) return 5;
|
||||
if (windowWidth < 1300) return 6;
|
||||
if (windowWidth < 1500) return 7;
|
||||
if (windowWidth < 1920) return 8;
|
||||
return 10;
|
||||
};
|
||||
|
||||
const CarouselContainer = styled(Stack)`
|
||||
container-type: inline-size;
|
||||
container-type: inline-size;
|
||||
`;
|
||||
|
||||
interface TitleProps {
|
||||
handleNext?: () => void;
|
||||
handlePrev?: () => void;
|
||||
label?: string | ReactNode;
|
||||
pagination: {
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
};
|
||||
handleNext?: () => void;
|
||||
handlePrev?: () => void;
|
||||
label?: string | ReactNode;
|
||||
pagination: {
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const Title = ({ label, handleNext, handlePrev, pagination }: TitleProps) => {
|
||||
return (
|
||||
<Group position="apart">
|
||||
{isValidElement(label) ? (
|
||||
label
|
||||
) : (
|
||||
<TextTitle
|
||||
order={2}
|
||||
weight={700}
|
||||
>
|
||||
{label}
|
||||
</TextTitle>
|
||||
)}
|
||||
return (
|
||||
<Group position="apart">
|
||||
{isValidElement(label) ? (
|
||||
label
|
||||
) : (
|
||||
<TextTitle
|
||||
order={2}
|
||||
weight={700}
|
||||
>
|
||||
{label}
|
||||
</TextTitle>
|
||||
)}
|
||||
|
||||
<Group spacing="sm">
|
||||
<Button
|
||||
compact
|
||||
disabled={!pagination.hasPreviousPage}
|
||||
size="lg"
|
||||
variant="default"
|
||||
onClick={handlePrev}
|
||||
>
|
||||
<RiArrowLeftSLine />
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
disabled={!pagination.hasNextPage}
|
||||
size="lg"
|
||||
variant="default"
|
||||
onClick={handleNext}
|
||||
>
|
||||
<RiArrowRightSLine />
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
<Group spacing="sm">
|
||||
<Button
|
||||
compact
|
||||
disabled={!pagination.hasPreviousPage}
|
||||
size="lg"
|
||||
variant="default"
|
||||
onClick={handlePrev}
|
||||
>
|
||||
<RiArrowLeftSLine />
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
disabled={!pagination.hasNextPage}
|
||||
size="lg"
|
||||
variant="default"
|
||||
onClick={handleNext}
|
||||
>
|
||||
<RiArrowRightSLine />
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
interface SwiperGridCarouselProps {
|
||||
cardRows: CardRow<Album>[] | CardRow<Artist>[] | CardRow<AlbumArtist>[];
|
||||
data: Album[] | AlbumArtist[] | Artist[] | RelatedArtist[] | undefined;
|
||||
isLoading?: boolean;
|
||||
itemType: LibraryItem;
|
||||
route: CardRoute;
|
||||
swiperProps?: SwiperOptions;
|
||||
title?: {
|
||||
children?: ReactNode;
|
||||
hasPagination?: boolean;
|
||||
icon?: ReactNode;
|
||||
label: string | ReactNode;
|
||||
};
|
||||
uniqueId: string;
|
||||
cardRows: CardRow<Album>[] | CardRow<Artist>[] | CardRow<AlbumArtist>[];
|
||||
data: Album[] | AlbumArtist[] | Artist[] | RelatedArtist[] | undefined;
|
||||
isLoading?: boolean;
|
||||
itemType: LibraryItem;
|
||||
route: CardRoute;
|
||||
swiperProps?: SwiperOptions;
|
||||
title?: {
|
||||
children?: ReactNode;
|
||||
hasPagination?: boolean;
|
||||
icon?: ReactNode;
|
||||
label: string | ReactNode;
|
||||
};
|
||||
uniqueId: string;
|
||||
}
|
||||
|
||||
export const SwiperGridCarousel = ({
|
||||
cardRows,
|
||||
data,
|
||||
itemType,
|
||||
route,
|
||||
swiperProps,
|
||||
title,
|
||||
isLoading,
|
||||
uniqueId,
|
||||
}: SwiperGridCarouselProps) => {
|
||||
const swiperRef = useRef<SwiperCore | any>(null);
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
|
||||
const [pagination, setPagination] = useState({
|
||||
hasNextPage: (data?.length || 0) > Math.round(3),
|
||||
hasPreviousPage: false,
|
||||
});
|
||||
|
||||
const createFavoriteMutation = useCreateFavorite({});
|
||||
const deleteFavoriteMutation = useDeleteFavorite({});
|
||||
|
||||
const handleFavorite = useCallback(
|
||||
(options: { id: string[]; isFavorite: boolean; itemType: LibraryItem; serverId: string }) => {
|
||||
const { id, itemType, isFavorite, serverId } = options;
|
||||
if (isFavorite) {
|
||||
deleteFavoriteMutation.mutate({
|
||||
query: {
|
||||
id,
|
||||
type: itemType,
|
||||
},
|
||||
serverId,
|
||||
});
|
||||
} else {
|
||||
createFavoriteMutation.mutate({
|
||||
query: {
|
||||
id,
|
||||
type: itemType,
|
||||
},
|
||||
serverId,
|
||||
});
|
||||
}
|
||||
},
|
||||
[createFavoriteMutation, deleteFavoriteMutation],
|
||||
);
|
||||
|
||||
const slides = useMemo(() => {
|
||||
if (!data) return [];
|
||||
|
||||
return data.map((el) => (
|
||||
<PosterCard
|
||||
controls={{
|
||||
cardRows,
|
||||
handleFavorite,
|
||||
handlePlayQueueAdd,
|
||||
itemType,
|
||||
playButtonBehavior,
|
||||
route,
|
||||
}}
|
||||
data={el}
|
||||
isLoading={isLoading}
|
||||
uniqueId={uniqueId}
|
||||
/>
|
||||
));
|
||||
}, [
|
||||
cardRows,
|
||||
data,
|
||||
handleFavorite,
|
||||
handlePlayQueueAdd,
|
||||
isLoading,
|
||||
itemType,
|
||||
playButtonBehavior,
|
||||
route,
|
||||
swiperProps,
|
||||
title,
|
||||
isLoading,
|
||||
uniqueId,
|
||||
]);
|
||||
}: SwiperGridCarouselProps) => {
|
||||
const swiperRef = useRef<SwiperCore | any>(null);
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
const activeIndex = swiperRef?.current?.activeIndex || 0;
|
||||
const slidesPerView = Math.round(Number(swiperProps?.slidesPerView || 4));
|
||||
swiperRef?.current?.slideTo(activeIndex + slidesPerView);
|
||||
}, [swiperProps?.slidesPerView]);
|
||||
|
||||
const handlePrev = useCallback(() => {
|
||||
const activeIndex = swiperRef?.current?.activeIndex || 0;
|
||||
const slidesPerView = Math.round(Number(swiperProps?.slidesPerView || 4));
|
||||
swiperRef?.current?.slideTo(activeIndex - slidesPerView);
|
||||
}, [swiperProps?.slidesPerView]);
|
||||
|
||||
const handleOnSlideChange = useCallback((e: SwiperCore) => {
|
||||
const { slides, isEnd, isBeginning, params } = e;
|
||||
if (isEnd || isBeginning) return;
|
||||
|
||||
setPagination({
|
||||
hasNextPage: (params?.slidesPerView || 4) < slides.length,
|
||||
hasPreviousPage: (params?.slidesPerView || 4) < slides.length,
|
||||
const [pagination, setPagination] = useState({
|
||||
hasNextPage: (data?.length || 0) > Math.round(3),
|
||||
hasPreviousPage: false,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleOnZoomChange = useCallback((e: SwiperCore) => {
|
||||
const { slides, isEnd, isBeginning, params } = e;
|
||||
if (isEnd || isBeginning) return;
|
||||
const createFavoriteMutation = useCreateFavorite({});
|
||||
const deleteFavoriteMutation = useDeleteFavorite({});
|
||||
|
||||
setPagination({
|
||||
hasNextPage: (params.slidesPerView || 4) < slides.length,
|
||||
hasPreviousPage: (params.slidesPerView || 4) < slides.length,
|
||||
});
|
||||
}, []);
|
||||
const handleFavorite = useCallback(
|
||||
(options: {
|
||||
id: string[];
|
||||
isFavorite: boolean;
|
||||
itemType: LibraryItem;
|
||||
serverId: string;
|
||||
}) => {
|
||||
const { id, itemType, isFavorite, serverId } = options;
|
||||
if (isFavorite) {
|
||||
deleteFavoriteMutation.mutate({
|
||||
query: {
|
||||
id,
|
||||
type: itemType,
|
||||
},
|
||||
serverId,
|
||||
});
|
||||
} else {
|
||||
createFavoriteMutation.mutate({
|
||||
query: {
|
||||
id,
|
||||
type: itemType,
|
||||
},
|
||||
serverId,
|
||||
});
|
||||
}
|
||||
},
|
||||
[createFavoriteMutation, deleteFavoriteMutation],
|
||||
);
|
||||
|
||||
const handleOnReachEnd = useCallback((e: SwiperCore) => {
|
||||
const { slides, params } = e;
|
||||
const slides = useMemo(() => {
|
||||
if (!data) return [];
|
||||
|
||||
setPagination({
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: (params.slidesPerView || 4) < slides.length,
|
||||
});
|
||||
}, []);
|
||||
return data.map((el) => (
|
||||
<PosterCard
|
||||
controls={{
|
||||
cardRows,
|
||||
handleFavorite,
|
||||
handlePlayQueueAdd,
|
||||
itemType,
|
||||
playButtonBehavior,
|
||||
route,
|
||||
}}
|
||||
data={el}
|
||||
isLoading={isLoading}
|
||||
uniqueId={uniqueId}
|
||||
/>
|
||||
));
|
||||
}, [
|
||||
cardRows,
|
||||
data,
|
||||
handleFavorite,
|
||||
handlePlayQueueAdd,
|
||||
isLoading,
|
||||
itemType,
|
||||
playButtonBehavior,
|
||||
route,
|
||||
uniqueId,
|
||||
]);
|
||||
|
||||
const handleOnReachBeginning = useCallback((e: SwiperCore) => {
|
||||
const { slides, params } = e;
|
||||
const handleNext = useCallback(() => {
|
||||
const activeIndex = swiperRef?.current?.activeIndex || 0;
|
||||
const slidesPerView = Math.round(Number(swiperProps?.slidesPerView || 4));
|
||||
swiperRef?.current?.slideTo(activeIndex + slidesPerView);
|
||||
}, [swiperProps?.slidesPerView]);
|
||||
|
||||
setPagination({
|
||||
hasNextPage: (params.slidesPerView || 4) < slides.length,
|
||||
hasPreviousPage: false,
|
||||
});
|
||||
}, []);
|
||||
const handlePrev = useCallback(() => {
|
||||
const activeIndex = swiperRef?.current?.activeIndex || 0;
|
||||
const slidesPerView = Math.round(Number(swiperProps?.slidesPerView || 4));
|
||||
swiperRef?.current?.slideTo(activeIndex - slidesPerView);
|
||||
}, [swiperProps?.slidesPerView]);
|
||||
|
||||
const handleOnResize = useCallback((e: SwiperCore) => {
|
||||
if (!e) return;
|
||||
const { width } = e;
|
||||
const slidesPerView = getSlidesPerView(width);
|
||||
if (!e.params) return;
|
||||
e.params.slidesPerView = slidesPerView;
|
||||
}, []);
|
||||
const handleOnSlideChange = useCallback((e: SwiperCore) => {
|
||||
const { slides, isEnd, isBeginning, params } = e;
|
||||
if (isEnd || isBeginning) return;
|
||||
|
||||
const throttledOnResize = throttle(handleOnResize, 200);
|
||||
setPagination({
|
||||
hasNextPage: (params?.slidesPerView || 4) < slides.length,
|
||||
hasPreviousPage: (params?.slidesPerView || 4) < slides.length,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<CarouselContainer
|
||||
className="grid-carousel"
|
||||
spacing="md"
|
||||
>
|
||||
{title ? (
|
||||
<Title
|
||||
{...title}
|
||||
handleNext={handleNext}
|
||||
handlePrev={handlePrev}
|
||||
pagination={pagination}
|
||||
/>
|
||||
) : null}
|
||||
<Swiper
|
||||
ref={swiperRef}
|
||||
resizeObserver
|
||||
modules={[Virtual]}
|
||||
slidesPerView={4}
|
||||
spaceBetween={20}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
onBeforeInit={(swiper) => {
|
||||
swiperRef.current = swiper;
|
||||
}}
|
||||
onBeforeResize={handleOnResize}
|
||||
onReachBeginning={handleOnReachBeginning}
|
||||
onReachEnd={handleOnReachEnd}
|
||||
onResize={throttledOnResize}
|
||||
onSlideChange={handleOnSlideChange}
|
||||
onZoomChange={handleOnZoomChange}
|
||||
{...swiperProps}
|
||||
>
|
||||
{slides.map((slideContent, index) => {
|
||||
return (
|
||||
<SwiperSlide
|
||||
key={`${uniqueId}-${slideContent?.props?.data?.id}-${index}`}
|
||||
virtualIndex={index}
|
||||
const handleOnZoomChange = useCallback((e: SwiperCore) => {
|
||||
const { slides, isEnd, isBeginning, params } = e;
|
||||
if (isEnd || isBeginning) return;
|
||||
|
||||
setPagination({
|
||||
hasNextPage: (params.slidesPerView || 4) < slides.length,
|
||||
hasPreviousPage: (params.slidesPerView || 4) < slides.length,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleOnReachEnd = useCallback((e: SwiperCore) => {
|
||||
const { slides, params } = e;
|
||||
|
||||
setPagination({
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: (params.slidesPerView || 4) < slides.length,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleOnReachBeginning = useCallback((e: SwiperCore) => {
|
||||
const { slides, params } = e;
|
||||
|
||||
setPagination({
|
||||
hasNextPage: (params.slidesPerView || 4) < slides.length,
|
||||
hasPreviousPage: false,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleOnResize = useCallback((e: SwiperCore) => {
|
||||
if (!e) return;
|
||||
const { width } = e;
|
||||
const slidesPerView = getSlidesPerView(width);
|
||||
if (!e.params) return;
|
||||
e.params.slidesPerView = slidesPerView;
|
||||
}, []);
|
||||
|
||||
const throttledOnResize = throttle(handleOnResize, 200);
|
||||
|
||||
return (
|
||||
<CarouselContainer
|
||||
className="grid-carousel"
|
||||
spacing="md"
|
||||
>
|
||||
{title ? (
|
||||
<Title
|
||||
{...title}
|
||||
handleNext={handleNext}
|
||||
handlePrev={handlePrev}
|
||||
pagination={pagination}
|
||||
/>
|
||||
) : null}
|
||||
<Swiper
|
||||
ref={swiperRef}
|
||||
resizeObserver
|
||||
modules={[Virtual]}
|
||||
slidesPerView={4}
|
||||
spaceBetween={20}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
onBeforeInit={(swiper) => {
|
||||
swiperRef.current = swiper;
|
||||
}}
|
||||
onBeforeResize={handleOnResize}
|
||||
onReachBeginning={handleOnReachBeginning}
|
||||
onReachEnd={handleOnReachEnd}
|
||||
onResize={throttledOnResize}
|
||||
onSlideChange={handleOnSlideChange}
|
||||
onZoomChange={handleOnZoomChange}
|
||||
{...swiperProps}
|
||||
>
|
||||
{slideContent}
|
||||
</SwiperSlide>
|
||||
);
|
||||
})}
|
||||
</Swiper>
|
||||
</CarouselContainer>
|
||||
);
|
||||
{slides.map((slideContent, index) => {
|
||||
return (
|
||||
<SwiperSlide
|
||||
key={`${uniqueId}-${slideContent?.props?.data?.id}-${index}`}
|
||||
virtualIndex={index}
|
||||
>
|
||||
{slideContent}
|
||||
</SwiperSlide>
|
||||
);
|
||||
})}
|
||||
</Swiper>
|
||||
</CarouselContainer>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,23 +1,23 @@
|
|||
import { HoverCard as MantineHoverCard, HoverCardProps } from '@mantine/core';
|
||||
|
||||
export const HoverCard = ({ children, ...props }: HoverCardProps) => {
|
||||
return (
|
||||
<MantineHoverCard
|
||||
styles={{
|
||||
dropdown: {
|
||||
background: 'var(--dropdown-menu-bg)',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--dropdown-menu-border-radius)',
|
||||
boxShadow: '2px 2px 10px 2px rgba(0, 0, 0, 40%)',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</MantineHoverCard>
|
||||
);
|
||||
return (
|
||||
<MantineHoverCard
|
||||
styles={{
|
||||
dropdown: {
|
||||
background: 'var(--dropdown-menu-bg)',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--dropdown-menu-border-radius)',
|
||||
boxShadow: '2px 2px 10px 2px rgba(0, 0, 0, 40%)',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</MantineHoverCard>
|
||||
);
|
||||
};
|
||||
|
||||
HoverCard.Target = MantineHoverCard.Target;
|
||||
|
|
|
|||
|
|
@ -1,404 +1,404 @@
|
|||
import React, { forwardRef } from 'react';
|
||||
import type {
|
||||
TextInputProps as MantineTextInputProps,
|
||||
NumberInputProps as MantineNumberInputProps,
|
||||
PasswordInputProps as MantinePasswordInputProps,
|
||||
FileInputProps as MantineFileInputProps,
|
||||
JsonInputProps as MantineJsonInputProps,
|
||||
TextareaProps as MantineTextareaProps,
|
||||
TextInputProps as MantineTextInputProps,
|
||||
NumberInputProps as MantineNumberInputProps,
|
||||
PasswordInputProps as MantinePasswordInputProps,
|
||||
FileInputProps as MantineFileInputProps,
|
||||
JsonInputProps as MantineJsonInputProps,
|
||||
TextareaProps as MantineTextareaProps,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
TextInput as MantineTextInput,
|
||||
NumberInput as MantineNumberInput,
|
||||
PasswordInput as MantinePasswordInput,
|
||||
FileInput as MantineFileInput,
|
||||
JsonInput as MantineJsonInput,
|
||||
Textarea as MantineTextarea,
|
||||
TextInput as MantineTextInput,
|
||||
NumberInput as MantineNumberInput,
|
||||
PasswordInput as MantinePasswordInput,
|
||||
FileInput as MantineFileInput,
|
||||
JsonInput as MantineJsonInput,
|
||||
Textarea as MantineTextarea,
|
||||
} from '@mantine/core';
|
||||
import styled from 'styled-components';
|
||||
|
||||
interface TextInputProps extends MantineTextInputProps {
|
||||
children?: React.ReactNode;
|
||||
maxWidth?: number | string;
|
||||
width?: number | string;
|
||||
children?: React.ReactNode;
|
||||
maxWidth?: number | string;
|
||||
width?: number | string;
|
||||
}
|
||||
|
||||
interface NumberInputProps extends MantineNumberInputProps {
|
||||
children?: React.ReactNode;
|
||||
maxWidth?: number | string;
|
||||
width?: number | string;
|
||||
children?: React.ReactNode;
|
||||
maxWidth?: number | string;
|
||||
width?: number | string;
|
||||
}
|
||||
|
||||
interface PasswordInputProps extends MantinePasswordInputProps {
|
||||
children?: React.ReactNode;
|
||||
maxWidth?: number | string;
|
||||
width?: number | string;
|
||||
children?: React.ReactNode;
|
||||
maxWidth?: number | string;
|
||||
width?: number | string;
|
||||
}
|
||||
|
||||
interface FileInputProps extends MantineFileInputProps {
|
||||
children?: React.ReactNode;
|
||||
maxWidth?: number | string;
|
||||
width?: number | string;
|
||||
children?: React.ReactNode;
|
||||
maxWidth?: number | string;
|
||||
width?: number | string;
|
||||
}
|
||||
|
||||
interface JsonInputProps extends MantineJsonInputProps {
|
||||
children?: React.ReactNode;
|
||||
maxWidth?: number | string;
|
||||
width?: number | string;
|
||||
children?: React.ReactNode;
|
||||
maxWidth?: number | string;
|
||||
width?: number | string;
|
||||
}
|
||||
|
||||
interface TextareaProps extends MantineTextareaProps {
|
||||
children?: React.ReactNode;
|
||||
maxWidth?: number | string;
|
||||
width?: number | string;
|
||||
children?: React.ReactNode;
|
||||
maxWidth?: number | string;
|
||||
width?: number | string;
|
||||
}
|
||||
|
||||
const StyledTextInput = styled(MantineTextInput)<TextInputProps>`
|
||||
& .mantine-TextInput-wrapper {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
& .mantine-TextInput-input {
|
||||
color: var(--input-fg);
|
||||
background: var(--input-bg);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--input-placeholder-fg);
|
||||
& .mantine-TextInput-wrapper {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
& .mantine-Input-icon {
|
||||
color: var(--input-placeholder-fg);
|
||||
}
|
||||
& .mantine-TextInput-input {
|
||||
color: var(--input-fg);
|
||||
background: var(--input-bg);
|
||||
|
||||
& .mantine-TextInput-required {
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
&::placeholder {
|
||||
color: var(--input-placeholder-fg);
|
||||
}
|
||||
}
|
||||
|
||||
& .mantine-TextInput-label {
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: var(--label-font-family);
|
||||
}
|
||||
& .mantine-Input-icon {
|
||||
color: var(--input-placeholder-fg);
|
||||
}
|
||||
|
||||
& .mantine-TextInput-disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
& .mantine-TextInput-required {
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
& [data-disabled='true'] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
& .mantine-TextInput-label {
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: var(--label-font-family);
|
||||
}
|
||||
|
||||
transition: width 0.3s ease-in-out;
|
||||
& .mantine-TextInput-disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
& [data-disabled='true'] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
transition: width 0.3s ease-in-out;
|
||||
`;
|
||||
|
||||
const StyledNumberInput = styled(MantineNumberInput)<NumberInputProps>`
|
||||
& .mantine-NumberInput-wrapper {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
& .mantine-NumberInput-input {
|
||||
color: var(--input-fg);
|
||||
background: var(--input-bg);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--input-placeholder-fg);
|
||||
& .mantine-NumberInput-wrapper {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
& .mantine-NumberInput-controlUp {
|
||||
svg {
|
||||
color: var(--btn-default-fg);
|
||||
fill: var(--btn-default-fg);
|
||||
& .mantine-NumberInput-input {
|
||||
color: var(--input-fg);
|
||||
background: var(--input-bg);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--input-placeholder-fg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .mantine-NumberInput-controlDown {
|
||||
svg {
|
||||
color: var(--btn-default-fg);
|
||||
fill: var(--btn-default-fg);
|
||||
& .mantine-NumberInput-controlUp {
|
||||
svg {
|
||||
color: var(--btn-default-fg);
|
||||
fill: var(--btn-default-fg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .mantine-Input-icon {
|
||||
color: var(--input-placeholder-fg);
|
||||
}
|
||||
& .mantine-NumberInput-controlDown {
|
||||
svg {
|
||||
color: var(--btn-default-fg);
|
||||
fill: var(--btn-default-fg);
|
||||
}
|
||||
}
|
||||
|
||||
& .mantine-NumberInput-required {
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
& .mantine-Input-icon {
|
||||
color: var(--input-placeholder-fg);
|
||||
}
|
||||
|
||||
& .mantine-NumberInput-label {
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: var(--label-font-family);
|
||||
}
|
||||
& .mantine-NumberInput-required {
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
& .mantine-NumberInput-disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
& .mantine-NumberInput-label {
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: var(--label-font-family);
|
||||
}
|
||||
|
||||
& [data-disabled='true'] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
& .mantine-NumberInput-disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
transition: width 0.3s ease-in-out;
|
||||
& [data-disabled='true'] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
transition: width 0.3s ease-in-out;
|
||||
`;
|
||||
|
||||
const StyledPasswordInput = styled(MantinePasswordInput)<PasswordInputProps>`
|
||||
& .mantine-PasswordInput-input {
|
||||
color: var(--input-fg);
|
||||
background: var(--input-bg);
|
||||
& .mantine-PasswordInput-input {
|
||||
color: var(--input-fg);
|
||||
background: var(--input-bg);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--input-placeholder-fg);
|
||||
&::placeholder {
|
||||
color: var(--input-placeholder-fg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .mantine-PasswordInput-icon {
|
||||
color: var(--input-placeholder-fg);
|
||||
}
|
||||
& .mantine-PasswordInput-icon {
|
||||
color: var(--input-placeholder-fg);
|
||||
}
|
||||
|
||||
& .mantine-PasswordInput-required {
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
& .mantine-PasswordInput-required {
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
& .mantine-PasswordInput-label {
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: var(--label-font-family);
|
||||
}
|
||||
& .mantine-PasswordInput-label {
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: var(--label-font-family);
|
||||
}
|
||||
|
||||
& .mantine-PasswordInput-disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
& .mantine-PasswordInput-disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
& [data-disabled='true'] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
& [data-disabled='true'] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
transition: width 0.3s ease-in-out;
|
||||
transition: width 0.3s ease-in-out;
|
||||
`;
|
||||
|
||||
const StyledFileInput = styled(MantineFileInput)<FileInputProps>`
|
||||
& .mantine-FileInput-input {
|
||||
color: var(--input-fg);
|
||||
background: var(--input-bg);
|
||||
& .mantine-FileInput-input {
|
||||
color: var(--input-fg);
|
||||
background: var(--input-bg);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--input-placeholder-fg);
|
||||
&::placeholder {
|
||||
color: var(--input-placeholder-fg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .mantine-FileInput-icon {
|
||||
color: var(--input-placeholder-fg);
|
||||
}
|
||||
& .mantine-FileInput-icon {
|
||||
color: var(--input-placeholder-fg);
|
||||
}
|
||||
|
||||
& .mantine-FileInput-required {
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
& .mantine-FileInput-required {
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
& .mantine-FileInput-label {
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: var(--label-font-family);
|
||||
}
|
||||
& .mantine-FileInput-label {
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: var(--label-font-family);
|
||||
}
|
||||
|
||||
& .mantine-FileInput-disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
& .mantine-FileInput-disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
& [data-disabled='true'] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
& [data-disabled='true'] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
transition: width 0.3s ease-in-out;
|
||||
transition: width 0.3s ease-in-out;
|
||||
`;
|
||||
|
||||
const StyledJsonInput = styled(MantineJsonInput)<JsonInputProps>`
|
||||
& .mantine-JsonInput-input {
|
||||
color: var(--input-fg);
|
||||
background: var(--input-bg);
|
||||
& .mantine-JsonInput-input {
|
||||
color: var(--input-fg);
|
||||
background: var(--input-bg);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--input-placeholder-fg);
|
||||
&::placeholder {
|
||||
color: var(--input-placeholder-fg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .mantine-JsonInput-icon {
|
||||
color: var(--input-placeholder-fg);
|
||||
}
|
||||
& .mantine-JsonInput-icon {
|
||||
color: var(--input-placeholder-fg);
|
||||
}
|
||||
|
||||
& .mantine-JsonInput-required {
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
& .mantine-JsonInput-required {
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
& .mantine-JsonInput-label {
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: var(--label-font-family);
|
||||
}
|
||||
& .mantine-JsonInput-label {
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: var(--label-font-family);
|
||||
}
|
||||
|
||||
& .mantine-JsonInput-disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
& .mantine-JsonInput-disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
& [data-disabled='true'] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
& [data-disabled='true'] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
transition: width 0.3s ease-in-out;
|
||||
transition: width 0.3s ease-in-out;
|
||||
`;
|
||||
|
||||
const StyledTextarea = styled(MantineTextarea)<TextareaProps>`
|
||||
& .mantine-Textarea-input {
|
||||
color: var(--input-fg);
|
||||
background: var(--input-bg);
|
||||
}
|
||||
& .mantine-Textarea-input {
|
||||
color: var(--input-fg);
|
||||
background: var(--input-bg);
|
||||
}
|
||||
|
||||
& .mantine-Textarea-icon {
|
||||
color: var(--input-placeholder-fg);
|
||||
}
|
||||
& .mantine-Textarea-icon {
|
||||
color: var(--input-placeholder-fg);
|
||||
}
|
||||
|
||||
& .mantine-Textarea-required {
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
& .mantine-Textarea-required {
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
& .mantine-Textarea-label {
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: var(--label-font-family);
|
||||
}
|
||||
& .mantine-Textarea-label {
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: var(--label-font-family);
|
||||
}
|
||||
|
||||
& .mantine-Textarea-disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
& .mantine-Textarea-disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
& [data-disabled='true'] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
& [data-disabled='true'] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
transition: width 0.3s ease-in-out;
|
||||
transition: width 0.3s ease-in-out;
|
||||
`;
|
||||
|
||||
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
|
||||
({ children, width, maxWidth, ...props }: TextInputProps, ref) => {
|
||||
return (
|
||||
<StyledTextInput
|
||||
ref={ref}
|
||||
spellCheck={false}
|
||||
{...props}
|
||||
sx={{ maxWidth, width }}
|
||||
>
|
||||
{children}
|
||||
</StyledTextInput>
|
||||
);
|
||||
},
|
||||
({ children, width, maxWidth, ...props }: TextInputProps, ref) => {
|
||||
return (
|
||||
<StyledTextInput
|
||||
ref={ref}
|
||||
spellCheck={false}
|
||||
{...props}
|
||||
sx={{ maxWidth, width }}
|
||||
>
|
||||
{children}
|
||||
</StyledTextInput>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(
|
||||
({ children, width, maxWidth, ...props }: NumberInputProps, ref) => {
|
||||
return (
|
||||
<StyledNumberInput
|
||||
ref={ref}
|
||||
hideControls
|
||||
spellCheck={false}
|
||||
{...props}
|
||||
sx={{ maxWidth, width }}
|
||||
>
|
||||
{children}
|
||||
</StyledNumberInput>
|
||||
);
|
||||
},
|
||||
({ children, width, maxWidth, ...props }: NumberInputProps, ref) => {
|
||||
return (
|
||||
<StyledNumberInput
|
||||
ref={ref}
|
||||
hideControls
|
||||
spellCheck={false}
|
||||
{...props}
|
||||
sx={{ maxWidth, width }}
|
||||
>
|
||||
{children}
|
||||
</StyledNumberInput>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
|
||||
({ children, width, maxWidth, ...props }: PasswordInputProps, ref) => {
|
||||
return (
|
||||
<StyledPasswordInput
|
||||
ref={ref}
|
||||
{...props}
|
||||
sx={{ maxWidth, width }}
|
||||
>
|
||||
{children}
|
||||
</StyledPasswordInput>
|
||||
);
|
||||
},
|
||||
({ children, width, maxWidth, ...props }: PasswordInputProps, ref) => {
|
||||
return (
|
||||
<StyledPasswordInput
|
||||
ref={ref}
|
||||
{...props}
|
||||
sx={{ maxWidth, width }}
|
||||
>
|
||||
{children}
|
||||
</StyledPasswordInput>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const FileInput = forwardRef<HTMLButtonElement, FileInputProps>(
|
||||
({ children, width, maxWidth, ...props }: FileInputProps, ref) => {
|
||||
return (
|
||||
<StyledFileInput
|
||||
ref={ref}
|
||||
{...props}
|
||||
styles={{
|
||||
placeholder: {
|
||||
color: 'var(--input-placeholder-fg)',
|
||||
},
|
||||
}}
|
||||
sx={{ maxWidth, width }}
|
||||
>
|
||||
{children}
|
||||
</StyledFileInput>
|
||||
);
|
||||
},
|
||||
({ children, width, maxWidth, ...props }: FileInputProps, ref) => {
|
||||
return (
|
||||
<StyledFileInput
|
||||
ref={ref}
|
||||
{...props}
|
||||
styles={{
|
||||
placeholder: {
|
||||
color: 'var(--input-placeholder-fg)',
|
||||
},
|
||||
}}
|
||||
sx={{ maxWidth, width }}
|
||||
>
|
||||
{children}
|
||||
</StyledFileInput>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const JsonInput = forwardRef<HTMLTextAreaElement, JsonInputProps>(
|
||||
({ children, width, maxWidth, ...props }: JsonInputProps, ref) => {
|
||||
return (
|
||||
<StyledJsonInput
|
||||
ref={ref}
|
||||
{...props}
|
||||
sx={{ maxWidth, width }}
|
||||
>
|
||||
{children}
|
||||
</StyledJsonInput>
|
||||
);
|
||||
},
|
||||
({ children, width, maxWidth, ...props }: JsonInputProps, ref) => {
|
||||
return (
|
||||
<StyledJsonInput
|
||||
ref={ref}
|
||||
{...props}
|
||||
sx={{ maxWidth, width }}
|
||||
>
|
||||
{children}
|
||||
</StyledJsonInput>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ children, width, maxWidth, ...props }: TextareaProps, ref) => {
|
||||
return (
|
||||
<StyledTextarea
|
||||
ref={ref}
|
||||
{...props}
|
||||
sx={{ maxWidth, width }}
|
||||
>
|
||||
{children}
|
||||
</StyledTextarea>
|
||||
);
|
||||
},
|
||||
({ children, width, maxWidth, ...props }: TextareaProps, ref) => {
|
||||
return (
|
||||
<StyledTextarea
|
||||
ref={ref}
|
||||
{...props}
|
||||
sx={{ maxWidth, width }}
|
||||
>
|
||||
{children}
|
||||
</StyledTextarea>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
TextInput.defaultProps = {
|
||||
children: undefined,
|
||||
maxWidth: undefined,
|
||||
width: undefined,
|
||||
children: undefined,
|
||||
maxWidth: undefined,
|
||||
width: undefined,
|
||||
};
|
||||
|
||||
NumberInput.defaultProps = {
|
||||
children: undefined,
|
||||
maxWidth: undefined,
|
||||
width: undefined,
|
||||
children: undefined,
|
||||
maxWidth: undefined,
|
||||
width: undefined,
|
||||
};
|
||||
|
||||
PasswordInput.defaultProps = {
|
||||
children: undefined,
|
||||
maxWidth: undefined,
|
||||
width: undefined,
|
||||
children: undefined,
|
||||
maxWidth: undefined,
|
||||
width: undefined,
|
||||
};
|
||||
|
||||
FileInput.defaultProps = {
|
||||
children: undefined,
|
||||
maxWidth: undefined,
|
||||
width: undefined,
|
||||
children: undefined,
|
||||
maxWidth: undefined,
|
||||
width: undefined,
|
||||
};
|
||||
|
||||
JsonInput.defaultProps = {
|
||||
children: undefined,
|
||||
maxWidth: undefined,
|
||||
width: undefined,
|
||||
children: undefined,
|
||||
maxWidth: undefined,
|
||||
width: undefined,
|
||||
};
|
||||
|
||||
Textarea.defaultProps = {
|
||||
children: undefined,
|
||||
maxWidth: undefined,
|
||||
width: undefined,
|
||||
children: undefined,
|
||||
maxWidth: undefined,
|
||||
width: undefined,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,99 +1,99 @@
|
|||
import React, { ReactNode } from 'react';
|
||||
import {
|
||||
ModalProps as MantineModalProps,
|
||||
Stack,
|
||||
Modal as MantineModal,
|
||||
Flex,
|
||||
Group,
|
||||
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?: ReactNode;
|
||||
handlers: {
|
||||
close: () => void;
|
||||
open: () => void;
|
||||
toggle: () => void;
|
||||
};
|
||||
children?: ReactNode;
|
||||
handlers: {
|
||||
close: () => void;
|
||||
open: () => void;
|
||||
toggle: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
export const Modal = ({ children, handlers, ...rest }: ModalProps) => {
|
||||
return (
|
||||
<MantineModal
|
||||
{...rest}
|
||||
onClose={handlers.close}
|
||||
>
|
||||
{children}
|
||||
</MantineModal>
|
||||
);
|
||||
return (
|
||||
<MantineModal
|
||||
{...rest}
|
||||
onClose={handlers.close}
|
||||
>
|
||||
{children}
|
||||
</MantineModal>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextModalVars = {
|
||||
context: ContextModalProps['context'];
|
||||
id: ContextModalProps['id'];
|
||||
context: ContextModalProps['context'];
|
||||
id: ContextModalProps['id'];
|
||||
};
|
||||
|
||||
export const BaseContextModal = ({
|
||||
context,
|
||||
id,
|
||||
innerProps,
|
||||
context,
|
||||
id,
|
||||
innerProps,
|
||||
}: ContextModalProps<{
|
||||
modalBody: (vars: ContextModalVars) => React.ReactNode;
|
||||
modalBody: (vars: ContextModalVars) => React.ReactNode;
|
||||
}>) => <>{innerProps.modalBody({ context, id })}</>;
|
||||
|
||||
Modal.defaultProps = {
|
||||
children: undefined,
|
||||
children: undefined,
|
||||
};
|
||||
|
||||
interface ConfirmModalProps {
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
labels?: {
|
||||
cancel?: string;
|
||||
confirm?: string;
|
||||
};
|
||||
loading?: boolean;
|
||||
onCancel?: () => void;
|
||||
onConfirm: () => void;
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
labels?: {
|
||||
cancel?: string;
|
||||
confirm?: string;
|
||||
};
|
||||
loading?: boolean;
|
||||
onCancel?: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export const ConfirmModal = ({
|
||||
loading,
|
||||
disabled,
|
||||
labels,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
children,
|
||||
loading,
|
||||
disabled,
|
||||
labels,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
children,
|
||||
}: ConfirmModalProps) => {
|
||||
const handleCancel = () => {
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
} else {
|
||||
closeAllModals();
|
||||
}
|
||||
};
|
||||
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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,30 +2,30 @@ import { ReactNode } from 'react';
|
|||
import { Flex, Group } from '@mantine/core';
|
||||
|
||||
export const Option = ({ children }: any) => {
|
||||
return (
|
||||
<Group
|
||||
grow
|
||||
p="0.5rem"
|
||||
>
|
||||
{children}
|
||||
</Group>
|
||||
);
|
||||
return (
|
||||
<Group
|
||||
grow
|
||||
p="0.5rem"
|
||||
>
|
||||
{children}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
interface LabelProps {
|
||||
children: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const Label = ({ children }: LabelProps) => {
|
||||
return <Flex align="flex-start">{children}</Flex>;
|
||||
return <Flex align="flex-start">{children}</Flex>;
|
||||
};
|
||||
|
||||
interface ControlProps {
|
||||
children: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const Control = ({ children }: ControlProps) => {
|
||||
return <Flex justify="flex-end">{children}</Flex>;
|
||||
return <Flex justify="flex-end">{children}</Flex>;
|
||||
};
|
||||
|
||||
Option.Label = Label;
|
||||
|
|
|
|||
|
|
@ -7,127 +7,127 @@ import { useWindowSettings } from '/@/renderer/store/settings.store';
|
|||
import { Platform } from '/@/renderer/types';
|
||||
|
||||
const Container = styled(motion(Flex))<{
|
||||
height?: string;
|
||||
position?: string;
|
||||
height?: string;
|
||||
position?: string;
|
||||
}>`
|
||||
position: ${(props) => props.position || 'relative'};
|
||||
z-index: 2000;
|
||||
width: 100%;
|
||||
height: ${(props) => props.height || '65px'};
|
||||
background: var(--titlebar-bg);
|
||||
position: ${(props) => props.position || 'relative'};
|
||||
z-index: 2000;
|
||||
width: 100%;
|
||||
height: ${(props) => props.height || '65px'};
|
||||
background: var(--titlebar-bg);
|
||||
`;
|
||||
|
||||
const Header = styled(motion.div)<{
|
||||
$isDraggable?: boolean;
|
||||
$isHidden?: boolean;
|
||||
$padRight?: boolean;
|
||||
$isDraggable?: boolean;
|
||||
$isHidden?: boolean;
|
||||
$padRight?: boolean;
|
||||
}>`
|
||||
position: relative;
|
||||
z-index: 15;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin-right: ${(props) => (props.$padRight ? '140px' : '1rem')};
|
||||
user-select: ${(props) => (props.$isHidden ? 'none' : 'auto')};
|
||||
pointer-events: ${(props) => (props.$isHidden ? 'none' : 'auto')};
|
||||
-webkit-app-region: ${(props) => props.$isDraggable && 'drag'};
|
||||
position: relative;
|
||||
z-index: 15;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin-right: ${(props) => (props.$padRight ? '140px' : '1rem')};
|
||||
user-select: ${(props) => (props.$isHidden ? 'none' : 'auto')};
|
||||
pointer-events: ${(props) => (props.$isHidden ? 'none' : 'auto')};
|
||||
-webkit-app-region: ${(props) => props.$isDraggable && 'drag'};
|
||||
|
||||
button {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
button {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
input {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
input {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
`;
|
||||
|
||||
const BackgroundImage = styled.div<{ background: string }>`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: ${(props) => props.background || 'var(--titlebar-bg)'};
|
||||
position: absolute;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: ${(props) => props.background || 'var(--titlebar-bg)'};
|
||||
`;
|
||||
|
||||
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(255, 255, 255, 25%), rgba(255, 255, 255, 25%))'
|
||||
: 'linear-gradient(rgba(0, 0, 0, 50%), rgba(0, 0, 0, 50%))'};
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: ${(props) =>
|
||||
props.theme === 'light'
|
||||
? 'linear-gradient(rgba(255, 255, 255, 25%), rgba(255, 255, 255, 25%))'
|
||||
: 'linear-gradient(rgba(0, 0, 0, 50%), rgba(0, 0, 0, 50%))'};
|
||||
`;
|
||||
|
||||
export interface PageHeaderProps
|
||||
extends Omit<FlexProps, 'onAnimationStart' | 'onDragStart' | 'onDragEnd' | 'onDrag'> {
|
||||
backgroundColor?: string;
|
||||
children?: React.ReactNode;
|
||||
height?: string;
|
||||
isHidden?: boolean;
|
||||
position?: string;
|
||||
extends Omit<FlexProps, 'onAnimationStart' | 'onDragStart' | 'onDragEnd' | 'onDrag'> {
|
||||
backgroundColor?: string;
|
||||
children?: React.ReactNode;
|
||||
height?: string;
|
||||
isHidden?: boolean;
|
||||
position?: string;
|
||||
}
|
||||
|
||||
const TitleWrapper = styled(motion.div)`
|
||||
position: absolute;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const variants: Variants = {
|
||||
animate: { opacity: 1 },
|
||||
exit: { opacity: 0 },
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: 1 },
|
||||
exit: { opacity: 0 },
|
||||
initial: { opacity: 0 },
|
||||
};
|
||||
|
||||
export const PageHeader = ({
|
||||
position,
|
||||
height,
|
||||
backgroundColor,
|
||||
isHidden,
|
||||
children,
|
||||
...props
|
||||
position,
|
||||
height,
|
||||
backgroundColor,
|
||||
isHidden,
|
||||
children,
|
||||
...props
|
||||
}: PageHeaderProps) => {
|
||||
const ref = useRef(null);
|
||||
const padRight = useShouldPadTitlebar();
|
||||
const { windowBarStyle } = useWindowSettings();
|
||||
const theme = useTheme();
|
||||
const ref = useRef(null);
|
||||
const padRight = useShouldPadTitlebar();
|
||||
const { windowBarStyle } = useWindowSettings();
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Container
|
||||
ref={ref}
|
||||
height={height}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<Header
|
||||
$isDraggable={windowBarStyle === Platform.WEB}
|
||||
$isHidden={isHidden}
|
||||
$padRight={padRight}
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
{!isHidden && (
|
||||
<TitleWrapper
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
initial="initial"
|
||||
variants={variants}
|
||||
return (
|
||||
<Container
|
||||
ref={ref}
|
||||
height={height}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<Header
|
||||
$isDraggable={windowBarStyle === Platform.WEB}
|
||||
$isHidden={isHidden}
|
||||
$padRight={padRight}
|
||||
>
|
||||
{children}
|
||||
</TitleWrapper>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Header>
|
||||
{backgroundColor && (
|
||||
<>
|
||||
<BackgroundImage background={backgroundColor || 'var(--titlebar-bg)'} />
|
||||
<BackgroundImageOverlay theme={theme} />
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
<AnimatePresence initial={false}>
|
||||
{!isHidden && (
|
||||
<TitleWrapper
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
initial="initial"
|
||||
variants={variants}
|
||||
>
|
||||
{children}
|
||||
</TitleWrapper>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Header>
|
||||
{backgroundColor && (
|
||||
<>
|
||||
<BackgroundImage background={backgroundColor || 'var(--titlebar-bg)'} />
|
||||
<BackgroundImageOverlay theme={theme} />
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,52 +1,52 @@
|
|||
import {
|
||||
Pagination as MantinePagination,
|
||||
PaginationProps as MantinePaginationProps,
|
||||
Pagination as MantinePagination,
|
||||
PaginationProps as MantinePaginationProps,
|
||||
} from '@mantine/core';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledPagination = styled(MantinePagination)<PaginationProps>`
|
||||
& .mantine-Pagination-item {
|
||||
color: var(--btn-default-fg);
|
||||
background-color: var(--btn-default-bg);
|
||||
border: none;
|
||||
transition: background 0.2s ease-in-out, color 0.2s ease-in-out;
|
||||
& .mantine-Pagination-item {
|
||||
color: var(--btn-default-fg);
|
||||
background-color: var(--btn-default-bg);
|
||||
border: none;
|
||||
transition: background 0.2s ease-in-out, color 0.2s ease-in-out;
|
||||
|
||||
&[data-active] {
|
||||
color: var(--btn-primary-fg);
|
||||
background-color: var(--btn-primary-bg);
|
||||
&[data-active] {
|
||||
color: var(--btn-primary-fg);
|
||||
background-color: var(--btn-primary-bg);
|
||||
}
|
||||
|
||||
&[data-dots] {
|
||||
display: ${({ $hideDividers }) => ($hideDividers ? 'none' : 'block')};
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--btn-default-fg-hover);
|
||||
background-color: var(--btn-default-bg-hover);
|
||||
|
||||
&[data-active] {
|
||||
color: var(--btn-primary-fg-hover);
|
||||
background-color: var(--btn-primary-bg-hover);
|
||||
}
|
||||
|
||||
&[data-dots] {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&[data-dots] {
|
||||
display: ${({ $hideDividers }) => ($hideDividers ? 'none' : 'block')};
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--btn-default-fg-hover);
|
||||
background-color: var(--btn-default-bg-hover);
|
||||
|
||||
&[data-active] {
|
||||
color: var(--btn-primary-fg-hover);
|
||||
background-color: var(--btn-primary-bg-hover);
|
||||
}
|
||||
|
||||
&[data-dots] {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface PaginationProps extends MantinePaginationProps {
|
||||
$hideDividers?: boolean;
|
||||
$hideDividers?: boolean;
|
||||
}
|
||||
|
||||
export const Pagination = ({ $hideDividers, ...props }: PaginationProps) => {
|
||||
return (
|
||||
<StyledPagination
|
||||
$hideDividers={$hideDividers}
|
||||
radius="xl"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<StyledPagination
|
||||
$hideDividers={$hideDividers}
|
||||
radius="xl"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,13 +3,13 @@ import { Paper as MantinePaper } from '@mantine/core';
|
|||
import styled from 'styled-components';
|
||||
|
||||
export interface PaperProps extends MantinePaperProps {
|
||||
children: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const StyledPaper = styled(MantinePaper)<PaperProps>`
|
||||
background: var(--paper-bg);
|
||||
background: var(--paper-bg);
|
||||
`;
|
||||
|
||||
export const Paper = ({ children, ...props }: PaperProps) => {
|
||||
return <StyledPaper {...props}>{children}</StyledPaper>;
|
||||
return <StyledPaper {...props}>{children}</StyledPaper>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type {
|
||||
PopoverProps as MantinePopoverProps,
|
||||
PopoverDropdownProps as MantinePopoverDropdownProps,
|
||||
PopoverProps as MantinePopoverProps,
|
||||
PopoverDropdownProps as MantinePopoverDropdownProps,
|
||||
} from '@mantine/core';
|
||||
import { Popover as MantinePopover } from '@mantine/core';
|
||||
import styled from 'styled-components';
|
||||
|
|
@ -11,28 +11,28 @@ type PopoverDropdownProps = MantinePopoverDropdownProps;
|
|||
const StyledPopover = styled(MantinePopover)``;
|
||||
|
||||
const StyledDropdown = styled(MantinePopover.Dropdown)<PopoverDropdownProps>`
|
||||
padding: 0.5rem;
|
||||
font-size: 0.9em;
|
||||
font-family: var(--content-font-family);
|
||||
background-color: var(--dropdown-menu-bg);
|
||||
border: var(--dropdown-menu-border);
|
||||
padding: 0.5rem;
|
||||
font-size: 0.9em;
|
||||
font-family: var(--content-font-family);
|
||||
background-color: var(--dropdown-menu-bg);
|
||||
border: var(--dropdown-menu-border);
|
||||
`;
|
||||
|
||||
export const Popover = ({ children, ...props }: PopoverProps) => {
|
||||
return (
|
||||
<StyledPopover
|
||||
withinPortal
|
||||
styles={{
|
||||
dropdown: {
|
||||
filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 50%))',
|
||||
},
|
||||
}}
|
||||
transitionProps={{ transition: 'fade' }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</StyledPopover>
|
||||
);
|
||||
return (
|
||||
<StyledPopover
|
||||
withinPortal
|
||||
styles={{
|
||||
dropdown: {
|
||||
filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 50%))',
|
||||
},
|
||||
}}
|
||||
transitionProps={{ transition: 'fade' }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</StyledPopover>
|
||||
);
|
||||
};
|
||||
|
||||
Popover.Target = MantinePopover.Target;
|
||||
|
|
|
|||
|
|
@ -8,211 +8,211 @@ import { QueryBuilderOption } from '/@/renderer/components/query-builder/query-b
|
|||
import { QueryBuilderGroup, QueryBuilderRule } from '/@/renderer/types';
|
||||
|
||||
const FILTER_GROUP_OPTIONS_DATA = [
|
||||
{
|
||||
label: 'Match all',
|
||||
value: 'all',
|
||||
},
|
||||
{
|
||||
label: 'Match any',
|
||||
value: 'any',
|
||||
},
|
||||
{
|
||||
label: 'Match all',
|
||||
value: 'all',
|
||||
},
|
||||
{
|
||||
label: 'Match any',
|
||||
value: 'any',
|
||||
},
|
||||
];
|
||||
|
||||
type AddArgs = {
|
||||
groupIndex: number[];
|
||||
level: number;
|
||||
groupIndex: number[];
|
||||
level: number;
|
||||
};
|
||||
|
||||
type DeleteArgs = {
|
||||
groupIndex: number[];
|
||||
level: number;
|
||||
uniqueId: string;
|
||||
groupIndex: number[];
|
||||
level: number;
|
||||
uniqueId: string;
|
||||
};
|
||||
interface QueryBuilderProps {
|
||||
data: Record<string, any>;
|
||||
filters: { label: string; type: string; value: string }[];
|
||||
groupIndex: number[];
|
||||
level: number;
|
||||
onAddRule: (args: AddArgs) => void;
|
||||
onAddRuleGroup: (args: AddArgs) => void;
|
||||
onChangeField: (args: any) => void;
|
||||
onChangeOperator: (args: any) => void;
|
||||
onChangeType: (args: any) => void;
|
||||
onChangeValue: (args: any) => void;
|
||||
onClearFilters: () => void;
|
||||
onDeleteRule: (args: DeleteArgs) => void;
|
||||
onDeleteRuleGroup: (args: DeleteArgs) => void;
|
||||
onResetFilters: () => void;
|
||||
operators: {
|
||||
boolean: { label: string; value: string }[];
|
||||
date: { label: string; value: string }[];
|
||||
number: { label: string; value: string }[];
|
||||
string: { label: string; value: string }[];
|
||||
};
|
||||
uniqueId: string;
|
||||
data: Record<string, any>;
|
||||
filters: { label: string; type: string; value: string }[];
|
||||
groupIndex: number[];
|
||||
level: number;
|
||||
onAddRule: (args: AddArgs) => void;
|
||||
onAddRuleGroup: (args: AddArgs) => void;
|
||||
onChangeField: (args: any) => void;
|
||||
onChangeOperator: (args: any) => void;
|
||||
onChangeType: (args: any) => void;
|
||||
onChangeValue: (args: any) => void;
|
||||
onClearFilters: () => void;
|
||||
onDeleteRule: (args: DeleteArgs) => void;
|
||||
onDeleteRuleGroup: (args: DeleteArgs) => void;
|
||||
onResetFilters: () => void;
|
||||
operators: {
|
||||
boolean: { label: string; value: string }[];
|
||||
date: { label: string; value: string }[];
|
||||
number: { label: string; value: string }[];
|
||||
string: { label: string; value: string }[];
|
||||
};
|
||||
uniqueId: string;
|
||||
}
|
||||
|
||||
export const QueryBuilder = ({
|
||||
data,
|
||||
level,
|
||||
onAddRule,
|
||||
onDeleteRuleGroup,
|
||||
onDeleteRule,
|
||||
onAddRuleGroup,
|
||||
onChangeType,
|
||||
onChangeField,
|
||||
operators,
|
||||
onChangeOperator,
|
||||
onChangeValue,
|
||||
onClearFilters,
|
||||
onResetFilters,
|
||||
groupIndex,
|
||||
uniqueId,
|
||||
filters,
|
||||
data,
|
||||
level,
|
||||
onAddRule,
|
||||
onDeleteRuleGroup,
|
||||
onDeleteRule,
|
||||
onAddRuleGroup,
|
||||
onChangeType,
|
||||
onChangeField,
|
||||
operators,
|
||||
onChangeOperator,
|
||||
onChangeValue,
|
||||
onClearFilters,
|
||||
onResetFilters,
|
||||
groupIndex,
|
||||
uniqueId,
|
||||
filters,
|
||||
}: QueryBuilderProps) => {
|
||||
const handleAddRule = () => {
|
||||
onAddRule({ groupIndex, level });
|
||||
};
|
||||
const handleAddRule = () => {
|
||||
onAddRule({ groupIndex, level });
|
||||
};
|
||||
|
||||
const handleAddRuleGroup = () => {
|
||||
onAddRuleGroup({ groupIndex, level });
|
||||
};
|
||||
const handleAddRuleGroup = () => {
|
||||
onAddRuleGroup({ groupIndex, level });
|
||||
};
|
||||
|
||||
const handleDeleteRuleGroup = () => {
|
||||
onDeleteRuleGroup({ groupIndex, level, uniqueId });
|
||||
};
|
||||
const handleDeleteRuleGroup = () => {
|
||||
onDeleteRuleGroup({ groupIndex, level, uniqueId });
|
||||
};
|
||||
|
||||
const handleChangeType = (value: string | null) => {
|
||||
onChangeType({ groupIndex, level, value });
|
||||
};
|
||||
const handleChangeType = (value: string | null) => {
|
||||
onChangeType({ groupIndex, level, value });
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack
|
||||
ml={`${level * 10}px`}
|
||||
spacing="sm"
|
||||
>
|
||||
<Group spacing="sm">
|
||||
<Select
|
||||
data={FILTER_GROUP_OPTIONS_DATA}
|
||||
maxWidth={175}
|
||||
size="sm"
|
||||
value={data.type}
|
||||
width="20%"
|
||||
onChange={handleChangeType}
|
||||
/>
|
||||
<Button
|
||||
px={5}
|
||||
size="sm"
|
||||
tooltip={{ label: 'Add rule' }}
|
||||
variant="default"
|
||||
onClick={handleAddRule}
|
||||
return (
|
||||
<Stack
|
||||
ml={`${level * 10}px`}
|
||||
spacing="sm"
|
||||
>
|
||||
<RiAddLine size={20} />
|
||||
</Button>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
p={0}
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
>
|
||||
<RiMore2Line size={20} />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Item
|
||||
icon={<RiAddFill />}
|
||||
onClick={handleAddRuleGroup}
|
||||
>
|
||||
Add rule group
|
||||
</DropdownMenu.Item>
|
||||
<Group spacing="sm">
|
||||
<Select
|
||||
data={FILTER_GROUP_OPTIONS_DATA}
|
||||
maxWidth={175}
|
||||
size="sm"
|
||||
value={data.type}
|
||||
width="20%"
|
||||
onChange={handleChangeType}
|
||||
/>
|
||||
<Button
|
||||
px={5}
|
||||
size="sm"
|
||||
tooltip={{ label: 'Add rule' }}
|
||||
variant="default"
|
||||
onClick={handleAddRule}
|
||||
>
|
||||
<RiAddLine size={20} />
|
||||
</Button>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
p={0}
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
>
|
||||
<RiMore2Line size={20} />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Item
|
||||
icon={<RiAddFill />}
|
||||
onClick={handleAddRuleGroup}
|
||||
>
|
||||
Add rule group
|
||||
</DropdownMenu.Item>
|
||||
|
||||
{level > 0 && (
|
||||
<DropdownMenu.Item
|
||||
icon={<RiDeleteBinFill />}
|
||||
onClick={handleDeleteRuleGroup}
|
||||
>
|
||||
Remove rule group
|
||||
</DropdownMenu.Item>
|
||||
{level > 0 && (
|
||||
<DropdownMenu.Item
|
||||
icon={<RiDeleteBinFill />}
|
||||
onClick={handleDeleteRuleGroup}
|
||||
>
|
||||
Remove rule group
|
||||
</DropdownMenu.Item>
|
||||
)}
|
||||
{level === 0 && (
|
||||
<>
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Item
|
||||
$danger
|
||||
icon={<RiRestartLine color="var(--danger-color)" />}
|
||||
onClick={onResetFilters}
|
||||
>
|
||||
Reset to default
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$danger
|
||||
icon={<RiDeleteBinFill color="var(--danger-color)" />}
|
||||
onClick={onClearFilters}
|
||||
>
|
||||
Clear filters
|
||||
</DropdownMenu.Item>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
</Group>
|
||||
<AnimatePresence initial={false}>
|
||||
{data?.rules?.map((rule: QueryBuilderRule) => (
|
||||
<motion.div
|
||||
key={rule.uniqueId}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -25 }}
|
||||
initial={{ opacity: 0, x: -25 }}
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
>
|
||||
<QueryBuilderOption
|
||||
data={rule}
|
||||
filters={filters}
|
||||
groupIndex={groupIndex || []}
|
||||
level={level}
|
||||
noRemove={data?.rules?.length === 1}
|
||||
operators={operators}
|
||||
onChangeField={onChangeField}
|
||||
onChangeOperator={onChangeOperator}
|
||||
onChangeValue={onChangeValue}
|
||||
onDeleteRule={onDeleteRule}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
{data?.group && (
|
||||
<AnimatePresence initial={false}>
|
||||
{data.group?.map((group: QueryBuilderGroup, index: number) => (
|
||||
<motion.div
|
||||
key={group.uniqueId}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -25 }}
|
||||
initial={{ opacity: 0, x: -25 }}
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
>
|
||||
<QueryBuilder
|
||||
data={group}
|
||||
filters={filters}
|
||||
groupIndex={[...(groupIndex || []), index]}
|
||||
level={level + 1}
|
||||
operators={operators}
|
||||
uniqueId={group.uniqueId}
|
||||
onAddRule={onAddRule}
|
||||
onAddRuleGroup={onAddRuleGroup}
|
||||
onChangeField={onChangeField}
|
||||
onChangeOperator={onChangeOperator}
|
||||
onChangeType={onChangeType}
|
||||
onChangeValue={onChangeValue}
|
||||
onClearFilters={onClearFilters}
|
||||
onDeleteRule={onDeleteRule}
|
||||
onDeleteRuleGroup={onDeleteRuleGroup}
|
||||
onResetFilters={onResetFilters}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
{level === 0 && (
|
||||
<>
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Item
|
||||
$danger
|
||||
icon={<RiRestartLine color="var(--danger-color)" />}
|
||||
onClick={onResetFilters}
|
||||
>
|
||||
Reset to default
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$danger
|
||||
icon={<RiDeleteBinFill color="var(--danger-color)" />}
|
||||
onClick={onClearFilters}
|
||||
>
|
||||
Clear filters
|
||||
</DropdownMenu.Item>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
</Group>
|
||||
<AnimatePresence initial={false}>
|
||||
{data?.rules?.map((rule: QueryBuilderRule) => (
|
||||
<motion.div
|
||||
key={rule.uniqueId}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -25 }}
|
||||
initial={{ opacity: 0, x: -25 }}
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
>
|
||||
<QueryBuilderOption
|
||||
data={rule}
|
||||
filters={filters}
|
||||
groupIndex={groupIndex || []}
|
||||
level={level}
|
||||
noRemove={data?.rules?.length === 1}
|
||||
operators={operators}
|
||||
onChangeField={onChangeField}
|
||||
onChangeOperator={onChangeOperator}
|
||||
onChangeValue={onChangeValue}
|
||||
onDeleteRule={onDeleteRule}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
{data?.group && (
|
||||
<AnimatePresence initial={false}>
|
||||
{data.group?.map((group: QueryBuilderGroup, index: number) => (
|
||||
<motion.div
|
||||
key={group.uniqueId}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -25 }}
|
||||
initial={{ opacity: 0, x: -25 }}
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
>
|
||||
<QueryBuilder
|
||||
data={group}
|
||||
filters={filters}
|
||||
groupIndex={[...(groupIndex || []), index]}
|
||||
level={level + 1}
|
||||
operators={operators}
|
||||
uniqueId={group.uniqueId}
|
||||
onAddRule={onAddRule}
|
||||
onAddRuleGroup={onAddRuleGroup}
|
||||
onChangeField={onChangeField}
|
||||
onChangeOperator={onChangeOperator}
|
||||
onChangeType={onChangeType}
|
||||
onChangeValue={onChangeValue}
|
||||
onClearFilters={onClearFilters}
|
||||
onDeleteRule={onDeleteRule}
|
||||
onDeleteRuleGroup={onDeleteRuleGroup}
|
||||
onResetFilters={onResetFilters}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,233 +7,233 @@ import { Select } from '/@/renderer/components/select';
|
|||
import { QueryBuilderRule } from '/@/renderer/types';
|
||||
|
||||
type DeleteArgs = {
|
||||
groupIndex: number[];
|
||||
level: number;
|
||||
uniqueId: string;
|
||||
groupIndex: number[];
|
||||
level: number;
|
||||
uniqueId: string;
|
||||
};
|
||||
|
||||
interface QueryOptionProps {
|
||||
data: QueryBuilderRule;
|
||||
filters: { label: string; type: string; value: string }[];
|
||||
groupIndex: number[];
|
||||
level: number;
|
||||
noRemove: boolean;
|
||||
onChangeField: (args: any) => void;
|
||||
onChangeOperator: (args: any) => void;
|
||||
onChangeValue: (args: any) => void;
|
||||
onDeleteRule: (args: DeleteArgs) => void;
|
||||
operators: {
|
||||
boolean: { label: string; value: string }[];
|
||||
date: { label: string; value: string }[];
|
||||
number: { label: string; value: string }[];
|
||||
string: { label: string; value: string }[];
|
||||
};
|
||||
data: QueryBuilderRule;
|
||||
filters: { label: string; type: string; value: string }[];
|
||||
groupIndex: number[];
|
||||
level: number;
|
||||
noRemove: boolean;
|
||||
onChangeField: (args: any) => void;
|
||||
onChangeOperator: (args: any) => void;
|
||||
onChangeValue: (args: any) => void;
|
||||
onDeleteRule: (args: DeleteArgs) => void;
|
||||
operators: {
|
||||
boolean: { label: string; value: string }[];
|
||||
date: { label: string; value: string }[];
|
||||
number: { label: string; value: string }[];
|
||||
string: { label: string; value: string }[];
|
||||
};
|
||||
}
|
||||
|
||||
const QueryValueInput = ({ onChange, type, ...props }: any) => {
|
||||
const [numberRange, setNumberRange] = useState([0, 0]);
|
||||
const [numberRange, setNumberRange] = useState([0, 0]);
|
||||
|
||||
switch (type) {
|
||||
case 'string':
|
||||
return (
|
||||
<TextInput
|
||||
size="sm"
|
||||
onChange={onChange}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
case 'number':
|
||||
return (
|
||||
<NumberInput
|
||||
size="sm"
|
||||
onChange={onChange}
|
||||
{...props}
|
||||
defaultValue={props.defaultValue && Number(props.defaultValue)}
|
||||
/>
|
||||
);
|
||||
case 'date':
|
||||
return (
|
||||
<TextInput
|
||||
size="sm"
|
||||
onChange={onChange}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
switch (type) {
|
||||
case 'string':
|
||||
return (
|
||||
<TextInput
|
||||
size="sm"
|
||||
onChange={onChange}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
case 'number':
|
||||
return (
|
||||
<NumberInput
|
||||
size="sm"
|
||||
onChange={onChange}
|
||||
{...props}
|
||||
defaultValue={props.defaultValue && Number(props.defaultValue)}
|
||||
/>
|
||||
);
|
||||
case 'date':
|
||||
return (
|
||||
<TextInput
|
||||
size="sm"
|
||||
onChange={onChange}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'dateRange':
|
||||
return (
|
||||
<>
|
||||
<NumberInput
|
||||
{...props}
|
||||
defaultValue={props.defaultValue && Number(props.defaultValue?.[0])}
|
||||
maxWidth={81}
|
||||
width="10%"
|
||||
onChange={(e) => {
|
||||
const newRange = [e || 0, numberRange[1]];
|
||||
setNumberRange(newRange);
|
||||
onChange(newRange);
|
||||
}}
|
||||
/>
|
||||
<NumberInput
|
||||
{...props}
|
||||
defaultValue={props.defaultValue && Number(props.defaultValue?.[1])}
|
||||
maxWidth={81}
|
||||
width="10%"
|
||||
onChange={(e) => {
|
||||
const newRange = [numberRange[0], e || 0];
|
||||
setNumberRange(newRange);
|
||||
onChange(newRange);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
case 'dateRange':
|
||||
return (
|
||||
<>
|
||||
<NumberInput
|
||||
{...props}
|
||||
defaultValue={props.defaultValue && Number(props.defaultValue?.[0])}
|
||||
maxWidth={81}
|
||||
width="10%"
|
||||
onChange={(e) => {
|
||||
const newRange = [e || 0, numberRange[1]];
|
||||
setNumberRange(newRange);
|
||||
onChange(newRange);
|
||||
}}
|
||||
/>
|
||||
<NumberInput
|
||||
{...props}
|
||||
defaultValue={props.defaultValue && Number(props.defaultValue?.[1])}
|
||||
maxWidth={81}
|
||||
width="10%"
|
||||
onChange={(e) => {
|
||||
const newRange = [numberRange[0], e || 0];
|
||||
setNumberRange(newRange);
|
||||
onChange(newRange);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
case 'boolean':
|
||||
return (
|
||||
<Select
|
||||
data={[
|
||||
{ label: 'true', value: 'true' },
|
||||
{ label: 'false', value: 'false' },
|
||||
]}
|
||||
onChange={onChange}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
case 'boolean':
|
||||
return (
|
||||
<Select
|
||||
data={[
|
||||
{ label: 'true', value: 'true' },
|
||||
{ label: 'false', value: 'false' },
|
||||
]}
|
||||
onChange={onChange}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return <></>;
|
||||
}
|
||||
default:
|
||||
return <></>;
|
||||
}
|
||||
};
|
||||
|
||||
export const QueryBuilderOption = ({
|
||||
data,
|
||||
filters,
|
||||
level,
|
||||
onDeleteRule,
|
||||
operators,
|
||||
groupIndex,
|
||||
noRemove,
|
||||
onChangeField,
|
||||
onChangeOperator,
|
||||
onChangeValue,
|
||||
data,
|
||||
filters,
|
||||
level,
|
||||
onDeleteRule,
|
||||
operators,
|
||||
groupIndex,
|
||||
noRemove,
|
||||
onChangeField,
|
||||
onChangeOperator,
|
||||
onChangeValue,
|
||||
}: QueryOptionProps) => {
|
||||
const { field, operator, uniqueId, value } = data;
|
||||
const { field, operator, uniqueId, value } = data;
|
||||
|
||||
const handleDeleteRule = () => {
|
||||
onDeleteRule({ groupIndex, level, uniqueId });
|
||||
};
|
||||
const handleDeleteRule = () => {
|
||||
onDeleteRule({ groupIndex, level, uniqueId });
|
||||
};
|
||||
|
||||
const handleChangeField = (e: any) => {
|
||||
onChangeField({ groupIndex, level, uniqueId, value: e });
|
||||
};
|
||||
const handleChangeField = (e: any) => {
|
||||
onChangeField({ groupIndex, level, uniqueId, value: e });
|
||||
};
|
||||
|
||||
const handleChangeOperator = (e: any) => {
|
||||
onChangeOperator({ groupIndex, level, uniqueId, value: e });
|
||||
};
|
||||
const handleChangeOperator = (e: any) => {
|
||||
onChangeOperator({ groupIndex, level, uniqueId, value: e });
|
||||
};
|
||||
|
||||
const handleChangeValue = (e: any) => {
|
||||
const isDirectValue =
|
||||
typeof e === 'string' ||
|
||||
typeof e === 'number' ||
|
||||
typeof e === 'undefined' ||
|
||||
typeof e === null;
|
||||
const handleChangeValue = (e: any) => {
|
||||
const isDirectValue =
|
||||
typeof e === 'string' ||
|
||||
typeof e === 'number' ||
|
||||
typeof e === 'undefined' ||
|
||||
typeof e === null;
|
||||
|
||||
if (isDirectValue) {
|
||||
return onChangeValue({
|
||||
groupIndex,
|
||||
level,
|
||||
uniqueId,
|
||||
value: e,
|
||||
});
|
||||
}
|
||||
if (isDirectValue) {
|
||||
return onChangeValue({
|
||||
groupIndex,
|
||||
level,
|
||||
uniqueId,
|
||||
value: e,
|
||||
});
|
||||
}
|
||||
|
||||
// const isDate = e instanceof Date;
|
||||
// const isDate = e instanceof Date;
|
||||
|
||||
// if (isDate) {
|
||||
// return onChangeValue({
|
||||
// groupIndex,
|
||||
// level,
|
||||
// uniqueId,
|
||||
// value: dayjs(e).format('YYYY-MM-DD'),
|
||||
// });
|
||||
// }
|
||||
// if (isDate) {
|
||||
// return onChangeValue({
|
||||
// groupIndex,
|
||||
// level,
|
||||
// uniqueId,
|
||||
// value: dayjs(e).format('YYYY-MM-DD'),
|
||||
// });
|
||||
// }
|
||||
|
||||
const isArray = Array.isArray(e);
|
||||
const isArray = Array.isArray(e);
|
||||
|
||||
if (isArray) {
|
||||
return onChangeValue({
|
||||
groupIndex,
|
||||
level,
|
||||
uniqueId,
|
||||
value: e,
|
||||
});
|
||||
}
|
||||
if (isArray) {
|
||||
return onChangeValue({
|
||||
groupIndex,
|
||||
level,
|
||||
uniqueId,
|
||||
value: e,
|
||||
});
|
||||
}
|
||||
|
||||
return onChangeValue({
|
||||
groupIndex,
|
||||
level,
|
||||
uniqueId,
|
||||
value: e.currentTarget.value,
|
||||
});
|
||||
};
|
||||
return onChangeValue({
|
||||
groupIndex,
|
||||
level,
|
||||
uniqueId,
|
||||
value: e.currentTarget.value,
|
||||
});
|
||||
};
|
||||
|
||||
const fieldType = filters.find((f) => f.value === field)?.type;
|
||||
const operatorsByFieldType = operators[fieldType as keyof typeof operators];
|
||||
const ml = (level + 1) * 10;
|
||||
const fieldType = filters.find((f) => f.value === field)?.type;
|
||||
const operatorsByFieldType = operators[fieldType as keyof typeof operators];
|
||||
const ml = (level + 1) * 10;
|
||||
|
||||
return (
|
||||
<Group
|
||||
ml={ml}
|
||||
spacing="sm"
|
||||
>
|
||||
<Select
|
||||
searchable
|
||||
data={filters}
|
||||
maxWidth={170}
|
||||
size="sm"
|
||||
value={field}
|
||||
width="25%"
|
||||
onChange={handleChangeField}
|
||||
/>
|
||||
<Select
|
||||
searchable
|
||||
data={operatorsByFieldType || []}
|
||||
disabled={!field}
|
||||
maxWidth={170}
|
||||
size="sm"
|
||||
value={operator}
|
||||
width="25%"
|
||||
onChange={handleChangeOperator}
|
||||
/>
|
||||
{field ? (
|
||||
<QueryValueInput
|
||||
defaultValue={value}
|
||||
maxWidth={170}
|
||||
size="sm"
|
||||
type={operator === 'inTheRange' ? 'dateRange' : fieldType}
|
||||
width="25%"
|
||||
onChange={handleChangeValue}
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
disabled
|
||||
defaultValue={value}
|
||||
maxWidth={170}
|
||||
size="sm"
|
||||
width="25%"
|
||||
onChange={handleChangeValue}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
disabled={noRemove}
|
||||
px={5}
|
||||
size="sm"
|
||||
tooltip={{ label: 'Remove rule' }}
|
||||
variant="default"
|
||||
onClick={handleDeleteRule}
|
||||
>
|
||||
<RiSubtractLine size={20} />
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
return (
|
||||
<Group
|
||||
ml={ml}
|
||||
spacing="sm"
|
||||
>
|
||||
<Select
|
||||
searchable
|
||||
data={filters}
|
||||
maxWidth={170}
|
||||
size="sm"
|
||||
value={field}
|
||||
width="25%"
|
||||
onChange={handleChangeField}
|
||||
/>
|
||||
<Select
|
||||
searchable
|
||||
data={operatorsByFieldType || []}
|
||||
disabled={!field}
|
||||
maxWidth={170}
|
||||
size="sm"
|
||||
value={operator}
|
||||
width="25%"
|
||||
onChange={handleChangeOperator}
|
||||
/>
|
||||
{field ? (
|
||||
<QueryValueInput
|
||||
defaultValue={value}
|
||||
maxWidth={170}
|
||||
size="sm"
|
||||
type={operator === 'inTheRange' ? 'dateRange' : fieldType}
|
||||
width="25%"
|
||||
onChange={handleChangeValue}
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
disabled
|
||||
defaultValue={value}
|
||||
maxWidth={170}
|
||||
size="sm"
|
||||
width="25%"
|
||||
onChange={handleChangeValue}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
disabled={noRemove}
|
||||
px={5}
|
||||
size="sm"
|
||||
tooltip={{ label: 'Remove rule' }}
|
||||
variant="default"
|
||||
onClick={handleDeleteRule}
|
||||
>
|
||||
<RiSubtractLine size={20} />
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,29 +5,29 @@ import styled from 'styled-components';
|
|||
import { Tooltip } from '/@/renderer/components/tooltip';
|
||||
|
||||
interface RatingProps extends Omit<MantineRatingProps, 'onClick'> {
|
||||
onClick: (e: MouseEvent<HTMLDivElement>, value: number | undefined) => void;
|
||||
onClick: (e: MouseEvent<HTMLDivElement>, value: number | undefined) => void;
|
||||
}
|
||||
|
||||
const StyledRating = styled(MantineRating)`
|
||||
& .mantine-Rating-symbolBody {
|
||||
svg {
|
||||
stroke: var(--main-fg-secondary);
|
||||
& .mantine-Rating-symbolBody {
|
||||
svg {
|
||||
stroke: var(--main-fg-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const Rating = ({ onClick, ...props }: RatingProps) => {
|
||||
// const debouncedOnClick = debounce(onClick, 100);
|
||||
// const debouncedOnClick = debounce(onClick, 100);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label="Double click to clear"
|
||||
openDelay={1000}
|
||||
>
|
||||
<StyledRating
|
||||
{...props}
|
||||
onDoubleClick={(e) => onClick(e, props.value)}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
return (
|
||||
<Tooltip
|
||||
label="Double click to clear"
|
||||
openDelay={1000}
|
||||
>
|
||||
<StyledRating
|
||||
{...props}
|
||||
onDoubleClick={(e) => onClick(e, props.value)}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,169 +9,169 @@ import { useWindowSettings } from '/@/renderer/store/settings.store';
|
|||
import { Platform } from '/@/renderer/types';
|
||||
|
||||
interface ScrollAreaProps extends MantineScrollAreaProps {
|
||||
children: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const StyledScrollArea = styled(MantineScrollArea)`
|
||||
& .mantine-ScrollArea-thumb {
|
||||
background: var(--scrollbar-thumb-bg);
|
||||
border-radius: 0;
|
||||
}
|
||||
& .mantine-ScrollArea-thumb {
|
||||
background: var(--scrollbar-thumb-bg);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
& .mantine-ScrollArea-scrollbar {
|
||||
padding: 0;
|
||||
background: var(--scrollbar-track-bg);
|
||||
}
|
||||
& .mantine-ScrollArea-scrollbar {
|
||||
padding: 0;
|
||||
background: var(--scrollbar-track-bg);
|
||||
}
|
||||
|
||||
& .mantine-ScrollArea-viewport > div {
|
||||
display: block !important;
|
||||
}
|
||||
& .mantine-ScrollArea-viewport > div {
|
||||
display: block !important;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledNativeScrollArea = styled.div<{ scrollBarOffset?: string; windowBarStyle?: Platform }>`
|
||||
height: 100%;
|
||||
overflow-y: overlay !important;
|
||||
height: 100%;
|
||||
overflow-y: overlay !important;
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
margin-top: ${(props) =>
|
||||
props.windowBarStyle === Platform.WINDOWS ||
|
||||
props.windowBarStyle === Platform.MACOS ||
|
||||
props.windowBarStyle === Platform.LINUX
|
||||
? '0px'
|
||||
: props.scrollBarOffset || '65px'};
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
margin-top: ${(props) =>
|
||||
props.windowBarStyle === Platform.WINDOWS ||
|
||||
props.windowBarStyle === Platform.MACOS ||
|
||||
props.windowBarStyle === Platform.LINUX
|
||||
? '0px'
|
||||
: props.scrollBarOffset || '65px'};
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
margin-top: ${(props) =>
|
||||
props.windowBarStyle === Platform.WINDOWS ||
|
||||
props.windowBarStyle === Platform.MACOS ||
|
||||
props.windowBarStyle === Platform.LINUX
|
||||
? '0px'
|
||||
: props.scrollBarOffset || '65px'};
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
margin-top: ${(props) =>
|
||||
props.windowBarStyle === Platform.WINDOWS ||
|
||||
props.windowBarStyle === Platform.MACOS ||
|
||||
props.windowBarStyle === Platform.LINUX
|
||||
? '0px'
|
||||
: props.scrollBarOffset || '65px'};
|
||||
}
|
||||
`;
|
||||
|
||||
export const ScrollArea = forwardRef(({ children, ...props }: ScrollAreaProps, ref: Ref<any>) => {
|
||||
return (
|
||||
<StyledScrollArea
|
||||
ref={ref}
|
||||
scrollbarSize={12}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</StyledScrollArea>
|
||||
);
|
||||
return (
|
||||
<StyledScrollArea
|
||||
ref={ref}
|
||||
scrollbarSize={12}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</StyledScrollArea>
|
||||
);
|
||||
});
|
||||
|
||||
interface NativeScrollAreaProps {
|
||||
children: React.ReactNode;
|
||||
debugScrollPosition?: boolean;
|
||||
noHeader?: boolean;
|
||||
pageHeaderProps?: PageHeaderProps & { offset?: any; target?: any };
|
||||
scrollBarOffset?: string;
|
||||
scrollHideDelay?: number;
|
||||
style?: React.CSSProperties;
|
||||
children: React.ReactNode;
|
||||
debugScrollPosition?: boolean;
|
||||
noHeader?: boolean;
|
||||
pageHeaderProps?: PageHeaderProps & { offset?: any; target?: any };
|
||||
scrollBarOffset?: string;
|
||||
scrollHideDelay?: number;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const NativeScrollArea = forwardRef(
|
||||
(
|
||||
{
|
||||
children,
|
||||
pageHeaderProps,
|
||||
debugScrollPosition,
|
||||
scrollBarOffset,
|
||||
scrollHideDelay,
|
||||
noHeader,
|
||||
...props
|
||||
}: NativeScrollAreaProps,
|
||||
ref: Ref<HTMLDivElement>,
|
||||
) => {
|
||||
const { windowBarStyle } = useWindowSettings();
|
||||
const [hideScrollbar, setHideScrollbar] = useState(false);
|
||||
const [hideHeader, setHideHeader] = useState(true);
|
||||
const { start, clear } = useTimeout(
|
||||
() => setHideScrollbar(true),
|
||||
scrollHideDelay !== undefined ? scrollHideDelay * 1000 : 0,
|
||||
);
|
||||
(
|
||||
{
|
||||
children,
|
||||
pageHeaderProps,
|
||||
debugScrollPosition,
|
||||
scrollBarOffset,
|
||||
scrollHideDelay,
|
||||
noHeader,
|
||||
...props
|
||||
}: NativeScrollAreaProps,
|
||||
ref: Ref<HTMLDivElement>,
|
||||
) => {
|
||||
const { windowBarStyle } = useWindowSettings();
|
||||
const [hideScrollbar, setHideScrollbar] = useState(false);
|
||||
const [hideHeader, setHideHeader] = useState(true);
|
||||
const { start, clear } = useTimeout(
|
||||
() => setHideScrollbar(true),
|
||||
scrollHideDelay !== undefined ? scrollHideDelay * 1000 : 0,
|
||||
);
|
||||
|
||||
const containerRef = useRef(null);
|
||||
const mergedRef = useMergedRef(ref, containerRef);
|
||||
const containerRef = useRef(null);
|
||||
const mergedRef = useMergedRef(ref, containerRef);
|
||||
|
||||
const { scrollYProgress } = useScroll({
|
||||
container: containerRef,
|
||||
offset: pageHeaderProps?.offset || ['center start', 'end start'],
|
||||
target: pageHeaderProps?.target,
|
||||
});
|
||||
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 (
|
||||
<>
|
||||
{!noHeader && (
|
||||
<PageHeader
|
||||
isHidden={hideHeader}
|
||||
position="absolute"
|
||||
style={{ opacity: scrollYProgress as any }}
|
||||
{...pageHeaderProps}
|
||||
/>
|
||||
)}
|
||||
<StyledNativeScrollArea
|
||||
ref={mergedRef}
|
||||
className={hideScrollbar ? 'hide-scrollbar' : undefined}
|
||||
scrollBarOffset={scrollBarOffset}
|
||||
windowBarStyle={windowBarStyle}
|
||||
onMouseEnter={() => {
|
||||
setHideScrollbar(false);
|
||||
clear();
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
// Automatically hide the scrollbar after the timeout duration
|
||||
useEffect(() => {
|
||||
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,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
// 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 (
|
||||
<>
|
||||
{!noHeader && (
|
||||
<PageHeader
|
||||
isHidden={hideHeader}
|
||||
position="absolute"
|
||||
style={{ opacity: scrollYProgress as any }}
|
||||
{...pageHeaderProps}
|
||||
/>
|
||||
)}
|
||||
<StyledNativeScrollArea
|
||||
ref={mergedRef}
|
||||
className={hideScrollbar ? 'hide-scrollbar' : undefined}
|
||||
scrollBarOffset={scrollBarOffset}
|
||||
windowBarStyle={windowBarStyle}
|
||||
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,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,64 +7,64 @@ import { useSettingsStore } from '/@/renderer/store';
|
|||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
interface SearchInputProps extends TextInputProps {
|
||||
initialWidth?: number;
|
||||
openedWidth?: number;
|
||||
value?: string;
|
||||
initialWidth?: number;
|
||||
openedWidth?: number;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export const SearchInput = ({
|
||||
initialWidth,
|
||||
onChange,
|
||||
openedWidth,
|
||||
...props
|
||||
initialWidth,
|
||||
onChange,
|
||||
openedWidth,
|
||||
...props
|
||||
}: SearchInputProps) => {
|
||||
const { ref, focused } = useFocusWithin();
|
||||
const mergedRef = useMergedRef<HTMLInputElement>(ref);
|
||||
const binding = useSettingsStore((state) => state.hotkeys.bindings.localSearch, shallow);
|
||||
const { ref, focused } = useFocusWithin();
|
||||
const mergedRef = useMergedRef<HTMLInputElement>(ref);
|
||||
const binding = useSettingsStore((state) => state.hotkeys.bindings.localSearch, shallow);
|
||||
|
||||
const isOpened = focused || ref.current?.value;
|
||||
const showIcon = !isOpened || (openedWidth || 100) > 100;
|
||||
const isOpened = focused || ref.current?.value;
|
||||
const showIcon = !isOpened || (openedWidth || 100) > 100;
|
||||
|
||||
useHotkeys([[binding.hotkey, () => ref.current.select()]]);
|
||||
useHotkeys([[binding.hotkey, () => ref.current.select()]]);
|
||||
|
||||
const handleEscape = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.code === 'Escape') {
|
||||
onChange?.({ target: { value: '' } } as ChangeEvent<HTMLInputElement>);
|
||||
ref.current.value = '';
|
||||
ref.current.blur();
|
||||
}
|
||||
};
|
||||
const handleEscape = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.code === 'Escape') {
|
||||
onChange?.({ target: { value: '' } } as ChangeEvent<HTMLInputElement>);
|
||||
ref.current.value = '';
|
||||
ref.current.blur();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
ref={mergedRef}
|
||||
{...props}
|
||||
icon={showIcon && <RiSearchLine />}
|
||||
rightSection={
|
||||
isOpened ? (
|
||||
<ActionIcon
|
||||
onClick={() => {
|
||||
ref.current.value = '';
|
||||
ref.current.focus();
|
||||
return (
|
||||
<TextInput
|
||||
ref={mergedRef}
|
||||
{...props}
|
||||
icon={showIcon && <RiSearchLine />}
|
||||
rightSection={
|
||||
isOpened ? (
|
||||
<ActionIcon
|
||||
onClick={() => {
|
||||
ref.current.value = '';
|
||||
ref.current.focus();
|
||||
}}
|
||||
>
|
||||
<RiCloseFill />
|
||||
</ActionIcon>
|
||||
) : null
|
||||
}
|
||||
size="md"
|
||||
styles={{
|
||||
icon: { svg: { fill: 'var(--titlebar-fg)' } },
|
||||
input: {
|
||||
backgroundColor: isOpened ? 'inherit' : 'transparent !important',
|
||||
border: 'none !important',
|
||||
cursor: isOpened ? 'text' : 'pointer',
|
||||
padding: isOpened ? '10px' : 0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<RiCloseFill />
|
||||
</ActionIcon>
|
||||
) : null
|
||||
}
|
||||
size="md"
|
||||
styles={{
|
||||
icon: { svg: { fill: 'var(--titlebar-fg)' } },
|
||||
input: {
|
||||
backgroundColor: isOpened ? 'inherit' : 'transparent !important',
|
||||
border: 'none !important',
|
||||
cursor: isOpened ? 'text' : 'pointer',
|
||||
padding: isOpened ? '10px' : 0,
|
||||
},
|
||||
}}
|
||||
width={isOpened ? openedWidth || 150 : initialWidth || 35}
|
||||
onChange={onChange}
|
||||
onKeyDown={handleEscape}
|
||||
/>
|
||||
);
|
||||
width={isOpened ? openedWidth || 150 : initialWidth || 35}
|
||||
onChange={onChange}
|
||||
onKeyDown={handleEscape}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,37 +6,37 @@ import styled from 'styled-components';
|
|||
type SegmentedControlProps = MantineSegmentedControlProps;
|
||||
|
||||
const StyledSegmentedControl = styled(MantineSegmentedControl)<MantineSegmentedControlProps>`
|
||||
& .mantine-SegmentedControl-label {
|
||||
color: var(--input-fg);
|
||||
font-family: var(--content-font-family);
|
||||
}
|
||||
& .mantine-SegmentedControl-label {
|
||||
color: var(--input-fg);
|
||||
font-family: var(--content-font-family);
|
||||
}
|
||||
|
||||
background-color: var(--input-bg);
|
||||
background-color: var(--input-bg);
|
||||
|
||||
& .mantine-SegmentedControl-disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
& .mantine-SegmentedControl-disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
& [data-disabled='true'] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
& [data-disabled='true'] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
& .mantine-SegmentedControl-active {
|
||||
color: var(--input-active-fg);
|
||||
background-color: var(--input-active-bg);
|
||||
}
|
||||
& .mantine-SegmentedControl-active {
|
||||
color: var(--input-active-fg);
|
||||
background-color: var(--input-active-bg);
|
||||
}
|
||||
`;
|
||||
|
||||
export const SegmentedControl = forwardRef<HTMLDivElement, SegmentedControlProps>(
|
||||
({ ...props }: SegmentedControlProps, ref) => {
|
||||
return (
|
||||
<StyledSegmentedControl
|
||||
ref={ref}
|
||||
styles={{}}
|
||||
transitionDuration={250}
|
||||
transitionTimingFunction="linear"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
({ ...props }: SegmentedControlProps, ref) => {
|
||||
return (
|
||||
<StyledSegmentedControl
|
||||
ref={ref}
|
||||
styles={{}}
|
||||
transitionDuration={250}
|
||||
transitionTimingFunction="linear"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,142 +1,142 @@
|
|||
import type {
|
||||
SelectProps as MantineSelectProps,
|
||||
MultiSelectProps as MantineMultiSelectProps,
|
||||
SelectProps as MantineSelectProps,
|
||||
MultiSelectProps as MantineMultiSelectProps,
|
||||
} from '@mantine/core';
|
||||
import { Select as MantineSelect, MultiSelect as MantineMultiSelect } from '@mantine/core';
|
||||
import styled from 'styled-components';
|
||||
|
||||
interface SelectProps extends MantineSelectProps {
|
||||
maxWidth?: number | string;
|
||||
width?: number | string;
|
||||
maxWidth?: number | string;
|
||||
width?: number | string;
|
||||
}
|
||||
|
||||
export interface MultiSelectProps extends MantineMultiSelectProps {
|
||||
maxWidth?: number | string;
|
||||
width?: number | string;
|
||||
maxWidth?: number | string;
|
||||
width?: number | string;
|
||||
}
|
||||
|
||||
const StyledSelect = styled(MantineSelect)`
|
||||
& [data-selected='true'] {
|
||||
background: var(--input-bg);
|
||||
}
|
||||
|
||||
& [data-disabled='true'] {
|
||||
background: var(--input-bg);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
& .mantine-Select-itemsWrapper {
|
||||
& .mantine-Select-item {
|
||||
padding: 40px;
|
||||
& [data-selected='true'] {
|
||||
background: var(--input-bg);
|
||||
}
|
||||
|
||||
& [data-disabled='true'] {
|
||||
background: var(--input-bg);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
& .mantine-Select-itemsWrapper {
|
||||
& .mantine-Select-item {
|
||||
padding: 40px;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const Select = ({ width, maxWidth, ...props }: SelectProps) => {
|
||||
return (
|
||||
<StyledSelect
|
||||
withinPortal
|
||||
styles={{
|
||||
dropdown: {
|
||||
background: 'var(--dropdown-menu-bg)',
|
||||
filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 20%))',
|
||||
},
|
||||
input: {
|
||||
background: 'var(--input-bg)',
|
||||
color: 'var(--input-fg)',
|
||||
},
|
||||
item: {
|
||||
'&:hover': {
|
||||
background: 'var(--dropdown-menu-bg-hover)',
|
||||
},
|
||||
'&[data-hovered]': {
|
||||
background: 'var(--dropdown-menu-bg-hover)',
|
||||
},
|
||||
'&[data-selected="true"]': {
|
||||
'&:hover': {
|
||||
background: 'var(--dropdown-menu-bg-hover)',
|
||||
},
|
||||
background: 'none',
|
||||
color: 'var(--primary-color)',
|
||||
},
|
||||
color: 'var(--dropdown-menu-fg)',
|
||||
padding: '.3rem',
|
||||
},
|
||||
}}
|
||||
sx={{ maxWidth, width }}
|
||||
transitionProps={{ duration: 100, transition: 'fade' }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<StyledSelect
|
||||
withinPortal
|
||||
styles={{
|
||||
dropdown: {
|
||||
background: 'var(--dropdown-menu-bg)',
|
||||
filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 20%))',
|
||||
},
|
||||
input: {
|
||||
background: 'var(--input-bg)',
|
||||
color: 'var(--input-fg)',
|
||||
},
|
||||
item: {
|
||||
'&:hover': {
|
||||
background: 'var(--dropdown-menu-bg-hover)',
|
||||
},
|
||||
'&[data-hovered]': {
|
||||
background: 'var(--dropdown-menu-bg-hover)',
|
||||
},
|
||||
'&[data-selected="true"]': {
|
||||
'&:hover': {
|
||||
background: 'var(--dropdown-menu-bg-hover)',
|
||||
},
|
||||
background: 'none',
|
||||
color: 'var(--primary-color)',
|
||||
},
|
||||
color: 'var(--dropdown-menu-fg)',
|
||||
padding: '.3rem',
|
||||
},
|
||||
}}
|
||||
sx={{ maxWidth, width }}
|
||||
transitionProps={{ duration: 100, transition: 'fade' }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledMultiSelect = styled(MantineMultiSelect)`
|
||||
& [data-selected='true'] {
|
||||
background: var(--input-select-bg);
|
||||
}
|
||||
|
||||
& [data-disabled='true'] {
|
||||
background: var(--input-bg);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
& .mantine-MultiSelect-itemsWrapper {
|
||||
& .mantine-Select-item {
|
||||
padding: 40px;
|
||||
& [data-selected='true'] {
|
||||
background: var(--input-select-bg);
|
||||
}
|
||||
|
||||
& [data-disabled='true'] {
|
||||
background: var(--input-bg);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
& .mantine-MultiSelect-itemsWrapper {
|
||||
& .mantine-Select-item {
|
||||
padding: 40px;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const MultiSelect = ({ width, maxWidth, ...props }: MultiSelectProps) => {
|
||||
return (
|
||||
<StyledMultiSelect
|
||||
withinPortal
|
||||
styles={{
|
||||
dropdown: {
|
||||
background: 'var(--dropdown-menu-bg)',
|
||||
filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 20%))',
|
||||
},
|
||||
input: {
|
||||
background: 'var(--input-bg)',
|
||||
color: 'var(--input-fg)',
|
||||
},
|
||||
item: {
|
||||
'&:hover': {
|
||||
background: 'var(--dropdown-menu-bg-hover)',
|
||||
},
|
||||
'&[data-hovered]': {
|
||||
background: 'var(--dropdown-menu-bg-hover)',
|
||||
},
|
||||
'&[data-selected="true"]': {
|
||||
'&:hover': {
|
||||
background: 'var(--dropdown-menu-bg-hover)',
|
||||
},
|
||||
background: 'none',
|
||||
color: 'var(--primary-color)',
|
||||
},
|
||||
color: 'var(--dropdown-menu-fg)',
|
||||
padding: '.5rem .1rem',
|
||||
},
|
||||
value: {
|
||||
margin: '.2rem',
|
||||
paddingBottom: '1rem',
|
||||
paddingLeft: '1rem',
|
||||
paddingTop: '1rem',
|
||||
},
|
||||
}}
|
||||
sx={{ maxWidth, width }}
|
||||
transitionProps={{ duration: 100, transition: 'fade' }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<StyledMultiSelect
|
||||
withinPortal
|
||||
styles={{
|
||||
dropdown: {
|
||||
background: 'var(--dropdown-menu-bg)',
|
||||
filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 20%))',
|
||||
},
|
||||
input: {
|
||||
background: 'var(--input-bg)',
|
||||
color: 'var(--input-fg)',
|
||||
},
|
||||
item: {
|
||||
'&:hover': {
|
||||
background: 'var(--dropdown-menu-bg-hover)',
|
||||
},
|
||||
'&[data-hovered]': {
|
||||
background: 'var(--dropdown-menu-bg-hover)',
|
||||
},
|
||||
'&[data-selected="true"]': {
|
||||
'&:hover': {
|
||||
background: 'var(--dropdown-menu-bg-hover)',
|
||||
},
|
||||
background: 'none',
|
||||
color: 'var(--primary-color)',
|
||||
},
|
||||
color: 'var(--dropdown-menu-fg)',
|
||||
padding: '.5rem .1rem',
|
||||
},
|
||||
value: {
|
||||
margin: '.2rem',
|
||||
paddingBottom: '1rem',
|
||||
paddingLeft: '1rem',
|
||||
paddingTop: '1rem',
|
||||
},
|
||||
}}
|
||||
sx={{ maxWidth, width }}
|
||||
transitionProps={{ duration: 100, transition: 'fade' }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Select.defaultProps = {
|
||||
maxWidth: undefined,
|
||||
width: undefined,
|
||||
maxWidth: undefined,
|
||||
width: undefined,
|
||||
};
|
||||
|
||||
MultiSelect.defaultProps = {
|
||||
maxWidth: undefined,
|
||||
width: undefined,
|
||||
maxWidth: undefined,
|
||||
width: undefined,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,16 +3,16 @@ import { Skeleton as MantineSkeleton } from '@mantine/core';
|
|||
import styled from 'styled-components';
|
||||
|
||||
const StyledSkeleton = styled(MantineSkeleton)`
|
||||
&::after {
|
||||
background: var(--placeholder-bg);
|
||||
}
|
||||
&::after {
|
||||
background: var(--placeholder-bg);
|
||||
}
|
||||
`;
|
||||
|
||||
export const Skeleton = ({ ...props }: MantineSkeletonProps) => {
|
||||
return (
|
||||
<StyledSkeleton
|
||||
animate={false}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<StyledSkeleton
|
||||
animate={false}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,30 +5,30 @@ import styled from 'styled-components';
|
|||
type SliderProps = MantineSliderProps;
|
||||
|
||||
const StyledSlider = styled(MantineSlider)`
|
||||
& .mantine-Slider-track {
|
||||
height: 0.5rem;
|
||||
background-color: var(--slider-track-bg);
|
||||
}
|
||||
& .mantine-Slider-track {
|
||||
height: 0.5rem;
|
||||
background-color: var(--slider-track-bg);
|
||||
}
|
||||
|
||||
& .mantine-Slider-bar {
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
& .mantine-Slider-bar {
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
|
||||
& .mantine-Slider-thumb {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
background: var(--slider-thumb-bg);
|
||||
border: none;
|
||||
}
|
||||
& .mantine-Slider-thumb {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
background: var(--slider-thumb-bg);
|
||||
border: none;
|
||||
}
|
||||
|
||||
& .mantine-Slider-label {
|
||||
padding: 0 1rem;
|
||||
color: var(--tooltip-fg);
|
||||
font-size: 1em;
|
||||
background: var(--tooltip-bg);
|
||||
}
|
||||
& .mantine-Slider-label {
|
||||
padding: 0 1rem;
|
||||
color: var(--tooltip-fg);
|
||||
font-size: 1em;
|
||||
background: var(--tooltip-bg);
|
||||
}
|
||||
`;
|
||||
|
||||
export const Slider = ({ ...props }: SliderProps) => {
|
||||
return <StyledSlider {...props} />;
|
||||
return <StyledSlider {...props} />;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,35 +5,35 @@ import styled from 'styled-components';
|
|||
import { rotating } from '/@/renderer/styles';
|
||||
|
||||
interface SpinnerProps extends IconType {
|
||||
color?: string;
|
||||
container?: boolean;
|
||||
size?: number;
|
||||
color?: string;
|
||||
container?: boolean;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export const SpinnerIcon = styled(RiLoader5Fill)`
|
||||
${rotating}
|
||||
animation: rotating 1s ease-in-out infinite;
|
||||
${rotating}
|
||||
animation: rotating 1s ease-in-out infinite;
|
||||
`;
|
||||
|
||||
export const Spinner = ({ ...props }: SpinnerProps) => {
|
||||
if (props.container) {
|
||||
return (
|
||||
<Center
|
||||
h="100%"
|
||||
w="100%"
|
||||
>
|
||||
<SpinnerIcon
|
||||
color={props.color}
|
||||
size={props.size}
|
||||
/>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
if (props.container) {
|
||||
return (
|
||||
<Center
|
||||
h="100%"
|
||||
w="100%"
|
||||
>
|
||||
<SpinnerIcon
|
||||
color={props.color}
|
||||
size={props.size}
|
||||
/>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return <SpinnerIcon {...props} />;
|
||||
return <SpinnerIcon {...props} />;
|
||||
};
|
||||
|
||||
Spinner.defaultProps = {
|
||||
color: undefined,
|
||||
size: 15,
|
||||
color: undefined,
|
||||
size: 15,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,24 +5,24 @@ import styled from 'styled-components';
|
|||
type SwitchProps = MantineSwitchProps;
|
||||
|
||||
const StyledSwitch = styled(MantineSwitch)`
|
||||
display: flex;
|
||||
display: flex;
|
||||
|
||||
& .mantine-Switch-track {
|
||||
background-color: var(--switch-track-bg);
|
||||
border: none;
|
||||
}
|
||||
|
||||
& .mantine-Switch-input {
|
||||
&:checked + .mantine-Switch-track {
|
||||
background-color: var(--switch-track-enabled-bg);
|
||||
& .mantine-Switch-track {
|
||||
background-color: var(--switch-track-bg);
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
& .mantine-Switch-thumb {
|
||||
background-color: var(--switch-thumb-bg);
|
||||
}
|
||||
& .mantine-Switch-input {
|
||||
&:checked + .mantine-Switch-track {
|
||||
background-color: var(--switch-track-enabled-bg);
|
||||
}
|
||||
}
|
||||
|
||||
& .mantine-Switch-thumb {
|
||||
background-color: var(--switch-thumb-bg);
|
||||
}
|
||||
`;
|
||||
|
||||
export const Switch = ({ ...props }: SwitchProps) => {
|
||||
return <StyledSwitch {...props} />;
|
||||
return <StyledSwitch {...props} />;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,57 +5,57 @@ import styled from 'styled-components';
|
|||
type TabsProps = MantineTabsProps;
|
||||
|
||||
const StyledTabs = styled(MantineTabs)`
|
||||
height: 100%;
|
||||
height: 100%;
|
||||
|
||||
& .mantine-Tabs-tabsList {
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
&.mantine-Tabs-tab {
|
||||
padding: 0.5rem 1rem;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
background-color: var(--main-bg);
|
||||
}
|
||||
|
||||
& .mantine-Tabs-panel {
|
||||
padding: 1.5rem 0.5rem;
|
||||
}
|
||||
|
||||
& .mantine-Tabs-tab {
|
||||
padding: 1rem;
|
||||
color: var(--btn-subtle-fg);
|
||||
border-radius: 0;
|
||||
|
||||
&:hover {
|
||||
color: var(--btn-subtle-fg-hover);
|
||||
background: var(--btn-subtle-bg-hover);
|
||||
& .mantine-Tabs-tabsList {
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
transition: background 0.2s ease-in-out, color 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
button[data-active] {
|
||||
color: var(--btn-subtle-fg);
|
||||
background: none;
|
||||
border-color: var(--primary-color);
|
||||
|
||||
&:hover {
|
||||
background: none;
|
||||
&.mantine-Tabs-tab {
|
||||
padding: 0.5rem 1rem;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
background-color: var(--main-bg);
|
||||
}
|
||||
|
||||
& .mantine-Tabs-panel {
|
||||
padding: 1.5rem 0.5rem;
|
||||
}
|
||||
|
||||
& .mantine-Tabs-tab {
|
||||
padding: 1rem;
|
||||
color: var(--btn-subtle-fg);
|
||||
border-radius: 0;
|
||||
|
||||
&:hover {
|
||||
color: var(--btn-subtle-fg-hover);
|
||||
background: var(--btn-subtle-bg-hover);
|
||||
}
|
||||
|
||||
transition: background 0.2s ease-in-out, color 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
button[data-active] {
|
||||
color: var(--btn-subtle-fg);
|
||||
background: none;
|
||||
border-color: var(--primary-color);
|
||||
|
||||
&:hover {
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const Tabs = ({ children, ...props }: TabsProps) => {
|
||||
return <StyledTabs {...props}>{children}</StyledTabs>;
|
||||
return <StyledTabs {...props}>{children}</StyledTabs>;
|
||||
};
|
||||
|
||||
const Panel = ({ children, ...props }: TabsPanelProps) => {
|
||||
return (
|
||||
<StyledTabs.Panel {...props}>
|
||||
<Suspense fallback={<></>}>{children}</Suspense>
|
||||
</StyledTabs.Panel>
|
||||
);
|
||||
return (
|
||||
<StyledTabs.Panel {...props}>
|
||||
<Suspense fallback={<></>}>{children}</Suspense>
|
||||
</StyledTabs.Panel>
|
||||
);
|
||||
};
|
||||
|
||||
Tabs.List = StyledTabs.List;
|
||||
|
|
|
|||
|
|
@ -7,50 +7,50 @@ import { textEllipsis } from '/@/renderer/styles';
|
|||
type MantineTextTitleDivProps = MantineTitleProps & ComponentPropsWithoutRef<'div'>;
|
||||
|
||||
interface TextTitleProps extends MantineTextTitleDivProps {
|
||||
$link?: boolean;
|
||||
$noSelect?: boolean;
|
||||
$secondary?: boolean;
|
||||
children?: ReactNode;
|
||||
overflow?: 'hidden' | 'visible';
|
||||
to?: string;
|
||||
weight?: number;
|
||||
$link?: boolean;
|
||||
$noSelect?: boolean;
|
||||
$secondary?: boolean;
|
||||
children?: ReactNode;
|
||||
overflow?: 'hidden' | 'visible';
|
||||
to?: string;
|
||||
weight?: number;
|
||||
}
|
||||
|
||||
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' && !props.lineClamp && textEllipsis}
|
||||
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' && !props.lineClamp && textEllipsis}
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.$link && 'var(--main-fg)'};
|
||||
text-decoration: ${(props) => (props.$link ? 'underline' : 'none')};
|
||||
}
|
||||
&:hover {
|
||||
color: ${(props) => props.$link && 'var(--main-fg)'};
|
||||
text-decoration: ${(props) => (props.$link ? 'underline' : 'none')};
|
||||
}
|
||||
`;
|
||||
|
||||
const _TextTitle = ({ children, $secondary, overflow, $noSelect, ...rest }: TextTitleProps) => {
|
||||
return (
|
||||
<StyledTextTitle
|
||||
$noSelect={$noSelect}
|
||||
$secondary={$secondary}
|
||||
overflow={overflow}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</StyledTextTitle>
|
||||
);
|
||||
return (
|
||||
<StyledTextTitle
|
||||
$noSelect={$noSelect}
|
||||
$secondary={$secondary}
|
||||
overflow={overflow}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</StyledTextTitle>
|
||||
);
|
||||
};
|
||||
|
||||
export const TextTitle = createPolymorphicComponent<'div', TextTitleProps>(_TextTitle);
|
||||
|
||||
_TextTitle.defaultProps = {
|
||||
$link: false,
|
||||
$noSelect: false,
|
||||
$secondary: false,
|
||||
font: undefined,
|
||||
overflow: 'visible',
|
||||
to: '',
|
||||
weight: 400,
|
||||
$link: false,
|
||||
$noSelect: false,
|
||||
$secondary: false,
|
||||
font: undefined,
|
||||
overflow: 'visible',
|
||||
to: '',
|
||||
weight: 400,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,52 +8,52 @@ import { textEllipsis } from '/@/renderer/styles';
|
|||
type MantineTextDivProps = MantineTextProps & ComponentPropsWithoutRef<'div'>;
|
||||
|
||||
interface TextProps extends MantineTextDivProps {
|
||||
$link?: boolean;
|
||||
$noSelect?: boolean;
|
||||
$secondary?: boolean;
|
||||
children?: ReactNode;
|
||||
font?: Font;
|
||||
overflow?: 'hidden' | 'visible';
|
||||
to?: string;
|
||||
weight?: number;
|
||||
$link?: boolean;
|
||||
$noSelect?: boolean;
|
||||
$secondary?: boolean;
|
||||
children?: ReactNode;
|
||||
font?: Font;
|
||||
overflow?: 'hidden' | 'visible';
|
||||
to?: string;
|
||||
weight?: number;
|
||||
}
|
||||
|
||||
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'};
|
||||
user-select: ${(props) => (props.$noSelect ? 'none' : 'auto')};
|
||||
${(props) => props.overflow === 'hidden' && !props.lineClamp && textEllipsis}
|
||||
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'};
|
||||
user-select: ${(props) => (props.$noSelect ? 'none' : 'auto')};
|
||||
${(props) => props.overflow === 'hidden' && !props.lineClamp && textEllipsis}
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.$link && 'var(--main-fg)'};
|
||||
text-decoration: ${(props) => (props.$link ? 'underline' : 'none')};
|
||||
}
|
||||
&:hover {
|
||||
color: ${(props) => props.$link && 'var(--main-fg)'};
|
||||
text-decoration: ${(props) => (props.$link ? 'underline' : 'none')};
|
||||
}
|
||||
`;
|
||||
|
||||
export const _Text = ({ children, $secondary, overflow, font, $noSelect, ...rest }: TextProps) => {
|
||||
return (
|
||||
<StyledText
|
||||
$noSelect={$noSelect}
|
||||
$secondary={$secondary}
|
||||
font={font}
|
||||
overflow={overflow}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</StyledText>
|
||||
);
|
||||
return (
|
||||
<StyledText
|
||||
$noSelect={$noSelect}
|
||||
$secondary={$secondary}
|
||||
font={font}
|
||||
overflow={overflow}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</StyledText>
|
||||
);
|
||||
};
|
||||
|
||||
export const Text = createPolymorphicComponent<'div', TextProps>(_Text);
|
||||
|
||||
_Text.defaultProps = {
|
||||
$link: false,
|
||||
$noSelect: false,
|
||||
$secondary: false,
|
||||
font: undefined,
|
||||
overflow: 'visible',
|
||||
to: '',
|
||||
weight: 400,
|
||||
$link: false,
|
||||
$noSelect: false,
|
||||
$secondary: false,
|
||||
font: undefined,
|
||||
overflow: 'visible',
|
||||
to: '',
|
||||
weight: 400,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,76 +1,76 @@
|
|||
import type { NotificationProps as MantineNotificationProps } from '@mantine/notifications';
|
||||
import {
|
||||
showNotification,
|
||||
updateNotification,
|
||||
hideNotification,
|
||||
cleanNotifications,
|
||||
cleanNotificationsQueue,
|
||||
showNotification,
|
||||
updateNotification,
|
||||
hideNotification,
|
||||
cleanNotifications,
|
||||
cleanNotificationsQueue,
|
||||
} from '@mantine/notifications';
|
||||
|
||||
interface NotificationProps extends MantineNotificationProps {
|
||||
type?: 'success' | 'error' | 'warning' | 'info';
|
||||
type?: 'success' | 'error' | 'warning' | 'info';
|
||||
}
|
||||
|
||||
const showToast = ({ type, ...props }: NotificationProps) => {
|
||||
const color =
|
||||
type === 'success'
|
||||
? 'var(--success-color)'
|
||||
: type === 'warning'
|
||||
? 'var(--warning-color)'
|
||||
: type === 'error'
|
||||
? 'var(--danger-color)'
|
||||
: 'var(--primary-color)';
|
||||
const color =
|
||||
type === 'success'
|
||||
? 'var(--success-color)'
|
||||
: type === 'warning'
|
||||
? 'var(--warning-color)'
|
||||
: type === 'error'
|
||||
? 'var(--danger-color)'
|
||||
: 'var(--primary-color)';
|
||||
|
||||
const defaultTitle =
|
||||
type === 'success'
|
||||
? 'Success'
|
||||
: type === 'warning'
|
||||
? 'Warning'
|
||||
: type === 'error'
|
||||
? 'Error'
|
||||
: 'Info';
|
||||
const defaultTitle =
|
||||
type === 'success'
|
||||
? 'Success'
|
||||
: type === 'warning'
|
||||
? 'Warning'
|
||||
: type === 'error'
|
||||
? 'Error'
|
||||
: 'Info';
|
||||
|
||||
const defaultDuration = type === 'error' ? 2000 : 1000;
|
||||
const defaultDuration = type === 'error' ? 2000 : 1000;
|
||||
|
||||
return showNotification({
|
||||
autoClose: defaultDuration,
|
||||
styles: () => ({
|
||||
closeButton: {
|
||||
'&:hover': {
|
||||
background: 'transparent',
|
||||
},
|
||||
},
|
||||
description: {
|
||||
color: 'var(--toast-description-fg)',
|
||||
fontSize: '1rem',
|
||||
},
|
||||
loader: {
|
||||
margin: '1rem',
|
||||
},
|
||||
root: {
|
||||
'&::before': { backgroundColor: color },
|
||||
background: 'var(--toast-bg)',
|
||||
border: '2px solid var(--generic-border-color)',
|
||||
bottom: '90px',
|
||||
},
|
||||
title: {
|
||||
color: 'var(--toast-title-fg)',
|
||||
fontSize: '1.3rem',
|
||||
},
|
||||
}),
|
||||
title: defaultTitle,
|
||||
...props,
|
||||
});
|
||||
return showNotification({
|
||||
autoClose: defaultDuration,
|
||||
styles: () => ({
|
||||
closeButton: {
|
||||
'&:hover': {
|
||||
background: 'transparent',
|
||||
},
|
||||
},
|
||||
description: {
|
||||
color: 'var(--toast-description-fg)',
|
||||
fontSize: '1rem',
|
||||
},
|
||||
loader: {
|
||||
margin: '1rem',
|
||||
},
|
||||
root: {
|
||||
'&::before': { backgroundColor: color },
|
||||
background: 'var(--toast-bg)',
|
||||
border: '2px solid var(--generic-border-color)',
|
||||
bottom: '90px',
|
||||
},
|
||||
title: {
|
||||
color: 'var(--toast-title-fg)',
|
||||
fontSize: '1.3rem',
|
||||
},
|
||||
}),
|
||||
title: defaultTitle,
|
||||
...props,
|
||||
});
|
||||
};
|
||||
|
||||
export const toast = {
|
||||
clean: cleanNotifications,
|
||||
cleanQueue: cleanNotificationsQueue,
|
||||
error: (props: NotificationProps) => showToast({ type: 'error', ...props }),
|
||||
hide: hideNotification,
|
||||
info: (props: NotificationProps) => showToast({ type: 'info', ...props }),
|
||||
show: showToast,
|
||||
success: (props: NotificationProps) => showToast({ type: 'success', ...props }),
|
||||
update: updateNotification,
|
||||
warn: (props: NotificationProps) => showToast({ type: 'warning', ...props }),
|
||||
clean: cleanNotifications,
|
||||
cleanQueue: cleanNotificationsQueue,
|
||||
error: (props: NotificationProps) => showToast({ type: 'error', ...props }),
|
||||
hide: hideNotification,
|
||||
info: (props: NotificationProps) => showToast({ type: 'info', ...props }),
|
||||
show: showToast,
|
||||
success: (props: NotificationProps) => showToast({ type: 'success', ...props }),
|
||||
update: updateNotification,
|
||||
warn: (props: NotificationProps) => showToast({ type: 'warning', ...props }),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,44 +3,44 @@ import { Tooltip as MantineTooltip } from '@mantine/core';
|
|||
import styled from 'styled-components';
|
||||
|
||||
const StyledTooltip = styled(MantineTooltip)`
|
||||
& .mantine-Tooltip-tooltip {
|
||||
margin: 20px;
|
||||
}
|
||||
& .mantine-Tooltip-tooltip {
|
||||
margin: 20px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Tooltip = ({ children, ...rest }: TooltipProps) => {
|
||||
return (
|
||||
<StyledTooltip
|
||||
multiline
|
||||
withinPortal
|
||||
pl={10}
|
||||
pr={10}
|
||||
py={5}
|
||||
radius="xs"
|
||||
styles={{
|
||||
tooltip: {
|
||||
background: 'var(--tooltip-bg)',
|
||||
boxShadow: '4px 4px 10px 0px rgba(0,0,0,0.2)',
|
||||
color: 'var(--tooltip-fg)',
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 550,
|
||||
maxWidth: '250px',
|
||||
},
|
||||
}}
|
||||
transitionProps={{
|
||||
duration: 250,
|
||||
transition: 'fade',
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</StyledTooltip>
|
||||
);
|
||||
return (
|
||||
<StyledTooltip
|
||||
multiline
|
||||
withinPortal
|
||||
pl={10}
|
||||
pr={10}
|
||||
py={5}
|
||||
radius="xs"
|
||||
styles={{
|
||||
tooltip: {
|
||||
background: 'var(--tooltip-bg)',
|
||||
boxShadow: '4px 4px 10px 0px rgba(0,0,0,0.2)',
|
||||
color: 'var(--tooltip-fg)',
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 550,
|
||||
maxWidth: '250px',
|
||||
},
|
||||
}}
|
||||
transitionProps={{
|
||||
duration: 250,
|
||||
transition: 'fade',
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</StyledTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
Tooltip.defaultProps = {
|
||||
openDelay: 0,
|
||||
position: 'top',
|
||||
withArrow: true,
|
||||
withinPortal: true,
|
||||
openDelay: 0,
|
||||
position: 'top',
|
||||
withArrow: true,
|
||||
withinPortal: true,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,87 +11,91 @@ import { GridCardControls } from '/@/renderer/components/virtual-grid/grid-card/
|
|||
import { CardRow, PlayQueueAddOptions, Play, CardRoute } from '/@/renderer/types';
|
||||
|
||||
interface BaseGridCardProps {
|
||||
columnIndex: number;
|
||||
controls: {
|
||||
cardRows: CardRow<Album | AlbumArtist | Artist>[];
|
||||
handleFavorite: (options: { id: string[]; isFavorite: boolean; itemType: LibraryItem }) => void;
|
||||
handlePlayQueueAdd: (options: PlayQueueAddOptions) => void;
|
||||
itemType: LibraryItem;
|
||||
playButtonBehavior: Play;
|
||||
route: CardRoute;
|
||||
};
|
||||
data: any;
|
||||
isHidden?: boolean;
|
||||
listChildProps: Omit<ListChildComponentProps, 'data' | 'style'>;
|
||||
columnIndex: number;
|
||||
controls: {
|
||||
cardRows: CardRow<Album | AlbumArtist | Artist>[];
|
||||
handleFavorite: (options: {
|
||||
id: string[];
|
||||
isFavorite: boolean;
|
||||
itemType: LibraryItem;
|
||||
}) => void;
|
||||
handlePlayQueueAdd: (options: PlayQueueAddOptions) => void;
|
||||
itemType: LibraryItem;
|
||||
playButtonBehavior: Play;
|
||||
route: CardRoute;
|
||||
};
|
||||
data: any;
|
||||
isHidden?: boolean;
|
||||
listChildProps: Omit<ListChildComponentProps, 'data' | 'style'>;
|
||||
}
|
||||
|
||||
const DefaultCardContainer = styled.div<{ $isHidden?: boolean }>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: calc(100% - 2rem);
|
||||
margin: 0.5rem;
|
||||
overflow: hidden;
|
||||
background: var(--card-default-bg);
|
||||
border-radius: var(--card-default-radius);
|
||||
cursor: pointer;
|
||||
opacity: ${({ $isHidden }) => ($isHidden ? 0 : 1)};
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: calc(100% - 2rem);
|
||||
margin: 0.5rem;
|
||||
overflow: hidden;
|
||||
background: var(--card-default-bg);
|
||||
border-radius: var(--card-default-radius);
|
||||
cursor: pointer;
|
||||
opacity: ${({ $isHidden }) => ($isHidden ? 0 : 1)};
|
||||
pointer-events: auto;
|
||||
|
||||
&:hover {
|
||||
background: var(--card-default-bg-hover);
|
||||
}
|
||||
&:hover {
|
||||
background: var(--card-default-bg-hover);
|
||||
}
|
||||
`;
|
||||
|
||||
const InnerCardContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 1rem;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 1rem;
|
||||
overflow: hidden;
|
||||
|
||||
.card-controls {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover .card-controls {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover * {
|
||||
&::before {
|
||||
opacity: 0.5;
|
||||
.card-controls {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover .card-controls {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover * {
|
||||
&::before {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const ImageContainer = styled.div<{ $isFavorite?: boolean }>`
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
aspect-ratio: 1/1;
|
||||
overflow: hidden;
|
||||
background: var(--placeholder-bg);
|
||||
border-radius: var(--card-default-radius);
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
background: linear-gradient(0deg, rgba(0, 0, 0, 100%) 35%, rgba(0, 0, 0, 0%) 100%);
|
||||
opacity: 0;
|
||||
transition: all 0.2s ease-in-out;
|
||||
content: '';
|
||||
user-select: none;
|
||||
}
|
||||
${(props) =>
|
||||
props.$isFavorite &&
|
||||
`
|
||||
aspect-ratio: 1/1;
|
||||
overflow: hidden;
|
||||
background: var(--placeholder-bg);
|
||||
border-radius: var(--card-default-radius);
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(0deg, rgba(0, 0, 0, 100%) 35%, rgba(0, 0, 0, 0%) 100%);
|
||||
opacity: 0;
|
||||
transition: all 0.2s ease-in-out;
|
||||
content: '';
|
||||
user-select: none;
|
||||
}
|
||||
${(props) =>
|
||||
props.$isFavorite &&
|
||||
`
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: -50px;
|
||||
|
|
@ -108,134 +112,134 @@ const ImageContainer = styled.div<{ $isFavorite?: boolean }>`
|
|||
`;
|
||||
|
||||
const Image = styled(SimpleImg)`
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100% !important;
|
||||
max-height: 100%;
|
||||
border: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100% !important;
|
||||
max-height: 100%;
|
||||
border: 0;
|
||||
|
||||
img {
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
img {
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
`;
|
||||
|
||||
const DetailContainer = styled.div`
|
||||
margin-top: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
`;
|
||||
|
||||
export const DefaultCard = ({
|
||||
listChildProps,
|
||||
data,
|
||||
columnIndex,
|
||||
controls,
|
||||
isHidden,
|
||||
listChildProps,
|
||||
data,
|
||||
columnIndex,
|
||||
controls,
|
||||
isHidden,
|
||||
}: BaseGridCardProps) => {
|
||||
const navigate = useNavigate();
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (data) {
|
||||
const path = generatePath(
|
||||
controls.route.route,
|
||||
controls.route.slugs?.reduce((acc, slug) => {
|
||||
return {
|
||||
...acc,
|
||||
[slug.slugProperty]: data[slug.idProperty],
|
||||
};
|
||||
}, {}),
|
||||
);
|
||||
if (data) {
|
||||
const path = generatePath(
|
||||
controls.route.route,
|
||||
controls.route.slugs?.reduce((acc, slug) => {
|
||||
return {
|
||||
...acc,
|
||||
[slug.slugProperty]: data[slug.idProperty],
|
||||
};
|
||||
}, {}),
|
||||
);
|
||||
|
||||
let Placeholder = RiAlbumFill;
|
||||
let Placeholder = RiAlbumFill;
|
||||
|
||||
switch (controls.itemType) {
|
||||
case LibraryItem.ALBUM:
|
||||
Placeholder = RiAlbumFill;
|
||||
break;
|
||||
case LibraryItem.ARTIST:
|
||||
Placeholder = RiUserVoiceFill;
|
||||
break;
|
||||
case LibraryItem.ALBUM_ARTIST:
|
||||
Placeholder = RiUserVoiceFill;
|
||||
break;
|
||||
case LibraryItem.PLAYLIST:
|
||||
Placeholder = RiPlayListFill;
|
||||
break;
|
||||
default:
|
||||
Placeholder = RiAlbumFill;
|
||||
break;
|
||||
switch (controls.itemType) {
|
||||
case LibraryItem.ALBUM:
|
||||
Placeholder = RiAlbumFill;
|
||||
break;
|
||||
case LibraryItem.ARTIST:
|
||||
Placeholder = RiUserVoiceFill;
|
||||
break;
|
||||
case LibraryItem.ALBUM_ARTIST:
|
||||
Placeholder = RiUserVoiceFill;
|
||||
break;
|
||||
case LibraryItem.PLAYLIST:
|
||||
Placeholder = RiPlayListFill;
|
||||
break;
|
||||
default:
|
||||
Placeholder = RiAlbumFill;
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<DefaultCardContainer
|
||||
key={`card-${columnIndex}-${listChildProps.index}`}
|
||||
onClick={() => navigate(path)}
|
||||
>
|
||||
<InnerCardContainer>
|
||||
<ImageContainer $isFavorite={data?.userFavorite}>
|
||||
{data?.imageUrl ? (
|
||||
<Image
|
||||
importance="auto"
|
||||
placeholder={data?.imagePlaceholderUrl || 'var(--placeholder-bg)'}
|
||||
src={data?.imageUrl}
|
||||
/>
|
||||
) : (
|
||||
<Center
|
||||
sx={{
|
||||
background: 'var(--placeholder-bg)',
|
||||
borderRadius: 'var(--card-default-radius)',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Placeholder
|
||||
color="var(--placeholder-fg)"
|
||||
size={35}
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
<GridCardControls
|
||||
handleFavorite={controls.handleFavorite}
|
||||
handlePlayQueueAdd={controls.handlePlayQueueAdd}
|
||||
itemData={data}
|
||||
itemType={controls.itemType}
|
||||
/>
|
||||
</ImageContainer>
|
||||
<DetailContainer>
|
||||
<CardRows
|
||||
data={data}
|
||||
rows={controls.cardRows}
|
||||
/>
|
||||
</DetailContainer>
|
||||
</InnerCardContainer>
|
||||
</DefaultCardContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DefaultCardContainer
|
||||
key={`card-${columnIndex}-${listChildProps.index}`}
|
||||
onClick={() => navigate(path)}
|
||||
>
|
||||
<InnerCardContainer>
|
||||
<ImageContainer $isFavorite={data?.userFavorite}>
|
||||
{data?.imageUrl ? (
|
||||
<Image
|
||||
importance="auto"
|
||||
placeholder={data?.imagePlaceholderUrl || 'var(--placeholder-bg)'}
|
||||
src={data?.imageUrl}
|
||||
/>
|
||||
) : (
|
||||
<Center
|
||||
sx={{
|
||||
background: 'var(--placeholder-bg)',
|
||||
borderRadius: 'var(--card-default-radius)',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Placeholder
|
||||
color="var(--placeholder-fg)"
|
||||
size={35}
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
<GridCardControls
|
||||
handleFavorite={controls.handleFavorite}
|
||||
handlePlayQueueAdd={controls.handlePlayQueueAdd}
|
||||
itemData={data}
|
||||
itemType={controls.itemType}
|
||||
/>
|
||||
</ImageContainer>
|
||||
<DetailContainer>
|
||||
<CardRows
|
||||
data={data}
|
||||
rows={controls.cardRows}
|
||||
/>
|
||||
</DetailContainer>
|
||||
</InnerCardContainer>
|
||||
</DefaultCardContainer>
|
||||
<DefaultCardContainer
|
||||
key={`card-${columnIndex}-${listChildProps.index}`}
|
||||
$isHidden={isHidden}
|
||||
>
|
||||
<InnerCardContainer>
|
||||
<ImageContainer>
|
||||
<Skeleton
|
||||
visible
|
||||
radius="sm"
|
||||
/>
|
||||
</ImageContainer>
|
||||
<DetailContainer>
|
||||
<Stack spacing="sm">
|
||||
{controls.cardRows.map((row, index) => (
|
||||
<Skeleton
|
||||
key={`${index}-${columnIndex}-${row.arrayProperty}`}
|
||||
visible
|
||||
height={14}
|
||||
radius="sm"
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</DetailContainer>
|
||||
</InnerCardContainer>
|
||||
</DefaultCardContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DefaultCardContainer
|
||||
key={`card-${columnIndex}-${listChildProps.index}`}
|
||||
$isHidden={isHidden}
|
||||
>
|
||||
<InnerCardContainer>
|
||||
<ImageContainer>
|
||||
<Skeleton
|
||||
visible
|
||||
radius="sm"
|
||||
/>
|
||||
</ImageContainer>
|
||||
<DetailContainer>
|
||||
<Stack spacing="sm">
|
||||
{controls.cardRows.map((row, index) => (
|
||||
<Skeleton
|
||||
key={`${index}-${columnIndex}-${row.arrayProperty}`}
|
||||
visible
|
||||
height={14}
|
||||
radius="sm"
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</DetailContainer>
|
||||
</InnerCardContainer>
|
||||
</DefaultCardContainer>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,197 +9,197 @@ import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
|||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
import { useHandleGeneralContextMenu } from '/@/renderer/features/context-menu/hooks/use-handle-context-menu';
|
||||
import {
|
||||
ALBUM_CONTEXT_MENU_ITEMS,
|
||||
ARTIST_CONTEXT_MENU_ITEMS,
|
||||
ALBUM_CONTEXT_MENU_ITEMS,
|
||||
ARTIST_CONTEXT_MENU_ITEMS,
|
||||
} from '/@/renderer/features/context-menu/context-menu-items';
|
||||
|
||||
type PlayButtonType = UnstyledButtonProps & React.ComponentPropsWithoutRef<'button'>;
|
||||
|
||||
const PlayButton = styled.button<PlayButtonType>`
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
background-color: rgb(255, 255, 255);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
transition: scale 0.1s ease-in-out;
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
background-color: rgb(255, 255, 255);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
transition: scale 0.1s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
scale: 1.1;
|
||||
}
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
scale: 1.1;
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 1;
|
||||
scale: 1;
|
||||
}
|
||||
&:active {
|
||||
opacity: 1;
|
||||
scale: 1;
|
||||
}
|
||||
|
||||
svg {
|
||||
fill: rgb(0, 0, 0);
|
||||
stroke: rgb(0, 0, 0);
|
||||
}
|
||||
svg {
|
||||
fill: rgb(0, 0, 0);
|
||||
stroke: rgb(0, 0, 0);
|
||||
}
|
||||
`;
|
||||
|
||||
const SecondaryButton = styled(_Button)`
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
transition: scale 0.2s linear;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
transition: scale 0.2s linear;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
scale: 1.1;
|
||||
}
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
scale: 1.1;
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 1;
|
||||
scale: 1;
|
||||
}
|
||||
&:active {
|
||||
opacity: 1;
|
||||
scale: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const GridCardControlsContainer = styled.div<{ $isFavorite?: boolean }>`
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const FavoriteBanner = styled.div`
|
||||
position: absolute;
|
||||
top: -50px;
|
||||
left: -50px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background-color: var(--primary-color);
|
||||
box-shadow: 0 0 10px 8px rgba(0, 0, 0, 80%);
|
||||
transform: rotate(-45deg);
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: -50px;
|
||||
left: -50px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background-color: var(--primary-color);
|
||||
box-shadow: 0 0 10px 8px rgba(0, 0, 0, 80%);
|
||||
transform: rotate(-45deg);
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
`;
|
||||
|
||||
const ControlsRow = styled.div`
|
||||
width: 100%;
|
||||
height: calc(100% / 3);
|
||||
width: 100%;
|
||||
height: calc(100% / 3);
|
||||
`;
|
||||
|
||||
const BottomControls = styled(ControlsRow)`
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
padding: 1rem 0.5rem;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
padding: 1rem 0.5rem;
|
||||
`;
|
||||
|
||||
const FavoriteWrapper = styled.span<{ isFavorite: boolean }>`
|
||||
svg {
|
||||
fill: ${(props) => props.isFavorite && 'var(--primary-color)'};
|
||||
}
|
||||
svg {
|
||||
fill: ${(props) => props.isFavorite && 'var(--primary-color)'};
|
||||
}
|
||||
`;
|
||||
|
||||
export const GridCardControls = ({
|
||||
itemData,
|
||||
itemType,
|
||||
handlePlayQueueAdd,
|
||||
handleFavorite,
|
||||
}: {
|
||||
handleFavorite: (options: {
|
||||
id: string[];
|
||||
isFavorite: boolean;
|
||||
itemType: LibraryItem;
|
||||
serverId: string;
|
||||
}) => void;
|
||||
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
|
||||
itemData: any;
|
||||
itemType: LibraryItem;
|
||||
}) => {
|
||||
const [isFavorite, setIsFavorite] = useState(itemData?.userFavorite);
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
const handlePlay = async (e: MouseEvent<HTMLButtonElement>, playType?: Play) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: [itemData.id],
|
||||
type: itemType,
|
||||
},
|
||||
playType: playType || playButtonBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
const handleFavorites = async (e: MouseEvent<HTMLButtonElement>, serverId: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
handleFavorite?.({
|
||||
id: [itemData.id],
|
||||
isFavorite: itemData.userFavorite,
|
||||
itemType,
|
||||
serverId,
|
||||
});
|
||||
|
||||
setIsFavorite(!isFavorite);
|
||||
};
|
||||
|
||||
const handleContextMenu = useHandleGeneralContextMenu(
|
||||
itemData,
|
||||
itemType,
|
||||
itemType === LibraryItem.ALBUM ? ALBUM_CONTEXT_MENU_ITEMS : ARTIST_CONTEXT_MENU_ITEMS,
|
||||
);
|
||||
handlePlayQueueAdd,
|
||||
handleFavorite,
|
||||
}: {
|
||||
handleFavorite: (options: {
|
||||
id: string[];
|
||||
isFavorite: boolean;
|
||||
itemType: LibraryItem;
|
||||
serverId: string;
|
||||
}) => void;
|
||||
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
|
||||
itemData: any;
|
||||
itemType: LibraryItem;
|
||||
}) => {
|
||||
const [isFavorite, setIsFavorite] = useState(itemData?.userFavorite);
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
return (
|
||||
<>
|
||||
{isFavorite ? <FavoriteBanner /> : null}
|
||||
<GridCardControlsContainer
|
||||
$isFavorite
|
||||
className="card-controls"
|
||||
>
|
||||
<PlayButton onClick={handlePlay}>
|
||||
<RiPlayFill size={25} />
|
||||
</PlayButton>
|
||||
<BottomControls>
|
||||
<SecondaryButton
|
||||
p={5}
|
||||
variant="subtle"
|
||||
onClick={(e) => handleFavorites(e, itemData?.serverId)}
|
||||
>
|
||||
<FavoriteWrapper isFavorite={itemData?.isFavorite}>
|
||||
{isFavorite ? (
|
||||
<RiHeartFill size={20} />
|
||||
) : (
|
||||
<RiHeartLine
|
||||
color="white"
|
||||
size={20}
|
||||
/>
|
||||
)}
|
||||
</FavoriteWrapper>
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
p={5}
|
||||
variant="subtle"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleContextMenu(e, [itemData]);
|
||||
}}
|
||||
>
|
||||
<RiMoreFill
|
||||
color="white"
|
||||
size={20}
|
||||
/>
|
||||
</SecondaryButton>
|
||||
</BottomControls>
|
||||
</GridCardControlsContainer>
|
||||
</>
|
||||
);
|
||||
const handlePlay = async (e: MouseEvent<HTMLButtonElement>, playType?: Play) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: [itemData.id],
|
||||
type: itemType,
|
||||
},
|
||||
playType: playType || playButtonBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
const handleFavorites = async (e: MouseEvent<HTMLButtonElement>, serverId: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
handleFavorite?.({
|
||||
id: [itemData.id],
|
||||
isFavorite: itemData.userFavorite,
|
||||
itemType,
|
||||
serverId,
|
||||
});
|
||||
|
||||
setIsFavorite(!isFavorite);
|
||||
};
|
||||
|
||||
const handleContextMenu = useHandleGeneralContextMenu(
|
||||
itemType,
|
||||
itemType === LibraryItem.ALBUM ? ALBUM_CONTEXT_MENU_ITEMS : ARTIST_CONTEXT_MENU_ITEMS,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isFavorite ? <FavoriteBanner /> : null}
|
||||
<GridCardControlsContainer
|
||||
$isFavorite
|
||||
className="card-controls"
|
||||
>
|
||||
<PlayButton onClick={handlePlay}>
|
||||
<RiPlayFill size={25} />
|
||||
</PlayButton>
|
||||
<BottomControls>
|
||||
<SecondaryButton
|
||||
p={5}
|
||||
variant="subtle"
|
||||
onClick={(e) => handleFavorites(e, itemData?.serverId)}
|
||||
>
|
||||
<FavoriteWrapper isFavorite={itemData?.isFavorite}>
|
||||
{isFavorite ? (
|
||||
<RiHeartFill size={20} />
|
||||
) : (
|
||||
<RiHeartLine
|
||||
color="white"
|
||||
size={20}
|
||||
/>
|
||||
)}
|
||||
</FavoriteWrapper>
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
p={5}
|
||||
variant="subtle"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleContextMenu(e, [itemData]);
|
||||
}}
|
||||
>
|
||||
<RiMoreFill
|
||||
color="white"
|
||||
size={20}
|
||||
/>
|
||||
</SecondaryButton>
|
||||
</BottomControls>
|
||||
</GridCardControlsContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,58 +6,58 @@ import { PosterCard } from '/@/renderer/components/virtual-grid/grid-card/poster
|
|||
import { GridCardData, ListDisplayType } from '/@/renderer/types';
|
||||
|
||||
export const GridCard = memo(({ data, index, style }: ListChildComponentProps) => {
|
||||
const {
|
||||
columnCount,
|
||||
itemCount,
|
||||
cardRows,
|
||||
itemData,
|
||||
itemType,
|
||||
playButtonBehavior,
|
||||
handlePlayQueueAdd,
|
||||
handleFavorite,
|
||||
route,
|
||||
display,
|
||||
} = data as GridCardData;
|
||||
const {
|
||||
columnCount,
|
||||
itemCount,
|
||||
cardRows,
|
||||
itemData,
|
||||
itemType,
|
||||
playButtonBehavior,
|
||||
handlePlayQueueAdd,
|
||||
handleFavorite,
|
||||
route,
|
||||
display,
|
||||
} = data as GridCardData;
|
||||
|
||||
const cards = [];
|
||||
const startIndex = index * columnCount;
|
||||
const stopIndex = Math.min(itemCount - 1, startIndex + columnCount - 1);
|
||||
const cards = [];
|
||||
const startIndex = index * columnCount;
|
||||
const stopIndex = Math.min(itemCount - 1, startIndex + columnCount - 1);
|
||||
|
||||
const columnCountInRow = stopIndex - startIndex + 1;
|
||||
let columnCountToAdd = 0;
|
||||
if (columnCountInRow !== columnCount) {
|
||||
columnCountToAdd = columnCount - columnCountInRow;
|
||||
}
|
||||
const View = display === ListDisplayType.CARD ? DefaultCard : PosterCard;
|
||||
const columnCountInRow = stopIndex - startIndex + 1;
|
||||
let columnCountToAdd = 0;
|
||||
if (columnCountInRow !== columnCount) {
|
||||
columnCountToAdd = columnCount - columnCountInRow;
|
||||
}
|
||||
const View = display === ListDisplayType.CARD ? DefaultCard : PosterCard;
|
||||
|
||||
for (let i = startIndex; i <= stopIndex + columnCountToAdd; i += 1) {
|
||||
cards.push(
|
||||
<View
|
||||
key={`card-${i}-${index}`}
|
||||
columnIndex={i}
|
||||
controls={{
|
||||
cardRows,
|
||||
handleFavorite,
|
||||
handlePlayQueueAdd,
|
||||
itemType,
|
||||
playButtonBehavior,
|
||||
route,
|
||||
}}
|
||||
data={itemData[i]}
|
||||
isHidden={i > stopIndex}
|
||||
listChildProps={{ index }}
|
||||
/>,
|
||||
for (let i = startIndex; i <= stopIndex + columnCountToAdd; i += 1) {
|
||||
cards.push(
|
||||
<View
|
||||
key={`card-${i}-${index}`}
|
||||
columnIndex={i}
|
||||
controls={{
|
||||
cardRows,
|
||||
handleFavorite,
|
||||
handlePlayQueueAdd,
|
||||
itemType,
|
||||
playButtonBehavior,
|
||||
route,
|
||||
}}
|
||||
data={itemData[i]}
|
||||
isHidden={i > stopIndex}
|
||||
listChildProps={{ index }}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...style,
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
{cards}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...style,
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
{cards}
|
||||
</div>
|
||||
);
|
||||
}, areEqual);
|
||||
|
|
|
|||
|
|
@ -11,65 +11,69 @@ import { GridCardControls } from '/@/renderer/components/virtual-grid/grid-card/
|
|||
import { CardRow, PlayQueueAddOptions, Play, CardRoute } from '/@/renderer/types';
|
||||
|
||||
interface BaseGridCardProps {
|
||||
columnIndex: number;
|
||||
controls: {
|
||||
cardRows: CardRow<Album | AlbumArtist | Artist>[];
|
||||
handleFavorite: (options: { id: string[]; isFavorite: boolean; itemType: LibraryItem }) => void;
|
||||
handlePlayQueueAdd: (options: PlayQueueAddOptions) => void;
|
||||
itemType: LibraryItem;
|
||||
playButtonBehavior: Play;
|
||||
route: CardRoute;
|
||||
};
|
||||
data: any;
|
||||
isHidden?: boolean;
|
||||
listChildProps: Omit<ListChildComponentProps, 'data' | 'style'>;
|
||||
columnIndex: number;
|
||||
controls: {
|
||||
cardRows: CardRow<Album | AlbumArtist | Artist>[];
|
||||
handleFavorite: (options: {
|
||||
id: string[];
|
||||
isFavorite: boolean;
|
||||
itemType: LibraryItem;
|
||||
}) => void;
|
||||
handlePlayQueueAdd: (options: PlayQueueAddOptions) => void;
|
||||
itemType: LibraryItem;
|
||||
playButtonBehavior: Play;
|
||||
route: CardRoute;
|
||||
};
|
||||
data: any;
|
||||
isHidden?: boolean;
|
||||
listChildProps: Omit<ListChildComponentProps, 'data' | 'style'>;
|
||||
}
|
||||
|
||||
const PosterCardContainer = styled.div<{ $isHidden?: boolean }>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 1rem;
|
||||
overflow: hidden;
|
||||
opacity: ${({ $isHidden }) => ($isHidden ? 0 : 1)};
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 1rem;
|
||||
overflow: hidden;
|
||||
opacity: ${({ $isHidden }) => ($isHidden ? 0 : 1)};
|
||||
pointer-events: auto;
|
||||
|
||||
.card-controls {
|
||||
opacity: 0;
|
||||
}
|
||||
.card-controls {
|
||||
opacity: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const LinkContainer = styled.div`
|
||||
cursor: pointer;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const ImageContainer = styled.div<{ $isFavorite?: boolean }>`
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
aspect-ratio: 1/1;
|
||||
overflow: hidden;
|
||||
background: var(--card-default-bg);
|
||||
border-radius: var(--card-poster-radius);
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
aspect-ratio: 1/1;
|
||||
overflow: hidden;
|
||||
background: var(--card-default-bg);
|
||||
border-radius: var(--card-poster-radius);
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(0deg, rgba(0, 0, 0, 100%) 35%, rgba(0, 0, 0, 0%) 100%);
|
||||
opacity: 0;
|
||||
transition: all 0.2s ease-in-out;
|
||||
content: '';
|
||||
user-select: none;
|
||||
}
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(0deg, rgba(0, 0, 0, 100%) 35%, rgba(0, 0, 0, 0%) 100%);
|
||||
opacity: 0;
|
||||
transition: all 0.2s ease-in-out;
|
||||
content: '';
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.$isFavorite &&
|
||||
`
|
||||
${(props) =>
|
||||
props.$isFavorite &&
|
||||
`
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: -50px;
|
||||
|
|
@ -84,140 +88,140 @@ const ImageContainer = styled.div<{ $isFavorite?: boolean }>`
|
|||
}
|
||||
`}
|
||||
|
||||
&:hover {
|
||||
&::before {
|
||||
opacity: 0.5;
|
||||
&:hover {
|
||||
&::before {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .card-controls {
|
||||
opacity: 1;
|
||||
}
|
||||
&:hover .card-controls {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const Image = styled(SimpleImg)`
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100% !important;
|
||||
max-height: 100%;
|
||||
border: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100% !important;
|
||||
max-height: 100%;
|
||||
border: 0;
|
||||
|
||||
img {
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
img {
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
`;
|
||||
|
||||
const DetailContainer = styled.div`
|
||||
margin-top: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
`;
|
||||
|
||||
export const PosterCard = ({
|
||||
listChildProps,
|
||||
data,
|
||||
columnIndex,
|
||||
controls,
|
||||
isHidden,
|
||||
listChildProps,
|
||||
data,
|
||||
columnIndex,
|
||||
controls,
|
||||
isHidden,
|
||||
}: BaseGridCardProps) => {
|
||||
const navigate = useNavigate();
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (data) {
|
||||
const path = generatePath(
|
||||
controls.route.route,
|
||||
controls.route.slugs?.reduce((acc, slug) => {
|
||||
return {
|
||||
...acc,
|
||||
[slug.slugProperty]: data[slug.idProperty],
|
||||
};
|
||||
}, {}),
|
||||
);
|
||||
if (data) {
|
||||
const path = generatePath(
|
||||
controls.route.route,
|
||||
controls.route.slugs?.reduce((acc, slug) => {
|
||||
return {
|
||||
...acc,
|
||||
[slug.slugProperty]: data[slug.idProperty],
|
||||
};
|
||||
}, {}),
|
||||
);
|
||||
|
||||
let Placeholder = RiAlbumFill;
|
||||
let Placeholder = RiAlbumFill;
|
||||
|
||||
switch (controls.itemType) {
|
||||
case LibraryItem.ALBUM:
|
||||
Placeholder = RiAlbumFill;
|
||||
break;
|
||||
case LibraryItem.ARTIST:
|
||||
Placeholder = RiUserVoiceFill;
|
||||
break;
|
||||
case LibraryItem.ALBUM_ARTIST:
|
||||
Placeholder = RiUserVoiceFill;
|
||||
break;
|
||||
case LibraryItem.PLAYLIST:
|
||||
Placeholder = RiPlayListFill;
|
||||
break;
|
||||
default:
|
||||
Placeholder = RiAlbumFill;
|
||||
break;
|
||||
switch (controls.itemType) {
|
||||
case LibraryItem.ALBUM:
|
||||
Placeholder = RiAlbumFill;
|
||||
break;
|
||||
case LibraryItem.ARTIST:
|
||||
Placeholder = RiUserVoiceFill;
|
||||
break;
|
||||
case LibraryItem.ALBUM_ARTIST:
|
||||
Placeholder = RiUserVoiceFill;
|
||||
break;
|
||||
case LibraryItem.PLAYLIST:
|
||||
Placeholder = RiPlayListFill;
|
||||
break;
|
||||
default:
|
||||
Placeholder = RiAlbumFill;
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<PosterCardContainer key={`card-${columnIndex}-${listChildProps.index}`}>
|
||||
<LinkContainer onClick={() => navigate(path)}>
|
||||
<ImageContainer $isFavorite={data?.userFavorite}>
|
||||
{data?.imageUrl ? (
|
||||
<Image
|
||||
importance="auto"
|
||||
placeholder={data?.imagePlaceholderUrl || 'var(--card-default-bg)'}
|
||||
src={data?.imageUrl}
|
||||
/>
|
||||
) : (
|
||||
<Center
|
||||
sx={{
|
||||
background: 'var(--placeholder-bg)',
|
||||
borderRadius: 'var(--card-default-radius)',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Placeholder
|
||||
color="var(--placeholder-fg)"
|
||||
size={35}
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
<GridCardControls
|
||||
handleFavorite={controls.handleFavorite}
|
||||
handlePlayQueueAdd={controls.handlePlayQueueAdd}
|
||||
itemData={data}
|
||||
itemType={controls.itemType}
|
||||
/>
|
||||
</ImageContainer>
|
||||
</LinkContainer>
|
||||
<DetailContainer>
|
||||
<CardRows
|
||||
data={data}
|
||||
rows={controls.cardRows}
|
||||
/>
|
||||
</DetailContainer>
|
||||
</PosterCardContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PosterCardContainer key={`card-${columnIndex}-${listChildProps.index}`}>
|
||||
<LinkContainer onClick={() => navigate(path)}>
|
||||
<ImageContainer $isFavorite={data?.userFavorite}>
|
||||
{data?.imageUrl ? (
|
||||
<Image
|
||||
importance="auto"
|
||||
placeholder={data?.imagePlaceholderUrl || 'var(--card-default-bg)'}
|
||||
src={data?.imageUrl}
|
||||
/>
|
||||
) : (
|
||||
<Center
|
||||
sx={{
|
||||
background: 'var(--placeholder-bg)',
|
||||
borderRadius: 'var(--card-default-radius)',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Placeholder
|
||||
color="var(--placeholder-fg)"
|
||||
size={35}
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
<GridCardControls
|
||||
handleFavorite={controls.handleFavorite}
|
||||
handlePlayQueueAdd={controls.handlePlayQueueAdd}
|
||||
itemData={data}
|
||||
itemType={controls.itemType}
|
||||
/>
|
||||
</ImageContainer>
|
||||
</LinkContainer>
|
||||
<DetailContainer>
|
||||
<CardRows
|
||||
data={data}
|
||||
rows={controls.cardRows}
|
||||
/>
|
||||
</DetailContainer>
|
||||
</PosterCardContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PosterCardContainer
|
||||
key={`card-${columnIndex}-${listChildProps.index}`}
|
||||
$isHidden={isHidden}
|
||||
>
|
||||
<Skeleton
|
||||
visible
|
||||
radius="sm"
|
||||
>
|
||||
<ImageContainer />
|
||||
</Skeleton>
|
||||
<DetailContainer>
|
||||
<Stack spacing="sm">
|
||||
{controls.cardRows.map((row, index) => (
|
||||
<PosterCardContainer
|
||||
key={`card-${columnIndex}-${listChildProps.index}`}
|
||||
$isHidden={isHidden}
|
||||
>
|
||||
<Skeleton
|
||||
key={`${index}-${columnIndex}-${row.arrayProperty}`}
|
||||
visible
|
||||
height={14}
|
||||
radius="sm"
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</DetailContainer>
|
||||
</PosterCardContainer>
|
||||
);
|
||||
visible
|
||||
radius="sm"
|
||||
>
|
||||
<ImageContainer />
|
||||
</Skeleton>
|
||||
<DetailContainer>
|
||||
<Stack spacing="sm">
|
||||
{controls.cardRows.map((row, index) => (
|
||||
<Skeleton
|
||||
key={`${index}-${columnIndex}-${row.arrayProperty}`}
|
||||
visible
|
||||
height={14}
|
||||
radius="sm"
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</DetailContainer>
|
||||
</PosterCardContainer>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,119 +9,123 @@ import type { CardRow, ListDisplayType, CardRoute, PlayQueueAddOptions } from '/
|
|||
import { Album, AlbumArtist, Artist, LibraryItem } from '/@/renderer/api/types';
|
||||
|
||||
const createItemData = memoize(
|
||||
(
|
||||
cardRows,
|
||||
columnCount,
|
||||
display,
|
||||
itemCount,
|
||||
itemData,
|
||||
itemGap,
|
||||
itemHeight,
|
||||
itemType,
|
||||
itemWidth,
|
||||
route,
|
||||
handlePlayQueueAdd,
|
||||
handleFavorite,
|
||||
) => ({
|
||||
cardRows,
|
||||
columnCount,
|
||||
display,
|
||||
handleFavorite,
|
||||
handlePlayQueueAdd,
|
||||
itemCount,
|
||||
itemData,
|
||||
itemGap,
|
||||
itemHeight,
|
||||
itemType,
|
||||
itemWidth,
|
||||
route,
|
||||
}),
|
||||
(
|
||||
cardRows,
|
||||
columnCount,
|
||||
display,
|
||||
itemCount,
|
||||
itemData,
|
||||
itemGap,
|
||||
itemHeight,
|
||||
itemType,
|
||||
itemWidth,
|
||||
route,
|
||||
handlePlayQueueAdd,
|
||||
handleFavorite,
|
||||
) => ({
|
||||
cardRows,
|
||||
columnCount,
|
||||
display,
|
||||
handleFavorite,
|
||||
handlePlayQueueAdd,
|
||||
itemCount,
|
||||
itemData,
|
||||
itemGap,
|
||||
itemHeight,
|
||||
itemType,
|
||||
itemWidth,
|
||||
route,
|
||||
}),
|
||||
);
|
||||
|
||||
const createScrollHandler = memoize((onScroll) => debounce(onScroll, 250));
|
||||
|
||||
export const VirtualGridWrapper = ({
|
||||
refInstance,
|
||||
cardRows,
|
||||
itemGap,
|
||||
itemType,
|
||||
itemWidth,
|
||||
display,
|
||||
itemHeight,
|
||||
itemCount,
|
||||
columnCount,
|
||||
rowCount,
|
||||
initialScrollOffset,
|
||||
handleFavorite,
|
||||
handlePlayQueueAdd,
|
||||
itemData,
|
||||
route,
|
||||
onScroll,
|
||||
height,
|
||||
width,
|
||||
...rest
|
||||
}: Omit<FixedSizeListProps, 'ref' | 'itemSize' | 'children' | 'height' | 'width'> & {
|
||||
cardRows: CardRow<Album | AlbumArtist | Artist>[];
|
||||
columnCount: number;
|
||||
display: ListDisplayType;
|
||||
handleFavorite?: (options: { id: string[]; isFavorite: boolean; itemType: LibraryItem }) => void;
|
||||
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
|
||||
height?: number;
|
||||
itemData: any[];
|
||||
itemGap: number;
|
||||
itemHeight: number;
|
||||
itemType: LibraryItem;
|
||||
itemWidth: number;
|
||||
refInstance: Ref<any>;
|
||||
route?: CardRoute;
|
||||
rowCount: number;
|
||||
width?: number;
|
||||
}) => {
|
||||
const memoizedItemData = createItemData(
|
||||
refInstance,
|
||||
cardRows,
|
||||
columnCount,
|
||||
display,
|
||||
itemCount,
|
||||
itemData,
|
||||
itemGap,
|
||||
itemHeight,
|
||||
itemType,
|
||||
itemWidth,
|
||||
route,
|
||||
handlePlayQueueAdd,
|
||||
display,
|
||||
itemHeight,
|
||||
itemCount,
|
||||
columnCount,
|
||||
rowCount,
|
||||
initialScrollOffset,
|
||||
handleFavorite,
|
||||
);
|
||||
handlePlayQueueAdd,
|
||||
itemData,
|
||||
route,
|
||||
onScroll,
|
||||
height,
|
||||
width,
|
||||
...rest
|
||||
}: Omit<FixedSizeListProps, 'ref' | 'itemSize' | 'children' | 'height' | 'width'> & {
|
||||
cardRows: CardRow<Album | AlbumArtist | Artist>[];
|
||||
columnCount: number;
|
||||
display: ListDisplayType;
|
||||
handleFavorite?: (options: {
|
||||
id: string[];
|
||||
isFavorite: boolean;
|
||||
itemType: LibraryItem;
|
||||
}) => void;
|
||||
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
|
||||
height?: number;
|
||||
itemData: any[];
|
||||
itemGap: number;
|
||||
itemHeight: number;
|
||||
itemType: LibraryItem;
|
||||
itemWidth: number;
|
||||
refInstance: Ref<any>;
|
||||
route?: CardRoute;
|
||||
rowCount: number;
|
||||
width?: number;
|
||||
}) => {
|
||||
const memoizedItemData = createItemData(
|
||||
cardRows,
|
||||
columnCount,
|
||||
display,
|
||||
itemCount,
|
||||
itemData,
|
||||
itemGap,
|
||||
itemHeight,
|
||||
itemType,
|
||||
itemWidth,
|
||||
route,
|
||||
handlePlayQueueAdd,
|
||||
handleFavorite,
|
||||
);
|
||||
|
||||
const memoizedOnScroll = createScrollHandler(onScroll);
|
||||
const memoizedOnScroll = createScrollHandler(onScroll);
|
||||
|
||||
return (
|
||||
<FixedSizeList
|
||||
ref={refInstance}
|
||||
{...rest}
|
||||
height={(height && Number(height)) || 0}
|
||||
initialScrollOffset={initialScrollOffset}
|
||||
itemCount={rowCount}
|
||||
itemData={memoizedItemData}
|
||||
itemSize={itemHeight}
|
||||
overscanCount={5}
|
||||
width={(width && Number(width)) || 0}
|
||||
onScroll={memoizedOnScroll}
|
||||
>
|
||||
{GridCard}
|
||||
</FixedSizeList>
|
||||
);
|
||||
return (
|
||||
<FixedSizeList
|
||||
ref={refInstance}
|
||||
{...rest}
|
||||
height={(height && Number(height)) || 0}
|
||||
initialScrollOffset={initialScrollOffset}
|
||||
itemCount={rowCount}
|
||||
itemData={memoizedItemData}
|
||||
itemSize={itemHeight}
|
||||
overscanCount={5}
|
||||
width={(width && Number(width)) || 0}
|
||||
onScroll={memoizedOnScroll}
|
||||
>
|
||||
{GridCard}
|
||||
</FixedSizeList>
|
||||
);
|
||||
};
|
||||
|
||||
VirtualGridWrapper.defaultProps = {
|
||||
route: undefined,
|
||||
route: undefined,
|
||||
};
|
||||
|
||||
export const VirtualGridContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
export const VirtualGridAutoSizerContainer = styled.div`
|
||||
flex: 1;
|
||||
flex: 1;
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import {
|
||||
useState,
|
||||
useRef,
|
||||
useMemo,
|
||||
useCallback,
|
||||
forwardRef,
|
||||
Ref,
|
||||
useImperativeHandle,
|
||||
useState,
|
||||
useRef,
|
||||
useMemo,
|
||||
useCallback,
|
||||
forwardRef,
|
||||
Ref,
|
||||
useImperativeHandle,
|
||||
} from 'react';
|
||||
import debounce from 'lodash/debounce';
|
||||
import type { FixedSizeListProps } from 'react-window';
|
||||
|
|
@ -16,167 +16,171 @@ import { ListDisplayType } from '/@/renderer/types';
|
|||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
|
||||
export type VirtualInfiniteGridRef = {
|
||||
resetLoadMoreItemsCache: () => void;
|
||||
scrollTo: (index: number) => void;
|
||||
setItemData: (data: any[]) => void;
|
||||
resetLoadMoreItemsCache: () => void;
|
||||
scrollTo: (index: number) => void;
|
||||
setItemData: (data: any[]) => void;
|
||||
};
|
||||
|
||||
interface VirtualGridProps
|
||||
extends Omit<FixedSizeListProps, 'children' | 'itemSize' | 'height' | 'width'> {
|
||||
cardRows: CardRow<any>[];
|
||||
display?: ListDisplayType;
|
||||
fetchFn: (options: { columnCount: number; skip: number; take: number }) => Promise<any>;
|
||||
handleFavorite?: (options: { id: string[]; isFavorite: boolean; itemType: LibraryItem }) => void;
|
||||
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
|
||||
height?: number;
|
||||
itemGap: number;
|
||||
itemSize: number;
|
||||
itemType: LibraryItem;
|
||||
loading?: boolean;
|
||||
minimumBatchSize?: number;
|
||||
route?: CardRoute;
|
||||
width?: number;
|
||||
extends Omit<FixedSizeListProps, 'children' | 'itemSize' | 'height' | 'width'> {
|
||||
cardRows: CardRow<any>[];
|
||||
display?: ListDisplayType;
|
||||
fetchFn: (options: { columnCount: number; skip: number; take: number }) => Promise<any>;
|
||||
handleFavorite?: (options: {
|
||||
id: string[];
|
||||
isFavorite: boolean;
|
||||
itemType: LibraryItem;
|
||||
}) => void;
|
||||
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
|
||||
height?: number;
|
||||
itemGap: number;
|
||||
itemSize: number;
|
||||
itemType: LibraryItem;
|
||||
loading?: boolean;
|
||||
minimumBatchSize?: number;
|
||||
route?: CardRoute;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export const VirtualInfiniteGrid = forwardRef(
|
||||
(
|
||||
{
|
||||
itemCount,
|
||||
itemGap,
|
||||
itemSize,
|
||||
itemType,
|
||||
cardRows,
|
||||
route,
|
||||
onScroll,
|
||||
display,
|
||||
handlePlayQueueAdd,
|
||||
minimumBatchSize,
|
||||
fetchFn,
|
||||
loading,
|
||||
initialScrollOffset,
|
||||
handleFavorite,
|
||||
height,
|
||||
width,
|
||||
}: VirtualGridProps,
|
||||
ref: Ref<VirtualInfiniteGridRef>,
|
||||
) => {
|
||||
const listRef = useRef<any>(null);
|
||||
const loader = useRef<InfiniteLoader>(null);
|
||||
(
|
||||
{
|
||||
itemCount,
|
||||
itemGap,
|
||||
itemSize,
|
||||
itemType,
|
||||
cardRows,
|
||||
route,
|
||||
onScroll,
|
||||
display,
|
||||
handlePlayQueueAdd,
|
||||
minimumBatchSize,
|
||||
fetchFn,
|
||||
loading,
|
||||
initialScrollOffset,
|
||||
handleFavorite,
|
||||
height,
|
||||
width,
|
||||
}: VirtualGridProps,
|
||||
ref: Ref<VirtualInfiniteGridRef>,
|
||||
) => {
|
||||
const listRef = useRef<any>(null);
|
||||
const loader = useRef<InfiniteLoader>(null);
|
||||
|
||||
const [itemData, setItemData] = useState<any[]>([]);
|
||||
const [itemData, setItemData] = useState<any[]>([]);
|
||||
|
||||
const { itemHeight, rowCount, columnCount } = useMemo(() => {
|
||||
const itemsPerRow = itemSize;
|
||||
const widthPerItem = Number(width) / itemsPerRow;
|
||||
const itemHeight = widthPerItem + cardRows.length * 26;
|
||||
const { itemHeight, rowCount, columnCount } = useMemo(() => {
|
||||
const itemsPerRow = itemSize;
|
||||
const widthPerItem = Number(width) / itemsPerRow;
|
||||
const itemHeight = widthPerItem + cardRows.length * 26;
|
||||
|
||||
return {
|
||||
columnCount: itemsPerRow,
|
||||
itemHeight,
|
||||
rowCount: Math.ceil(itemCount / itemsPerRow),
|
||||
};
|
||||
}, [cardRows.length, itemCount, itemSize, width]);
|
||||
return {
|
||||
columnCount: itemsPerRow,
|
||||
itemHeight,
|
||||
rowCount: Math.ceil(itemCount / itemsPerRow),
|
||||
};
|
||||
}, [cardRows.length, itemCount, itemSize, width]);
|
||||
|
||||
const isItemLoaded = useCallback(
|
||||
(index: number) => {
|
||||
const itemIndex = index * columnCount;
|
||||
const isItemLoaded = useCallback(
|
||||
(index: number) => {
|
||||
const itemIndex = index * columnCount;
|
||||
|
||||
return itemData[itemIndex] !== undefined;
|
||||
},
|
||||
[columnCount, itemData],
|
||||
);
|
||||
return itemData[itemIndex] !== undefined;
|
||||
},
|
||||
[columnCount, itemData],
|
||||
);
|
||||
|
||||
const loadMoreItems = useCallback(
|
||||
async (startIndex: number, stopIndex: number) => {
|
||||
// Fixes a caching bug(?) when switching between filters and the itemCount increases
|
||||
if (startIndex === 1) return;
|
||||
const loadMoreItems = useCallback(
|
||||
async (startIndex: number, stopIndex: number) => {
|
||||
// Fixes a caching bug(?) when switching between filters and the itemCount increases
|
||||
if (startIndex === 1) return;
|
||||
|
||||
// Need to multiply by columnCount due to the grid layout
|
||||
const start = startIndex * columnCount;
|
||||
const end = stopIndex * columnCount + columnCount;
|
||||
// Need to multiply by columnCount due to the grid layout
|
||||
const start = startIndex * columnCount;
|
||||
const end = stopIndex * columnCount + columnCount;
|
||||
|
||||
const data = await fetchFn({
|
||||
columnCount,
|
||||
skip: start,
|
||||
take: end - start,
|
||||
});
|
||||
const data = await fetchFn({
|
||||
columnCount,
|
||||
skip: start,
|
||||
take: end - start,
|
||||
});
|
||||
|
||||
const newData: any[] = [...itemData];
|
||||
const newData: any[] = [...itemData];
|
||||
|
||||
let itemIndex = 0;
|
||||
for (let rowIndex = start; rowIndex < end; rowIndex += 1) {
|
||||
newData[rowIndex] = data.items[itemIndex];
|
||||
itemIndex += 1;
|
||||
}
|
||||
let itemIndex = 0;
|
||||
for (let rowIndex = start; rowIndex < end; rowIndex += 1) {
|
||||
newData[rowIndex] = data.items[itemIndex];
|
||||
itemIndex += 1;
|
||||
}
|
||||
|
||||
setItemData(newData);
|
||||
},
|
||||
[columnCount, fetchFn, itemData, setItemData],
|
||||
);
|
||||
setItemData(newData);
|
||||
},
|
||||
[columnCount, fetchFn, itemData, setItemData],
|
||||
);
|
||||
|
||||
const debouncedLoadMoreItems = debounce(loadMoreItems, 500);
|
||||
const debouncedLoadMoreItems = debounce(loadMoreItems, 500);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
resetLoadMoreItemsCache: () => {
|
||||
if (loader.current) {
|
||||
loader.current.resetloadMoreItemsCache(false);
|
||||
setItemData([]);
|
||||
}
|
||||
},
|
||||
scrollTo: (index: number) => {
|
||||
listRef?.current?.scrollToItem(index);
|
||||
},
|
||||
setItemData: (data: any[]) => {
|
||||
setItemData(data);
|
||||
},
|
||||
}));
|
||||
useImperativeHandle(ref, () => ({
|
||||
resetLoadMoreItemsCache: () => {
|
||||
if (loader.current) {
|
||||
loader.current.resetloadMoreItemsCache(false);
|
||||
setItemData([]);
|
||||
}
|
||||
},
|
||||
scrollTo: (index: number) => {
|
||||
listRef?.current?.scrollToItem(index);
|
||||
},
|
||||
setItemData: (data: any[]) => {
|
||||
setItemData(data);
|
||||
},
|
||||
}));
|
||||
|
||||
if (loading) return null;
|
||||
if (loading) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<InfiniteLoader
|
||||
ref={loader}
|
||||
isItemLoaded={(index) => isItemLoaded(index)}
|
||||
itemCount={itemCount || 0}
|
||||
loadMoreItems={debouncedLoadMoreItems}
|
||||
minimumBatchSize={minimumBatchSize}
|
||||
threshold={30}
|
||||
>
|
||||
{({ onItemsRendered, ref: infiniteLoaderRef }) => (
|
||||
<VirtualGridWrapper
|
||||
cardRows={cardRows}
|
||||
columnCount={columnCount}
|
||||
display={display || ListDisplayType.CARD}
|
||||
handleFavorite={handleFavorite}
|
||||
handlePlayQueueAdd={handlePlayQueueAdd}
|
||||
height={height}
|
||||
initialScrollOffset={initialScrollOffset}
|
||||
itemCount={itemCount || 0}
|
||||
itemData={itemData}
|
||||
itemGap={itemGap}
|
||||
itemHeight={itemHeight}
|
||||
itemType={itemType}
|
||||
itemWidth={itemSize}
|
||||
refInstance={(list) => {
|
||||
infiniteLoaderRef(list);
|
||||
listRef.current = list;
|
||||
}}
|
||||
route={route}
|
||||
rowCount={rowCount}
|
||||
width={width}
|
||||
onItemsRendered={onItemsRendered}
|
||||
onScroll={onScroll}
|
||||
/>
|
||||
)}
|
||||
</InfiniteLoader>
|
||||
</>
|
||||
);
|
||||
},
|
||||
return (
|
||||
<>
|
||||
<InfiniteLoader
|
||||
ref={loader}
|
||||
isItemLoaded={(index) => isItemLoaded(index)}
|
||||
itemCount={itemCount || 0}
|
||||
loadMoreItems={debouncedLoadMoreItems}
|
||||
minimumBatchSize={minimumBatchSize}
|
||||
threshold={30}
|
||||
>
|
||||
{({ onItemsRendered, ref: infiniteLoaderRef }) => (
|
||||
<VirtualGridWrapper
|
||||
cardRows={cardRows}
|
||||
columnCount={columnCount}
|
||||
display={display || ListDisplayType.CARD}
|
||||
handleFavorite={handleFavorite}
|
||||
handlePlayQueueAdd={handlePlayQueueAdd}
|
||||
height={height}
|
||||
initialScrollOffset={initialScrollOffset}
|
||||
itemCount={itemCount || 0}
|
||||
itemData={itemData}
|
||||
itemGap={itemGap}
|
||||
itemHeight={itemHeight}
|
||||
itemType={itemType}
|
||||
itemWidth={itemSize}
|
||||
refInstance={(list) => {
|
||||
infiniteLoaderRef(list);
|
||||
listRef.current = list;
|
||||
}}
|
||||
route={route}
|
||||
rowCount={rowCount}
|
||||
width={width}
|
||||
onItemsRendered={onItemsRendered}
|
||||
onScroll={onScroll}
|
||||
/>
|
||||
)}
|
||||
</InfiniteLoader>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
VirtualInfiniteGrid.defaultProps = {
|
||||
display: ListDisplayType.CARD,
|
||||
minimumBatchSize: 20,
|
||||
route: undefined,
|
||||
display: ListDisplayType.CARD,
|
||||
minimumBatchSize: 20,
|
||||
route: undefined,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,60 +9,60 @@ import { AppRoute } from '/@/renderer/router/routes';
|
|||
import { Skeleton } from '/@/renderer/components/skeleton';
|
||||
|
||||
export const AlbumArtistCell = ({ value, data }: ICellRendererParams) => {
|
||||
if (value === undefined) {
|
||||
return (
|
||||
<CellContainer position="left">
|
||||
<Skeleton
|
||||
height="1rem"
|
||||
width="80%"
|
||||
/>
|
||||
</CellContainer>
|
||||
);
|
||||
}
|
||||
if (value === undefined) {
|
||||
return (
|
||||
<CellContainer position="left">
|
||||
<Skeleton
|
||||
height="1rem"
|
||||
width="80%"
|
||||
/>
|
||||
</CellContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CellContainer position="left">
|
||||
<Text
|
||||
$secondary
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
>
|
||||
{value?.map((item: Artist | AlbumArtist, index: number) => (
|
||||
<React.Fragment key={`row-${item.id}-${data.uniqueId}`}>
|
||||
{index > 0 && (
|
||||
<Text
|
||||
$secondary
|
||||
size="md"
|
||||
style={{ display: 'inline-block' }}
|
||||
>
|
||||
,
|
||||
</Text>
|
||||
)}{' '}
|
||||
{item.id ? (
|
||||
<Text
|
||||
$link
|
||||
$secondary
|
||||
component={Link}
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||
albumArtistId: item.id,
|
||||
})}
|
||||
>
|
||||
{item.name || '—'}
|
||||
</Text>
|
||||
) : (
|
||||
<Text
|
||||
return (
|
||||
<CellContainer position="left">
|
||||
<Text
|
||||
$secondary
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
>
|
||||
{item.name || '—'}
|
||||
</Text>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Text>
|
||||
</CellContainer>
|
||||
);
|
||||
>
|
||||
{value?.map((item: Artist | AlbumArtist, index: number) => (
|
||||
<React.Fragment key={`row-${item.id}-${data.uniqueId}`}>
|
||||
{index > 0 && (
|
||||
<Text
|
||||
$secondary
|
||||
size="md"
|
||||
style={{ display: 'inline-block' }}
|
||||
>
|
||||
,
|
||||
</Text>
|
||||
)}{' '}
|
||||
{item.id ? (
|
||||
<Text
|
||||
$link
|
||||
$secondary
|
||||
component={Link}
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||
albumArtistId: item.id,
|
||||
})}
|
||||
>
|
||||
{item.name || '—'}
|
||||
</Text>
|
||||
) : (
|
||||
<Text
|
||||
$secondary
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
>
|
||||
{item.name || '—'}
|
||||
</Text>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Text>
|
||||
</CellContainer>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,60 +9,60 @@ import { AppRoute } from '/@/renderer/router/routes';
|
|||
import { Skeleton } from '/@/renderer/components/skeleton';
|
||||
|
||||
export const ArtistCell = ({ value, data }: ICellRendererParams) => {
|
||||
if (value === undefined) {
|
||||
return (
|
||||
<CellContainer position="left">
|
||||
<Skeleton
|
||||
height="1rem"
|
||||
width="80%"
|
||||
/>
|
||||
</CellContainer>
|
||||
);
|
||||
}
|
||||
if (value === undefined) {
|
||||
return (
|
||||
<CellContainer position="left">
|
||||
<Skeleton
|
||||
height="1rem"
|
||||
width="80%"
|
||||
/>
|
||||
</CellContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CellContainer position="left">
|
||||
<Text
|
||||
$secondary
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
>
|
||||
{value?.map((item: Artist | AlbumArtist, index: number) => (
|
||||
<React.Fragment key={`row-${item.id}-${data.uniqueId}`}>
|
||||
{index > 0 && (
|
||||
<Text
|
||||
$secondary
|
||||
size="md"
|
||||
style={{ display: 'inline-block' }}
|
||||
>
|
||||
,
|
||||
</Text>
|
||||
)}{' '}
|
||||
{item.id ? (
|
||||
<Text
|
||||
$link
|
||||
$secondary
|
||||
component={Link}
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||
albumArtistId: item.id,
|
||||
})}
|
||||
>
|
||||
{item.name || '—'}
|
||||
</Text>
|
||||
) : (
|
||||
<Text
|
||||
return (
|
||||
<CellContainer position="left">
|
||||
<Text
|
||||
$secondary
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
>
|
||||
{item.name || '—'}
|
||||
</Text>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Text>
|
||||
</CellContainer>
|
||||
);
|
||||
>
|
||||
{value?.map((item: Artist | AlbumArtist, index: number) => (
|
||||
<React.Fragment key={`row-${item.id}-${data.uniqueId}`}>
|
||||
{index > 0 && (
|
||||
<Text
|
||||
$secondary
|
||||
size="md"
|
||||
style={{ display: 'inline-block' }}
|
||||
>
|
||||
,
|
||||
</Text>
|
||||
)}{' '}
|
||||
{item.id ? (
|
||||
<Text
|
||||
$link
|
||||
$secondary
|
||||
component={Link}
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||
albumArtistId: item.id,
|
||||
})}
|
||||
>
|
||||
{item.name || '—'}
|
||||
</Text>
|
||||
) : (
|
||||
<Text
|
||||
$secondary
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
>
|
||||
{item.name || '—'}
|
||||
</Text>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Text>
|
||||
</CellContainer>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,140 +13,140 @@ import { ServerType } from '/@/renderer/types';
|
|||
import { Skeleton } from '/@/renderer/components/skeleton';
|
||||
|
||||
const CellContainer = styled(motion.div)<{ height: number }>`
|
||||
display: grid;
|
||||
grid-auto-columns: 1fr;
|
||||
grid-template-areas: 'image info';
|
||||
grid-template-rows: 1fr;
|
||||
grid-template-columns: ${(props) => props.height}px minmax(0, 1fr);
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
letter-spacing: 0.5px;
|
||||
display: grid;
|
||||
grid-auto-columns: 1fr;
|
||||
grid-template-areas: 'image info';
|
||||
grid-template-rows: 1fr;
|
||||
grid-template-columns: ${(props) => props.height}px minmax(0, 1fr);
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
letter-spacing: 0.5px;
|
||||
`;
|
||||
|
||||
const ImageWrapper = styled.div`
|
||||
display: flex;
|
||||
grid-area: image;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
grid-area: image;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const MetadataWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-area: info;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-area: info;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledImage = styled.img`
|
||||
object-fit: cover;
|
||||
object-fit: cover;
|
||||
`;
|
||||
|
||||
export const CombinedTitleCell = ({ value, rowIndex, node }: ICellRendererParams) => {
|
||||
const artists = useMemo(() => {
|
||||
if (!value) return null;
|
||||
return value?.type === ServerType.JELLYFIN ? value.artists : value.albumArtists;
|
||||
}, [value]);
|
||||
const artists = useMemo(() => {
|
||||
if (!value) return null;
|
||||
return value?.type === ServerType.JELLYFIN ? value.artists : value.albumArtists;
|
||||
}, [value]);
|
||||
|
||||
if (value === undefined) {
|
||||
return (
|
||||
<CellContainer height={node.rowHeight || 40}>
|
||||
<Skeleton>
|
||||
<ImageWrapper />
|
||||
</Skeleton>
|
||||
<MetadataWrapper>
|
||||
<Skeleton
|
||||
height="1rem"
|
||||
width="80%"
|
||||
/>
|
||||
<Skeleton
|
||||
height="1rem"
|
||||
mt="0.5rem"
|
||||
width="60%"
|
||||
/>
|
||||
</MetadataWrapper>
|
||||
</CellContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (value === undefined) {
|
||||
return (
|
||||
<CellContainer height={node.rowHeight || 40}>
|
||||
<Skeleton>
|
||||
<ImageWrapper />
|
||||
</Skeleton>
|
||||
<MetadataWrapper>
|
||||
<Skeleton
|
||||
height="1rem"
|
||||
width="80%"
|
||||
/>
|
||||
<Skeleton
|
||||
height="1rem"
|
||||
mt="0.5rem"
|
||||
width="60%"
|
||||
/>
|
||||
</MetadataWrapper>
|
||||
</CellContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CellContainer height={node.rowHeight || 40}>
|
||||
<ImageWrapper>
|
||||
{value.imageUrl ? (
|
||||
<StyledImage
|
||||
alt="cover"
|
||||
height={(node.rowHeight || 40) - 10}
|
||||
placeholder={value.imagePlaceholderUrl || 'var(--placeholder-bg)'}
|
||||
src={value.imageUrl}
|
||||
style={{}}
|
||||
width={(node.rowHeight || 40) - 10}
|
||||
/>
|
||||
) : (
|
||||
<Center
|
||||
sx={{
|
||||
background: 'var(--placeholder-bg)',
|
||||
borderRadius: 'var(--card-default-radius)',
|
||||
height: `${(node.rowHeight || 40) - 10}px`,
|
||||
width: `${(node.rowHeight || 40) - 10}px`,
|
||||
}}
|
||||
>
|
||||
<RiAlbumFill
|
||||
color="var(--placeholder-fg)"
|
||||
size={35}
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
</ImageWrapper>
|
||||
<MetadataWrapper>
|
||||
<Text
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
>
|
||||
{value.name}
|
||||
</Text>
|
||||
<Text
|
||||
$secondary
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
>
|
||||
{artists?.length ? (
|
||||
artists.map((artist: Artist | AlbumArtist, index: number) => (
|
||||
<React.Fragment key={`queue-${rowIndex}-artist-${artist.id}`}>
|
||||
{index > 0 ? ', ' : null}
|
||||
{artist.id ? (
|
||||
<Text
|
||||
$link
|
||||
$secondary
|
||||
component={Link}
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
sx={{ width: 'fit-content' }}
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||
albumArtistId: artist.id,
|
||||
})}
|
||||
>
|
||||
{artist.name}
|
||||
</Text>
|
||||
<CellContainer height={node.rowHeight || 40}>
|
||||
<ImageWrapper>
|
||||
{value.imageUrl ? (
|
||||
<StyledImage
|
||||
alt="cover"
|
||||
height={(node.rowHeight || 40) - 10}
|
||||
placeholder={value.imagePlaceholderUrl || 'var(--placeholder-bg)'}
|
||||
src={value.imageUrl}
|
||||
style={{}}
|
||||
width={(node.rowHeight || 40) - 10}
|
||||
/>
|
||||
) : (
|
||||
<Text
|
||||
<Center
|
||||
sx={{
|
||||
background: 'var(--placeholder-bg)',
|
||||
borderRadius: 'var(--card-default-radius)',
|
||||
height: `${(node.rowHeight || 40) - 10}px`,
|
||||
width: `${(node.rowHeight || 40) - 10}px`,
|
||||
}}
|
||||
>
|
||||
<RiAlbumFill
|
||||
color="var(--placeholder-fg)"
|
||||
size={35}
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
</ImageWrapper>
|
||||
<MetadataWrapper>
|
||||
<Text
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
>
|
||||
{value.name}
|
||||
</Text>
|
||||
<Text
|
||||
$secondary
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
sx={{ width: 'fit-content' }}
|
||||
>
|
||||
{artist.name}
|
||||
</Text>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))
|
||||
) : (
|
||||
<Text $secondary>—</Text>
|
||||
)}
|
||||
</Text>
|
||||
</MetadataWrapper>
|
||||
</CellContainer>
|
||||
);
|
||||
>
|
||||
{artists?.length ? (
|
||||
artists.map((artist: Artist | AlbumArtist, index: number) => (
|
||||
<React.Fragment key={`queue-${rowIndex}-artist-${artist.id}`}>
|
||||
{index > 0 ? ', ' : null}
|
||||
{artist.id ? (
|
||||
<Text
|
||||
$link
|
||||
$secondary
|
||||
component={Link}
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
sx={{ width: 'fit-content' }}
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||
albumArtistId: artist.id,
|
||||
})}
|
||||
>
|
||||
{artist.name}
|
||||
</Text>
|
||||
) : (
|
||||
<Text
|
||||
$secondary
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
sx={{ width: 'fit-content' }}
|
||||
>
|
||||
{artist.name}
|
||||
</Text>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))
|
||||
) : (
|
||||
<Text $secondary>—</Text>
|
||||
)}
|
||||
</Text>
|
||||
</MetadataWrapper>
|
||||
</CellContainer>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,61 +6,61 @@ import { CellContainer } from '/@/renderer/components/virtual-table/cells/generi
|
|||
import { useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
|
||||
|
||||
export const FavoriteCell = ({ value, data, node }: ICellRendererParams) => {
|
||||
const createMutation = useCreateFavorite({});
|
||||
const deleteMutation = useDeleteFavorite({});
|
||||
const createMutation = useCreateFavorite({});
|
||||
const deleteMutation = useDeleteFavorite({});
|
||||
|
||||
const handleToggleFavorite = () => {
|
||||
const newFavoriteValue = !value;
|
||||
const handleToggleFavorite = () => {
|
||||
const newFavoriteValue = !value;
|
||||
|
||||
if (newFavoriteValue) {
|
||||
createMutation.mutate(
|
||||
{
|
||||
query: {
|
||||
id: [data.id],
|
||||
type: data.itemType,
|
||||
},
|
||||
serverId: data.serverId,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
node.setData({ ...data, userFavorite: newFavoriteValue });
|
||||
},
|
||||
},
|
||||
);
|
||||
} else {
|
||||
deleteMutation.mutate(
|
||||
{
|
||||
query: {
|
||||
id: [data.id],
|
||||
type: data.itemType,
|
||||
},
|
||||
serverId: data.serverId,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
node.setData({ ...data, userFavorite: newFavoriteValue });
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
if (newFavoriteValue) {
|
||||
createMutation.mutate(
|
||||
{
|
||||
query: {
|
||||
id: [data.id],
|
||||
type: data.itemType,
|
||||
},
|
||||
serverId: data.serverId,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
node.setData({ ...data, userFavorite: newFavoriteValue });
|
||||
},
|
||||
},
|
||||
);
|
||||
} else {
|
||||
deleteMutation.mutate(
|
||||
{
|
||||
query: {
|
||||
id: [data.id],
|
||||
type: data.itemType,
|
||||
},
|
||||
serverId: data.serverId,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
node.setData({ ...data, userFavorite: newFavoriteValue });
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CellContainer position="center">
|
||||
<Button
|
||||
compact
|
||||
sx={{
|
||||
svg: {
|
||||
fill: !value
|
||||
? 'var(--main-fg-secondary) !important'
|
||||
: 'var(--primary-color) !important',
|
||||
},
|
||||
}}
|
||||
variant="subtle"
|
||||
onClick={handleToggleFavorite}
|
||||
>
|
||||
{!value ? <RiHeartLine size="1.3em" /> : <RiHeartFill size="1.3em" />}
|
||||
</Button>
|
||||
</CellContainer>
|
||||
);
|
||||
return (
|
||||
<CellContainer position="center">
|
||||
<Button
|
||||
compact
|
||||
sx={{
|
||||
svg: {
|
||||
fill: !value
|
||||
? 'var(--main-fg-secondary) !important'
|
||||
: 'var(--primary-color) !important',
|
||||
},
|
||||
}}
|
||||
variant="subtle"
|
||||
onClick={handleToggleFavorite}
|
||||
>
|
||||
{!value ? <RiHeartLine size="1.3em" /> : <RiHeartFill size="1.3em" />}
|
||||
</Button>
|
||||
</CellContainer>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,38 +8,38 @@ import { Paper } from '/@/renderer/components/paper';
|
|||
import { getNodesByDiscNumber, setNodeSelection } from '../utils';
|
||||
|
||||
const Container = styled(Paper)`
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid transparent;
|
||||
`;
|
||||
|
||||
export const FullWidthDiscCell = ({ node, data, api }: ICellRendererParams) => {
|
||||
const [isSelected, setIsSelected] = useState(false);
|
||||
const [isSelected, setIsSelected] = useState(false);
|
||||
|
||||
const handleToggleDiscNodes = () => {
|
||||
if (!data) return;
|
||||
const discNumber = Number(node.data.id.split('-')[1]);
|
||||
const nodes = getNodesByDiscNumber({ api, discNumber });
|
||||
const handleToggleDiscNodes = () => {
|
||||
if (!data) return;
|
||||
const discNumber = Number(node.data.id.split('-')[1]);
|
||||
const nodes = getNodesByDiscNumber({ api, discNumber });
|
||||
|
||||
setNodeSelection({ isSelected: !isSelected, nodes });
|
||||
setIsSelected((prev) => !prev);
|
||||
};
|
||||
setNodeSelection({ isSelected: !isSelected, nodes });
|
||||
setIsSelected((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Group
|
||||
position="apart"
|
||||
w="100%"
|
||||
>
|
||||
<Button
|
||||
compact
|
||||
leftIcon={isSelected ? <RiCheckboxLine /> : <RiCheckboxBlankLine />}
|
||||
size="md"
|
||||
variant="subtle"
|
||||
onClick={handleToggleDiscNodes}
|
||||
>
|
||||
{data.name}
|
||||
</Button>
|
||||
</Group>
|
||||
</Container>
|
||||
);
|
||||
return (
|
||||
<Container>
|
||||
<Group
|
||||
position="apart"
|
||||
w="100%"
|
||||
>
|
||||
<Button
|
||||
compact
|
||||
leftIcon={isSelected ? <RiCheckboxLine /> : <RiCheckboxBlankLine />}
|
||||
size="md"
|
||||
variant="subtle"
|
||||
onClick={handleToggleDiscNodes}
|
||||
>
|
||||
{data.name}
|
||||
</Button>
|
||||
</Group>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,79 +6,79 @@ import { Skeleton } from '/@/renderer/components/skeleton';
|
|||
import { Text } from '/@/renderer/components/text';
|
||||
|
||||
export const CELL_VARIANTS: Variants = {
|
||||
animate: {
|
||||
opacity: 1,
|
||||
},
|
||||
initial: {
|
||||
opacity: 0,
|
||||
},
|
||||
animate: {
|
||||
opacity: 1,
|
||||
},
|
||||
initial: {
|
||||
opacity: 0,
|
||||
},
|
||||
};
|
||||
|
||||
export const CellContainer = styled(motion.div)<{ position?: 'left' | 'center' | 'right' }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: ${(props) =>
|
||||
props.position === 'right'
|
||||
? 'flex-end'
|
||||
: props.position === 'center'
|
||||
? 'center'
|
||||
: 'flex-start'};
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
letter-spacing: 0.5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: ${(props) =>
|
||||
props.position === 'right'
|
||||
? 'flex-end'
|
||||
: props.position === 'center'
|
||||
? 'center'
|
||||
: 'flex-start'};
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
letter-spacing: 0.5px;
|
||||
`;
|
||||
|
||||
type Options = {
|
||||
array?: boolean;
|
||||
isArray?: boolean;
|
||||
isLink?: boolean;
|
||||
position?: 'left' | 'center' | 'right';
|
||||
primary?: boolean;
|
||||
array?: boolean;
|
||||
isArray?: boolean;
|
||||
isLink?: boolean;
|
||||
position?: 'left' | 'center' | 'right';
|
||||
primary?: boolean;
|
||||
};
|
||||
|
||||
export const GenericCell = (
|
||||
{ value, valueFormatted }: ICellRendererParams,
|
||||
{ position, primary, isLink }: Options,
|
||||
{ value, valueFormatted }: ICellRendererParams,
|
||||
{ position, primary, isLink }: Options,
|
||||
) => {
|
||||
const displayedValue = valueFormatted || value;
|
||||
const displayedValue = valueFormatted || value;
|
||||
|
||||
if (value === undefined) {
|
||||
return (
|
||||
<CellContainer position={position || 'left'}>
|
||||
<Skeleton
|
||||
height="1rem"
|
||||
width="80%"
|
||||
/>
|
||||
</CellContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (value === undefined) {
|
||||
return (
|
||||
<CellContainer position={position || 'left'}>
|
||||
<Skeleton
|
||||
height="1rem"
|
||||
width="80%"
|
||||
/>
|
||||
</CellContainer>
|
||||
<CellContainer position={position || 'left'}>
|
||||
{isLink ? (
|
||||
<Text
|
||||
$link={isLink}
|
||||
$secondary={!primary}
|
||||
component={Link}
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
to={displayedValue.link}
|
||||
>
|
||||
{isLink ? displayedValue.value : displayedValue}
|
||||
</Text>
|
||||
) : (
|
||||
<Text
|
||||
$secondary={!primary}
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
>
|
||||
{displayedValue}
|
||||
</Text>
|
||||
)}
|
||||
</CellContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CellContainer position={position || 'left'}>
|
||||
{isLink ? (
|
||||
<Text
|
||||
$link={isLink}
|
||||
$secondary={!primary}
|
||||
component={Link}
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
to={displayedValue.link}
|
||||
>
|
||||
{isLink ? displayedValue.value : displayedValue}
|
||||
</Text>
|
||||
) : (
|
||||
<Text
|
||||
$secondary={!primary}
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
>
|
||||
{displayedValue}
|
||||
</Text>
|
||||
)}
|
||||
</CellContainer>
|
||||
);
|
||||
};
|
||||
|
||||
GenericCell.defaultProps = {
|
||||
position: undefined,
|
||||
position: undefined,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,37 +6,37 @@ import { Text } from '/@/renderer/components/text';
|
|||
import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell';
|
||||
|
||||
export const GenreCell = ({ value, data }: ICellRendererParams) => {
|
||||
return (
|
||||
<CellContainer position="left">
|
||||
<Text
|
||||
$secondary
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
>
|
||||
{value?.map((item: Artist | AlbumArtist, index: number) => (
|
||||
<React.Fragment key={`row-${item.id}-${data.uniqueId}`}>
|
||||
{index > 0 && (
|
||||
<Text
|
||||
$secondary
|
||||
size="md"
|
||||
style={{ display: 'inline-block' }}
|
||||
>
|
||||
,
|
||||
</Text>
|
||||
)}{' '}
|
||||
return (
|
||||
<CellContainer position="left">
|
||||
<Text
|
||||
$link
|
||||
$secondary
|
||||
component={Link}
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
to="/"
|
||||
$secondary
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
>
|
||||
{item.name || '—'}
|
||||
{value?.map((item: Artist | AlbumArtist, index: number) => (
|
||||
<React.Fragment key={`row-${item.id}-${data.uniqueId}`}>
|
||||
{index > 0 && (
|
||||
<Text
|
||||
$secondary
|
||||
size="md"
|
||||
style={{ display: 'inline-block' }}
|
||||
>
|
||||
,
|
||||
</Text>
|
||||
)}{' '}
|
||||
<Text
|
||||
$link
|
||||
$secondary
|
||||
component={Link}
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
to="/"
|
||||
>
|
||||
{item.name || '—'}
|
||||
</Text>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Text>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Text>
|
||||
</CellContainer>
|
||||
);
|
||||
</CellContainer>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,54 +6,54 @@ import { CellContainer } from '/@/renderer/components/virtual-table/cells/generi
|
|||
import { useSetRating } from '/@/renderer/features/shared';
|
||||
|
||||
export const RatingCell = ({ value, node }: ICellRendererParams) => {
|
||||
const updateRatingMutation = useSetRating({});
|
||||
const updateRatingMutation = useSetRating({});
|
||||
|
||||
const handleUpdateRating = (rating: number) => {
|
||||
if (!value) return;
|
||||
const handleUpdateRating = (rating: number) => {
|
||||
if (!value) return;
|
||||
|
||||
updateRatingMutation.mutate(
|
||||
{
|
||||
query: {
|
||||
item: [value],
|
||||
rating,
|
||||
},
|
||||
serverId: value?.serverId,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
node.setData({ ...node.data, userRating: rating });
|
||||
},
|
||||
},
|
||||
updateRatingMutation.mutate(
|
||||
{
|
||||
query: {
|
||||
item: [value],
|
||||
rating,
|
||||
},
|
||||
serverId: value?.serverId,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
node.setData({ ...node.data, userRating: rating });
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleClearRating = (e: MouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
updateRatingMutation.mutate(
|
||||
{
|
||||
query: {
|
||||
item: [value],
|
||||
rating: 0,
|
||||
},
|
||||
serverId: value?.serverId,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
node.setData({ ...node.data, userRating: 0 });
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<CellContainer position="center">
|
||||
<Rating
|
||||
size="xs"
|
||||
value={value?.userRating}
|
||||
onChange={handleUpdateRating}
|
||||
onClick={handleClearRating}
|
||||
/>
|
||||
</CellContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const handleClearRating = (e: MouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
updateRatingMutation.mutate(
|
||||
{
|
||||
query: {
|
||||
item: [value],
|
||||
rating: 0,
|
||||
},
|
||||
serverId: value?.serverId,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
node.setData({ ...node.data, userRating: 0 });
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<CellContainer position="center">
|
||||
<Rating
|
||||
size="xs"
|
||||
value={value?.userRating}
|
||||
onChange={handleUpdateRating}
|
||||
onClick={handleClearRating}
|
||||
/>
|
||||
</CellContainer>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ import type { IHeaderParams } from '@ag-grid-community/core';
|
|||
import { FiClock } from 'react-icons/fi';
|
||||
|
||||
export interface ICustomHeaderParams extends IHeaderParams {
|
||||
menuIcon: string;
|
||||
menuIcon: string;
|
||||
}
|
||||
|
||||
export const DurationHeader = () => {
|
||||
return <FiClock size={15} />;
|
||||
return <FiClock size={15} />;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,84 +9,84 @@ import { _Text } from '/@/renderer/components/text';
|
|||
type Presets = 'duration' | 'rowIndex' | 'userFavorite' | 'userRating';
|
||||
|
||||
type Options = {
|
||||
children?: ReactNode;
|
||||
position?: 'left' | 'center' | 'right';
|
||||
preset?: Presets;
|
||||
children?: ReactNode;
|
||||
position?: 'left' | 'center' | 'right';
|
||||
preset?: Presets;
|
||||
};
|
||||
|
||||
const HeaderWrapper = styled.div<{ position: Options['position'] }>`
|
||||
display: flex;
|
||||
justify-content: ${(props) =>
|
||||
props.position === 'right'
|
||||
? 'flex-end'
|
||||
: props.position === 'center'
|
||||
? 'center'
|
||||
: 'flex-start'};
|
||||
width: 100%;
|
||||
font-family: var(--content-font-family);
|
||||
text-transform: uppercase;
|
||||
display: flex;
|
||||
justify-content: ${(props) =>
|
||||
props.position === 'right'
|
||||
? 'flex-end'
|
||||
: props.position === 'center'
|
||||
? 'center'
|
||||
: 'flex-start'};
|
||||
width: 100%;
|
||||
font-family: var(--content-font-family);
|
||||
text-transform: uppercase;
|
||||
`;
|
||||
|
||||
const TextHeaderWrapper = styled(_Text)<{ position: Options['position'] }>`
|
||||
width: 100%;
|
||||
color: var(--ag-header-foreground-color);
|
||||
font-weight: 500;
|
||||
text-align: ${(props) =>
|
||||
props.position === 'right'
|
||||
? 'flex-end'
|
||||
: props.position === 'center'
|
||||
? 'center'
|
||||
: 'flex-start'};
|
||||
text-transform: uppercase;
|
||||
width: 100%;
|
||||
color: var(--ag-header-foreground-color);
|
||||
font-weight: 500;
|
||||
text-align: ${(props) =>
|
||||
props.position === 'right'
|
||||
? 'flex-end'
|
||||
: props.position === 'center'
|
||||
? 'center'
|
||||
: 'flex-start'};
|
||||
text-transform: uppercase;
|
||||
`;
|
||||
|
||||
const headerPresets = {
|
||||
duration: (
|
||||
<FiClock
|
||||
color="var(--ag-header-foreground-color)"
|
||||
size="1em"
|
||||
/>
|
||||
),
|
||||
rowIndex: (
|
||||
<AiOutlineNumber
|
||||
color="var(--ag-header-foreground-color)"
|
||||
size="1em"
|
||||
/>
|
||||
),
|
||||
userFavorite: (
|
||||
<RiHeartLine
|
||||
color="var(--ag-header-foreground-color)"
|
||||
size="1em"
|
||||
/>
|
||||
),
|
||||
userRating: (
|
||||
<RiStarLine
|
||||
color="var(--ag-header-foreground-color)"
|
||||
size="1em"
|
||||
/>
|
||||
),
|
||||
duration: (
|
||||
<FiClock
|
||||
color="var(--ag-header-foreground-color)"
|
||||
size="1em"
|
||||
/>
|
||||
),
|
||||
rowIndex: (
|
||||
<AiOutlineNumber
|
||||
color="var(--ag-header-foreground-color)"
|
||||
size="1em"
|
||||
/>
|
||||
),
|
||||
userFavorite: (
|
||||
<RiHeartLine
|
||||
color="var(--ag-header-foreground-color)"
|
||||
size="1em"
|
||||
/>
|
||||
),
|
||||
userRating: (
|
||||
<RiStarLine
|
||||
color="var(--ag-header-foreground-color)"
|
||||
size="1em"
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const GenericTableHeader = (
|
||||
{ displayName }: IHeaderParams,
|
||||
{ preset, children, position }: Options,
|
||||
{ displayName }: IHeaderParams,
|
||||
{ preset, children, position }: Options,
|
||||
) => {
|
||||
if (preset) {
|
||||
return <HeaderWrapper position={position}>{headerPresets[preset]}</HeaderWrapper>;
|
||||
}
|
||||
if (preset) {
|
||||
return <HeaderWrapper position={position}>{headerPresets[preset]}</HeaderWrapper>;
|
||||
}
|
||||
|
||||
return (
|
||||
<TextHeaderWrapper
|
||||
overflow="hidden"
|
||||
position={position}
|
||||
weight={500}
|
||||
>
|
||||
{children || displayName}
|
||||
</TextHeaderWrapper>
|
||||
);
|
||||
return (
|
||||
<TextHeaderWrapper
|
||||
overflow="hidden"
|
||||
position={position}
|
||||
weight={500}
|
||||
>
|
||||
{children || displayName}
|
||||
</TextHeaderWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
GenericTableHeader.defaultProps = {
|
||||
position: 'left',
|
||||
preset: undefined,
|
||||
position: 'left',
|
||||
preset: undefined,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,13 +3,13 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li
|
|||
import { useClickOutside } from '@mantine/hooks';
|
||||
|
||||
export const useClickOutsideDeselect = (tableRef: MutableRefObject<AgGridReactType | null>) => {
|
||||
const handleDeselect = () => {
|
||||
if (tableRef.current) {
|
||||
tableRef.current.api.deselectAll();
|
||||
}
|
||||
};
|
||||
const handleDeselect = () => {
|
||||
if (tableRef.current) {
|
||||
tableRef.current.api.deselectAll();
|
||||
}
|
||||
};
|
||||
|
||||
const ref = useClickOutside(handleDeselect);
|
||||
const ref = useClickOutside(handleDeselect);
|
||||
|
||||
return ref;
|
||||
return ref;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,39 +4,39 @@ import { useWindowSettings } from '/@/renderer/store/settings.store';
|
|||
import { Platform } from '/@/renderer/types';
|
||||
|
||||
export const useFixedTableHeader = () => {
|
||||
const intersectRef = useRef<HTMLDivElement | null>(null);
|
||||
const tableContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const { windowBarStyle } = useWindowSettings();
|
||||
const intersectRef = useRef<HTMLDivElement | null>(null);
|
||||
const tableContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const { windowBarStyle } = useWindowSettings();
|
||||
|
||||
const isNotPastTableIntersection = useInView(intersectRef, {
|
||||
margin:
|
||||
windowBarStyle === Platform.WEB || windowBarStyle === Platform.LINUX
|
||||
? '-68px 0px 0px 0px'
|
||||
: '-98px 0px 0px 0px',
|
||||
});
|
||||
const isNotPastTableIntersection = useInView(intersectRef, {
|
||||
margin:
|
||||
windowBarStyle === Platform.WEB || windowBarStyle === Platform.LINUX
|
||||
? '-68px 0px 0px 0px'
|
||||
: '-98px 0px 0px 0px',
|
||||
});
|
||||
|
||||
const tableInView = useInView(tableContainerRef, {
|
||||
margin: '-128px 0px 0px 0px',
|
||||
});
|
||||
const tableInView = useInView(tableContainerRef, {
|
||||
margin: '-128px 0px 0px 0px',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const header = document.querySelector('main .ag-header');
|
||||
const root = document.querySelector('main .ag-root');
|
||||
useEffect(() => {
|
||||
const header = document.querySelector('main .ag-header');
|
||||
const root = document.querySelector('main .ag-root');
|
||||
|
||||
if (isNotPastTableIntersection || !tableInView) {
|
||||
if (windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS) {
|
||||
header?.classList.remove('window-frame');
|
||||
}
|
||||
header?.classList.remove('ag-header-fixed');
|
||||
root?.classList.remove('ag-header-fixed-margin');
|
||||
} else {
|
||||
if (windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS) {
|
||||
header?.classList.add('window-frame');
|
||||
}
|
||||
header?.classList.add('ag-header-fixed');
|
||||
root?.classList.add('ag-header-fixed-margin');
|
||||
}
|
||||
}, [isNotPastTableIntersection, tableInView, windowBarStyle]);
|
||||
if (isNotPastTableIntersection || !tableInView) {
|
||||
if (windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS) {
|
||||
header?.classList.remove('window-frame');
|
||||
}
|
||||
header?.classList.remove('ag-header-fixed');
|
||||
root?.classList.remove('ag-header-fixed-margin');
|
||||
} else {
|
||||
if (windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS) {
|
||||
header?.classList.add('window-frame');
|
||||
}
|
||||
header?.classList.add('ag-header-fixed');
|
||||
root?.classList.add('ag-header-fixed-margin');
|
||||
}
|
||||
}, [isNotPastTableIntersection, tableInView, windowBarStyle]);
|
||||
|
||||
return { intersectRef, tableContainerRef };
|
||||
return { intersectRef, tableContainerRef };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,121 +5,126 @@ import { NDAlbumDetail, NDAlbumArtistDetail } from '/@/renderer/api/navidrome.ty
|
|||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { SSAlbumDetail, SSAlbumArtistDetail } from '/@/renderer/api/subsonic.types';
|
||||
import {
|
||||
SetRatingArgs,
|
||||
Album,
|
||||
AlbumArtist,
|
||||
LibraryItem,
|
||||
AnyLibraryItems,
|
||||
RatingResponse,
|
||||
SetRatingArgs,
|
||||
Album,
|
||||
AlbumArtist,
|
||||
LibraryItem,
|
||||
AnyLibraryItems,
|
||||
RatingResponse,
|
||||
} from '/@/renderer/api/types';
|
||||
import { useSetAlbumListItemDataById, useSetQueueRating, getServerById } from '/@/renderer/store';
|
||||
import { ServerType } from '/@/renderer/types';
|
||||
|
||||
export const useUpdateRating = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const setAlbumListData = useSetAlbumListItemDataById();
|
||||
const setQueueRating = useSetQueueRating();
|
||||
const queryClient = useQueryClient();
|
||||
const setAlbumListData = useSetAlbumListItemDataById();
|
||||
const setQueueRating = useSetQueueRating();
|
||||
|
||||
return useMutation<
|
||||
RatingResponse,
|
||||
AxiosError,
|
||||
Omit<SetRatingArgs, 'server' | 'apiClientProps'>,
|
||||
{ previous: { items: AnyLibraryItems } | undefined }
|
||||
>({
|
||||
mutationFn: (args) => {
|
||||
const server = getServerById(args.serverId);
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.updateRating({ ...args, apiClientProps: { server } });
|
||||
},
|
||||
onError: (_error, _variables, context) => {
|
||||
for (const item of context?.previous?.items || []) {
|
||||
switch (item.itemType) {
|
||||
case LibraryItem.ALBUM:
|
||||
setAlbumListData(item.id, { userRating: item.userRating });
|
||||
break;
|
||||
case LibraryItem.SONG:
|
||||
setQueueRating([item.id], item.userRating);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
onMutate: (variables) => {
|
||||
for (const item of variables.query.item) {
|
||||
switch (item.itemType) {
|
||||
case LibraryItem.ALBUM:
|
||||
setAlbumListData(item.id, { userRating: variables.query.rating });
|
||||
break;
|
||||
case LibraryItem.SONG:
|
||||
setQueueRating([item.id], variables.query.rating);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return useMutation<
|
||||
RatingResponse,
|
||||
AxiosError,
|
||||
Omit<SetRatingArgs, 'server' | 'apiClientProps'>,
|
||||
{ previous: { items: AnyLibraryItems } | undefined }
|
||||
>({
|
||||
mutationFn: (args) => {
|
||||
const server = getServerById(args.serverId);
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.updateRating({ ...args, apiClientProps: { server } });
|
||||
},
|
||||
onError: (_error, _variables, context) => {
|
||||
for (const item of context?.previous?.items || []) {
|
||||
switch (item.itemType) {
|
||||
case LibraryItem.ALBUM:
|
||||
setAlbumListData(item.id, { userRating: item.userRating });
|
||||
break;
|
||||
case LibraryItem.SONG:
|
||||
setQueueRating([item.id], item.userRating);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
onMutate: (variables) => {
|
||||
for (const item of variables.query.item) {
|
||||
switch (item.itemType) {
|
||||
case LibraryItem.ALBUM:
|
||||
setAlbumListData(item.id, { userRating: variables.query.rating });
|
||||
break;
|
||||
case LibraryItem.SONG:
|
||||
setQueueRating([item.id], variables.query.rating);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { previous: { items: variables.query.item } };
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
// We only need to set if we're already on the album detail page
|
||||
const isAlbumDetailPage =
|
||||
variables.query.item.length === 1 && variables.query.item[0].itemType === LibraryItem.ALBUM;
|
||||
return { previous: { items: variables.query.item } };
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
// We only need to set if we're already on the album detail page
|
||||
const isAlbumDetailPage =
|
||||
variables.query.item.length === 1 &&
|
||||
variables.query.item[0].itemType === LibraryItem.ALBUM;
|
||||
|
||||
if (isAlbumDetailPage) {
|
||||
const { serverType, id: albumId, serverId } = variables.query.item[0] as Album;
|
||||
if (isAlbumDetailPage) {
|
||||
const { serverType, id: albumId, serverId } = variables.query.item[0] as Album;
|
||||
|
||||
const queryKey = queryKeys.albums.detail(serverId || '', { id: albumId });
|
||||
const previous = queryClient.getQueryData<any>(queryKey);
|
||||
if (previous) {
|
||||
switch (serverType) {
|
||||
case ServerType.NAVIDROME:
|
||||
queryClient.setQueryData<NDAlbumDetail>(queryKey, {
|
||||
...previous,
|
||||
userRating: variables.query.rating,
|
||||
});
|
||||
break;
|
||||
case ServerType.SUBSONIC:
|
||||
queryClient.setQueryData<SSAlbumDetail>(queryKey, {
|
||||
...previous,
|
||||
userRating: variables.query.rating,
|
||||
});
|
||||
break;
|
||||
case ServerType.JELLYFIN:
|
||||
// Jellyfin does not support ratings
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const queryKey = queryKeys.albums.detail(serverId || '', { id: albumId });
|
||||
const previous = queryClient.getQueryData<any>(queryKey);
|
||||
if (previous) {
|
||||
switch (serverType) {
|
||||
case ServerType.NAVIDROME:
|
||||
queryClient.setQueryData<NDAlbumDetail>(queryKey, {
|
||||
...previous,
|
||||
userRating: variables.query.rating,
|
||||
});
|
||||
break;
|
||||
case ServerType.SUBSONIC:
|
||||
queryClient.setQueryData<SSAlbumDetail>(queryKey, {
|
||||
...previous,
|
||||
userRating: variables.query.rating,
|
||||
});
|
||||
break;
|
||||
case ServerType.JELLYFIN:
|
||||
// Jellyfin does not support ratings
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// We only need to set if we're already on the album detail page
|
||||
const isAlbumArtistDetailPage =
|
||||
variables.query.item.length === 1 &&
|
||||
variables.query.item[0].itemType === LibraryItem.ALBUM_ARTIST;
|
||||
// We only need to set if we're already on the album detail page
|
||||
const isAlbumArtistDetailPage =
|
||||
variables.query.item.length === 1 &&
|
||||
variables.query.item[0].itemType === LibraryItem.ALBUM_ARTIST;
|
||||
|
||||
if (isAlbumArtistDetailPage) {
|
||||
const { serverType, id: albumArtistId, serverId } = variables.query.item[0] as AlbumArtist;
|
||||
if (isAlbumArtistDetailPage) {
|
||||
const {
|
||||
serverType,
|
||||
id: albumArtistId,
|
||||
serverId,
|
||||
} = variables.query.item[0] as AlbumArtist;
|
||||
|
||||
const queryKey = queryKeys.albumArtists.detail(serverId || '', {
|
||||
id: albumArtistId,
|
||||
});
|
||||
const previous = queryClient.getQueryData<any>(queryKey);
|
||||
if (previous) {
|
||||
switch (serverType) {
|
||||
case ServerType.NAVIDROME:
|
||||
queryClient.setQueryData<NDAlbumArtistDetail>(queryKey, {
|
||||
...previous,
|
||||
userRating: variables.query.rating,
|
||||
});
|
||||
break;
|
||||
case ServerType.SUBSONIC:
|
||||
queryClient.setQueryData<SSAlbumArtistDetail>(queryKey, {
|
||||
...previous,
|
||||
userRating: variables.query.rating,
|
||||
});
|
||||
break;
|
||||
case ServerType.JELLYFIN:
|
||||
// Jellyfin does not support ratings
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
const queryKey = queryKeys.albumArtists.detail(serverId || '', {
|
||||
id: albumArtistId,
|
||||
});
|
||||
const previous = queryClient.getQueryData<any>(queryKey);
|
||||
if (previous) {
|
||||
switch (serverType) {
|
||||
case ServerType.NAVIDROME:
|
||||
queryClient.setQueryData<NDAlbumArtistDetail>(queryKey, {
|
||||
...previous,
|
||||
userRating: variables.query.rating,
|
||||
});
|
||||
break;
|
||||
case ServerType.SUBSONIC:
|
||||
queryClient.setQueryData<SSAlbumArtistDetail>(queryKey, {
|
||||
...previous,
|
||||
userRating: variables.query.rating,
|
||||
});
|
||||
break;
|
||||
case ServerType.JELLYFIN:
|
||||
// Jellyfin does not support ratings
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
/* eslint-disable import/no-cycle */
|
||||
import { Ref, forwardRef, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import type {
|
||||
ICellRendererParams,
|
||||
ValueGetterParams,
|
||||
IHeaderParams,
|
||||
ValueFormatterParams,
|
||||
ColDef,
|
||||
ColumnMovedEvent,
|
||||
NewColumnsLoadedEvent,
|
||||
GridReadyEvent,
|
||||
GridSizeChangedEvent,
|
||||
ModelUpdatedEvent,
|
||||
ICellRendererParams,
|
||||
ValueGetterParams,
|
||||
IHeaderParams,
|
||||
ValueFormatterParams,
|
||||
ColDef,
|
||||
ColumnMovedEvent,
|
||||
NewColumnsLoadedEvent,
|
||||
GridReadyEvent,
|
||||
GridSizeChangedEvent,
|
||||
ModelUpdatedEvent,
|
||||
} from '@ag-grid-community/core';
|
||||
import type { AgGridReactProps } from '@ag-grid-community/react';
|
||||
import { AgGridReact } from '@ag-grid-community/react';
|
||||
|
|
@ -40,428 +40,453 @@ export * from './hooks/use-click-outside-deselect';
|
|||
export * from './utils';
|
||||
|
||||
const TableWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const tableColumns: { [key: string]: ColDef } = {
|
||||
album: {
|
||||
cellRenderer: (params: ICellRendererParams) =>
|
||||
GenericCell(params, { isLink: true, position: 'left' }),
|
||||
colId: TableColumn.ALBUM,
|
||||
headerName: 'Album',
|
||||
valueGetter: (params: ValueGetterParams) =>
|
||||
params.data
|
||||
? {
|
||||
link: generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
|
||||
albumId: params.data?.albumId || '',
|
||||
}),
|
||||
value: params.data?.album,
|
||||
}
|
||||
: undefined,
|
||||
width: 200,
|
||||
},
|
||||
albumArtist: {
|
||||
cellRenderer: AlbumArtistCell,
|
||||
colId: TableColumn.ALBUM_ARTIST,
|
||||
headerName: 'Album Artist',
|
||||
valueGetter: (params: ValueGetterParams) =>
|
||||
params.data ? params.data.albumArtists : undefined,
|
||||
width: 150,
|
||||
},
|
||||
albumCount: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
colId: TableColumn.ALBUM_COUNT,
|
||||
field: 'albumCount',
|
||||
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: 'Albums',
|
||||
suppressSizeToFit: true,
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.albumCount : undefined),
|
||||
width: 80,
|
||||
},
|
||||
artist: {
|
||||
cellRenderer: ArtistCell,
|
||||
colId: TableColumn.ARTIST,
|
||||
headerName: 'Artist',
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.artists : undefined),
|
||||
width: 150,
|
||||
},
|
||||
biography: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'left' }),
|
||||
colId: TableColumn.BIOGRAPHY,
|
||||
field: 'biography',
|
||||
headerName: 'Biography',
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.biography : ''),
|
||||
width: 200,
|
||||
},
|
||||
bitRate: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
colId: TableColumn.BIT_RATE,
|
||||
field: 'bitRate',
|
||||
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
|
||||
suppressSizeToFit: true,
|
||||
valueFormatter: (params: ValueFormatterParams) => `${params.value} kbps`,
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.bitRate : undefined),
|
||||
width: 90,
|
||||
},
|
||||
bpm: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
colId: TableColumn.BPM,
|
||||
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: 'BPM',
|
||||
suppressSizeToFit: true,
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.bpm : undefined),
|
||||
width: 60,
|
||||
},
|
||||
channels: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
colId: TableColumn.CHANNELS,
|
||||
field: 'channels',
|
||||
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.channels : undefined),
|
||||
width: 100,
|
||||
},
|
||||
comment: {
|
||||
cellRenderer: GenericCell,
|
||||
colId: TableColumn.COMMENT,
|
||||
headerName: 'Note',
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.comment : undefined),
|
||||
width: 150,
|
||||
},
|
||||
dateAdded: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
colId: TableColumn.DATE_ADDED,
|
||||
field: 'createdAt',
|
||||
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: 'Date Added',
|
||||
suppressSizeToFit: true,
|
||||
valueFormatter: (params: ValueFormatterParams) =>
|
||||
params.value ? dayjs(params.value).format('MMM D, YYYY') : '',
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.createdAt : undefined),
|
||||
width: 130,
|
||||
},
|
||||
discNumber: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
colId: TableColumn.DISC_NUMBER,
|
||||
field: 'discNumber',
|
||||
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: 'Disc',
|
||||
suppressSizeToFit: true,
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.discNumber : undefined),
|
||||
width: 60,
|
||||
},
|
||||
duration: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
colId: TableColumn.DURATION,
|
||||
field: 'duration',
|
||||
headerComponent: (params: IHeaderParams) =>
|
||||
GenericTableHeader(params, { position: 'center', preset: 'duration' }),
|
||||
suppressSizeToFit: true,
|
||||
valueFormatter: (params: ValueFormatterParams) => formatDuration(params.value * 1000),
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.duration : undefined),
|
||||
width: 70,
|
||||
},
|
||||
genre: {
|
||||
cellRenderer: GenreCell,
|
||||
colId: TableColumn.GENRE,
|
||||
headerName: 'Genre',
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.genres : undefined),
|
||||
width: 100,
|
||||
},
|
||||
lastPlayedAt: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
colId: TableColumn.LAST_PLAYED,
|
||||
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: 'Last Played',
|
||||
valueFormatter: (params: ValueFormatterParams) =>
|
||||
params.value ? dayjs(params.value).fromNow() : '',
|
||||
valueGetter: (params: ValueGetterParams) =>
|
||||
params.data ? params.data.lastPlayedAt : undefined,
|
||||
width: 130,
|
||||
},
|
||||
path: {
|
||||
cellRenderer: GenericCell,
|
||||
colId: TableColumn.PATH,
|
||||
headerName: 'Path',
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.path : undefined),
|
||||
width: 200,
|
||||
},
|
||||
playCount: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
colId: TableColumn.PLAY_COUNT,
|
||||
field: 'playCount',
|
||||
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: 'Plays',
|
||||
suppressSizeToFit: true,
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.playCount : undefined),
|
||||
width: 90,
|
||||
},
|
||||
releaseDate: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
colId: TableColumn.RELEASE_DATE,
|
||||
field: 'releaseDate',
|
||||
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: 'Release Date',
|
||||
suppressSizeToFit: true,
|
||||
valueFormatter: (params: ValueFormatterParams) =>
|
||||
params.value ? dayjs(params.value).format('MMM D, YYYY') : '',
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.releaseDate : undefined),
|
||||
width: 130,
|
||||
},
|
||||
releaseYear: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
colId: TableColumn.YEAR,
|
||||
field: 'releaseYear',
|
||||
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: 'Year',
|
||||
suppressSizeToFit: true,
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.releaseYear : undefined),
|
||||
width: 80,
|
||||
},
|
||||
rowIndex: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'right' }),
|
||||
colId: TableColumn.ROW_INDEX,
|
||||
headerComponent: (params: IHeaderParams) =>
|
||||
GenericTableHeader(params, { position: 'right', preset: 'rowIndex' }),
|
||||
suppressSizeToFit: true,
|
||||
valueGetter: (params) => {
|
||||
return (params.node?.rowIndex || 0) + 1;
|
||||
album: {
|
||||
cellRenderer: (params: ICellRendererParams) =>
|
||||
GenericCell(params, { isLink: true, position: 'left' }),
|
||||
colId: TableColumn.ALBUM,
|
||||
headerName: 'Album',
|
||||
valueGetter: (params: ValueGetterParams) =>
|
||||
params.data
|
||||
? {
|
||||
link: generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
|
||||
albumId: params.data?.albumId || '',
|
||||
}),
|
||||
value: params.data?.album,
|
||||
}
|
||||
: undefined,
|
||||
width: 200,
|
||||
},
|
||||
albumArtist: {
|
||||
cellRenderer: AlbumArtistCell,
|
||||
colId: TableColumn.ALBUM_ARTIST,
|
||||
headerName: 'Album Artist',
|
||||
valueGetter: (params: ValueGetterParams) =>
|
||||
params.data ? params.data.albumArtists : undefined,
|
||||
width: 150,
|
||||
},
|
||||
albumCount: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
colId: TableColumn.ALBUM_COUNT,
|
||||
field: 'albumCount',
|
||||
headerComponent: (params: IHeaderParams) =>
|
||||
GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: 'Albums',
|
||||
suppressSizeToFit: true,
|
||||
valueGetter: (params: ValueGetterParams) =>
|
||||
params.data ? params.data.albumCount : undefined,
|
||||
width: 80,
|
||||
},
|
||||
artist: {
|
||||
cellRenderer: ArtistCell,
|
||||
colId: TableColumn.ARTIST,
|
||||
headerName: 'Artist',
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.artists : undefined),
|
||||
width: 150,
|
||||
},
|
||||
biography: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'left' }),
|
||||
colId: TableColumn.BIOGRAPHY,
|
||||
field: 'biography',
|
||||
headerName: 'Biography',
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.biography : ''),
|
||||
width: 200,
|
||||
},
|
||||
bitRate: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
colId: TableColumn.BIT_RATE,
|
||||
field: 'bitRate',
|
||||
headerComponent: (params: IHeaderParams) =>
|
||||
GenericTableHeader(params, { position: 'center' }),
|
||||
suppressSizeToFit: true,
|
||||
valueFormatter: (params: ValueFormatterParams) => `${params.value} kbps`,
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.bitRate : undefined),
|
||||
width: 90,
|
||||
},
|
||||
bpm: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
colId: TableColumn.BPM,
|
||||
headerComponent: (params: IHeaderParams) =>
|
||||
GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: 'BPM',
|
||||
suppressSizeToFit: true,
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.bpm : undefined),
|
||||
width: 60,
|
||||
},
|
||||
channels: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
colId: TableColumn.CHANNELS,
|
||||
field: 'channels',
|
||||
headerComponent: (params: IHeaderParams) =>
|
||||
GenericTableHeader(params, { position: 'center' }),
|
||||
valueGetter: (params: ValueGetterParams) =>
|
||||
params.data ? params.data.channels : undefined,
|
||||
width: 100,
|
||||
},
|
||||
comment: {
|
||||
cellRenderer: GenericCell,
|
||||
colId: TableColumn.COMMENT,
|
||||
headerName: 'Note',
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.comment : undefined),
|
||||
width: 150,
|
||||
},
|
||||
dateAdded: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
colId: TableColumn.DATE_ADDED,
|
||||
field: 'createdAt',
|
||||
headerComponent: (params: IHeaderParams) =>
|
||||
GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: 'Date Added',
|
||||
suppressSizeToFit: true,
|
||||
valueFormatter: (params: ValueFormatterParams) =>
|
||||
params.value ? dayjs(params.value).format('MMM D, YYYY') : '',
|
||||
valueGetter: (params: ValueGetterParams) =>
|
||||
params.data ? params.data.createdAt : undefined,
|
||||
width: 130,
|
||||
},
|
||||
discNumber: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
colId: TableColumn.DISC_NUMBER,
|
||||
field: 'discNumber',
|
||||
headerComponent: (params: IHeaderParams) =>
|
||||
GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: 'Disc',
|
||||
suppressSizeToFit: true,
|
||||
valueGetter: (params: ValueGetterParams) =>
|
||||
params.data ? params.data.discNumber : undefined,
|
||||
width: 60,
|
||||
},
|
||||
duration: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
colId: TableColumn.DURATION,
|
||||
field: 'duration',
|
||||
headerComponent: (params: IHeaderParams) =>
|
||||
GenericTableHeader(params, { position: 'center', preset: 'duration' }),
|
||||
suppressSizeToFit: true,
|
||||
valueFormatter: (params: ValueFormatterParams) => formatDuration(params.value * 1000),
|
||||
valueGetter: (params: ValueGetterParams) =>
|
||||
params.data ? params.data.duration : undefined,
|
||||
width: 70,
|
||||
},
|
||||
genre: {
|
||||
cellRenderer: GenreCell,
|
||||
colId: TableColumn.GENRE,
|
||||
headerName: 'Genre',
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.genres : undefined),
|
||||
width: 100,
|
||||
},
|
||||
lastPlayedAt: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
colId: TableColumn.LAST_PLAYED,
|
||||
headerComponent: (params: IHeaderParams) =>
|
||||
GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: 'Last Played',
|
||||
valueFormatter: (params: ValueFormatterParams) =>
|
||||
params.value ? dayjs(params.value).fromNow() : '',
|
||||
valueGetter: (params: ValueGetterParams) =>
|
||||
params.data ? params.data.lastPlayedAt : undefined,
|
||||
width: 130,
|
||||
},
|
||||
path: {
|
||||
cellRenderer: GenericCell,
|
||||
colId: TableColumn.PATH,
|
||||
headerName: 'Path',
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.path : undefined),
|
||||
width: 200,
|
||||
},
|
||||
playCount: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
colId: TableColumn.PLAY_COUNT,
|
||||
field: 'playCount',
|
||||
headerComponent: (params: IHeaderParams) =>
|
||||
GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: 'Plays',
|
||||
suppressSizeToFit: true,
|
||||
valueGetter: (params: ValueGetterParams) =>
|
||||
params.data ? params.data.playCount : undefined,
|
||||
width: 90,
|
||||
},
|
||||
releaseDate: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
colId: TableColumn.RELEASE_DATE,
|
||||
field: 'releaseDate',
|
||||
headerComponent: (params: IHeaderParams) =>
|
||||
GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: 'Release Date',
|
||||
suppressSizeToFit: true,
|
||||
valueFormatter: (params: ValueFormatterParams) =>
|
||||
params.value ? dayjs(params.value).format('MMM D, YYYY') : '',
|
||||
valueGetter: (params: ValueGetterParams) =>
|
||||
params.data ? params.data.releaseDate : undefined,
|
||||
width: 130,
|
||||
},
|
||||
releaseYear: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
colId: TableColumn.YEAR,
|
||||
field: 'releaseYear',
|
||||
headerComponent: (params: IHeaderParams) =>
|
||||
GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: 'Year',
|
||||
suppressSizeToFit: true,
|
||||
valueGetter: (params: ValueGetterParams) =>
|
||||
params.data ? params.data.releaseYear : undefined,
|
||||
width: 80,
|
||||
},
|
||||
rowIndex: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'right' }),
|
||||
colId: TableColumn.ROW_INDEX,
|
||||
headerComponent: (params: IHeaderParams) =>
|
||||
GenericTableHeader(params, { position: 'right', preset: 'rowIndex' }),
|
||||
suppressSizeToFit: true,
|
||||
valueGetter: (params) => {
|
||||
return (params.node?.rowIndex || 0) + 1;
|
||||
},
|
||||
width: 65,
|
||||
},
|
||||
songCount: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
colId: TableColumn.SONG_COUNT,
|
||||
field: 'songCount',
|
||||
headerComponent: (params: IHeaderParams) =>
|
||||
GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: 'Songs',
|
||||
suppressSizeToFit: true,
|
||||
valueGetter: (params: ValueGetterParams) =>
|
||||
params.data ? params.data.songCount : undefined,
|
||||
width: 80,
|
||||
},
|
||||
title: {
|
||||
cellRenderer: (params: ICellRendererParams) =>
|
||||
GenericCell(params, { position: 'left', primary: true }),
|
||||
colId: TableColumn.TITLE,
|
||||
field: 'name',
|
||||
headerName: 'Title',
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.name : undefined),
|
||||
width: 250,
|
||||
},
|
||||
titleCombined: {
|
||||
cellRenderer: CombinedTitleCell,
|
||||
colId: TableColumn.TITLE_COMBINED,
|
||||
headerName: 'Title',
|
||||
initialWidth: 500,
|
||||
minWidth: 150,
|
||||
valueGetter: (params: ValueGetterParams) =>
|
||||
params.data
|
||||
? {
|
||||
albumArtists: params.data?.albumArtists,
|
||||
artists: params.data?.artists,
|
||||
imagePlaceholderUrl: params.data?.imagePlaceholderUrl,
|
||||
imageUrl: params.data?.imageUrl,
|
||||
name: params.data?.name,
|
||||
rowHeight: params.node?.rowHeight,
|
||||
type: params.data?.serverType,
|
||||
}
|
||||
: undefined,
|
||||
width: 250,
|
||||
},
|
||||
trackNumber: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
colId: TableColumn.TRACK_NUMBER,
|
||||
field: 'trackNumber',
|
||||
headerComponent: (params: IHeaderParams) =>
|
||||
GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: 'Track',
|
||||
suppressSizeToFit: true,
|
||||
valueGetter: (params: ValueGetterParams) =>
|
||||
params.data ? params.data.trackNumber : undefined,
|
||||
width: 80,
|
||||
},
|
||||
userFavorite: {
|
||||
cellClass: (params) => (params.value ? 'visible ag-cell-favorite' : 'ag-cell-favorite'),
|
||||
cellRenderer: FavoriteCell,
|
||||
colId: TableColumn.USER_FAVORITE,
|
||||
field: 'userFavorite',
|
||||
headerComponent: (params: IHeaderParams) =>
|
||||
GenericTableHeader(params, { position: 'center', preset: 'userFavorite' }),
|
||||
headerName: 'Favorite',
|
||||
suppressSizeToFit: true,
|
||||
valueGetter: (params: ValueGetterParams) =>
|
||||
params.data ? params.data.userFavorite : undefined,
|
||||
width: 50,
|
||||
},
|
||||
userRating: {
|
||||
cellClass: (params) =>
|
||||
params.value?.userRating ? 'visible ag-cell-rating' : 'ag-cell-rating',
|
||||
cellRenderer: RatingCell,
|
||||
colId: TableColumn.USER_RATING,
|
||||
field: 'userRating',
|
||||
headerComponent: (params: IHeaderParams) =>
|
||||
GenericTableHeader(params, { position: 'center', preset: 'userRating' }),
|
||||
headerName: 'Rating',
|
||||
suppressSizeToFit: true,
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data : undefined),
|
||||
width: 95,
|
||||
},
|
||||
width: 65,
|
||||
},
|
||||
songCount: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
colId: TableColumn.SONG_COUNT,
|
||||
field: 'songCount',
|
||||
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: 'Songs',
|
||||
suppressSizeToFit: true,
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.songCount : undefined),
|
||||
width: 80,
|
||||
},
|
||||
title: {
|
||||
cellRenderer: (params: ICellRendererParams) =>
|
||||
GenericCell(params, { position: 'left', primary: true }),
|
||||
colId: TableColumn.TITLE,
|
||||
field: 'name',
|
||||
headerName: 'Title',
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.name : undefined),
|
||||
width: 250,
|
||||
},
|
||||
titleCombined: {
|
||||
cellRenderer: CombinedTitleCell,
|
||||
colId: TableColumn.TITLE_COMBINED,
|
||||
headerName: 'Title',
|
||||
initialWidth: 500,
|
||||
minWidth: 150,
|
||||
valueGetter: (params: ValueGetterParams) =>
|
||||
params.data
|
||||
? {
|
||||
albumArtists: params.data?.albumArtists,
|
||||
artists: params.data?.artists,
|
||||
imagePlaceholderUrl: params.data?.imagePlaceholderUrl,
|
||||
imageUrl: params.data?.imageUrl,
|
||||
name: params.data?.name,
|
||||
rowHeight: params.node?.rowHeight,
|
||||
type: params.data?.serverType,
|
||||
}
|
||||
: undefined,
|
||||
width: 250,
|
||||
},
|
||||
trackNumber: {
|
||||
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
|
||||
colId: TableColumn.TRACK_NUMBER,
|
||||
field: 'trackNumber',
|
||||
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
|
||||
headerName: 'Track',
|
||||
suppressSizeToFit: true,
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.trackNumber : undefined),
|
||||
width: 80,
|
||||
},
|
||||
userFavorite: {
|
||||
cellClass: (params) => (params.value ? 'visible ag-cell-favorite' : 'ag-cell-favorite'),
|
||||
cellRenderer: FavoriteCell,
|
||||
colId: TableColumn.USER_FAVORITE,
|
||||
field: 'userFavorite',
|
||||
headerComponent: (params: IHeaderParams) =>
|
||||
GenericTableHeader(params, { position: 'center', preset: 'userFavorite' }),
|
||||
headerName: 'Favorite',
|
||||
suppressSizeToFit: true,
|
||||
valueGetter: (params: ValueGetterParams) =>
|
||||
params.data ? params.data.userFavorite : undefined,
|
||||
width: 50,
|
||||
},
|
||||
userRating: {
|
||||
cellClass: (params) => (params.value?.userRating ? 'visible ag-cell-rating' : 'ag-cell-rating'),
|
||||
cellRenderer: RatingCell,
|
||||
colId: TableColumn.USER_RATING,
|
||||
field: 'userRating',
|
||||
headerComponent: (params: IHeaderParams) =>
|
||||
GenericTableHeader(params, { position: 'center', preset: 'userRating' }),
|
||||
headerName: 'Rating',
|
||||
suppressSizeToFit: true,
|
||||
valueGetter: (params: ValueGetterParams) => (params.data ? params.data : undefined),
|
||||
width: 95,
|
||||
},
|
||||
};
|
||||
|
||||
export const getColumnDef = (column: TableColumn) => {
|
||||
return tableColumns[column as keyof typeof tableColumns];
|
||||
return tableColumns[column as keyof typeof tableColumns];
|
||||
};
|
||||
|
||||
export const getColumnDefs = (columns: PersistedTableColumn[]) => {
|
||||
const columnDefs: ColDef[] = [];
|
||||
for (const column of columns) {
|
||||
const presetColumn = tableColumns[column.column as keyof typeof tableColumns];
|
||||
if (presetColumn) {
|
||||
columnDefs.push({
|
||||
...presetColumn,
|
||||
initialWidth: column.width,
|
||||
...column.extraProps,
|
||||
});
|
||||
const columnDefs: ColDef[] = [];
|
||||
for (const column of columns) {
|
||||
const presetColumn = tableColumns[column.column as keyof typeof tableColumns];
|
||||
if (presetColumn) {
|
||||
columnDefs.push({
|
||||
...presetColumn,
|
||||
initialWidth: column.width,
|
||||
...column.extraProps,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return columnDefs;
|
||||
return columnDefs;
|
||||
};
|
||||
|
||||
interface VirtualTableProps extends AgGridReactProps {
|
||||
autoFitColumns?: boolean;
|
||||
autoHeight?: boolean;
|
||||
deselectOnClickOutside?: boolean;
|
||||
transparentHeader?: boolean;
|
||||
autoFitColumns?: boolean;
|
||||
autoHeight?: boolean;
|
||||
deselectOnClickOutside?: boolean;
|
||||
transparentHeader?: boolean;
|
||||
}
|
||||
|
||||
export const VirtualTable = forwardRef(
|
||||
(
|
||||
{
|
||||
autoFitColumns,
|
||||
deselectOnClickOutside,
|
||||
autoHeight,
|
||||
transparentHeader,
|
||||
onColumnMoved,
|
||||
onNewColumnsLoaded,
|
||||
onGridReady,
|
||||
onGridSizeChanged,
|
||||
...rest
|
||||
}: VirtualTableProps,
|
||||
ref: Ref<AgGridReactType | null>,
|
||||
) => {
|
||||
const tableRef = useRef<AgGridReactType | null>(null);
|
||||
(
|
||||
{
|
||||
autoFitColumns,
|
||||
deselectOnClickOutside,
|
||||
autoHeight,
|
||||
transparentHeader,
|
||||
onColumnMoved,
|
||||
onNewColumnsLoaded,
|
||||
onGridReady,
|
||||
onGridSizeChanged,
|
||||
...rest
|
||||
}: VirtualTableProps,
|
||||
ref: Ref<AgGridReactType | null>,
|
||||
) => {
|
||||
const tableRef = useRef<AgGridReactType | null>(null);
|
||||
|
||||
const mergedRef = useMergedRef(ref, tableRef);
|
||||
const mergedRef = useMergedRef(ref, tableRef);
|
||||
|
||||
const deselectRef = useClickOutside(() => {
|
||||
if (tableRef?.current?.api && deselectOnClickOutside) {
|
||||
tableRef?.current?.api?.deselectAll();
|
||||
}
|
||||
});
|
||||
const deselectRef = useClickOutside(() => {
|
||||
if (tableRef?.current?.api && deselectOnClickOutside) {
|
||||
tableRef?.current?.api?.deselectAll();
|
||||
}
|
||||
});
|
||||
|
||||
const defaultColumnDefs: ColDef = useMemo(() => {
|
||||
return {
|
||||
lockPinned: true,
|
||||
lockVisible: true,
|
||||
resizable: true,
|
||||
};
|
||||
}, []);
|
||||
const defaultColumnDefs: ColDef = useMemo(() => {
|
||||
return {
|
||||
lockPinned: true,
|
||||
lockVisible: true,
|
||||
resizable: true,
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto fit columns on column change
|
||||
useEffect(() => {
|
||||
if (!tableRef?.current?.api) return;
|
||||
if (autoFitColumns && tableRef?.current?.api) {
|
||||
tableRef?.current?.api?.sizeColumnsToFit?.();
|
||||
}
|
||||
}, [autoFitColumns]);
|
||||
// Auto fit columns on column change
|
||||
useEffect(() => {
|
||||
if (!tableRef?.current?.api) return;
|
||||
if (autoFitColumns && tableRef?.current?.api) {
|
||||
tableRef?.current?.api?.sizeColumnsToFit?.();
|
||||
}
|
||||
}, [autoFitColumns]);
|
||||
|
||||
// Reset row heights on row height change
|
||||
useEffect(() => {
|
||||
if (!tableRef?.current?.api) return;
|
||||
tableRef?.current?.api?.resetRowHeights();
|
||||
tableRef?.current?.api?.redrawRows();
|
||||
}, [rest.rowHeight]);
|
||||
// Reset row heights on row height change
|
||||
useEffect(() => {
|
||||
if (!tableRef?.current?.api) return;
|
||||
tableRef?.current?.api?.resetRowHeights();
|
||||
tableRef?.current?.api?.redrawRows();
|
||||
}, [rest.rowHeight]);
|
||||
|
||||
const handleColumnMoved = useCallback(
|
||||
(e: ColumnMovedEvent) => {
|
||||
if (!e?.api) return;
|
||||
onColumnMoved?.(e);
|
||||
if (autoFitColumns) e.api?.sizeColumnsToFit?.();
|
||||
},
|
||||
[autoFitColumns, onColumnMoved],
|
||||
);
|
||||
const handleColumnMoved = useCallback(
|
||||
(e: ColumnMovedEvent) => {
|
||||
if (!e?.api) return;
|
||||
onColumnMoved?.(e);
|
||||
if (autoFitColumns) e.api?.sizeColumnsToFit?.();
|
||||
},
|
||||
[autoFitColumns, onColumnMoved],
|
||||
);
|
||||
|
||||
const handleNewColumnsLoaded = useCallback(
|
||||
(e: NewColumnsLoadedEvent) => {
|
||||
if (!e?.api) return;
|
||||
onNewColumnsLoaded?.(e);
|
||||
if (autoFitColumns) e.api?.sizeColumnsToFit?.();
|
||||
},
|
||||
[autoFitColumns, onNewColumnsLoaded],
|
||||
);
|
||||
const handleNewColumnsLoaded = useCallback(
|
||||
(e: NewColumnsLoadedEvent) => {
|
||||
if (!e?.api) return;
|
||||
onNewColumnsLoaded?.(e);
|
||||
if (autoFitColumns) e.api?.sizeColumnsToFit?.();
|
||||
},
|
||||
[autoFitColumns, onNewColumnsLoaded],
|
||||
);
|
||||
|
||||
const handleGridReady = useCallback(
|
||||
(e: GridReadyEvent) => {
|
||||
if (!e?.api) return;
|
||||
onGridReady?.(e);
|
||||
if (autoHeight) e.api.setDomLayout('autoHeight');
|
||||
if (autoFitColumns) e.api?.sizeColumnsToFit?.();
|
||||
},
|
||||
[autoHeight, autoFitColumns, onGridReady],
|
||||
);
|
||||
const handleGridReady = useCallback(
|
||||
(e: GridReadyEvent) => {
|
||||
if (!e?.api) return;
|
||||
onGridReady?.(e);
|
||||
if (autoHeight) e.api.setDomLayout('autoHeight');
|
||||
if (autoFitColumns) e.api?.sizeColumnsToFit?.();
|
||||
},
|
||||
[autoHeight, autoFitColumns, onGridReady],
|
||||
);
|
||||
|
||||
const handleGridSizeChanged = useCallback(
|
||||
(e: GridSizeChangedEvent) => {
|
||||
if (!e?.api) return;
|
||||
onGridSizeChanged?.(e);
|
||||
if (autoFitColumns) e.api?.sizeColumnsToFit?.();
|
||||
},
|
||||
[autoFitColumns, onGridSizeChanged],
|
||||
);
|
||||
const handleGridSizeChanged = useCallback(
|
||||
(e: GridSizeChangedEvent) => {
|
||||
if (!e?.api) return;
|
||||
onGridSizeChanged?.(e);
|
||||
if (autoFitColumns) e.api?.sizeColumnsToFit?.();
|
||||
},
|
||||
[autoFitColumns, onGridSizeChanged],
|
||||
);
|
||||
|
||||
const handleModelUpdated = useCallback(
|
||||
(e: ModelUpdatedEvent) => {
|
||||
if (!e?.api) return;
|
||||
if (autoFitColumns) e.api?.sizeColumnsToFit?.();
|
||||
},
|
||||
[autoFitColumns],
|
||||
);
|
||||
const handleModelUpdated = useCallback(
|
||||
(e: ModelUpdatedEvent) => {
|
||||
if (!e?.api) return;
|
||||
if (autoFitColumns) e.api?.sizeColumnsToFit?.();
|
||||
},
|
||||
[autoFitColumns],
|
||||
);
|
||||
|
||||
return (
|
||||
<TableWrapper
|
||||
ref={deselectRef}
|
||||
className={
|
||||
transparentHeader ? 'ag-header-transparent ag-theme-alpine-dark' : 'ag-theme-alpine-dark'
|
||||
}
|
||||
>
|
||||
<AgGridReact
|
||||
ref={mergedRef}
|
||||
animateRows
|
||||
maintainColumnOrder
|
||||
suppressAsyncEvents
|
||||
suppressContextMenu
|
||||
suppressCopyRowsToClipboard
|
||||
suppressMoveWhenRowDragging
|
||||
suppressPaginationPanel
|
||||
suppressScrollOnNewData
|
||||
blockLoadDebounceMillis={200}
|
||||
cacheBlockSize={300}
|
||||
cacheOverflowSize={1}
|
||||
defaultColDef={defaultColumnDefs}
|
||||
enableCellChangeFlash={false}
|
||||
headerHeight={36}
|
||||
rowBuffer={30}
|
||||
rowSelection="multiple"
|
||||
{...rest}
|
||||
onColumnMoved={handleColumnMoved}
|
||||
onGridReady={handleGridReady}
|
||||
onGridSizeChanged={handleGridSizeChanged}
|
||||
onModelUpdated={handleModelUpdated}
|
||||
onNewColumnsLoaded={handleNewColumnsLoaded}
|
||||
/>
|
||||
</TableWrapper>
|
||||
);
|
||||
},
|
||||
return (
|
||||
<TableWrapper
|
||||
ref={deselectRef}
|
||||
className={
|
||||
transparentHeader
|
||||
? 'ag-header-transparent ag-theme-alpine-dark'
|
||||
: 'ag-theme-alpine-dark'
|
||||
}
|
||||
>
|
||||
<AgGridReact
|
||||
ref={mergedRef}
|
||||
animateRows
|
||||
maintainColumnOrder
|
||||
suppressAsyncEvents
|
||||
suppressContextMenu
|
||||
suppressCopyRowsToClipboard
|
||||
suppressMoveWhenRowDragging
|
||||
suppressPaginationPanel
|
||||
suppressScrollOnNewData
|
||||
blockLoadDebounceMillis={200}
|
||||
cacheBlockSize={300}
|
||||
cacheOverflowSize={1}
|
||||
defaultColDef={defaultColumnDefs}
|
||||
enableCellChangeFlash={false}
|
||||
headerHeight={36}
|
||||
rowBuffer={30}
|
||||
rowSelection="multiple"
|
||||
{...rest}
|
||||
onColumnMoved={handleColumnMoved}
|
||||
onGridReady={handleGridReady}
|
||||
onGridSizeChanged={handleGridSizeChanged}
|
||||
onModelUpdated={handleModelUpdated}
|
||||
onNewColumnsLoaded={handleNewColumnsLoaded}
|
||||
/>
|
||||
</TableWrapper>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,256 +3,256 @@ import { MultiSelect } from '/@/renderer/components/select';
|
|||
import { Slider } from '/@/renderer/components/slider';
|
||||
import { Switch } from '/@/renderer/components/switch';
|
||||
import {
|
||||
useSettingsStoreActions,
|
||||
useSettingsStore,
|
||||
useLyricsSettings,
|
||||
useSettingsStoreActions,
|
||||
useSettingsStore,
|
||||
useLyricsSettings,
|
||||
} from '/@/renderer/store/settings.store';
|
||||
import { TableColumn, TableType } from '/@/renderer/types';
|
||||
import { Option } from '/@/renderer/components/option';
|
||||
import { NumberInput } from '/@/renderer/components/input';
|
||||
|
||||
export const SONG_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: 'Album', value: TableColumn.ALBUM },
|
||||
{ label: 'Album Artist', value: TableColumn.ALBUM_ARTIST },
|
||||
{ label: 'Artist', value: TableColumn.ARTIST },
|
||||
{ label: 'Genre', value: TableColumn.GENRE },
|
||||
{ label: 'Year', value: TableColumn.YEAR },
|
||||
{ label: 'Release Date', value: TableColumn.RELEASE_DATE },
|
||||
{ label: 'Disc Number', value: TableColumn.DISC_NUMBER },
|
||||
{ label: 'Track Number', value: TableColumn.TRACK_NUMBER },
|
||||
{ label: 'Bitrate', value: TableColumn.BIT_RATE },
|
||||
{ label: 'Last Played', value: TableColumn.LAST_PLAYED },
|
||||
{ label: 'Note', value: TableColumn.COMMENT },
|
||||
{ label: 'Channels', value: TableColumn.CHANNELS },
|
||||
{ label: 'BPM', value: TableColumn.BPM },
|
||||
{ label: 'Date Added', value: TableColumn.DATE_ADDED },
|
||||
{ label: 'Path', value: TableColumn.PATH },
|
||||
{ label: 'Plays', value: TableColumn.PLAY_COUNT },
|
||||
{ label: 'Size', value: TableColumn.SIZE },
|
||||
{ label: 'Favorite', value: TableColumn.USER_FAVORITE },
|
||||
{ label: 'Rating', value: TableColumn.USER_RATING },
|
||||
// { label: 'Skip', value: TableColumn.SKIP },
|
||||
{ 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: 'Album', value: TableColumn.ALBUM },
|
||||
{ label: 'Album Artist', value: TableColumn.ALBUM_ARTIST },
|
||||
{ label: 'Artist', value: TableColumn.ARTIST },
|
||||
{ label: 'Genre', value: TableColumn.GENRE },
|
||||
{ label: 'Year', value: TableColumn.YEAR },
|
||||
{ label: 'Release Date', value: TableColumn.RELEASE_DATE },
|
||||
{ label: 'Disc Number', value: TableColumn.DISC_NUMBER },
|
||||
{ label: 'Track Number', value: TableColumn.TRACK_NUMBER },
|
||||
{ label: 'Bitrate', value: TableColumn.BIT_RATE },
|
||||
{ label: 'Last Played', value: TableColumn.LAST_PLAYED },
|
||||
{ label: 'Note', value: TableColumn.COMMENT },
|
||||
{ label: 'Channels', value: TableColumn.CHANNELS },
|
||||
{ label: 'BPM', value: TableColumn.BPM },
|
||||
{ label: 'Date Added', value: TableColumn.DATE_ADDED },
|
||||
{ label: 'Path', value: TableColumn.PATH },
|
||||
{ label: 'Plays', value: TableColumn.PLAY_COUNT },
|
||||
{ label: 'Size', value: TableColumn.SIZE },
|
||||
{ label: 'Favorite', value: TableColumn.USER_FAVORITE },
|
||||
{ label: 'Rating', value: TableColumn.USER_RATING },
|
||||
// { label: 'Skip', value: TableColumn.SKIP },
|
||||
];
|
||||
|
||||
export const ALBUM_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: 'Album Artist', value: TableColumn.ALBUM_ARTIST },
|
||||
{ label: 'Artist', value: TableColumn.ARTIST },
|
||||
{ label: 'Genre', value: TableColumn.GENRE },
|
||||
{ label: 'Year', value: TableColumn.YEAR },
|
||||
{ label: 'Release Date', value: TableColumn.RELEASE_DATE },
|
||||
{ label: 'Last Played', value: TableColumn.LAST_PLAYED },
|
||||
{ label: 'Date Added', value: TableColumn.DATE_ADDED },
|
||||
{ label: 'Plays', value: TableColumn.PLAY_COUNT },
|
||||
{ label: 'Favorite', value: TableColumn.USER_FAVORITE },
|
||||
{ label: 'Rating', value: TableColumn.USER_RATING },
|
||||
{ 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: 'Album Artist', value: TableColumn.ALBUM_ARTIST },
|
||||
{ label: 'Artist', value: TableColumn.ARTIST },
|
||||
{ label: 'Genre', value: TableColumn.GENRE },
|
||||
{ label: 'Year', value: TableColumn.YEAR },
|
||||
{ label: 'Release Date', value: TableColumn.RELEASE_DATE },
|
||||
{ label: 'Last Played', value: TableColumn.LAST_PLAYED },
|
||||
{ label: 'Date Added', value: TableColumn.DATE_ADDED },
|
||||
{ label: 'Plays', value: TableColumn.PLAY_COUNT },
|
||||
{ label: 'Favorite', value: TableColumn.USER_FAVORITE },
|
||||
{ label: 'Rating', value: TableColumn.USER_RATING },
|
||||
];
|
||||
|
||||
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 },
|
||||
{ label: 'Favorite', value: TableColumn.USER_FAVORITE },
|
||||
{ label: 'Rating', value: TableColumn.USER_RATING },
|
||||
{ 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 },
|
||||
{ label: 'Favorite', value: TableColumn.USER_FAVORITE },
|
||||
{ label: 'Rating', value: TableColumn.USER_RATING },
|
||||
];
|
||||
|
||||
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 },
|
||||
{ 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;
|
||||
type: TableType;
|
||||
}
|
||||
|
||||
export const TableConfigDropdown = ({ type }: TableConfigDropdownProps) => {
|
||||
const { setSettings } = useSettingsStoreActions();
|
||||
const tableConfig = useSettingsStore((state) => state.tables);
|
||||
const lyricConfig = useLyricsSettings();
|
||||
const { setSettings } = useSettingsStoreActions();
|
||||
const tableConfig = useSettingsStore((state) => state.tables);
|
||||
const lyricConfig = useLyricsSettings();
|
||||
|
||||
const handleAddOrRemoveColumns = (values: TableColumn[]) => {
|
||||
const existingColumns = tableConfig[type].columns;
|
||||
const handleAddOrRemoveColumns = (values: TableColumn[]) => {
|
||||
const existingColumns = tableConfig[type].columns;
|
||||
|
||||
if (values.length === 0) {
|
||||
setSettings({
|
||||
tables: {
|
||||
...useSettingsStore.getState().tables,
|
||||
[type]: {
|
||||
...useSettingsStore.getState().tables[type],
|
||||
columns: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (values.length === 0) {
|
||||
setSettings({
|
||||
tables: {
|
||||
...useSettingsStore.getState().tables,
|
||||
[type]: {
|
||||
...useSettingsStore.getState().tables[type],
|
||||
columns: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// If adding a column
|
||||
if (values.length > existingColumns.length) {
|
||||
const newColumn = { column: values[values.length - 1] };
|
||||
setSettings({
|
||||
tables: {
|
||||
...useSettingsStore.getState().tables,
|
||||
[type]: {
|
||||
...useSettingsStore.getState().tables[type],
|
||||
columns: [...existingColumns, newColumn],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
// If removing a column
|
||||
else {
|
||||
const removed = existingColumns.filter((column) => !values.includes(column.column));
|
||||
// If adding a column
|
||||
if (values.length > existingColumns.length) {
|
||||
const newColumn = { column: values[values.length - 1] };
|
||||
setSettings({
|
||||
tables: {
|
||||
...useSettingsStore.getState().tables,
|
||||
[type]: {
|
||||
...useSettingsStore.getState().tables[type],
|
||||
columns: [...existingColumns, newColumn],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
// If removing a column
|
||||
else {
|
||||
const removed = existingColumns.filter((column) => !values.includes(column.column));
|
||||
|
||||
const newColumns = existingColumns.filter((column) => !removed.includes(column));
|
||||
const newColumns = existingColumns.filter((column) => !removed.includes(column));
|
||||
|
||||
setSettings({
|
||||
tables: {
|
||||
...useSettingsStore.getState().tables,
|
||||
[type]: {
|
||||
...useSettingsStore.getState().tables[type],
|
||||
columns: newColumns,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
setSettings({
|
||||
tables: {
|
||||
...useSettingsStore.getState().tables,
|
||||
[type]: {
|
||||
...useSettingsStore.getState().tables[type],
|
||||
columns: newColumns,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateRowHeight = (value: number) => {
|
||||
setSettings({
|
||||
tables: {
|
||||
...useSettingsStore.getState().tables,
|
||||
[type]: {
|
||||
...useSettingsStore.getState().tables[type],
|
||||
rowHeight: value,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
const handleUpdateRowHeight = (value: number) => {
|
||||
setSettings({
|
||||
tables: {
|
||||
...useSettingsStore.getState().tables,
|
||||
[type]: {
|
||||
...useSettingsStore.getState().tables[type],
|
||||
rowHeight: value,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateAutoFit = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setSettings({
|
||||
tables: {
|
||||
...useSettingsStore.getState().tables,
|
||||
[type]: {
|
||||
...useSettingsStore.getState().tables[type],
|
||||
autoFit: e.currentTarget.checked,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
const handleUpdateAutoFit = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setSettings({
|
||||
tables: {
|
||||
...useSettingsStore.getState().tables,
|
||||
[type]: {
|
||||
...useSettingsStore.getState().tables[type],
|
||||
autoFit: e.currentTarget.checked,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateFollow = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setSettings({
|
||||
tables: {
|
||||
...useSettingsStore.getState().tables,
|
||||
[type]: {
|
||||
...useSettingsStore.getState().tables[type],
|
||||
followCurrentSong: e.currentTarget.checked,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
const handleUpdateFollow = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setSettings({
|
||||
tables: {
|
||||
...useSettingsStore.getState().tables,
|
||||
[type]: {
|
||||
...useSettingsStore.getState().tables[type],
|
||||
followCurrentSong: e.currentTarget.checked,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleLyricFollow = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setSettings({
|
||||
lyrics: {
|
||||
...useSettingsStore.getState().lyrics,
|
||||
follow: e.currentTarget.checked,
|
||||
},
|
||||
});
|
||||
};
|
||||
const handleLyricFollow = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setSettings({
|
||||
lyrics: {
|
||||
...useSettingsStore.getState().lyrics,
|
||||
follow: e.currentTarget.checked,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleLyricOffset = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setSettings({
|
||||
lyrics: {
|
||||
...useSettingsStore.getState().lyrics,
|
||||
delayMs: Number(e.currentTarget.value),
|
||||
},
|
||||
});
|
||||
};
|
||||
const handleLyricOffset = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setSettings({
|
||||
lyrics: {
|
||||
...useSettingsStore.getState().lyrics,
|
||||
delayMs: Number(e.currentTarget.value),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Option>
|
||||
<Option.Label>Auto-fit Columns</Option.Label>
|
||||
<Option.Control>
|
||||
<Switch
|
||||
defaultChecked={tableConfig[type]?.autoFit}
|
||||
onChange={handleUpdateAutoFit}
|
||||
/>
|
||||
</Option.Control>
|
||||
</Option>
|
||||
<Option>
|
||||
<Option.Label>Follow current song</Option.Label>
|
||||
<Option.Control>
|
||||
<Switch
|
||||
defaultChecked={tableConfig[type]?.followCurrentSong}
|
||||
onChange={handleUpdateFollow}
|
||||
/>
|
||||
</Option.Control>
|
||||
</Option>
|
||||
<Option>
|
||||
<Option.Label>Follow current lyrics</Option.Label>
|
||||
<Option.Control>
|
||||
<Switch
|
||||
defaultChecked={lyricConfig.follow}
|
||||
onChange={handleLyricFollow}
|
||||
/>
|
||||
</Option.Control>
|
||||
</Option>
|
||||
<Option>
|
||||
<Option.Label>Lyric offset (ms)</Option.Label>
|
||||
<Option.Control>
|
||||
<NumberInput
|
||||
defaultValue={lyricConfig.delayMs}
|
||||
step={10}
|
||||
onBlur={handleLyricOffset}
|
||||
/>
|
||||
</Option.Control>
|
||||
</Option>
|
||||
<Option>
|
||||
<Option.Control>
|
||||
<Slider
|
||||
defaultValue={tableConfig[type]?.rowHeight}
|
||||
label={(value) => `Item size: ${value}`}
|
||||
max={100}
|
||||
min={25}
|
||||
w="100%"
|
||||
onChangeEnd={handleUpdateRowHeight}
|
||||
/>
|
||||
</Option.Control>
|
||||
</Option>
|
||||
<Option>
|
||||
<Option.Control>
|
||||
<MultiSelect
|
||||
clearable
|
||||
data={SONG_TABLE_COLUMNS}
|
||||
defaultValue={tableConfig[type]?.columns.map((column) => column.column)}
|
||||
dropdownPosition="bottom"
|
||||
width={300}
|
||||
onChange={handleAddOrRemoveColumns}
|
||||
/>
|
||||
</Option.Control>
|
||||
</Option>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Option>
|
||||
<Option.Label>Auto-fit Columns</Option.Label>
|
||||
<Option.Control>
|
||||
<Switch
|
||||
defaultChecked={tableConfig[type]?.autoFit}
|
||||
onChange={handleUpdateAutoFit}
|
||||
/>
|
||||
</Option.Control>
|
||||
</Option>
|
||||
<Option>
|
||||
<Option.Label>Follow current song</Option.Label>
|
||||
<Option.Control>
|
||||
<Switch
|
||||
defaultChecked={tableConfig[type]?.followCurrentSong}
|
||||
onChange={handleUpdateFollow}
|
||||
/>
|
||||
</Option.Control>
|
||||
</Option>
|
||||
<Option>
|
||||
<Option.Label>Follow current lyrics</Option.Label>
|
||||
<Option.Control>
|
||||
<Switch
|
||||
defaultChecked={lyricConfig.follow}
|
||||
onChange={handleLyricFollow}
|
||||
/>
|
||||
</Option.Control>
|
||||
</Option>
|
||||
<Option>
|
||||
<Option.Label>Lyric offset (ms)</Option.Label>
|
||||
<Option.Control>
|
||||
<NumberInput
|
||||
defaultValue={lyricConfig.delayMs}
|
||||
step={10}
|
||||
onBlur={handleLyricOffset}
|
||||
/>
|
||||
</Option.Control>
|
||||
</Option>
|
||||
<Option>
|
||||
<Option.Control>
|
||||
<Slider
|
||||
defaultValue={tableConfig[type]?.rowHeight}
|
||||
label={(value) => `Item size: ${value}`}
|
||||
max={100}
|
||||
min={25}
|
||||
w="100%"
|
||||
onChangeEnd={handleUpdateRowHeight}
|
||||
/>
|
||||
</Option.Control>
|
||||
</Option>
|
||||
<Option>
|
||||
<Option.Control>
|
||||
<MultiSelect
|
||||
clearable
|
||||
data={SONG_TABLE_COLUMNS}
|
||||
defaultValue={tableConfig[type]?.columns.map((column) => column.column)}
|
||||
dropdownPosition="bottom"
|
||||
width={300}
|
||||
onChange={handleAddOrRemoveColumns}
|
||||
/>
|
||||
</Option.Control>
|
||||
</Option>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -15,140 +15,144 @@ import { TablePagination as TablePaginationType } from '/@/renderer/types';
|
|||
import { ListKey } from '/@/renderer/store';
|
||||
|
||||
interface TablePaginationProps {
|
||||
pageKey: ListKey;
|
||||
pagination: TablePaginationType;
|
||||
setIdPagination?: (id: string, pagination: Partial<TablePaginationType>) => void;
|
||||
setPagination?: (args: { data: Partial<TablePaginationType>; key: ListKey }) => void;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
pageKey: ListKey;
|
||||
pagination: TablePaginationType;
|
||||
setIdPagination?: (id: string, pagination: Partial<TablePaginationType>) => void;
|
||||
setPagination?: (args: { data: Partial<TablePaginationType>; key: ListKey }) => void;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const TablePagination = ({
|
||||
pageKey,
|
||||
tableRef,
|
||||
pagination,
|
||||
setPagination,
|
||||
setIdPagination,
|
||||
pageKey,
|
||||
tableRef,
|
||||
pagination,
|
||||
setPagination,
|
||||
setIdPagination,
|
||||
}: TablePaginationProps) => {
|
||||
const [isGoToPageOpen, handlers] = useDisclosure(false);
|
||||
const containerQuery = useContainerQuery();
|
||||
const [isGoToPageOpen, handlers] = useDisclosure(false);
|
||||
const containerQuery = useContainerQuery();
|
||||
|
||||
const goToForm = useForm({
|
||||
initialValues: {
|
||||
pageNumber: undefined,
|
||||
},
|
||||
});
|
||||
const goToForm = useForm({
|
||||
initialValues: {
|
||||
pageNumber: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const handlePagination = (index: number) => {
|
||||
const newPage = index - 1;
|
||||
tableRef.current?.api.paginationGoToPage(newPage);
|
||||
setPagination?.({ data: { currentPage: newPage }, key: pageKey });
|
||||
setIdPagination?.(pageKey || '', { currentPage: newPage });
|
||||
};
|
||||
const handlePagination = (index: number) => {
|
||||
const newPage = index - 1;
|
||||
tableRef.current?.api.paginationGoToPage(newPage);
|
||||
setPagination?.({ data: { currentPage: newPage }, key: pageKey });
|
||||
setIdPagination?.(pageKey || '', { currentPage: newPage });
|
||||
};
|
||||
|
||||
const handleGoSubmit = goToForm.onSubmit((values) => {
|
||||
handlers.close();
|
||||
if (!values.pageNumber || values.pageNumber < 1 || values.pageNumber > pagination.totalPages) {
|
||||
return;
|
||||
}
|
||||
const handleGoSubmit = goToForm.onSubmit((values) => {
|
||||
handlers.close();
|
||||
if (
|
||||
!values.pageNumber ||
|
||||
values.pageNumber < 1 ||
|
||||
values.pageNumber > pagination.totalPages
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newPage = values.pageNumber - 1;
|
||||
tableRef.current?.api.paginationGoToPage(newPage);
|
||||
setPagination?.({ data: { currentPage: newPage }, key: pageKey });
|
||||
setIdPagination?.(pageKey || '', { currentPage: newPage });
|
||||
});
|
||||
const newPage = values.pageNumber - 1;
|
||||
tableRef.current?.api.paginationGoToPage(newPage);
|
||||
setPagination?.({ data: { currentPage: newPage }, key: pageKey });
|
||||
setIdPagination?.(pageKey || '', { currentPage: newPage });
|
||||
});
|
||||
|
||||
const currentPageStartIndex = pagination.currentPage * pagination.itemsPerPage + 1;
|
||||
const currentPageMaxIndex = (pagination.currentPage + 1) * pagination.itemsPerPage;
|
||||
const currentPageStopIndex =
|
||||
currentPageMaxIndex > pagination.totalItems ? pagination.totalItems : currentPageMaxIndex;
|
||||
const currentPageStartIndex = pagination.currentPage * pagination.itemsPerPage + 1;
|
||||
const currentPageMaxIndex = (pagination.currentPage + 1) * pagination.itemsPerPage;
|
||||
const currentPageStopIndex =
|
||||
currentPageMaxIndex > pagination.totalItems ? pagination.totalItems : currentPageMaxIndex;
|
||||
|
||||
return (
|
||||
<MotionFlex
|
||||
ref={containerQuery.ref}
|
||||
layout
|
||||
align="center"
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: 50 }}
|
||||
initial={{ y: 50 }}
|
||||
justify="space-between"
|
||||
p="1rem"
|
||||
sx={{ borderTop: '1px solid var(--generic-border-color)' }}
|
||||
>
|
||||
<Text
|
||||
$secondary
|
||||
size="md"
|
||||
>
|
||||
{containerQuery.isMd ? (
|
||||
<>
|
||||
Showing <b>{currentPageStartIndex}</b> - <b>{currentPageStopIndex}</b> of{' '}
|
||||
<b>{pagination.totalItems}</b> items
|
||||
</>
|
||||
) : containerQuery.isSm ? (
|
||||
<>
|
||||
<b>{currentPageStartIndex}</b> - <b>{currentPageStopIndex}</b> of{' '}
|
||||
<b>{pagination.totalItems}</b> items
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<b>{currentPageStartIndex}</b> - <b>{currentPageStopIndex}</b> of{' '}
|
||||
<b>{pagination.totalItems}</b>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
<Group
|
||||
ref={containerQuery.ref}
|
||||
noWrap
|
||||
spacing="sm"
|
||||
>
|
||||
<Popover
|
||||
trapFocus
|
||||
opened={isGoToPageOpen}
|
||||
position="bottom-start"
|
||||
onClose={() => handlers.close()}
|
||||
return (
|
||||
<MotionFlex
|
||||
ref={containerQuery.ref}
|
||||
layout
|
||||
align="center"
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: 50 }}
|
||||
initial={{ y: 50 }}
|
||||
justify="space-between"
|
||||
p="1rem"
|
||||
sx={{ borderTop: '1px solid var(--generic-border-color)' }}
|
||||
>
|
||||
<Popover.Target>
|
||||
<Button
|
||||
radius="sm"
|
||||
size="sm"
|
||||
sx={{ height: '26px', padding: '0', width: '26px' }}
|
||||
tooltip={{ label: 'Go to page' }}
|
||||
variant="default"
|
||||
onClick={() => handlers.toggle()}
|
||||
<Text
|
||||
$secondary
|
||||
size="md"
|
||||
>
|
||||
<RiHashtag size={15} />
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<form onSubmit={handleGoSubmit}>
|
||||
<Group>
|
||||
<NumberInput
|
||||
{...goToForm.getInputProps('pageNumber')}
|
||||
hideControls={false}
|
||||
max={pagination.totalPages}
|
||||
min={1}
|
||||
width={70}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="filled"
|
||||
{containerQuery.isMd ? (
|
||||
<>
|
||||
Showing <b>{currentPageStartIndex}</b> - <b>{currentPageStopIndex}</b> of{' '}
|
||||
<b>{pagination.totalItems}</b> items
|
||||
</>
|
||||
) : containerQuery.isSm ? (
|
||||
<>
|
||||
<b>{currentPageStartIndex}</b> - <b>{currentPageStopIndex}</b> of{' '}
|
||||
<b>{pagination.totalItems}</b> items
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<b>{currentPageStartIndex}</b> - <b>{currentPageStopIndex}</b> of{' '}
|
||||
<b>{pagination.totalItems}</b>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
<Group
|
||||
ref={containerQuery.ref}
|
||||
noWrap
|
||||
spacing="sm"
|
||||
>
|
||||
<Popover
|
||||
trapFocus
|
||||
opened={isGoToPageOpen}
|
||||
position="bottom-start"
|
||||
onClose={() => handlers.close()}
|
||||
>
|
||||
Go
|
||||
</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
<Pagination
|
||||
noWrap
|
||||
$hideDividers={!containerQuery.isSm}
|
||||
boundaries={1}
|
||||
radius="sm"
|
||||
siblings={containerQuery.isMd ? 2 : containerQuery.isSm ? 1 : 0}
|
||||
total={pagination.totalPages - 1}
|
||||
value={pagination.currentPage + 1}
|
||||
onChange={handlePagination}
|
||||
/>
|
||||
</Group>
|
||||
</MotionFlex>
|
||||
);
|
||||
<Popover.Target>
|
||||
<Button
|
||||
radius="sm"
|
||||
size="sm"
|
||||
sx={{ height: '26px', padding: '0', width: '26px' }}
|
||||
tooltip={{ label: 'Go to page' }}
|
||||
variant="default"
|
||||
onClick={() => handlers.toggle()}
|
||||
>
|
||||
<RiHashtag size={15} />
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<form onSubmit={handleGoSubmit}>
|
||||
<Group>
|
||||
<NumberInput
|
||||
{...goToForm.getInputProps('pageNumber')}
|
||||
hideControls={false}
|
||||
max={pagination.totalPages}
|
||||
min={1}
|
||||
width={70}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="filled"
|
||||
>
|
||||
Go
|
||||
</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
<Pagination
|
||||
noWrap
|
||||
$hideDividers={!containerQuery.isSm}
|
||||
boundaries={1}
|
||||
radius="sm"
|
||||
siblings={containerQuery.isMd ? 2 : containerQuery.isSm ? 1 : 0}
|
||||
total={pagination.totalPages - 1}
|
||||
value={pagination.currentPage + 1}
|
||||
onChange={handlePagination}
|
||||
/>
|
||||
</Group>
|
||||
</MotionFlex>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,36 +1,36 @@
|
|||
import { GridApi, RowNode } from '@ag-grid-community/core';
|
||||
|
||||
export const getNodesByDiscNumber = (args: { api: GridApi; discNumber: number }) => {
|
||||
const { api, discNumber } = args;
|
||||
const { api, discNumber } = args;
|
||||
|
||||
const nodes: RowNode<any>[] = [];
|
||||
api.forEachNode((node) => {
|
||||
if (node.data.discNumber === discNumber) nodes.push(node);
|
||||
});
|
||||
const nodes: RowNode<any>[] = [];
|
||||
api.forEachNode((node) => {
|
||||
if (node.data.discNumber === discNumber) nodes.push(node);
|
||||
});
|
||||
|
||||
return nodes;
|
||||
return nodes;
|
||||
};
|
||||
|
||||
export const setNodeSelection = (args: {
|
||||
deselectAll?: boolean;
|
||||
isSelected: boolean;
|
||||
nodes: RowNode<any>[];
|
||||
deselectAll?: boolean;
|
||||
isSelected: boolean;
|
||||
nodes: RowNode<any>[];
|
||||
}) => {
|
||||
const { nodes, isSelected } = args;
|
||||
const { nodes, isSelected } = args;
|
||||
|
||||
nodes.forEach((node) => {
|
||||
node.setSelected(isSelected);
|
||||
});
|
||||
nodes.forEach((node) => {
|
||||
node.setSelected(isSelected);
|
||||
});
|
||||
};
|
||||
|
||||
export const toggleNodeSelection = (args: { nodes: RowNode<any>[] }) => {
|
||||
const { nodes } = args;
|
||||
const { nodes } = args;
|
||||
|
||||
nodes.forEach((node) => {
|
||||
if (node.isSelected()) {
|
||||
node.setSelected(false);
|
||||
} else {
|
||||
node.setSelected(true);
|
||||
}
|
||||
});
|
||||
nodes.forEach((node) => {
|
||||
if (node.isSelected()) {
|
||||
node.setSelected(false);
|
||||
} else {
|
||||
node.setSelected(true);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,24 +3,24 @@ import { RiAlertFill } from 'react-icons/ri';
|
|||
import { Text } from '/@/renderer/components';
|
||||
|
||||
interface ActionRequiredContainerProps {
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export const ActionRequiredContainer = ({ title, children }: ActionRequiredContainerProps) => (
|
||||
<Stack sx={{ cursor: 'default', maxWidth: '700px' }}>
|
||||
<Group>
|
||||
<RiAlertFill
|
||||
color="var(--warning-color)"
|
||||
size={30}
|
||||
/>
|
||||
<Text
|
||||
size="xl"
|
||||
sx={{ textTransform: 'uppercase' }}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
</Group>
|
||||
<Stack>{children}</Stack>
|
||||
</Stack>
|
||||
<Stack sx={{ cursor: 'default', maxWidth: '700px' }}>
|
||||
<Group>
|
||||
<RiAlertFill
|
||||
color="var(--warning-color)"
|
||||
size={30}
|
||||
/>
|
||||
<Text
|
||||
size="xl"
|
||||
sx={{ textTransform: 'uppercase' }}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
</Group>
|
||||
<Stack>{children}</Stack>
|
||||
</Stack>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,32 +6,32 @@ import styled from 'styled-components';
|
|||
import { Button, Text } from '/@/renderer/components';
|
||||
|
||||
const Container = styled(Box)`
|
||||
background: var(--main-bg);
|
||||
background: var(--main-bg);
|
||||
`;
|
||||
|
||||
export const ErrorFallback = ({ resetErrorBoundary }: FallbackProps) => {
|
||||
const error = useRouteError() as any;
|
||||
const error = useRouteError() as any;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Center sx={{ height: '100vh' }}>
|
||||
<Stack sx={{ maxWidth: '50%' }}>
|
||||
<Group spacing="xs">
|
||||
<RiErrorWarningLine
|
||||
color="var(--danger-color)"
|
||||
size={30}
|
||||
/>
|
||||
<Text size="lg">Something went wrong</Text>
|
||||
</Group>
|
||||
<Text>{error.message}</Text>
|
||||
<Button
|
||||
variant="filled"
|
||||
onClick={resetErrorBoundary}
|
||||
>
|
||||
Reload
|
||||
</Button>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Container>
|
||||
);
|
||||
return (
|
||||
<Container>
|
||||
<Center sx={{ height: '100vh' }}>
|
||||
<Stack sx={{ maxWidth: '50%' }}>
|
||||
<Group spacing="xs">
|
||||
<RiErrorWarningLine
|
||||
color="var(--danger-color)"
|
||||
size={30}
|
||||
/>
|
||||
<Text size="lg">Something went wrong</Text>
|
||||
</Group>
|
||||
<Text>{error.message}</Text>
|
||||
<Button
|
||||
variant="filled"
|
||||
onClick={resetErrorBoundary}
|
||||
>
|
||||
Reload
|
||||
</Button>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,39 +5,39 @@ import { FileInput, Text, Button } from '/@/renderer/components';
|
|||
const localSettings = isElectron() ? window.electron.localSettings : null;
|
||||
|
||||
export const MpvRequired = () => {
|
||||
const [mpvPath, setMpvPath] = useState('');
|
||||
const handleSetMpvPath = (e: File) => {
|
||||
localSettings.set('mpv_path', e.path);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const getMpvPath = async () => {
|
||||
if (!isElectron()) return setMpvPath('');
|
||||
const mpvPath = localSettings.get('mpv_path') as string;
|
||||
return setMpvPath(mpvPath);
|
||||
const [mpvPath, setMpvPath] = useState('');
|
||||
const handleSetMpvPath = (e: File) => {
|
||||
localSettings.set('mpv_path', e.path);
|
||||
};
|
||||
|
||||
getMpvPath();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const getMpvPath = async () => {
|
||||
if (!isElectron()) return setMpvPath('');
|
||||
const mpvPath = localSettings.get('mpv_path') as string;
|
||||
return setMpvPath(mpvPath);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text>Set your MPV executable location below and restart the application.</Text>
|
||||
<Text>
|
||||
MPV is available at the following:{' '}
|
||||
<a
|
||||
href="https://mpv.io/installation/"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
https://mpv.io/
|
||||
</a>
|
||||
</Text>
|
||||
<FileInput
|
||||
placeholder={mpvPath}
|
||||
onChange={handleSetMpvPath}
|
||||
/>
|
||||
<Button onClick={() => localSettings.restart()}>Restart</Button>
|
||||
</>
|
||||
);
|
||||
getMpvPath();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text>Set your MPV executable location below and restart the application.</Text>
|
||||
<Text>
|
||||
MPV is available at the following:{' '}
|
||||
<a
|
||||
href="https://mpv.io/installation/"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
https://mpv.io/
|
||||
</a>
|
||||
</Text>
|
||||
<FileInput
|
||||
placeholder={mpvPath}
|
||||
onChange={handleSetMpvPath}
|
||||
/>
|
||||
<Button onClick={() => localSettings.restart()}>Restart</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,84 +6,84 @@ import { AppMenu } from '/@/renderer/features/titlebar/components/app-menu';
|
|||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
|
||||
const RouteErrorBoundary = () => {
|
||||
const navigate = useNavigate();
|
||||
const error = useRouteError() as any;
|
||||
console.log('error', error);
|
||||
const navigate = useNavigate();
|
||||
const error = useRouteError() as any;
|
||||
console.log('error', error);
|
||||
|
||||
const handleReload = () => {
|
||||
navigate(0);
|
||||
};
|
||||
const handleReload = () => {
|
||||
navigate(0);
|
||||
};
|
||||
|
||||
const handleReturn = () => {
|
||||
navigate(-1);
|
||||
};
|
||||
const handleReturn = () => {
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
const handleHome = () => {
|
||||
navigate(AppRoute.HOME);
|
||||
};
|
||||
const handleHome = () => {
|
||||
navigate(AppRoute.HOME);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box bg="var(--main-bg)">
|
||||
<Center sx={{ height: '100vh' }}>
|
||||
<Stack sx={{ maxWidth: '50%' }}>
|
||||
<Group>
|
||||
<Button
|
||||
px={10}
|
||||
variant="subtle"
|
||||
onClick={handleReturn}
|
||||
>
|
||||
<RiArrowLeftSLine size={20} />
|
||||
</Button>
|
||||
<RiErrorWarningLine
|
||||
color="var(--danger-color)"
|
||||
size={30}
|
||||
/>
|
||||
<Text size="lg">Something went wrong</Text>
|
||||
</Group>
|
||||
<Divider my={5} />
|
||||
<Text size="sm">{error?.message}</Text>
|
||||
<Group
|
||||
grow
|
||||
spacing="sm"
|
||||
>
|
||||
<Button
|
||||
leftIcon={<RiHome4Line />}
|
||||
size="md"
|
||||
sx={{ flex: 0.5 }}
|
||||
variant="default"
|
||||
onClick={handleHome}
|
||||
>
|
||||
Go home
|
||||
</Button>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
leftIcon={<RiMenuFill />}
|
||||
size="md"
|
||||
sx={{ flex: 0.5 }}
|
||||
variant="default"
|
||||
>
|
||||
Menu
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<AppMenu />
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<Button
|
||||
size="md"
|
||||
variant="filled"
|
||||
onClick={handleReload}
|
||||
>
|
||||
Reload
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
return (
|
||||
<Box bg="var(--main-bg)">
|
||||
<Center sx={{ height: '100vh' }}>
|
||||
<Stack sx={{ maxWidth: '50%' }}>
|
||||
<Group>
|
||||
<Button
|
||||
px={10}
|
||||
variant="subtle"
|
||||
onClick={handleReturn}
|
||||
>
|
||||
<RiArrowLeftSLine size={20} />
|
||||
</Button>
|
||||
<RiErrorWarningLine
|
||||
color="var(--danger-color)"
|
||||
size={30}
|
||||
/>
|
||||
<Text size="lg">Something went wrong</Text>
|
||||
</Group>
|
||||
<Divider my={5} />
|
||||
<Text size="sm">{error?.message}</Text>
|
||||
<Group
|
||||
grow
|
||||
spacing="sm"
|
||||
>
|
||||
<Button
|
||||
leftIcon={<RiHome4Line />}
|
||||
size="md"
|
||||
sx={{ flex: 0.5 }}
|
||||
variant="default"
|
||||
onClick={handleHome}
|
||||
>
|
||||
Go home
|
||||
</Button>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
leftIcon={<RiMenuFill />}
|
||||
size="md"
|
||||
sx={{ flex: 0.5 }}
|
||||
variant="default"
|
||||
>
|
||||
Menu
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<AppMenu />
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<Button
|
||||
size="md"
|
||||
variant="filled"
|
||||
onClick={handleReload}
|
||||
>
|
||||
Reload
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default RouteErrorBoundary;
|
||||
|
|
|
|||
|
|
@ -2,17 +2,18 @@ import { Text } from '/@/renderer/components';
|
|||
import { useCurrentServer } from '/@/renderer/store';
|
||||
|
||||
export const ServerCredentialRequired = () => {
|
||||
const currentServer = useCurrentServer();
|
||||
const currentServer = useCurrentServer();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text>
|
||||
The selected server '{currentServer?.name}' requires an additional login to
|
||||
access.
|
||||
</Text>
|
||||
<Text>
|
||||
Add your credentials in the 'manage servers' menu or switch to a different server.
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Text>
|
||||
The selected server '{currentServer?.name}' requires an additional login
|
||||
to access.
|
||||
</Text>
|
||||
<Text>
|
||||
Add your credentials in the 'manage servers' menu or switch to a different
|
||||
server.
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,22 +3,22 @@ import { Button, DropdownMenu, Text } from '/@/renderer/components';
|
|||
import { AppMenu } from '/@/renderer/features/titlebar/components/app-menu';
|
||||
|
||||
export const ServerRequired = () => {
|
||||
return (
|
||||
<>
|
||||
<Text>No server selected.</Text>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
leftIcon={<RiMenuFill />}
|
||||
variant="filled"
|
||||
>
|
||||
Open menu
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<AppMenu />
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Text>No server selected.</Text>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
leftIcon={<RiMenuFill />}
|
||||
variant="filled"
|
||||
>
|
||||
Open menu
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<AppMenu />
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -15,91 +15,91 @@ import { useCurrentServer } from '/@/renderer/store';
|
|||
const localSettings = isElectron() ? window.electron.localSettings : null;
|
||||
|
||||
const ActionRequiredRoute = () => {
|
||||
const currentServer = useCurrentServer();
|
||||
const [isMpvRequired, setIsMpvRequired] = useState(false);
|
||||
const isServerRequired = !currentServer;
|
||||
const isCredentialRequired = false;
|
||||
const currentServer = useCurrentServer();
|
||||
const [isMpvRequired, setIsMpvRequired] = useState(false);
|
||||
const isServerRequired = !currentServer;
|
||||
const isCredentialRequired = false;
|
||||
|
||||
useEffect(() => {
|
||||
const getMpvPath = async () => {
|
||||
if (!isElectron()) return setIsMpvRequired(false);
|
||||
const mpvPath = await localSettings.get('mpv_path');
|
||||
useEffect(() => {
|
||||
const getMpvPath = async () => {
|
||||
if (!isElectron()) return setIsMpvRequired(false);
|
||||
const mpvPath = await localSettings.get('mpv_path');
|
||||
|
||||
if (mpvPath) {
|
||||
return setIsMpvRequired(false);
|
||||
}
|
||||
if (mpvPath) {
|
||||
return setIsMpvRequired(false);
|
||||
}
|
||||
|
||||
return setIsMpvRequired(true);
|
||||
};
|
||||
return setIsMpvRequired(true);
|
||||
};
|
||||
|
||||
getMpvPath();
|
||||
}, []);
|
||||
getMpvPath();
|
||||
}, []);
|
||||
|
||||
const checks = [
|
||||
{
|
||||
component: <MpvRequired />,
|
||||
title: 'MPV required',
|
||||
valid: !isMpvRequired,
|
||||
},
|
||||
{
|
||||
component: <ServerCredentialRequired />,
|
||||
title: 'Credentials required',
|
||||
valid: !isCredentialRequired,
|
||||
},
|
||||
{
|
||||
component: <ServerRequired />,
|
||||
title: 'Server required',
|
||||
valid: !isServerRequired,
|
||||
},
|
||||
];
|
||||
const checks = [
|
||||
{
|
||||
component: <MpvRequired />,
|
||||
title: 'MPV required',
|
||||
valid: !isMpvRequired,
|
||||
},
|
||||
{
|
||||
component: <ServerCredentialRequired />,
|
||||
title: 'Credentials required',
|
||||
valid: !isCredentialRequired,
|
||||
},
|
||||
{
|
||||
component: <ServerRequired />,
|
||||
title: 'Server required',
|
||||
valid: !isServerRequired,
|
||||
},
|
||||
];
|
||||
|
||||
const canReturnHome = checks.every((c) => c.valid);
|
||||
const displayedCheck = checks.find((c) => !c.valid);
|
||||
const canReturnHome = checks.every((c) => c.valid);
|
||||
const displayedCheck = checks.find((c) => !c.valid);
|
||||
|
||||
return (
|
||||
<AnimatedPage>
|
||||
<PageHeader />
|
||||
<Center sx={{ height: '100%', width: '100vw' }}>
|
||||
<Stack
|
||||
spacing="xl"
|
||||
sx={{ maxWidth: '50%' }}
|
||||
>
|
||||
<Group noWrap>
|
||||
{displayedCheck && (
|
||||
<ActionRequiredContainer title={displayedCheck.title}>
|
||||
{displayedCheck?.component}
|
||||
</ActionRequiredContainer>
|
||||
)}
|
||||
</Group>
|
||||
<Stack mt="2rem">
|
||||
{canReturnHome && (
|
||||
<>
|
||||
<Navigate to={AppRoute.HOME} />
|
||||
<Group
|
||||
noWrap
|
||||
position="center"
|
||||
return (
|
||||
<AnimatedPage>
|
||||
<PageHeader />
|
||||
<Center sx={{ height: '100%', width: '100vw' }}>
|
||||
<Stack
|
||||
spacing="xl"
|
||||
sx={{ maxWidth: '50%' }}
|
||||
>
|
||||
<RiCheckFill
|
||||
color="var(--success-color)"
|
||||
size={30}
|
||||
/>
|
||||
<Text size="xl">No issues found</Text>
|
||||
</Group>
|
||||
<Button
|
||||
component={Link}
|
||||
disabled={!canReturnHome}
|
||||
to={AppRoute.HOME}
|
||||
variant="filled"
|
||||
>
|
||||
Go back
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Center>
|
||||
</AnimatedPage>
|
||||
);
|
||||
<Group noWrap>
|
||||
{displayedCheck && (
|
||||
<ActionRequiredContainer title={displayedCheck.title}>
|
||||
{displayedCheck?.component}
|
||||
</ActionRequiredContainer>
|
||||
)}
|
||||
</Group>
|
||||
<Stack mt="2rem">
|
||||
{canReturnHome && (
|
||||
<>
|
||||
<Navigate to={AppRoute.HOME} />
|
||||
<Group
|
||||
noWrap
|
||||
position="center"
|
||||
>
|
||||
<RiCheckFill
|
||||
color="var(--success-color)"
|
||||
size={30}
|
||||
/>
|
||||
<Text size="xl">No issues found</Text>
|
||||
</Group>
|
||||
<Button
|
||||
component={Link}
|
||||
disabled={!canReturnHome}
|
||||
to={AppRoute.HOME}
|
||||
variant="filled"
|
||||
>
|
||||
Go back
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Center>
|
||||
</AnimatedPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionRequiredRoute;
|
||||
|
|
|
|||
|
|
@ -5,34 +5,34 @@ import { Button, Text } from '/@/renderer/components';
|
|||
import { AnimatedPage } from '/@/renderer/features/shared';
|
||||
|
||||
const InvalidRoute = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<AnimatedPage>
|
||||
<Center sx={{ height: '100%', width: '100%' }}>
|
||||
<Stack>
|
||||
<Group
|
||||
noWrap
|
||||
position="center"
|
||||
>
|
||||
<RiQuestionLine
|
||||
color="var(--warning-color)"
|
||||
size={30}
|
||||
/>
|
||||
<Text size="xl">Page not found</Text>
|
||||
</Group>
|
||||
<Text>{location.pathname}</Text>
|
||||
<Button
|
||||
variant="filled"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
Go back
|
||||
</Button>
|
||||
</Stack>
|
||||
</Center>
|
||||
</AnimatedPage>
|
||||
);
|
||||
return (
|
||||
<AnimatedPage>
|
||||
<Center sx={{ height: '100%', width: '100%' }}>
|
||||
<Stack>
|
||||
<Group
|
||||
noWrap
|
||||
position="center"
|
||||
>
|
||||
<RiQuestionLine
|
||||
color="var(--warning-color)"
|
||||
size={30}
|
||||
/>
|
||||
<Text size="xl">Page not found</Text>
|
||||
</Group>
|
||||
<Text>{location.pathname}</Text>
|
||||
<Button
|
||||
variant="filled"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
Go back
|
||||
</Button>
|
||||
</Stack>
|
||||
</Center>
|
||||
</AnimatedPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default InvalidRoute;
|
||||
|
|
|
|||
|
|
@ -13,13 +13,13 @@ import { AppRoute } from '/@/renderer/router/routes';
|
|||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { PersistedTableColumn, usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import {
|
||||
useHandleGeneralContextMenu,
|
||||
useHandleTableContextMenu,
|
||||
useHandleGeneralContextMenu,
|
||||
useHandleTableContextMenu,
|
||||
} from '/@/renderer/features/context-menu';
|
||||
import { Play, ServerType, TableColumn } from '/@/renderer/types';
|
||||
import {
|
||||
ALBUM_CONTEXT_MENU_ITEMS,
|
||||
SONG_CONTEXT_MENU_ITEMS,
|
||||
ALBUM_CONTEXT_MENU_ITEMS,
|
||||
SONG_CONTEXT_MENU_ITEMS,
|
||||
} from '/@/renderer/features/context-menu/context-menu-items';
|
||||
import { PlayButton, useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
|
||||
import { useAlbumList } from '/@/renderer/features/albums/queries/album-list-query';
|
||||
|
|
@ -27,377 +27,393 @@ import { AlbumListSort, LibraryItem, QueueSong, SortOrder } from '/@/renderer/ap
|
|||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import {
|
||||
getColumnDefs,
|
||||
useFixedTableHeader,
|
||||
VirtualTable,
|
||||
getColumnDefs,
|
||||
useFixedTableHeader,
|
||||
VirtualTable,
|
||||
} from '/@/renderer/components/virtual-table';
|
||||
import { SwiperGridCarousel } from '/@/renderer/components/grid-carousel';
|
||||
import { FullWidthDiscCell } from '/@/renderer/components/virtual-table/cells/full-width-disc-cell';
|
||||
|
||||
const isFullWidthRow = (node: RowNode) => {
|
||||
return node.id?.startsWith('disc-');
|
||||
return node.id?.startsWith('disc-');
|
||||
};
|
||||
|
||||
const ContentContainer = styled.div`
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem 2rem 5rem;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem 2rem 5rem;
|
||||
overflow: hidden;
|
||||
|
||||
.ag-theme-alpine-dark {
|
||||
--ag-header-background-color: rgba(0, 0, 0, 0%) !important;
|
||||
}
|
||||
.ag-theme-alpine-dark {
|
||||
--ag-header-background-color: rgba(0, 0, 0, 0%) !important;
|
||||
}
|
||||
|
||||
.ag-header {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.ag-header {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
`;
|
||||
|
||||
interface AlbumDetailContentProps {
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
|
||||
const { albumId } = useParams() as { albumId: string };
|
||||
const server = useCurrentServer();
|
||||
const detailQuery = useAlbumDetail({ query: { id: albumId }, serverId: server?.id });
|
||||
const cq = useContainerQuery();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const { albumId } = useParams() as { albumId: string };
|
||||
const server = useCurrentServer();
|
||||
const detailQuery = useAlbumDetail({ query: { id: albumId }, serverId: server?.id });
|
||||
const cq = useContainerQuery();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
|
||||
// TODO: Make this customizable
|
||||
const columnDefs: ColDef[] = useMemo(() => {
|
||||
const userRatingColumn =
|
||||
detailQuery?.data?.serverType !== ServerType.JELLYFIN
|
||||
? [
|
||||
// TODO: Make this customizable
|
||||
const columnDefs: ColDef[] = useMemo(() => {
|
||||
const userRatingColumn =
|
||||
detailQuery?.data?.serverType !== ServerType.JELLYFIN
|
||||
? [
|
||||
{
|
||||
column: TableColumn.USER_RATING,
|
||||
width: 0,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const cols: PersistedTableColumn[] = [
|
||||
{
|
||||
column: TableColumn.USER_RATING,
|
||||
width: 0,
|
||||
column: TableColumn.TRACK_NUMBER,
|
||||
width: 0,
|
||||
},
|
||||
{
|
||||
column: TableColumn.TITLE_COMBINED,
|
||||
width: 0,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const cols: PersistedTableColumn[] = [
|
||||
{
|
||||
column: TableColumn.TRACK_NUMBER,
|
||||
width: 0,
|
||||
},
|
||||
{
|
||||
column: TableColumn.TITLE_COMBINED,
|
||||
width: 0,
|
||||
},
|
||||
{
|
||||
column: TableColumn.DURATION,
|
||||
width: 0,
|
||||
},
|
||||
{
|
||||
column: TableColumn.BIT_RATE,
|
||||
width: 0,
|
||||
},
|
||||
{
|
||||
column: TableColumn.PLAY_COUNT,
|
||||
width: 0,
|
||||
},
|
||||
{
|
||||
column: TableColumn.LAST_PLAYED,
|
||||
width: 0,
|
||||
},
|
||||
...userRatingColumn,
|
||||
{
|
||||
column: TableColumn.USER_FAVORITE,
|
||||
width: 0,
|
||||
},
|
||||
];
|
||||
return getColumnDefs(cols).filter((c) => c.colId !== 'album' && c.colId !== 'artist');
|
||||
}, [detailQuery?.data?.serverType]);
|
||||
|
||||
{
|
||||
column: TableColumn.DURATION,
|
||||
width: 0,
|
||||
},
|
||||
{
|
||||
column: TableColumn.BIT_RATE,
|
||||
width: 0,
|
||||
},
|
||||
{
|
||||
column: TableColumn.PLAY_COUNT,
|
||||
width: 0,
|
||||
},
|
||||
{
|
||||
column: TableColumn.LAST_PLAYED,
|
||||
width: 0,
|
||||
},
|
||||
...userRatingColumn,
|
||||
{
|
||||
column: TableColumn.USER_FAVORITE,
|
||||
width: 0,
|
||||
},
|
||||
const getRowHeight = useCallback((params: RowHeightParams) => {
|
||||
if (isFullWidthRow(params.node)) {
|
||||
return 45;
|
||||
}
|
||||
|
||||
return 60;
|
||||
}, []);
|
||||
|
||||
const songsRowData = useMemo(() => {
|
||||
if (!detailQuery.data?.songs) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const uniqueDiscNumbers = new Set(detailQuery.data?.songs.map((s) => s.discNumber));
|
||||
const rowData: (QueueSong | { id: string; name: string })[] = [];
|
||||
|
||||
for (const discNumber of uniqueDiscNumbers.values()) {
|
||||
const songsByDiscNumber = detailQuery.data?.songs.filter(
|
||||
(s) => s.discNumber === discNumber,
|
||||
);
|
||||
rowData.push({
|
||||
id: `disc-${discNumber}`,
|
||||
name: `Disc ${discNumber}`.toLocaleUpperCase(),
|
||||
});
|
||||
rowData.push(...songsByDiscNumber);
|
||||
}
|
||||
|
||||
return rowData;
|
||||
}, [detailQuery.data?.songs]);
|
||||
|
||||
const [pagination, setPagination] = useSetState({
|
||||
artist: 0,
|
||||
});
|
||||
|
||||
const handleNextPage = useCallback(
|
||||
(key: 'artist') => {
|
||||
setPagination({
|
||||
[key]: pagination[key as keyof typeof pagination] + 1,
|
||||
});
|
||||
},
|
||||
[pagination, setPagination],
|
||||
);
|
||||
|
||||
const handlePreviousPage = useCallback(
|
||||
(key: 'artist') => {
|
||||
setPagination({
|
||||
[key]: pagination[key as keyof typeof pagination] - 1,
|
||||
});
|
||||
},
|
||||
[pagination, setPagination],
|
||||
);
|
||||
|
||||
const itemsPerPage = cq.isXl ? 9 : cq.isLg ? 7 : cq.isMd ? 5 : cq.isSm ? 4 : 3;
|
||||
|
||||
const artistQuery = useAlbumList({
|
||||
options: {
|
||||
cacheTime: 1000 * 60,
|
||||
enabled: detailQuery?.data?.albumArtists[0]?.id !== undefined,
|
||||
keepPreviousData: true,
|
||||
staleTime: 1000 * 60,
|
||||
},
|
||||
query: {
|
||||
_custom: {
|
||||
jellyfin: {
|
||||
AlbumArtistIds: detailQuery?.data?.albumArtists[0]?.id,
|
||||
ExcludeItemIds: detailQuery?.data?.id,
|
||||
},
|
||||
navidrome: {
|
||||
artist_id: detailQuery?.data?.albumArtists[0]?.id,
|
||||
},
|
||||
},
|
||||
limit: 10,
|
||||
sortBy: AlbumListSort.YEAR,
|
||||
sortOrder: SortOrder.DESC,
|
||||
startIndex: pagination.artist * itemsPerPage,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const carousels = [
|
||||
{
|
||||
data: artistQuery?.data?.items,
|
||||
loading: artistQuery?.isLoading || artistQuery.isFetching,
|
||||
pagination: {
|
||||
handleNextPage: () => handleNextPage('artist'),
|
||||
handlePreviousPage: () => handlePreviousPage('artist'),
|
||||
hasPreviousPage: pagination.artist > 0,
|
||||
itemsPerPage,
|
||||
},
|
||||
title: 'More from this artist',
|
||||
uniqueId: 'mostPlayed',
|
||||
},
|
||||
];
|
||||
return getColumnDefs(cols).filter((c) => c.colId !== 'album' && c.colId !== 'artist');
|
||||
}, [detailQuery?.data?.serverType]);
|
||||
|
||||
const getRowHeight = useCallback((params: RowHeightParams) => {
|
||||
if (isFullWidthRow(params.node)) {
|
||||
return 45;
|
||||
}
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
return 60;
|
||||
}, []);
|
||||
const handlePlay = async (playType?: Play) => {
|
||||
handlePlayQueueAdd?.({
|
||||
byData: detailQuery?.data?.songs,
|
||||
playType: playType || playButtonBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
const songsRowData = useMemo(() => {
|
||||
if (!detailQuery.data?.songs) {
|
||||
return [];
|
||||
}
|
||||
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, SONG_CONTEXT_MENU_ITEMS);
|
||||
|
||||
const uniqueDiscNumbers = new Set(detailQuery.data?.songs.map((s) => s.discNumber));
|
||||
const rowData: (QueueSong | { id: string; name: string })[] = [];
|
||||
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
|
||||
if (!e.data || e.node.isFullWidthCell()) return;
|
||||
|
||||
for (const discNumber of uniqueDiscNumbers.values()) {
|
||||
const songsByDiscNumber = detailQuery.data?.songs.filter((s) => s.discNumber === discNumber);
|
||||
rowData.push({ id: `disc-${discNumber}`, name: `Disc ${discNumber}`.toLocaleUpperCase() });
|
||||
rowData.push(...songsByDiscNumber);
|
||||
}
|
||||
const rowData: QueueSong[] = [];
|
||||
e.api.forEachNode((node) => {
|
||||
if (!node.data || node.isFullWidthCell()) return;
|
||||
rowData.push(node.data);
|
||||
});
|
||||
|
||||
return rowData;
|
||||
}, [detailQuery.data?.songs]);
|
||||
handlePlayQueueAdd?.({
|
||||
byData: rowData,
|
||||
initialSongId: e.data.id,
|
||||
playType: playButtonBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
const [pagination, setPagination] = useSetState({
|
||||
artist: 0,
|
||||
});
|
||||
const createFavoriteMutation = useCreateFavorite({});
|
||||
const deleteFavoriteMutation = useDeleteFavorite({});
|
||||
|
||||
const handleNextPage = useCallback(
|
||||
(key: 'artist') => {
|
||||
setPagination({
|
||||
[key]: pagination[key as keyof typeof pagination] + 1,
|
||||
});
|
||||
},
|
||||
[pagination, setPagination],
|
||||
);
|
||||
const handleFavorite = () => {
|
||||
if (!detailQuery?.data) return;
|
||||
|
||||
const handlePreviousPage = useCallback(
|
||||
(key: 'artist') => {
|
||||
setPagination({
|
||||
[key]: pagination[key as keyof typeof pagination] - 1,
|
||||
});
|
||||
},
|
||||
[pagination, setPagination],
|
||||
);
|
||||
if (detailQuery.data.userFavorite) {
|
||||
deleteFavoriteMutation.mutate({
|
||||
query: {
|
||||
id: [detailQuery.data.id],
|
||||
type: LibraryItem.ALBUM,
|
||||
},
|
||||
serverId: detailQuery.data.serverId,
|
||||
});
|
||||
} else {
|
||||
createFavoriteMutation.mutate({
|
||||
query: {
|
||||
id: [detailQuery.data.id],
|
||||
type: LibraryItem.ALBUM,
|
||||
},
|
||||
serverId: detailQuery.data.serverId,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const itemsPerPage = cq.isXl ? 9 : cq.isLg ? 7 : cq.isMd ? 5 : cq.isSm ? 4 : 3;
|
||||
const showGenres = detailQuery?.data?.genres ? detailQuery?.data?.genres.length !== 0 : false;
|
||||
|
||||
const artistQuery = useAlbumList({
|
||||
options: {
|
||||
cacheTime: 1000 * 60,
|
||||
enabled: detailQuery?.data?.albumArtists[0]?.id !== undefined,
|
||||
keepPreviousData: true,
|
||||
staleTime: 1000 * 60,
|
||||
},
|
||||
query: {
|
||||
_custom: {
|
||||
jellyfin: {
|
||||
AlbumArtistIds: detailQuery?.data?.albumArtists[0]?.id,
|
||||
ExcludeItemIds: detailQuery?.data?.id,
|
||||
},
|
||||
navidrome: {
|
||||
artist_id: detailQuery?.data?.albumArtists[0]?.id,
|
||||
},
|
||||
},
|
||||
limit: 10,
|
||||
sortBy: AlbumListSort.YEAR,
|
||||
sortOrder: SortOrder.DESC,
|
||||
startIndex: pagination.artist * itemsPerPage,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
const { intersectRef, tableContainerRef } = useFixedTableHeader();
|
||||
|
||||
const carousels = [
|
||||
{
|
||||
data: artistQuery?.data?.items,
|
||||
loading: artistQuery?.isLoading || artistQuery.isFetching,
|
||||
pagination: {
|
||||
handleNextPage: () => handleNextPage('artist'),
|
||||
handlePreviousPage: () => handlePreviousPage('artist'),
|
||||
hasPreviousPage: pagination.artist > 0,
|
||||
itemsPerPage,
|
||||
},
|
||||
title: 'More from this artist',
|
||||
uniqueId: 'mostPlayed',
|
||||
},
|
||||
];
|
||||
const handleGeneralContextMenu = useHandleGeneralContextMenu(
|
||||
LibraryItem.ALBUM,
|
||||
ALBUM_CONTEXT_MENU_ITEMS,
|
||||
);
|
||||
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
const handlePlay = async (playType?: Play) => {
|
||||
handlePlayQueueAdd?.({
|
||||
byData: detailQuery?.data?.songs,
|
||||
playType: playType || playButtonBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, SONG_CONTEXT_MENU_ITEMS);
|
||||
|
||||
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
|
||||
if (!e.data || e.node.isFullWidthCell()) return;
|
||||
|
||||
const rowData: QueueSong[] = [];
|
||||
e.api.forEachNode((node) => {
|
||||
if (!node.data || node.isFullWidthCell()) return;
|
||||
rowData.push(node.data);
|
||||
});
|
||||
|
||||
handlePlayQueueAdd?.({
|
||||
byData: rowData,
|
||||
initialSongId: e.data.id,
|
||||
playType: playButtonBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
const createFavoriteMutation = useCreateFavorite({});
|
||||
const deleteFavoriteMutation = useDeleteFavorite({});
|
||||
|
||||
const handleFavorite = () => {
|
||||
if (!detailQuery?.data) return;
|
||||
|
||||
if (detailQuery.data.userFavorite) {
|
||||
deleteFavoriteMutation.mutate({
|
||||
query: {
|
||||
id: [detailQuery.data.id],
|
||||
type: LibraryItem.ALBUM,
|
||||
},
|
||||
serverId: detailQuery.data.serverId,
|
||||
});
|
||||
} else {
|
||||
createFavoriteMutation.mutate({
|
||||
query: {
|
||||
id: [detailQuery.data.id],
|
||||
type: LibraryItem.ALBUM,
|
||||
},
|
||||
serverId: detailQuery.data.serverId,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const showGenres = detailQuery?.data?.genres ? detailQuery?.data?.genres.length !== 0 : false;
|
||||
|
||||
const { intersectRef, tableContainerRef } = useFixedTableHeader();
|
||||
|
||||
const handleGeneralContextMenu = useHandleGeneralContextMenu(
|
||||
LibraryItem.ALBUM,
|
||||
ALBUM_CONTEXT_MENU_ITEMS,
|
||||
);
|
||||
|
||||
return (
|
||||
<ContentContainer>
|
||||
<Box component="section">
|
||||
<Group
|
||||
ref={showGenres ? null : intersectRef}
|
||||
className="test"
|
||||
py="1rem"
|
||||
spacing="md"
|
||||
>
|
||||
<PlayButton onClick={() => handlePlay(playButtonBehavior)} />
|
||||
<Group spacing="xs">
|
||||
<Button
|
||||
compact
|
||||
loading={createFavoriteMutation.isLoading || deleteFavoriteMutation.isLoading}
|
||||
variant="subtle"
|
||||
onClick={handleFavorite}
|
||||
return (
|
||||
<ContentContainer>
|
||||
<Box component="section">
|
||||
<Group
|
||||
ref={showGenres ? null : intersectRef}
|
||||
className="test"
|
||||
py="1rem"
|
||||
spacing="md"
|
||||
>
|
||||
<PlayButton onClick={() => handlePlay(playButtonBehavior)} />
|
||||
<Group spacing="xs">
|
||||
<Button
|
||||
compact
|
||||
loading={
|
||||
createFavoriteMutation.isLoading || deleteFavoriteMutation.isLoading
|
||||
}
|
||||
variant="subtle"
|
||||
onClick={handleFavorite}
|
||||
>
|
||||
{detailQuery?.data?.userFavorite ? (
|
||||
<RiHeartFill
|
||||
color="red"
|
||||
size={20}
|
||||
/>
|
||||
) : (
|
||||
<RiHeartLine size={20} />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
variant="subtle"
|
||||
onClick={(e) => {
|
||||
if (!detailQuery?.data) return;
|
||||
handleGeneralContextMenu(e, [detailQuery.data!]);
|
||||
}}
|
||||
>
|
||||
<RiMoreFill size={20} />
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
{showGenres && (
|
||||
<Box
|
||||
ref={showGenres ? intersectRef : null}
|
||||
component="section"
|
||||
py="1rem"
|
||||
>
|
||||
<Group spacing="sm">
|
||||
{detailQuery?.data?.genres?.map((genre) => (
|
||||
<Button
|
||||
key={`genre-${genre.id}`}
|
||||
compact
|
||||
component={Link}
|
||||
radius={0}
|
||||
size="md"
|
||||
to={generatePath(`${AppRoute.LIBRARY_ALBUMS}?genre=${genre.id}`, {
|
||||
albumId,
|
||||
})}
|
||||
variant="outline"
|
||||
>
|
||||
{genre.name}
|
||||
</Button>
|
||||
))}
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
ref={tableContainerRef}
|
||||
style={{ minHeight: '300px' }}
|
||||
>
|
||||
{detailQuery?.data?.userFavorite ? (
|
||||
<RiHeartFill
|
||||
color="red"
|
||||
size={20}
|
||||
<VirtualTable
|
||||
ref={tableRef}
|
||||
autoFitColumns
|
||||
autoHeight
|
||||
suppressCellFocus
|
||||
suppressHorizontalScroll
|
||||
suppressLoadingOverlay
|
||||
suppressRowDrag
|
||||
columnDefs={columnDefs}
|
||||
enableCellChangeFlash={false}
|
||||
fullWidthCellRenderer={FullWidthDiscCell}
|
||||
getRowHeight={getRowHeight}
|
||||
getRowId={(data) => data.data.id}
|
||||
isFullWidthRow={(data) => {
|
||||
return isFullWidthRow(data.rowNode) || false;
|
||||
}}
|
||||
isRowSelectable={(data) => {
|
||||
if (isFullWidthRow(data.data)) return false;
|
||||
return true;
|
||||
}}
|
||||
rowData={songsRowData}
|
||||
rowSelection="multiple"
|
||||
onCellContextMenu={handleContextMenu}
|
||||
onRowDoubleClicked={handleRowDoubleClick}
|
||||
/>
|
||||
) : (
|
||||
<RiHeartLine size={20} />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
variant="subtle"
|
||||
onClick={(e) => {
|
||||
if (!detailQuery?.data) return;
|
||||
handleGeneralContextMenu(e, [detailQuery.data!]);
|
||||
}}
|
||||
</Box>
|
||||
<Stack
|
||||
ref={cq.ref}
|
||||
mt="5rem"
|
||||
>
|
||||
<RiMoreFill size={20} />
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
{showGenres && (
|
||||
<Box
|
||||
ref={showGenres ? intersectRef : null}
|
||||
component="section"
|
||||
py="1rem"
|
||||
>
|
||||
<Group spacing="sm">
|
||||
{detailQuery?.data?.genres?.map((genre) => (
|
||||
<Button
|
||||
key={`genre-${genre.id}`}
|
||||
compact
|
||||
component={Link}
|
||||
radius={0}
|
||||
size="md"
|
||||
to={generatePath(`${AppRoute.LIBRARY_ALBUMS}?genre=${genre.id}`, { albumId })}
|
||||
variant="outline"
|
||||
>
|
||||
{genre.name}
|
||||
</Button>
|
||||
))}
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
ref={tableContainerRef}
|
||||
style={{ minHeight: '300px' }}
|
||||
>
|
||||
<VirtualTable
|
||||
ref={tableRef}
|
||||
autoFitColumns
|
||||
autoHeight
|
||||
suppressCellFocus
|
||||
suppressHorizontalScroll
|
||||
suppressLoadingOverlay
|
||||
suppressRowDrag
|
||||
columnDefs={columnDefs}
|
||||
enableCellChangeFlash={false}
|
||||
fullWidthCellRenderer={FullWidthDiscCell}
|
||||
getRowHeight={getRowHeight}
|
||||
getRowId={(data) => data.data.id}
|
||||
isFullWidthRow={(data) => {
|
||||
return isFullWidthRow(data.rowNode) || false;
|
||||
}}
|
||||
isRowSelectable={(data) => {
|
||||
if (isFullWidthRow(data.data)) return false;
|
||||
return true;
|
||||
}}
|
||||
rowData={songsRowData}
|
||||
rowSelection="multiple"
|
||||
onCellContextMenu={handleContextMenu}
|
||||
onRowDoubleClicked={handleRowDoubleClick}
|
||||
/>
|
||||
</Box>
|
||||
<Stack
|
||||
ref={cq.ref}
|
||||
mt="5rem"
|
||||
>
|
||||
<>
|
||||
{cq.height || cq.width ? (
|
||||
<>
|
||||
{carousels.map((carousel, index) => (
|
||||
<SwiperGridCarousel
|
||||
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_ALBUM_ARTISTS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
|
||||
},
|
||||
},
|
||||
]}
|
||||
data={carousel.data}
|
||||
isLoading={carousel.loading}
|
||||
itemType={LibraryItem.ALBUM}
|
||||
route={{
|
||||
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
|
||||
}}
|
||||
title={{
|
||||
label: carousel.title,
|
||||
}}
|
||||
uniqueId={carousel.uniqueId}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
</Stack>
|
||||
</ContentContainer>
|
||||
);
|
||||
<>
|
||||
{cq.height || cq.width ? (
|
||||
<>
|
||||
{carousels.map((carousel, index) => (
|
||||
<SwiperGridCarousel
|
||||
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_ALBUM_ARTISTS_DETAIL,
|
||||
slugs: [
|
||||
{
|
||||
idProperty: 'id',
|
||||
slugProperty: 'albumArtistId',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]}
|
||||
data={carousel.data}
|
||||
isLoading={carousel.loading}
|
||||
itemType={LibraryItem.ALBUM}
|
||||
route={{
|
||||
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
|
||||
}}
|
||||
title={{
|
||||
label: carousel.title,
|
||||
}}
|
||||
uniqueId={carousel.uniqueId}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
</Stack>
|
||||
</ContentContainer>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,118 +12,122 @@ import { useCurrentServer } from '/@/renderer/store';
|
|||
import { formatDurationString } from '/@/renderer/utils';
|
||||
|
||||
interface AlbumDetailHeaderProps {
|
||||
background: string;
|
||||
background: string;
|
||||
}
|
||||
|
||||
export const AlbumDetailHeader = forwardRef(
|
||||
({ background }: AlbumDetailHeaderProps, ref: Ref<HTMLDivElement>) => {
|
||||
const { albumId } = useParams() as { albumId: string };
|
||||
const server = useCurrentServer();
|
||||
const detailQuery = useAlbumDetail({ query: { id: albumId }, serverId: server?.id });
|
||||
const cq = useContainerQuery();
|
||||
({ background }: AlbumDetailHeaderProps, ref: Ref<HTMLDivElement>) => {
|
||||
const { albumId } = useParams() as { albumId: string };
|
||||
const server = useCurrentServer();
|
||||
const detailQuery = useAlbumDetail({ query: { id: albumId }, serverId: server?.id });
|
||||
const cq = useContainerQuery();
|
||||
|
||||
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),
|
||||
},
|
||||
];
|
||||
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),
|
||||
},
|
||||
];
|
||||
|
||||
const updateRatingMutation = useSetRating({});
|
||||
const updateRatingMutation = useSetRating({});
|
||||
|
||||
const handleUpdateRating = (rating: number) => {
|
||||
if (!detailQuery?.data) return;
|
||||
const handleUpdateRating = (rating: number) => {
|
||||
if (!detailQuery?.data) return;
|
||||
|
||||
updateRatingMutation.mutate({
|
||||
query: {
|
||||
item: [detailQuery.data],
|
||||
rating,
|
||||
},
|
||||
serverId: detailQuery.data.serverId,
|
||||
});
|
||||
};
|
||||
updateRatingMutation.mutate({
|
||||
query: {
|
||||
item: [detailQuery.data],
|
||||
rating,
|
||||
},
|
||||
serverId: detailQuery.data.serverId,
|
||||
});
|
||||
};
|
||||
|
||||
const handleClearRating = () => {
|
||||
if (!detailQuery?.data || !detailQuery?.data.userRating) return;
|
||||
const handleClearRating = () => {
|
||||
if (!detailQuery?.data || !detailQuery?.data.userRating) return;
|
||||
|
||||
updateRatingMutation.mutate({
|
||||
query: {
|
||||
item: [detailQuery.data],
|
||||
rating: 0,
|
||||
},
|
||||
serverId: detailQuery.data.serverId,
|
||||
});
|
||||
};
|
||||
updateRatingMutation.mutate({
|
||||
query: {
|
||||
item: [detailQuery.data],
|
||||
rating: 0,
|
||||
},
|
||||
serverId: detailQuery.data.serverId,
|
||||
});
|
||||
};
|
||||
|
||||
const showRating = detailQuery?.data?.serverType === ServerType.NAVIDROME;
|
||||
const showRating = detailQuery?.data?.serverType === ServerType.NAVIDROME;
|
||||
|
||||
return (
|
||||
<Stack ref={cq.ref}>
|
||||
<LibraryHeader
|
||||
ref={ref}
|
||||
background={background}
|
||||
imageUrl={detailQuery?.data?.imageUrl}
|
||||
item={{ route: AppRoute.LIBRARY_ALBUMS, type: LibraryItem.ALBUM }}
|
||||
title={detailQuery?.data?.name || ''}
|
||||
>
|
||||
<Stack spacing="sm">
|
||||
<Group spacing="sm">
|
||||
{metadataItems.map((item, index) => (
|
||||
<Fragment key={`item-${item.id}-${index}`}>
|
||||
{index > 0 && <Text $noSelect>•</Text>}
|
||||
<Text $secondary={item.secondary}>{item.value}</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
{showRating && (
|
||||
<>
|
||||
<Text $noSelect>•</Text>
|
||||
<Rating
|
||||
readOnly={detailQuery?.isFetching || updateRatingMutation.isLoading}
|
||||
value={detailQuery?.data?.userRating || 0}
|
||||
onChange={handleUpdateRating}
|
||||
onClick={handleClearRating}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
<Group
|
||||
spacing="md"
|
||||
sx={{
|
||||
WebkitBoxOrient: 'vertical',
|
||||
WebkitLineClamp: 2,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{detailQuery?.data?.albumArtists.map((artist) => (
|
||||
<Text
|
||||
key={`artist-${artist.id}`}
|
||||
$link
|
||||
component={Link}
|
||||
fw={600}
|
||||
size="md"
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||
albumArtistId: artist.id,
|
||||
})}
|
||||
variant="subtle"
|
||||
return (
|
||||
<Stack ref={cq.ref}>
|
||||
<LibraryHeader
|
||||
ref={ref}
|
||||
background={background}
|
||||
imageUrl={detailQuery?.data?.imageUrl}
|
||||
item={{ route: AppRoute.LIBRARY_ALBUMS, type: LibraryItem.ALBUM }}
|
||||
title={detailQuery?.data?.name || ''}
|
||||
>
|
||||
{artist.name}
|
||||
</Text>
|
||||
))}
|
||||
</Group>
|
||||
</Stack>
|
||||
</LibraryHeader>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
<Stack spacing="sm">
|
||||
<Group spacing="sm">
|
||||
{metadataItems.map((item, index) => (
|
||||
<Fragment key={`item-${item.id}-${index}`}>
|
||||
{index > 0 && <Text $noSelect>•</Text>}
|
||||
<Text $secondary={item.secondary}>{item.value}</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
{showRating && (
|
||||
<>
|
||||
<Text $noSelect>•</Text>
|
||||
<Rating
|
||||
readOnly={
|
||||
detailQuery?.isFetching ||
|
||||
updateRatingMutation.isLoading
|
||||
}
|
||||
value={detailQuery?.data?.userRating || 0}
|
||||
onChange={handleUpdateRating}
|
||||
onClick={handleClearRating}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
<Group
|
||||
spacing="md"
|
||||
sx={{
|
||||
WebkitBoxOrient: 'vertical',
|
||||
WebkitLineClamp: 2,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{detailQuery?.data?.albumArtists.map((artist) => (
|
||||
<Text
|
||||
key={`artist-${artist.id}`}
|
||||
$link
|
||||
component={Link}
|
||||
fw={600}
|
||||
size="md"
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||
albumArtistId: artist.id,
|
||||
})}
|
||||
variant="subtle"
|
||||
>
|
||||
{artist.name}
|
||||
</Text>
|
||||
))}
|
||||
</Group>
|
||||
</Stack>
|
||||
</LibraryHeader>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,40 +7,40 @@ import { useAlbumListStore } from '/@/renderer/store';
|
|||
import { ListDisplayType } from '/@/renderer/types';
|
||||
|
||||
const AlbumListGridView = lazy(() =>
|
||||
import('/@/renderer/features/albums/components/album-list-grid-view').then((module) => ({
|
||||
default: module.AlbumListGridView,
|
||||
})),
|
||||
import('/@/renderer/features/albums/components/album-list-grid-view').then((module) => ({
|
||||
default: module.AlbumListGridView,
|
||||
})),
|
||||
);
|
||||
|
||||
const AlbumListTableView = lazy(() =>
|
||||
import('/@/renderer/features/albums/components/album-list-table-view').then((module) => ({
|
||||
default: module.AlbumListTableView,
|
||||
})),
|
||||
import('/@/renderer/features/albums/components/album-list-table-view').then((module) => ({
|
||||
default: module.AlbumListTableView,
|
||||
})),
|
||||
);
|
||||
|
||||
interface AlbumListContentProps {
|
||||
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
|
||||
itemCount?: number;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
|
||||
itemCount?: number;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const AlbumListContent = ({ itemCount, gridRef, tableRef }: AlbumListContentProps) => {
|
||||
const { id, pageKey } = useAlbumListContext();
|
||||
const { display } = useAlbumListStore({ id, key: pageKey });
|
||||
const { id, pageKey } = useAlbumListContext();
|
||||
const { display } = useAlbumListStore({ id, key: pageKey });
|
||||
|
||||
return (
|
||||
<Suspense fallback={<Spinner container />}>
|
||||
{display === ListDisplayType.CARD || display === ListDisplayType.POSTER ? (
|
||||
<AlbumListGridView
|
||||
gridRef={gridRef}
|
||||
itemCount={itemCount}
|
||||
/>
|
||||
) : (
|
||||
<AlbumListTableView
|
||||
itemCount={itemCount}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
);
|
||||
return (
|
||||
<Suspense fallback={<Spinner container />}>
|
||||
{display === ListDisplayType.CARD || display === ListDisplayType.POSTER ? (
|
||||
<AlbumListGridView
|
||||
gridRef={gridRef}
|
||||
itemCount={itemCount}
|
||||
/>
|
||||
) : (
|
||||
<AlbumListTableView
|
||||
itemCount={itemCount}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,194 +7,194 @@ import { queryKeys } from '/@/renderer/api/query-keys';
|
|||
import { Album, AlbumListQuery, AlbumListSort, LibraryItem } from '/@/renderer/api/types';
|
||||
import { ALBUM_CARD_ROWS } from '/@/renderer/components';
|
||||
import {
|
||||
VirtualGridAutoSizerContainer,
|
||||
VirtualInfiniteGrid,
|
||||
VirtualGridAutoSizerContainer,
|
||||
VirtualInfiniteGrid,
|
||||
} from '/@/renderer/components/virtual-grid';
|
||||
import { useAlbumListContext } from '/@/renderer/features/albums/context/album-list-context';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import {
|
||||
useAlbumListFilter,
|
||||
useAlbumListStore,
|
||||
useCurrentServer,
|
||||
useListStoreActions,
|
||||
useAlbumListFilter,
|
||||
useAlbumListStore,
|
||||
useCurrentServer,
|
||||
useListStoreActions,
|
||||
} from '/@/renderer/store';
|
||||
import { CardRow, ListDisplayType } from '/@/renderer/types';
|
||||
import { useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
|
||||
|
||||
export const AlbumListGridView = ({ gridRef, itemCount }: any) => {
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const { id, pageKey } = useAlbumListContext();
|
||||
const { grid, display } = useAlbumListStore({ id, key: pageKey });
|
||||
const { setGrid } = useListStoreActions();
|
||||
const filter = useAlbumListFilter({ id, key: pageKey });
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const { id, pageKey } = useAlbumListContext();
|
||||
const { grid, display } = useAlbumListStore({ id, key: pageKey });
|
||||
const { setGrid } = useListStoreActions();
|
||||
const filter = useAlbumListFilter({ id, key: pageKey });
|
||||
|
||||
const createFavoriteMutation = useCreateFavorite({});
|
||||
const deleteFavoriteMutation = useDeleteFavorite({});
|
||||
const createFavoriteMutation = useCreateFavorite({});
|
||||
const deleteFavoriteMutation = useDeleteFavorite({});
|
||||
|
||||
const handleFavorite = (options: {
|
||||
id: string[];
|
||||
isFavorite: boolean;
|
||||
itemType: LibraryItem;
|
||||
}) => {
|
||||
const { id, itemType, isFavorite } = options;
|
||||
if (isFavorite) {
|
||||
deleteFavoriteMutation.mutate({
|
||||
query: {
|
||||
id,
|
||||
type: itemType,
|
||||
const handleFavorite = (options: {
|
||||
id: string[];
|
||||
isFavorite: boolean;
|
||||
itemType: LibraryItem;
|
||||
}) => {
|
||||
const { id, itemType, isFavorite } = options;
|
||||
if (isFavorite) {
|
||||
deleteFavoriteMutation.mutate({
|
||||
query: {
|
||||
id,
|
||||
type: itemType,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
} else {
|
||||
createFavoriteMutation.mutate({
|
||||
query: {
|
||||
id,
|
||||
type: itemType,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const cardRows = useMemo(() => {
|
||||
const rows: CardRow<Album>[] = [ALBUM_CARD_ROWS.name];
|
||||
|
||||
switch (filter.sortBy) {
|
||||
case AlbumListSort.ALBUM_ARTIST:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.releaseYear);
|
||||
break;
|
||||
case AlbumListSort.ARTIST:
|
||||
rows.push(ALBUM_CARD_ROWS.artists);
|
||||
rows.push(ALBUM_CARD_ROWS.releaseYear);
|
||||
break;
|
||||
case AlbumListSort.COMMUNITY_RATING:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
break;
|
||||
case AlbumListSort.DURATION:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.duration);
|
||||
break;
|
||||
case AlbumListSort.FAVORITED:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.releaseYear);
|
||||
break;
|
||||
case AlbumListSort.NAME:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.releaseYear);
|
||||
break;
|
||||
case AlbumListSort.PLAY_COUNT:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.playCount);
|
||||
break;
|
||||
case AlbumListSort.RANDOM:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.releaseYear);
|
||||
break;
|
||||
case AlbumListSort.RATING:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.rating);
|
||||
break;
|
||||
case AlbumListSort.RECENTLY_ADDED:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.createdAt);
|
||||
break;
|
||||
case AlbumListSort.RECENTLY_PLAYED:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.lastPlayedAt);
|
||||
break;
|
||||
case AlbumListSort.SONG_COUNT:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.songCount);
|
||||
break;
|
||||
case AlbumListSort.YEAR:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.releaseYear);
|
||||
break;
|
||||
case AlbumListSort.RELEASE_DATE:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.releaseDate);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}, [filter.sortBy]);
|
||||
|
||||
const handleGridScroll = useCallback(
|
||||
(e: ListOnScrollProps) => {
|
||||
setGrid({ data: { scrollOffset: e.scrollOffset }, key: pageKey });
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
} else {
|
||||
createFavoriteMutation.mutate({
|
||||
query: {
|
||||
id,
|
||||
type: itemType,
|
||||
[pageKey, setGrid],
|
||||
);
|
||||
|
||||
const fetch = useCallback(
|
||||
async ({ skip, take }: { skip: number; take: number }) => {
|
||||
if (!server) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const query: AlbumListQuery = {
|
||||
limit: take,
|
||||
startIndex: skip,
|
||||
...filter,
|
||||
_custom: {
|
||||
jellyfin: {
|
||||
...filter._custom?.jellyfin,
|
||||
},
|
||||
navidrome: {
|
||||
...filter._custom?.navidrome,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.albums.list(server?.id || '', query);
|
||||
|
||||
const albums = await queryClient.fetchQuery(queryKey, async ({ signal }) =>
|
||||
controller.getAlbumList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query,
|
||||
}),
|
||||
);
|
||||
|
||||
return albums;
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
}
|
||||
};
|
||||
[filter, queryClient, server],
|
||||
);
|
||||
|
||||
const cardRows = useMemo(() => {
|
||||
const rows: CardRow<Album>[] = [ALBUM_CARD_ROWS.name];
|
||||
|
||||
switch (filter.sortBy) {
|
||||
case AlbumListSort.ALBUM_ARTIST:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.releaseYear);
|
||||
break;
|
||||
case AlbumListSort.ARTIST:
|
||||
rows.push(ALBUM_CARD_ROWS.artists);
|
||||
rows.push(ALBUM_CARD_ROWS.releaseYear);
|
||||
break;
|
||||
case AlbumListSort.COMMUNITY_RATING:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
break;
|
||||
case AlbumListSort.DURATION:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.duration);
|
||||
break;
|
||||
case AlbumListSort.FAVORITED:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.releaseYear);
|
||||
break;
|
||||
case AlbumListSort.NAME:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.releaseYear);
|
||||
break;
|
||||
case AlbumListSort.PLAY_COUNT:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.playCount);
|
||||
break;
|
||||
case AlbumListSort.RANDOM:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.releaseYear);
|
||||
break;
|
||||
case AlbumListSort.RATING:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.rating);
|
||||
break;
|
||||
case AlbumListSort.RECENTLY_ADDED:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.createdAt);
|
||||
break;
|
||||
case AlbumListSort.RECENTLY_PLAYED:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.lastPlayedAt);
|
||||
break;
|
||||
case AlbumListSort.SONG_COUNT:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.songCount);
|
||||
break;
|
||||
case AlbumListSort.YEAR:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.releaseYear);
|
||||
break;
|
||||
case AlbumListSort.RELEASE_DATE:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.releaseDate);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}, [filter.sortBy]);
|
||||
|
||||
const handleGridScroll = useCallback(
|
||||
(e: ListOnScrollProps) => {
|
||||
setGrid({ data: { scrollOffset: e.scrollOffset }, key: pageKey });
|
||||
},
|
||||
[pageKey, setGrid],
|
||||
);
|
||||
|
||||
const fetch = useCallback(
|
||||
async ({ skip, take }: { skip: number; take: number }) => {
|
||||
if (!server) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const query: AlbumListQuery = {
|
||||
limit: take,
|
||||
startIndex: skip,
|
||||
...filter,
|
||||
_custom: {
|
||||
jellyfin: {
|
||||
...filter._custom?.jellyfin,
|
||||
},
|
||||
navidrome: {
|
||||
...filter._custom?.navidrome,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.albums.list(server?.id || '', query);
|
||||
|
||||
const albums = await queryClient.fetchQuery(queryKey, async ({ signal }) =>
|
||||
controller.getAlbumList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query,
|
||||
}),
|
||||
);
|
||||
|
||||
return albums;
|
||||
},
|
||||
[filter, queryClient, server],
|
||||
);
|
||||
|
||||
return (
|
||||
<VirtualGridAutoSizerContainer>
|
||||
<AutoSizer>
|
||||
{({ height, width }) => (
|
||||
<VirtualInfiniteGrid
|
||||
key={`album-list-${server?.id}-${display}`}
|
||||
ref={gridRef}
|
||||
cardRows={cardRows}
|
||||
display={display || ListDisplayType.CARD}
|
||||
fetchFn={fetch}
|
||||
handleFavorite={handleFavorite}
|
||||
handlePlayQueueAdd={handlePlayQueueAdd}
|
||||
height={height}
|
||||
initialScrollOffset={grid?.scrollOffset || 0}
|
||||
itemCount={itemCount || 0}
|
||||
itemGap={20}
|
||||
itemSize={grid?.itemsPerRow || 5}
|
||||
itemType={LibraryItem.ALBUM}
|
||||
loading={itemCount === undefined || itemCount === null}
|
||||
minimumBatchSize={40}
|
||||
route={{
|
||||
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
|
||||
}}
|
||||
width={width}
|
||||
onScroll={handleGridScroll}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</VirtualGridAutoSizerContainer>
|
||||
);
|
||||
return (
|
||||
<VirtualGridAutoSizerContainer>
|
||||
<AutoSizer>
|
||||
{({ height, width }) => (
|
||||
<VirtualInfiniteGrid
|
||||
key={`album-list-${server?.id}-${display}`}
|
||||
ref={gridRef}
|
||||
cardRows={cardRows}
|
||||
display={display || ListDisplayType.CARD}
|
||||
fetchFn={fetch}
|
||||
handleFavorite={handleFavorite}
|
||||
handlePlayQueueAdd={handlePlayQueueAdd}
|
||||
height={height}
|
||||
initialScrollOffset={grid?.scrollOffset || 0}
|
||||
itemCount={itemCount || 0}
|
||||
itemGap={20}
|
||||
itemSize={grid?.itemsPerRow || 5}
|
||||
itemType={LibraryItem.ALBUM}
|
||||
loading={itemCount === undefined || itemCount === null}
|
||||
minimumBatchSize={40}
|
||||
route={{
|
||||
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
|
||||
}}
|
||||
width={width}
|
||||
onScroll={handleGridScroll}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</VirtualGridAutoSizerContainer>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -13,11 +13,11 @@ import { PageHeader, SearchInput } from '/@/renderer/components';
|
|||
import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import {
|
||||
AlbumListFilter,
|
||||
useAlbumListFilter,
|
||||
useAlbumListStore,
|
||||
useCurrentServer,
|
||||
useListStoreActions,
|
||||
AlbumListFilter,
|
||||
useAlbumListFilter,
|
||||
useAlbumListStore,
|
||||
useCurrentServer,
|
||||
useListStoreActions,
|
||||
} from '/@/renderer/store';
|
||||
import { ListDisplayType, Play } from '/@/renderer/types';
|
||||
import { AlbumListHeaderFilters } from '/@/renderer/features/albums/components/album-list-header-filters';
|
||||
|
|
@ -27,219 +27,226 @@ import { useAlbumListContext } from '/@/renderer/features/albums/context/album-l
|
|||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
|
||||
interface AlbumListHeaderProps {
|
||||
customFilters?: Partial<AlbumListFilter>;
|
||||
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
|
||||
itemCount?: number;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
title?: string;
|
||||
customFilters?: Partial<AlbumListFilter>;
|
||||
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
|
||||
itemCount?: number;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const AlbumListHeader = ({
|
||||
itemCount,
|
||||
gridRef,
|
||||
tableRef,
|
||||
title,
|
||||
customFilters,
|
||||
itemCount,
|
||||
gridRef,
|
||||
tableRef,
|
||||
title,
|
||||
customFilters,
|
||||
}: AlbumListHeaderProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
const { setFilter, setTablePagination } = useListStoreActions();
|
||||
const cq = useContainerQuery();
|
||||
const { id, pageKey } = useAlbumListContext();
|
||||
const { display } = useAlbumListStore({ id, key: pageKey });
|
||||
const filter = useAlbumListFilter({ id, key: pageKey });
|
||||
|
||||
const fetch = useCallback(
|
||||
async (skip: number, take: number, filters: AlbumListFilter) => {
|
||||
const query: AlbumListQuery = {
|
||||
limit: take,
|
||||
startIndex: skip,
|
||||
...filters,
|
||||
...customFilters,
|
||||
_custom: {
|
||||
jellyfin: {
|
||||
...filters._custom?.jellyfin,
|
||||
...customFilters?._custom?.jellyfin,
|
||||
},
|
||||
navidrome: {
|
||||
...filters._custom?.navidrome,
|
||||
...customFilters?._custom?.navidrome,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.albums.list(server?.id || '', query);
|
||||
|
||||
const albums = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
controller.getAlbumList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query,
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
return albums;
|
||||
},
|
||||
[customFilters, queryClient, server],
|
||||
);
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
async (filters: AlbumListFilter) => {
|
||||
if (display === ListDisplayType.TABLE || display === ListDisplayType.TABLE_PAGINATED) {
|
||||
const dataSource: IDatasource = {
|
||||
getRows: async (params) => {
|
||||
const limit = params.endRow - params.startRow;
|
||||
const startIndex = params.startRow;
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
const { setFilter, setTablePagination } = useListStoreActions();
|
||||
const cq = useContainerQuery();
|
||||
const { id, pageKey } = useAlbumListContext();
|
||||
const { display } = useAlbumListStore({ id, key: pageKey });
|
||||
const filter = useAlbumListFilter({ id, key: pageKey });
|
||||
|
||||
const fetch = useCallback(
|
||||
async (skip: number, take: number, filters: AlbumListFilter) => {
|
||||
const query: AlbumListQuery = {
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
...customFilters,
|
||||
_custom: {
|
||||
jellyfin: {
|
||||
...filters._custom?.jellyfin,
|
||||
...customFilters?._custom?.jellyfin,
|
||||
limit: take,
|
||||
startIndex: skip,
|
||||
...filters,
|
||||
...customFilters,
|
||||
_custom: {
|
||||
jellyfin: {
|
||||
...filters._custom?.jellyfin,
|
||||
...customFilters?._custom?.jellyfin,
|
||||
},
|
||||
navidrome: {
|
||||
...filters._custom?.navidrome,
|
||||
...customFilters?._custom?.navidrome,
|
||||
},
|
||||
},
|
||||
navidrome: {
|
||||
...filters._custom?.navidrome,
|
||||
...customFilters?._custom?.navidrome,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.albums.list(server?.id || '', query);
|
||||
|
||||
const albumsRes = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getAlbumList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query,
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
const albums = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
controller.getAlbumList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query,
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
params.successCallback(albumsRes?.items || [], albumsRes?.totalRecordCount || 0);
|
||||
},
|
||||
rowCount: undefined,
|
||||
return albums;
|
||||
},
|
||||
[customFilters, queryClient, server],
|
||||
);
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
async (filters: AlbumListFilter) => {
|
||||
if (display === ListDisplayType.TABLE || display === ListDisplayType.TABLE_PAGINATED) {
|
||||
const dataSource: IDatasource = {
|
||||
getRows: async (params) => {
|
||||
const limit = params.endRow - params.startRow;
|
||||
const startIndex = params.startRow;
|
||||
|
||||
const query: AlbumListQuery = {
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
...customFilters,
|
||||
_custom: {
|
||||
jellyfin: {
|
||||
...filters._custom?.jellyfin,
|
||||
...customFilters?._custom?.jellyfin,
|
||||
},
|
||||
navidrome: {
|
||||
...filters._custom?.navidrome,
|
||||
...customFilters?._custom?.navidrome,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.albums.list(server?.id || '', query);
|
||||
|
||||
const albumsRes = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getAlbumList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query,
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
params.successCallback(
|
||||
albumsRes?.items || [],
|
||||
albumsRes?.totalRecordCount || 0,
|
||||
);
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
tableRef.current?.api.setDatasource(dataSource);
|
||||
tableRef.current?.api.purgeInfiniteCache();
|
||||
tableRef.current?.api.ensureIndexVisible(0, 'top');
|
||||
|
||||
if (display === ListDisplayType.TABLE_PAGINATED) {
|
||||
setTablePagination({ data: { currentPage: 0 }, key: 'album' });
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
},
|
||||
[display, tableRef, customFilters, server, queryClient, setTablePagination, gridRef, fetch],
|
||||
);
|
||||
|
||||
const handleSearch = debounce((e: ChangeEvent<HTMLInputElement>) => {
|
||||
const previousSearchTerm = filter.searchTerm;
|
||||
const searchTerm = e.target.value === '' ? undefined : e.target.value;
|
||||
const updatedFilters = setFilter({
|
||||
data: { searchTerm },
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: 'album',
|
||||
}) as AlbumListFilter;
|
||||
if (previousSearchTerm !== searchTerm) handleFilterChange(updatedFilters);
|
||||
}, 500);
|
||||
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
const handlePlay = async (playType: Play) => {
|
||||
if (!itemCount || itemCount === 0) return;
|
||||
|
||||
const query = {
|
||||
startIndex: 0,
|
||||
...filter,
|
||||
...customFilters,
|
||||
_custom: {
|
||||
jellyfin: {
|
||||
...filter._custom?.jellyfin,
|
||||
...customFilters?._custom?.jellyfin,
|
||||
},
|
||||
navidrome: {
|
||||
...filter._custom?.navidrome,
|
||||
...customFilters?._custom?.navidrome,
|
||||
},
|
||||
},
|
||||
};
|
||||
tableRef.current?.api.setDatasource(dataSource);
|
||||
tableRef.current?.api.purgeInfiniteCache();
|
||||
tableRef.current?.api.ensureIndexVisible(0, 'top');
|
||||
const queryKey = queryKeys.albums.list(server?.id || '', query);
|
||||
|
||||
if (display === ListDisplayType.TABLE_PAGINATED) {
|
||||
setTablePagination({ data: { currentPage: 0 }, key: 'album' });
|
||||
}
|
||||
} else {
|
||||
gridRef.current?.scrollTo(0);
|
||||
gridRef.current?.resetLoadMoreItemsCache();
|
||||
const albumListRes = await queryClient.fetchQuery({
|
||||
queryFn: ({ signal }) =>
|
||||
api.controller.getAlbumList({ apiClientProps: { server, signal }, query }),
|
||||
queryKey,
|
||||
});
|
||||
|
||||
// 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);
|
||||
const albumIds = albumListRes?.items?.map((item) => item.id) || [];
|
||||
|
||||
if (!data?.items) return;
|
||||
gridRef.current?.setItemData(data.items);
|
||||
}
|
||||
},
|
||||
[display, tableRef, customFilters, server, queryClient, setTablePagination, gridRef, fetch],
|
||||
);
|
||||
|
||||
const handleSearch = debounce((e: ChangeEvent<HTMLInputElement>) => {
|
||||
const previousSearchTerm = filter.searchTerm;
|
||||
const searchTerm = e.target.value === '' ? undefined : e.target.value;
|
||||
const updatedFilters = setFilter({
|
||||
data: { searchTerm },
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: 'album',
|
||||
}) as AlbumListFilter;
|
||||
if (previousSearchTerm !== searchTerm) handleFilterChange(updatedFilters);
|
||||
}, 500);
|
||||
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
const handlePlay = async (playType: Play) => {
|
||||
if (!itemCount || itemCount === 0) return;
|
||||
|
||||
const query = {
|
||||
startIndex: 0,
|
||||
...filter,
|
||||
...customFilters,
|
||||
_custom: {
|
||||
jellyfin: {
|
||||
...filter._custom?.jellyfin,
|
||||
...customFilters?._custom?.jellyfin,
|
||||
},
|
||||
navidrome: {
|
||||
...filter._custom?.navidrome,
|
||||
...customFilters?._custom?.navidrome,
|
||||
},
|
||||
},
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: albumIds,
|
||||
type: LibraryItem.ALBUM,
|
||||
},
|
||||
playType,
|
||||
});
|
||||
};
|
||||
const queryKey = queryKeys.albums.list(server?.id || '', query);
|
||||
|
||||
const albumListRes = await queryClient.fetchQuery({
|
||||
queryFn: ({ signal }) =>
|
||||
api.controller.getAlbumList({ apiClientProps: { server, signal }, query }),
|
||||
queryKey,
|
||||
});
|
||||
|
||||
const albumIds = albumListRes?.items?.map((item) => item.id) || [];
|
||||
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: albumIds,
|
||||
type: LibraryItem.ALBUM,
|
||||
},
|
||||
playType,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack
|
||||
ref={cq.ref}
|
||||
spacing={0}
|
||||
>
|
||||
<PageHeader backgroundColor="var(--titlebar-bg)">
|
||||
<Flex
|
||||
justify="space-between"
|
||||
w="100%"
|
||||
return (
|
||||
<Stack
|
||||
ref={cq.ref}
|
||||
spacing={0}
|
||||
>
|
||||
<LibraryHeaderBar>
|
||||
<LibraryHeaderBar.PlayButton onClick={() => handlePlay(playButtonBehavior)} />
|
||||
<LibraryHeaderBar.Title>{title || 'Albums'}</LibraryHeaderBar.Title>
|
||||
<LibraryHeaderBar.Badge isLoading={itemCount === null || itemCount === undefined}>
|
||||
{itemCount}
|
||||
</LibraryHeaderBar.Badge>
|
||||
</LibraryHeaderBar>
|
||||
<Group>
|
||||
<SearchInput
|
||||
defaultValue={filter.searchTerm}
|
||||
openedWidth={cq.isMd ? 250 : cq.isSm ? 200 : 150}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
</Group>
|
||||
</Flex>
|
||||
</PageHeader>
|
||||
<FilterBar>
|
||||
<AlbumListHeaderFilters
|
||||
customFilters={customFilters}
|
||||
gridRef={gridRef}
|
||||
itemCount={itemCount}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
</FilterBar>
|
||||
</Stack>
|
||||
);
|
||||
<PageHeader backgroundColor="var(--titlebar-bg)">
|
||||
<Flex
|
||||
justify="space-between"
|
||||
w="100%"
|
||||
>
|
||||
<LibraryHeaderBar>
|
||||
<LibraryHeaderBar.PlayButton
|
||||
onClick={() => handlePlay(playButtonBehavior)}
|
||||
/>
|
||||
<LibraryHeaderBar.Title>{title || 'Albums'}</LibraryHeaderBar.Title>
|
||||
<LibraryHeaderBar.Badge
|
||||
isLoading={itemCount === null || itemCount === undefined}
|
||||
>
|
||||
{itemCount}
|
||||
</LibraryHeaderBar.Badge>
|
||||
</LibraryHeaderBar>
|
||||
<Group>
|
||||
<SearchInput
|
||||
defaultValue={filter.searchTerm}
|
||||
openedWidth={cq.isMd ? 250 : cq.isSm ? 200 : 150}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
</Group>
|
||||
</Flex>
|
||||
</PageHeader>
|
||||
<FilterBar>
|
||||
<AlbumListHeaderFilters
|
||||
customFilters={customFilters}
|
||||
gridRef={gridRef}
|
||||
itemCount={itemCount}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
</FilterBar>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { useMemo, useCallback } from 'react';
|
||||
import {
|
||||
ColDef,
|
||||
GridReadyEvent,
|
||||
IDatasource,
|
||||
PaginationChangedEvent,
|
||||
BodyScrollEvent,
|
||||
RowDoubleClickedEvent,
|
||||
ColDef,
|
||||
GridReadyEvent,
|
||||
IDatasource,
|
||||
PaginationChangedEvent,
|
||||
BodyScrollEvent,
|
||||
RowDoubleClickedEvent,
|
||||
} from '@ag-grid-community/core';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
|
|
@ -13,10 +13,10 @@ import { AlbumListQuery, LibraryItem } from '/@/renderer/api/types';
|
|||
import { getColumnDefs, TablePagination, VirtualTable } from '/@/renderer/components/virtual-table';
|
||||
import { useAlbumListContext } from '/@/renderer/features/albums/context/album-list-context';
|
||||
import {
|
||||
useCurrentServer,
|
||||
useAlbumListFilter,
|
||||
useListStoreActions,
|
||||
useAlbumListStore,
|
||||
useCurrentServer,
|
||||
useAlbumListFilter,
|
||||
useListStoreActions,
|
||||
useAlbumListStore,
|
||||
} from '/@/renderer/store';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
|
|
@ -29,179 +29,188 @@ import { AppRoute } from '/@/renderer/router/routes';
|
|||
import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid';
|
||||
|
||||
export const AlbumListTableView = ({ tableRef, itemCount }: any) => {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const server = useCurrentServer();
|
||||
const { id, pageKey } = useAlbumListContext();
|
||||
const filter = useAlbumListFilter({ id, key: pageKey });
|
||||
const { setTable, setTablePagination } = useListStoreActions();
|
||||
const { table, display } = useAlbumListStore({ id, key: pageKey });
|
||||
const columnDefs: ColDef[] = useMemo(() => getColumnDefs(table.columns), [table.columns]);
|
||||
const isPaginationEnabled = display === ListDisplayType.TABLE_PAGINATED;
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const server = useCurrentServer();
|
||||
const { id, pageKey } = useAlbumListContext();
|
||||
const filter = useAlbumListFilter({ id, key: pageKey });
|
||||
const { setTable, setTablePagination } = useListStoreActions();
|
||||
const { table, display } = useAlbumListStore({ id, key: pageKey });
|
||||
const columnDefs: ColDef[] = useMemo(() => getColumnDefs(table.columns), [table.columns]);
|
||||
const isPaginationEnabled = display === ListDisplayType.TABLE_PAGINATED;
|
||||
|
||||
const onTableReady = useCallback(
|
||||
(params: GridReadyEvent) => {
|
||||
const dataSource: IDatasource = {
|
||||
getRows: async (params) => {
|
||||
const limit = params.endRow - params.startRow;
|
||||
const startIndex = params.startRow;
|
||||
const onTableReady = useCallback(
|
||||
(params: GridReadyEvent) => {
|
||||
const dataSource: IDatasource = {
|
||||
getRows: async (params) => {
|
||||
const limit = params.endRow - params.startRow;
|
||||
const startIndex = params.startRow;
|
||||
|
||||
const query: AlbumListQuery = {
|
||||
limit,
|
||||
startIndex,
|
||||
...filter,
|
||||
_custom: {
|
||||
jellyfin: {
|
||||
...filter._custom?.jellyfin,
|
||||
},
|
||||
navidrome: {
|
||||
...filter._custom?.navidrome,
|
||||
},
|
||||
},
|
||||
};
|
||||
const query: AlbumListQuery = {
|
||||
limit,
|
||||
startIndex,
|
||||
...filter,
|
||||
_custom: {
|
||||
jellyfin: {
|
||||
...filter._custom?.jellyfin,
|
||||
},
|
||||
navidrome: {
|
||||
...filter._custom?.navidrome,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.albums.list(server?.id || '', query);
|
||||
const queryKey = queryKeys.albums.list(server?.id || '', query);
|
||||
|
||||
if (!server) {
|
||||
return params.failCallback();
|
||||
}
|
||||
if (!server) {
|
||||
return params.failCallback();
|
||||
}
|
||||
|
||||
const albumsRes = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getAlbumList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
const albumsRes = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getAlbumList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query,
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
return params.successCallback(
|
||||
albumsRes?.items || [],
|
||||
albumsRes?.totalRecordCount || 0,
|
||||
);
|
||||
},
|
||||
query,
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
return params.successCallback(albumsRes?.items || [], albumsRes?.totalRecordCount || 0);
|
||||
rowCount: undefined,
|
||||
};
|
||||
params.api.setDatasource(dataSource);
|
||||
params.api.ensureIndexVisible(table.scrollOffset || 0, 'top');
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
params.api.setDatasource(dataSource);
|
||||
params.api.ensureIndexVisible(table.scrollOffset || 0, 'top');
|
||||
},
|
||||
[filter, queryClient, server, table.scrollOffset],
|
||||
);
|
||||
[filter, queryClient, server, table.scrollOffset],
|
||||
);
|
||||
|
||||
const onTablePaginationChanged = useCallback(
|
||||
(event: PaginationChangedEvent) => {
|
||||
if (!isPaginationEnabled || !event.api) return;
|
||||
const onTablePaginationChanged = useCallback(
|
||||
(event: PaginationChangedEvent) => {
|
||||
if (!isPaginationEnabled || !event.api) return;
|
||||
|
||||
try {
|
||||
// Scroll to top of page on pagination change
|
||||
const currentPageStartIndex = table.pagination.currentPage * table.pagination.itemsPerPage;
|
||||
event.api?.ensureIndexVisible(currentPageStartIndex, 'top');
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
try {
|
||||
// Scroll to top of page on pagination change
|
||||
const currentPageStartIndex =
|
||||
table.pagination.currentPage * table.pagination.itemsPerPage;
|
||||
event.api?.ensureIndexVisible(currentPageStartIndex, 'top');
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
|
||||
setTablePagination({
|
||||
data: {
|
||||
itemsPerPage: event.api.paginationGetPageSize(),
|
||||
totalItems: event.api.paginationGetRowCount(),
|
||||
totalPages: event.api.paginationGetTotalPages() + 1,
|
||||
setTablePagination({
|
||||
data: {
|
||||
itemsPerPage: event.api.paginationGetPageSize(),
|
||||
totalItems: event.api.paginationGetRowCount(),
|
||||
totalPages: event.api.paginationGetTotalPages() + 1,
|
||||
},
|
||||
key: pageKey,
|
||||
});
|
||||
},
|
||||
key: pageKey,
|
||||
});
|
||||
},
|
||||
[
|
||||
isPaginationEnabled,
|
||||
setTablePagination,
|
||||
pageKey,
|
||||
table.pagination.currentPage,
|
||||
table.pagination.itemsPerPage,
|
||||
],
|
||||
);
|
||||
[
|
||||
isPaginationEnabled,
|
||||
setTablePagination,
|
||||
pageKey,
|
||||
table.pagination.currentPage,
|
||||
table.pagination.itemsPerPage,
|
||||
],
|
||||
);
|
||||
|
||||
const handleTableColumnChange = useCallback(() => {
|
||||
const { columnApi } = tableRef?.current || {};
|
||||
const columnsOrder = columnApi?.getAllGridColumns();
|
||||
const handleTableColumnChange = useCallback(() => {
|
||||
const { columnApi } = tableRef?.current || {};
|
||||
const columnsOrder = columnApi?.getAllGridColumns();
|
||||
|
||||
if (!columnsOrder) return;
|
||||
if (!columnsOrder) return;
|
||||
|
||||
const columnsInSettings = table.columns;
|
||||
const updatedColumns = [];
|
||||
for (const column of columnsOrder) {
|
||||
const columnInSettings = columnsInSettings.find((c) => c.column === column.getColDef().colId);
|
||||
const columnsInSettings = table.columns;
|
||||
const updatedColumns = [];
|
||||
for (const column of columnsOrder) {
|
||||
const columnInSettings = columnsInSettings.find(
|
||||
(c) => c.column === column.getColDef().colId,
|
||||
);
|
||||
|
||||
if (columnInSettings) {
|
||||
updatedColumns.push({
|
||||
...columnInSettings,
|
||||
...(!table.autoFit && {
|
||||
width: column.getColDef().width,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (columnInSettings) {
|
||||
updatedColumns.push({
|
||||
...columnInSettings,
|
||||
...(!table.autoFit && {
|
||||
width: column.getColDef().width,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setTable({ data: { columns: updatedColumns }, key: pageKey });
|
||||
}, [tableRef, table.columns, table.autoFit, setTable, pageKey]);
|
||||
setTable({ data: { columns: updatedColumns }, key: pageKey });
|
||||
}, [tableRef, table.columns, table.autoFit, setTable, pageKey]);
|
||||
|
||||
const debouncedTableColumnChange = debounce(handleTableColumnChange, 200);
|
||||
const debouncedTableColumnChange = debounce(handleTableColumnChange, 200);
|
||||
|
||||
const handleTableScroll = (e: BodyScrollEvent) => {
|
||||
const scrollOffset = Number((e.top / table.rowHeight).toFixed(0));
|
||||
setTable({ data: { scrollOffset }, key: pageKey });
|
||||
};
|
||||
const handleTableScroll = (e: BodyScrollEvent) => {
|
||||
const scrollOffset = Number((e.top / table.rowHeight).toFixed(0));
|
||||
setTable({ data: { scrollOffset }, key: pageKey });
|
||||
};
|
||||
|
||||
const handleContextMenu = useHandleTableContextMenu(LibraryItem.ALBUM, ALBUM_CONTEXT_MENU_ITEMS);
|
||||
const handleContextMenu = useHandleTableContextMenu(
|
||||
LibraryItem.ALBUM,
|
||||
ALBUM_CONTEXT_MENU_ITEMS,
|
||||
);
|
||||
|
||||
const handleRowDoubleClick = (e: RowDoubleClickedEvent) => {
|
||||
navigate(generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, { albumId: e.data.id }));
|
||||
};
|
||||
const handleRowDoubleClick = (e: RowDoubleClickedEvent) => {
|
||||
navigate(generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, { albumId: e.data.id }));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<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-${display}-${table.rowHeight}-${server?.id}`}
|
||||
ref={tableRef}
|
||||
alwaysShowHorizontalScroll
|
||||
suppressRowDrag
|
||||
autoFitColumns={table.autoFit}
|
||||
blockLoadDebounceMillis={200}
|
||||
columnDefs={columnDefs}
|
||||
getRowId={(data) => data.data.id}
|
||||
infiniteInitialRowCount={itemCount || 100}
|
||||
pagination={isPaginationEnabled}
|
||||
paginationAutoPageSize={isPaginationEnabled}
|
||||
paginationPageSize={table.pagination.itemsPerPage || 100}
|
||||
rowBuffer={20}
|
||||
rowHeight={table.rowHeight || 40}
|
||||
rowModelType="infinite"
|
||||
onBodyScrollEnd={handleTableScroll}
|
||||
onCellContextMenu={handleContextMenu}
|
||||
onColumnMoved={handleTableColumnChange}
|
||||
onColumnResized={debouncedTableColumnChange}
|
||||
onGridReady={onTableReady}
|
||||
onPaginationChanged={onTablePaginationChanged}
|
||||
onRowDoubleClicked={handleRowDoubleClick}
|
||||
/>
|
||||
</VirtualGridAutoSizerContainer>
|
||||
{isPaginationEnabled && (
|
||||
<AnimatePresence
|
||||
presenceAffectsLayout
|
||||
initial={false}
|
||||
mode="wait"
|
||||
>
|
||||
{display === ListDisplayType.TABLE_PAGINATED && (
|
||||
<TablePagination
|
||||
pageKey={pageKey}
|
||||
pagination={table.pagination}
|
||||
setPagination={setTablePagination}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<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-${display}-${table.rowHeight}-${server?.id}`}
|
||||
ref={tableRef}
|
||||
alwaysShowHorizontalScroll
|
||||
suppressRowDrag
|
||||
autoFitColumns={table.autoFit}
|
||||
blockLoadDebounceMillis={200}
|
||||
columnDefs={columnDefs}
|
||||
getRowId={(data) => data.data.id}
|
||||
infiniteInitialRowCount={itemCount || 100}
|
||||
pagination={isPaginationEnabled}
|
||||
paginationAutoPageSize={isPaginationEnabled}
|
||||
paginationPageSize={table.pagination.itemsPerPage || 100}
|
||||
rowBuffer={20}
|
||||
rowHeight={table.rowHeight || 40}
|
||||
rowModelType="infinite"
|
||||
onBodyScrollEnd={handleTableScroll}
|
||||
onCellContextMenu={handleContextMenu}
|
||||
onColumnMoved={handleTableColumnChange}
|
||||
onColumnResized={debouncedTableColumnChange}
|
||||
onGridReady={onTableReady}
|
||||
onPaginationChanged={onTablePaginationChanged}
|
||||
onRowDoubleClicked={handleRowDoubleClick}
|
||||
/>
|
||||
</VirtualGridAutoSizerContainer>
|
||||
{isPaginationEnabled && (
|
||||
<AnimatePresence
|
||||
presenceAffectsLayout
|
||||
initial={false}
|
||||
mode="wait"
|
||||
>
|
||||
{display === ListDisplayType.TABLE_PAGINATED && (
|
||||
<TablePagination
|
||||
pageKey={pageKey}
|
||||
pagination={table.pagination}
|
||||
setPagination={setTablePagination}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,220 +8,220 @@ import { AlbumArtistListSort, LibraryItem, SortOrder } from '/@/renderer/api/typ
|
|||
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
|
||||
|
||||
interface JellyfinAlbumFiltersProps {
|
||||
disableArtistFilter?: boolean;
|
||||
handleFilterChange: (filters: AlbumListFilter) => void;
|
||||
id?: string;
|
||||
pageKey: string;
|
||||
serverId?: string;
|
||||
disableArtistFilter?: boolean;
|
||||
handleFilterChange: (filters: AlbumListFilter) => void;
|
||||
id?: string;
|
||||
pageKey: string;
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
export const JellyfinAlbumFilters = ({
|
||||
disableArtistFilter,
|
||||
handleFilterChange,
|
||||
pageKey,
|
||||
id,
|
||||
serverId,
|
||||
disableArtistFilter,
|
||||
handleFilterChange,
|
||||
pageKey,
|
||||
id,
|
||||
serverId,
|
||||
}: JellyfinAlbumFiltersProps) => {
|
||||
const filter = useAlbumListFilter({ id, key: pageKey });
|
||||
const { setFilter } = useListStoreActions();
|
||||
const filter = useAlbumListFilter({ id, key: pageKey });
|
||||
const { setFilter } = useListStoreActions();
|
||||
|
||||
// TODO - eventually replace with /items/filters endpoint to fetch genres and tags specific to the selected library
|
||||
const genreListQuery = useGenreList({ query: null, serverId });
|
||||
// TODO - eventually replace with /items/filters endpoint to fetch genres and tags specific to the selected library
|
||||
const genreListQuery = useGenreList({ query: null, serverId });
|
||||
|
||||
const genreList = useMemo(() => {
|
||||
if (!genreListQuery?.data) return [];
|
||||
return genreListQuery.data.items.map((genre) => ({
|
||||
label: genre.name,
|
||||
value: genre.id,
|
||||
}));
|
||||
}, [genreListQuery.data]);
|
||||
const genreList = useMemo(() => {
|
||||
if (!genreListQuery?.data) return [];
|
||||
return genreListQuery.data.items.map((genre) => ({
|
||||
label: genre.name,
|
||||
value: genre.id,
|
||||
}));
|
||||
}, [genreListQuery.data]);
|
||||
|
||||
const selectedGenres = useMemo(() => {
|
||||
return filter._custom?.jellyfin?.GenreIds?.split(',');
|
||||
}, [filter._custom?.jellyfin?.GenreIds]);
|
||||
const selectedGenres = useMemo(() => {
|
||||
return filter._custom?.jellyfin?.GenreIds?.split(',');
|
||||
}, [filter._custom?.jellyfin?.GenreIds]);
|
||||
|
||||
const toggleFilters = [
|
||||
{
|
||||
label: 'Is favorited',
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
jellyfin: {
|
||||
...filter._custom?.jellyfin,
|
||||
IsFavorite: e.currentTarget.checked ? true : undefined,
|
||||
},
|
||||
const toggleFilters = [
|
||||
{
|
||||
label: 'Is favorited',
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
jellyfin: {
|
||||
...filter._custom?.jellyfin,
|
||||
IsFavorite: e.currentTarget.checked ? true : undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
},
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
value: filter._custom?.jellyfin?.IsFavorite,
|
||||
},
|
||||
];
|
||||
|
||||
const handleMinYearFilter = debounce((e: number | string) => {
|
||||
if (typeof e === 'number' && (e < 1700 || e > 2300)) return;
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
jellyfin: {
|
||||
...filter._custom?.jellyfin,
|
||||
minYear: e === '' ? undefined : (e as number),
|
||||
},
|
||||
},
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
},
|
||||
value: filter._custom?.jellyfin?.IsFavorite,
|
||||
},
|
||||
];
|
||||
}, 500);
|
||||
|
||||
const handleMinYearFilter = debounce((e: number | string) => {
|
||||
if (typeof e === 'number' && (e < 1700 || e > 2300)) return;
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
jellyfin: {
|
||||
...filter._custom?.jellyfin,
|
||||
minYear: e === '' ? undefined : (e as number),
|
||||
},
|
||||
const handleMaxYearFilter = debounce((e: number | string) => {
|
||||
if (typeof e === 'number' && (e < 1700 || e > 2300)) return;
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
jellyfin: {
|
||||
...filter._custom?.jellyfin,
|
||||
maxYear: e === '' ? undefined : (e as number),
|
||||
},
|
||||
},
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
}, 500);
|
||||
|
||||
const handleGenresFilter = debounce((e: string[] | undefined) => {
|
||||
const genreFilterString = e?.length ? e.join(',') : undefined;
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
jellyfin: {
|
||||
...filter._custom?.jellyfin,
|
||||
GenreIds: genreFilterString,
|
||||
},
|
||||
},
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
}, 250);
|
||||
|
||||
const [albumArtistSearchTerm, setAlbumArtistSearchTerm] = useState<string>('');
|
||||
|
||||
const albumArtistListQuery = useAlbumArtistList({
|
||||
options: {
|
||||
cacheTime: 1000 * 60 * 2,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
},
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
}, 500);
|
||||
|
||||
const handleMaxYearFilter = debounce((e: number | string) => {
|
||||
if (typeof e === 'number' && (e < 1700 || e > 2300)) return;
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
jellyfin: {
|
||||
...filter._custom?.jellyfin,
|
||||
maxYear: e === '' ? undefined : (e as number),
|
||||
},
|
||||
query: {
|
||||
sortBy: AlbumArtistListSort.NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
}, 500);
|
||||
serverId,
|
||||
});
|
||||
|
||||
const handleGenresFilter = debounce((e: string[] | undefined) => {
|
||||
const genreFilterString = e?.length ? e.join(',') : undefined;
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
jellyfin: {
|
||||
...filter._custom?.jellyfin,
|
||||
GenreIds: genreFilterString,
|
||||
},
|
||||
},
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
}, 250);
|
||||
const selectableAlbumArtists = useMemo(() => {
|
||||
if (!albumArtistListQuery?.data?.items) return [];
|
||||
|
||||
const [albumArtistSearchTerm, setAlbumArtistSearchTerm] = useState<string>('');
|
||||
return albumArtistListQuery?.data?.items?.map((artist) => ({
|
||||
label: artist.name,
|
||||
value: artist.id,
|
||||
}));
|
||||
}, [albumArtistListQuery?.data?.items]);
|
||||
|
||||
const albumArtistListQuery = useAlbumArtistList({
|
||||
options: {
|
||||
cacheTime: 1000 * 60 * 2,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
},
|
||||
query: {
|
||||
sortBy: AlbumArtistListSort.NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId,
|
||||
});
|
||||
const handleAlbumArtistFilter = (e: string[] | null) => {
|
||||
const albumArtistFilterString = e?.length ? e.join(',') : undefined;
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
jellyfin: {
|
||||
...filter._custom?.jellyfin,
|
||||
AlbumArtistIds: albumArtistFilterString,
|
||||
},
|
||||
},
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
};
|
||||
|
||||
const selectableAlbumArtists = useMemo(() => {
|
||||
if (!albumArtistListQuery?.data?.items) return [];
|
||||
return (
|
||||
<Stack p="0.8rem">
|
||||
{toggleFilters.map((filter) => (
|
||||
<Group
|
||||
key={`nd-filter-${filter.label}`}
|
||||
position="apart"
|
||||
>
|
||||
<Text>{filter.label}</Text>
|
||||
<Switch
|
||||
checked={filter?.value || false}
|
||||
size="xs"
|
||||
onChange={filter.onChange}
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
<Divider my="0.5rem" />
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
defaultValue={filter._custom?.jellyfin?.minYear}
|
||||
hideControls={false}
|
||||
label="From year"
|
||||
max={2300}
|
||||
min={1700}
|
||||
required={!!filter._custom?.jellyfin?.maxYear}
|
||||
onChange={(e) => handleMinYearFilter(e)}
|
||||
/>
|
||||
<NumberInput
|
||||
defaultValue={filter._custom?.jellyfin?.maxYear}
|
||||
hideControls={false}
|
||||
label="To year"
|
||||
max={2300}
|
||||
min={1700}
|
||||
required={!!filter._custom?.jellyfin?.minYear}
|
||||
onChange={(e) => handleMaxYearFilter(e)}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<MultiSelect
|
||||
clearable
|
||||
searchable
|
||||
data={genreList}
|
||||
defaultValue={selectedGenres}
|
||||
label="Genres"
|
||||
onChange={handleGenresFilter}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
return albumArtistListQuery?.data?.items?.map((artist) => ({
|
||||
label: artist.name,
|
||||
value: artist.id,
|
||||
}));
|
||||
}, [albumArtistListQuery?.data?.items]);
|
||||
|
||||
const handleAlbumArtistFilter = (e: string[] | null) => {
|
||||
const albumArtistFilterString = e?.length ? e.join(',') : undefined;
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
jellyfin: {
|
||||
...filter._custom?.jellyfin,
|
||||
AlbumArtistIds: albumArtistFilterString,
|
||||
},
|
||||
},
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack p="0.8rem">
|
||||
{toggleFilters.map((filter) => (
|
||||
<Group
|
||||
key={`nd-filter-${filter.label}`}
|
||||
position="apart"
|
||||
>
|
||||
<Text>{filter.label}</Text>
|
||||
<Switch
|
||||
checked={filter?.value || false}
|
||||
size="xs"
|
||||
onChange={filter.onChange}
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
<Divider my="0.5rem" />
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
defaultValue={filter._custom?.jellyfin?.minYear}
|
||||
hideControls={false}
|
||||
label="From year"
|
||||
max={2300}
|
||||
min={1700}
|
||||
required={!!filter._custom?.jellyfin?.maxYear}
|
||||
onChange={(e) => handleMinYearFilter(e)}
|
||||
/>
|
||||
<NumberInput
|
||||
defaultValue={filter._custom?.jellyfin?.maxYear}
|
||||
hideControls={false}
|
||||
label="To year"
|
||||
max={2300}
|
||||
min={1700}
|
||||
required={!!filter._custom?.jellyfin?.minYear}
|
||||
onChange={(e) => handleMaxYearFilter(e)}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<MultiSelect
|
||||
clearable
|
||||
searchable
|
||||
data={genreList}
|
||||
defaultValue={selectedGenres}
|
||||
label="Genres"
|
||||
onChange={handleGenresFilter}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group grow>
|
||||
<MultiSelect
|
||||
clearable
|
||||
searchable
|
||||
data={selectableAlbumArtists}
|
||||
defaultValue={filter._custom?.jellyfin?.AlbumArtistIds?.split(',')}
|
||||
disabled={disableArtistFilter}
|
||||
label="Artist"
|
||||
limit={300}
|
||||
placeholder="Type to search for an artist"
|
||||
rightSection={albumArtistListQuery.isFetching ? <SpinnerIcon /> : undefined}
|
||||
searchValue={albumArtistSearchTerm}
|
||||
onChange={handleAlbumArtistFilter}
|
||||
onSearchChange={setAlbumArtistSearchTerm}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
<Group grow>
|
||||
<MultiSelect
|
||||
clearable
|
||||
searchable
|
||||
data={selectableAlbumArtists}
|
||||
defaultValue={filter._custom?.jellyfin?.AlbumArtistIds?.split(',')}
|
||||
disabled={disableArtistFilter}
|
||||
label="Artist"
|
||||
limit={300}
|
||||
placeholder="Type to search for an artist"
|
||||
rightSection={albumArtistListQuery.isFetching ? <SpinnerIcon /> : undefined}
|
||||
searchValue={albumArtistSearchTerm}
|
||||
onChange={handleAlbumArtistFilter}
|
||||
onSearchChange={setAlbumArtistSearchTerm}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,241 +8,241 @@ import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-a
|
|||
import { AlbumArtistListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
|
||||
|
||||
interface NavidromeAlbumFiltersProps {
|
||||
disableArtistFilter?: boolean;
|
||||
handleFilterChange: (filters: AlbumListFilter) => void;
|
||||
id?: string;
|
||||
pageKey: string;
|
||||
serverId?: string;
|
||||
disableArtistFilter?: boolean;
|
||||
handleFilterChange: (filters: AlbumListFilter) => void;
|
||||
id?: string;
|
||||
pageKey: string;
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
export const NavidromeAlbumFilters = ({
|
||||
handleFilterChange,
|
||||
disableArtistFilter,
|
||||
pageKey,
|
||||
id,
|
||||
serverId,
|
||||
}: NavidromeAlbumFiltersProps) => {
|
||||
const filter = useAlbumListFilter({ id, key: pageKey });
|
||||
const { setFilter } = useListStoreActions();
|
||||
|
||||
const genreListQuery = useGenreList({ query: null, serverId });
|
||||
|
||||
const genreList = useMemo(() => {
|
||||
if (!genreListQuery?.data) return [];
|
||||
return genreListQuery.data.items.map((genre) => ({
|
||||
label: genre.name,
|
||||
value: genre.id,
|
||||
}));
|
||||
}, [genreListQuery.data]);
|
||||
|
||||
const handleGenresFilter = debounce((e: string | null) => {
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
navidrome: {
|
||||
...filter._custom?.navidrome,
|
||||
genre_id: e || undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: 'album',
|
||||
}) as AlbumListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
}, 250);
|
||||
|
||||
const toggleFilters = [
|
||||
{
|
||||
label: 'Is rated',
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
navidrome: {
|
||||
...filter._custom?.navidrome,
|
||||
has_rating: e.currentTarget.checked ? true : undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
},
|
||||
value: filter._custom?.navidrome?.has_rating,
|
||||
},
|
||||
{
|
||||
label: 'Is favorited',
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
navidrome: {
|
||||
...filter._custom?.navidrome,
|
||||
starred: e.currentTarget.checked ? true : undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
},
|
||||
value: filter._custom?.navidrome?.starred,
|
||||
},
|
||||
{
|
||||
label: 'Is compilation',
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
navidrome: {
|
||||
...filter._custom?.navidrome,
|
||||
compilation: e.currentTarget.checked ? true : undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
},
|
||||
value: filter._custom?.navidrome?.compilation,
|
||||
},
|
||||
{
|
||||
label: 'Is recently played',
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
navidrome: {
|
||||
...filter._custom?.navidrome,
|
||||
recently_played: e.currentTarget.checked ? true : undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
},
|
||||
value: filter._custom?.navidrome?.recently_played,
|
||||
},
|
||||
];
|
||||
|
||||
const handleYearFilter = debounce((e: number | string) => {
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
_custom: {
|
||||
navidrome: {
|
||||
...filter._custom?.navidrome,
|
||||
year: e === '' ? undefined : (e as number),
|
||||
},
|
||||
...filter._custom,
|
||||
},
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
}, 500);
|
||||
|
||||
const [albumArtistSearchTerm, setAlbumArtistSearchTerm] = useState<string>('');
|
||||
|
||||
const albumArtistListQuery = useAlbumArtistList({
|
||||
options: {
|
||||
cacheTime: 1000 * 60 * 2,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
},
|
||||
query: {
|
||||
// searchTerm: debouncedSearchTerm,
|
||||
sortBy: AlbumArtistListSort.NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
handleFilterChange,
|
||||
disableArtistFilter,
|
||||
pageKey,
|
||||
id,
|
||||
serverId,
|
||||
});
|
||||
}: NavidromeAlbumFiltersProps) => {
|
||||
const filter = useAlbumListFilter({ id, key: pageKey });
|
||||
const { setFilter } = useListStoreActions();
|
||||
|
||||
const selectableAlbumArtists = useMemo(() => {
|
||||
if (!albumArtistListQuery?.data?.items) return [];
|
||||
const genreListQuery = useGenreList({ query: null, serverId });
|
||||
|
||||
return albumArtistListQuery?.data?.items?.map((artist) => ({
|
||||
label: artist.name,
|
||||
value: artist.id,
|
||||
}));
|
||||
}, [albumArtistListQuery?.data?.items]);
|
||||
const genreList = useMemo(() => {
|
||||
if (!genreListQuery?.data) return [];
|
||||
return genreListQuery.data.items.map((genre) => ({
|
||||
label: genre.name,
|
||||
value: genre.id,
|
||||
}));
|
||||
}, [genreListQuery.data]);
|
||||
|
||||
const handleAlbumArtistFilter = (e: string | null) => {
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
navidrome: {
|
||||
...filter._custom?.navidrome,
|
||||
artist_id: e || undefined,
|
||||
},
|
||||
const handleGenresFilter = debounce((e: string | null) => {
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
navidrome: {
|
||||
...filter._custom?.navidrome,
|
||||
genre_id: e || undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: 'album',
|
||||
}) as AlbumListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
}, 250);
|
||||
|
||||
const toggleFilters = [
|
||||
{
|
||||
label: 'Is rated',
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
navidrome: {
|
||||
...filter._custom?.navidrome,
|
||||
has_rating: e.currentTarget.checked ? true : undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
},
|
||||
value: filter._custom?.navidrome?.has_rating,
|
||||
},
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
};
|
||||
{
|
||||
label: 'Is favorited',
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
navidrome: {
|
||||
...filter._custom?.navidrome,
|
||||
starred: e.currentTarget.checked ? true : undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
},
|
||||
value: filter._custom?.navidrome?.starred,
|
||||
},
|
||||
{
|
||||
label: 'Is compilation',
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
navidrome: {
|
||||
...filter._custom?.navidrome,
|
||||
compilation: e.currentTarget.checked ? true : undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
},
|
||||
value: filter._custom?.navidrome?.compilation,
|
||||
},
|
||||
{
|
||||
label: 'Is recently played',
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
navidrome: {
|
||||
...filter._custom?.navidrome,
|
||||
recently_played: e.currentTarget.checked ? true : undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
},
|
||||
value: filter._custom?.navidrome?.recently_played,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack p="0.8rem">
|
||||
{toggleFilters.map((filter) => (
|
||||
<Group
|
||||
key={`nd-filter-${filter.label}`}
|
||||
position="apart"
|
||||
>
|
||||
<Text>{filter.label}</Text>
|
||||
<Switch
|
||||
checked={filter?.value || false}
|
||||
onChange={filter.onChange}
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
<Divider my="0.5rem" />
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
defaultValue={filter._custom?.navidrome?.year}
|
||||
hideControls={false}
|
||||
label="Year"
|
||||
max={5000}
|
||||
min={0}
|
||||
onChange={(e) => handleYearFilter(e)}
|
||||
/>
|
||||
<Select
|
||||
clearable
|
||||
searchable
|
||||
data={genreList}
|
||||
defaultValue={filter._custom?.navidrome?.genre_id}
|
||||
label="Genre"
|
||||
onChange={handleGenresFilter}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<Select
|
||||
clearable
|
||||
searchable
|
||||
data={selectableAlbumArtists}
|
||||
defaultValue={filter._custom?.navidrome?.artist_id}
|
||||
disabled={disableArtistFilter}
|
||||
label="Artist"
|
||||
limit={300}
|
||||
placeholder="Type to search for an artist"
|
||||
rightSection={albumArtistListQuery.isFetching ? <SpinnerIcon /> : undefined}
|
||||
searchValue={albumArtistSearchTerm}
|
||||
onChange={handleAlbumArtistFilter}
|
||||
onSearchChange={setAlbumArtistSearchTerm}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
const handleYearFilter = debounce((e: number | string) => {
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
_custom: {
|
||||
navidrome: {
|
||||
...filter._custom?.navidrome,
|
||||
year: e === '' ? undefined : (e as number),
|
||||
},
|
||||
...filter._custom,
|
||||
},
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
}, 500);
|
||||
|
||||
const [albumArtistSearchTerm, setAlbumArtistSearchTerm] = useState<string>('');
|
||||
|
||||
const albumArtistListQuery = useAlbumArtistList({
|
||||
options: {
|
||||
cacheTime: 1000 * 60 * 2,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
},
|
||||
query: {
|
||||
// searchTerm: debouncedSearchTerm,
|
||||
sortBy: AlbumArtistListSort.NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId,
|
||||
});
|
||||
|
||||
const selectableAlbumArtists = useMemo(() => {
|
||||
if (!albumArtistListQuery?.data?.items) return [];
|
||||
|
||||
return albumArtistListQuery?.data?.items?.map((artist) => ({
|
||||
label: artist.name,
|
||||
value: artist.id,
|
||||
}));
|
||||
}, [albumArtistListQuery?.data?.items]);
|
||||
|
||||
const handleAlbumArtistFilter = (e: string | null) => {
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
navidrome: {
|
||||
...filter._custom?.navidrome,
|
||||
artist_id: e || undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack p="0.8rem">
|
||||
{toggleFilters.map((filter) => (
|
||||
<Group
|
||||
key={`nd-filter-${filter.label}`}
|
||||
position="apart"
|
||||
>
|
||||
<Text>{filter.label}</Text>
|
||||
<Switch
|
||||
checked={filter?.value || false}
|
||||
onChange={filter.onChange}
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
<Divider my="0.5rem" />
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
defaultValue={filter._custom?.navidrome?.year}
|
||||
hideControls={false}
|
||||
label="Year"
|
||||
max={5000}
|
||||
min={0}
|
||||
onChange={(e) => handleYearFilter(e)}
|
||||
/>
|
||||
<Select
|
||||
clearable
|
||||
searchable
|
||||
data={genreList}
|
||||
defaultValue={filter._custom?.navidrome?.genre_id}
|
||||
label="Genre"
|
||||
onChange={handleGenresFilter}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<Select
|
||||
clearable
|
||||
searchable
|
||||
data={selectableAlbumArtists}
|
||||
defaultValue={filter._custom?.navidrome?.artist_id}
|
||||
disabled={disableArtistFilter}
|
||||
label="Artist"
|
||||
limit={300}
|
||||
placeholder="Type to search for an artist"
|
||||
rightSection={albumArtistListQuery.isFetching ? <SpinnerIcon /> : undefined}
|
||||
searchValue={albumArtistSearchTerm}
|
||||
onChange={handleAlbumArtistFilter}
|
||||
onSearchChange={setAlbumArtistSearchTerm}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue