Add files

This commit is contained in:
jeffvli 2022-12-19 15:59:14 -08:00
commit e87c814068
266 changed files with 63938 additions and 0 deletions

View file

@ -0,0 +1,190 @@
import { useAuthStore } from '/@/renderer/store';
import { navidromeApi } from '/@/renderer/api/navidrome.api';
import { toast } from '/@/renderer/components';
import type {
AlbumDetailArgs,
RawAlbumDetailResponse,
RawAlbumListResponse,
AlbumListArgs,
SongListArgs,
RawSongListResponse,
SongDetailArgs,
RawSongDetailResponse,
AlbumArtistDetailArgs,
RawAlbumArtistDetailResponse,
AlbumArtistListArgs,
RawAlbumArtistListResponse,
RatingArgs,
RawRatingResponse,
FavoriteArgs,
RawFavoriteResponse,
GenreListArgs,
RawGenreListResponse,
CreatePlaylistArgs,
RawCreatePlaylistResponse,
DeletePlaylistArgs,
RawDeletePlaylistResponse,
PlaylistDetailArgs,
RawPlaylistDetailResponse,
PlaylistListArgs,
RawPlaylistListResponse,
MusicFolderListArgs,
RawMusicFolderListResponse,
PlaylistSongListArgs,
ArtistListArgs,
RawArtistListResponse,
} from '/@/renderer/api/types';
import { subsonicApi } from '/@/renderer/api/subsonic.api';
import { jellyfinApi } from '/@/renderer/api/jellyfin.api';
export type ControllerEndpoint = Partial<{
clearPlaylist: () => void;
createFavorite: (args: FavoriteArgs) => Promise<RawFavoriteResponse>;
createPlaylist: (args: CreatePlaylistArgs) => Promise<RawCreatePlaylistResponse>;
deleteFavorite: (args: FavoriteArgs) => Promise<RawFavoriteResponse>;
deletePlaylist: (args: DeletePlaylistArgs) => Promise<RawDeletePlaylistResponse>;
getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise<RawAlbumArtistDetailResponse>;
getAlbumArtistList: (args: AlbumArtistListArgs) => Promise<RawAlbumArtistListResponse>;
getAlbumDetail: (args: AlbumDetailArgs) => Promise<RawAlbumDetailResponse>;
getAlbumList: (args: AlbumListArgs) => Promise<RawAlbumListResponse>;
getArtistDetail: () => void;
getArtistList: (args: ArtistListArgs) => Promise<RawArtistListResponse>;
getFavoritesList: () => void;
getFolderItemList: () => void;
getFolderList: () => void;
getFolderSongs: () => void;
getGenreList: (args: GenreListArgs) => Promise<RawGenreListResponse>;
getMusicFolderList: (args: MusicFolderListArgs) => Promise<RawMusicFolderListResponse>;
getPlaylistDetail: (args: PlaylistDetailArgs) => Promise<RawPlaylistDetailResponse>;
getPlaylistList: (args: PlaylistListArgs) => Promise<RawPlaylistListResponse>;
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<RawSongListResponse>;
getSongDetail: (args: SongDetailArgs) => Promise<RawSongDetailResponse>;
getSongList: (args: SongListArgs) => Promise<RawSongListResponse>;
updatePlaylist: () => void;
updateRating: (args: RatingArgs) => Promise<RawRatingResponse>;
}>;
type ApiController = {
jellyfin: ControllerEndpoint;
navidrome: ControllerEndpoint;
subsonic: ControllerEndpoint;
};
const endpoints: ApiController = {
jellyfin: {
clearPlaylist: undefined,
createFavorite: jellyfinApi.createFavorite,
createPlaylist: jellyfinApi.createPlaylist,
deleteFavorite: jellyfinApi.deleteFavorite,
deletePlaylist: jellyfinApi.deletePlaylist,
getAlbumArtistDetail: jellyfinApi.getAlbumArtistDetail,
getAlbumArtistList: jellyfinApi.getAlbumArtistList,
getAlbumDetail: jellyfinApi.getAlbumDetail,
getAlbumList: jellyfinApi.getAlbumList,
getArtistDetail: undefined,
getArtistList: jellyfinApi.getArtistList,
getFavoritesList: undefined,
getFolderItemList: undefined,
getFolderList: undefined,
getFolderSongs: undefined,
getGenreList: jellyfinApi.getGenreList,
getMusicFolderList: jellyfinApi.getMusicFolderList,
getPlaylistDetail: jellyfinApi.getPlaylistDetail,
getPlaylistList: jellyfinApi.getPlaylistList,
getPlaylistSongList: jellyfinApi.getPlaylistSongList,
getSongDetail: undefined,
getSongList: jellyfinApi.getSongList,
updatePlaylist: undefined,
updateRating: undefined,
},
navidrome: {
clearPlaylist: undefined,
createFavorite: subsonicApi.createFavorite,
createPlaylist: navidromeApi.createPlaylist,
deleteFavorite: subsonicApi.deleteFavorite,
deletePlaylist: navidromeApi.deletePlaylist,
getAlbumArtistDetail: navidromeApi.getAlbumArtistDetail,
getAlbumArtistList: navidromeApi.getAlbumArtistList,
getAlbumDetail: navidromeApi.getAlbumDetail,
getAlbumList: navidromeApi.getAlbumList,
getArtistDetail: undefined,
getArtistList: undefined,
getFavoritesList: undefined,
getFolderItemList: undefined,
getFolderList: undefined,
getFolderSongs: undefined,
getGenreList: navidromeApi.getGenreList,
getMusicFolderList: undefined,
getPlaylistDetail: navidromeApi.getPlaylistDetail,
getPlaylistList: navidromeApi.getPlaylistList,
getPlaylistSongList: navidromeApi.getPlaylistSongList,
getSongDetail: navidromeApi.getSongDetail,
getSongList: navidromeApi.getSongList,
updatePlaylist: undefined,
updateRating: subsonicApi.updateRating,
},
subsonic: {
clearPlaylist: undefined,
createFavorite: subsonicApi.createFavorite,
createPlaylist: undefined,
deleteFavorite: subsonicApi.deleteFavorite,
deletePlaylist: undefined,
getAlbumArtistDetail: subsonicApi.getAlbumArtistDetail,
getAlbumArtistList: subsonicApi.getAlbumArtistList,
getAlbumDetail: subsonicApi.getAlbumDetail,
getAlbumList: subsonicApi.getAlbumList,
getArtistDetail: undefined,
getArtistList: undefined,
getFavoritesList: undefined,
getFolderItemList: undefined,
getFolderList: undefined,
getFolderSongs: undefined,
getGenreList: undefined,
getMusicFolderList: undefined,
getPlaylistDetail: undefined,
getPlaylistList: undefined,
getSongDetail: undefined,
getSongList: undefined,
updatePlaylist: undefined,
updateRating: undefined,
},
};
const apiController = (endpoint: keyof ControllerEndpoint) => {
const serverType = useAuthStore.getState().currentServer?.type;
if (!serverType) {
toast.error({ message: 'No server selected', title: 'Unable to route request' });
return () => undefined;
}
const controllerFn = endpoints[serverType][endpoint];
if (typeof controllerFn !== 'function') {
toast.error({
message: `Endpoint ${endpoint} is not implemented for ${serverType}`,
title: 'Unable to route request',
});
return () => undefined;
}
return endpoints[serverType][endpoint];
};
const getAlbumList = async (args: AlbumListArgs) => {
return (apiController('getAlbumList') as ControllerEndpoint['getAlbumList'])?.(args);
};
const getAlbumDetail = async (args: AlbumDetailArgs) => {
return (apiController('getAlbumDetail') as ControllerEndpoint['getAlbumDetail'])?.(args);
};
const getSongList = async (args: SongListArgs) => {
return (apiController('getSongList') as ControllerEndpoint['getSongList'])?.(args);
};
export const controller = {
getAlbumDetail,
getAlbumList,
getSongList,
};

View file

@ -0,0 +1,7 @@
import { controller } from '/@/renderer/api/controller';
import { normalize } from '/@/renderer/api/normalize';
export const api = {
controller,
normalize,
};

View file

@ -0,0 +1,683 @@
import ky from 'ky';
import { nanoid } from 'nanoid/non-secure';
import type {
JFAlbum,
JFAlbumArtistDetail,
JFAlbumArtistDetailResponse,
JFAlbumArtistList,
JFAlbumArtistListParams,
JFAlbumArtistListResponse,
JFAlbumDetail,
JFAlbumDetailResponse,
JFAlbumList,
JFAlbumListParams,
JFAlbumListResponse,
JFArtistList,
JFArtistListParams,
JFArtistListResponse,
JFAuthenticate,
JFCreatePlaylistResponse,
JFGenreList,
JFGenreListResponse,
JFMusicFolderList,
JFMusicFolderListResponse,
JFPlaylistDetail,
JFPlaylistDetailResponse,
JFPlaylistList,
JFPlaylistListResponse,
JFSong,
JFSongList,
JFSongListParams,
JFSongListResponse,
} from '/@/renderer/api/jellyfin.types';
import { JFCollectionType } from '/@/renderer/api/jellyfin.types';
import type {
Album,
AlbumArtistDetailArgs,
AlbumArtistListArgs,
AlbumDetailArgs,
AlbumListArgs,
ArtistListArgs,
AuthenticationResponse,
CreatePlaylistArgs,
CreatePlaylistResponse,
DeletePlaylistArgs,
FavoriteArgs,
FavoriteResponse,
GenreListArgs,
MusicFolderListArgs,
PlaylistDetailArgs,
PlaylistListArgs,
PlaylistSongListArgs,
Song,
SongListArgs,
} from '/@/renderer/api/types';
import {
songListSortMap,
albumListSortMap,
artistListSortMap,
sortOrderMap,
albumArtistListSortMap,
} from '/@/renderer/api/types';
import { useAuthStore } from '/@/renderer/store';
import { ServerListItem, ServerType } from '/@/renderer/types';
import { parseSearchParams } from '/@/renderer/utils';
const api = ky.create({});
const authenticate = async (
url: string,
body: {
password: string;
username: string;
},
): Promise<AuthenticationResponse> => {
const cleanServerUrl = url.replace(/\/$/, '');
const data = await ky
.post(`${cleanServerUrl}/users/authenticatebyname`, {
headers: {
'X-Emby-Authorization':
'MediaBrowser Client="Feishin", Device="PC", DeviceId="Feishin", Version="0.0.1-alpha1"',
},
json: {
pw: body.password,
username: body.username,
},
})
.json<JFAuthenticate>();
return {
credential: data.AccessToken,
userId: data.User.Id,
username: data.User.Name,
};
};
const getMusicFolderList = async (args: MusicFolderListArgs): Promise<JFMusicFolderList> => {
const { signal } = args;
const userId = useAuthStore.getState().currentServer?.userId;
const data = await api
.get(`users/${userId}/items`, {
signal,
})
.json<JFMusicFolderListResponse>();
const musicFolders = data.Items.filter(
(folder) => folder.CollectionType === JFCollectionType.MUSIC,
);
return {
items: musicFolders,
startIndex: data.StartIndex,
totalRecordCount: data.TotalRecordCount,
};
};
const getGenreList = async (args: GenreListArgs): Promise<JFGenreList> => {
const { signal, server } = args;
const data = await api
.get('genres', {
headers: { 'X-MediaBrowser-Token': server?.credential },
prefixUrl: server?.url,
signal,
})
.json<JFGenreListResponse>();
return data;
};
const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs): Promise<JFAlbumArtistDetail> => {
const { query, server, signal } = args;
const searchParams = {
fields: 'Genres',
};
const data = await api
.get(`/users/${server?.userId}/items/${query.id}`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
prefixUrl: server?.url,
searchParams: parseSearchParams(searchParams),
signal,
})
.json<JFAlbumArtistDetailResponse>();
return data;
};
// const getAlbumArtistAlbums = () => {
// const { data: albumData } = await api.get(`/users/${auth.username}/items`, {
// params: {
// artistIds: options.id,
// fields: 'AudioInfo, ParentId, Genres, DateCreated, ChildCount, ParentId',
// includeItemTypes: 'MusicAlbum',
// parentId: options.musicFolderId,
// recursive: true,
// sortBy: 'SortName',
// },
// });
// const { data: similarData } = await api.get(`/artists/${options.id}/similar`, {
// params: { limit: 15, parentId: options.musicFolderId, userId: auth.username },
// });
// };
const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<JFAlbumArtistList> => {
const { query, server, signal } = args;
const searchParams: JFAlbumArtistListParams = {
limit: query.limit,
parentId: query.musicFolderId,
recursive: true,
sortBy: albumArtistListSortMap.jellyfin[query.sortBy],
sortOrder: sortOrderMap.jellyfin[query.sortOrder],
startIndex: query.startIndex,
};
const data = await api
.get('artists/albumArtists', {
headers: { 'X-MediaBrowser-Token': server?.credential },
prefixUrl: server?.url,
searchParams: parseSearchParams(searchParams),
signal,
})
.json<JFAlbumArtistListResponse>();
return data;
};
const getArtistList = async (args: ArtistListArgs): Promise<JFArtistList> => {
const { query, server, signal } = args;
const searchParams: JFArtistListParams = {
limit: query.limit,
parentId: query.musicFolderId,
recursive: true,
sortBy: artistListSortMap.jellyfin[query.sortBy],
sortOrder: sortOrderMap.jellyfin[query.sortOrder],
startIndex: query.startIndex,
};
const data = await api
.get('artists', {
headers: { 'X-MediaBrowser-Token': server?.credential },
prefixUrl: server?.url,
searchParams: parseSearchParams(searchParams),
signal,
})
.json<JFArtistListResponse>();
return data;
};
const getAlbumDetail = async (args: AlbumDetailArgs): Promise<JFAlbumDetail> => {
const { query, server, signal } = args;
const searchParams = {
fields: 'Genres, DateCreated, ChildCount',
};
const data = await api
.get(`users/${server?.userId}/items/${query.id}`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
prefixUrl: server?.url,
searchParams,
signal,
})
.json<JFAlbumDetailResponse>();
const songsSearchParams = {
fields: 'Genres, DateCreated, MediaSources, ParentId',
parentId: query.id,
sortBy: 'SortName',
};
const songsData = await api
.get(`users/${server?.userId}/items`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
prefixUrl: server?.url,
searchParams: songsSearchParams,
signal,
})
.json<JFSongListResponse>();
return { ...data, songs: songsData.Items };
};
const getAlbumList = async (args: AlbumListArgs): Promise<JFAlbumList> => {
const { query, server, signal } = args;
const searchParams: JFAlbumListParams = {
includeItemTypes: 'MusicAlbum',
limit: query.limit,
parentId: query.musicFolderId,
recursive: true,
sortBy: albumListSortMap.jellyfin[query.sortBy],
sortOrder: sortOrderMap.jellyfin[query.sortOrder],
startIndex: query.startIndex,
};
const data = await api
.get(`users/${server?.userId}/items`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
prefixUrl: server?.url,
searchParams: parseSearchParams(searchParams),
signal,
})
.json<JFAlbumListResponse>();
return {
items: data.Items,
startIndex: query.startIndex,
totalRecordCount: data.TotalRecordCount,
};
};
const getSongList = async (args: SongListArgs): Promise<JFSongList> => {
const { query, server, signal } = args;
const searchParams: JFSongListParams = {
fields: 'Genres, DateCreated, MediaSources, ParentId',
includeItemTypes: 'Audio',
limit: query.limit,
parentId: query.musicFolderId,
recursive: true,
sortBy: songListSortMap.jellyfin[query.sortBy],
sortOrder: sortOrderMap.jellyfin[query.sortOrder],
startIndex: query.startIndex,
...query.jfParams,
};
const data = await api
.get(`users/${server?.userId}/items`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
prefixUrl: server?.url,
searchParams: parseSearchParams(searchParams),
signal,
})
.json<JFSongListResponse>();
return {
items: data.Items,
startIndex: query.startIndex,
totalRecordCount: data.TotalRecordCount,
};
};
const getPlaylistDetail = async (args: PlaylistDetailArgs): Promise<JFPlaylistDetail> => {
const { query, server, signal } = args;
const searchParams = {
fields: 'Genres, DateCreated, MediaSources, ChildCount, ParentId',
ids: query.id,
};
const data = await api
.get(`users/${server?.userId}/items/${query.id}`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
prefixUrl: server?.url,
searchParams,
signal,
})
.json<JFPlaylistDetailResponse>();
return data;
};
const getPlaylistSongList = async (args: PlaylistSongListArgs): Promise<JFSongList> => {
const { query, server, signal } = args;
const searchParams: JFSongListParams = {
fields: 'Genres, DateCreated, MediaSources, UserData, ParentId',
includeItemTypes: 'Audio',
sortOrder: query.sortOrder ? sortOrderMap.jellyfin[query.sortOrder] : undefined,
startIndex: 0,
};
const data = await api
.get(`playlists/${query.id}/items`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
prefixUrl: server?.url,
searchParams: parseSearchParams(searchParams),
signal,
})
.json<JFSongListResponse>();
return {
items: data.Items,
startIndex: query.startIndex,
totalRecordCount: data.TotalRecordCount,
};
};
const getPlaylistList = async (args: PlaylistListArgs): Promise<JFPlaylistList> => {
const { server, signal } = args;
const searchParams = {
fields: 'ChildCount, Genres, DateCreated, ParentId, Overview',
includeItemTypes: 'Playlist',
recursive: true,
sortBy: 'SortName',
sortOrder: 'Ascending',
};
const data = await api
.get(`/users/${server?.userId}/items`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
prefixUrl: server?.url,
searchParams: parseSearchParams(searchParams),
signal,
})
.json<JFPlaylistListResponse>();
const playlistData = data.Items.filter((item) => item.MediaType === 'Audio');
return {
Items: playlistData,
StartIndex: 0,
TotalRecordCount: playlistData.length,
};
};
const createPlaylist = async (args: CreatePlaylistArgs): Promise<CreatePlaylistResponse> => {
const { query, server } = args;
const body = {
MediaType: 'Audio',
Name: query.name,
UserId: server?.userId,
};
const data = await api
.post('playlists', {
headers: { 'X-MediaBrowser-Token': server?.credential },
json: body,
prefixUrl: server?.url,
})
.json<JFCreatePlaylistResponse>();
return {
id: data.Id,
name: query.name,
};
};
const deletePlaylist = async (args: DeletePlaylistArgs): Promise<null> => {
const { query, server } = args;
await api.delete(`items/${query.id}`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
prefixUrl: server?.url,
});
return null;
};
const createFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
const { query, server } = args;
await api.post(`users/${server?.userId}/favoriteitems/${query.id}`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
prefixUrl: server?.url,
});
return {
id: query.id,
};
};
const deleteFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
const { query, server } = args;
await api.delete(`users/${server?.userId}/favoriteitems/${query.id}`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
prefixUrl: server?.url,
});
return {
id: query.id,
};
};
const getStreamUrl = (args: {
container?: string;
deviceId: string;
eTag?: string;
id: string;
mediaSourceId?: string;
server: ServerListItem;
}) => {
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'
);
};
const getAlbumCoverArtUrl = (args: { baseUrl: string; item: JFAlbum; size: number }) => {
const size = args.size ? args.size : 300;
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'
);
};
const getSongCoverArtUrl = (args: { baseUrl: string; item: JFSong; size: number }) => {
const size = args.size ? args.size : 300;
if (!args.item.ImageTags?.Primary) {
return null;
}
if (args.item.ImageTags.Primary) {
return (
`${args.baseUrl}/Items` +
`/${args.item.Id}` +
'/Images/Primary' +
`?width=${size}&height=${size}` +
'&quality=96'
);
}
if (!args.item?.AlbumPrimaryImageTag) {
return null;
}
// Fall back to album art if no image embedded
return (
`${args.baseUrl}/Items` +
`/${args.item?.AlbumId}` +
'/Images/Primary' +
`?width=${size}&height=${size}` +
'&quality=96'
);
};
const normalizeAlbum = (item: JFAlbum, server: ServerListItem, imageSize?: number): Album => {
return {
albumArtists:
item.AlbumArtists?.map((entry) => ({
id: entry.Id,
name: entry.Name,
})) || [],
artists: item.ArtistItems?.map((entry) => ({ id: entry.Id, name: entry.Name })),
backdropImageUrl: null,
createdAt: item.DateCreated,
duration: item.RunTimeTicks / 10000000,
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,
isFavorite: item.UserData?.IsFavorite || false,
name: item.Name,
playCount: item.UserData?.PlayCount || 0,
rating: null,
releaseDate: item.PremiereDate || null,
releaseYear: item.ProductionYear,
serverType: ServerType.JELLYFIN,
size: null,
songCount: item?.ChildCount || null,
uniqueId: nanoid(),
updatedAt: item?.DateLastMediaAdded || item.DateCreated,
};
};
const normalizeSong = (
item: JFSong,
server: ServerListItem,
deviceId: string,
imageSize?: number,
): Song => {
return {
album: item.Album,
albumArtists: item.AlbumArtists?.map((entry) => ({ id: entry.Id, name: entry.Name })),
albumId: item.AlbumId,
artistName: item.ArtistItems[0]?.Name,
artists: item.ArtistItems.map((entry) => ({ id: entry.Id, name: entry.Name })),
bitRate: item.MediaSources && Number(Math.trunc(item.MediaSources[0]?.Bitrate / 1000)),
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,
imageUrl: getSongCoverArtUrl({ baseUrl: server.url, item, size: imageSize || 300 }),
isFavorite: (item.UserData && item.UserData.IsFavorite) || false,
name: item.Name,
path: (item.MediaSources && item.MediaSources[0]?.Path) || null,
playCount: (item.UserData && item.UserData.PlayCount) || 0,
releaseDate: (item.ProductionYear && new Date(item.ProductionYear, 0, 1).toISOString()) || null,
releaseYear: (item.ProductionYear && String(item.ProductionYear)) || null,
serverId: server.id,
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,
type: ServerType.JELLYFIN,
uniqueId: nanoid(),
updatedAt: item.DateCreated,
};
};
// const normalizeArtist = (item: any) => {
// return {
// album: (item.album || []).map((entry: any) => normalizeAlbum(entry)),
// albumCount: item.AlbumCount,
// duration: item.RunTimeTicks / 10000000,
// genre: item.GenreItems && item.GenreItems.map((entry: any) => normalizeItem(entry)),
// id: item.Id,
// image: getCoverArtUrl(item),
// info: {
// biography: item.Overview,
// externalUrl: (item.ExternalUrls || []).map((entry: any) => normalizeItem(entry)),
// imageUrl: undefined,
// similarArtist: (item.similarArtist || []).map((entry: any) => normalizeArtist(entry)),
// },
// starred: item.UserData && item.UserData?.IsFavorite ? 'true' : undefined,
// title: item.Name,
// uniqueId: nanoid(),
// };
// };
// const normalizePlaylist = (item: any) => {
// return {
// changed: item.DateLastMediaAdded,
// comment: item.Overview,
// created: item.DateCreated,
// duration: item.RunTimeTicks / 10000000,
// genre: item.GenreItems && item.GenreItems.map((entry: any) => normalizeItem(entry)),
// id: item.Id,
// image: getCoverArtUrl(item, 350),
// owner: undefined,
// public: undefined,
// song: [],
// songCount: item.ChildCount,
// title: item.Name,
// uniqueId: nanoid(),
// };
// };
// const normalizeGenre = (item: any) => {
// return {
// albumCount: undefined,
// id: item.Id,
// songCount: undefined,
// title: item.Name,
// type: Item.Genre,
// uniqueId: nanoid(),
// };
// };
// const normalizeFolder = (item: any) => {
// return {
// created: item.DateCreated,
// id: item.Id,
// image: getCoverArtUrl(item, 150),
// isDir: true,
// title: item.Name,
// type: Item.Folder,
// uniqueId: nanoid(),
// };
// };
// const normalizeScanStatus = () => {
// return {
// count: 'N/a',
// scanning: false,
// };
// };
export const jellyfinApi = {
authenticate,
createFavorite,
createPlaylist,
deleteFavorite,
deletePlaylist,
getAlbumArtistDetail,
getAlbumArtistList,
getAlbumDetail,
getAlbumList,
getArtistList,
getGenreList,
getMusicFolderList,
getPlaylistDetail,
getPlaylistList,
getPlaylistSongList,
getSongList,
};
export const jfNormalize = {
album: normalizeAlbum,
song: normalizeSong,
};

View file

@ -0,0 +1,574 @@
export type JFBasePaginatedResponse = {
StartIndex: number;
TotalRecordCount: number;
};
export interface JFMusicFolderListResponse extends JFBasePaginatedResponse {
Items: JFMusicFolder[];
}
export type JFMusicFolderList = {
items: JFMusicFolder[];
startIndex: number;
totalRecordCount: number;
};
export interface JFGenreListResponse extends JFBasePaginatedResponse {
Items: JFGenre[];
}
export type JFGenreList = JFGenreListResponse;
export type JFAlbumArtistDetailResponse = JFAlbumArtist;
export type JFAlbumArtistDetail = JFAlbumArtistDetailResponse;
export interface JFAlbumArtistListResponse extends JFBasePaginatedResponse {
Items: JFAlbumArtist[];
}
export type JFAlbumArtistList = JFAlbumArtistListResponse;
export interface JFArtistListResponse extends JFBasePaginatedResponse {
Items: JFAlbumArtist[];
}
export type JFArtistList = JFArtistListResponse;
export interface JFAlbumListResponse extends JFBasePaginatedResponse {
Items: JFAlbum[];
}
export type JFAlbumList = {
items: JFAlbum[];
startIndex: number;
totalRecordCount: number;
};
export type JFAlbumDetailResponse = JFAlbum;
export type JFAlbumDetail = JFAlbum & { songs?: JFSong[] };
export interface JFSongListResponse extends JFBasePaginatedResponse {
Items: JFSong[];
}
export type JFSongList = {
items: JFSong[];
startIndex: number;
totalRecordCount: number;
};
export interface JFPlaylistListResponse extends JFBasePaginatedResponse {
Items: JFPlaylist[];
}
export type JFPlaylistList = JFPlaylistListResponse;
export type JFPlaylistDetailResponse = JFPlaylist;
export type JFPlaylistDetail = JFPlaylist & { songs?: JFSong[] };
export type JFPlaylist = {
BackdropImageTags: string[];
ChannelId: null;
ChildCount?: number;
DateCreated: string;
GenreItems: GenreItem[];
Genres: string[];
Id: string;
ImageBlurHashes: ImageBlurHashes;
ImageTags: ImageTags;
IsFolder: boolean;
LocationType: string;
MediaType: string;
Name: string;
RunTimeTicks: number;
ServerId: string;
Type: string;
UserData: UserData;
};
export type JFRequestParams = {
albumArtistIds?: string;
artistIds?: string;
enableImageTypes?: string;
enableTotalRecordCount?: boolean;
enableUserData?: boolean;
excludeItemTypes?: string;
fields?: string;
imageTypeLimit?: number;
includeItemTypes?: string;
isFavorite?: boolean;
limit?: number;
parentId?: string;
recursive?: boolean;
searchTerm?: string;
sortBy?: string;
sortOrder?: 'Ascending' | 'Descending';
startIndex?: number;
userId?: string;
};
export type JFMusicFolder = {
BackdropImageTags: string[];
ChannelId: null;
CollectionType: string;
Id: string;
ImageBlurHashes: ImageBlurHashes;
ImageTags: ImageTags;
IsFolder: boolean;
LocationType: string;
Name: string;
ServerId: string;
Type: string;
UserData: UserData;
};
export type JFGenre = {
BackdropImageTags: any[];
ChannelId: null;
Id: string;
ImageBlurHashes: any;
ImageTags: ImageTags;
LocationType: string;
Name: string;
ServerId: string;
Type: string;
};
export type JFAlbumArtist = {
BackdropImageTags: string[];
ChannelId: null;
DateCreated: string;
ExternalUrls: ExternalURL[];
GenreItems: GenreItem[];
Genres: string[];
Id: string;
ImageBlurHashes: any;
ImageTags: ImageTags;
LocationType: string;
Name: string;
Overview?: string;
RunTimeTicks: number;
ServerId: string;
Type: string;
};
export type JFArtist = {
BackdropImageTags: string[];
ChannelId: null;
DateCreated: string;
ExternalUrls: ExternalURL[];
GenreItems: GenreItem[];
Genres: string[];
Id: string;
ImageBlurHashes: any;
ImageTags: string[];
LocationType: string;
Name: string;
Overview?: string;
RunTimeTicks: number;
ServerId: string;
Type: string;
};
export type JFAlbum = {
AlbumArtist: string;
AlbumArtists: JFGenericItem[];
AlbumPrimaryImageTag: string;
ArtistItems: JFGenericItem[];
Artists: string[];
ChannelId: null;
ChildCount?: number;
DateCreated: string;
DateLastMediaAdded?: string;
ExternalUrls: ExternalURL[];
GenreItems: JFGenericItem[];
Genres: string[];
Id: string;
ImageBlurHashes: ImageBlurHashes;
ImageTags: ImageTags;
IsFolder: boolean;
LocationType: string;
Name: string;
ParentLogoImageTag: string;
ParentLogoItemId: string;
PremiereDate?: string;
ProductionYear: number;
RunTimeTicks: number;
ServerId: string;
Type: string;
UserData?: UserData;
} & {
songs?: JFSong[];
};
export type JFSong = {
Album: string;
AlbumArtist: string;
AlbumArtists: JFGenericItem[];
AlbumId: string;
AlbumPrimaryImageTag: string;
ArtistItems: JFGenericItem[];
Artists: string[];
BackdropImageTags: string[];
ChannelId: null;
DateCreated: string;
ExternalUrls: ExternalURL[];
GenreItems: JFGenericItem[];
Genres: string[];
Id: string;
ImageBlurHashes: ImageBlurHashes;
ImageTags: ImageTags;
IndexNumber: number;
IsFolder: boolean;
LocationType: string;
MediaSources: MediaSources[];
MediaType: string;
Name: string;
ParentIndexNumber: number;
PremiereDate?: string;
ProductionYear: number;
RunTimeTicks: number;
ServerId: string;
SortName: string;
Type: string;
UserData?: UserData;
};
type ImageBlurHashes = {
Backdrop?: any;
Logo?: any;
Primary?: any;
};
type ImageTags = {
Logo?: string;
Primary?: string;
};
type UserData = {
IsFavorite: boolean;
Key: string;
PlayCount: number;
PlaybackPositionTicks: number;
Played: boolean;
};
type ExternalURL = {
Name: string;
Url: string;
};
type GenreItem = {
Id: string;
Name: string;
};
export type JFGenericItem = {
Id: string;
Name: string;
};
type MediaSources = {
Bitrate: number;
Container: string;
DefaultAudioStreamIndex: number;
ETag: string;
Formats: any[];
GenPtsInput: boolean;
Id: string;
IgnoreDts: boolean;
IgnoreIndex: boolean;
IsInfiniteStream: boolean;
IsRemote: boolean;
MediaAttachments: any[];
MediaStreams: MediaStream[];
Name: string;
Path: string;
Protocol: string;
ReadAtNativeFramerate: boolean;
RequiredHttpHeaders: any;
RequiresClosing: boolean;
RequiresLooping: boolean;
RequiresOpening: boolean;
RunTimeTicks: number;
Size: number;
SupportsDirectPlay: boolean;
SupportsDirectStream: boolean;
SupportsProbing: boolean;
SupportsTranscoding: boolean;
Type: string;
};
type MediaStream = {
AspectRatio?: string;
BitDepth?: number;
BitRate?: number;
ChannelLayout?: string;
Channels?: number;
Codec: string;
CodecTimeBase: string;
ColorSpace?: string;
Comment?: string;
DisplayTitle?: string;
Height?: number;
Index: number;
IsDefault: boolean;
IsExternal: boolean;
IsForced: boolean;
IsInterlaced: boolean;
IsTextSubtitleStream: boolean;
Level: number;
PixelFormat?: string;
Profile?: string;
RealFrameRate?: number;
RefFrames?: number;
SampleRate?: number;
SupportsExternalStream: boolean;
TimeBase: string;
Type: string;
Width?: number;
};
export enum JFExternalType {
MUSICBRAINZ = 'MusicBrainz',
THEAUDIODB = 'TheAudioDb',
}
export enum JFImageType {
LOGO = 'Logo',
PRIMARY = 'Primary',
}
export enum JFItemType {
AUDIO = 'Audio',
MUSICALBUM = 'MusicAlbum',
}
export enum JFCollectionType {
MUSIC = 'music',
PLAYLISTS = 'playlists',
}
export interface JFAuthenticate {
AccessToken: string;
ServerId: string;
SessionInfo: SessionInfo;
User: User;
}
type SessionInfo = {
AdditionalUsers: any[];
ApplicationVersion: string;
Capabilities: Capabilities;
Client: string;
DeviceId: string;
DeviceName: string;
HasCustomDeviceName: boolean;
Id: string;
IsActive: boolean;
LastActivityDate: string;
LastPlaybackCheckIn: string;
NowPlayingQueue: any[];
NowPlayingQueueFullItems: any[];
PlayState: PlayState;
PlayableMediaTypes: any[];
RemoteEndPoint: string;
ServerId: string;
SupportedCommands: any[];
SupportsMediaControl: boolean;
SupportsRemoteControl: boolean;
UserId: string;
UserName: string;
};
type Capabilities = {
PlayableMediaTypes: any[];
SupportedCommands: any[];
SupportsContentUploading: boolean;
SupportsMediaControl: boolean;
SupportsPersistentIdentifier: boolean;
SupportsSync: boolean;
};
type PlayState = {
CanSeek: boolean;
IsMuted: boolean;
IsPaused: boolean;
RepeatMode: string;
};
type User = {
Configuration: Configuration;
EnableAutoLogin: boolean;
HasConfiguredEasyPassword: boolean;
HasConfiguredPassword: boolean;
HasPassword: boolean;
Id: string;
LastActivityDate: string;
LastLoginDate: string;
Name: string;
Policy: Policy;
ServerId: string;
};
type Configuration = {
DisplayCollectionsView: boolean;
DisplayMissingEpisodes: boolean;
EnableLocalPassword: boolean;
EnableNextEpisodeAutoPlay: boolean;
GroupedFolders: any[];
HidePlayedInLatest: boolean;
LatestItemsExcludes: any[];
MyMediaExcludes: any[];
OrderedViews: any[];
PlayDefaultAudioTrack: boolean;
RememberAudioSelections: boolean;
RememberSubtitleSelections: boolean;
SubtitleLanguagePreference: string;
SubtitleMode: string;
};
type Policy = {
AccessSchedules: any[];
AuthenticationProviderId: string;
BlockUnratedItems: any[];
BlockedChannels: any[];
BlockedMediaFolders: any[];
BlockedTags: any[];
EnableAllChannels: boolean;
EnableAllDevices: boolean;
EnableAllFolders: boolean;
EnableAudioPlaybackTranscoding: boolean;
EnableContentDeletion: boolean;
EnableContentDeletionFromFolders: any[];
EnableContentDownloading: boolean;
EnableLiveTvAccess: boolean;
EnableLiveTvManagement: boolean;
EnableMediaConversion: boolean;
EnableMediaPlayback: boolean;
EnablePlaybackRemuxing: boolean;
EnablePublicSharing: boolean;
EnableRemoteAccess: boolean;
EnableRemoteControlOfOtherUsers: boolean;
EnableSharedDeviceControl: boolean;
EnableSyncTranscoding: boolean;
EnableUserPreferenceAccess: boolean;
EnableVideoPlaybackTranscoding: boolean;
EnabledChannels: any[];
EnabledDevices: any[];
EnabledFolders: any[];
ForceRemoteSourceTranscoding: boolean;
InvalidLoginAttemptCount: number;
IsAdministrator: boolean;
IsDisabled: boolean;
IsHidden: boolean;
LoginAttemptsBeforeLockout: number;
MaxActiveSessions: number;
PasswordResetProviderId: string;
RemoteClientBitrateLimit: number;
SyncPlayAccess: string;
};
type JFBaseParams = {
enableImageTypes?: JFImageType[];
fields?: string;
imageTypeLimit?: number;
parentId?: string;
recursive?: boolean;
};
type JFPaginationParams = {
limit?: number;
nameStartsWith?: string;
sortOrder?: JFSortOrder;
startIndex?: number;
};
export enum JFSortOrder {
ASC = 'Ascending',
DESC = 'Descending',
}
export enum JFAlbumListSort {
ALBUM_ARTIST = 'AlbumArtist,SortName',
COMMUNITY_RATING = 'CommunityRating,SortName',
CRITIC_RATING = 'CriticRating,SortName',
NAME = 'SortName',
RANDOM = 'Random,SortName',
RECENTLY_ADDED = 'DateCreated,SortName',
RELEASE_DATE = 'ProductionYear,PremiereDate,SortName',
}
export type JFAlbumListParams = {
filters?: string;
genres?: string;
includeItemTypes: 'MusicAlbum';
sortBy?: JFAlbumListSort;
years?: string;
} & JFBaseParams &
JFPaginationParams;
export enum JFSongListSort {
ALBUM = 'Album,SortName',
ALBUM_ARTIST = 'AlbumArtist,Album,SortName',
ARTIST = 'Artist,Album,SortName',
DURATION = 'Runtime,AlbumArtist,Album,SortName',
NAME = 'Name,SortName',
PLAY_COUNT = 'PlayCount,SortName',
RANDOM = 'Random,SortName',
RECENTLY_ADDED = 'DateCreated,SortName',
RECENTLY_PLAYED = 'DatePlayed,SortName',
RELEASE_DATE = 'PremiereDate,AlbumArtist,Album,SortName',
}
export type JFSongListParams = {
filters?: string;
genres?: string;
includeItemTypes: 'Audio';
sortBy?: JFSongListSort;
years?: string;
} & JFBaseParams &
JFPaginationParams;
export enum JFAlbumArtistListSort {
ALBUM = 'Album,SortName',
DURATION = 'Runtime,AlbumArtist,Album,SortName',
NAME = 'Name,SortName',
RANDOM = 'Random,SortName',
RECENTLY_ADDED = 'DateCreated,SortName',
RELEASE_DATE = 'PremiereDate,AlbumArtist,Album,SortName',
}
export type JFAlbumArtistListParams = {
filters?: string;
genres?: string;
sortBy?: JFAlbumArtistListSort;
years?: string;
} & JFBaseParams &
JFPaginationParams;
export enum JFArtistListSort {
ALBUM = 'Album,SortName',
DURATION = 'Runtime,AlbumArtist,Album,SortName',
NAME = 'Name,SortName',
RANDOM = 'Random,SortName',
RECENTLY_ADDED = 'DateCreated,SortName',
RELEASE_DATE = 'PremiereDate,AlbumArtist,Album,SortName',
}
export type JFArtistListParams = {
filters?: string;
genres?: string;
sortBy?: JFArtistListSort;
years?: string;
} & JFBaseParams &
JFPaginationParams;
export type JFCreatePlaylistResponse = {
Id: string;
};
export type JFCreatePlaylist = JFCreatePlaylistResponse;

View file

@ -0,0 +1,499 @@
import { nanoid } from 'nanoid/non-secure';
import ky from 'ky';
import type {
NDGenreListResponse,
NDArtistListResponse,
NDAlbumDetail,
NDAlbumListParams,
NDAlbumList,
NDSongDetailResponse,
NDAlbum,
NDSong,
NDAuthenticationResponse,
NDAlbumDetailResponse,
NDSongDetail,
NDGenreList,
NDAlbumArtistListParams,
NDAlbumArtistDetail,
NDAlbumListResponse,
NDAlbumArtistDetailResponse,
NDAlbumArtistList,
NDSongListParams,
NDCreatePlaylistParams,
NDCreatePlaylistResponse,
NDDeletePlaylist,
NDDeletePlaylistResponse,
NDPlaylistListParams,
NDPlaylistDetail,
NDPlaylistList,
NDPlaylistListResponse,
NDPlaylistDetailResponse,
NDSongList,
NDSongListResponse,
} from '/@/renderer/api/navidrome.types';
import { NDPlaylistListSort, NDSongListSort, NDSortOrder } from '/@/renderer/api/navidrome.types';
import type {
Album,
Song,
AuthenticationResponse,
AlbumDetailArgs,
GenreListArgs,
AlbumListArgs,
AlbumArtistListArgs,
AlbumArtistDetailArgs,
SongListArgs,
SongDetailArgs,
CreatePlaylistArgs,
DeletePlaylistArgs,
PlaylistListArgs,
PlaylistDetailArgs,
CreatePlaylistResponse,
PlaylistSongListArgs,
} from '/@/renderer/api/types';
import {
playlistListSortMap,
albumArtistListSortMap,
songListSortMap,
albumListSortMap,
sortOrderMap,
} from '/@/renderer/api/types';
import { toast } from '/@/renderer/components';
import { useAuthStore } from '/@/renderer/store';
import { ServerListItem, ServerType } from '/@/renderer/types';
import { parseSearchParams } from '/@/renderer/utils';
const api = ky.create({
hooks: {
afterResponse: [
async (_request, _options, response) => {
const serverId = useAuthStore.getState().currentServer?.id;
if (serverId) {
useAuthStore.getState().actions.updateServer(serverId, {
ndCredential: response.headers.get('x-nd-authorization') as string,
});
}
return response;
},
],
beforeError: [
(error) => {
if (error.response && error.response.status === 401) {
toast.error({
message: 'Your session has expired.',
});
const serverId = useAuthStore.getState().currentServer?.id;
if (serverId) {
useAuthStore.getState().actions.setCurrentServer(null);
useAuthStore.getState().actions.updateServer(serverId, { ndCredential: undefined });
}
}
return error;
},
],
},
});
const authenticate = async (
url: string,
body: { password: string; username: string },
): Promise<AuthenticationResponse> => {
const cleanServerUrl = url.replace(/\/$/, '');
const data = await ky
.post(`${cleanServerUrl}/auth/login`, {
json: {
password: body.password,
username: body.username,
},
})
.json<NDAuthenticationResponse>();
return {
credential: `u=${body.username}&s=${data.subsonicSalt}&t=${data.subsonicToken}`,
ndCredential: data.token,
userId: data.id,
username: data.username,
};
};
const getGenreList = async (args: GenreListArgs): Promise<NDGenreList> => {
const { server, signal } = args;
const data = await api
.get('api/genre', {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
prefixUrl: server?.url,
signal,
})
.json<NDGenreListResponse>();
return data;
};
const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs): Promise<NDAlbumArtistDetail> => {
const { query, server, signal } = args;
const data = await api
.get(`api/artist/${query.id}`, {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
prefixUrl: server?.url,
signal,
})
.json<NDAlbumArtistDetailResponse>();
return { ...data };
};
const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<NDAlbumArtistList> => {
const { query, server, signal } = args;
const searchParams: NDAlbumArtistListParams = {
_end: query.startIndex + (query.limit || 0),
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: albumArtistListSortMap.navidrome[query.sortBy],
_start: query.startIndex,
...query.ndParams,
};
const data = await api
.get('api/artist', {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
searchParams,
signal,
})
.json<NDArtistListResponse>();
return data;
};
const getAlbumDetail = async (args: AlbumDetailArgs): Promise<NDAlbumDetail> => {
const { query, server, signal } = args;
const data = await api
.get(`api/album/${query.id}`, {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
prefixUrl: server?.url,
signal,
})
.json<NDAlbumDetailResponse>();
const songsData = await api
.get('api/song', {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
prefixUrl: server?.url,
searchParams: {
_end: 0,
_order: NDSortOrder.ASC,
_sort: 'album',
_start: 0,
album_id: query.id,
},
signal,
})
.json<NDSongListResponse>();
return { ...data, songs: songsData };
};
const getAlbumList = async (args: AlbumListArgs): Promise<NDAlbumList> => {
const { query, server, signal } = args;
const searchParams: NDAlbumListParams = {
_end: query.startIndex + (query.limit || 0),
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: albumListSortMap.navidrome[query.sortBy],
_start: query.startIndex,
...query.ndParams,
};
const res = await api.get('api/album', {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
prefixUrl: server?.url,
searchParams,
signal,
});
const data = await res.json<NDAlbumListResponse>();
const itemCount = res.headers.get('x-total-count');
return {
items: data,
startIndex: query?.startIndex || 0,
totalRecordCount: Number(itemCount),
};
};
const getSongList = async (args: SongListArgs): Promise<NDSongList> => {
const { query, server, signal } = args;
const searchParams: NDSongListParams = {
_end: query.startIndex + (query.limit || 0),
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: songListSortMap.navidrome[query.sortBy],
_start: query.startIndex,
...query.ndParams,
};
const res = await api.get('api/song', {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
prefixUrl: server?.url,
searchParams,
signal,
});
const data = await res.json<NDSongListResponse>();
const itemCount = res.headers.get('x-total-count');
return {
items: data,
startIndex: query?.startIndex || 0,
totalRecordCount: Number(itemCount),
};
};
const getSongDetail = async (args: SongDetailArgs): Promise<NDSongDetail> => {
const { query, server, signal } = args;
const data = await api
.get(`api/song/${query.id}`, {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
prefixUrl: server?.url,
signal,
})
.json<NDSongDetailResponse>();
return data;
};
const createPlaylist = async (args: CreatePlaylistArgs): Promise<CreatePlaylistResponse> => {
const { query, server, signal } = args;
const json: NDCreatePlaylistParams = {
comment: query.comment,
name: query.name,
public: query.public || false,
};
const data = await api
.post('api/playlist', {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
json,
prefixUrl: server?.url,
signal,
})
.json<NDCreatePlaylistResponse>();
return {
id: data.id,
name: query.name,
};
};
const deletePlaylist = async (args: DeletePlaylistArgs): Promise<NDDeletePlaylist> => {
const { query, server, signal } = args;
const data = await api
.delete(`api/playlist/${query.id}`, {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
prefixUrl: server?.url,
signal,
})
.json<NDDeletePlaylistResponse>();
return data;
};
const getPlaylistList = async (args: PlaylistListArgs): Promise<NDPlaylistList> => {
const { query, server, signal } = args;
const searchParams: NDPlaylistListParams = {
_end: query.startIndex + (query.limit || 0),
_order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : NDSortOrder.ASC,
_sort: query.sortBy ? playlistListSortMap.navidrome[query.sortBy] : NDPlaylistListSort.NAME,
_start: query.startIndex,
};
const res = await api.get('api/playlist', {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
prefixUrl: server?.url,
searchParams,
signal,
});
const data = await res.json<NDPlaylistListResponse>();
const itemCount = res.headers.get('x-total-count');
return {
items: data,
startIndex: query?.startIndex || 0,
totalRecordCount: Number(itemCount),
};
};
const getPlaylistDetail = async (args: PlaylistDetailArgs): Promise<NDPlaylistDetail> => {
const { query, server, signal } = args;
const data = await api
.get(`api/playlist/${query.id}`, {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
prefixUrl: server?.url,
signal,
})
.json<NDPlaylistDetailResponse>();
return data;
};
const getPlaylistSongList = async (args: PlaylistSongListArgs): Promise<NDSongList> => {
const { query, server, signal } = args;
const searchParams: NDSongListParams & { playlist_id: string } = {
_end: query.startIndex + (query.limit || 0),
_order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : NDSortOrder.ASC,
_sort: query.sortBy ? songListSortMap.navidrome[query.sortBy] : NDSongListSort.ID,
_start: query.startIndex,
playlist_id: query.id,
};
const data = await api
.get(`api/playlist/${query.id}/tracks`, {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
prefixUrl: server?.url,
searchParams: parseSearchParams(searchParams),
signal,
})
.json<NDSongListResponse>();
return {
items: data,
startIndex: query?.startIndex || 0,
totalRecordCount: data.length,
};
};
const getCoverArtUrl = (args: {
baseUrl: string;
coverArtId: string;
credential: string;
size: number;
}) => {
const size = args.size ? args.size : 250;
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}`
);
};
const normalizeAlbum = (item: NDAlbum, server: ServerListItem, imageSize?: number): Album => {
const imageUrl = getCoverArtUrl({
baseUrl: server.url,
coverArtId: item.coverArtId,
credential: server.credential,
size: imageSize || 300,
});
const imagePlaceholderUrl = imageUrl?.replace(/size=\d+/, 'size=50') || null;
return {
albumArtists: [{ id: item.albumArtistId, name: item.albumArtist }],
artists: [{ id: item.artistId, name: item.artist }],
backdropImageUrl: null,
createdAt: item.createdAt,
duration: null,
genres: item.genres,
id: item.id,
imagePlaceholderUrl,
imageUrl,
isCompilation: item.compilation,
isFavorite: item.starred,
name: item.name,
playCount: item.playCount,
rating: item.rating,
releaseDate: new Date(item.minYear, 0, 1).toISOString(),
releaseYear: item.minYear,
serverType: ServerType.NAVIDROME,
size: item.size,
songCount: item.songCount,
uniqueId: nanoid(),
updatedAt: item.updatedAt,
};
};
const normalizeSong = (
item: NDSong,
server: ServerListItem,
deviceId: string,
imageSize?: number,
): Song => {
const imageUrl = getCoverArtUrl({
baseUrl: server.url,
coverArtId: item.albumId,
credential: server.credential,
size: imageSize || 300,
});
return {
album: item.album,
albumArtists: [{ id: item.artistId, name: item.artist }],
albumId: item.albumId,
artistName: item.artist,
artists: [{ id: item.artistId, name: item.artist }],
bitRate: item.bitRate,
compilation: item.compilation,
container: item.suffix,
createdAt: item.createdAt,
discNumber: item.discNumber,
duration: item.duration,
genres: item.genres,
id: item.id,
imageUrl,
isFavorite: item.starred,
name: item.title,
path: item.path,
playCount: item.playCount,
releaseDate: new Date(item.year, 0, 1).toISOString(),
releaseYear: String(item.year),
serverId: server.id,
size: item.size,
streamUrl: `${server.url}/rest/stream.view?id=${item.id}&v=1.13.0&c=feishin_${deviceId}&${server.credential}`,
trackNumber: item.trackNumber,
type: ServerType.NAVIDROME,
uniqueId: nanoid(),
updatedAt: item.updatedAt,
};
};
export const navidromeApi = {
authenticate,
createPlaylist,
deletePlaylist,
getAlbumArtistDetail,
getAlbumArtistList,
getAlbumDetail,
getAlbumList,
getGenreList,
getPlaylistDetail,
getPlaylistList,
getPlaylistSongList,
getSongDetail,
getSongList,
};
export const ndNormalize = {
album: normalizeAlbum,
song: normalizeSong,
};

View file

@ -0,0 +1,313 @@
export type NDAuthenticate = {
id: string;
isAdmin: boolean;
name: string;
subsonicSalt: string;
subsonicToken: string;
token: string;
username: string;
};
export type NDGenre = {
id: string;
name: string;
};
export type NDAlbum = {
albumArtist: string;
albumArtistId: string;
allArtistIds: string;
artist: string;
artistId: string;
compilation: boolean;
coverArtId: string;
coverArtPath: string;
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;
};
export type NDSong = {
album: string;
albumArtist: string;
albumArtistId: string;
albumId: string;
artist: string;
artistId: string;
bitRate: number;
bookmarkPosition: number;
channels: number;
compilation: boolean;
createdAt: string;
discNumber: number;
duration: number;
fullText: string;
genre: string;
genres: NDGenre[];
hasCoverArt: boolean;
id: 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;
};
export type NDAuthenticationResponse = NDAuthenticate;
export type NDAlbumArtistList = NDAlbumArtist[];
export type NDAlbumArtistDetail = NDAlbumArtist;
export type NDAlbumArtistDetailResponse = NDAlbumArtist;
export type NDGenreList = NDGenre[];
export type NDGenreListResponse = NDGenre[];
export type NDAlbumDetailResponse = NDAlbum;
export type NDAlbumDetail = NDAlbum & { songs?: NDSongListResponse };
export type NDAlbumListResponse = NDAlbum[];
export type NDAlbumList = {
items: NDAlbum[];
startIndex: number;
totalRecordCount: number;
};
export type NDSongDetail = NDSong;
export type NDSongDetailResponse = NDSong;
export type NDSongListResponse = NDSong[];
export type NDSongList = {
items: NDSong[];
startIndex: number;
totalRecordCount: number;
};
export type NDArtistListResponse = NDAlbumArtist[];
export type NDPagination = {
_end?: number;
_start?: number;
};
export enum NDSortOrder {
ASC = 'ASC',
DESC = 'DESC',
}
export type NDOrder = {
_order?: NDSortOrder;
};
export enum NDGenreListSort {
NAME = 'name',
}
export type NDGenreListParams = {
_sort?: NDGenreListSort;
id?: string;
} & NDPagination &
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',
}
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;
} & NDPagination &
NDOrder;
export enum NDSongListSort {
ALBUM = 'album',
ALBUM_ARTIST = 'albumArtist',
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',
TITLE = 'title',
TRACK = 'track',
YEAR = 'year',
}
export type NDSongListParams = {
_sort?: NDSongListSort;
genre_id?: string;
starred?: boolean;
} & NDPagination &
NDOrder;
export enum NDAlbumArtistListSort {
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;
} & NDPagination &
NDOrder;
export type NDCreatePlaylistParams = {
comment?: string;
name: string;
public: boolean;
};
export type NDCreatePlaylistResponse = {
id: string;
};
export type NDCreatePlaylist = NDCreatePlaylistResponse;
export type NDDeletePlaylistParams = {
id: string;
};
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: null;
size: number;
songCount: number;
sync: boolean;
updatedAt: string;
};
export type NDPlaylistDetail = NDPlaylist;
export type NDPlaylistDetailResponse = NDPlaylist;
export type NDPlaylistList = {
items: NDPlaylist[];
startIndex: number;
totalRecordCount: number;
};
export type NDPlaylistListResponse = NDPlaylist[];
export enum NDPlaylistListSort {
DURATION = 'duration',
NAME = 'name',
OWNER = 'owner',
PUBLIC = 'public',
SONG_COUNT = 'songCount',
UPDATED_AT = 'updatedAt',
}
export type NDPlaylistListParams = {
_sort?: NDPlaylistListSort;
owner_id?: string;
} & NDPagination &
NDOrder;

View file

@ -0,0 +1,51 @@
import { jfNormalize } from '/@/renderer/api/jellyfin.api';
import type { JFAlbum, JFSong } from '/@/renderer/api/jellyfin.types';
import { ndNormalize } from '/@/renderer/api/navidrome.api';
import type { NDAlbum, NDSong } from '/@/renderer/api/navidrome.types';
import type { RawAlbumListResponse, RawSongListResponse } from '/@/renderer/api/types';
import { ServerListItem } from '/@/renderer/types';
const albumList = (data: RawAlbumListResponse | undefined, server: ServerListItem | null) => {
let albums;
switch (server?.type) {
case 'jellyfin':
albums = data?.items.map((item) => jfNormalize.album(item as JFAlbum, server));
break;
case 'navidrome':
albums = data?.items.map((item) => ndNormalize.album(item as NDAlbum, server));
break;
case 'subsonic':
break;
}
return {
items: albums,
startIndex: data?.startIndex,
totalRecordCount: data?.totalRecordCount,
};
};
const songList = (data: RawSongListResponse | undefined, server: ServerListItem | null) => {
let songs;
switch (server?.type) {
case 'jellyfin':
songs = data?.items.map((item) => jfNormalize.song(item as JFSong, server, ''));
break;
case 'navidrome':
songs = data?.items.map((item) => ndNormalize.song(item as NDSong, server, ''));
break;
case 'subsonic':
break;
}
return {
items: songs,
startIndex: data?.startIndex,
totalRecordCount: data?.totalRecordCount,
};
};
export const normalize = {
albumList,
songList,
};

View file

@ -0,0 +1,24 @@
import type { AlbumListQuery, SongListQuery } from './types';
import type { AlbumDetailQuery } from './types';
export const queryKeys = {
albums: {
detail: (serverId: string, query: AlbumDetailQuery) =>
[serverId, 'albums', 'detail', query] as const,
list: (serverId: string, query: AlbumListQuery) => [serverId, 'albums', 'list', query] as const,
root: ['albums'],
serverRoot: (serverId: string) => [serverId, 'albums'],
songs: (serverId: string, query: SongListQuery) =>
[serverId, 'albums', 'songs', query] as const,
},
genres: {
list: (serverId: string) => [serverId, 'genres', 'list'] as const,
root: (serverId: string) => [serverId, 'genres'] as const,
},
server: {
root: (serverId: string) => [serverId] as const,
},
songs: {
list: (serverId: string, query: SongListQuery) => [serverId, 'songs', 'list', query] as const,
},
};

View file

@ -0,0 +1,297 @@
import ky from 'ky';
import md5 from 'md5';
import { randomString } from '/@/renderer/utils';
import type {
SSAlbumListResponse,
SSAlbumDetailResponse,
SSArtistIndex,
SSAlbumArtistList,
SSAlbumArtistListResponse,
SSGenreListResponse,
SSMusicFolderList,
SSMusicFolderListResponse,
SSGenreList,
SSAlbumDetail,
SSAlbumList,
SSAlbumArtistDetail,
SSAlbumArtistDetailResponse,
SSFavoriteParams,
SSFavoriteResponse,
SSRatingParams,
SSRatingResponse,
SSAlbumArtistDetailParams,
SSAlbumArtistListParams,
} from '/@/renderer/api/subsonic.types';
import type {
AlbumArtistDetailArgs,
AlbumArtistListArgs,
AlbumDetailArgs,
AlbumListArgs,
AuthenticationResponse,
FavoriteArgs,
FavoriteResponse,
GenreListArgs,
RatingArgs,
} from '/@/renderer/api/types';
import { useAuthStore } from '/@/renderer/store';
import { toast } from '/@/renderer/components';
const getCoverArtUrl = (args: {
baseUrl: string;
coverArtId: string;
credential: string;
size: number;
}) => {
const size = args.size ? args.size : 150;
if (!args.coverArtId || args.coverArtId.match('2a96cbd8b46e442fc41c2b86b821562f')) {
return null;
}
return (
`${args.baseUrl}/getCoverArt.view` +
`?id=${args.coverArtId}` +
`&${args.credential}` +
'&v=1.13.0' +
'&c=feishin' +
`&size=${size}`
);
};
const api = ky.create({
hooks: {
afterResponse: [
async (_request, _options, response) => {
const data = await response.json();
if (data['subsonic-response'].status !== 'ok') {
toast.warn({ message: 'Issue from Subsonic API' });
}
return new Response(JSON.stringify(data['subsonic-response']), { status: 200 });
},
],
beforeRequest: [
(request) => {
const server = useAuthStore.getState().currentServer;
const searchParams = new URLSearchParams();
if (server) {
const authParams = server.credential.split(/&?\w=/gm);
searchParams.set('u', server.username);
searchParams.set('v', '1.13.0');
searchParams.set('c', 'Feishin');
searchParams.set('f', 'json');
if (authParams?.length === 4) {
searchParams.set('s', authParams[2]);
searchParams.set('t', authParams[3]);
} else if (authParams?.length === 3) {
searchParams.set('p', authParams[2]);
}
}
return ky(request, { searchParams });
},
],
},
});
const authenticate = async (
url: string,
body: {
legacy?: boolean;
password: string;
username: string;
},
): Promise<AuthenticationResponse> => {
let credential;
const cleanServerUrl = url.replace(/\/$/, '');
if (body.legacy) {
credential = `u=${body.username}&p=${body.password}`;
} else {
const salt = randomString(12);
const hash = md5(body.password + salt);
credential = `u=${body.username}&s=${salt}&t=${hash}`;
}
await ky.get(`${cleanServerUrl}/rest/ping.view?v=1.13.0&c=Feishin&f=json&${credential}`);
return {
credential,
userId: null,
username: body.username,
};
};
const getMusicFolderList = async (
server: any,
signal?: AbortSignal,
): Promise<SSMusicFolderList> => {
const data = await api
.get('rest/getMusicFolders.view', {
prefixUrl: server.url,
signal,
})
.json<SSMusicFolderListResponse>();
return data.musicFolders.musicFolder;
};
export const getAlbumArtistDetail = async (
args: AlbumArtistDetailArgs,
): Promise<SSAlbumArtistDetail> => {
const { signal, query } = args;
const searchParams: SSAlbumArtistDetailParams = {
id: query.id,
};
const data = await api
.get('/getArtist.view', {
searchParams,
signal,
})
.json<SSAlbumArtistDetailResponse>();
return data.artist;
};
const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<SSAlbumArtistList> => {
const { signal, query } = args;
const searchParams: SSAlbumArtistListParams = {
musicFolderId: query.musicFolderId,
};
const data = await api
.get('/rest/getArtists.view', {
searchParams,
signal,
})
.json<SSAlbumArtistListResponse>();
const artists = (data.artists?.index || []).flatMap((index: SSArtistIndex) => index.artist);
return artists;
};
const getGenreList = async (args: GenreListArgs): Promise<SSGenreList> => {
const { signal } = args;
const data = await api
.get('/rest/getGenres.view', {
signal,
})
.json<SSGenreListResponse>();
return data.genres.genre;
};
const getAlbumDetail = async (args: AlbumDetailArgs): Promise<SSAlbumDetail> => {
const { query, signal } = args;
const data = await api
.get('/rest/getAlbum.view', {
searchParams: { id: query.id },
signal,
})
.json<SSAlbumDetailResponse>();
const { song: songs, ...dataWithoutSong } = data.album;
return { ...dataWithoutSong, songs };
};
const getAlbumList = async (args: AlbumListArgs): Promise<SSAlbumList> => {
const { query, signal } = args;
const normalizedParams = {};
const data = await api
.get('/rest/getAlbumList2.view', {
searchParams: normalizedParams,
signal,
})
.json<SSAlbumListResponse>();
return {
items: data.albumList2.album,
startIndex: query.startIndex,
totalRecordCount: null,
};
};
const createFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
const { query, signal } = args;
const searchParams: SSFavoriteParams = {
albumId: query.type === 'album' ? query.id : undefined,
artistId: query.type === 'albumArtist' ? query.id : undefined,
id: query.type === 'song' ? query.id : undefined,
};
await api
.get('/rest/star.view', {
searchParams,
signal,
})
.json<SSFavoriteResponse>();
return {
id: query.id,
};
};
const deleteFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
const { query, signal } = args;
const searchParams: SSFavoriteParams = {
albumId: query.type === 'album' ? query.id : undefined,
artistId: query.type === 'albumArtist' ? query.id : undefined,
id: query.type === 'song' ? query.id : undefined,
};
await api
.get('/rest/unstar.view', {
searchParams,
signal,
})
.json<SSFavoriteResponse>();
return {
id: query.id,
};
};
const updateRating = async (args: RatingArgs) => {
const { query, signal } = args;
const searchParams: SSRatingParams = {
id: query.id,
rating: query.rating,
};
const data = await api
.get('/rest/setRating.view', {
searchParams,
signal,
})
.json<SSRatingResponse>();
return data;
};
export const subsonicApi = {
authenticate,
createFavorite,
deleteFavorite,
getAlbumArtistDetail,
getAlbumArtistList,
getAlbumDetail,
getAlbumList,
getCoverArtUrl,
getGenreList,
getMusicFolderList,
updateRating,
};

View file

@ -0,0 +1,184 @@
export type SSBaseResponse = {
serverVersion?: 'string';
status: 'string';
type?: 'string';
version: 'string';
};
export type SSMusicFolderList = SSMusicFolder[];
export type SSMusicFolderListResponse = {
musicFolders: {
musicFolder: SSMusicFolder[];
};
};
export type SSGenreList = SSGenre[];
export type SSGenreListResponse = {
genres: {
genre: SSGenre[];
};
};
export type SSAlbumArtistDetailParams = {
id: string;
};
export type SSAlbumArtistDetail = SSAlbumArtistListEntry & { album: SSAlbumListEntry[] };
export type SSAlbumArtistDetailResponse = {
artist: SSAlbumArtistListEntry & {
album: SSAlbumListEntry[];
};
};
export type SSAlbumArtistList = SSAlbumArtistListEntry[];
export type SSAlbumArtistListResponse = {
artists: {
ignoredArticles: string;
index: SSArtistIndex[];
lastModified: number;
};
};
export type SSAlbumList = {
items: SSAlbumListEntry[];
startIndex: number;
totalRecordCount: number | null;
};
export type SSAlbumListResponse = {
albumList2: {
album: SSAlbumListEntry[];
};
};
export type SSAlbumDetail = Omit<SSAlbum, 'song'> & { songs: SSSong[] };
export type SSAlbumDetailResponse = {
album: SSAlbum;
};
export type SSArtistInfoResponse = {
artistInfo2: SSArtistInfo;
};
export type SSArtistInfo = {
biography: string;
largeImageUrl?: string;
lastFmUrl?: string;
mediumImageUrl?: string;
musicBrainzId?: 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;
};
export type SSAlbum = {
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;
};
export type SSAlbumListParams = {
fromYear?: number;
genre?: string;
musicFolderId?: string;
offset?: number;
size?: number;
toYear?: number;
type: string;
};
export type SSAlbumArtistListParams = {
musicFolderId?: string;
};
export type SSFavoriteParams = {
albumId?: string;
artistId?: string;
id?: string;
};
export type SSFavorite = null;
export type SSFavoriteResponse = null;
export type SSRatingParams = {
id: string;
rating: number;
};
export type SSRating = null;
export type SSRatingResponse = null;

795
src/renderer/api/types.ts Normal file
View file

@ -0,0 +1,795 @@
import {
JFSortOrder,
JFGenreList,
JFAlbumList,
JFAlbumListSort,
JFAlbumDetail,
JFSongList,
JFSongListSort,
JFAlbumArtistList,
JFAlbumArtistListSort,
JFAlbumArtistDetail,
JFArtistList,
JFArtistListSort,
JFPlaylistList,
JFPlaylistDetail,
JFMusicFolderList,
} from '/@/renderer/api/jellyfin.types';
import {
NDSortOrder,
NDOrder,
NDGenreList,
NDAlbumList,
NDAlbumListSort,
NDAlbumDetail,
NDSongList,
NDSongListSort,
NDSongDetail,
NDAlbumArtistList,
NDAlbumArtistListSort,
NDAlbumArtistDetail,
NDDeletePlaylist,
NDPlaylistList,
NDPlaylistListSort,
NDPlaylistDetail,
} from '/@/renderer/api/navidrome.types';
import {
SSAlbumList,
SSAlbumDetail,
SSAlbumArtistList,
SSAlbumArtistDetail,
SSMusicFolderList,
} from '/@/renderer/api/subsonic.types';
import { ServerListItem, ServerType } from '/@/renderer/types';
export enum SortOrder {
ASC = 'ASC',
DESC = 'DESC',
}
type SortOrderMap = {
jellyfin: Record<SortOrder, JFSortOrder>;
navidrome: Record<SortOrder, NDSortOrder>;
subsonic: Record<SortOrder, undefined>;
};
export const sortOrderMap: SortOrderMap = {
jellyfin: {
ASC: JFSortOrder.ASC,
DESC: JFSortOrder.DESC,
},
navidrome: {
ASC: NDSortOrder.ASC,
DESC: NDSortOrder.DESC,
},
subsonic: {
ASC: undefined,
DESC: undefined,
},
};
export enum ExternalSource {
LASTFM = 'LASTFM',
MUSICBRAINZ = 'MUSICBRAINZ',
SPOTIFY = 'SPOTIFY',
THEAUDIODB = 'THEAUDIODB',
}
export enum ExternalType {
ID = 'ID',
LINK = 'LINK',
}
export enum ImageType {
BACKDROP = 'BACKDROP',
LOGO = 'LOGO',
PRIMARY = 'PRIMARY',
SCREENSHOT = 'SCREENSHOT',
}
export type EndpointDetails = {
server: ServerListItem;
};
export interface BasePaginatedResponse<T> {
error?: string | any;
items: T;
startIndex: number;
totalRecordCount: number;
}
export type ApiError = {
error: {
message: string;
path: string;
trace: string[];
};
response: string;
statusCode: number;
};
export type AuthenticationResponse = {
credential: string;
ndCredential?: string;
userId: string | null;
username: string;
};
export type Genre = {
id: string;
name: string;
};
export type Album = {
albumArtists: RelatedArtist[];
artists: RelatedArtist[];
backdropImageUrl: string | null;
createdAt: string;
duration: number | null;
genres: Genre[];
id: string;
imagePlaceholderUrl: string | null;
imageUrl: string | null;
isCompilation: boolean | null;
isFavorite: boolean;
name: string;
playCount: number | null;
rating: number | null;
releaseDate: string | null;
releaseYear: number | null;
serverType: ServerType;
size: number | null;
songCount: number | null;
songs?: Song[];
uniqueId: string;
updatedAt: string;
};
export type Song = {
album: string;
albumArtists: RelatedArtist[];
albumId: string;
artistName: string;
artists: RelatedArtist[];
bitRate: number;
compilation: boolean | null;
container: string | null;
createdAt: string;
discNumber: number;
duration: number;
genres: Genre[];
id: string;
imageUrl: string | null;
isFavorite: boolean;
name: string;
path: string | null;
playCount: number;
releaseDate: string | null;
releaseYear: string | null;
serverId: string;
size: number;
streamUrl: string;
trackNumber: number;
type: ServerType;
uniqueId: string;
updatedAt: string;
};
export type AlbumArtist = {
biography: string | null;
createdAt: string;
id: string;
name: string;
remoteCreatedAt: string | null;
serverFolderId: string;
updatedAt: string;
};
export type RelatedAlbumArtist = {
id: string;
name: string;
};
export type Artist = {
biography: string | null;
createdAt: string;
id: string;
name: string;
remoteCreatedAt: string | null;
serverFolderId: string;
updatedAt: string;
};
export type RelatedArtist = {
id: string;
name: string;
};
export type MusicFolder = {
id: string;
name: string;
};
export type Playlist = {
duration?: number;
id: string;
name: string;
public?: boolean;
size?: number;
songCount?: number;
userId: string;
username: string;
};
export type GenresResponse = Genre[];
export type MusicFoldersResponse = MusicFolder[];
export type ListSortOrder = NDOrder | JFSortOrder;
type BaseEndpointArgs = {
server: ServerListItem | null;
signal?: AbortSignal;
};
// Genre List
export type RawGenreListResponse = NDGenreList | JFGenreList | undefined;
export type GenreListResponse = BasePaginatedResponse<Genre[]> | null | undefined;
export type GenreListArgs = { query: GenreListQuery } & BaseEndpointArgs;
export type GenreListQuery = null;
// Album List
export type RawAlbumListResponse = NDAlbumList | SSAlbumList | JFAlbumList | undefined;
export type AlbumListResponse = BasePaginatedResponse<Album[]> | null | undefined;
export enum AlbumListSort {
ALBUM_ARTIST = 'albumArtist',
ARTIST = 'artist',
COMMUNITY_RATING = 'communityRating',
CRITIC_RATING = 'criticRating',
DURATION = 'duration',
FAVORITED = 'favorited',
NAME = 'name',
PLAY_COUNT = 'playCount',
RANDOM = 'random',
RATING = 'rating',
RECENTLY_ADDED = 'recentlyAdded',
RECENTLY_PLAYED = 'recentlyPlayed',
RELEASE_DATE = 'releaseDate',
SONG_COUNT = 'songCount',
YEAR = 'year',
}
export type AlbumListQuery = {
jfParams?: {
filters?: string;
genres?: string;
years?: string;
};
limit?: number;
musicFolderId?: string;
ndParams?: {
artist_id?: string;
compilation?: boolean;
genre_id?: string;
has_rating?: boolean;
name?: string;
starred?: boolean;
year?: number;
};
sortBy: AlbumListSort;
sortOrder: SortOrder;
startIndex: number;
};
export type AlbumListArgs = { query: AlbumListQuery } & BaseEndpointArgs;
type AlbumListSortMap = {
jellyfin: Record<AlbumListSort, JFAlbumListSort | undefined>;
navidrome: Record<AlbumListSort, NDAlbumListSort | undefined>;
subsonic: Record<AlbumListSort, undefined>;
};
export const albumListSortMap: AlbumListSortMap = {
jellyfin: {
albumArtist: JFAlbumListSort.ALBUM_ARTIST,
artist: undefined,
communityRating: JFAlbumListSort.COMMUNITY_RATING,
criticRating: JFAlbumListSort.CRITIC_RATING,
duration: undefined,
favorited: undefined,
name: JFAlbumListSort.NAME,
playCount: undefined,
random: JFAlbumListSort.RANDOM,
rating: undefined,
recentlyAdded: JFAlbumListSort.RECENTLY_ADDED,
recentlyPlayed: undefined,
releaseDate: JFAlbumListSort.RELEASE_DATE,
songCount: undefined,
year: undefined,
},
navidrome: {
albumArtist: NDAlbumListSort.ALBUM_ARTIST,
artist: NDAlbumListSort.ARTIST,
communityRating: undefined,
criticRating: undefined,
duration: NDAlbumListSort.DURATION,
favorited: NDAlbumListSort.STARRED,
name: NDAlbumListSort.NAME,
playCount: NDAlbumListSort.PLAY_COUNT,
random: NDAlbumListSort.RANDOM,
rating: NDAlbumListSort.RATING,
recentlyAdded: NDAlbumListSort.RECENTLY_ADDED,
recentlyPlayed: NDAlbumListSort.PLAY_DATE,
releaseDate: undefined,
songCount: NDAlbumListSort.SONG_COUNT,
year: NDAlbumListSort.YEAR,
},
subsonic: {
albumArtist: undefined,
artist: undefined,
communityRating: undefined,
criticRating: undefined,
duration: undefined,
favorited: undefined,
name: undefined,
playCount: undefined,
random: undefined,
rating: undefined,
recentlyAdded: undefined,
recentlyPlayed: undefined,
releaseDate: undefined,
songCount: undefined,
year: undefined,
},
};
// Album Detail
export type RawAlbumDetailResponse = NDAlbumDetail | SSAlbumDetail | JFAlbumDetail | undefined;
export type AlbumDetailResponse = Album | null | undefined;
export type AlbumDetailQuery = { id: string };
export type AlbumDetailArgs = { query: AlbumDetailQuery } & BaseEndpointArgs;
// Song List
export type RawSongListResponse = NDSongList | JFSongList | undefined;
export type SongListResponse = BasePaginatedResponse<Song[]>;
export enum SongListSort {
ALBUM_ARTIST = 'albumArtist',
ARTIST = 'artist',
BPM = 'bpm',
CHANNELS = 'channels',
COMMENT = 'comment',
DURATION = 'duration',
FAVORITED = 'favorited',
GENRE = 'genre',
NAME = 'name',
PLAY_COUNT = 'playCount',
RANDOM = 'random',
RATING = 'rating',
RECENTLY_ADDED = 'recentlyAdded',
RECENTLY_PLAYED = 'recentlyPlayed',
RELEASE_DATE = 'releaseDate',
YEAR = 'year',
}
export type SongListQuery = {
jfParams?: {
filters?: string;
genres?: string;
includeItemTypes: 'Audio';
sortBy?: JFSongListSort;
years?: string;
};
limit?: number;
musicFolderId?: string;
ndParams?: {
artist_id?: string;
compilation?: boolean;
genre_id?: string;
has_rating?: boolean;
starred?: boolean;
title?: string;
year?: number;
};
sortBy: SongListSort;
sortOrder: SortOrder;
startIndex: number;
};
export type SongListArgs = { query: SongListQuery } & BaseEndpointArgs;
type SongListSortMap = {
jellyfin: Record<SongListSort, JFSongListSort | undefined>;
navidrome: Record<SongListSort, NDSongListSort | undefined>;
subsonic: Record<SongListSort, undefined>;
};
export const songListSortMap: SongListSortMap = {
jellyfin: {
albumArtist: JFSongListSort.ALBUM_ARTIST,
artist: JFSongListSort.ARTIST,
bpm: undefined,
channels: undefined,
comment: undefined,
duration: JFSongListSort.DURATION,
favorited: undefined,
genre: undefined,
name: JFSongListSort.NAME,
playCount: JFSongListSort.PLAY_COUNT,
random: JFSongListSort.RANDOM,
rating: undefined,
recentlyAdded: JFSongListSort.RECENTLY_ADDED,
recentlyPlayed: JFSongListSort.RECENTLY_PLAYED,
releaseDate: JFSongListSort.RELEASE_DATE,
year: undefined,
},
navidrome: {
albumArtist: NDSongListSort.ALBUM_ARTIST,
artist: NDSongListSort.ARTIST,
bpm: NDSongListSort.BPM,
channels: NDSongListSort.CHANNELS,
comment: NDSongListSort.COMMENT,
duration: NDSongListSort.DURATION,
favorited: NDSongListSort.FAVORITED,
genre: NDSongListSort.GENRE,
name: NDSongListSort.TITLE,
playCount: NDSongListSort.PLAY_COUNT,
random: undefined,
rating: NDSongListSort.RATING,
recentlyAdded: NDSongListSort.PLAY_DATE,
recentlyPlayed: NDSongListSort.PLAY_DATE,
releaseDate: undefined,
year: NDSongListSort.YEAR,
},
subsonic: {
albumArtist: undefined,
artist: undefined,
bpm: undefined,
channels: undefined,
comment: undefined,
duration: undefined,
favorited: undefined,
genre: undefined,
name: undefined,
playCount: undefined,
random: undefined,
rating: undefined,
recentlyAdded: undefined,
recentlyPlayed: undefined,
releaseDate: undefined,
year: undefined,
},
};
// Song Detail
export type RawSongDetailResponse = NDSongDetail | undefined;
export type SongDetailResponse = Song | null | undefined;
export type SongDetailQuery = { id: string };
export type SongDetailArgs = { query: SongDetailQuery } & BaseEndpointArgs;
// Album Artist List
export type RawAlbumArtistListResponse =
| NDAlbumArtistList
| SSAlbumArtistList
| JFAlbumArtistList
| undefined;
export type AlbumArtistListResponse = BasePaginatedResponse<AlbumArtist[]>;
export enum AlbumArtistListSort {
ALBUM = 'album',
ALBUM_COUNT = 'albumCount',
DURATION = 'duration',
FAVORITED = 'favorited',
NAME = 'name',
PLAY_COUNT = 'playCount',
RANDOM = 'random',
RATING = 'rating',
RECENTLY_ADDED = 'recentlyAdded',
RELEASE_DATE = 'releaseDate',
SONG_COUNT = 'songCount',
}
export type AlbumArtistListQuery = {
limit?: number;
musicFolderId?: string;
ndParams?: {
genre_id?: string;
name?: string;
starred?: boolean;
};
sortBy: AlbumArtistListSort;
sortOrder: SortOrder;
startIndex: number;
};
export type AlbumArtistListArgs = { query: AlbumArtistListQuery } & BaseEndpointArgs;
type AlbumArtistListSortMap = {
jellyfin: Record<AlbumArtistListSort, JFAlbumArtistListSort | undefined>;
navidrome: Record<AlbumArtistListSort, NDAlbumArtistListSort | undefined>;
subsonic: Record<AlbumArtistListSort, undefined>;
};
export const albumArtistListSortMap: AlbumArtistListSortMap = {
jellyfin: {
album: JFAlbumArtistListSort.ALBUM,
albumCount: undefined,
duration: JFAlbumArtistListSort.DURATION,
favorited: undefined,
name: JFAlbumArtistListSort.NAME,
playCount: undefined,
random: JFAlbumArtistListSort.RANDOM,
rating: undefined,
recentlyAdded: JFAlbumArtistListSort.RECENTLY_ADDED,
releaseDate: undefined,
songCount: undefined,
},
navidrome: {
album: undefined,
albumCount: NDAlbumArtistListSort.ALBUM_COUNT,
duration: undefined,
favorited: NDAlbumArtistListSort.FAVORITED,
name: NDAlbumArtistListSort.NAME,
playCount: NDAlbumArtistListSort.PLAY_COUNT,
random: undefined,
rating: NDAlbumArtistListSort.RATING,
recentlyAdded: undefined,
releaseDate: undefined,
songCount: NDAlbumArtistListSort.SONG_COUNT,
},
subsonic: {
album: undefined,
albumCount: undefined,
duration: undefined,
favorited: undefined,
name: undefined,
playCount: undefined,
random: undefined,
rating: undefined,
recentlyAdded: undefined,
releaseDate: undefined,
songCount: undefined,
},
};
// Album Artist Detail
export type RawAlbumArtistDetailResponse =
| NDAlbumArtistDetail
| SSAlbumArtistDetail
| JFAlbumArtistDetail
| undefined;
export type AlbumArtistDetailResponse = BasePaginatedResponse<AlbumArtist[]>;
export type AlbumArtistDetailQuery = { id: string };
export type AlbumArtistDetailArgs = { query: AlbumArtistDetailQuery } & BaseEndpointArgs;
// Artist List
export type RawArtistListResponse = JFArtistList | undefined;
export type ArtistListResponse = BasePaginatedResponse<Artist[]>;
export enum ArtistListSort {
ALBUM = 'album',
ALBUM_COUNT = 'albumCount',
DURATION = 'duration',
FAVORITED = 'favorited',
NAME = 'name',
PLAY_COUNT = 'playCount',
RANDOM = 'random',
RATING = 'rating',
RECENTLY_ADDED = 'recentlyAdded',
RELEASE_DATE = 'releaseDate',
SONG_COUNT = 'songCount',
}
export type ArtistListQuery = {
limit?: number;
musicFolderId?: string;
ndParams?: {
genre_id?: string;
name?: string;
starred?: boolean;
};
sortBy: ArtistListSort;
sortOrder: SortOrder;
startIndex: number;
};
export type ArtistListArgs = { query: ArtistListQuery } & BaseEndpointArgs;
type ArtistListSortMap = {
jellyfin: Record<ArtistListSort, JFArtistListSort | undefined>;
navidrome: Record<ArtistListSort, undefined>;
subsonic: Record<ArtistListSort, undefined>;
};
export const artistListSortMap: ArtistListSortMap = {
jellyfin: {
album: JFArtistListSort.ALBUM,
albumCount: undefined,
duration: JFArtistListSort.DURATION,
favorited: undefined,
name: JFArtistListSort.NAME,
playCount: undefined,
random: JFArtistListSort.RANDOM,
rating: undefined,
recentlyAdded: JFArtistListSort.RECENTLY_ADDED,
releaseDate: undefined,
songCount: undefined,
},
navidrome: {
album: undefined,
albumCount: undefined,
duration: undefined,
favorited: undefined,
name: undefined,
playCount: undefined,
random: undefined,
rating: undefined,
recentlyAdded: undefined,
releaseDate: undefined,
songCount: undefined,
},
subsonic: {
album: undefined,
albumCount: undefined,
duration: undefined,
favorited: undefined,
name: undefined,
playCount: undefined,
random: undefined,
rating: undefined,
recentlyAdded: undefined,
releaseDate: undefined,
songCount: undefined,
},
};
// Artist Detail
// Favorite
export type RawFavoriteResponse = FavoriteResponse | undefined;
export type FavoriteResponse = { id: string };
export type FavoriteQuery = { id: string; type?: 'song' | 'album' | 'albumArtist' };
export type FavoriteArgs = { query: FavoriteQuery } & BaseEndpointArgs;
// Rating
export type RawRatingResponse = null | undefined;
export type RatingResponse = null;
export type RatingQuery = { id: string; rating: number };
export type RatingArgs = { query: RatingQuery } & BaseEndpointArgs;
// Create Playlist
export type RawCreatePlaylistResponse = CreatePlaylistResponse | undefined;
export type CreatePlaylistResponse = { id: string; name: string };
export type CreatePlaylistQuery = { comment?: string; name: string; public?: boolean };
export type CreatePlaylistArgs = { query: CreatePlaylistQuery } & BaseEndpointArgs;
// Delete Playlist
export type RawDeletePlaylistResponse = NDDeletePlaylist | undefined;
export type DeletePlaylistResponse = null;
export type DeletePlaylistQuery = { id: string };
export type DeletePlaylistArgs = { query: DeletePlaylistQuery } & BaseEndpointArgs;
// Playlist List
export type RawPlaylistListResponse = NDPlaylistList | JFPlaylistList | undefined;
export type PlaylistListResponse = BasePaginatedResponse<Playlist[]>;
export type PlaylistListSort = NDPlaylistListSort;
export type PlaylistListQuery = {
limit?: number;
musicFolderId?: string;
sortBy: PlaylistListSort;
sortOrder: SortOrder;
startIndex: number;
};
export type PlaylistListArgs = { query: PlaylistListQuery } & BaseEndpointArgs;
type PlaylistListSortMap = {
jellyfin: Record<PlaylistListSort, undefined>;
navidrome: Record<PlaylistListSort, NDPlaylistListSort | undefined>;
subsonic: Record<PlaylistListSort, undefined>;
};
export const playlistListSortMap: PlaylistListSortMap = {
jellyfin: {
duration: undefined,
name: undefined,
owner: undefined,
public: undefined,
songCount: undefined,
updatedAt: undefined,
},
navidrome: {
duration: NDPlaylistListSort.DURATION,
name: NDPlaylistListSort.NAME,
owner: NDPlaylistListSort.OWNER,
public: NDPlaylistListSort.PUBLIC,
songCount: NDPlaylistListSort.SONG_COUNT,
updatedAt: NDPlaylistListSort.UPDATED_AT,
},
subsonic: {
duration: undefined,
name: undefined,
owner: undefined,
public: undefined,
songCount: undefined,
updatedAt: undefined,
},
};
// Playlist Detail
export type RawPlaylistDetailResponse = NDPlaylistDetail | JFPlaylistDetail | undefined;
export type PlaylistDetailResponse = BasePaginatedResponse<Playlist[]>;
export type PlaylistDetailQuery = {
id: string;
};
export type PlaylistDetailArgs = { query: PlaylistDetailQuery } & BaseEndpointArgs;
// Playlist Songs
export type RawPlaylistSongListResponse = JFSongList | undefined;
export type PlaylistSongListResponse = BasePaginatedResponse<Song[]>;
export type PlaylistSongListQuery = {
id: string;
limit?: number;
sortBy?: SongListSort;
sortOrder?: SortOrder;
startIndex: number;
};
export type PlaylistSongListArgs = { query: PlaylistSongListQuery } & BaseEndpointArgs;
// Music Folder List
export type RawMusicFolderListResponse = SSMusicFolderList | JFMusicFolderList | undefined;
export type MusicFolderListResponse = BasePaginatedResponse<Playlist[]>;
export type MusicFolderListQuery = {
id: string;
};
export type MusicFolderListArgs = { query: MusicFolderListQuery } & BaseEndpointArgs;
// Create Favorite
export type RawCreateFavoriteResponse = CreateFavoriteResponse | undefined;
export type CreateFavoriteResponse = { id: string };
export type CreateFavoriteQuery = { comment?: string; name: string; public?: boolean };
export type CreateFavoriteArgs = { query: CreateFavoriteQuery } & BaseEndpointArgs;

109
src/renderer/app.tsx Normal file
View file

@ -0,0 +1,109 @@
import { useEffect } from 'react';
import { ClientSideRowModelModule } from '@ag-grid-community/client-side-row-model';
import { ModuleRegistry } from '@ag-grid-community/core';
import { InfiniteRowModelModule } from '@ag-grid-community/infinite-row-model';
import { MantineProvider } from '@mantine/core';
import { ModalsProvider } from '@mantine/modals';
import { NotificationsProvider } from '@mantine/notifications';
import { QueryClientProvider } from '@tanstack/react-query';
import { initSimpleImg } from 'react-simple-img';
import { BaseContextModal } from './components';
import { useTheme } from './hooks';
import { queryClient } from './lib/react-query';
import { AppRouter } from './router/app-router';
import { useSettingsStore } from './store/settings.store';
import './styles/global.scss';
import '@ag-grid-community/styles/ag-grid.css';
ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule]);
initSimpleImg({ threshold: 0.05 }, true);
export const App = () => {
const theme = useTheme();
const contentFont = useSettingsStore((state) => state.general.fontContent);
const headerFont = useSettingsStore((state) => state.general.fontHeader);
useEffect(() => {
const root = document.documentElement;
root.style.setProperty('--content-font-family', contentFont);
root.style.setProperty('--header-font-family', headerFont);
}, [contentFont, headerFont]);
return (
<QueryClientProvider client={queryClient}>
<MantineProvider
withGlobalStyles
withNormalizeCSS
theme={{
breakpoints: {
lg: 1200,
md: 1000,
sm: 800,
xl: 1400,
xs: 500,
},
colorScheme: theme as 'light' | 'dark',
components: { Modal: { styles: { body: { padding: '.5rem' } } } },
defaultRadius: 'xs',
dir: 'ltr',
focusRing: 'auto',
focusRingStyles: {
inputStyles: () => ({
border: '1px solid var(--primary-color)',
}),
resetStyles: () => ({ outline: 'none' }),
styles: () => ({
outline: '1px solid var(--primary-color)',
outlineOffset: '-1px',
}),
},
fontFamily: 'var(--content-font-family)',
fontSizes: {
lg: 16,
md: 14,
sm: 12,
xl: 18,
xs: 10,
},
headings: { fontFamily: 'var(--header-font-family)' },
other: {},
spacing: {
lg: 12,
md: 8,
sm: 4,
xl: 16,
xs: 2,
},
}}
>
<NotificationsProvider
autoClose={1500}
position="bottom-right"
style={{
marginBottom: '85px',
opacity: '.8',
userSelect: 'none',
width: '250px',
}}
transitionDuration={200}
>
<ModalsProvider
modalProps={{
centered: true,
exitTransitionDuration: 300,
overflow: 'inside',
overlayBlur: 5,
overlayOpacity: 0.5,
transition: 'slide-down',
transitionDuration: 300,
}}
modals={{ base: BaseContextModal }}
>
<AppRouter />
</ModalsProvider>
</NotificationsProvider>
</MantineProvider>
</QueryClientProvider>
);
};

View file

@ -0,0 +1,23 @@
import type { AccordionProps as MantineAccordionProps } from '@mantine/core';
import { Accordion as MantineAccordion } from '@mantine/core';
import styled from 'styled-components';
type AccordionProps = MantineAccordionProps;
const StyledAccordion = styled(MantineAccordion)`
& .mantine-Accordion-panel {
background: var(--paper-bg);
}
.mantine-Accordion-control {
background: var(--paper-bg);
}
`;
export const Accordion = ({ children, ...props }: AccordionProps) => {
return <StyledAccordion {...props}>{children}</StyledAccordion>;
};
Accordion.Control = StyledAccordion.Control;
Accordion.Item = StyledAccordion.Item;
Accordion.Panel = StyledAccordion.Panel;

View file

@ -0,0 +1,191 @@
import { useImperativeHandle, forwardRef, useRef, useState, useCallback, useEffect } from 'react';
import isElectron from 'is-electron';
import type { ReactPlayerProps } from 'react-player';
import ReactPlayer from 'react-player';
import type { Song } from '/@/renderer/api/types';
import {
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;
}
export type AudioPlayerProgress = {
loaded: number;
loadedSeconds: number;
played: number;
playedSeconds: number;
};
const getDuration = (ref: any) => {
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.player.audioDeviceId);
useImperativeHandle(ref, () => ({
get player1() {
return player1Ref?.current;
},
get player2() {
return player2Ref?.current;
},
}));
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]);
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 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],
);
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}
/>
</>
);
},
);

View file

@ -0,0 +1,131 @@
/* eslint-disable no-nested-ternary */
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>;
}) => {
const { nextPlayerRef, currentTime, duration, isTransitioning, setIsTransitioning, isFlac } =
args;
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;
};
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;
}) => {
const {
currentTime,
player,
currentPlayer,
currentPlayerRef,
nextPlayerRef,
fadeDuration,
fadeType,
duration,
volume,
isTransitioning,
setIsTransitioning,
} = args;
if (!isTransitioning || currentPlayer !== player) {
const shouldBeginTransition = currentTime >= duration - fadeDuration;
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;
};

View file

@ -0,0 +1,34 @@
import type { BadgeProps as MantineBadgeProps } from '@mantine/core';
import { createPolymorphicComponent } from '@mantine/core';
import { Badge as MantineBadge } from '@mantine/core';
import styled from 'styled-components';
export type BadgeProps = MantineBadgeProps;
const StyledBadge = styled(MantineBadge)<BadgeProps>`
.mantine-Badge-root {
color: var(--badge-fg);
}
.mantine-Badge-inner {
padding: 0 0.5rem;
color: var(--badge-fg);
}
`;
const _Badge = ({ children, ...props }: BadgeProps) => {
return (
<StyledBadge
radius="md"
size="sm"
styles={{
root: { background: 'var(--badge-bg)' },
}}
{...props}
>
{children}
</StyledBadge>
);
};
export const Badge = createPolymorphicComponent<'button', BadgeProps>(_Badge);

View file

@ -0,0 +1,290 @@
import type { Ref } from 'react';
import React, { useRef, useCallback, useState, forwardRef } from 'react';
import type { ButtonProps as MantineButtonProps, TooltipProps } from '@mantine/core';
import { Button as MantineButton, createPolymorphicComponent } from '@mantine/core';
import { useTimeout } from '@mantine/hooks';
import styled from 'styled-components';
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'>;
}
interface StyledButtonProps extends ButtonProps {
ref: Ref<HTMLButtonElement>;
}
const StyledButton = styled(MantineButton)<StyledButtonProps>`
color: ${(props) => {
switch (props.variant) {
case 'default':
return 'var(--btn-default-fg)';
case 'filled':
return 'var(--btn-primary-fg)';
case 'subtle':
return 'var(--btn-subtle-fg)';
default:
return '';
}
}};
background: ${(props) => {
switch (props.variant) {
case 'default':
return 'var(--btn-default-bg)';
case 'filled':
return 'var(--btn-primary-bg)';
case 'subtle':
return 'var(--btn-subtle-bg)';
default:
return '';
}
}};
border: none;
transition: background 0.2s ease-in-out, color 0.2s ease-in-out;
svg {
transition: fill 0.2s ease-in-out;
fill: ${(props) => {
switch (props.variant) {
case 'default':
return 'var(--btn-default-fg)';
case 'filled':
return 'var(--btn-primary-fg)';
case 'subtle':
return 'var(--btn-subtle-fg)';
default:
return '';
}
}};
}
&:disabled {
color: ${(props) => {
switch (props.variant) {
case 'default':
return 'var(--btn-default-fg)';
case 'filled':
return 'var(--btn-primary-fg)';
case 'subtle':
return 'var(--btn-subtle-fg)';
default:
return '';
}
}};
background: ${(props) => {
switch (props.variant) {
case 'default':
return 'var(--btn-default-bg)';
case 'filled':
return 'var(--btn-primary-bg)';
case 'subtle':
return 'var(--btn-subtle-bg)';
default:
return '';
}
}};
opacity: 0.6;
}
&:hover {
color: ${(props) => {
switch (props.variant) {
case 'default':
return 'var(--btn-default-fg-hover)';
case 'filled':
return 'var(--btn-primary-fg-hover)';
case 'subtle':
return 'var(--btn-subtle-fg-hover)';
default:
return '';
}
}};
background: ${(props) => {
switch (props.variant) {
case 'default':
return 'var(--btn-default-bg-hover)';
case 'filled':
return 'var(--btn-primary-bg-hover)';
case 'subtle':
return 'var(--btn-subtle-bg-hover)';
default:
return '';
}
}};
svg {
fill: ${(props) => {
switch (props.variant) {
case 'default':
return 'var(--btn-default-fg-hover)';
case 'filled':
return 'var(--btn-primary-fg-hover)';
case 'subtle':
return 'var(--btn-subtle-fg-hover)';
default:
return '';
}
}};
}
}
&:focus-visible {
color: ${(props) => {
switch (props.variant) {
case 'default':
return 'var(--btn-default-fg-hover)';
case 'filled':
return 'var(--btn-primary-fg-hover)';
case 'subtle':
return 'var(--btn-subtle-fg-hover)';
default:
return '';
}
}};
background: ${(props) => {
switch (props.variant) {
case 'default':
return 'var(--btn-default-bg-hover)';
case 'filled':
return 'var(--btn-primary-bg-hover)';
case 'subtle':
return 'var(--btn-subtle-bg-hover)';
default:
return '';
}
}};
}
&:active {
transform: scale(0.98);
}
& .mantine-Button-centerLoader {
display: none;
}
& .mantine-Button-leftIcon {
display: flex;
margin-right: 0.5rem;
}
.mantine-Button-rightIcon {
display: flex;
margin-left: 0.5rem;
}
`;
const ButtonChildWrapper = styled.span<{ $loading?: boolean }>`
color: ${(props) => props.$loading && 'transparent !important'};
`;
const SpinnerWrapper = styled.div`
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>
);
}
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,
};
interface HoldButtonProps extends ButtonProps {
timeoutProps: {
callback: () => void;
duration: number;
};
}
export const TimeoutButton = ({ timeoutProps, ...props }: HoldButtonProps) => {
const [_timeoutRemaining, 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 { start, clear } = useTimeout(callback, timeoutProps.duration);
const startTimeout = useCallback(() => {
if (isRunning) {
clearInterval(intervalRef.current);
setIsRunning(false);
clear();
} else {
setIsRunning(true);
start();
const intervalId = window.setInterval(() => {
setTimeoutRemaining((prev) => prev - 100);
}, 100);
intervalRef.current = intervalId;
}
}, [clear, isRunning, start]);
return (
<_Button
sx={{ color: 'var(--danger-color)' }}
onClick={startTimeout}
{...props}
>
{isRunning ? 'Cancel' : props.children}
</_Button>
);
};

View file

@ -0,0 +1,304 @@
import React, { useCallback } from 'react';
import { Center } from '@mantine/core';
import { RiAlbumFill } from 'react-icons/ri';
import { generatePath, useNavigate } from 'react-router';
import { Link } from 'react-router-dom';
import { SimpleImg } from 'react-simple-img';
import styled from 'styled-components';
import { Text } from '/@/renderer/components/text';
import type { LibraryItem, CardRow, CardRoute, Play } from '/@/renderer/types';
import { Skeleton } from '/@/renderer/components/skeleton';
import CardControls from '/@/renderer/components/card/card-controls';
const CardWrapper = styled.div<{
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;
&:hover {
background: var(--card-default-bg-hover);
}
&: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);
`;
const ImageSection = styled.div`
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;
}
`;
const Image = styled(SimpleImg)`
border-radius: var(--card-default-radius);
box-shadow: 2px 2px 10px 10px 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;
`;
const DetailSection = styled.div`
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;
`;
interface BaseGridCardProps {
controls: {
cardRows: CardRow[];
itemType: LibraryItem;
playButtonBehavior: Play;
route: CardRoute;
};
data: any;
loading?: boolean;
size: number;
}
export const AlbumCard = ({ loading, size, data, controls }: BaseGridCardProps) => {
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]);
if (!loading) {
return (
<CardWrapper
link
onClick={handleNavigate}
>
<StyledCard>
<ImageSection>
{data?.imageUrl ? (
<Image
animationDuration={0.3}
height={size}
imgStyle={{ objectFit: 'cover' }}
placeholder="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
itemData={data}
itemType={itemType}
/>
</ControlsContainer>
</ImageSection>
<DetailSection>
{cardRows.map((row: CardRow, 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
sx={{
display: 'inline-block',
padding: '0 2px 0 1px',
}}
>
,
</Text>
)}{' '}
<Text
$link
$noSelect
$secondary={index > 0}
component={Link}
overflow="hidden"
size={index > 0 ? 'xs' : 'md'}
to={generatePath(
row.route!.route,
row.route!.slugs?.reduce((acc, slug) => {
return {
...acc,
[slug.slugProperty]: data[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 ? 'xs' : '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 ? 'xs' : 'md'}
>
{data && data[row.property]}
</Text>
)}
</Row>
);
})}
</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, index: number) => (
<Skeleton
visible
height={15}
my={3}
radius="md"
width={!data ? (index > 0 ? '50%' : '90%') : '100%'}
>
<Row />
</Skeleton>
))}
</DetailSection>
</StyledCard>
</CardWrapper>
);
};

View file

@ -0,0 +1,196 @@
import type { MouseEvent } from 'react';
import React from 'react';
import type { UnstyledButtonProps } from '@mantine/core';
import { Group } from '@mantine/core';
import { RiPlayFill, RiMore2Fill, RiHeartFill, RiHeartLine } from 'react-icons/ri';
import styled from 'styled-components';
import { _Button } from '/@/renderer/components/button';
import { DropdownMenu } from '/@/renderer/components/dropdown-menu';
import type { LibraryItem } from '/@/renderer/types';
import { Play } from '/@/renderer/types';
import { useSettingsStore } from '/@/renderer/store/settings.store';
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;
&:hover {
opacity: 1;
scale: 1.1;
}
&:active {
opacity: 1;
scale: 1;
}
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;
&:hover {
opacity: 1;
scale: 1.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%;
`;
const ControlsRow = styled.div`
width: 100%;
height: calc(100% / 3);
`;
// const TopControls = styled(ControlsRow)`
// display: flex;
// align-items: flex-start;
// justify-content: space-between;
// padding: 0.5rem;
// `;
// const CenterControls = styled(ControlsRow)`
// display: flex;
// align-items: center;
// justify-content: center;
// padding: 0.5rem;
// `;
const BottomControls = styled(ControlsRow)`
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)'};
}
`;
const PLAY_TYPES = [
{
label: 'Play',
play: Play.NOW,
},
{
label: 'Play last',
play: Play.LAST,
},
{
label: 'Play next',
play: Play.NEXT,
},
];
export const CardControls = ({ itemData, itemType }: { itemData: any; itemType: LibraryItem }) => {
const playButtonBehavior = useSettingsStore((state) => state.player.playButtonBehavior);
const handlePlay = (e: MouseEvent<HTMLButtonElement>, playType?: Play) => {
e.preventDefault();
e.stopPropagation();
import('/@/renderer/features/player/utils/handle-playqueue-add').then((fn) => {
fn.handlePlayQueueAdd({
byItemType: {
id: itemData.id,
type: itemType,
},
play: playType || playButtonBehavior,
});
});
};
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>
<DropdownMenu
withinPortal
position="bottom-start"
>
<DropdownMenu.Target>
<SecondaryButton
p={5}
sx={{ svg: { fill: 'white !important' } }}
variant="subtle"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<RiMore2Fill
color="white"
size={20}
/>
</SecondaryButton>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{PLAY_TYPES.filter((type) => type.play !== playButtonBehavior).map((type) => (
<DropdownMenu.Item
key={`playtype-${type.play}`}
onClick={(e: MouseEvent<HTMLButtonElement>) => handlePlay(e, type.play)}
>
{type.label}
</DropdownMenu.Item>
))}
<DropdownMenu.Item disabled>Add to playlist</DropdownMenu.Item>
<DropdownMenu.Item disabled>Refresh metadata</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
</BottomControls>
</GridCardControlsContainer>
);
};
export default CardControls;

View file

@ -0,0 +1 @@
export * from './album-card';

View file

@ -0,0 +1,50 @@
import type { DatePickerProps as MantineDatePickerProps } from '@mantine/dates';
import { DatePicker as MantineDatePicker } from '@mantine/dates';
import styled from 'styled-components';
interface DatePickerProps extends MantineDatePickerProps {
maxWidth?: number | string;
width?: number | string;
}
const StyledDatePicker = styled(MantineDatePicker)<DatePickerProps>`
& .mantine-DatePicker-input {
color: var(--input-fg);
background: var(--input-bg);
&::placeholder {
color: var(--input-placeholder-fg);
}
}
& .mantine-DatePicker-icon {
color: var(--input-placeholder-fg);
}
& .mantine-DatePicker-required {
color: var(--secondary-color);
}
& .mantine-DatePicker-label {
font-family: var(--label-font-faimly);
}
& .mantine-DateRangePicker-disabled {
opacity: 0.6;
}
`;
export const DatePicker = ({ width, maxWidth, ...props }: DatePickerProps) => {
return (
<StyledDatePicker
withinPortal
{...props}
sx={{ maxWidth, width }}
/>
);
};
DatePicker.defaultProps = {
maxWidth: undefined,
width: undefined,
};

View file

@ -0,0 +1,116 @@
import type {
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 { RiArrowLeftLine } from 'react-icons/ri';
import styled from 'styled-components';
type MenuProps = MantineMenuProps;
type MenuLabelProps = MantineMenuLabelProps;
interface MenuItemProps extends MantineMenuItemProps {
$isActive?: boolean;
children: React.ReactNode;
}
type MenuDividerProps = MantineMenuDividerProps;
type MenuDropdownProps = MantineMenuDropdownProps;
const StyledMenu = styled(MantineMenu)<MenuProps>``;
const StyledMenuLabel = styled(MantineMenu.Label)<MenuLabelProps>`
font-family: var(--content-font-family);
`;
const StyledMenuItem = styled(MantineMenu.Item)<MenuItemProps>`
padding: 0.8rem;
font-size: 0.9em;
font-family: var(--content-font-family);
&:disabled {
opacity: 0.6;
}
&:hover {
background-color: var(--dropdown-menu-bg-hover);
}
& .mantine-Menu-itemIcon {
margin-right: 0.5rem;
}
& .mantine-Menu-itemLabel {
color: ${({ $isActive }) => ($isActive ? 'var(--primary-color)' : 'var(--dropdown-menu-fg)')};
font-weight: 500;
font-size: 1em;
}
& .mantine-Menu-itemRightSection {
display: flex;
margin-left: 2rem !important;
color: ${({ $isActive }) => ($isActive ? 'var(--primary-color)' : 'var(--dropdown-menu-fg)')};
}
`;
const StyledMenuDropdown = styled(MantineMenu.Dropdown)`
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%));
`;
const StyledMenuDivider = styled(MantineMenu.Divider)`
margin: 0.3rem 0;
`;
export const DropdownMenu = ({ children, ...props }: MenuProps) => {
return (
<StyledMenu
withinPortal
radius="sm"
styles={{
dropdown: {
filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 50%))',
},
}}
transition="scale"
{...props}
>
{children}
</StyledMenu>
);
};
const MenuLabel = ({ children, ...props }: MenuLabelProps) => {
return <StyledMenuLabel {...props}>{children}</StyledMenuLabel>;
};
const pMenuItem = ({ $isActive, children, ...props }: MenuItemProps) => {
return (
<StyledMenuItem
$isActive={$isActive}
rightSection={$isActive && <RiArrowLeftLine />}
{...props}
>
{children}
</StyledMenuItem>
);
};
const MenuDropdown = ({ children, ...props }: MenuDropdownProps) => {
return <StyledMenuDropdown {...props}>{children}</StyledMenuDropdown>;
};
const MenuItem = createPolymorphicComponent<'button', MenuItemProps>(pMenuItem);
const MenuDivider = ({ ...props }: MenuDividerProps) => {
return <StyledMenuDivider {...props} />;
};
DropdownMenu.Label = MenuLabel;
DropdownMenu.Item = MenuItem;
DropdownMenu.Target = MantineMenu.Target;
DropdownMenu.Dropdown = MenuDropdown;
DropdownMenu.Divider = MenuDivider;

View file

@ -0,0 +1,33 @@
import type { DropzoneProps as MantineDropzoneProps } from '@mantine/dropzone';
import { Dropzone as MantineDropzone } from '@mantine/dropzone';
import styled from 'styled-components';
export type DropzoneProps = MantineDropzoneProps;
const StyledDropzone = styled(MantineDropzone)`
display: flex;
justify-content: center;
width: 100%;
height: 100%;
background: var(--input-bg);
border-radius: 5px;
opacity: 0.8;
transition: opacity 0.2s ease;
&:hover {
background: var(--input-bg);
opacity: 1;
}
& .mantine-Dropzone-inner {
display: flex;
}
`;
export const Dropzone = ({ children, ...props }: DropzoneProps) => {
return <StyledDropzone {...props}>{children}</StyledDropzone>;
};
Dropzone.Accept = StyledDropzone.Accept;
Dropzone.Idle = StyledDropzone.Idle;
Dropzone.Reject = StyledDropzone.Reject;

View file

@ -0,0 +1,206 @@
import type { MouseEvent } from 'react';
import { useState } from 'react';
import { Group, Image, Stack } from '@mantine/core';
import type { Variants } from 'framer-motion';
import { AnimatePresence, motion } from 'framer-motion';
import { RiArrowLeftSLine, RiArrowRightSLine } from 'react-icons/ri';
import { Link, generatePath } from 'react-router-dom';
import styled from 'styled-components';
import type { Album } from '/@/renderer/api/types';
import { Button } from '/@/renderer/components/button';
import { TextTitle } from '/@/renderer/components/text-title';
import { Badge } from '/@/renderer/components/badge';
import { AppRoute } from '/@/renderer/router/routes';
const Carousel = styled(motion.div)`
position: relative;
padding: 2rem;
overflow: hidden;
background: linear-gradient(180deg, var(--main-bg), rgba(25, 26, 28, 60%));
`;
const Grid = styled.div`
display: grid;
grid-auto-columns: 1fr;
grid-template-areas: 'image info';
grid-template-rows: 1fr;
grid-template-columns: 150px 1fr;
gap: 0.5rem;
width: 100%;
max-width: 100%;
height: 100%;
`;
const ImageColumn = styled.div`
z-index: 15;
display: flex;
grid-area: image;
`;
const InfoColumn = styled.div`
z-index: 15;
display: flex;
grid-area: info;
width: 100%;
`;
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;
`;
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));
`;
const Wrapper = styled(Link)`
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
`;
const TitleWrapper = styled.div`
display: -webkit-box;
-webkit-line-clamp: 1;
-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,
},
};
interface FeatureCarouselProps {
data: Album[] | undefined;
loading?: boolean;
}
export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
const [itemIndex, setItemIndex] = useState(0);
const [direction, setDirection] = useState(0);
const currentItem = data?.[itemIndex];
const handleNext = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
setDirection(1);
setItemIndex((prev) => prev + 1);
};
const handlePrevious = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
setDirection(-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={150}
placeholder="var(--card-default-bg)"
radius="sm"
src={data[itemIndex]?.imageUrl}
sx={{ objectFit: 'cover' }}
width={150}
/>
</ImageColumn>
<InfoColumn>
<Stack sx={{ width: '100%' }}>
<TitleWrapper>
<TextTitle fw="bold">{currentItem?.name}</TextTitle>
</TitleWrapper>
<TitleWrapper>
{currentItem?.albumArtists.map((artist) => (
<TextTitle
fw="600"
order={3}
>
{artist.name}
</TextTitle>
))}
</TitleWrapper>
<Group>
{currentItem?.genres?.map((genre) => (
<Badge key={`carousel-genre-${genre.id}`}>{genre.name}</Badge>
))}
<Badge>{currentItem?.releaseYear}</Badge>
<Badge>{currentItem?.songCount} tracks</Badge>
</Group>
</Stack>
</InfoColumn>
</Grid>
<BackgroundImage
draggable="false"
src={currentItem?.imageUrl || undefined}
/>
<BackgroundImageOverlay />
</Carousel>
)}
</AnimatePresence>
<Group
spacing="xs"
sx={{ bottom: 0, position: 'absolute', right: 0, zIndex: 20 }}
>
<Button
disabled={itemIndex === 0}
px="lg"
radius={100}
variant="subtle"
onClick={handlePrevious}
>
<RiArrowLeftSLine size={15} />
</Button>
<Button
disabled={itemIndex === (data?.length || 1) - 1}
px="lg"
radius={100}
variant="subtle"
onClick={handleNext}
>
<RiArrowRightSLine size={15} />
</Button>
</Group>
</Wrapper>
);
};

View file

@ -0,0 +1,210 @@
import { createContext, useContext, useState, useCallback, useMemo } from 'react';
import { Group, Stack } from '@mantine/core';
import type { Variants } from 'framer-motion';
import { AnimatePresence, motion } from 'framer-motion';
import { RiArrowLeftSLine, RiArrowRightSLine } from 'react-icons/ri';
import { AlbumCard, Button } from '/@/renderer/components';
import { AppRoute } from '/@/renderer/router/routes';
import type { CardRow } from '/@/renderer/types';
import { LibraryItem, Play } from '/@/renderer/types';
import styled from 'styled-components';
interface GridCarouselProps {
cardRows: CardRow[];
children: React.ReactElement;
containerWidth: number;
data: any[] | undefined;
loading?: boolean;
pagination?: {
handleNextPage?: () => void;
handlePreviousPage?: () => void;
hasNextPage?: boolean;
hasPreviousPage?: boolean;
itemsPerPage?: number;
};
uniqueId: string;
}
const GridCarouselContext = createContext<any>(null);
export const GridCarousel = ({
data,
loading,
cardRows,
pagination,
children,
containerWidth,
uniqueId,
}: GridCarouselProps) => {
const [direction, setDirection] = useState(0);
const gridHeight = useMemo(
() => (containerWidth * 1.2 - 36) / (pagination?.itemsPerPage || 4),
[containerWidth, pagination?.itemsPerPage],
);
const imageSize = useMemo(() => gridHeight * 0.66, [gridHeight]);
const providerValue = useMemo(
() => ({
cardRows,
data,
direction,
gridHeight,
imageSize,
loading,
pagination,
setDirection,
uniqueId,
}),
[cardRows, data, direction, gridHeight, imageSize, loading, pagination, uniqueId],
);
return (
<GridCarouselContext.Provider value={providerValue}>
<Stack>
{children}
{data && (
<Carousel
cardRows={cardRows}
data={data}
/>
)}
</Stack>
</GridCarouselContext.Provider>
);
};
const variants: Variants = {
animate: (custom: { direction: number; loading: boolean }) => {
return {
opacity: custom.loading ? 0.5 : 1,
scale: custom.loading ? 0.95 : 1,
transition: {
opacity: { duration: 0.2 },
x: { damping: 30, stiffness: 300, type: 'spring' },
},
x: 0,
};
},
exit: (custom: { direction: number; loading: boolean }) => {
return {
opacity: 0,
transition: {
opacity: { duration: 0.2 },
x: { damping: 30, stiffness: 300, type: 'spring' },
},
x: custom.direction > 0 ? -1000 : 1000,
};
},
initial: (custom: { direction: number; loading: boolean }) => {
return {
opacity: 0,
x: custom.direction > 0 ? 1000 : -1000,
};
},
};
const GridContainer = styled(motion.div)<{ height: number; itemsPerPage: number }>`
display: grid;
grid-auto-rows: 0;
grid-gap: 18px;
grid-template-rows: 1fr;
grid-template-columns: repeat(${(props) => props.itemsPerPage || 4}, minmax(0, 1fr));
height: ${(props) => props.height}px;
overflow: hidden;
`;
const Wrapper = styled.div`
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
`;
const Carousel = ({ data, cardRows }: any) => {
const { loading, pagination, gridHeight, imageSize, direction, uniqueId } =
useContext(GridCarouselContext);
return (
<Wrapper>
<AnimatePresence
custom={{ direction, loading }}
initial={false}
mode="popLayout"
>
<GridContainer
key={`carousel-${uniqueId}-${data[0].id}`}
animate="animate"
custom={{ direction, loading }}
exit="exit"
height={gridHeight}
initial="initial"
itemsPerPage={pagination.itemsPerPage}
variants={variants}
>
{data?.map((item: any, index: number) => (
<AlbumCard
key={`card-${uniqueId}-${index}`}
controls={{
cardRows,
itemType: LibraryItem.ALBUM,
playButtonBehavior: Play.NOW,
route: {
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
},
}}
data={item}
size={imageSize}
/>
))}
</GridContainer>
</AnimatePresence>
</Wrapper>
);
};
interface TitleProps {
children?: React.ReactNode;
}
const Title = ({ children }: TitleProps) => {
const { pagination, setDirection } = useContext(GridCarouselContext);
const handleNextPage = useCallback(() => {
setDirection(1);
pagination?.handleNextPage?.();
}, [pagination, setDirection]);
const handlePreviousPage = useCallback(() => {
setDirection(-1);
pagination?.handlePreviousPage?.();
}, [pagination, setDirection]);
return (
<Group position="apart">
{children}
<Group>
<Button
compact
disabled={!pagination?.hasPreviousPage}
variant="default"
onClick={handlePreviousPage}
>
<RiArrowLeftSLine size={15} />
</Button>
<Button
compact
variant="default"
onClick={handleNextPage}
>
<RiArrowRightSLine size={15} />
</Button>
</Group>
</Group>
);
};
GridCarousel.Title = Title;
GridCarousel.Carousel = Carousel;

View file

@ -0,0 +1,29 @@
export * from './tooltip';
export * from './audio-player';
export * from './text';
export * from './button';
export * from './virtual-grid';
export * from './modal';
export * from './input';
export * from './segmented-control';
export * from './dropdown-menu';
export * from './toast';
export * from './switch';
export * from './popover';
export * from './select';
export * from './date-picker';
export * from './scroll-area';
export * from './paper';
export * from './tabs';
export * from './slider';
export * from './accordion';
export * from './dropzone';
export * from './spinner';
export * from './virtual-table';
export * from './skeleton';
export * from './page-header';
export * from './text-title';
export * from './grid-carousel';
export * from './card';
export * from './feature-carousel';
export * from './badge';

View file

@ -0,0 +1,364 @@
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,
} from '@mantine/core';
import {
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;
}
interface NumberInputProps extends MantineNumberInputProps {
children?: React.ReactNode;
maxWidth?: number | string;
width?: number | string;
}
interface PasswordInputProps extends MantinePasswordInputProps {
children?: React.ReactNode;
maxWidth?: number | string;
width?: number | string;
}
interface FileInputProps extends MantineFileInputProps {
children?: React.ReactNode;
maxWidth?: number | string;
width?: number | string;
}
interface JsonInputProps extends MantineJsonInputProps {
children?: React.ReactNode;
maxWidth?: number | string;
width?: number | string;
}
interface TextareaProps extends MantineTextareaProps {
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-Input-icon {
color: var(--input-placeholder-fg);
}
& .mantine-TextInput-required {
color: var(--secondary-color);
}
& .mantine-TextInput-label {
font-family: var(--label-font-faimly);
}
& .mantine-TextInput-disabled {
opacity: 0.6;
}
`;
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-rightSection {
color: var(--input-placeholder-fg);
background: var(--input-bg);
} */
& .mantine-NumberInput-controlUp {
svg {
color: white;
fill: white;
}
}
& .mantine-Input-icon {
color: var(--input-placeholder-fg);
}
& .mantine-NumberInput-required {
color: var(--secondary-color);
}
& .mantine-NumberInput-label {
font-family: var(--label-font-faimly);
}
& .mantine-NumberInput-disabled {
opacity: 0.6;
}
`;
const StyledPasswordInput = styled(MantinePasswordInput)<PasswordInputProps>`
& .mantine-PasswordInput-input {
color: var(--input-fg);
background: var(--input-bg);
&::placeholder {
color: var(--input-placeholder-fg);
}
}
& .mantine-PasswordInput-icon {
color: var(--input-placeholder-fg);
}
& .mantine-PasswordInput-required {
color: var(--secondary-color);
}
& .mantine-PasswordInput-label {
font-family: var(--label-font-faimly);
}
& .mantine-PasswordInput-disabled {
opacity: 0.6;
}
`;
const StyledFileInput = styled(MantineFileInput)<FileInputProps>`
& .mantine-FileInput-input {
color: var(--input-fg);
background: var(--input-bg);
&::placeholder {
color: var(--input-placeholder-fg);
}
}
& .mantine-FileInput-icon {
color: var(--input-placeholder-fg);
}
& .mantine-FileInput-required {
color: var(--secondary-color);
}
& .mantine-FileInput-label {
font-family: var(--label-font-faimly);
}
& .mantine-FileInput-disabled {
opacity: 0.6;
}
`;
const StyledJsonInput = styled(MantineJsonInput)<JsonInputProps>`
& .mantine-JsonInput-input {
color: var(--input-fg);
background: var(--input-bg);
&::placeholder {
color: var(--input-placeholder-fg);
}
}
& .mantine-JsonInput-icon {
color: var(--input-placeholder-fg);
}
& .mantine-JsonInput-required {
color: var(--secondary-color);
}
& .mantine-JsonInput-label {
font-family: var(--label-font-faimly);
}
& .mantine-JsonInput-disabled {
opacity: 0.6;
}
`;
const StyledTextarea = styled(MantineTextarea)<TextareaProps>`
& .mantine-Textarea-input {
color: var(--input-fg);
background: var(--input-bg);
&::placeholder {
color: var(--input-placeholder-fg);
}
}
& .mantine-Textarea-icon {
color: var(--input-placeholder-fg);
}
& .mantine-Textarea-required {
color: var(--secondary-color);
}
& .mantine-Textarea-label {
font-family: var(--label-font-faimly);
}
& .mantine-Textarea-disabled {
opacity: 0.6;
}
`;
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
({ 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>
);
},
);
export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
({ 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>
);
},
);
export const JsonInput = forwardRef<HTMLTextAreaElement, JsonInputProps>(
({ 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>
);
},
);
TextInput.defaultProps = {
children: undefined,
maxWidth: undefined,
width: undefined,
};
NumberInput.defaultProps = {
children: undefined,
maxWidth: undefined,
width: undefined,
};
PasswordInput.defaultProps = {
children: undefined,
maxWidth: undefined,
width: undefined,
};
FileInput.defaultProps = {
children: undefined,
maxWidth: undefined,
width: undefined,
};
JsonInput.defaultProps = {
children: undefined,
maxWidth: undefined,
width: undefined,
};
Textarea.defaultProps = {
children: undefined,
maxWidth: undefined,
width: undefined,
};

View file

@ -0,0 +1,43 @@
import React from 'react';
import type { ModalProps as MantineModalProps } from '@mantine/core';
import { Modal as MantineModal } from '@mantine/core';
import type { ContextModalProps } from '@mantine/modals';
export interface ModalProps extends Omit<MantineModalProps, 'onClose'> {
children?: React.ReactNode;
handlers: {
close: () => void;
open: () => void;
toggle: () => void;
};
}
export const Modal = ({ children, handlers, ...rest }: ModalProps) => {
return (
<MantineModal
overlayBlur={2}
overlayOpacity={0.2}
{...rest}
onClose={handlers.close}
>
{children}
</MantineModal>
);
};
export type ContextModalVars = {
context: ContextModalProps['context'];
id: ContextModalProps['id'];
};
export const BaseContextModal = ({
context,
id,
innerProps,
}: ContextModalProps<{
modalBody: (vars: ContextModalVars) => React.ReactNode;
}>) => <>{innerProps.modalBody({ context, id })}</>;
Modal.defaultProps = {
children: undefined,
};

View file

@ -0,0 +1,54 @@
import { motion } from 'framer-motion';
import { useEffect, useRef } from 'react';
import styled from 'styled-components';
import { useShouldPadTitlebar } from '/@/renderer/hooks';
const Container = styled(motion.div)<{ $useOpacity?: boolean; height?: string }>`
z-index: 100;
width: 100%;
height: ${(props) => props.height || '55px'};
opacity: ${(props) => props.$useOpacity && 'var(--header-opacity)'};
transition: opacity 0.3s ease-in-out;
`;
const Header = styled(motion.div)<{ $padRight?: boolean }>`
height: 100%;
margin-right: ${(props) => props.$padRight && '170px'};
padding: 1rem;
-webkit-app-region: drag;
button {
-webkit-app-region: no-drag;
}
`;
interface PageHeaderProps {
backgroundColor?: string;
children?: React.ReactNode;
height?: string;
useOpacity?: boolean;
}
export const PageHeader = ({ height, backgroundColor, useOpacity, children }: PageHeaderProps) => {
const ref = useRef(null);
const padRight = useShouldPadTitlebar();
useEffect(() => {
const rootElement = document.querySelector(':root') as HTMLElement;
rootElement?.style?.setProperty('--header-opacity', '0');
}, []);
return (
<Container
ref={ref}
$useOpacity={useOpacity}
animate={{
backgroundColor,
transition: { duration: 1.5 },
}}
height={height}
>
<Header $padRight={padRight}>{children}</Header>
</Container>
);
};

View file

@ -0,0 +1,15 @@
import type { PaperProps as MantinePaperProps } from '@mantine/core';
import { Paper as MantinePaper } from '@mantine/core';
import styled from 'styled-components';
export interface PaperProps extends MantinePaperProps {
children: React.ReactNode;
}
const StyledPaper = styled(MantinePaper)<PaperProps>`
background: var(--paper-bg);
`;
export const Paper = ({ children, ...props }: PaperProps) => {
return <StyledPaper {...props}>{children}</StyledPaper>;
};

View file

@ -0,0 +1,38 @@
import type {
PopoverProps as MantinePopoverProps,
PopoverDropdownProps as MantinePopoverDropdownProps,
} from '@mantine/core';
import { Popover as MantinePopover } from '@mantine/core';
import styled from 'styled-components';
type PopoverProps = MantinePopoverProps;
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);
`;
export const Popover = ({ children, ...props }: PopoverProps) => {
return (
<StyledPopover
withArrow
withinPortal
styles={{
dropdown: {
filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 50%))',
},
}}
{...props}
>
{children}
</StyledPopover>
);
};
Popover.Target = MantinePopover.Target;
Popover.Dropdown = StyledDropdown;

View file

@ -0,0 +1,31 @@
import type { ScrollAreaProps as MantineScrollAreaProps } from '@mantine/core';
import { ScrollArea as MantineScrollArea } from '@mantine/core';
import styled from 'styled-components';
interface ScrollAreaProps extends MantineScrollAreaProps {
children: React.ReactNode;
}
const StyledScrollArea = styled(MantineScrollArea)`
& .mantine-ScrollArea-thumb {
background: var(--scrollbar-thumb-bg);
border-radius: 0;
}
& .mantine-ScrollArea-scrollbar {
width: 12px;
padding: 0;
background: var(--scrollbar-track-bg);
}
`;
export const ScrollArea = ({ children, ...props }: ScrollAreaProps) => {
return (
<StyledScrollArea
offsetScrollbars
{...props}
>
{children}
</StyledScrollArea>
);
};

View file

@ -0,0 +1,38 @@
import { forwardRef } from 'react';
import type { SegmentedControlProps as MantineSegmentedControlProps } from '@mantine/core';
import { SegmentedControl as MantineSegmentedControl } from '@mantine/core';
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);
}
background-color: var(--input-bg);
& .mantine-SegmentedControl-disabled {
opacity: 0.6;
}
& .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}
/>
);
},
);

View file

@ -0,0 +1,144 @@
import type {
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;
}
interface MultiSelectProps extends MantineMultiSelectProps {
maxWidth?: number | string;
width?: number | string;
}
const StyledSelect = styled(MantineSelect)`
& [data-selected='true'] {
background: var(--input-bg);
}
& .mantine-Select-disabled {
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 }}
transition="pop"
transitionDuration={100}
{...props}
/>
);
};
const StyledMultiSelect = styled(MantineMultiSelect)`
& [data-selected='true'] {
background: var(--input-select-bg);
}
& .mantine-MultiSelect-disabled {
background: var(--input-select-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 }}
transition="pop"
transitionDuration={100}
{...props}
/>
);
};
Select.defaultProps = {
maxWidth: undefined,
width: undefined,
};
MultiSelect.defaultProps = {
maxWidth: undefined,
width: undefined,
};

View file

@ -0,0 +1,39 @@
import type { SkeletonProps as MantineSkeletonProps } from '@mantine/core';
import { Skeleton as MantineSkeleton } from '@mantine/core';
import styled from 'styled-components';
const StyledSkeleton = styled(MantineSkeleton)`
@keyframes run {
0% {
left: 0;
transform: translateX(-100%);
}
80% {
transform: translateX(100%);
}
100% {
transform: translateX(100%);
}
}
&::before {
background: var(--skeleton-bg);
}
&::after {
position: absolute;
background: linear-gradient(90deg, transparent, var(--skeleton-bg), transparent);
transform: translateX(-100%);
animation-name: run;
animation-duration: 1.5s;
animation-timing-function: linear;
content: '';
inset: 0;
}
`;
export const Skeleton = ({ ...props }: MantineSkeletonProps) => {
return <StyledSkeleton {...props} />;
};

View file

@ -0,0 +1,30 @@
import type { SliderProps as MantineSliderProps } from '@mantine/core';
import { Slider as MantineSlider } from '@mantine/core';
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-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);
}
`;
export const Slider = ({ ...props }: SliderProps) => {
return <StyledSlider {...props} />;
};

View file

@ -0,0 +1,23 @@
import type { IconType } from 'react-icons';
import { RiLoader5Fill } from 'react-icons/ri';
import styled from 'styled-components';
import { rotating } from '/@/renderer/styles';
interface SpinnerProps extends IconType {
color?: string;
size?: number;
}
export const SpinnerIcon = styled(RiLoader5Fill)`
${rotating}
animation: rotating 1s ease-in-out infinite;
`;
export const Spinner = ({ ...props }: SpinnerProps) => {
return <SpinnerIcon {...props} />;
};
Spinner.defaultProps = {
color: undefined,
size: 15,
};

View file

@ -0,0 +1,27 @@
import type { SwitchProps as MantineSwitchProps } from '@mantine/core';
import { Switch as MantineSwitch } from '@mantine/core';
import styled from 'styled-components';
type SwitchProps = MantineSwitchProps;
const StyledSwitch = styled(MantineSwitch)`
display: flex;
& .mantine-Switch-track {
background-color: var(--switch-track-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} />;
};

View file

@ -0,0 +1,52 @@
import type { TabsProps as MantineTabsProps } from '@mantine/core';
import { Tabs as MantineTabs } from '@mantine/core';
import styled from 'styled-components';
type TabsProps = MantineTabsProps;
const StyledTabs = styled(MantineTabs)`
height: 100%;
& .mantine-Tabs-tabsList {
padding-right: 1rem;
}
&.mantine-Tabs-tab {
background-color: var(--main-bg);
}
& .mantine-Tabs-panel {
padding: 0 1rem;
}
button {
padding: 1rem;
color: var(--btn-subtle-fg);
&: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-primary-fg);
background: var(--primary-color);
border-color: var(--primary-color);
&:hover {
background: var(--btn-primary-bg-hover);
border-color: var(--primary-color);
}
}
`;
export const Tabs = ({ children, ...props }: TabsProps) => {
return <StyledTabs {...props}>{children}</StyledTabs>;
};
Tabs.List = StyledTabs.List;
Tabs.Panel = StyledTabs.Panel;
Tabs.Tab = StyledTabs.Tab;

View file

@ -0,0 +1,55 @@
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
import type { TitleProps as MantineTitleProps } from '@mantine/core';
import { createPolymorphicComponent, Title as MantineHeader } from '@mantine/core';
import styled from 'styled-components';
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;
}
const StyledTextTitle = styled(MantineHeader)<TextTitleProps>`
color: ${(props) => (props.$secondary ? 'var(--main-fg-secondary)' : 'var(--main-fg)')};
cursor: ${(props) => props.$link && 'cursor'};
transition: color 0.2s ease-in-out;
user-select: ${(props) => (props.$noSelect ? 'none' : 'auto')};
${(props) => props.overflow === 'hidden' && textEllipsis}
&: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>
);
};
export const TextTitle = createPolymorphicComponent<'div', TextTitleProps>(_TextTitle);
_TextTitle.defaultProps = {
$link: false,
$noSelect: false,
$secondary: false,
font: undefined,
overflow: 'visible',
to: '',
weight: 400,
};

View file

@ -0,0 +1,59 @@
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
import type { TextProps as MantineTextProps } from '@mantine/core';
import { createPolymorphicComponent, Text as MantineText } from '@mantine/core';
import styled from 'styled-components';
import type { Font } from '/@/renderer/styles';
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;
}
const StyledText = styled(MantineText)<TextProps>`
color: ${(props) => (props.$secondary ? 'var(--main-fg-secondary)' : 'var(--main-fg)')};
font-family: ${(props) => props.font};
cursor: ${(props) => props.$link && 'cursor'};
transition: color 0.2s ease-in-out;
user-select: ${(props) => (props.$noSelect ? 'none' : 'auto')};
${(props) => props.overflow === 'hidden' && textEllipsis}
&:hover {
color: ${(props) => props.$link && 'var(--main-fg)'};
text-decoration: ${(props) => (props.$link ? 'underline' : 'none')};
}
`;
const _Text = ({ children, $secondary, overflow, font, $noSelect, ...rest }: TextProps) => {
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,
};

View file

@ -0,0 +1,71 @@
import type { NotificationProps as MantineNotificationProps } from '@mantine/notifications';
import {
showNotification,
updateNotification,
hideNotification,
cleanNotifications,
cleanNotificationsQueue,
} from '@mantine/notifications';
interface NotificationProps extends MantineNotificationProps {
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 defaultTitle =
type === 'success'
? 'Success'
: type === 'warning'
? 'Warning'
: type === 'error'
? 'Error'
: 'Info';
const defaultDuration = type === 'error' ? 3500 : 2000;
return showNotification({
autoClose: defaultDuration,
disallowClose: true,
styles: () => ({
closeButton: {},
description: {
color: 'var(--toast-description-fg)',
fontSize: '.9em',
},
loader: {
margin: '1rem',
},
root: {
'&::before': { backgroundColor: color },
background: 'var(--toast-bg)',
},
title: {
color: 'var(--toast-title-fg)',
fontSize: '1em',
},
}),
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 }),
};

View file

@ -0,0 +1,44 @@
import type { TooltipProps } from '@mantine/core';
import { Tooltip as MantineTooltip } from '@mantine/core';
import styled from 'styled-components';
const StyledTooltip = styled(MantineTooltip)`
& .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',
},
}}
{...rest}
>
{children}
</StyledTooltip>
);
};
Tooltip.defaultProps = {
openDelay: 0,
position: 'top',
transition: 'fade',
transitionDuration: 250,
withArrow: true,
withinPortal: true,
};

View file

@ -0,0 +1,335 @@
import React from 'react';
import { Center } from '@mantine/core';
import { RiAlbumFill } from 'react-icons/ri';
import { generatePath, useNavigate } from 'react-router';
import { Link } from 'react-router-dom';
import { SimpleImg } from 'react-simple-img';
import type { ListChildComponentProps } from 'react-window';
import styled from 'styled-components';
import { Text } from '/@/renderer/components/text';
import type { LibraryItem, CardRow, CardRoute, Play } from '/@/renderer/types';
import GridCardControls from './grid-card-controls';
import { Skeleton } from '/@/renderer/components/skeleton';
const CardWrapper = styled.div<{
itemGap: number;
itemHeight: number;
itemWidth: number;
link?: boolean;
}>`
flex: ${({ itemWidth }) => `0 0 ${itemWidth - 12}px`};
width: ${({ itemWidth }) => `${itemWidth}px`};
height: ${({ itemHeight, itemGap }) => `${itemHeight - 12 - itemGap}px`};
margin: ${({ itemGap }) => `0 ${itemGap / 2}px`};
padding: 12px 12px 0;
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;
user-select: none;
pointer-events: auto; // https://github.com/bvaughn/react-window/issues/128#issuecomment-460166682
&:hover {
background: var(--card-default-bg-hover);
}
&: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);
`;
const ImageSection = styled.div<{ size?: number }>`
position: relative;
width: ${({ size }) => size && `${size - 24}px`};
height: ${({ size }) => size && `${size - 24}px`};
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;
}
`;
const Image = styled(SimpleImg)`
border-radius: var(--card-default-radius);
box-shadow: 2px 2px 10px 10px 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;
`;
const DetailSection = styled.div`
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;
`;
interface BaseGridCardProps {
columnIndex: number;
controls: {
cardRows: CardRow[];
itemType: LibraryItem;
playButtonBehavior: Play;
route: CardRoute;
};
data: any;
listChildProps: Omit<ListChildComponentProps, 'data' | 'style'>;
sizes: {
itemGap: number;
itemHeight: number;
itemWidth: number;
};
}
export const DefaultCard = ({
listChildProps,
data,
columnIndex,
controls,
sizes,
}: BaseGridCardProps) => {
const navigate = useNavigate();
const { index } = listChildProps;
const { itemGap, itemHeight, itemWidth } = sizes;
const { itemType, cardRows, route } = controls;
const cardSize = itemWidth - 24;
if (data) {
return (
<CardWrapper
key={`card-${columnIndex}-${index}`}
link
itemGap={itemGap}
itemHeight={itemHeight}
itemWidth={itemWidth}
onClick={() =>
navigate(
generatePath(
route.route,
route.slugs?.reduce((acc, slug) => {
return {
...acc,
[slug.slugProperty]: data[slug.idProperty],
};
}, {}),
),
)
}
>
<StyledCard>
<ImageSection size={itemWidth}>
{data?.imageUrl ? (
<Image
animationDuration={0.3}
height={cardSize}
imgStyle={{ objectFit: 'cover' }}
placeholder="var(--card-default-bg)"
src={data?.imageUrl}
width={cardSize}
/>
) : (
<Center
sx={{
background: 'var(--placeholder-bg)',
borderRadius: 'var(--card-default-radius)',
height: '100%',
width: '100%',
}}
>
<RiAlbumFill
color="var(--placeholder-fg)"
size={35}
/>
</Center>
)}
<ControlsContainer>
<GridCardControls
itemData={data}
itemType={itemType}
/>
</ControlsContainer>
</ImageSection>
<DetailSection>
{cardRows.map((row: CardRow, index: number) => {
if (row.arrayProperty && row.route) {
return (
<Row
key={`row-${row.property}-${columnIndex}`}
$secondary={index > 0}
>
{data[row.property].map((item: any, itemIndex: number) => (
<React.Fragment key={`${data.id}-${item.id}`}>
{itemIndex > 0 && (
<Text
$noSelect
sx={{
display: 'inline-block',
padding: '0 2px 0 1px',
}}
>
,
</Text>
)}{' '}
<Text
$link
$noSelect
$secondary={index > 0}
component={Link}
overflow="hidden"
size={index > 0 ? 'xs' : 'md'}
to={generatePath(
row.route!.route,
row.route!.slugs?.reduce((acc, slug) => {
return {
...acc,
[slug.slugProperty]: data[slug.idProperty],
};
}, {}),
)}
onClick={(e) => e.stopPropagation()}
>
{row.arrayProperty && item[row.arrayProperty]}
</Text>
</React.Fragment>
))}
</Row>
);
}
if (row.arrayProperty) {
return (
<Row key={`row-${row.property}-${columnIndex}`}>
{data[row.property].map((item: any) => (
<Text
key={`${data.id}-${item.id}`}
$noSelect
$secondary={index > 0}
overflow="hidden"
size={index > 0 ? 'xs' : 'md'}
>
{row.arrayProperty && item[row.arrayProperty]}
</Text>
))}
</Row>
);
}
return (
<Row key={`row-${row.property}-${columnIndex}`}>
{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 ? 'xs' : 'md'}
>
{data && data[row.property]}
</Text>
)}
</Row>
);
})}
</DetailSection>
</StyledCard>
</CardWrapper>
);
}
return (
<CardWrapper
key={`card-${columnIndex}-${index}`}
itemGap={itemGap}
itemHeight={itemHeight}
itemWidth={itemWidth + 12}
>
<StyledCard>
<Skeleton
visible
radius="sm"
>
<ImageSection size={itemWidth} />
</Skeleton>
<DetailSection>
{cardRows.map((row: CardRow, index: number) => (
<Skeleton
key={`row-${row.property}-${columnIndex}`}
height={20}
my={2}
radius="md"
visible={!data}
width={!data ? (index > 0 ? '50%' : '90%') : '100%'}
>
<Row />
</Skeleton>
))}
</DetailSection>
</StyledCard>
</CardWrapper>
);
};

View file

@ -0,0 +1,204 @@
import type { MouseEvent } from 'react';
import React from 'react';
import type { UnstyledButtonProps } from '@mantine/core';
import { Group } from '@mantine/core';
import { RiPlayFill, RiMore2Fill, RiHeartFill, RiHeartLine } from 'react-icons/ri';
import styled from 'styled-components';
import { _Button } from '/@/renderer/components/button';
import { DropdownMenu } from '/@/renderer/components/dropdown-menu';
import type { LibraryItem } from '/@/renderer/types';
import { Play } from '/@/renderer/types';
import { useSettingsStore } from '/@/renderer/store/settings.store';
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;
&:hover {
opacity: 1;
scale: 1.1;
}
&:active {
opacity: 1;
scale: 1;
}
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;
&:hover {
opacity: 1;
scale: 1.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%;
`;
const ControlsRow = styled.div`
width: 100%;
height: calc(100% / 3);
`;
// const TopControls = styled(ControlsRow)`
// display: flex;
// align-items: flex-start;
// justify-content: space-between;
// padding: 0.5rem;
// `;
// const CenterControls = styled(ControlsRow)`
// display: flex;
// align-items: center;
// justify-content: center;
// padding: 0.5rem;
// `;
const BottomControls = styled(ControlsRow)`
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)'};
}
`;
const PLAY_TYPES = [
{
label: 'Play',
play: Play.NOW,
},
{
label: 'Play last',
play: Play.LAST,
},
{
label: 'Play next',
play: Play.NEXT,
},
];
export const GridCardControls = ({
itemData,
itemType,
}: {
itemData: any;
itemType: LibraryItem;
}) => {
const playButtonBehavior = useSettingsStore((state) => state.player.playButtonBehavior);
const handlePlay = (e: MouseEvent<HTMLButtonElement>, playType?: Play) => {
e.preventDefault();
e.stopPropagation();
import('/@/renderer/features/player/utils/handle-playqueue-add').then((fn) => {
fn.handlePlayQueueAdd({
byItemType: {
id: itemData.id,
type: itemType,
},
play: playType || playButtonBehavior,
});
});
};
return (
<GridCardControlsContainer>
{/* <TopControls /> */}
{/* <CenterControls /> */}
<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>
<DropdownMenu
withinPortal
position="bottom-start"
>
<DropdownMenu.Target>
<SecondaryButton
p={5}
sx={{ svg: { fill: 'white !important' } }}
variant="subtle"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<RiMore2Fill
color="white"
size={20}
/>
</SecondaryButton>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{PLAY_TYPES.filter((type) => type.play !== playButtonBehavior).map((type) => (
<DropdownMenu.Item
key={`playtype-${type.play}`}
onClick={(e: MouseEvent<HTMLButtonElement>) => handlePlay(e, type.play)}
>
{type.label}
</DropdownMenu.Item>
))}
<DropdownMenu.Item disabled>Add to playlist</DropdownMenu.Item>
<DropdownMenu.Item disabled>Refresh metadata</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
</BottomControls>
</GridCardControlsContainer>
);
};
export default GridCardControls;

View file

@ -0,0 +1,61 @@
import { memo } from 'react';
import type { ListChildComponentProps } from 'react-window';
import { areEqual } from 'react-window';
import { DefaultCard } from '/@/renderer/components/virtual-grid/grid-card/default-card';
import { PosterCard } from '/@/renderer/components/virtual-grid/grid-card/poster-card';
import type { GridCardData } from '/@/renderer/types';
import { CardDisplayType } from '/@/renderer/types';
export const GridCard = memo(({ data, index, style }: ListChildComponentProps) => {
const {
itemHeight,
itemWidth,
columnCount,
itemGap,
itemCount,
cardRows,
itemData,
itemType,
playButtonBehavior,
route,
display,
} = data as GridCardData;
const cards = [];
const startIndex = index * columnCount;
const stopIndex = Math.min(itemCount - 1, startIndex + columnCount - 1);
const View = display === CardDisplayType.CARD ? DefaultCard : PosterCard;
for (let i = startIndex; i <= stopIndex; i += 1) {
cards.push(
<View
key={`card-${i}-${index}`}
columnIndex={i}
controls={{
cardRows,
itemType,
playButtonBehavior,
route,
}}
data={itemData[i]}
listChildProps={{ index }}
sizes={{ itemGap, itemHeight, itemWidth }}
/>,
);
}
return (
<>
<div
style={{
...style,
alignItems: 'center',
display: 'flex',
justifyContent: 'start',
}}
>
{cards}
</div>
</>
);
}, areEqual);

View file

@ -0,0 +1,329 @@
import React from 'react';
import { Center } from '@mantine/core';
import { RiAlbumFill } from 'react-icons/ri';
import { generatePath } from 'react-router';
import { Link } from 'react-router-dom';
import { SimpleImg } from 'react-simple-img';
import type { ListChildComponentProps } from 'react-window';
import styled from 'styled-components';
import { Skeleton } from '/@/renderer/components/skeleton';
import { Text } from '/@/renderer/components/text';
import type { LibraryItem, CardRow, CardRoute, Play } from '/@/renderer/types';
import GridCardControls from './grid-card-controls';
const CardWrapper = styled.div<{
itemGap: number;
itemHeight: number;
itemWidth: number;
}>`
flex: ${({ itemWidth }) => `0 0 ${itemWidth}px`};
width: ${({ itemWidth }) => `${itemWidth}px`};
height: ${({ itemHeight, itemGap }) => `${itemHeight - itemGap}px`};
margin: ${({ itemGap }) => `0 ${itemGap / 2}px`};
user-select: none;
pointer-events: auto; // https://github.com/bvaughn/react-window/issues/128#issuecomment-460166682
&: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;
background: var(--card-poster-bg);
border-radius: var(--card-poster-radius);
&:hover {
background: var(--card-poster-bg-hover);
}
`;
const ImageSection = styled.div`
position: relative;
width: 100%;
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;
}
`;
interface ImageProps {
height: number;
isLoading?: boolean;
}
const Image = styled(SimpleImg)<ImageProps>`
border: 0;
border-radius: var(--card-poster-radius);
img {
object-fit: cover;
}
`;
const ControlsContainer = styled.div`
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;
`;
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;
`;
interface BaseGridCardProps {
columnIndex: number;
controls: {
cardRows: CardRow[];
itemType: LibraryItem;
playButtonBehavior: Play;
route: CardRoute;
};
data: any;
listChildProps: Omit<ListChildComponentProps, 'data' | 'style'>;
sizes: {
itemGap: number;
itemHeight: number;
itemWidth: number;
};
}
export const PosterCard = ({
listChildProps,
data,
columnIndex,
controls,
sizes,
}: BaseGridCardProps) => {
if (data) {
return (
<CardWrapper
key={`card-${columnIndex}-${listChildProps.index}`}
itemGap={sizes.itemGap}
itemHeight={sizes.itemHeight}
itemWidth={sizes.itemWidth}
>
<StyledCard>
<Link
tabIndex={0}
to={generatePath(
controls.route.route,
controls.route.slugs?.reduce((acc, slug) => {
return {
...acc,
[slug.slugProperty]: data[slug.idProperty],
};
}, {}),
)}
>
<ImageSection style={{ height: `${sizes.itemWidth}px` }}>
{data?.imageUrl ? (
<Image
animationDuration={0.3}
height={sizes.itemWidth}
importance="auto"
placeholder="var(--card-default-bg)"
src={data?.imageUrl}
width={sizes.itemWidth}
/>
) : (
<Center
sx={{
background: 'var(--placeholder-bg)',
borderRadius: 'var(--card-poster-radius)',
height: '100%',
}}
>
<RiAlbumFill
color="var(--placeholder-fg)"
size={35}
/>
</Center>
)}
<ControlsContainer>
<GridCardControls
itemData={data}
itemType={controls.itemType}
/>
</ControlsContainer>
</ImageSection>
</Link>
<DetailSection>
{controls.cardRows.map((row: CardRow, index: number) => {
if (row.arrayProperty && row.route) {
return (
<Row
key={`row-${row.property}-${columnIndex}`}
$secondary={index > 0}
>
{data[row.property].map((item: any, itemIndex: number) => (
<React.Fragment key={`${data.id}-${item.id}`}>
{itemIndex > 0 && (
<Text
$noSelect
size={index > 0 ? 'xs' : 'md'}
sx={{
display: 'inline-block',
padding: '0 2px 0 1px',
}}
>
,
</Text>
)}{' '}
<Text
$link
$noSelect
$secondary={index > 0}
component={Link}
overflow="hidden"
size={index > 0 ? 'xs' : 'md'}
to={generatePath(
row.route!.route,
row.route!.slugs?.reduce((acc, slug) => {
return {
...acc,
[slug.slugProperty]: data[slug.idProperty],
};
}, {}),
)}
>
{row.arrayProperty && item[row.arrayProperty]}
</Text>
</React.Fragment>
))}
</Row>
);
}
if (row.arrayProperty) {
return (
<Row key={`row-${row.property}-${columnIndex}`}>
{data[row.property].map((item: any) => (
<Text
key={`${data.id}-${item.id}`}
$noSelect
$secondary={index > 0}
overflow="hidden"
size={index > 0 ? 'xs' : 'md'}
>
{row.arrayProperty && item[row.arrayProperty]}
</Text>
))}
</Row>
);
}
return (
<Row key={`row-${row.property}-${columnIndex}`}>
{row.route ? (
<Text
$link
$noSelect
component={Link}
overflow="hidden"
size={index > 0 ? 'xs' : 'md'}
to={generatePath(
row.route.route,
row.route.slugs?.reduce((acc, slug) => {
return {
...acc,
[slug.slugProperty]: data[slug.idProperty],
};
}, {}),
)}
>
{data && data[row.property]}
</Text>
) : (
<Text
$noSelect
$secondary={index > 0}
overflow="hidden"
size={index > 0 ? 'xs' : 'md'}
>
{data && data[row.property]}
</Text>
)}
</Row>
);
})}
</DetailSection>
</StyledCard>
</CardWrapper>
);
}
return (
<CardWrapper
key={`card-${columnIndex}-${listChildProps.index}`}
itemGap={sizes.itemGap}
itemHeight={sizes.itemHeight}
itemWidth={sizes.itemWidth}
>
<StyledCard>
<Skeleton
visible
radius="sm"
>
<ImageSection style={{ height: `${sizes.itemWidth}px` }} />
</Skeleton>
<DetailSection>
{controls.cardRows.map((row: CardRow, index: number) => (
<Skeleton
key={`row-${row.property}-${columnIndex}`}
height={20}
my={2}
radius="md"
visible={!data}
width={!data ? (index > 0 ? '50%' : '90%') : '100%'}
>
<Row />
</Skeleton>
))}
</DetailSection>
</StyledCard>
</CardWrapper>
);
};

View file

@ -0,0 +1,2 @@
export * from './virtual-grid-wrapper';
export * from './virtual-infinite-grid';

View file

@ -0,0 +1,110 @@
import type { Ref } from 'react';
import debounce from 'lodash/debounce';
import memoize from 'memoize-one';
import type { FixedSizeListProps } from 'react-window';
import { FixedSizeList } from 'react-window';
import styled from 'styled-components';
import { GridCard } from '/@/renderer/components/virtual-grid/grid-card';
import type { CardRow, LibraryItem, CardDisplayType, CardRoute } from '/@/renderer/types';
const createItemData = memoize(
(
cardRows,
columnCount,
display,
itemCount,
itemData,
itemGap,
itemHeight,
itemType,
itemWidth,
route,
) => ({
cardRows,
columnCount,
display,
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,
itemData,
route,
onScroll,
...rest
}: Omit<FixedSizeListProps, 'ref' | 'itemSize' | 'children'> & {
cardRows: CardRow[];
columnCount: number;
display: CardDisplayType;
itemData: any[];
itemGap: number;
itemHeight: number;
itemType: LibraryItem;
itemWidth: number;
refInstance: Ref<any>;
route?: CardRoute;
rowCount: number;
}) => {
const memoizedItemData = createItemData(
cardRows,
columnCount,
display,
itemCount,
itemData,
itemGap,
itemHeight,
itemType,
itemWidth,
route,
);
const memoizedOnScroll = createScrollHandler(onScroll);
return (
<FixedSizeList
ref={refInstance}
{...rest}
initialScrollOffset={initialScrollOffset}
itemCount={rowCount}
itemData={memoizedItemData}
itemSize={itemHeight}
overscanCount={5}
onScroll={memoizedOnScroll}
>
{GridCard}
</FixedSizeList>
);
};
VirtualGridWrapper.defaultProps = {
route: undefined,
};
export const VirtualGridContainer = styled.div`
display: flex;
flex-direction: column;
height: 100%;
`;
export const VirtualGridAutoSizerContainer = styled.div`
flex: 1;
`;

View file

@ -0,0 +1,154 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import debounce from 'lodash/debounce';
import type { FixedSizeListProps } from 'react-window';
import InfiniteLoader from 'react-window-infinite-loader';
import { VirtualGridWrapper } from '/@/renderer/components/virtual-grid/virtual-grid-wrapper';
import type { CardRoute, CardRow, LibraryItem } from '/@/renderer/types';
import { CardDisplayType } from '/@/renderer/types';
interface VirtualGridProps extends Omit<FixedSizeListProps, 'children' | 'itemSize'> {
cardRows: CardRow[];
display?: CardDisplayType;
fetchFn: (options: { columnCount: number; skip: number; take: number }) => Promise<any>;
itemGap: number;
itemSize: number;
itemType: LibraryItem;
minimumBatchSize?: number;
refresh?: any; // Pass in any value to refresh the grid when changed
route?: CardRoute;
}
const constrainWidth = (width: number) => {
if (width < 1920) {
return width;
}
return 1920;
};
export const VirtualInfiniteGrid = ({
itemCount,
itemGap,
itemSize,
itemType,
cardRows,
route,
onScroll,
display,
minimumBatchSize,
fetchFn,
initialScrollOffset,
height,
width,
refresh,
}: VirtualGridProps) => {
const [itemData, setItemData] = useState<any[]>([]);
const listRef = useRef<any>(null);
const loader = useRef<InfiniteLoader>(null);
const { itemHeight, rowCount, columnCount } = useMemo(() => {
const itemsPerRow = Math.floor(
(constrainWidth(Number(width)) - itemGap + 3) / (itemSize! + itemGap + 2),
);
return {
columnCount: itemsPerRow,
itemHeight: itemSize! + cardRows.length * 22 + itemGap,
itemWidth: itemSize! + itemGap,
rowCount: Math.ceil(itemCount / itemsPerRow),
};
}, [cardRows.length, itemCount, itemGap, itemSize, width]);
const isItemLoaded = useCallback(
(index: number) => {
const itemIndex = index * columnCount;
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;
// 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 newData: any[] = [...itemData];
let itemIndex = 0;
for (let rowIndex = start; rowIndex < end; rowIndex += 1) {
newData[rowIndex] = data.items[itemIndex];
itemIndex += 1;
}
setItemData(newData);
},
[columnCount, fetchFn, itemData],
);
const debouncedLoadMoreItems = debounce(loadMoreItems, 500);
useEffect(() => {
if (loader.current) {
listRef.current.scrollTo(0);
loader.current.resetloadMoreItemsCache(false);
setItemData(() => []);
loadMoreItems(0, minimumBatchSize! * 2);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [minimumBatchSize, fetchFn, refresh]);
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 || CardDisplayType.CARD}
height={height}
initialScrollOffset={initialScrollOffset}
itemCount={itemCount || 0}
itemData={itemData}
itemGap={itemGap}
itemHeight={itemHeight + itemGap / 2}
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: CardDisplayType.CARD,
minimumBatchSize: 20,
refresh: undefined,
route: undefined,
};

View file

@ -0,0 +1,47 @@
import React from 'react';
import type { ICellRendererParams } from '@ag-grid-community/core';
import { generatePath } from 'react-router';
import { Link } from 'react-router-dom';
import type { AlbumArtist, Artist } from '/@/renderer/api/types';
import { Text } from '/@/renderer/components/text';
import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell';
import { AppRoute } from '/@/renderer/router/routes';
export const AlbumArtistCell = ({ value, data }: ICellRendererParams) => {
return (
<CellContainer position="left">
<Text
$secondary
overflow="hidden"
size="sm"
>
{value?.map((item: Artist | AlbumArtist, index: number) => (
<React.Fragment key={`row-${item.id}-${data.uniqueId}`}>
{index > 0 && (
<Text
$link
$secondary
size="sm"
style={{ display: 'inline-block' }}
>
,
</Text>
)}{' '}
<Text
$link
$secondary
component={Link}
overflow="hidden"
size="sm"
to={generatePath(AppRoute.LIBRARY_ALBUMARTISTS_DETAIL, {
albumArtistId: item.id,
})}
>
{item.name || '—'}
</Text>
</React.Fragment>
))}
</Text>
</CellContainer>
);
};

View file

@ -0,0 +1,47 @@
import React from 'react';
import type { ICellRendererParams } from '@ag-grid-community/core';
import { generatePath } from 'react-router';
import { Link } from 'react-router-dom';
import type { AlbumArtist, Artist } from '/@/renderer/api/types';
import { Text } from '/@/renderer/components/text';
import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell';
import { AppRoute } from '/@/renderer/router/routes';
export const ArtistCell = ({ value, data }: ICellRendererParams) => {
return (
<CellContainer position="left">
<Text
$secondary
overflow="hidden"
size="sm"
>
{value?.map((item: Artist | AlbumArtist, index: number) => (
<React.Fragment key={`row-${item.id}-${data.uniqueId}`}>
{index > 0 && (
<Text
$link
$secondary
size="sm"
style={{ display: 'inline-block' }}
>
,
</Text>
)}{' '}
<Text
$link
$secondary
component={Link}
overflow="hidden"
size="sm"
to={generatePath(AppRoute.LIBRARY_ARTISTS_DETAIL, {
artistId: item.id,
})}
>
{item.name || '—'}
</Text>
</React.Fragment>
))}
</Text>
</CellContainer>
);
};

View file

@ -0,0 +1,99 @@
import React, { useMemo } from 'react';
import type { ICellRendererParams } from '@ag-grid-community/core';
import { motion } from 'framer-motion';
import { generatePath } from 'react-router';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import type { AlbumArtist, Artist } from '/@/renderer/api/types';
import { Text } from '/@/renderer/components/text';
import { AppRoute } from '/@/renderer/router/routes';
import { ServerType } from '/@/renderer/types';
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%;
`;
const ImageWrapper = styled.div`
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%;
`;
const StyledImage = styled.img`
object-fit: cover;
`;
export const CombinedTitleCell = ({ value, rowIndex, node }: ICellRendererParams) => {
const artists = useMemo(() => {
return value.type === ServerType.JELLYFIN ? value.artists : value.albumArtists;
}, [value]);
return (
<CellContainer height={node.rowHeight || 40}>
<ImageWrapper>
<StyledImage
alt="song-cover"
height={(node.rowHeight || 40) - 10}
loading="lazy"
src={value.imageUrl}
style={{}}
width={(node.rowHeight || 40) - 10}
/>
</ImageWrapper>
<MetadataWrapper>
<Text
overflow="hidden"
size="sm"
>
{value.name}
</Text>
<Text
$secondary
overflow="hidden"
size="sm"
>
{artists?.length ? (
artists.map((artist: Artist | AlbumArtist, index: number) => (
<React.Fragment key={`queue-${rowIndex}-artist-${artist.id}`}>
{index > 0 ? ', ' : null}
<Text
$link
$secondary
component={Link}
overflow="hidden"
size="sm"
sx={{ width: 'fit-content' }}
to={generatePath(AppRoute.LIBRARY_ALBUMARTISTS_DETAIL, {
albumArtistId: artist.id,
})}
>
{artist.name}
</Text>
</React.Fragment>
))
) : (
<Text $secondary></Text>
)}
</Text>
</MetadataWrapper>
</CellContainer>
);
};

View file

@ -0,0 +1,63 @@
import type { ICellRendererParams } from '@ag-grid-community/core';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import { Text } from '/@/renderer/components/text';
export const CellContainer = styled.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%;
`;
type Options = {
array?: boolean;
isArray?: boolean;
isLink?: boolean;
position?: 'left' | 'center' | 'right';
primary?: boolean;
};
export const GenericCell = (
{ value, valueFormatted }: ICellRendererParams,
{ position, primary, isLink }: Options,
) => {
const displayedValue = valueFormatted || value;
return (
<CellContainer position={position || 'left'}>
{isLink ? (
<Text
$link={isLink}
$secondary={!primary}
component={Link}
overflow="hidden"
size="sm"
to={displayedValue.link}
>
{isLink ? displayedValue.value : displayedValue}
</Text>
) : (
<Text
$secondary={!primary}
overflow="hidden"
size="sm"
>
{displayedValue}
</Text>
)}
</CellContainer>
);
};
GenericCell.defaultProps = {
position: undefined,
};

View file

@ -0,0 +1,43 @@
import React from 'react';
import type { ICellRendererParams } from '@ag-grid-community/core';
import { Link } from 'react-router-dom';
import type { AlbumArtist, Artist } from '/@/renderer/api/types';
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="sm"
>
{value?.map((item: Artist | AlbumArtist, index: number) => (
<React.Fragment key={`row-${item.id}-${data.uniqueId}`}>
{index > 0 && (
<Text
$link
$secondary
size="sm"
style={{ display: 'inline-block' }}
>
,
</Text>
)}{' '}
<Text
$link
$secondary
component={Link}
overflow="hidden"
size="sm"
to="/"
>
{item.name || '—'}
</Text>
</React.Fragment>
))}
</Text>
</CellContainer>
);
};

View file

@ -0,0 +1,10 @@
import type { IHeaderParams } from '@ag-grid-community/core';
import { FiClock } from 'react-icons/fi';
export interface ICustomHeaderParams extends IHeaderParams {
menuIcon: string;
}
export const DurationHeader = () => {
return <FiClock size={15} />;
};

View file

@ -0,0 +1,44 @@
import type { ReactNode } from 'react';
import type { IHeaderParams } from '@ag-grid-community/core';
import { AiOutlineNumber } from 'react-icons/ai';
import { FiClock } from 'react-icons/fi';
import styled from 'styled-components';
type Presets = 'duration' | 'rowIndex';
type Options = {
children?: ReactNode;
position?: 'left' | 'center' | 'right';
preset?: Presets;
};
const HeaderWrapper = styled.div<{ position: 'left' | 'center' | 'right' }>`
display: flex;
justify-content: ${(props) =>
props.position === 'right'
? 'flex-end'
: props.position === 'center'
? 'center'
: 'flex-start'};
width: 100%;
font-family: var(--header-font-family);
text-transform: uppercase;
`;
const headerPresets = { duration: <FiClock size={15} />, rowIndex: <AiOutlineNumber size={15} /> };
export const GenericTableHeader = (
{ displayName }: IHeaderParams,
{ preset, children, position }: Options,
) => {
if (preset) {
return <HeaderWrapper position={position || 'left'}>{headerPresets[preset]}</HeaderWrapper>;
}
return <HeaderWrapper position={position || 'left'}>{children || displayName}</HeaderWrapper>;
};
GenericTableHeader.defaultProps = {
position: 'left',
preset: undefined,
};

View file

@ -0,0 +1,190 @@
import type { Ref } from 'react';
import { forwardRef, useRef } from 'react';
import type {
ICellRendererParams,
ValueGetterParams,
IHeaderParams,
ValueFormatterParams,
ColDef,
} from '@ag-grid-community/core';
import type { AgGridReactProps } from '@ag-grid-community/react';
import { AgGridReact } from '@ag-grid-community/react';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { useMergedRef } from '@mantine/hooks';
import formatDuration from 'format-duration';
import { generatePath } from 'react-router';
import styled from 'styled-components';
import { AlbumArtistCell } from '/@/renderer/components/virtual-table/cells/album-artist-cell';
import { ArtistCell } from '/@/renderer/components/virtual-table/cells/artist-cell';
import { CombinedTitleCell } from '/@/renderer/components/virtual-table/cells/combined-title-cell';
import { GenericCell } from '/@/renderer/components/virtual-table/cells/generic-cell';
import { GenreCell } from '/@/renderer/components/virtual-table/cells/genre-cell';
import { GenericTableHeader } from '/@/renderer/components/virtual-table/headers/generic-table-header';
import { AppRoute } from '/@/renderer/router/routes';
import { PersistedTableColumn } from '/@/renderer/store/settings.store';
import { TableColumn } from '/@/renderer/types';
export * from './table-config-dropdown';
const TableWrapper = styled.div`
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
`;
const tableColumns: { [key: string]: ColDef } = {
album: {
cellRenderer: (params: ICellRendererParams) =>
GenericCell(params, { isLink: true, position: 'left' }),
colId: TableColumn.ALBUM,
headerName: 'Album',
valueGetter: (params: ValueGetterParams) => ({
link: generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
albumId: params.data?.albumId || '',
}),
value: params.data?.album,
}),
},
albumArtist: {
cellRenderer: AlbumArtistCell,
colId: TableColumn.ALBUM_ARTIST,
headerName: 'Album Artist',
valueGetter: (params: ValueGetterParams) => params.data?.albumArtists,
},
artist: {
cellRenderer: ArtistCell,
colId: TableColumn.ARTIST,
headerName: 'Artist',
valueGetter: (params: ValueGetterParams) => params.data?.artists,
},
bitRate: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'left' }),
colId: TableColumn.BIT_RATE,
field: 'bitRate',
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'left' }),
valueFormatter: (params: ValueFormatterParams) => `${params.value} kbps`,
},
dateAdded: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'left' }),
colId: TableColumn.DATE_ADDED,
field: 'createdAt',
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'left' }),
headerName: 'Date Added',
valueFormatter: (params: ValueFormatterParams) => params.value?.split('T')[0],
},
discNumber: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
colId: TableColumn.DISC_NUMBER,
field: 'discNumber',
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
headerName: 'Disc',
initialWidth: 75,
suppressSizeToFit: true,
},
duration: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
colId: TableColumn.DURATION,
field: 'duration',
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center', preset: 'duration' }),
initialWidth: 100,
valueFormatter: (params: ValueFormatterParams) => formatDuration(params.value * 1000),
},
genre: {
cellRenderer: GenreCell,
colId: TableColumn.GENRE,
headerName: 'Genre',
valueGetter: (params: ValueGetterParams) => params.data.genres,
},
releaseDate: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
colId: TableColumn.RELEASE_DATE,
field: 'releaseDate',
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
headerName: 'Release Date',
valueFormatter: (params: ValueFormatterParams) => params.value?.split('T')[0],
},
releaseYear: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
colId: TableColumn.YEAR,
field: 'releaseYear',
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
headerName: 'Year',
},
rowIndex: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'left' }),
colId: TableColumn.ROW_INDEX,
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'left', preset: 'rowIndex' }),
initialWidth: 50,
suppressSizeToFit: true,
valueGetter: (params) => {
return (params.node?.rowIndex || 0) + 1;
},
},
title: {
cellRenderer: (params: ICellRendererParams) =>
GenericCell(params, { position: 'left', primary: true }),
colId: TableColumn.TITLE,
field: 'name',
headerName: 'Title',
},
titleCombined: {
cellRenderer: CombinedTitleCell,
colId: TableColumn.TITLE_COMBINED,
headerName: 'Title',
initialWidth: 500,
valueGetter: (params: ValueGetterParams) => ({
albumArtists: params.data?.albumArtists,
artists: params.data?.artists,
imageUrl: params.data?.imageUrl,
name: params.data?.name,
rowHeight: params.node?.rowHeight,
type: params.data?.type,
}),
},
trackNumber: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
colId: TableColumn.TRACK_NUMBER,
field: 'trackNumber',
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
headerName: 'Track',
initialWidth: 75,
suppressSizeToFit: true,
},
};
export const getColumnDef = (column: TableColumn) => {
return tableColumns[column as keyof typeof tableColumns];
};
export const getColumnDefs = (columns: PersistedTableColumn[]) => {
const columnDefs: any[] = [];
for (const column of columns) {
const columnExists = tableColumns[column.column as keyof typeof tableColumns];
if (columnExists) columnDefs.push(columnExists);
}
return columnDefs;
};
export const VirtualTable = forwardRef(
({ ...rest }: AgGridReactProps, ref: Ref<AgGridReactType | null>) => {
const tableRef = useRef<AgGridReactType | null>(null);
const mergedRef = useMergedRef(ref, tableRef);
return (
<TableWrapper className="ag-theme-alpine-dark">
<AgGridReact
ref={mergedRef}
suppressMoveWhenRowDragging
suppressScrollOnNewData
rowBuffer={30}
{...rest}
/>
</TableWrapper>
);
},
);

View file

@ -0,0 +1,163 @@
import type { ChangeEvent } from 'react';
import { Stack } from '@mantine/core';
import { MultiSelect, Slider, Switch, Text } from '/@/renderer/components';
import { useSettingsStoreActions, useSettingsStore } from '/@/renderer/store/settings.store';
import { TableColumn, TableType } from '/@/renderer/types';
export const tableColumns = [
{ 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: 'Size', value: TableColumn.SIZE },
// { label: 'Skip', value: TableColumn.SKIP },
// { label: 'Path', value: TableColumn.PATH },
// { label: 'Play Count', value: TableColumn.PLAY_COUNT },
// { label: 'Favorite', value: TableColumn.FAVORITE },
// { label: 'Rating', value: TableColumn.RATING },
{ label: 'Date Added', value: TableColumn.DATE_ADDED },
];
interface TableConfigDropdownProps {
type: TableType;
}
export const TableConfigDropdown = ({ type }: TableConfigDropdownProps) => {
const { setSettings } = useSettingsStoreActions();
const tableConfig = useSettingsStore((state) => state.tables);
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 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));
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 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,
},
},
});
};
return (
<Stack
p="1rem"
spacing="xl"
>
<Stack spacing="xs">
<Text>Table Columns</Text>
<MultiSelect
clearable
data={tableColumns}
defaultValue={tableConfig[type]?.columns.map((column) => column.column)}
dropdownPosition="top"
width={300}
onChange={handleAddOrRemoveColumns}
/>
</Stack>
<Stack spacing="xs">
<Text>Row Height</Text>
<Slider
defaultValue={tableConfig[type]?.rowHeight}
max={100}
min={25}
sx={{ width: 150 }}
onChangeEnd={handleUpdateRowHeight}
/>
</Stack>
<Stack spacing="xs">
<Text>Auto Fit Columns</Text>
<Switch
defaultChecked={tableConfig[type]?.autoFit}
onChange={handleUpdateAutoFit}
/>
</Stack>
<Stack spacing="xs">
<Text>Follow Current Song</Text>
<Switch
defaultChecked={tableConfig[type]?.followCurrentSong}
onChange={handleUpdateFollow}
/>
</Stack>
</Stack>
);
};

View file

@ -0,0 +1,26 @@
import { Stack, Group } from '@mantine/core';
import { RiAlertFill } from 'react-icons/ri';
import { Text } from '/@/renderer/components';
interface ActionRequiredContainerProps {
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>
);

View file

@ -0,0 +1,83 @@
import { Box, Center, Divider, Group, Stack } from '@mantine/core';
import type { FallbackProps } from 'react-error-boundary';
import { RiErrorWarningLine, RiArrowLeftLine } from 'react-icons/ri';
import { useNavigate, useRouteError } from 'react-router';
import styled from 'styled-components';
import { Button, Text } from '/@/renderer/components';
const Container = styled(Box)`
background: var(--main-bg);
`;
export const ErrorFallback = ({ error, resetErrorBoundary }: FallbackProps) => {
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>
);
};
export const RouteErrorBoundary = () => {
const navigate = useNavigate();
const error = useRouteError() as any;
console.log('error', error);
const handleReload = () => {
navigate(0);
};
const handleReturn = () => {
navigate(-1);
};
return (
<Container>
<Center sx={{ height: '100vh' }}>
<Stack sx={{ maxWidth: '50%' }}>
<Group>
<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>
<Button
leftIcon={<RiArrowLeftLine />}
sx={{ flex: 0.5 }}
variant="default"
onClick={handleReturn}
>
Go back
</Button>
<Button
variant="filled"
onClick={handleReload}
>
Reload
</Button>
</Group>
</Stack>
</Center>
</Container>
);
};

View file

@ -0,0 +1,43 @@
import { useEffect, useState } from 'react';
import isElectron from 'is-electron';
import { FileInput, Text, Button } from '/@/renderer/components';
const localSettings = window.electron.localSettings;
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);
};
getMpvPath();
}, []);
return (
<>
<Text size="lg">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>
</>
);
};

View file

@ -0,0 +1,18 @@
import { Text } from '/@/renderer/components';
import { useCurrentServer } from '/@/renderer/store';
export const ServerCredentialRequired = () => {
const currentServer = useCurrentServer();
return (
<>
<Text size="lg">
The selected server &apos;{currentServer?.name}&apos; requires an additional login to
access.
</Text>
<Text size="lg">
Add your credentials in the &apos;manage servers&apos; menu or switch to a different server.
</Text>
</>
);
};

View file

@ -0,0 +1,10 @@
import { Text } from '/@/renderer/components';
export const ServerRequired = () => {
return (
<>
<Text size="xl">No server selected.</Text>
<Text>Add or select a server in the file menu.</Text>
</>
);
};

View file

@ -0,0 +1 @@
export * from './components/error-fallback';

View file

@ -0,0 +1,99 @@
import { useState, useEffect } from 'react';
import { Center, Group, Stack } from '@mantine/core';
import isElectron from 'is-electron';
import { RiCheckFill } from 'react-icons/ri';
import { Link } from 'react-router-dom';
import { Button, Text } from '/@/renderer/components';
import { ActionRequiredContainer } from '/@/renderer/features/action-required/components/action-required-container';
import { MpvRequired } from '/@/renderer/features/action-required/components/mpv-required';
import { ServerCredentialRequired } from '/@/renderer/features/action-required/components/server-credential-required';
import { ServerRequired } from '/@/renderer/features/action-required/components/server-required';
import { AnimatedPage } from '/@/renderer/features/shared';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer } from '/@/renderer/store';
const localSettings = window.electron.localSettings;
const ActionRequiredRoute = () => {
const currentServer = useCurrentServer();
const [isMpvRequired, setIsMpvRequired] = useState(false);
const isServerRequired = !currentServer;
// const isCredentialRequired = currentServer?.noCredential && !serverToken;
const isCredentialRequired = false;
useEffect(() => {
const getMpvPath = async () => {
if (!isElectron()) return setIsMpvRequired(false);
const mpvPath = await localSettings.get('mpv_path');
return setIsMpvRequired(!mpvPath);
};
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 canReturnHome = checks.every((c) => c.valid);
const displayedCheck = checks.find((c) => !c.valid);
return (
<AnimatedPage>
<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 && (
<>
<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;

View file

@ -0,0 +1,38 @@
import { Center, Group, Stack } from '@mantine/core';
import { RiQuestionLine } from 'react-icons/ri';
import { useLocation, useNavigate } from 'react-router-dom';
import { Button, Text } from '/@/renderer/components';
import { AnimatedPage } from '/@/renderer/features/shared';
const InvalidRoute = () => {
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>
);
};
export default InvalidRoute;

View file

@ -0,0 +1,256 @@
import type { MouseEvent } from 'react';
import { useCallback } from 'react';
import { Group, Slider } from '@mantine/core';
import throttle from 'lodash/throttle';
import { RiArrowDownSLine } from 'react-icons/ri';
import { AlbumListSort, SortOrder } from '/@/renderer/api/types';
import { Button, DropdownMenu, PageHeader } from '/@/renderer/components';
import { useCurrentServer, useAppStoreActions, useAlbumRouteStore } from '/@/renderer/store';
import { CardDisplayType } from '/@/renderer/types';
const FILTERS = {
jellyfin: [
{ name: 'Album Artist', value: AlbumListSort.ALBUM_ARTIST },
{ name: 'Community Rating', value: AlbumListSort.COMMUNITY_RATING },
{ name: 'Critic Rating', value: AlbumListSort.CRITIC_RATING },
{ name: 'Name', value: AlbumListSort.NAME },
{ name: 'Random', value: AlbumListSort.RANDOM },
{ name: 'Recently Added', value: AlbumListSort.RECENTLY_ADDED },
{ name: 'Release Date', value: AlbumListSort.RELEASE_DATE },
],
navidrome: [
{ name: 'Album Artist', value: AlbumListSort.ALBUM_ARTIST },
{ name: 'Artist', value: AlbumListSort.ARTIST },
{ name: 'Duration', value: AlbumListSort.DURATION },
{ name: 'Name', value: AlbumListSort.NAME },
{ name: 'Play Count', value: AlbumListSort.PLAY_COUNT },
{ name: 'Random', value: AlbumListSort.RANDOM },
{ name: 'Rating', value: AlbumListSort.RATING },
{ name: 'Recently Added', value: AlbumListSort.RECENTLY_ADDED },
{ name: 'Song Count', value: AlbumListSort.SONG_COUNT },
{ name: 'Favorited', value: AlbumListSort.FAVORITED },
{ name: 'Year', value: AlbumListSort.YEAR },
],
};
const ORDER = [
{ name: 'Ascending', value: SortOrder.ASC },
{ name: 'Descending', value: SortOrder.DESC },
];
export const AlbumListHeader = () => {
const server = useCurrentServer();
const { setPage } = useAppStoreActions();
const page = useAlbumRouteStore();
const filters = page.list.filter;
const sortByLabel =
(server?.type &&
(FILTERS[server.type as keyof typeof FILTERS] as { name: string; value: string }[]).find(
(f) => f.value === filters.sortBy,
)?.name) ||
'Unknown';
const sortOrderLabel = ORDER.find((s) => s.value === filters.sortOrder)?.name;
const setSize = throttle(
(e: number) =>
setPage('albums', {
...page,
list: { ...page.list, size: e },
}),
200,
);
const handleSetFilter = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
if (!e.currentTarget?.value) return;
setPage('albums', {
list: {
...page.list,
filter: {
...page.list.filter,
sortBy: e.currentTarget.value as AlbumListSort,
},
},
});
},
[page.list, setPage],
);
const handleSetOrder = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
if (!e.currentTarget?.value) return;
setPage('albums', {
list: {
...page.list,
filter: {
...page.list.filter,
sortOrder: e.currentTarget.value as SortOrder,
},
},
});
},
[page.list, setPage],
);
const handleSetViewType = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
if (!e.currentTarget?.value) return;
const type = e.currentTarget.value;
if (type === CardDisplayType.CARD) {
setPage('albums', {
...page,
list: {
...page.list,
display: CardDisplayType.CARD,
type: 'grid',
},
});
} else if (type === CardDisplayType.POSTER) {
setPage('albums', {
...page,
list: {
...page.list,
display: CardDisplayType.POSTER,
type: 'grid',
},
});
} else {
setPage('albums', {
...page,
list: {
...page.list,
type: 'list',
},
});
}
},
[page, setPage],
);
return (
<PageHeader>
<Group>
<DropdownMenu
position="bottom-end"
width={100}
>
<DropdownMenu.Target>
<Button
compact
rightIcon={<RiArrowDownSLine size={15} />}
size="xl"
sx={{ paddingLeft: 0, paddingRight: 0 }}
variant="subtle"
>
Albums
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Item>
<Slider
defaultValue={page.list?.size || 0}
label={null}
onChange={setSize}
/>
</DropdownMenu.Item>
<DropdownMenu.Divider />
<DropdownMenu.Item
$isActive={page.list.type === 'grid' && page.list.display === CardDisplayType.CARD}
value={CardDisplayType.CARD}
onClick={handleSetViewType}
>
Card
</DropdownMenu.Item>
<DropdownMenu.Item
$isActive={page.list.type === 'grid' && page.list.display === CardDisplayType.POSTER}
value={CardDisplayType.POSTER}
onClick={handleSetViewType}
>
Poster
</DropdownMenu.Item>
<DropdownMenu.Item
disabled
$isActive={page.list.type === 'list'}
value="list"
onClick={handleSetViewType}
>
List
</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
fw="normal"
variant="subtle"
>
{sortByLabel}
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{FILTERS[server?.type as keyof typeof FILTERS].map((filter) => (
<DropdownMenu.Item
key={`filter-${filter.name}`}
$isActive={filter.value === filters.sortBy}
value={filter.value}
onClick={handleSetFilter}
>
{filter.name}
</DropdownMenu.Item>
))}
</DropdownMenu.Dropdown>
</DropdownMenu>
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
fw="normal"
variant="subtle"
>
{sortOrderLabel}
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{ORDER.map((sort) => (
<DropdownMenu.Item
key={`sort-${sort.value}`}
$isActive={sort.value === filters.sortOrder}
value={sort.value}
onClick={handleSetOrder}
>
{sort.name}
</DropdownMenu.Item>
))}
</DropdownMenu.Dropdown>
</DropdownMenu>
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
fw="normal"
variant="subtle"
>
Folder
</Button>
</DropdownMenu.Target>
{/* <DropdownMenu.Dropdown>
{serverFolders?.map((folder) => (
<DropdownMenu.Item
key={folder.id}
$isActive={filters.serverFolderId.includes(folder.id)}
closeMenuOnClick={false}
value={folder.id}
onClick={handleSetServerFolder}
>
{folder.name}
</DropdownMenu.Item>
))}
</DropdownMenu.Dropdown> */}
</DropdownMenu>
</Group>
</PageHeader>
);
};

View file

@ -0,0 +1,2 @@
export * from './queries/album-detail-query';
export * from './queries/album-list-query';

View file

@ -0,0 +1,16 @@
import { useQuery } from '@tanstack/react-query';
import { queryKeys } from '/@/renderer/api/query-keys';
import type { QueryOptions } from '/@/renderer/lib/react-query';
import { useCurrentServer } from '../../../store/auth.store';
import type { AlbumDetailQuery } from '/@/renderer/api/types';
import { controller } from '/@/renderer/api/controller';
export const useAlbumDetail = (query: AlbumDetailQuery, options: QueryOptions) => {
const server = useCurrentServer();
return useQuery({
queryFn: ({ signal }) => controller.getAlbumDetail({ query, server, signal }),
queryKey: queryKeys.albums.detail(server?.id || '', query),
...options,
});
};

View file

@ -0,0 +1,40 @@
import { useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
import { controller } from '/@/renderer/api/controller';
import { queryKeys } from '/@/renderer/api/query-keys';
import type { AlbumListQuery, RawAlbumListResponse } from '/@/renderer/api/types';
import type { QueryOptions } from '/@/renderer/lib/react-query';
import { useCurrentServer } from '/@/renderer/store';
import { api } from '/@/renderer/api';
export const useAlbumList = (query: AlbumListQuery, options?: QueryOptions) => {
const server = useCurrentServer();
return useQuery({
enabled: !!server?.id,
queryFn: ({ signal }) => controller.getAlbumList({ query, server, signal }),
queryKey: queryKeys.albums.list(server?.id || '', query),
select: useCallback(
(data: RawAlbumListResponse | undefined) => api.normalize.albumList(data, server),
[server],
),
...options,
});
};
// export const useAlbumListInfinite = (params: AlbumListParams, options?: QueryOptions) => {
// const serverId = useAuthStore((state) => state.currentServer?.id) || '';
// return useInfiniteQuery({
// enabled: !!serverId,
// getNextPageParam: (lastPage: AlbumListResponse) => {
// return !!lastPage.pagination.nextPage;
// },
// getPreviousPageParam: (firstPage: AlbumListResponse) => {
// return !!firstPage.pagination.prevPage;
// },
// queryFn: ({ pageParam }) => api.albums.getAlbumList({ serverId }, { ...(pageParam || params) }),
// queryKey: queryKeys.albums.list(serverId, params),
// ...options,
// });
// };

View file

@ -0,0 +1,127 @@
/* eslint-disable no-plusplus */
import { useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import AutoSizer from 'react-virtualized-auto-sizer';
import type { ListOnScrollProps } from 'react-window';
import { queryKeys } from '/@/renderer/api/query-keys';
import {
VirtualGridAutoSizerContainer,
VirtualGridContainer,
VirtualInfiniteGrid,
} from '/@/renderer/components';
import { AppRoute } from '/@/renderer/router/routes';
import { useAlbumRouteStore, useAppStoreActions, useCurrentServer } from '/@/renderer/store';
import { LibraryItem, CardDisplayType } from '/@/renderer/types';
import { useAlbumList } from '../queries/album-list-query';
import { controller } from '/@/renderer/api/controller';
import { AnimatedPage } from '/@/renderer/features/shared';
import { AlbumListHeader } from '/@/renderer/features/albums/components/album-list-header';
import { api } from '/@/renderer/api';
const AlbumListRoute = () => {
const queryClient = useQueryClient();
const server = useCurrentServer();
const { setPage } = useAppStoreActions();
const page = useAlbumRouteStore();
const filters = page.list.filter;
const albumListQuery = useAlbumList({
limit: 1,
sortBy: filters.sortBy,
sortOrder: filters.sortOrder,
startIndex: 0,
});
const fetch = useCallback(
async ({ skip, take }: { skip: number; take: number }) => {
const queryKey = queryKeys.albums.list(server?.id || '', {
limit: take,
startIndex: skip,
...filters,
});
const albums = await queryClient.fetchQuery(queryKey, async ({ signal }) =>
controller.getAlbumList({
query: {
limit: take,
sortBy: filters.sortBy,
sortOrder: filters.sortOrder,
startIndex: skip,
},
server,
signal,
}),
);
return api.normalize.albumList(albums, server);
},
[filters, queryClient, server],
);
const handleGridScroll = useCallback(
(e: ListOnScrollProps) => {
setPage('albums', {
...page,
list: {
...page.list,
gridScrollOffset: e.scrollOffset,
},
});
},
[page, setPage],
);
return (
<AnimatedPage>
<VirtualGridContainer>
<AlbumListHeader />
<VirtualGridAutoSizerContainer>
<AutoSizer>
{({ height, width }) => (
<VirtualInfiniteGrid
cardRows={[
{
property: 'name',
route: {
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
},
},
{
arrayProperty: 'name',
property: 'albumArtists',
route: {
route: AppRoute.LIBRARY_ALBUMARTISTS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
},
},
{
property: 'releaseYear',
},
]}
display={page.list?.display || CardDisplayType.CARD}
fetchFn={fetch}
height={height}
initialScrollOffset={page.list?.gridScrollOffset || 0}
itemCount={albumListQuery?.data?.totalRecordCount || 0}
itemGap={20}
itemSize={150 + page.list?.size}
itemType={LibraryItem.ALBUM}
minimumBatchSize={40}
// refresh={advancedFilters}
route={{
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
}}
width={width}
onScroll={handleGridScroll}
/>
)}
</AutoSizer>
</VirtualGridAutoSizerContainer>
</VirtualGridContainer>
</AnimatedPage>
);
};
export default AlbumListRoute;

View file

@ -0,0 +1,58 @@
import { useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
import { api } from '/@/renderer/api';
import { ndNormalize } from '/@/renderer/api/navidrome.api';
import { NDAlbum } from '/@/renderer/api/navidrome.types';
import { queryKeys } from '/@/renderer/api/query-keys';
import {
AlbumListQuery,
AlbumListSort,
RawAlbumListResponse,
SortOrder,
} from '/@/renderer/api/types';
import { useCurrentServer } from '/@/renderer/store';
import { QueryOptions } from '/@/renderer/lib/react-query';
export const useRecentlyPlayed = (query: Partial<AlbumListQuery>, options?: QueryOptions) => {
const server = useCurrentServer();
const requestQuery: AlbumListQuery = {
limit: 5,
sortBy: AlbumListSort.RECENTLY_PLAYED,
sortOrder: SortOrder.ASC,
startIndex: 0,
...query,
};
return useQuery({
queryFn: ({ signal }) =>
api.controller.getAlbumList({
query: requestQuery,
server,
signal,
}),
queryKey: queryKeys.albums.list(server?.id || '', requestQuery),
select: useCallback(
(data: RawAlbumListResponse | undefined) => {
let albums;
switch (server?.type) {
case 'jellyfin':
break;
case 'navidrome':
albums = data?.items.map((item) => ndNormalize.album(item as NDAlbum, server));
break;
case 'subsonic':
break;
}
return {
items: albums,
startIndex: data?.startIndex,
totalRecordCount: data?.totalRecordCount,
};
},
[server],
),
...options,
});
};

View file

@ -0,0 +1,261 @@
import { useCallback, useMemo } from 'react';
import { Box, Stack } from '@mantine/core';
import { useSetState } from '@mantine/hooks';
import { AlbumListSort, SortOrder } from '/@/renderer/api/types';
import { TextTitle, PageHeader, FeatureCarousel, GridCarousel } from '/@/renderer/components';
import { useAlbumList } from '/@/renderer/features/albums';
import { useRecentlyPlayed } from '/@/renderer/features/home/queries/recently-played-query';
import { AnimatedPage } from '/@/renderer/features/shared';
import { useContainerQuery } from '/@/renderer/hooks';
import { AppRoute } from '/@/renderer/router/routes';
const HomeRoute = () => {
// const rootElement = document.querySelector(':root') as HTMLElement;
const cq = useContainerQuery();
const itemsPerPage = cq.isXl ? 9 : cq.isLg ? 7 : cq.isMd ? 5 : cq.isSm ? 4 : 3;
const [pagination, setPagination] = useSetState({
mostPlayed: 0,
random: 0,
recentlyAdded: 0,
recentlyPlayed: 0,
});
const feature = useAlbumList({
limit: 20,
sortBy: AlbumListSort.RANDOM,
sortOrder: SortOrder.DESC,
startIndex: 0,
});
const featureItemsWithImage = useMemo(() => {
return feature.data?.items?.filter((item) => item.imageUrl) ?? [];
}, [feature.data?.items]);
const random = useAlbumList(
{
limit: itemsPerPage,
sortBy: AlbumListSort.RANDOM,
sortOrder: SortOrder.ASC,
startIndex: pagination.random * itemsPerPage,
},
{
cacheTime: 0,
keepPreviousData: true,
staleTime: 0,
},
);
const recentlyPlayed = useRecentlyPlayed(
{
limit: itemsPerPage,
sortBy: AlbumListSort.RECENTLY_PLAYED,
sortOrder: SortOrder.ASC,
startIndex: pagination.recentlyPlayed * itemsPerPage,
},
{
keepPreviousData: true,
staleTime: 0,
},
);
const recentlyAdded = useAlbumList(
{
limit: itemsPerPage,
sortBy: AlbumListSort.RECENTLY_ADDED,
sortOrder: SortOrder.ASC,
startIndex: pagination.recentlyAdded * itemsPerPage,
},
{
keepPreviousData: true,
staleTime: 0,
},
);
const mostPlayed = useAlbumList(
{
limit: itemsPerPage,
sortBy: AlbumListSort.PLAY_COUNT,
sortOrder: SortOrder.DESC,
startIndex: pagination.mostPlayed * itemsPerPage,
},
{
keepPreviousData: true,
staleTime: 0,
},
);
const handleNextPage = useCallback(
(key: 'mostPlayed' | 'random' | 'recentlyAdded' | 'recentlyPlayed') => {
setPagination({
[key]: pagination[key as keyof typeof pagination] + 1,
});
},
[pagination, setPagination],
);
const handlePreviousPage = useCallback(
(key: 'mostPlayed' | 'random' | 'recentlyAdded' | 'recentlyPlayed') => {
setPagination({
[key]: pagination[key as keyof typeof pagination] - 1,
});
},
[pagination, setPagination],
);
// const handleScroll = (position: { x: number; y: number }) => {
// if (position.y <= 15) {
// return rootElement?.style?.setProperty('--header-opacity', '0');
// }
// return rootElement?.style?.setProperty('--header-opacity', '1');
// };
// const throttledScroll = throttle(handleScroll, 200);
const carousels = [
{
data: random?.data?.items,
loading: random?.isLoading || random.isFetching,
pagination: {
handleNextPage: () => handleNextPage('random'),
handlePreviousPage: () => handlePreviousPage('random'),
hasPreviousPage: pagination.random > 0,
itemsPerPage,
},
title: (
<TextTitle
fw="bold"
order={3}
>
Explore from your library
</TextTitle>
),
uniqueId: 'random',
},
{
data: recentlyPlayed?.data?.items,
loading: recentlyPlayed?.isLoading || recentlyPlayed.isFetching,
pagination: {
handleNextPage: () => handleNextPage('recentlyPlayed'),
handlePreviousPage: () => handlePreviousPage('recentlyPlayed'),
hasPreviousPage: pagination.recentlyPlayed > 0,
itemsPerPage,
},
title: (
<TextTitle
fw="bold"
order={3}
>
Recently played
</TextTitle>
),
uniqueId: 'recentlyPlayed',
},
{
data: recentlyAdded?.data?.items,
loading: recentlyAdded?.isLoading || recentlyAdded.isFetching,
pagination: {
handleNextPage: () => handleNextPage('recentlyAdded'),
handlePreviousPage: () => handlePreviousPage('recentlyAdded'),
hasPreviousPage: pagination.recentlyAdded > 0,
itemsPerPage,
},
title: (
<TextTitle
fw="bold"
order={3}
>
Newly added releases
</TextTitle>
),
uniqueId: 'recentlyAdded',
},
{
data: mostPlayed?.data?.items,
loading: mostPlayed?.isLoading || mostPlayed.isFetching,
pagination: {
handleNextPage: () => handleNextPage('mostPlayed'),
handlePreviousPage: () => handlePreviousPage('mostPlayed'),
hasPreviousPage: pagination.mostPlayed > 0,
itemsPerPage,
},
title: (
<TextTitle
fw="bold"
order={3}
>
Most played
</TextTitle>
),
uniqueId: 'mostPlayed',
},
];
return (
<AnimatedPage>
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<PageHeader
useOpacity
backgroundColor="var(--sidebar-bg)"
/>
<Box
mb="1rem"
mt="-1.5rem"
px="1rem"
sx={{
height: '100%',
overflow: 'auto',
}}
>
<Box
ref={cq.ref}
sx={{
height: '100%',
maxWidth: '1920px',
width: '100%',
}}
>
<Stack spacing={35}>
<FeatureCarousel
data={featureItemsWithImage}
loading={feature.isLoading || feature.isFetching}
/>
{carousels.map((carousel, index) => (
<GridCarousel
key={`carousel-${carousel.uniqueId}-${index}`}
cardRows={[
{
property: 'name',
route: {
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
},
},
{
arrayProperty: 'name',
property: 'albumArtists',
route: {
route: AppRoute.LIBRARY_ALBUMARTISTS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
},
},
]}
containerWidth={cq.width}
data={carousel.data}
loading={carousel.loading}
pagination={carousel.pagination}
uniqueId={carousel.uniqueId}
>
<GridCarousel.Title>{carousel.title}</GridCarousel.Title>
</GridCarousel>
))}
</Stack>
</Box>
</Box>
</Box>
</AnimatedPage>
);
};
export default HomeRoute;

View file

@ -0,0 +1,26 @@
import { useRef } from 'react';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Stack } from '@mantine/core';
import { PlayQueueListControls } from './play-queue-list-controls';
import { Song } from '/@/renderer/api/types';
import { PlayQueue } from '/@/renderer/features/now-playing/components/play-queue';
export const DrawerPlayQueue = () => {
const queueRef = useRef<{ grid: AgGridReactType<Song> } | null>(null);
return (
<Stack
pb="1rem"
sx={{ height: '100%' }}
>
<PlayQueue
ref={queueRef}
type="sideQueue"
/>
<PlayQueueListControls
tableRef={queueRef}
type="sideQueue"
/>
</Stack>
);
};

View file

@ -0,0 +1,52 @@
import { useEffect, useState } from 'react';
import { Group } from '@mantine/core';
import { FastAverageColor } from 'fast-average-color';
import { PageHeader, Text } from '/@/renderer/components';
import { useCurrentSong } from '/@/renderer/store';
import { getHeaderColor } from '/@/renderer/utils';
import { useTheme } from '/@/renderer/hooks';
export const NowPlayingHeader = () => {
const [headerColor, setHeaderColor] = useState({ isDark: false, value: 'rgba(0, 0, 0, 0)' });
const currentSong = useCurrentSong();
const theme = useTheme();
useEffect(() => {
const fac = new FastAverageColor();
const url = currentSong?.imageUrl;
if (url) {
fac
.getColorAsync(currentSong?.imageUrl, {
algorithm: 'simple',
ignoredColor: [
[255, 255, 255, 255], // White
[0, 0, 0, 255], // Black
],
mode: 'precision',
})
.then((color) => {
const isDark = color.isDark;
setHeaderColor({
isDark,
value: getHeaderColor(color.rgb, theme === 'dark' ? 0.5 : 0.8),
});
})
.catch((e) => {
console.log(e);
});
}
return () => {
fac.destroy();
};
}, [currentSong?.imageUrl, theme]);
return (
<PageHeader backgroundColor={headerColor.value}>
<Group>
<Text size="xl">Queue</Text>
</Group>
</PageHeader>
);
};

View file

@ -0,0 +1,138 @@
import type { MutableRefObject } from 'react';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Group } from '@mantine/core';
import { Button, Popover, TableConfigDropdown } from '/@/renderer/components';
import {
RiArrowDownLine,
RiArrowUpLine,
RiShuffleLine,
RiDeleteBinLine,
RiListSettingsLine,
RiEraserLine,
} from 'react-icons/ri';
import { Song } from '/@/renderer/api/types';
import { useQueueControls } from '/@/renderer/store';
import { TableType } from '/@/renderer/types';
const mpvPlayer = window.electron.mpvPlayer;
interface PlayQueueListOptionsProps {
tableRef: MutableRefObject<{ grid: AgGridReactType<Song> } | null>;
type: TableType;
}
export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsProps) => {
const { clearQueue, moveToBottomOfQueue, moveToTopOfQueue, shuffleQueue, removeFromQueue } =
useQueueControls();
const handleMoveToBottom = () => {
const selectedRows = tableRef?.current?.grid.api.getSelectedRows();
const uniqueIds = selectedRows?.map((row) => row.uniqueId);
if (!uniqueIds?.length) return;
const playerData = moveToBottomOfQueue(uniqueIds);
mpvPlayer.setQueueNext(playerData);
};
const handleMoveToTop = () => {
const selectedRows = tableRef?.current?.grid.api.getSelectedRows();
const uniqueIds = selectedRows?.map((row) => row.uniqueId);
if (!uniqueIds?.length) return;
const playerData = moveToTopOfQueue(uniqueIds);
mpvPlayer.setQueueNext(playerData);
};
const handleRemoveSelected = () => {
const selectedRows = tableRef?.current?.grid.api.getSelectedRows();
const uniqueIds = selectedRows?.map((row) => row.uniqueId);
if (!uniqueIds?.length) return;
const playerData = removeFromQueue(uniqueIds);
mpvPlayer.setQueueNext(playerData);
};
const handleClearQueue = () => {
const playerData = clearQueue();
mpvPlayer.setQueue(playerData);
mpvPlayer.stop();
};
const handleShuffleQueue = () => {
const playerData = shuffleQueue();
mpvPlayer.setQueueNext(playerData);
};
return (
<Group
position="apart"
px="1rem"
sx={{ alignItems: 'center' }}
>
<Group>
<Button
compact
size="sm"
tooltip={{ label: 'Shuffle queue' }}
variant="default"
onClick={handleShuffleQueue}
>
<RiShuffleLine size={15} />
</Button>
<Button
compact
size="sm"
tooltip={{ label: 'Move selected to top' }}
variant="default"
onClick={handleMoveToTop}
>
<RiArrowUpLine size={15} />
</Button>
<Button
compact
size="sm"
tooltip={{ label: 'Move selected to bottom' }}
variant="default"
onClick={handleMoveToBottom}
>
<RiArrowDownLine size={15} />
</Button>
<Button
compact
size="sm"
tooltip={{ label: 'Remove selected' }}
variant="default"
onClick={handleRemoveSelected}
>
<RiEraserLine size={15} />
</Button>
<Button
compact
size="sm"
tooltip={{ label: 'Clear queue' }}
variant="default"
onClick={handleClearQueue}
>
<RiDeleteBinLine size={15} />
</Button>
</Group>
<Group>
<Popover>
<Popover.Target>
<Button
compact
size="sm"
tooltip={{ label: 'Configure' }}
variant="default"
>
<RiListSettingsLine size={15} />
</Button>
</Popover.Target>
<Popover.Dropdown>
<TableConfigDropdown type={type} />
</Popover.Dropdown>
</Popover>
</Group>
</Group>
);
};

View file

@ -0,0 +1,236 @@
import type { Ref } from 'react';
import { useState, forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react';
import type {
CellDoubleClickedEvent,
ColDef,
RowClassRules,
RowDragEvent,
RowNode,
} from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import '@ag-grid-community/styles/ag-theme-alpine.css';
import {
VirtualGridAutoSizerContainer,
VirtualGridContainer,
getColumnDefs,
} from '/@/renderer/components';
import {
useAppStoreActions,
useCurrentSong,
useDefaultQueue,
usePreviousSong,
useQueueControls,
} from '/@/renderer/store';
import {
useSettingsStore,
useSettingsStoreActions,
useTableSettings,
} from '/@/renderer/store/settings.store';
import { useMergedRef } from '@mantine/hooks';
import { ErrorBoundary } from 'react-error-boundary';
import { VirtualTable } from '/@/renderer/components/virtual-table';
import { ErrorFallback } from '/@/renderer/features/action-required';
import { TableType, QueueSong } from '/@/renderer/types';
const mpvPlayer = window.electron.mpvPlayer;
type QueueProps = {
type: TableType;
};
export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
const tableRef = useRef<AgGridReactType | null>(null);
const mergedRef = useMergedRef(ref, tableRef);
const queue = useDefaultQueue();
const { reorderQueue, setCurrentTrack } = useQueueControls();
const currentSong = useCurrentSong();
const previousSong = usePreviousSong();
const { setSettings } = useSettingsStoreActions();
const { setAppStore } = useAppStoreActions();
const tableConfig = useTableSettings(type);
const [gridApi, setGridApi] = useState<AgGridReactType | undefined>();
useEffect(() => {
if (tableRef.current) {
setGridApi(tableRef.current);
}
}, []);
useImperativeHandle(ref, () => ({
get grid() {
return gridApi;
},
}));
const columnDefs = useMemo(() => getColumnDefs(tableConfig.columns), [tableConfig.columns]);
const defaultColumnDefs: ColDef = useMemo(() => {
return {
lockPinned: true,
lockVisible: true,
resizable: true,
};
}, []);
const handlePlayByRowClick = (e: CellDoubleClickedEvent) => {
const playerData = setCurrentTrack(e.data.uniqueId);
mpvPlayer.setQueue(playerData);
};
const handleDragStart = () => {
if (type === 'sideDrawerQueue') {
setAppStore({ isReorderingQueue: true });
}
};
let timeout: any;
const handleDragEnd = (e: RowDragEvent<QueueSong>) => {
if (!e.nodes.length) return;
const selectedUniqueIds = e.nodes
.map((node) => node.data?.uniqueId)
.filter((e) => e !== undefined);
const playerData = reorderQueue(selectedUniqueIds as string[], e.overNode?.data?.uniqueId);
mpvPlayer.setQueueNext(playerData);
if (type === 'sideDrawerQueue') {
setAppStore({ isReorderingQueue: false });
}
const { api } = tableRef?.current || {};
clearTimeout(timeout);
timeout = setTimeout(() => api?.redrawRows(), 250);
};
const handleGridReady = () => {
const { api } = tableRef?.current || {};
if (currentSong?.uniqueId) {
const currentNode = api?.getRowNode(currentSong?.uniqueId);
if (!currentNode) return;
api?.ensureNodeVisible(currentNode, 'middle');
}
};
const handleColumnChange = () => {
const { columnApi } = tableRef?.current || {};
const columnsOrder = columnApi?.getAllGridColumns();
if (!columnsOrder) return;
const columnsInSettings = useSettingsStore.getState().tables[type].columns;
const updatedColumns = [];
for (const column of columnsOrder) {
const columnInSettings = columnsInSettings.find((c) => c.column === column.colId);
if (columnInSettings) {
updatedColumns.push({
...columnInSettings,
...(!useSettingsStore.getState().tables[type].autoFit && {
width: column.actualWidth,
}),
});
}
}
setSettings({
tables: {
...useSettingsStore.getState().tables,
[type]: {
...useSettingsStore.getState().tables[type],
columns: updatedColumns,
},
},
});
};
const handleGridSizeChange = () => {
if (tableConfig.autoFit) {
tableRef?.current?.api.sizeColumnsToFit();
}
};
const rowClassRules = useMemo<RowClassRules>(() => {
return {
'current-song': (params) => {
return params.data.uniqueId === currentSong?.uniqueId;
},
};
}, [currentSong?.uniqueId]);
// Redraw the current song row when the previous song changes
useEffect(() => {
if (tableRef?.current) {
const { api, columnApi } = tableRef?.current || {};
if (api == null || columnApi == null) {
return;
}
const currentNode = currentSong?.uniqueId ? api.getRowNode(currentSong.uniqueId) : undefined;
const previousNode = previousSong?.uniqueId
? api.getRowNode(previousSong?.uniqueId)
: undefined;
const rowNodes = [currentNode, previousNode].filter((e) => e !== undefined) as RowNode<any>[];
if (rowNodes) {
api.redrawRows({ rowNodes });
if (tableConfig.followCurrentSong) {
if (!currentNode) return;
api.ensureNodeVisible(currentNode, 'middle');
}
}
}
}, [currentSong, previousSong, tableConfig.followCurrentSong]);
// Auto resize the columns when the column config changes
useEffect(() => {
if (tableConfig.autoFit) {
const { api } = tableRef?.current || {};
api?.sizeColumnsToFit();
}
}, [tableConfig.autoFit, tableConfig.columns]);
useEffect(() => {
const { api } = tableRef?.current || {};
api?.resetRowHeights();
api?.redrawRows();
}, [tableConfig.rowHeight]);
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<VirtualGridContainer>
<VirtualGridAutoSizerContainer>
<VirtualTable
ref={mergedRef}
alwaysShowHorizontalScroll
animateRows
maintainColumnOrder
rowDragEntireRow
rowDragMultiRow
suppressCopyRowsToClipboard
suppressMoveWhenRowDragging
suppressRowDrag
suppressScrollOnNewData
columnDefs={columnDefs}
defaultColDef={defaultColumnDefs}
enableCellChangeFlash={false}
getRowId={(data) => data.data.uniqueId}
rowBuffer={30}
rowClassRules={rowClassRules}
rowData={queue}
rowHeight={tableConfig.rowHeight || 40}
rowSelection="multiple"
onCellDoubleClicked={handlePlayByRowClick}
onColumnMoved={handleColumnChange}
onColumnResized={handleColumnChange}
onDragStarted={handleDragStart}
onGridReady={handleGridReady}
onGridSizeChanged={handleGridSizeChange}
onRowDragEnd={handleDragEnd}
/>
</VirtualGridAutoSizerContainer>
</VirtualGridContainer>
</ErrorBoundary>
);
});

View file

@ -0,0 +1,27 @@
import { useRef } from 'react';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Stack } from '@mantine/core';
import { PlayQueue } from '/@/renderer/features/now-playing/components/play-queue';
import { PlayQueueListControls } from './play-queue-list-controls';
import { Song } from '/@/renderer/api/types';
export const SidebarPlayQueue = () => {
const queueRef = useRef<{ grid: AgGridReactType<Song> } | null>(null);
return (
<Stack
pb="1rem"
pt="2.5rem"
sx={{ height: '100%' }}
>
<PlayQueue
ref={queueRef}
type="sideQueue"
/>
<PlayQueueListControls
tableRef={queueRef}
type="sideQueue"
/>
</Stack>
);
};

View file

@ -0,0 +1,4 @@
export * from './components/play-queue';
export * from './components/sidebar-play-queue';
export * from './components/drawer-play-queue';
export * from './components/play-queue-list-controls';

View file

@ -0,0 +1,35 @@
import { useRef } from 'react';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Stack } from '@mantine/core';
import { NowPlayingHeader } from '/@/renderer/features/now-playing/components/now-playing-header';
import { PlayQueue } from '/@/renderer/features/now-playing/components/play-queue';
import { PlayQueueListControls } from '/@/renderer/features/now-playing/components/play-queue-list-controls';
import type { Song } from '/@/renderer/api/types';
import { AnimatedPage } from '/@/renderer/features/shared';
const NowPlayingRoute = () => {
const queueRef = useRef<{ grid: AgGridReactType<Song> } | null>(null);
return (
<AnimatedPage>
<Stack
pb="1rem"
spacing={0}
sx={{ height: '100%' }}
>
<NowPlayingHeader />
<PlayQueue
ref={queueRef}
type="nowPlaying"
/>
<PlayQueueListControls
tableRef={queueRef}
type="nowPlaying"
/>
</Stack>
</AnimatedPage>
);
};
export default NowPlayingRoute;

View file

@ -0,0 +1,238 @@
import { useEffect, useState } from 'react';
import format from 'format-duration';
import isElectron from 'is-electron';
import { IoIosPause } from 'react-icons/io';
import {
RiPlayFill,
RiRepeat2Line,
RiRepeatOneLine,
RiRewindFill,
RiShuffleFill,
RiSkipBackFill,
RiSkipForwardFill,
RiSpeedFill,
} from 'react-icons/ri';
import styled from 'styled-components';
import { Text } from '/@/renderer/components';
import { useCenterControls } from '../hooks/use-center-controls';
import { PlayerButton } from './player-button';
import { Slider } from './slider';
import {
useCurrentSong,
useCurrentStatus,
useCurrentPlayer,
useSetCurrentTime,
useRepeatStatus,
useShuffleStatus,
useCurrentTime,
} from '/@/renderer/store';
import { useSettingsStore } from '/@/renderer/store/settings.store';
import { PlayerStatus, PlaybackType, PlayerShuffle, PlayerRepeat } from '/@/renderer/types';
interface CenterControlsProps {
playersRef: any;
}
const ControlsContainer = styled.div`
display: flex;
align-items: center;
justify-content: center;
height: 35px;
`;
const ButtonsContainer = styled.div`
display: flex;
gap: 0.5rem;
align-items: center;
`;
const SliderContainer = styled.div`
display: flex;
height: 20px;
`;
const SliderValueWrapper = styled.div<{ position: 'left' | 'right' }>`
flex: 1;
align-self: center;
max-width: 50px;
text-align: center;
`;
const SliderWrapper = styled.div`
display: flex;
flex: 6;
height: 100%;
`;
export const CenterControls = ({ playersRef }: CenterControlsProps) => {
const [isSeeking, setIsSeeking] = useState(false);
const currentSong = useCurrentSong();
const songDuration = currentSong?.duration;
const skip = useSettingsStore((state) => state.player.skipButtons);
const playerType = useSettingsStore((state) => state.player.type);
const player1 = playersRef?.current?.player1;
const player2 = playersRef?.current?.player2;
const status = useCurrentStatus();
const player = useCurrentPlayer();
const setCurrentTime = useSetCurrentTime();
const repeat = useRepeatStatus();
const shuffle = useShuffleStatus();
const {
handleNextTrack,
handlePlayPause,
handlePrevTrack,
handleSeekSlider,
handleSkipBackward,
handleSkipForward,
handleToggleRepeat,
handleToggleShuffle,
} = useCenterControls({ playersRef });
const currentTime = useCurrentTime();
const currentPlayerRef = player === 1 ? player1 : player2;
const duration = format((songDuration || 0) * 1000);
const formattedTime = format(currentTime * 1000 || 0);
useEffect(() => {
let interval: any;
if (status === PlayerStatus.PLAYING && !isSeeking) {
if (!isElectron() || playerType === PlaybackType.WEB) {
interval = setInterval(() => {
setCurrentTime(currentPlayerRef.getCurrentTime());
}, 1000);
}
} else {
clearInterval(interval);
}
return () => clearInterval(interval);
}, [currentPlayerRef, isSeeking, setCurrentTime, playerType, status]);
return (
<>
<ControlsContainer>
<ButtonsContainer>
<PlayerButton
$isActive={shuffle !== PlayerShuffle.NONE}
icon={<RiShuffleFill size={15} />}
tooltip={{
label:
shuffle === PlayerShuffle.NONE
? 'Shuffle disabled'
: shuffle === PlayerShuffle.TRACK
? 'Shuffle tracks'
: 'Shuffle albums',
openDelay: 500,
}}
variant="tertiary"
onClick={handleToggleShuffle}
/>
<PlayerButton
icon={<RiSkipBackFill size={15} />}
tooltip={{ label: 'Previous track', openDelay: 500 }}
variant="secondary"
onClick={handlePrevTrack}
/>
{skip?.enabled && (
<PlayerButton
icon={<RiRewindFill size={15} />}
tooltip={{
label: `Skip backwards ${skip?.skipBackwardSeconds} seconds`,
openDelay: 500,
}}
variant="secondary"
onClick={() => handleSkipBackward(skip?.skipBackwardSeconds)}
/>
)}
<PlayerButton
icon={
status === PlayerStatus.PAUSED ? <RiPlayFill size={20} /> : <IoIosPause size={20} />
}
tooltip={{
label: status === PlayerStatus.PAUSED ? 'Play' : 'Pause',
openDelay: 500,
}}
variant="main"
onClick={handlePlayPause}
/>
{skip?.enabled && (
<PlayerButton
icon={<RiSpeedFill size={15} />}
tooltip={{
label: `Skip forwards ${skip?.skipForwardSeconds} seconds`,
openDelay: 500,
}}
variant="secondary"
onClick={() => handleSkipForward(skip?.skipForwardSeconds)}
/>
)}
<PlayerButton
icon={<RiSkipForwardFill size={15} />}
tooltip={{ label: 'Next track', openDelay: 500 }}
variant="secondary"
onClick={handleNextTrack}
/>
<PlayerButton
$isActive={repeat !== PlayerRepeat.NONE}
icon={
repeat === PlayerRepeat.ONE ? (
<RiRepeatOneLine size={15} />
) : (
<RiRepeat2Line size={15} />
)
}
tooltip={{
label: `${
repeat === PlayerRepeat.NONE
? 'Repeat disabled'
: repeat === PlayerRepeat.ALL
? 'Repeat all'
: 'Repeat one'
}`,
openDelay: 500,
}}
variant="tertiary"
onClick={handleToggleRepeat}
/>
</ButtonsContainer>
</ControlsContainer>
<SliderContainer>
<SliderValueWrapper position="left">
<Text
$noSelect
$secondary
size="xs"
weight={600}
>
{formattedTime}
</Text>
</SliderValueWrapper>
<SliderWrapper>
<Slider
height="100%"
max={songDuration}
min={0}
tooltipType="time"
value={currentTime}
onAfterChange={(e) => {
handleSeekSlider(e);
setIsSeeking(false);
}}
/>
</SliderWrapper>
<SliderValueWrapper position="right">
<Text
$noSelect
$secondary
size="xs"
weight={600}
>
{duration}
</Text>
</SliderValueWrapper>
</SliderContainer>
</>
);
};

View file

@ -0,0 +1,207 @@
import React from 'react';
import { Center } from '@mantine/core';
import { motion, AnimatePresence, LayoutGroup } from 'framer-motion';
import { RiArrowUpSLine, RiDiscLine } from 'react-icons/ri';
import { generatePath, Link } from 'react-router-dom';
import styled from 'styled-components';
import { Button, Text } from '/@/renderer/components';
import { AppRoute } from '/@/renderer/router/routes';
import { useAppStoreActions, useAppStore, useCurrentSong } from '/@/renderer/store';
import { fadeIn } from '/@/renderer/styles';
const LeftControlsContainer = styled.div`
display: flex;
width: 100%;
height: 100%;
margin-left: 1rem;
`;
const ImageWrapper = styled.div`
display: flex;
align-items: center;
justify-content: center;
padding: 1rem 1rem 1rem 0;
`;
const MetadataStack = styled(motion.div)`
display: flex;
flex-direction: column;
gap: 0;
justify-content: center;
width: 100%;
overflow: hidden;
`;
const Image = styled(motion(Link))`
width: 60px;
height: 60px;
filter: drop-shadow(0 0 5px rgb(0, 0, 0, 100%));
${fadeIn};
animation: fadein 0.2s ease-in-out;
button {
display: none;
}
&:hover button {
display: block;
}
`;
const PlayerbarImage = styled.img`
width: 100%;
height: 100%;
object-fit: cover;
`;
const LineItem = styled.div<{ $secondary?: boolean }>`
display: inline-block;
width: 95%;
max-width: 20vw;
overflow: hidden;
color: ${(props) => props.$secondary && 'var(--main-fg-secondary)'};
line-height: 1.3;
white-space: nowrap;
text-overflow: ellipsis;
a {
color: ${(props) => props.$secondary && 'var(--text-secondary)'};
}
`;
export const LeftControls = () => {
const { setSidebar } = useAppStoreActions();
const hideImage = useAppStore((state) => state.sidebar.image);
const currentSong = useCurrentSong();
const title = currentSong?.name;
const artists = currentSong?.artists;
return (
<LeftControlsContainer>
<LayoutGroup>
<AnimatePresence
initial={false}
mode="wait"
>
{!hideImage && (
<ImageWrapper>
<Image
key="playerbar-image"
animate={{ opacity: 1, scale: 1, x: 0 }}
exit={{ opacity: 0, x: -50 }}
initial={{ opacity: 0, x: -50 }}
to={AppRoute.NOW_PLAYING}
transition={{ duration: 0.3, ease: 'easeInOut' }}
>
{currentSong?.imageUrl ? (
<PlayerbarImage
loading="eager"
src={currentSong?.imageUrl}
/>
) : (
<>
<Center
sx={{
background: 'var(--placeholder-bg)',
height: '100%',
}}
>
<RiDiscLine
color="var(--placeholder-fg)"
size={50}
/>
</Center>
</>
)}
<Button
compact
opacity={0.8}
radius={50}
size="xs"
sx={{ position: 'absolute', right: 2, top: 2 }}
tooltip={{ label: 'Expand' }}
variant="default"
onClick={(e) => {
e.preventDefault();
setSidebar({ image: true });
}}
>
<RiArrowUpSLine
color="white"
size={20}
/>
</Button>
</Image>
</ImageWrapper>
)}
</AnimatePresence>
<MetadataStack layout>
<LineItem>
<Text
$link
component={Link}
overflow="hidden"
size="sm"
to={AppRoute.NOW_PLAYING}
weight={500}
>
{title || '—'}
</Text>
</LineItem>
<LineItem $secondary>
{artists?.map((artist, index) => (
<React.Fragment key={`bar-${artist.id}`}>
{index > 0 && (
<Text
$link
$secondary
size="xs"
style={{ display: 'inline-block' }}
>
,
</Text>
)}{' '}
<Text
$link
component={Link}
overflow="hidden"
size="xs"
to={
artist.id
? generatePath(AppRoute.LIBRARY_ARTISTS_DETAIL, {
artistId: artist.id,
})
: ''
}
weight={500}
>
{artist.name || '—'}
</Text>
</React.Fragment>
))}
</LineItem>
<LineItem $secondary>
<Text
$link
component={Link}
overflow="hidden"
size="xs"
to={
currentSong?.albumId
? generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
albumId: currentSong.albumId,
})
: ''
}
weight={500}
>
{currentSong?.album || '—'}
</Text>
</LineItem>
</MetadataStack>
</LayoutGroup>
</LeftControlsContainer>
);
};

View file

@ -0,0 +1,156 @@
/* stylelint-disable no-descending-specificity */
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
import type { TooltipProps, UnstyledButtonProps } from '@mantine/core';
import { UnstyledButton } from '@mantine/core';
import { motion } from 'framer-motion';
import styled, { css } from 'styled-components';
import { Tooltip } from '/@/renderer/components';
type MantineButtonProps = UnstyledButtonProps & ComponentPropsWithoutRef<'button'>;
interface PlayerButtonProps extends MantineButtonProps {
$isActive?: boolean;
icon: ReactNode;
tooltip?: Omit<TooltipProps, 'children'>;
variant: 'main' | 'secondary' | 'tertiary';
}
const WrapperMainVariant = css`
margin: 0 0.5rem;
`;
type MotionWrapperProps = { variant: PlayerButtonProps['variant'] };
const MotionWrapper = styled(motion.div)<MotionWrapperProps>`
display: flex;
align-items: center;
justify-content: center;
${({ variant }) => variant === 'main' && WrapperMainVariant};
`;
const ButtonMainVariant = css`
padding: 0.5rem;
background: var(--playerbar-btn-main-bg);
border-radius: 50%;
svg {
display: flex;
fill: var(--playerbar-btn-main-fg);
}
&:focus-visible {
background: var(--playerbar-btn-main-bg-hover);
}
&:hover {
background: var(--playerbar-btn-main-bg-hover);
svg {
fill: var(--playerbar-btn-main-fg-hover);
}
}
`;
const ButtonSecondaryVariant = css`
padding: 0.5rem;
`;
const ButtonTertiaryVariant = css`
padding: 0.5rem;
svg {
display: flex;
}
&:focus-visible {
svg {
fill: var(--playerbar-btn-fg-hover);
stroke: var(--playerbar-btn-fg-hover);
}
}
`;
type StyledPlayerButtonProps = Omit<PlayerButtonProps, 'icon'>;
const StyledPlayerButton = styled(UnstyledButton)<StyledPlayerButtonProps>`
display: flex;
align-items: center;
width: 100%;
padding: 0.5rem;
overflow: visible;
background: var(--playerbar-btn-bg-hover);
all: unset;
cursor: default;
button {
display: flex;
}
&:focus-visible {
background: var(--playerbar-btn-bg-hover);
outline: 1px var(--primary-color) solid;
}
&:disabled {
opacity: 0.5;
}
svg {
display: flex;
fill: ${({ $isActive }) => ($isActive ? 'var(--primary-color)' : 'var(--playerbar-btn-fg)')};
stroke: var(--playerbar-btn-fg);
}
&:hover {
background: var(--playerbar-btn-bg-hover);
svg {
fill: ${({ $isActive }) =>
$isActive ? 'var(--primary-color)' : 'var(--playerbar-btn-fg-hover)'};
}
}
${({ variant }) =>
variant === 'main'
? ButtonMainVariant
: variant === 'secondary'
? ButtonSecondaryVariant
: ButtonTertiaryVariant};
`;
export const PlayerButton = ({ tooltip, variant, icon, ...rest }: PlayerButtonProps) => {
if (tooltip) {
return (
<Tooltip {...tooltip}>
<MotionWrapper
variant={variant}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 1 }}
>
<StyledPlayerButton
variant={variant}
{...rest}
>
{icon}
</StyledPlayerButton>
</MotionWrapper>
</Tooltip>
);
}
return (
<MotionWrapper variant={variant}>
<StyledPlayerButton
variant={variant}
{...rest}
>
{icon}
</StyledPlayerButton>
</MotionWrapper>
);
};
PlayerButton.defaultProps = {
$isActive: false,
tooltip: undefined,
};

View file

@ -0,0 +1,94 @@
import { useRef } from 'react';
import styled from 'styled-components';
import { useSettingsStore } from '/@/renderer/store/settings.store';
import { PlaybackType } from '/@/renderer/types';
import { AudioPlayer } from '/@/renderer/components';
import {
useCurrentPlayer,
useCurrentStatus,
usePlayer1Data,
usePlayer2Data,
usePlayerControls,
useVolume,
} from '/@/renderer/store';
import { CenterControls } from './center-controls';
import { LeftControls } from './left-controls';
import { RightControls } from './right-controls';
const PlayerbarContainer = styled.div`
width: 100%;
height: 100%;
border-top: var(--playerbar-border-top);
`;
const PlayerbarControlsGrid = styled.div`
display: flex;
gap: 1rem;
height: 100%;
`;
const RightGridItem = styled.div`
align-self: center;
width: calc(100% / 3);
height: 100%;
overflow: hidden;
`;
const LeftGridItem = styled.div`
width: calc(100% / 3);
height: 100%;
overflow: hidden;
`;
const CenterGridItem = styled.div`
display: flex;
flex-direction: column;
gap: 0.5rem;
justify-content: center;
width: calc(100% / 3);
height: 100%;
overflow: hidden;
`;
export const Playerbar = () => {
const playersRef = useRef<any>();
const settings = useSettingsStore((state) => state.player);
const volume = useVolume();
const player1 = usePlayer1Data();
const player2 = usePlayer2Data();
const status = useCurrentStatus();
const player = useCurrentPlayer();
const { autoNext } = usePlayerControls();
return (
<PlayerbarContainer>
<PlayerbarControlsGrid>
<LeftGridItem>
<LeftControls />
</LeftGridItem>
<CenterGridItem>
<CenterControls playersRef={playersRef} />
</CenterGridItem>
<RightGridItem>
<RightControls />
</RightGridItem>
</PlayerbarControlsGrid>
{settings.type === PlaybackType.WEB && (
<AudioPlayer
ref={playersRef}
autoNext={autoNext}
crossfadeDuration={settings.crossfadeDuration}
crossfadeStyle={settings.crossfadeStyle}
currentPlayer={player}
muted={settings.muted}
playbackStyle={settings.style}
player1={player1}
player2={player2}
status={status}
style={settings.style}
volume={(volume / 100) ** 2}
/>
)}
</PlayerbarContainer>
);
};

View file

@ -0,0 +1,81 @@
import { Group } from '@mantine/core';
import { HiOutlineQueueList } from 'react-icons/hi2';
import { RiVolumeUpFill, RiVolumeDownFill, RiVolumeMuteFill } from 'react-icons/ri';
import styled from 'styled-components';
import { useAppStoreActions, useMuted, useSidebarStore, useVolume } from '/@/renderer/store';
import { useRightControls } from '../hooks/use-right-controls';
import { PlayerButton } from './player-button';
import { Slider } from './slider';
const RightControlsContainer = styled.div`
display: flex;
flex-direction: row;
justify-content: flex-end;
width: 100%;
height: 100%;
padding-right: 1rem;
`;
const VolumeSliderWrapper = styled.div`
display: flex;
gap: 0.3rem;
align-items: center;
width: 90px;
`;
const MetadataStack = styled.div`
display: flex;
flex-direction: column;
gap: 0.3rem;
align-items: flex-end;
justify-content: center;
overflow: visible;
`;
export const RightControls = () => {
const volume = useVolume();
const muted = useMuted();
const { setSidebar } = useAppStoreActions();
const { rightExpanded: isQueueExpanded } = useSidebarStore();
const { handleVolumeSlider, handleVolumeSliderState, handleMute } = useRightControls();
return (
<RightControlsContainer>
<Group>
<PlayerButton
icon={<HiOutlineQueueList />}
tooltip={{ label: 'View queue', openDelay: 500 }}
variant="secondary"
onClick={() => setSidebar({ rightExpanded: !isQueueExpanded })}
/>
</Group>
<MetadataStack>
<VolumeSliderWrapper>
<PlayerButton
icon={
muted ? (
<RiVolumeMuteFill size={15} />
) : volume > 50 ? (
<RiVolumeUpFill size={15} />
) : (
<RiVolumeDownFill size={15} />
)
}
tooltip={{ label: muted ? 'Muted' : volume, openDelay: 500 }}
variant="secondary"
onClick={handleMute}
/>
<Slider
hasTooltip
height="60%"
max={100}
min={0}
value={volume}
onAfterChange={handleVolumeSliderState}
onChange={handleVolumeSlider}
/>
</VolumeSliderWrapper>
</MetadataStack>
</RightControlsContainer>
);
};

View file

@ -0,0 +1,149 @@
import { useMemo, useState } from 'react';
import format from 'format-duration';
import type { ReactSliderProps } from 'react-slider';
import ReactSlider from 'react-slider';
import styled from 'styled-components';
interface SliderProps extends ReactSliderProps {
hasTooltip?: boolean;
height: string;
tooltipType?: 'text' | 'time';
}
const StyledSlider = styled(ReactSlider)<SliderProps | any>`
width: 100%;
height: ${(props) => props.height};
outline: none;
.thumb {
top: 37%;
opacity: 1;
&::after {
position: absolute;
top: -25px;
left: -18px;
display: ${(props) => (props.$isDragging && props.$hasToolTip ? 'block' : 'none')};
padding: 2px 6px;
color: var(--tooltip-fg);
white-space: nowrap;
background: var(--tooltip-bg);
border-radius: 4px;
content: attr(data-tooltip);
}
&:focus-visible {
width: 13px;
height: 13px;
text-align: center;
background-color: #fff;
border: 1px var(--primary-color) solid;
border-radius: 100%;
outline: none;
transform: translate(-12px, -4px);
opacity: 1;
}
}
.track-0 {
background: ${(props) => props.$isDragging && 'var(--primary-color)'};
transition: background 0.2s ease-in-out;
}
.track {
top: 37%;
border-radius: 5px;
}
&:hover {
.track-0 {
background: var(--primary-color);
}
}
`;
const MemoizedThumb = ({ props, state, toolTipType }: any) => {
const { value } = state;
const formattedValue = useMemo(() => {
if (toolTipType === 'text') {
return value;
}
return format(value * 1000);
}, [toolTipType, value]);
return (
<div
{...props}
data-tooltip={formattedValue}
/>
);
};
const StyledTrack = styled.div<any>`
top: 0;
bottom: 0;
height: 5px;
background: ${(props) =>
props.index === 1
? 'var(--playerbar-slider-track-bg)'
: 'var(--playerbar-slider-track-progress-bg)'};
`;
const Track = (props: any, state: any) => (
// eslint-disable-next-line react/destructuring-assignment
<StyledTrack
{...props}
index={state.index}
/>
);
const Thumb = (props: any, state: any, toolTipType: any) => (
<MemoizedThumb
key="slider"
props={props}
state={state}
tabIndex={0}
toolTipType={toolTipType}
/>
);
export const Slider = ({
height,
tooltipType: toolTipType,
hasTooltip: hasToolTip,
...rest
}: SliderProps) => {
const [isDragging, setIsDragging] = useState(false);
return (
<StyledSlider
{...rest}
$hasToolTip={hasToolTip}
$isDragging={isDragging}
className="player-slider"
defaultValue={0}
height={height}
renderThumb={(props: any, state: any) => {
return Thumb(props, state, toolTipType);
}}
renderTrack={Track}
onAfterChange={(e: number, index: number) => {
if (rest.onAfterChange) {
rest.onAfterChange(e, index);
}
setIsDragging(false);
}}
onBeforeChange={(e: number, index: number) => {
if (rest.onBeforeChange) {
rest.onBeforeChange(e, index);
}
setIsDragging(true);
}}
/>
);
};
Slider.defaultProps = {
hasTooltip: true,
tooltipType: 'text',
};

View file

@ -0,0 +1,520 @@
import { useCallback, useEffect } from 'react';
import isElectron from 'is-electron';
import { PlaybackType, PlayerRepeat, PlayerShuffle, PlayerStatus } from '/@/renderer/types';
import {
useCurrentPlayer,
useCurrentStatus,
useDefaultQueue,
usePlayerControls,
usePlayerStore,
useRepeatStatus,
useSetCurrentTime,
useShuffleStatus,
} from '/@/renderer/store';
import { useSettingsStore } from '/@/renderer/store/settings.store';
const mpvPlayer = window.electron.mpvPlayer;
const mpvPlayerListener = window.electron.mpvPlayerListener;
const ipc = window.electron.ipc;
export const useCenterControls = (args: { playersRef: any }) => {
const { playersRef } = args;
const settings = useSettingsStore((state) => state.player);
const currentPlayer = useCurrentPlayer();
const { setShuffle, setRepeat, play, pause, previous, next, setCurrentIndex, autoNext } =
usePlayerControls();
const setCurrentTime = useSetCurrentTime();
const queue = useDefaultQueue();
const playerStatus = useCurrentStatus();
const repeatStatus = useRepeatStatus();
const shuffleStatus = useShuffleStatus();
const playerType = useSettingsStore((state) => state.player.type);
const player1Ref = playersRef?.current?.player1;
const player2Ref = playersRef?.current?.player2;
const currentPlayerRef = currentPlayer === 1 ? player1Ref : player2Ref;
const nextPlayerRef = currentPlayer === 1 ? player2Ref : player1Ref;
const resetPlayers = useCallback(() => {
if (player1Ref.getInternalPlayer()) {
player1Ref.getInternalPlayer().currentTime = 0;
player1Ref.getInternalPlayer().pause();
}
if (player2Ref.getInternalPlayer()) {
player2Ref.getInternalPlayer().currentTime = 0;
player2Ref.getInternalPlayer().pause();
}
}, [player1Ref, player2Ref]);
const resetNextPlayer = useCallback(() => {
currentPlayerRef.getInternalPlayer().volume = 0.1;
nextPlayerRef.getInternalPlayer().currentTime = 0;
nextPlayerRef.getInternalPlayer().pause();
}, [currentPlayerRef, nextPlayerRef]);
const stopPlayback = useCallback(() => {
player1Ref.getInternalPlayer().pause();
player2Ref.getInternalPlayer().pause();
resetPlayers();
}, [player1Ref, player2Ref, resetPlayers]);
const isMpvPlayer = isElectron() && settings.type === PlaybackType.LOCAL;
const handlePlay = useCallback(() => {
if (isMpvPlayer) {
mpvPlayer.play();
} else {
currentPlayerRef.getInternalPlayer().play();
}
play();
}, [currentPlayerRef, isMpvPlayer, play]);
const handlePause = useCallback(() => {
if (isMpvPlayer) {
mpvPlayer.pause();
}
pause();
}, [isMpvPlayer, pause]);
const handleStop = useCallback(() => {
if (isMpvPlayer) {
mpvPlayer.stop();
} else {
stopPlayback();
}
setCurrentTime(0);
pause();
}, [isMpvPlayer, pause, setCurrentTime, stopPlayback]);
const handleToggleShuffle = useCallback(() => {
if (shuffleStatus === PlayerShuffle.NONE) {
const playerData = setShuffle(PlayerShuffle.TRACK);
return mpvPlayer.setQueueNext(playerData);
}
const playerData = setShuffle(PlayerShuffle.NONE);
return mpvPlayer.setQueueNext(playerData);
}, [setShuffle, shuffleStatus]);
const handleToggleRepeat = useCallback(() => {
if (repeatStatus === PlayerRepeat.NONE) {
const playerData = setRepeat(PlayerRepeat.ALL);
return mpvPlayer.setQueueNext(playerData);
}
if (repeatStatus === PlayerRepeat.ALL) {
const playerData = setRepeat(PlayerRepeat.ONE);
return mpvPlayer.setQueueNext(playerData);
}
return setRepeat(PlayerRepeat.NONE);
}, [repeatStatus, setRepeat]);
const checkIsLastTrack = useCallback(() => {
return usePlayerStore.getState().actions.checkIsLastTrack();
}, []);
const checkIsFirstTrack = useCallback(() => {
return usePlayerStore.getState().actions.checkIsFirstTrack();
}, []);
const handleAutoNext = useCallback(() => {
const isLastTrack = checkIsLastTrack();
const handleRepeatAll = {
local: () => {
const playerData = autoNext();
mpvPlayer.autoNext(playerData);
play();
},
web: () => {
autoNext();
},
};
const handleRepeatNone = {
local: () => {
if (isLastTrack) {
const playerData = setCurrentIndex(0);
mpvPlayer.setQueue(playerData);
mpvPlayer.pause();
pause();
} else {
const playerData = autoNext();
mpvPlayer.autoNext(playerData);
play();
}
},
web: () => {
if (isLastTrack) {
resetPlayers();
pause();
} else {
autoNext();
resetPlayers();
}
},
};
const handleRepeatOne = {
local: () => {
const playerData = autoNext();
mpvPlayer.autoNext(playerData);
play();
},
web: () => {
if (isLastTrack) {
resetPlayers();
} else {
autoNext();
resetPlayers();
}
},
};
switch (repeatStatus) {
case PlayerRepeat.NONE:
handleRepeatNone[playerType]();
break;
case PlayerRepeat.ALL:
handleRepeatAll[playerType]();
break;
case PlayerRepeat.ONE:
handleRepeatOne[playerType]();
break;
default:
break;
}
}, [
autoNext,
checkIsLastTrack,
pause,
play,
playerType,
repeatStatus,
resetPlayers,
setCurrentIndex,
]);
const handleNextTrack = useCallback(() => {
const isLastTrack = checkIsLastTrack();
const handleRepeatAll = {
local: () => {
const playerData = next();
mpvPlayer.setQueue(playerData);
mpvPlayer.next();
},
web: () => {
next();
},
};
const handleRepeatNone = {
local: () => {
if (isLastTrack) {
const playerData = setCurrentIndex(0);
mpvPlayer.setQueue(playerData);
mpvPlayer.pause();
pause();
} else {
const playerData = next();
mpvPlayer.setQueue(playerData);
mpvPlayer.next();
}
},
web: () => {
if (isLastTrack) {
setCurrentIndex(0);
resetPlayers();
pause();
} else {
next();
resetPlayers();
}
},
};
const handleRepeatOne = {
local: () => {
const playerData = next();
mpvPlayer.setQueue(playerData);
mpvPlayer.next();
},
web: () => {
if (!isLastTrack) {
next();
}
},
};
switch (repeatStatus) {
case PlayerRepeat.NONE:
handleRepeatNone[playerType]();
break;
case PlayerRepeat.ALL:
handleRepeatAll[playerType]();
break;
case PlayerRepeat.ONE:
handleRepeatOne[playerType]();
break;
default:
break;
}
setCurrentTime(0);
}, [
checkIsLastTrack,
next,
pause,
playerType,
repeatStatus,
resetPlayers,
setCurrentIndex,
setCurrentTime,
]);
const handlePrevTrack = useCallback(() => {
const currentTime = isMpvPlayer
? usePlayerStore.getState().current.time
: currentPlayerRef.getCurrentTime();
// Reset the current track more than 10 seconds have elapsed
if (currentTime >= 10) {
if (isMpvPlayer) {
return mpvPlayer.seekTo(0);
}
return currentPlayerRef.seekTo(0);
}
const isFirstTrack = checkIsFirstTrack();
const handleRepeatAll = {
local: () => {
if (!isFirstTrack) {
const playerData = previous();
mpvPlayer.setQueue(playerData);
mpvPlayer.previous();
} else {
const playerData = setCurrentIndex(queue.length - 1);
mpvPlayer.setQueue(playerData);
mpvPlayer.previous();
}
},
web: () => {
if (isFirstTrack) {
setCurrentIndex(queue.length - 1);
resetPlayers();
} else {
previous();
resetPlayers();
}
},
};
const handleRepeatNone = {
local: () => {
const playerData = previous();
mpvPlayer.setQueue(playerData);
mpvPlayer.previous();
},
web: () => {
if (isFirstTrack) {
resetPlayers();
pause();
} else {
previous();
resetPlayers();
}
},
};
const handleRepeatOne = {
local: () => {
if (!isFirstTrack) {
const playerData = previous();
mpvPlayer.setQueue(playerData);
mpvPlayer.previous();
} else {
mpvPlayer.stop();
}
},
web: () => {
previous();
resetPlayers();
},
};
switch (repeatStatus) {
case PlayerRepeat.NONE:
handleRepeatNone[playerType]();
break;
case PlayerRepeat.ALL:
handleRepeatAll[playerType]();
break;
case PlayerRepeat.ONE:
handleRepeatOne[playerType]();
break;
default:
break;
}
return setCurrentTime(0);
}, [
checkIsFirstTrack,
currentPlayerRef,
isMpvPlayer,
pause,
playerType,
previous,
queue.length,
repeatStatus,
resetPlayers,
setCurrentIndex,
setCurrentTime,
]);
const handlePlayPause = useCallback(() => {
if (queue) {
if (playerStatus === PlayerStatus.PAUSED) {
return handlePlay();
}
return handlePause();
}
return null;
}, [handlePause, handlePlay, playerStatus, queue]);
const handleSkipBackward = (seconds: number) => {
const currentTime = isMpvPlayer
? usePlayerStore.getState().current.time
: currentPlayerRef.getCurrentTime();
if (isMpvPlayer) {
const newTime = currentTime - seconds;
mpvPlayer.seek(-seconds);
setCurrentTime(newTime < 0 ? 0 : newTime);
} else {
const newTime = currentTime - seconds;
resetNextPlayer();
setCurrentTime(newTime);
currentPlayerRef.seekTo(newTime);
}
};
const handleSkipForward = (seconds: number) => {
const currentTime = isMpvPlayer
? usePlayerStore.getState().current.time
: currentPlayerRef.getCurrentTime();
if (isMpvPlayer) {
const newTime = currentTime + seconds;
mpvPlayer.seek(seconds);
setCurrentTime(newTime);
} else {
const checkNewTime = currentTime + seconds;
const songDuration = currentPlayerRef.player.player.duration;
const newTime = checkNewTime >= songDuration ? songDuration - 1 : checkNewTime;
resetNextPlayer();
setCurrentTime(newTime);
currentPlayerRef.seekTo(newTime);
}
};
const handleSeekSlider = useCallback(
(e: number | any) => {
setCurrentTime(e);
if (isMpvPlayer) {
mpvPlayer.seekTo(e);
} else {
currentPlayerRef.seekTo(e);
}
},
[currentPlayerRef, isMpvPlayer, setCurrentTime],
);
useEffect(() => {
if (isElectron()) {
mpvPlayerListener.rendererPlayPause(() => {
handlePlayPause();
});
mpvPlayerListener.rendererNext(() => {
handleNextTrack();
});
mpvPlayerListener.rendererPrevious(() => {
handlePrevTrack();
});
mpvPlayerListener.rendererPlay(() => {
handlePlay();
});
mpvPlayerListener.rendererPause(() => {
handlePause();
});
mpvPlayerListener.rendererStop(() => {
handleStop();
});
mpvPlayerListener.rendererCurrentTime((_event: any, time: number) => {
setCurrentTime(time);
});
mpvPlayerListener.rendererAutoNext(() => {
handleAutoNext();
});
}
return () => {
ipc?.removeAllListeners('renderer-player-play-pause');
ipc?.removeAllListeners('renderer-player-next');
ipc?.removeAllListeners('renderer-player-previous');
ipc?.removeAllListeners('renderer-player-play');
ipc?.removeAllListeners('renderer-player-pause');
ipc?.removeAllListeners('renderer-player-stop');
ipc?.removeAllListeners('renderer-player-current-time');
ipc?.removeAllListeners('renderer-player-auto-next');
};
}, [
autoNext,
handleAutoNext,
handleNextTrack,
handlePause,
handlePlay,
handlePlayPause,
handlePrevTrack,
handleStop,
isMpvPlayer,
next,
pause,
play,
previous,
setCurrentTime,
]);
return {
handleNextTrack,
handlePlayPause,
handlePrevTrack,
handleSeekSlider,
handleSkipBackward,
handleSkipForward,
handleStop,
handleToggleRepeat,
handleToggleShuffle,
};
};

View file

@ -0,0 +1,44 @@
import { useEffect } from 'react';
import isElectron from 'is-electron';
import { useMuted, usePlayerControls, useVolume } from '/@/renderer/store';
const mpvPlayer = window.electron.mpvPlayer;
export const useRightControls = () => {
const { setVolume, setMuted } = usePlayerControls();
const volume = useVolume();
const muted = useMuted();
// Ensure that the mpv player volume is set on startup
useEffect(() => {
if (isElectron()) {
mpvPlayer.volume(volume);
if (muted) {
mpvPlayer.mute();
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleVolumeSlider = (e: number) => {
mpvPlayer.volume(e);
setVolume(e);
};
const handleVolumeSliderState = (e: number) => {
setVolume(e);
};
const handleMute = () => {
setMuted(!muted);
mpvPlayer.mute();
};
return {
handleMute,
handleVolumeSlider,
handleVolumeSliderState,
};
};

View file

@ -0,0 +1,23 @@
import { useState } from 'react';
import { usePlayerStore } from '/@/renderer/store';
export const useScrobble = () => {
const [isScrobbled, setIsScrobbled] = useState(false);
const currentSongDuration = usePlayerStore((state) => state.current.song?.duration);
const scrobbleAtPercentage = usePlayerStore((state) => state.settings.scrobbleAtPercentage);
console.log('currentSongDuration', currentSongDuration);
const scrobbleAtTime = (currentSongDuration * scrobbleAtPercentage) / 100;
console.log('scrobbleAtTime', scrobbleAtTime);
console.log('render');
const handleScrobble = () => {
console.log('scrobble complete');
};
return { handleScrobble, isScrobbled, setIsScrobbled };
};

View file

@ -0,0 +1,4 @@
export * from './components/center-controls';
export * from './components/left-controls';
export * from './components/playerbar';
export * from './components/slider';

View file

@ -0,0 +1,71 @@
import { controller } from '/@/renderer/api/controller';
import { jfNormalize } from '/@/renderer/api/jellyfin.api';
import { JFSong } from '/@/renderer/api/jellyfin.types';
import { ndNormalize } from '/@/renderer/api/navidrome.api';
import { NDSong } from '/@/renderer/api/navidrome.types';
import { queryKeys } from '/@/renderer/api/query-keys';
import { toast } from '/@/renderer/components';
import { queryClient } from '/@/renderer/lib/react-query';
import { useAuthStore, usePlayerStore } from '/@/renderer/store';
import { useSettingsStore } from '/@/renderer/store/settings.store';
import { PlayQueueAddOptions, LibraryItem, Play, PlaybackType } from '/@/renderer/types';
const mpvPlayer = window.electron.mpvPlayer;
export const handlePlayQueueAdd = async (options: PlayQueueAddOptions) => {
const playerType = useSettingsStore.getState().player.type;
const deviceId = useAuthStore.getState().deviceId;
const server = useAuthStore.getState().currentServer;
if (!server) return toast.error({ message: 'No server selected' });
if (options.byItemType) {
let songs = null;
if (options.byItemType.type === LibraryItem.ALBUM) {
const albumDetail = await queryClient.fetchQuery(
queryKeys.albums.detail(server?.id, { id: options.byItemType.id }),
async ({ signal }) =>
controller.getAlbumDetail({ query: { id: options.byItemType!.id }, server, signal }),
);
if (!albumDetail) return null;
switch (server?.type) {
case 'jellyfin':
songs = albumDetail.songs?.map((song) =>
jfNormalize.song(song as JFSong, server, deviceId),
);
break;
case 'navidrome':
songs = albumDetail.songs?.map((song) =>
ndNormalize.song(song as NDSong, server, deviceId),
);
break;
case 'subsonic':
break;
}
}
if (!songs) return null;
const playerData = usePlayerStore.getState().actions.addToQueue(songs, options.play);
if (options.play === Play.NEXT || options.play === Play.LAST) {
if (playerType === PlaybackType.LOCAL) {
mpvPlayer.setQueueNext(playerData);
}
}
if (options.play === Play.NOW) {
if (playerType === PlaybackType.LOCAL) {
mpvPlayer.setQueue(playerData);
mpvPlayer.play();
}
usePlayerStore.getState().actions.play();
}
}
return null;
};

View file

@ -0,0 +1,141 @@
import { useState } from 'react';
import { Stack, Group, Checkbox } from '@mantine/core';
import { Button, PasswordInput, SegmentedControl, TextInput } from '/@/renderer/components';
import { useForm } from '@mantine/form';
import { useFocusTrap } from '@mantine/hooks';
import { closeAllModals } from '@mantine/modals';
import { nanoid } from 'nanoid/non-secure';
import { jellyfinApi } from '/@/renderer/api/jellyfin.api';
import { navidromeApi } from '/@/renderer/api/navidrome.api';
import { subsonicApi } from '/@/renderer/api/subsonic.api';
import { AuthenticationResponse } from '/@/renderer/api/types';
import { toast } from '/@/renderer/components';
import { useAuthStoreActions } from '/@/renderer/store';
import { ServerType } from '/@/renderer/types';
const SERVER_TYPES = [
{ label: 'Jellyfin', value: ServerType.JELLYFIN },
{ label: 'Navidrome', value: ServerType.NAVIDROME },
{ label: 'Subsonic', value: ServerType.SUBSONIC },
];
const AUTH_FUNCTIONS = {
[ServerType.JELLYFIN]: jellyfinApi.authenticate,
[ServerType.NAVIDROME]: navidromeApi.authenticate,
[ServerType.SUBSONIC]: subsonicApi.authenticate,
};
interface AddServerFormProps {
onCancel: () => void;
}
export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
const focusTrapRef = useFocusTrap(true);
const [isLoading, setIsLoading] = useState(false);
const { addServer } = useAuthStoreActions();
const form = useForm({
initialValues: {
legacyAuth: false,
name: '',
password: '',
type: ServerType.JELLYFIN,
url: 'http://',
username: '',
},
});
const isSubmitDisabled =
!form.values.name || !form.values.url || !form.values.username || !form.values.password;
const handleSubmit = form.onSubmit(async (values) => {
const authFunction = AUTH_FUNCTIONS[values.type];
if (!authFunction) {
return toast.error({ message: 'Selected server type is invalid' });
}
try {
setIsLoading(true);
const data: AuthenticationResponse = await authFunction(values.url, {
legacy: values.legacyAuth,
password: values.password,
username: values.username,
});
addServer({
credential: data.credential,
id: nanoid(),
name: values.name,
ndCredential: data.ndCredential,
type: values.type,
url: values.url,
userId: data.userId,
username: data.username,
});
toast.success({ message: 'Server added' });
closeAllModals();
} catch (err: any) {
setIsLoading(false);
return toast.error({ message: err?.message });
}
return setIsLoading(false);
});
return (
<form onSubmit={handleSubmit}>
<Stack
ref={focusTrapRef}
m={5}
>
<SegmentedControl
data={SERVER_TYPES}
{...form.getInputProps('type')}
/>
<Group grow>
<TextInput
data-autofocus
label="Name"
{...form.getInputProps('name')}
/>
<TextInput
label="Url"
{...form.getInputProps('url')}
/>
</Group>
<TextInput
label="Username"
{...form.getInputProps('username')}
/>
<PasswordInput
label="Password"
{...form.getInputProps('password')}
/>
{form.values.type === ServerType.SUBSONIC && (
<Checkbox
label="Enable legacy authentication"
{...form.getInputProps('legacyAuth', { type: 'checkbox' })}
/>
)}
<Group position="right">
<Button
variant="subtle"
onClick={onCancel}
>
Cancel
</Button>
<Button
disabled={isSubmitDisabled}
loading={isLoading}
type="submit"
variant="filled"
>
Add
</Button>
</Group>
</Stack>
</form>
);
};

View file

@ -0,0 +1,137 @@
import { useState } from 'react';
import { Checkbox, Stack, Group } from '@mantine/core';
import { Button, PasswordInput, TextInput, toast } from '/@/renderer/components';
import { useForm } from '@mantine/form';
import { closeAllModals } from '@mantine/modals';
import { nanoid } from 'nanoid/non-secure';
import { RiInformationLine } from 'react-icons/ri';
import { jellyfinApi } from '/@/renderer/api/jellyfin.api';
import { navidromeApi } from '/@/renderer/api/navidrome.api';
import { subsonicApi } from '/@/renderer/api/subsonic.api';
import { AuthenticationResponse } from '/@/renderer/api/types';
import { useAuthStoreActions } from '/@/renderer/store';
import { ServerListItem, ServerType } from '/@/renderer/types';
interface EditServerFormProps {
isUpdate?: boolean;
onCancel: () => void;
server: ServerListItem;
}
const AUTH_FUNCTIONS = {
[ServerType.JELLYFIN]: jellyfinApi.authenticate,
[ServerType.NAVIDROME]: navidromeApi.authenticate,
[ServerType.SUBSONIC]: subsonicApi.authenticate,
};
export const EditServerForm = ({ isUpdate, server, onCancel }: EditServerFormProps) => {
const { updateServer, setCurrentServer } = useAuthStoreActions();
const [isLoading, setIsLoading] = useState(false);
const form = useForm({
initialValues: {
legacyAuth: false,
name: server?.name,
password: '',
type: server?.type,
url: server?.url,
username: server?.username,
},
});
const isSubsonic = form.values.type === ServerType.SUBSONIC;
const isSubmitDisabled =
!form.values.name || !form.values.url || !form.values.username || !form.values.password;
const handleSubmit = form.onSubmit(async (values) => {
const authFunction = AUTH_FUNCTIONS[values.type];
if (!authFunction) {
return toast.error({ message: 'Selected server type is invalid' });
}
try {
setIsLoading(true);
const data: AuthenticationResponse = await authFunction(values.url, {
legacy: values.legacyAuth,
password: values.password,
username: values.username,
});
const serverItem = {
credential: data.credential,
id: nanoid(),
name: values.name,
ndCredential: data.ndCredential,
type: values.type,
url: values.url,
userId: data.userId,
username: data.username,
};
updateServer(server.id, serverItem);
setCurrentServer(serverItem);
toast.success({ message: 'Server updated' });
} catch (err: any) {
setIsLoading(false);
return toast.error({ message: err?.message });
}
if (isUpdate) closeAllModals();
return setIsLoading(false);
});
return (
<form onSubmit={handleSubmit}>
<Stack>
<TextInput
required
label="Name"
rightSection={form.isDirty('name') && <RiInformationLine color="red" />}
{...form.getInputProps('name')}
/>
<TextInput
required
label="Url"
rightSection={form.isDirty('url') && <RiInformationLine color="red" />}
{...form.getInputProps('url')}
/>
<TextInput
label="Username"
rightSection={form.isDirty('username') && <RiInformationLine color="red" />}
{...form.getInputProps('username')}
/>
<PasswordInput
label="Password"
{...form.getInputProps('password')}
/>
{isSubsonic && (
<Checkbox
label="Enable legacy authentication"
{...form.getInputProps('legacyAuth', {
type: 'checkbox',
})}
/>
)}
<Group position="right">
<Button
variant="subtle"
onClick={onCancel}
>
Cancel
</Button>
<Button
disabled={isSubmitDisabled}
loading={isLoading}
type="submit"
variant="filled"
>
Save
</Button>
</Group>
</Stack>
</form>
);
};

View file

@ -0,0 +1,75 @@
import { Stack, Group, Divider } from '@mantine/core';
import { Button, Text, TimeoutButton } from '/@/renderer/components';
import { useDisclosure } from '@mantine/hooks';
import { RiDeleteBin2Line, RiEdit2Fill } from 'react-icons/ri';
import { EditServerForm } from '/@/renderer/features/servers/components/edit-server-form';
import { ServerSection } from '/@/renderer/features/servers/components/server-section';
import { useAuthStoreActions } from '/@/renderer/store';
import { ServerListItem as ServerItem } from '/@/renderer/types';
interface ServerListItemProps {
server: ServerItem;
}
export const ServerListItem = ({ server }: ServerListItemProps) => {
const [edit, editHandlers] = useDisclosure(false);
const { deleteServer } = useAuthStoreActions();
const handleDeleteServer = () => {
deleteServer(server.id);
};
return (
<Stack
mt="1rem"
p="1rem"
spacing="xl"
>
<ServerSection
title={
<Group position="apart">
<Text>Server details</Text>
<Group spacing="md" />
</Group>
}
>
{edit ? (
<EditServerForm
server={server}
onCancel={() => editHandlers.toggle()}
/>
) : (
<Group position="apart">
<Group>
<Stack>
<Text>URL</Text>
<Text>Username</Text>
</Stack>
<Stack>
<Text size="sm">{server.url}</Text>
<Text size="sm">{server.username}</Text>
</Stack>
</Group>
<Group>
<Button
tooltip={{ label: 'Edit server details' }}
variant="subtle"
onClick={() => editHandlers.toggle()}
>
<RiEdit2Fill />
</Button>
</Group>
</Group>
)}
</ServerSection>
<Divider my="xl" />
<TimeoutButton
leftIcon={<RiDeleteBin2Line />}
timeoutProps={{ callback: handleDeleteServer, duration: 1500 }}
variant="subtle"
>
Remove server
</TimeoutButton>
</Stack>
);
};

View file

@ -0,0 +1,66 @@
import { Group } from '@mantine/core';
import { Accordion, Button, ContextModalVars } from '/@/renderer/components';
import { openContextModal } from '@mantine/modals';
import { RiAddFill, RiServerFill } from 'react-icons/ri';
import { AddServerForm } from '/@/renderer/features/servers/components/add-server-form';
import { ServerListItem } from '/@/renderer/features/servers/components/server-list-item';
import { useServerList } from '/@/renderer/store';
import { titleCase } from '/@/renderer/utils';
export const ServerList = () => {
const serverListQuery = useServerList();
const handleAddServerModal = () => {
openContextModal({
innerProps: {
modalBody: (vars: ContextModalVars) => (
<AddServerForm onCancel={() => vars.context.closeModal(vars.id)} />
),
},
modal: 'base',
title: 'Add server',
});
};
return (
<>
<Group
mb={10}
position="right"
sx={{
position: 'absolute',
right: 55,
transform: 'translateY(-4rem)',
}}
>
<Button
autoFocus
compact
leftIcon={<RiAddFill size={15} />}
size="sm"
variant="filled"
onClick={handleAddServerModal}
>
Add server
</Button>
</Group>
<Accordion variant="separated">
{serverListQuery?.map((s) => (
<Accordion.Item
key={s.id}
value={s.name}
>
<Accordion.Control icon={<RiServerFill size={15} />}>
<Group position="apart">
{titleCase(s.type)} - {s.name}
</Group>
</Accordion.Control>
<Accordion.Panel>
<ServerListItem server={s} />
</Accordion.Panel>
</Accordion.Item>
))}
</Accordion>
</>
);
};

View file

@ -0,0 +1,24 @@
import React from 'react';
import styled from 'styled-components';
import { Text } from '/@/renderer/components';
interface ServerSectionProps {
children: React.ReactNode;
title: string | React.ReactNode;
}
const Container = styled.div``;
const Section = styled.div`
padding: 1rem;
border: 1px dashed var(--generic-border-color);
`;
export const ServerSection = ({ title, children }: ServerSectionProps) => {
return (
<Container>
{React.isValidElement(title) ? title : <Text>{title}</Text>}
<Section>{children}</Section>
</Container>
);
};

View file

@ -0,0 +1,2 @@
export * from './components/add-server-form';
export * from './components/server-list';

View file

@ -0,0 +1,258 @@
import { Divider, Stack } from '@mantine/core';
import { Select, Switch } from '/@/renderer/components';
import isElectron from 'is-electron';
import { SettingsOptions } from '/@/renderer/features/settings/components/settings-option';
import { THEME_DATA } from '/@/renderer/hooks';
import {
useGeneralSettings,
useSettingsStoreActions,
SideQueueType,
} from '/@/renderer/store/settings.store';
import { AppTheme } from '/@/renderer/themes/types';
const FONT_OPTIONS = [
{ label: 'AnekTamil', value: 'AnekTamil' },
{ label: 'Archivo', value: 'Archivo' },
{ label: 'Circular STD', value: 'Circular STD' },
{ label: 'Didact Gothic', value: 'Didact Gothic' },
{ label: 'DM Sans', value: 'DM Sans' },
{ label: 'Encode Sans', value: 'Encode Sans' },
{ label: 'Epilogue', value: 'Epilogue' },
{ label: 'Gotham', value: 'Gotham' },
{ label: 'Inconsolata', value: 'Inconsolata' },
{ label: 'Inter', value: 'Inter' },
{ label: 'JetBrains Mono', value: 'JetBrainsMono' },
{ label: 'Manrope', value: 'Manrope' },
{ label: 'Montserrat', value: 'Montserrat' },
{ label: 'Oxygen', value: 'Oxygen' },
{ label: 'Poppins', value: 'Poppins' },
{ label: 'Raleway', value: 'Raleway' },
{ label: 'Roboto', value: 'Roboto' },
{ label: 'Sora', value: 'Sora' },
{ label: 'Work Sans', value: 'Work Sans' },
];
const SIDE_QUEUE_OPTIONS = [
{ label: 'Fixed', value: 'sideQueue' },
{ label: 'Floating', value: 'sideDrawerQueue' },
];
export const GeneralTab = () => {
const settings = useGeneralSettings();
const { setSettings } = useSettingsStoreActions();
const options = [
{
control: (
<Select
disabled
data={['Windows', 'macOS']}
defaultValue="Windows"
/>
),
description: 'Adjust the style of the titlebar',
isHidden: !isElectron(),
title: 'Titlebar style',
},
{
control: (
<Select
disabled
data={[]}
/>
),
description: 'Sets the application language',
isHidden: false,
title: 'Language',
},
{
control: (
<Select
data={FONT_OPTIONS}
defaultValue={settings.fontContent}
onChange={(e) => {
if (!e) return;
setSettings({
general: {
...settings,
fontContent: e,
},
});
}}
/>
),
description: 'Sets the application content font',
isHidden: false,
title: 'Font (Content)',
},
{
control: (
<Select
data={FONT_OPTIONS}
defaultValue={settings.fontHeader}
onChange={(e) => {
if (!e) return;
setSettings({
general: {
...settings,
fontHeader: e,
},
});
}}
/>
),
description: 'Sets the application header font',
isHidden: false,
title: 'Font (Header)',
},
];
const themeOptions = [
{
control: (
<Switch
defaultChecked={settings.followSystemTheme}
onChange={(e) => {
setSettings({
general: {
...settings,
followSystemTheme: e.currentTarget.checked,
},
});
}}
/>
),
description: 'Follows the system-defined light or dark preference',
isHidden: false,
title: 'Use system theme',
},
{
control: (
<Select
data={THEME_DATA}
defaultValue={settings.theme}
onChange={(e) => {
setSettings({
general: {
...settings,
theme: e as AppTheme,
},
});
}}
/>
),
description: 'Sets the default theme',
isHidden: settings.followSystemTheme,
title: 'Theme',
},
{
control: (
<Select
data={THEME_DATA}
defaultValue={settings.themeDark}
onChange={(e) => {
setSettings({
general: {
...settings,
themeDark: e as AppTheme,
},
});
}}
/>
),
description: 'Sets the dark theme',
isHidden: !settings.followSystemTheme,
title: 'Theme (dark)',
},
{
control: (
<Select
data={THEME_DATA}
defaultValue={settings.themeLight}
onChange={(e) => {
setSettings({
general: {
...settings,
themeLight: e as AppTheme,
},
});
}}
/>
),
description: 'Sets the light theme',
isHidden: !settings.followSystemTheme,
title: 'Theme (light)',
},
];
const layoutOptions = [
{
control: (
<Select
data={SIDE_QUEUE_OPTIONS}
defaultValue={settings.sideQueueType}
onChange={(e) => {
setSettings({
general: {
...settings,
sideQueueType: e as SideQueueType,
},
});
}}
/>
),
description: 'The style of the sidebar play queue',
isHidden: false,
title: 'Side play queue style',
},
{
control: (
<Switch
defaultChecked={settings.showQueueDrawerButton}
onChange={(e) => {
setSettings({
general: {
...settings,
showQueueDrawerButton: e.currentTarget.checked,
},
});
}}
/>
),
description: 'Display a hover icon on the right side of the application view the play queue',
isHidden: false,
title: 'Show floating queue hover area',
},
];
return (
<Stack spacing="xl">
{options
.filter((o) => !o.isHidden)
.map((option) => (
<SettingsOptions
key={`general-${option.title}`}
{...option}
/>
))}
<Divider />
{themeOptions
.filter((o) => !o.isHidden)
.map((option) => (
<SettingsOptions
key={`general-${option.title}`}
{...option}
/>
))}
<Divider />
{layoutOptions
.filter((o) => !o.isHidden)
.map((option) => (
<SettingsOptions
key={`general-${option.title}`}
{...option}
/>
))}
</Stack>
);
};

View file

@ -0,0 +1,383 @@
import { useEffect, useState } from 'react';
import { Divider, Group, SelectItem, Stack } from '@mantine/core';
import {
FileInput,
NumberInput,
SegmentedControl,
Select,
Slider,
Switch,
Textarea,
Tooltip,
toast,
Text,
} from '/@/renderer/components';
import isElectron from 'is-electron';
import { SettingsOptions } from '/@/renderer/features/settings/components/settings-option';
import { useCurrentStatus, usePlayerStore } from '/@/renderer/store';
import { useSettingsStore, useSettingsStoreActions } from '/@/renderer/store/settings.store';
import { PlaybackType, PlayerStatus, PlaybackStyle, CrossfadeStyle, Play } from '/@/renderer/types';
const localSettings = window.electron.localSettings;
const mpvPlayer = window.electron.mpvPlayer;
const getAudioDevice = async () => {
const devices = await navigator.mediaDevices.enumerateDevices();
return (devices || []).filter((dev: MediaDeviceInfo) => dev.kind === 'audiooutput');
};
export const PlaybackTab = () => {
const settings = useSettingsStore((state) => state.player);
const { setSettings } = useSettingsStoreActions();
const status = useCurrentStatus();
const [audioDevices, setAudioDevices] = useState<SelectItem[]>([]);
const [mpvPath, setMpvPath] = useState('');
const [mpvParameters, setMpvParameters] = useState('');
const handleSetMpvPath = (e: File) => {
localSettings.set('mpv_path', e.path);
};
useEffect(() => {
const getMpvPath = async () => {
if (!isElectron()) return setMpvPath('');
const mpvPath = (await localSettings.get('mpv_path')) as string;
return setMpvPath(mpvPath);
};
const getMpvParameters = async () => {
if (!isElectron()) return setMpvPath('');
const mpvParametersFromSettings = (await localSettings.get('mpv_parameters')) as string[];
const mpvParameters = mpvParametersFromSettings?.join('\n');
return setMpvParameters(mpvParameters);
};
getMpvPath();
getMpvParameters();
}, []);
useEffect(() => {
const getAudioDevices = () => {
getAudioDevice()
.then((dev) => setAudioDevices(dev.map((d) => ({ label: d.label, value: d.deviceId }))))
.catch(() => toast.error({ message: 'Error fetching audio devices' }));
};
getAudioDevices();
}, []);
const playerOptions = [
{
control: (
<SegmentedControl
data={[
{
disabled: !isElectron(),
label: 'Mpv',
value: PlaybackType.LOCAL,
},
{ label: 'Web', value: PlaybackType.WEB },
]}
defaultValue={settings.type}
disabled={status === PlayerStatus.PLAYING}
onChange={(e) => {
setSettings({ player: { ...settings, type: e as PlaybackType } });
if (isElectron() && e === PlaybackType.LOCAL) {
const queueData = usePlayerStore.getState().actions.getPlayerData();
mpvPlayer.setQueue(queueData);
}
}}
/>
),
description: 'The audio player to use for playback',
isHidden: !isElectron(),
note: status === PlayerStatus.PLAYING ? 'Player must be paused' : undefined,
title: 'Audio player',
},
{
control: (
<FileInput
placeholder={mpvPath}
size="sm"
width={225}
onChange={handleSetMpvPath}
/>
),
description: 'The location of your mpv executable',
isHidden: settings.type !== PlaybackType.LOCAL,
note: 'Restart required',
title: 'Mpv executable path',
},
{
control: (
<Stack spacing="xs">
<Textarea
autosize
defaultValue={mpvParameters}
minRows={4}
placeholder={'--gapless-playback=yes\n--prefetch-playlist=yes'}
width={225}
onBlur={(e) => {
if (isElectron()) {
localSettings.set('mpv_parameters', e.currentTarget.value.split('\n'));
}
}}
/>
</Stack>
),
description: (
<Text
$noSelect
$secondary
size="sm"
>
Options to pass to the player{' '}
<a
href="https://mpv.io/manual/stable/#audio"
rel="noreferrer"
target="_blank"
>
https://mpv.io/manual/stable/#audio
</a>
</Text>
),
isHidden: settings.type !== PlaybackType.LOCAL,
note: 'Restart required',
title: 'Mpv parameters',
},
{
control: (
<Select
clearable
data={audioDevices}
defaultValue={settings.audioDeviceId}
disabled={settings.type !== PlaybackType.WEB}
onChange={(e) => setSettings({ player: { ...settings, audioDeviceId: e } })}
/>
),
description: 'The audio device to use for playback (web player only)',
isHidden: !isElectron() || settings.type !== PlaybackType.WEB,
title: 'Audio device',
},
{
control: (
<SegmentedControl
data={[
{ label: 'Normal', value: PlaybackStyle.GAPLESS },
{ label: 'Crossfade', value: PlaybackStyle.CROSSFADE },
]}
defaultValue={settings.style}
disabled={settings.type !== PlaybackType.WEB || status === PlayerStatus.PLAYING}
onChange={(e) => setSettings({ player: { ...settings, style: e as PlaybackStyle } })}
/>
),
description: 'Adjust the playback style (web player only)',
isHidden: settings.type !== PlaybackType.WEB,
note: status === PlayerStatus.PLAYING ? 'Player must be paused' : undefined,
title: 'Playback style',
},
{
control: (
<Slider
defaultValue={settings.crossfadeDuration}
disabled={
settings.type !== PlaybackType.WEB ||
settings.style !== PlaybackStyle.CROSSFADE ||
status === PlayerStatus.PLAYING
}
max={15}
min={0}
w={100}
onChangeEnd={(e) => setSettings({ player: { ...settings, crossfadeDuration: e } })}
/>
),
description: 'Adjust the crossfade duration (web player only)',
isHidden: settings.type !== PlaybackType.WEB,
note: status === PlayerStatus.PLAYING ? 'Player must be paused' : undefined,
title: 'Crossfade Duration',
},
{
control: (
<Select
data={[
{ label: 'Linear', value: CrossfadeStyle.LINEAR },
{ label: 'Constant Power', value: CrossfadeStyle.CONSTANT_POWER },
{
label: 'Constant Power (Slow cut)',
value: CrossfadeStyle.CONSTANT_POWER_SLOW_CUT,
},
{
label: 'Constant Power (Slow fade)',
value: CrossfadeStyle.CONSTANT_POWER_SLOW_FADE,
},
{ label: 'Dipped', value: CrossfadeStyle.DIPPED },
{ label: 'Equal Power', value: CrossfadeStyle.EQUALPOWER },
]}
defaultValue={settings.crossfadeStyle}
disabled={
settings.type !== PlaybackType.WEB ||
settings.style !== PlaybackStyle.CROSSFADE ||
status === PlayerStatus.PLAYING
}
width={200}
onChange={(e) => {
if (!e) return;
setSettings({
player: { ...settings, crossfadeStyle: e as CrossfadeStyle },
});
}}
/>
),
description: 'Change the crossfade algorithm (web player only)',
isHidden: settings.type !== PlaybackType.WEB,
note: status === PlayerStatus.PLAYING ? 'Player must be paused' : undefined,
title: 'Crossfade Style',
},
{
control: (
<Switch
aria-label="Toggle global media hotkeys"
defaultChecked={settings.globalMediaHotkeys}
disabled={!isElectron()}
onChange={(e) => {
setSettings({
player: {
...settings,
globalMediaHotkeys: e.currentTarget.checked,
},
});
localSettings.set('global_media_hotkeys', e.currentTarget.checked);
if (e.currentTarget.checked) {
localSettings.enableMediaKeys();
} else {
localSettings.disableMediaKeys();
}
}}
/>
),
description:
'Enable or disable the usage of your system media hotkeys to control the audio player (desktop only)',
isHidden: !isElectron(),
title: 'Global media hotkeys',
},
];
const otherOptions = [
{
control: (
<SegmentedControl
data={[
{ label: 'Now', value: Play.NOW },
{ label: 'Next', value: Play.NEXT },
{ label: 'Last', value: Play.LAST },
]}
defaultValue={settings.playButtonBehavior}
onChange={(e) =>
setSettings({
player: {
...settings,
playButtonBehavior: e as Play,
},
})
}
/>
),
description: 'The default behavior of the play button when adding songs to the queue',
isHidden: false,
title: 'Play button behavior',
},
{
control: (
<Switch
aria-label="Toggle skip buttons"
defaultChecked={settings.skipButtons?.enabled}
onChange={(e) =>
setSettings({
player: {
...settings,
skipButtons: {
...settings.skipButtons,
enabled: e.currentTarget.checked,
},
},
})
}
/>
),
description: 'Show or hide the skip buttons on the playerbar',
isHidden: false,
title: 'Show skip buttons',
},
{
control: (
<Group>
<Tooltip label="Backward">
<NumberInput
defaultValue={settings.skipButtons.skipBackwardSeconds}
min={0}
width={75}
onBlur={(e) =>
setSettings({
player: {
...settings,
skipButtons: {
...settings.skipButtons,
skipBackwardSeconds: e.currentTarget.value
? Number(e.currentTarget.value)
: 0,
},
},
})
}
/>
</Tooltip>
<Tooltip label="Forward">
<NumberInput
defaultValue={settings.skipButtons.skipForwardSeconds}
min={0}
width={75}
onBlur={(e) =>
setSettings({
player: {
...settings,
skipButtons: {
...settings.skipButtons,
skipForwardSeconds: e.currentTarget.value ? Number(e.currentTarget.value) : 0,
},
},
})
}
/>
</Tooltip>
</Group>
),
description:
'The number (in seconds) to skip forward or backward when using the skip buttons',
isHidden: false,
title: 'Skip duration',
},
];
return (
<Stack spacing="xl">
{playerOptions
.filter((o) => !o.isHidden)
.map((option) => (
<SettingsOptions
key={`playback-${option.title}`}
{...option}
/>
))}
<Divider />
{otherOptions
.filter((o) => !o.isHidden)
.map((option) => (
<SettingsOptions
key={`playerbar-${option.title}`}
{...option}
/>
))}
</Stack>
);
};

Some files were not shown because too many files have changed in this diff Show more