Add initial files

This commit is contained in:
jeffvli
2022-12-08 00:38:12 -08:00
commit 4d64a96f75
239 changed files with 45979 additions and 0 deletions
+80
View File
@@ -0,0 +1,80 @@
{
"env": {
"browser": true,
"node": false
},
"extends": ["plugin:react-hooks/recommended", "plugin:typescript-sort-keys/recommended"],
"plugins": ["react", "@typescript-eslint", "import", "sort-keys-fix"],
"root": false,
"parserOptions": {
"createDefaultProgram": true,
"parser": "@typescript-eslint/parser",
"ecmaVersion": 12,
"sourceType": "module",
"project": "./packages/renderer/tsconfig.json",
"tsconfigRootDir": "./"
},
"settings": {
"import/parsers": {
"@typescript-eslint/parser": [".ts", ".tsx"]
},
"import/resolver": {
// See https://github.com/benmosher/eslint-plugin-import/issues/1396#issuecomment-575727774 for line below
"typescript": {
"alwaysTryTypes": true,
"project": "./packages/renderer/tsconfig.json"
}
}
},
"rules": {
"@typescript-eslint/naming-convention": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-shadow": ["off"],
"import/extensions": "off",
"import/no-extraneous-dependencies": "off",
"import/no-unresolved": "error",
"import/order": [
"error",
{
"alphabetize": {
"caseInsensitive": true,
"order": "asc"
},
"groups": ["builtin", "external", "internal", ["parent", "sibling"]],
"newlines-between": "never",
"pathGroups": [
{
"group": "external",
"pattern": "react",
"position": "before"
}
],
"pathGroupsExcludedImportTypes": ["react"]
}
],
"import/prefer-default-export": "off",
"jsx-a11y/click-events-have-key-events": "off",
"jsx-a11y/interactive-supports-focus": "off",
"jsx-a11y/media-has-caption": "off",
"no-await-in-loop": "off",
"no-console": "off",
"no-nested-ternary": "off",
"no-restricted-syntax": "off",
"no-underscore-dangle": "off",
"react/jsx-props-no-spreading": "off",
"react/jsx-sort-props": [
"error",
{
"callbacksLast": true,
"ignoreCase": false,
"noSortAlphabetically": false,
"reservedFirst": true,
"shorthandFirst": true,
"shorthandLast": false
}
],
"react/react-in-jsx-scope": "off",
"sort-keys-fix/sort-keys-fix": "warn"
}
}
+20
View File
@@ -0,0 +1,20 @@
<svg width="410" height="404" viewBox="0 0 410 404" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M399.641 59.5246L215.643 388.545C211.844 395.338 202.084 395.378 198.228 388.618L10.5817 59.5563C6.38087 52.1896 12.6802 43.2665 21.0281 44.7586L205.223 77.6824C206.398 77.8924 207.601 77.8904 208.776 77.6763L389.119 44.8058C397.439 43.2894 403.768 52.1434 399.641 59.5246Z"
fill="url(#paint0_linear)"/>
<path
d="M292.965 1.5744L156.801 28.2552C154.563 28.6937 152.906 30.5903 152.771 32.8664L144.395 174.33C144.198 177.662 147.258 180.248 150.51 179.498L188.42 170.749C191.967 169.931 195.172 173.055 194.443 176.622L183.18 231.775C182.422 235.487 185.907 238.661 189.532 237.56L212.947 230.446C216.577 229.344 220.065 232.527 219.297 236.242L201.398 322.875C200.278 328.294 207.486 331.249 210.492 326.603L212.5 323.5L323.454 102.072C325.312 98.3645 322.108 94.137 318.036 94.9228L279.014 102.454C275.347 103.161 272.227 99.746 273.262 96.1583L298.731 7.86689C299.767 4.27314 296.636 0.855181 292.965 1.5744Z"
fill="url(#paint1_linear)"/>
<defs>
<linearGradient id="paint0_linear" x1="6.00017" y1="32.9999" x2="235" y2="344" gradientUnits="userSpaceOnUse">
<stop stop-color="#41D1FF"/>
<stop offset="1" stop-color="#BD34FE"/>
</linearGradient>
<linearGradient id="paint1_linear" x1="194.651" y1="8.81818" x2="236.076" y2="292.989"
gradientUnits="userSpaceOnUse">
<stop stop-color="#FFEA83"/>
<stop offset="0.0833333" stop-color="#FFDD35"/>
<stop offset="1" stop-color="#FFA800"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta content="script-src 'self' blob:" http-equiv="Content-Security-Policy">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script src="./src/index.tsx" type="module"></script>
</body>
</html>
+63
View File
@@ -0,0 +1,63 @@
import { useAuthStore } from '../store/auth.store';
import { navidromeApi } from './navidrome.api';
import type {
AlbumDetailQuery,
AlbumDetailResponse,
AlbumListParams,
AlbumListResponse,
} from './types';
export const getServerType = () => {
const server = useAuthStore.getState().currentServer;
if (!server) {
return null;
}
return server.type;
};
const getAlbumDetail = async (
query: AlbumDetailQuery,
signal?: AbortSignal,
): Promise<AlbumDetailResponse> => {
const serverType = getServerType();
if (!serverType) return null;
const functions = {
jellyfin: null,
navidrome: navidromeApi.getAlbumDetail,
subsonic: null,
};
if (functions[serverType] === null) {
return null;
}
return functions[serverType]?.(query, signal);
};
const getAlbumList = async (
params: AlbumListParams,
signal?: AbortSignal,
): Promise<AlbumListResponse> => {
const serverType = getServerType();
if (!serverType) return null;
const functions = {
jellyfin: null,
navidrome: navidromeApi.getAlbumList,
subsonic: null,
};
if (functions[serverType] === null) {
return null;
}
return functions[serverType]?.(params, signal);
};
export const apiController = {
getAlbumDetail,
getAlbumList,
};
+7
View File
@@ -0,0 +1,7 @@
import { apiController } from './controller';
import { navidromeApi } from './navidrome.api';
export const api = {
controller: apiController,
navidrome: navidromeApi,
};
+270
View File
@@ -0,0 +1,270 @@
import ky from 'ky';
import { nanoid } from 'nanoid/non-secure';
import type { ServerListItem } from '../store';
import { useAuthStore } from '../store';
import { ServerType } from '../types';
import type {
NDAlbumListResponse,
NDGenreListResponse,
NDAlbumListParams,
NDGenreListParams,
NDSongListParams,
NDArtistListResponse,
NDAuthenticate,
NDAlbum,
NDAlbumListSort,
NDAlbumDetailResponse,
NDSong,
NDSongListResponse,
} from './navidrome.types';
import { NDSortOrder } from './navidrome.types';
import type {
Album,
AlbumDetailQuery,
AlbumDetailResponse,
AlbumListParams,
AlbumListResponse,
Song,
} from './types';
import { SortOrder } from './types';
const api = ky.create({
hooks: {
afterResponse: [
(request, _options, response) => {
// const serverId = request.headers.get('--local-id');
return response;
},
],
beforeRequest: [
(request, options) => {
const { headers } = options;
console.log('headers', headers);
const { currentServer } = useAuthStore.getState();
const { ndCredential } = currentServer || {};
if (ndCredential) {
request.headers.set('x-nd-authorization', `Bearer ${ndCredential}`);
request.headers.set('--local-id', currentServer?.id || '');
}
},
],
},
});
const authenticate = async (options: { password: string; url: string; username: string }) => {
const { password, url, username } = options;
const cleanServerUrl = url.replace(/\/$/, '');
const data = await ky
.post(`${cleanServerUrl}/auth/login`, {
json: {
password,
username,
},
})
.json<NDAuthenticate>();
return {
credential: `u=${options.username}&s=${data.subsonicSalt}&t=${data.subsonicToken}`,
ndCredential: data.token,
username: data.username,
};
};
const getGenreList = async (params?: NDGenreListParams) => {
const data = await api
.get('api/genre', {
prefixUrl: useAuthStore.getState().currentServer?.url,
searchParams: params,
})
.json<NDGenreListResponse>();
return data;
};
const getArtistList = async (params?: NDGenreListParams) => {
const data = await api
.get('api/artist', {
prefixUrl: useAuthStore.getState().currentServer?.url,
searchParams: params,
})
.json<NDArtistListResponse>();
return data;
};
const getAlbumDetail = async (query: AlbumDetailQuery, signal?: AbortSignal) => {
const albumDetail = await api
.get(`api/album/${query.id}`, {
prefixUrl: useAuthStore.getState().currentServer?.url,
signal,
})
.json<NDAlbumDetailResponse>();
const albumSongs = await api
.get('api/song/', {
prefixUrl: useAuthStore.getState().currentServer?.url,
searchParams: {
_end: 0,
_order: NDSortOrder.ASC,
_sort: 'album',
_start: 0,
album_id: query.id,
},
})
.json<NDSongListResponse>();
return { ...albumDetail, songs: albumSongs } as AlbumDetailResponse;
};
const getAlbumList = async (params: AlbumListParams, signal?: AbortSignal) => {
const searchParams: NDAlbumListParams = {
_end: params._skip + (params._take || 0),
_order: params.sortOrder === SortOrder.ASC ? NDSortOrder.ASC : NDSortOrder.DESC,
_sort: params.sortBy as NDAlbumListSort,
_start: params._skip,
...params.nd,
};
const res = await api.get('api/album', {
prefixUrl: useAuthStore.getState().currentServer?.url,
searchParams,
signal,
});
const itemCount = res.headers.get('X-Total-Count');
const data = await res.json<NDAlbumListResponse>();
return {
items: data,
pagination: {
startIndex: params?._skip || 0,
totalEntries: Number(itemCount),
},
} as AlbumListResponse;
};
const getSongList = async (params?: NDSongListParams) => {
const data = await api
.get('api/song', {
prefixUrl: useAuthStore.getState().currentServer?.url,
searchParams: params,
})
.json<NDSongListResponse>();
return data;
};
export const navidromeApi = {
authenticate,
getAlbumDetail,
getAlbumList,
getArtistList,
getGenreList,
getSongList,
};
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) => {
const imageUrl = getCoverArtUrl({
baseUrl: server.url,
coverArtId: item.coverArtId,
credential: server.credential,
size: imageSize || 300,
});
const imagePlaceholderUrl = imageUrl?.replace(/size=\d+/, 'size=50');
return {
albumArtists: [{ id: item.albumArtistId, name: item.albumArtist }],
artists: [{ id: item.artistId, name: item.artist }],
backdropImageUrl: null,
createdAt: item.createdAt,
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,
} as Album;
};
const normalizeSong = (
item: NDSong,
server: ServerListItem,
deviceId: string,
imageSize?: number,
) => {
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,
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,
} as Song;
};
export const ndNormalize = {
album: normalizeAlbum,
song: normalizeSong,
};
@@ -0,0 +1,184 @@
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;
} & {
songs?: NDSong[];
};
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 NDArtist = {
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 NDGenreListResponse = NDGenre[];
export type NDAlbumDetailResponse = NDAlbum;
export type NDAlbumListResponse = NDAlbum[];
export type NDSongListResponse = NDSong[];
export type NDArtistListResponse = NDArtist[];
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',
RANDOM = 'random',
RATING = 'rating',
RECENTLY_ADDED = 'recently_added',
SONG_COUNT = 'songCount',
STARRED = 'starred',
YEAR = 'max_year',
}
export type NDAlbumListParams = {
_sort?: NDAlbumListSort;
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 type NDSongListParams = {
genre_id?: string;
starred?: boolean;
} & NDPagination &
NDOrder;
+33
View File
@@ -0,0 +1,33 @@
import type { AlbumListParams } from './types';
import type { AlbumDetailQuery } from './types';
export const queryKeys = {
albums: {
detail: (serverId: string, query: AlbumDetailQuery) => ['albums', serverId, query] as const,
list: (serverId: string, params: AlbumListParams) =>
[serverId, 'albums', 'list', serverId, params] as const,
root: ['albums'],
},
genres: {
list: (serverId: string) => [serverId, 'genres', 'list'] as const,
root: (serverId: string) => [serverId, 'genres'] as const,
},
ping: (url: string) => ['ping', url] as const,
server: {
root: (serverId: string) => [serverId] as const,
},
servers: {
list: (params?: any) => ['servers', 'list', params] as const,
map: () => ['servers', 'map'] as const,
root: ['servers'],
},
tasks: {
list: () => ['tasks', 'list'] as const,
root: ['tasks'],
},
users: {
detail: (userId: string) => ['users', userId] as const,
list: (params?: any) => ['users', 'list', params] as const,
root: ['users'],
},
};
+155
View File
@@ -0,0 +1,155 @@
import axios from 'axios';
import md5 from 'md5';
import { ServerType } from '/@/types';
import { randomString } from '/@/utils';
type JFAuthenticate = {
AccessToken: string;
ServerId: string;
SessionInfo: any;
User: any;
};
export const jfAuthenticate = async (options: {
password: string;
url: string;
username: string;
}) => {
const { password, url, username } = options;
const cleanServerUrl = url.replace(/\/$/, '');
const { data } = await axios.post<JFAuthenticate>(
`${cleanServerUrl}/users/authenticatebyname`,
{ pw: password, username },
{
headers: {
'X-Emby-Authorization':
'MediaBrowser Client="Feishin", Device="PC", DeviceId="Feishin", Version="0.0.1-alpha1"',
},
},
);
return data;
};
type NDAuthenticate = {
id: string;
isAdmin: boolean;
name: string;
subsonicSalt: string;
subsonicToken: string;
token: string;
username: string;
};
const ndAuthenticate = async (options: { password: string; url: string; username: string }) => {
const { password, url, username } = options;
const cleanServerUrl = url.replace(/\/$/, '');
const { data } = await axios.post<NDAuthenticate>(`${cleanServerUrl}/auth/login`, {
password,
username,
});
return data;
};
const ssAuthenticate = async (options: {
legacy?: boolean;
password: string;
url: string;
username: string;
}) => {
let token;
const cleanServerUrl = options.url.replace(/\/$/, '');
if (options.legacy) {
token = `u=${options.username}&p=${options.password}`;
} else {
const salt = randomString();
const hash = md5(options.password + salt);
token = `u=${options.username}&s=${salt}&t=${hash}`;
}
const { data } = await axios.get(
`${cleanServerUrl}/rest/ping.view?v=1.13.0&c=Feishin&f=json&${token}`,
);
return { token, ...data };
};
export const remoteServerLogin = async (options: {
legacy?: boolean;
password: string;
type: ServerType;
url: string;
username: string;
}) => {
if (options.type === ServerType.JELLYFIN) {
try {
const res = await jfAuthenticate({
password: options.password,
url: options.url,
username: options.username,
});
return {
remoteUserId: res.User.Id,
token: res.AccessToken,
type: ServerType.JELLYFIN,
url: options.url,
username: options.username,
};
} catch (err: any) {
return { message: err.message, type: 'error' };
}
}
if (options.type === ServerType.SUBSONIC) {
const res = await ssAuthenticate({
legacy: options.legacy,
password: options.password,
url: options.url,
username: options.username,
});
if (res.status === 'failed') {
return {
message: 'Could not validate username and password',
type: 'error',
};
}
return {
remoteUserId: '',
token: res.token,
type: ServerType.SUBSONIC,
url: options.url,
username: options.username,
};
}
if (options.type === ServerType.NAVIDROME) {
try {
const res = await ndAuthenticate({
password: options.password,
url: options.url,
username: options.username,
});
return {
remoteUserId: res.id,
token: `u=${res.name}&s=${res.subsonicSalt}&t=${res.subsonicToken}`,
// token: res.token,
type: ServerType.NAVIDROME,
url: options.url,
username: options.username,
};
} catch (err: any) {
return { message: err.message, type: 'error' };
}
}
return { message: 'Not found', type: 'error' };
};
+170
View File
@@ -0,0 +1,170 @@
import axios from 'axios';
import md5 from 'md5';
import { randomString } from '../utils/random-string';
import type {
SSAlbumListEntry,
SSAlbumListResponse,
SSAlbumResponse,
SSAlbumsParams,
SSArtistIndex,
SSArtistInfoResponse,
SSArtistsResponse,
SSGenresResponse,
SSMusicFoldersResponse,
} from './subsonic.types';
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 = axios.create({
validateStatus: (status) => status >= 200,
});
api.interceptors.response.use(
(res: any) => {
res.data = res.data['subsonic-response'];
return res;
},
(err: any) => {
return Promise.reject(err);
},
);
const authenticate = async (options: {
legacy?: boolean;
password: string;
url: string;
username: string;
}) => {
let token;
const cleanServerUrl = options.url.replace(/\/$/, '');
if (options.legacy) {
token = `u=${options.username}&p=${options.password}`;
} else {
const salt = randomString(12);
const hash = md5(options.password + salt);
token = `u=${options.username}&s=${salt}&t=${hash}`;
}
const { data } = await api.get(
`${cleanServerUrl}/rest/ping.view?v=1.13.0&c=Feishin&f=json&${token}`,
);
return { token, ...data };
};
const getMusicFolders = async (server: Partial<Server>) => {
const { data } = await api.get<SSMusicFoldersResponse>(
`${server.url}/rest/getMusicFolders.view?v=1.13.0&c=Feishin&f=json&${server.token}`,
);
return data.musicFolders.musicFolder;
};
const getArtists = async (server: Server, musicFolderId: string) => {
const { data } = await api.get<SSArtistsResponse>(
`${server.url}/rest/getArtists.view?v=1.13.0&c=Feishin&f=json&${server.token}`,
{ params: { musicFolderId } },
);
const artists = (data.artists?.index || []).flatMap((index: SSArtistIndex) => index.artist);
return artists;
};
const getGenres = async (server: Server) => {
const { data: genres } = await api.get<SSGenresResponse>(
`${server.url}/rest/getGenres.view?v=1.13.0&c=Feishin&f=json&${server.token}`,
);
return genres;
};
const getAlbum = async (server: Server, id: string) => {
const { data: album } = await api.get<SSAlbumResponse>(
`${server.url}/rest/getAlbum.view?v=1.13.0&c=Feishin&f=json&${server.token}`,
{ params: { id } },
);
return album;
};
const getAlbums = async (server: Server, params: SSAlbumsParams, recursiveData: any[] = []) => {
const albums: any = api
.get<SSAlbumListResponse>(
`${server.url}/rest/getAlbumList2.view?v=1.13.0&c=Feishin&f=json&${server.token}`,
{ params },
)
.then((res) => {
if (!res.data.albumList2?.album || res.data.albumList2?.album?.length === 0) {
// Flatten and return once there are no more albums left
return recursiveData.flatMap((album) => album);
}
// On every iteration, push the existing combined album array and increase the offset
recursiveData.push(res.data.albumList2.album);
return getAlbums(
server,
{
musicFolderId: params.musicFolderId,
offset: (params.offset || 0) + (params.size || 0),
size: params.size,
type: 'newest',
},
recursiveData,
);
})
.catch((err) => console.log(err));
return albums as SSAlbumListEntry[];
};
const getArtistInfo = async (server: Server, id: string) => {
const { data: artistInfo } = await api.get<SSArtistInfoResponse>(
`${server.url}/rest/getArtistInfo2.view?v=1.13.0&c=Feishin&f=json&${server.token}`,
{ params: { id } },
);
return {
...artistInfo,
artistInfo2: {
...artistInfo.artistInfo2,
biography: artistInfo.artistInfo2.biography
.replaceAll(/<a target.*<\/a>/gm, '')
.replace('Biography not available', ''),
},
};
};
export const subsonicApi = {
authenticate,
getAlbum,
getAlbums,
getArtistInfo,
getArtists,
getCoverArtUrl,
getGenres,
getMusicFolders,
};
+139
View File
@@ -0,0 +1,139 @@
export interface SSBaseResponse {
serverVersion?: 'string';
status: 'string';
type?: 'string';
version: 'string';
}
export interface SSMusicFoldersResponse extends SSBaseResponse {
musicFolders: {
musicFolder: SSMusicFolder[];
};
}
export interface SSGenresResponse extends SSBaseResponse {
genres: {
genre: SSGenre[];
};
}
export interface SSArtistsResponse extends SSBaseResponse {
artists: {
ignoredArticles: string;
index: SSArtistIndex[];
lastModified: number;
};
}
export interface SSAlbumListResponse extends SSBaseResponse {
albumList2: {
album: SSAlbumListEntry[];
};
}
export interface SSAlbumResponse extends SSBaseResponse {
album: SSAlbum;
}
export interface SSArtistInfoResponse extends SSBaseResponse {
artistInfo2: SSArtistInfo;
}
export interface SSArtistInfo {
biography: string;
largeImageUrl?: string;
lastFmUrl?: string;
mediumImageUrl?: string;
musicBrainzId?: string;
smallImageUrl?: string;
}
export interface SSMusicFolder {
id: number;
name: string;
}
export interface SSGenre {
albumCount?: number;
songCount?: number;
value: string;
}
export interface SSArtistIndex {
artist: SSArtistListEntry[];
name: string;
}
export interface SSArtistListEntry {
albumCount: string;
artistImageUrl?: string;
coverArt?: string;
id: string;
name: string;
}
export interface 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 interface SSAlbum extends SSAlbumListEntry {
song: SSSong[];
}
export interface 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 interface SSAlbumsParams {
fromYear?: number;
genre?: string;
musicFolderId?: string;
offset?: number;
size?: number;
toYear?: number;
type: string;
}
export interface SSArtistsParams {
musicFolderId?: number;
}
+261
View File
@@ -0,0 +1,261 @@
import type { ServerListItem } from '../store';
import type { ServerType } from '../types';
import type { JFAlbum, JFAlbumListSort, JFSortOrder } from './jellyfin.types';
import type { NDAlbum, NDAlbumListSort, NDOrder } from './navidrome.types';
export enum SortOrder {
ASC = 'ASC',
DESC = 'DESC',
}
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 enum TaskType {
FULL_SCAN = 'FULL_SCAN',
LASTFM = 'LASTFM',
MUSICBRAINZ = 'MUSICBRAINZ',
QUICK_SCAN = 'QUICK_SCAN',
REFRESH = 'REFRESH',
SPOTIFY = 'SPOTIFY',
}
export type EndpointDetails = {
server: ServerListItem;
};
// export interface BaseResponse<T> {
// error?: string | any;
// items: T;
// response: 'Success' | 'Error';
// statusCode: number;
// }
export interface BasePaginatedResponse<T> {
error?: string | any;
items: T;
pagination?: {
startIndex: number;
totalEntries: number;
};
}
export type ApiError = {
error: {
message: string;
path: string;
trace: string[];
};
response: string;
statusCode: number;
};
export type AuthResponse = {
credential: string;
ndCredential?: string;
username: string;
};
// export type NullResponse = BaseResponse<null>;
export type PaginationParams = {
skip: number;
take: number;
};
export type RelatedServerFolder = {
enabled: boolean;
id: string;
lastScannedAt: string | null;
name: string;
};
export type ServerFolder = {
createdAt: string;
enabled: boolean;
id: string;
lastScannedAt: string | null;
name: string;
serverId: string;
updatedAt: string;
};
export type Genre = {
albumArtistCount: number;
albumCount: number;
artistCount: number;
id: string;
name: string;
songCount: number;
totalCount: number;
};
export type RelatedGenre = {
id: string;
name: string;
};
export type Album = {
albumArtists: RelatedArtist[];
artists: RelatedArtist[];
backdropImageUrl: string | null;
createdAt: string;
genres: RelatedGenre[];
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;
createdAt: string;
discNumber: number;
duration: number;
genres: RelatedGenre[];
id: string;
imageUrl: string;
isFavorite: boolean;
name: string;
releaseDate: string;
releaseYear: string;
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 RelatedServer = {
id: string;
name: string;
type: ServerType;
url: string;
};
export type RelatedUser = {
enabled: boolean;
id: string;
isAdmin: boolean;
username: string;
};
export type Task = {
createdAt: string;
id: string;
isCompleted: boolean;
isError: boolean;
message: string;
server: RelatedServer | null;
type: TaskType;
updatedAt: string;
user: RelatedUser | null;
};
export type AlbumListSort = NDAlbumListSort | JFAlbumListSort;
export type ListSortOrder = NDOrder | JFSortOrder;
export type AlbumListParams = {
_skip: number;
_take?: number;
musicFolderId: string | null;
nd?: {
artist_id?: string;
compilation?: boolean;
genre_id?: string;
has_rating?: boolean;
starred?: boolean;
year?: number;
};
sortBy: NDAlbumListSort | JFAlbumListSort;
sortOrder: SortOrder;
};
export type AlbumListResponse =
| BasePaginatedResponse<Album[] | NDAlbum[] | JFAlbum[]>
| null
| undefined;
export type AlbumDetailQuery = {
id: string;
};
export type AlbumDetailResponse = Album | NDAlbum | JFAlbum | null | undefined;
export type Count = {
artists?: number;
externals?: number;
favorites?: number;
genres?: number;
images?: number;
ratings?: number;
songs?: number;
};
+95
View File
@@ -0,0 +1,95 @@
import { useEffect } from 'react';
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';
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={{
colorScheme: theme as 'light' | 'dark',
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: 13,
xl: 18,
xs: 12,
},
other: {},
spacing: {
lg: 12,
md: 8,
sm: 4,
xl: 16,
xs: 2,
},
}}
>
<NotificationsProvider
autoClose={1500}
position="bottom-right"
style={{
marginBottom: '90px',
opacity: '.8',
userSelect: 'none',
width: '250px',
}}
transitionDuration={200}
>
<ModalsProvider
modalProps={{
centered: true,
exitTransitionDuration: 200,
overflow: 'inside',
// overlayBlur: 0,
overlayOpacity: 0.5,
transition: 'pop',
transitionDuration: 200,
}}
modals={{ base: BaseContextModal }}
>
<AppRouter />
</ModalsProvider>
</NotificationsProvider>
</MantineProvider>
</QueryClientProvider>
);
};
@@ -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;
@@ -0,0 +1,188 @@
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 '/@/api/types';
import { crossfadeHandler, gaplessHandler } from '/@/components/audio-player/utils/list-handlers';
import { useSettingsStore } from '/@/store/settings.store';
import type { CrossfadeStyle } from '/@/types';
import { PlaybackStyle, PlayerStatus } from '/@/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}
/>
</>
);
},
);
@@ -0,0 +1,131 @@
/* eslint-disable no-nested-ternary */
import type { Dispatch } from 'react';
import type { CrossfadeStyle } from '/@/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;
};
@@ -0,0 +1,287 @@
import type { Ref } from 'react';
import { useRef } from 'react';
import { useCallback, useState } from 'react';
import React, { 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 '/@/components/spinner';
import { Tooltip } from '/@/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 '';
}
}};
font-weight: normal;
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 {
margin-right: 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>
);
};
@@ -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,
};
@@ -0,0 +1,114 @@
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);
background-color: var(--dropdown-menu-bg);
&: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 {
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;
@@ -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;
+23
View File
@@ -0,0 +1,23 @@
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';
@@ -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,
};
@@ -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,
};
@@ -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>;
};
@@ -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;
@@ -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: 8px;
padding: 0;
background: var(--scrollbar-track-bg);
}
`;
export const ScrollArea = ({ children, ...props }: ScrollAreaProps) => {
return (
<StyledScrollArea
offsetScrollbars
{...props}
>
{children}
</StyledScrollArea>
);
};
@@ -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}
/>
);
},
);
@@ -0,0 +1,147 @@
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-select-bg);
}
& .mantine-Select-disabled {
background: var(--input-select-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',
},
itemsWrapper: {
background: 'var(--dropdown-menu-bg)',
},
}}
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,
};
@@ -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} />;
};
@@ -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} />;
};
@@ -0,0 +1,23 @@
import type { IconType } from 'react-icons';
import { RiLoader5Fill } from 'react-icons/ri';
import styled from 'styled-components';
import { rotating } from '/@/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,
};
@@ -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} />;
};
@@ -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;
@@ -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 '/@/styles';
import { textEllipsis } from '/@/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' : 'default')};
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,
};
@@ -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 }),
};
@@ -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,
};
@@ -0,0 +1,337 @@
import React from 'react';
import { Center, Skeleton } 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 '/@/components/text';
import type { PlayQueueAddOptions, LibraryItem, CardRow, CardRoute, Play } from '/@/types';
import GridCardControls from './grid-card-controls';
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);
`;
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: {
cardControls: any[];
cardRows: CardRow[];
handlePlayQueueAdd: (options: PlayQueueAddOptions) => void;
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, isScrolling } = 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>
{!isScrolling && (
<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>
);
};
@@ -0,0 +1,200 @@
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 '/@/components/button';
import { DropdownMenu } from '/@/components/dropdown-menu';
import type { LibraryItem } from '/@/types';
import { Play } from '/@/types';
import { useSettingsStore } from '/@/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)`
fill: white !important;
svg: {
fill: white !important;
}
`;
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('/@/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
// initial="initial"
// variants={buttonVariants}
// whileHover="hover"
// whileTap="pressed"
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}
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) => 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;
@@ -0,0 +1,65 @@
import { useMemo } from 'react';
import type { ListChildComponentProps } from 'react-window';
import { DefaultCard } from '/@/components/virtual-grid/grid-card/default-card';
import { PosterCard } from '/@/components/virtual-grid/grid-card/poster-card';
import type { GridCardData } from '/@/types';
import { CardDisplayType } from '/@/types';
export const GridCard = ({ data, index, style, isScrolling }: ListChildComponentProps) => {
const {
itemHeight,
itemWidth,
columnCount,
itemGap,
itemCount,
cardControls,
handlePlayQueueAdd,
cardRows,
itemData,
itemType,
playButtonBehavior,
route,
display,
} = data as GridCardData;
const cards = [];
const startIndex = useMemo(() => index * columnCount, [columnCount, index]);
const stopIndex = useMemo(
() => Math.min(itemCount - 1, startIndex + columnCount - 1),
[columnCount, itemCount, startIndex],
);
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={{
cardControls,
cardRows,
handlePlayQueueAdd,
itemType,
playButtonBehavior,
route,
}}
data={itemData[i]}
listChildProps={{ index, isScrolling }}
sizes={{ itemGap, itemHeight, itemWidth }}
/>,
);
}
return (
<div
style={{
...style,
alignItems: 'center',
display: 'flex',
justifyContent: 'start',
}}
>
{cards}
</div>
);
};
@@ -0,0 +1,333 @@
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 '/@/components/skeleton';
import { Text } from '/@/components/text';
import type { PlayQueueAddOptions, LibraryItem, CardRow, CardRoute, Play } from '/@/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[];
handlePlayQueueAdd: (options: PlayQueueAddOptions) => void;
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)'}
// placeholder={data?.imagePlaceholderUrl || '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>
)}
{!listChildProps.isScrolling && (
<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>
);
};
@@ -0,0 +1,2 @@
export * from './virtual-grid-wrapper';
export * from './virtual-infinite-grid';
@@ -0,0 +1,94 @@
import type { Ref } from 'react';
import { useMemo } from 'react';
import type { FixedSizeListProps } from 'react-window';
import { FixedSizeList } from 'react-window';
import styled from 'styled-components';
import { GridCard } from '/@/components/virtual-grid/grid-card';
import type { CardRow, LibraryItem, CardDisplayType, CardRoute } from '/@/types';
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 = useMemo(
() => ({
cardRows,
columnCount,
display,
itemCount,
itemData,
itemGap,
itemHeight,
itemType,
itemWidth,
route,
}),
[
cardRows,
itemType,
columnCount,
itemCount,
itemData,
display,
itemGap,
itemHeight,
route,
itemWidth,
],
);
return (
<FixedSizeList
ref={refInstance}
{...rest}
useIsScrolling
initialScrollOffset={initialScrollOffset}
itemCount={rowCount}
itemData={memoizedItemData}
itemSize={itemHeight}
overscanCount={5}
onScroll={onScroll}
>
{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;
`;
@@ -0,0 +1,145 @@
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 '/@/components/virtual-grid/virtual-grid-wrapper';
import type { CardRoute, CardRow, LibraryItem } from '/@/types';
import { CardDisplayType } from '/@/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;
}
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((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, 400);
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
useIsScrolling
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,
};
@@ -0,0 +1,47 @@
import React from 'react';
import type { ICellRendererParams } from 'ag-grid-community';
import { generatePath } from 'react-router';
import { Link } from 'react-router-dom';
import type { AlbumArtist, Artist } from '/@/api/types';
import { Text } from '/@/components/text';
import { CellContainer } from '/@/components/virtual-table/cells/generic-cell';
import { AppRoute } from '/@/router/routes';
export const AlbumArtistCell = ({ value, data }: ICellRendererParams) => {
return (
<CellContainer position="left">
<Text
$secondary
overflow="hidden"
size="xs"
>
{value?.map((item: Artist | AlbumArtist, index: number) => (
<React.Fragment key={`row-${item.id}-${data.uniqueId}`}>
{index > 0 && (
<Text
$link
$secondary
size="xs"
style={{ display: 'inline-block' }}
>
,
</Text>
)}{' '}
<Text
$link
$secondary
component={Link}
overflow="hidden"
size="xs"
to={generatePath(AppRoute.LIBRARY_ALBUMARTISTS_DETAIL, {
albumArtistId: item.id,
})}
>
{item.name || '—'}
</Text>
</React.Fragment>
))}
</Text>
</CellContainer>
);
};
@@ -0,0 +1,47 @@
import React from 'react';
import type { ICellRendererParams } from 'ag-grid-community';
import { generatePath } from 'react-router';
import { Link } from 'react-router-dom';
import type { AlbumArtist, Artist } from '/@/api/types';
import { Text } from '/@/components/text';
import { CellContainer } from '/@/components/virtual-table/cells/generic-cell';
import { AppRoute } from '/@/router/routes';
export const ArtistCell = ({ value, data }: ICellRendererParams) => {
return (
<CellContainer position="left">
<Text
$secondary
overflow="hidden"
size="xs"
>
{value?.map((item: Artist | AlbumArtist, index: number) => (
<React.Fragment key={`row-${item.id}-${data.uniqueId}`}>
{index > 0 && (
<Text
$link
$secondary
size="xs"
style={{ display: 'inline-block' }}
>
,
</Text>
)}{' '}
<Text
$link
$secondary
component={Link}
overflow="hidden"
size="xs"
to={generatePath(AppRoute.LIBRARY_ARTISTS_DETAIL, {
artistId: item.id,
})}
>
{item.name || '—'}
</Text>
</React.Fragment>
))}
</Text>
</CellContainer>
);
};
@@ -0,0 +1,99 @@
import React, { useMemo } from 'react';
import type { ICellRendererParams } from 'ag-grid-community';
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 '/@/api/types';
import { Text } from '/@/components/text';
import { AppRoute } from '/@/router/routes';
import { ServerType } from '/@/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="xs"
>
{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="xs"
sx={{ width: 'fit-content' }}
to={generatePath(AppRoute.LIBRARY_ALBUMARTISTS_DETAIL, {
albumArtistId: artist.id,
})}
>
{artist.name}
</Text>
</React.Fragment>
))
) : (
<Text $secondary></Text>
)}
</Text>
</MetadataWrapper>
</CellContainer>
);
};
@@ -0,0 +1,63 @@
import type { ICellRendererParams } from 'ag-grid-community';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import { Text } from '/@/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="xs"
to={displayedValue.link}
>
{isLink ? displayedValue.value : displayedValue}
</Text>
) : (
<Text
$secondary={!primary}
overflow="hidden"
size="xs"
>
{displayedValue}
</Text>
)}
</CellContainer>
);
};
GenericCell.defaultProps = {
position: undefined,
};
@@ -0,0 +1,43 @@
import React from 'react';
import type { ICellRendererParams } from 'ag-grid-community';
import { Link } from 'react-router-dom';
import type { AlbumArtist, Artist } from '/@/api/types';
import { Text } from '/@/components/text';
import { CellContainer } from '/@/components/virtual-table/cells/generic-cell';
export const GenreCell = ({ value, data }: ICellRendererParams) => {
return (
<CellContainer position="left">
<Text
$secondary
overflow="hidden"
size="xs"
>
{value?.map((item: Artist | AlbumArtist, index: number) => (
<React.Fragment key={`row-${item.id}-${data.uniqueId}`}>
{index > 0 && (
<Text
$link
$secondary
size="xs"
style={{ display: 'inline-block' }}
>
,
</Text>
)}{' '}
<Text
$link
$secondary
component={Link}
overflow="hidden"
size="xs"
to="/"
>
{item.name || '—'}
</Text>
</React.Fragment>
))}
</Text>
</CellContainer>
);
};
@@ -0,0 +1,10 @@
import type { IHeaderParams } from 'ag-grid-community';
import { FiClock } from 'react-icons/fi';
export interface ICustomHeaderParams extends IHeaderParams {
menuIcon: string;
}
export const DurationHeader = () => {
return <FiClock size={15} />;
};
@@ -0,0 +1,44 @@
import type { ReactNode } from 'react';
import type { IHeaderParams } from 'ag-grid-community';
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,
};
@@ -0,0 +1,199 @@
import type { Ref } from 'react';
import { forwardRef, useRef } from 'react';
import { useClickOutside, useMergedRef } from '@mantine/hooks';
import type {
ICellRendererParams,
ValueGetterParams,
IHeaderParams,
ValueFormatterParams,
ColDef,
} from 'ag-grid-community';
import type { AgGridReactProps } from 'ag-grid-react';
import { AgGridReact } from 'ag-grid-react';
import type { AgGridReact as AgGridReactType } from 'ag-grid-react/lib/agGridReact';
import formatDuration from 'format-duration';
import { generatePath } from 'react-router';
import styled from 'styled-components';
import { AlbumArtistCell } from '/@/components/virtual-table/cells/album-artist-cell';
import { ArtistCell } from '/@/components/virtual-table/cells/artist-cell';
import { CombinedTitleCell } from '/@/components/virtual-table/cells/combined-title-cell';
import { GenericCell } from '/@/components/virtual-table/cells/generic-cell';
import { GenreCell } from '/@/components/virtual-table/cells/genre-cell';
import { GenericTableHeader } from '/@/components/virtual-table/headers/generic-table-header';
import { AppRoute } from '/@/router/routes';
import type { PersistedTableColumn } from '/@/store/settings.store';
import { TableColumn } from '/@/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);
const tableContainerRef = useClickOutside(() => {
if (tableRef?.current) {
tableRef?.current.api.deselectAll();
}
});
return (
<TableWrapper
ref={tableContainerRef}
className="ag-theme-alpine-dark"
>
<AgGridReact
ref={mergedRef}
suppressMoveWhenRowDragging
suppressScrollOnNewData
rowBuffer={30}
{...rest}
/>
</TableWrapper>
);
},
);
@@ -0,0 +1,214 @@
import type { ChangeEvent } from 'react';
import { Stack } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import type { Variants } from 'framer-motion';
import { motion } from 'framer-motion';
import { RiListSettingsLine } from 'react-icons/ri';
import styled from 'styled-components';
import { Button } from '/@/components/button';
import { Popover } from '/@/components/popover';
import { MultiSelect } from '/@/components/select';
import { Slider } from '/@/components/slider';
import { Switch } from '/@/components/switch';
import { Text } from '/@/components/text';
import { useSettingsStore } from '/@/store/settings.store';
import type { TableType } from '/@/types';
import { TableColumn } from '/@/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 },
];
const Container = styled(motion.div)`
position: absolute;
right: 0;
bottom: 0;
z-index: 500;
`;
interface TableConfigDropdownProps {
type: TableType;
}
export const TableConfigDropdown = ({ type }: TableConfigDropdownProps) => {
const setSettings = useSettingsStore((state) => state.setSettings);
const tableConfig = useSettingsStore((state) => state.tables);
const [opened, handlers] = useDisclosure(false);
const containerVariants: Variants = {
animate: {
opacity: 0.2,
},
initial: {
opacity: 0,
},
};
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 (
<Container
animate="animate"
initial="initial"
variants={containerVariants}
whileHover={{ opacity: 1 }}
>
<Popover
opened={opened}
position="top-start"
withArrow={false}
>
<Popover.Target>
<Button
compact
variant="subtle"
onClick={() => handlers.toggle()}
>
<RiListSettingsLine size={20} />
</Button>
</Popover.Target>
<Popover.Dropdown>
<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>
</Popover.Dropdown>
</Popover>
</Container>
);
};
@@ -0,0 +1,26 @@
import { Stack, Group } from '@mantine/core';
import { RiAlertFill } from 'react-icons/ri';
import { Text } from '/@/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>
);
@@ -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 '/@/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>
);
};
@@ -0,0 +1,42 @@
import { useEffect, useState } from 'react';
import isElectron from 'is-electron';
import { FileInput, Text, Button } from '/@/components';
import { localSettings } from '#preload';
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>
</>
);
};
@@ -0,0 +1,18 @@
import { Text } from '/@/components';
import { useCurrentServer } from '/@/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>
</>
);
};
@@ -0,0 +1,10 @@
import { Text } from '/@/components';
export const ServerRequired = () => {
return (
<>
<Text size="xl">No server selected.</Text>
<Text>Add or select a server in the file menu.</Text>
</>
);
};
@@ -0,0 +1,3 @@
export * from './routes/action-required-route';
export * from './routes/invalid-route';
export * from './components/error-fallback';
@@ -0,0 +1,96 @@
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 '/@/components';
import { ActionRequiredContainer } from '/@/features/action-required/components/action-required-container';
import { MpvRequired } from '/@/features/action-required/components/mpv-required';
import { ServerCredentialRequired } from '/@/features/action-required/components/server-credential-required';
import { ServerRequired } from '/@/features/action-required/components/server-required';
import { AnimatedPage } from '/@/features/shared';
import { AppRoute } from '/@/router/routes';
import { useCurrentServer } from '/@/store';
import { localSettings } from '#preload';
export 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>
);
};
@@ -0,0 +1,36 @@
import { Center, Group, Stack } from '@mantine/core';
import { RiQuestionLine } from 'react-icons/ri';
import { useLocation, useNavigate } from 'react-router-dom';
import { Button, Text } from '/@/components';
import { AnimatedPage } from '/@/features/shared';
export 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>
);
};
@@ -0,0 +1,3 @@
export * from './queries/album-detail-query';
export * from './queries/album-list-query';
export * from './routes/album-list-route';
@@ -0,0 +1,16 @@
import { useQuery } from '@tanstack/react-query';
import { queryKeys } from '/@/api/query-keys';
import type { QueryOptions } from '/@/lib/react-query';
import { useCurrentServer } from '../../../store/auth.store';
import { apiController } from '/@/api/controller';
import type { AlbumDetailQuery } from '/@/api/types';
export const useAlbumDetail = (query: AlbumDetailQuery, options: QueryOptions) => {
const server = useCurrentServer();
return useQuery({
queryFn: ({ signal }) => apiController.getAlbumDetail(query, signal),
queryKey: queryKeys.albums.detail(server?.id || '', query),
...options,
});
};
@@ -0,0 +1,58 @@
import { useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
import { apiController } from '/@/api/controller';
import { queryKeys } from '/@/api/query-keys';
import type { AlbumListParams, AlbumListResponse } from '/@/api/types';
import type { QueryOptions } from '/@/lib/react-query';
import { useCurrentServer, useCurrentServerId } from '/@/store';
import { ndNormalize } from '/@/api/navidrome.api';
import type { NDAlbum } from '/@/api/navidrome.types';
export const useAlbumList = (params: AlbumListParams, options?: QueryOptions) => {
const serverId = useCurrentServerId();
const server = useCurrentServer();
return useQuery({
enabled: !!serverId,
queryFn: ({ signal }) => apiController.getAlbumList(params, signal),
queryKey: queryKeys.albums.list(serverId, params),
select: useCallback(
(data: AlbumListResponse) => {
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 {
...data,
items: albums,
};
},
[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,
// });
// };
@@ -0,0 +1,467 @@
/* eslint-disable no-plusplus */
import type { MouseEvent } from 'react';
import { useCallback } from 'react';
import { Group } from '@mantine/core';
import { useQueryClient } from '@tanstack/react-query';
import { AnimatePresence } from 'framer-motion';
import debounce from 'lodash/debounce';
import throttle from 'lodash/throttle';
import { RiArrowDownSLine, RiSettings2Fill } from 'react-icons/ri';
import AutoSizer from 'react-virtualized-auto-sizer';
import type { ListOnScrollProps } from 'react-window';
import { queryKeys } from '/@/api/query-keys';
import type { Album, AlbumListResponse, AlbumListSort } from '/@/api/types';
import { SortOrder } from '/@/api/types';
import {
Button,
DropdownMenu,
Slider,
Text,
VirtualGridAutoSizerContainer,
VirtualGridContainer,
VirtualInfiniteGrid,
} from '/@/components';
import { AnimatedPage } from '/@/features/shared';
import { AppRoute } from '/@/router/routes';
import { useAlbumRouteStore, useAppStoreActions, useCurrentServer } from '/@/store';
import { LibraryItem, CardDisplayType } from '/@/types';
import { useAlbumList } from '../queries/album-list-query';
import { JFAlbumListSort } from '/@/api/jellyfin.types';
import type { NDAlbum } from '/@/api/navidrome.types';
import { NDAlbumListSort } from '/@/api/navidrome.types';
import { apiController } from '/@/api/controller';
import { ndNormalize } from '/@/api/navidrome.api';
const FILTERS = {
jellyfin: [
{ name: 'Album Artist', value: JFAlbumListSort.NAME },
{ name: 'Community Rating', value: JFAlbumListSort.RATING },
{ name: 'Critic Rating', value: JFAlbumListSort.CRITIC_RATING },
{ name: 'Name', value: JFAlbumListSort.NAME },
{ name: 'Random', value: JFAlbumListSort.RANDOM },
{ name: 'Recently Added', value: JFAlbumListSort.RECENTLY_ADDED },
{ name: 'Release Date', value: JFAlbumListSort.RELEASE_DATE },
],
navidrome: [
{ name: 'Album Artist', value: NDAlbumListSort.ALBUM_ARTIST },
{ name: 'Artist', value: NDAlbumListSort.ARTIST },
{ name: 'Duration', value: NDAlbumListSort.DURATION },
{ name: 'Name', value: NDAlbumListSort.NAME },
{ name: 'Play Count', value: NDAlbumListSort.PLAY_COUNT },
{ name: 'Random', value: NDAlbumListSort.RANDOM },
{ name: 'Rating', value: NDAlbumListSort.RATING },
{ name: 'Recently Added', value: NDAlbumListSort.RECENTLY_ADDED },
{ name: 'Song Count', value: NDAlbumListSort.SONG_COUNT },
{ name: 'Starred', value: NDAlbumListSort.STARRED },
{ name: 'Year', value: NDAlbumListSort.YEAR },
],
};
const ORDER = [
{ name: 'Ascending', value: SortOrder.ASC },
{ name: 'Descending', value: SortOrder.DESC },
];
export const AlbumListRoute = () => {
const queryClient = useQueryClient();
const server = useCurrentServer();
const { setPage } = useAppStoreActions();
const page = useAlbumRouteStore();
const filters = page.list.filter;
const albumListQuery = useAlbumList({
_skip: 0,
_take: 1,
musicFolderId: null,
sortBy: filters.sortBy,
sortOrder: filters.sortOrder,
});
const fetch = useCallback(
async ({ skip, take }: { skip: number; take: number }) => {
const queryKey = queryKeys.albums.list(server?.id || '', {
_skip: skip,
_take: take,
...filters,
});
const albums = await queryClient.fetchQuery(queryKey, async () =>
apiController.getAlbumList({
_skip: skip,
_take: take,
musicFolderId: null,
sortBy: filters.sortBy,
sortOrder: filters.sortOrder,
}),
);
let items: Album[] = [];
switch (server?.type) {
case 'jellyfin':
break;
case 'navidrome':
items = (albums?.items || []).map((a) => {
return ndNormalize.album(a as NDAlbum, server);
});
break;
case 'subsonic':
break;
}
return {
items,
pagination: {
startIndex: skip,
totalEntries: albums?.pagination?.totalEntries || 0,
},
} as AlbumListResponse;
},
[filters, queryClient, server],
);
const setSize = throttle(
(e: number) =>
setPage('albums', {
...page,
list: { ...page.list, size: e },
}),
200,
);
const handleSetFilter = (e: MouseEvent<HTMLButtonElement>) => {
if (!e.currentTarget?.value) return;
setPage('albums', {
list: {
...page.list,
filter: {
...page.list.filter,
sortBy: e.currentTarget.value as AlbumListSort,
},
},
});
};
const handleSetOrder = (e: MouseEvent<HTMLButtonElement>) => {
if (!e.currentTarget?.value) return;
setPage('albums', {
list: {
...page.list,
filter: {
...page.list.filter,
sortOrder: e.currentTarget.value as SortOrder,
},
},
});
};
const handleSetViewType = (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',
},
});
}
};
const handleGridScroll = debounce((e: ListOnScrollProps) => {
setPage('albums', {
...page,
list: {
...page.list,
gridScrollOffset: e.scrollOffset,
},
});
}, 300);
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;
return (
<AnimatedPage>
<VirtualGridContainer>
<Group
m={10}
position="apart"
>
<Group>
<Text
$noSelect
size="lg"
>
Albums
</Text>
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
variant="subtle"
>
<Group>
{sortByLabel}
<RiArrowDownSLine size={15} />
</Group>
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{FILTERS[server?.type as keyof typeof FILTERS].map((filter) => (
<DropdownMenu.Item
key={`filter-${filter.value}`}
$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
variant="subtle"
>
<Group>
{sortOrderLabel}
<RiArrowDownSLine size={15} />
</Group>
</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
variant="subtle"
>
<Group>
Folders <RiArrowDownSLine size={15} />
</Group>
</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>
<Group position="right">
<DropdownMenu
position="bottom-end"
width={100}
>
<DropdownMenu.Target>
<Button
compact
variant="subtle"
>
<RiSettings2Fill size={15} />
</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>
</Group>
</Group>
<AnimatePresence
key="album-list-advanced-filter"
initial={false}
mode="wait"
>
{/* {isAdvFilter && (
<motion.div
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -25 }}
initial={{ opacity: 0, y: -25 }}
style={{ maxHeight: '25vh', zIndex: 100 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
>
<Paper
sx={{
boxShadow: ' 0 10px 5px -2px rgb(0, 0, 0, .2)',
height: '100%',
position: 'relative',
}}
>
<ScrollArea sx={{ height: '100%', width: '100%' }}>
<Group
noWrap
p={10}
position="apart"
sx={{
background: 'var(--paper-bg)',
position: 'sticky',
top: 0,
zIndex: 50,
}}
>
<Group>
<Text $noSelect>Advanced Filters</Text>
<NumberInput
disabled
min={1}
placeholder="Limit"
size="xs"
width={75}
/>
<Button
px={10}
size="xs"
tooltip={{ label: 'Reset' }}
variant="default"
onClick={handleResetAdvancedFilters}
>
<RiDeleteBack2Fill size={15} />
</Button>
</Group>
<Button
disabled
uppercase
variant="default"
>
Save as...
</Button>
</Group>
<Box p={10}>
<AdvancedFilters
ref={advancedFiltersRef}
defaultFilters={page.list.advancedFilter.filter}
onChange={handleUpdateAdvancedFilters}
/>
</Box>
</ScrollArea>
</Paper>
</motion.div>
)} */}
</AnimatePresence>
<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?.pagination?.totalEntries || 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>
);
};
@@ -0,0 +1 @@
export * from './routes/DashboardRoute';
@@ -0,0 +1,3 @@
export const DashboardRoute = () => {
return <></>;
};
@@ -0,0 +1,204 @@
import { useEffect, useMemo, useRef } from 'react';
import type {
CellDoubleClickedEvent,
ColDef,
RowClassRules,
RowDragEvent,
} from 'ag-grid-community';
import 'ag-grid-community/styles/ag-theme-alpine.css';
import {
VirtualGridAutoSizerContainer,
VirtualGridContainer,
getColumnDefs,
TableConfigDropdown,
} from '/@/components';
import { useAppStoreActions, usePlayerStore } from '/@/store';
import { useSettingsStore } from '/@/store/settings.store';
import type { QueueSong, TableType } from '/@/types';
import { ErrorBoundary } from 'react-error-boundary';
import { mpvPlayer } from '#preload';
import { VirtualTable } from '../../../components/virtual-table';
import { ErrorFallback } from '../../action-required';
type QueueProps = {
type: TableType;
};
export const PlayQueue = ({ type }: QueueProps) => {
const gridRef = useRef<any>(null);
const queue = usePlayerStore((state) => state.queue.default);
const reorderQueue = usePlayerStore((state) => state.reorderQueue);
const current = usePlayerStore((state) => state.getQueueData().current);
const previous = usePlayerStore((state) => state.queue.previousNode);
const setCurrentTrack = usePlayerStore((state) => state.setCurrentTrack);
const setSettings = useSettingsStore((state) => state.setSettings);
const { setAppStore } = useAppStoreActions();
const tableConfig = useSettingsStore((state) => state.tables[type]);
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 } = gridRef?.current || {};
clearTimeout(timeout);
timeout = setTimeout(() => api.redrawRows(), 250);
};
const handleGridReady = () => {
const { api } = gridRef?.current || {};
const currentNode = api.getRowNode(current?.uniqueId);
api.ensureNodeVisible(currentNode, 'middle');
};
const handleColumnChange = () => {
const { columnApi } = gridRef?.current || {};
const columnsOrder = columnApi.getAllGridColumns();
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) {
gridRef?.current.api.sizeColumnsToFit();
}
};
const rowClassRules = useMemo<RowClassRules>(() => {
return {
'current-song': (params) => {
return params.data.uniqueId === current?.uniqueId;
},
};
}, [current?.uniqueId]);
// Redraw the current song row when the previous song changes
useEffect(() => {
if (gridRef?.current) {
const { api, columnApi } = gridRef?.current || {};
if (api == null || columnApi == null) {
return;
}
const currentNode = api.getRowNode(current?.uniqueId);
const previousNode = api.getRowNode(previous?.uniqueId);
const rowNodes = [currentNode, previousNode];
if (rowNodes) {
api.redrawRows({ rowNodes });
if (tableConfig.followCurrentSong) {
api.ensureNodeVisible(currentNode, 'middle');
}
}
}
}, [current, previous, tableConfig.followCurrentSong]);
// Auto resize the columns when the column config changes
useEffect(() => {
if (tableConfig.autoFit) {
const { api } = gridRef?.current || {};
api?.sizeColumnsToFit();
}
}, [tableConfig.autoFit, tableConfig.columns]);
useEffect(() => {
const { api } = gridRef?.current || {};
api?.resetRowHeights();
api?.redrawRows();
}, [tableConfig.rowHeight]);
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<VirtualGridContainer>
<VirtualGridAutoSizerContainer>
<VirtualTable
ref={gridRef}
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"
// onCellClicked={(e) => console.log('clicked', e)}
// onCellContextMenu={(e) => console.log(e)}
onCellDoubleClicked={handlePlayByRowClick}
onColumnMoved={handleColumnChange}
onColumnResized={handleColumnChange}
onDragStarted={handleDragStart}
onGridReady={handleGridReady}
onGridSizeChanged={handleGridSizeChange}
onRowDragEnd={handleDragEnd}
/>
</VirtualGridAutoSizerContainer>
</VirtualGridContainer>
<TableConfigDropdown type={type} />
</ErrorBoundary>
);
};
@@ -0,0 +1,2 @@
export * from './routes/now-playing-route';
export * from './components/play-queue';
@@ -0,0 +1,20 @@
import { Box } from '@mantine/core';
import styled from 'styled-components';
import { PlayQueue } from '/@/features/now-playing/components/play-queue';
import { AnimatedPage } from '/@/features/shared';
const QueueContainer = styled(Box)`
position: relative;
width: 100%;
height: 100%;
`;
export const NowPlayingRoute = () => {
return (
<AnimatedPage>
<QueueContainer>
<PlayQueue type="nowPlaying" />
</QueueContainer>
</AnimatedPage>
);
};
@@ -0,0 +1,228 @@
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 '/@/components';
import { usePlayerStore } from '/@/store';
import { useSettingsStore } from '/@/store/settings.store';
import { PlaybackType, PlayerRepeat, PlayerShuffle, PlayerStatus } from '/@/types';
import { useCenterControls } from '../hooks/use-center-controls';
import { PlayerButton } from './player-button';
import { Slider } from './slider';
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 songDuration = usePlayerStore((state) => state.current.song?.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, player } = usePlayerStore((state) => state.current);
const setCurrentTime = usePlayerStore((state) => state.setCurrentTime);
const repeat = usePlayerStore((state) => state.repeat);
const shuffle = usePlayerStore((state) => state.shuffle);
const {
handleNextTrack,
handlePlayPause,
handlePrevTrack,
handleSeekSlider,
handleSkipBackward,
handleSkipForward,
handleToggleRepeat,
handleToggleShuffle,
} = useCenterControls({ playersRef });
const currentTime = usePlayerStore((state) => state.current.time);
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>
</>
);
};
@@ -0,0 +1,208 @@
import React from 'react';
import { Center, Group } 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 '/@/components';
import { AppRoute } from '/@/router/routes';
import { useAppStore, useAppStoreActions, usePlayerStore } from '/@/store';
import { fadeIn } from '/@/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.1rem;
justify-content: center;
width: 100%;
overflow: hidden;
`;
const Image = styled(motion(Link))`
width: 70px;
height: 70px;
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)'};
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 song = usePlayerStore((state) => state.current.song);
const title = song?.name;
const artists = song?.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' }}
>
{song?.imageUrl ? (
<PlayerbarImage
loading="eager"
src={song?.imageUrl}
/>
) : (
<>
<Center
sx={{
background: 'var(--placeholder-bg)',
height: '100%',
}}
>
<RiDiscLine
color="var(--placeholder-fg)"
size={50}
/>
</Center>
</>
)}
<Group
position="right"
sx={{ position: 'absolute', right: 0, top: 0 }}
>
<Button
compact
radius={0}
size="xs"
variant="default"
onClick={(e) => {
e.preventDefault();
setSidebar({ image: true });
}}
>
<RiArrowUpSLine
color="white"
size={20}
/>
</Button>
</Group>
</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={
song?.albumId
? generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
albumId: song.albumId,
})
: ''
}
weight={500}
>
{song?.album || '—'}
</Text>
</LineItem>
</MetadataStack>
</LayoutGroup>
</LeftControlsContainer>
);
};
@@ -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 '/@/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,
};
@@ -0,0 +1,87 @@
import { useRef } from 'react';
import styled from 'styled-components';
import { useSettingsStore } from '/@/store/settings.store';
import { PlaybackType } from '/@/types';
import { AudioPlayer } from '../../../components';
import { usePlayerStore } from '../../../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 = usePlayerStore((state) => state.volume);
const player1 = usePlayerStore((state) => state.player1());
const player2 = usePlayerStore((state) => state.player2());
const status = usePlayerStore((state) => state.current.status);
const player = usePlayerStore((state) => state.current.player);
const autoNext = usePlayerStore((state) => state.autoNext);
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>
);
};
@@ -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 { usePlayerStore, useAppStoreActions, useSidebarStore } from '/@/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 = usePlayerStore((state) => state.volume);
const muted = usePlayerStore((state) => state.muted);
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>
);
};
@@ -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',
};
@@ -0,0 +1,505 @@
import { useCallback, useEffect } from 'react';
import isElectron from 'is-electron';
import { PlaybackType, PlayerRepeat, PlayerShuffle, PlayerStatus } from '/@/types';
import { mpvPlayer, mpvPlayerListener, ipc } from '#preload';
import { usePlayerStore } from '../../../store';
import { useSettingsStore } from '../../../store/settings.store';
export const useCenterControls = (args: { playersRef: any }) => {
const { playersRef } = args;
const settings = useSettingsStore((state) => state.player);
const setShuffle = usePlayerStore((state) => state.setShuffle);
const setRepeat = usePlayerStore((state) => state.setRepeat);
const play = usePlayerStore((state) => state.play);
const pause = usePlayerStore((state) => state.pause);
const prev = usePlayerStore((state) => state.prev);
const next = usePlayerStore((state) => state.next);
const setCurrentIndex = usePlayerStore((state) => state.setCurrentIndex);
const autoNext = usePlayerStore((state) => state.autoNext);
const queue = usePlayerStore((state) => state.queue.default);
const playerStatus = usePlayerStore((state) => state.current.status);
const currentPlayer = usePlayerStore((state) => state.current.player);
const repeat = usePlayerStore((state) => state.repeat);
const shuffle = usePlayerStore((state) => state.shuffle);
const playerType = useSettingsStore((state) => state.player.type);
const setCurrentTime = usePlayerStore((state) => state.setCurrentTime);
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 (shuffle === PlayerShuffle.NONE) {
const playerData = setShuffle(PlayerShuffle.TRACK);
return mpvPlayer.setQueueNext(playerData);
}
const playerData = setShuffle(PlayerShuffle.NONE);
return mpvPlayer.setQueueNext(playerData);
}, [setShuffle, shuffle]);
const handleToggleRepeat = useCallback(() => {
if (repeat === PlayerRepeat.NONE) {
const playerData = setRepeat(PlayerRepeat.ALL);
return mpvPlayer.setQueueNext(playerData);
}
if (repeat === PlayerRepeat.ALL) {
const playerData = setRepeat(PlayerRepeat.ONE);
return mpvPlayer.setQueueNext(playerData);
}
return setRepeat(PlayerRepeat.NONE);
}, [repeat, setRepeat]);
const checkIsLastTrack = useCallback(() => {
return usePlayerStore.getState().checkIsLastTrack();
}, []);
const checkIsFirstTrack = useCallback(() => {
return usePlayerStore.getState().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 (repeat) {
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, repeat, 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 (repeat) {
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,
repeat,
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 = prev();
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 {
prev();
resetPlayers();
}
},
};
const handleRepeatNone = {
local: () => {
const playerData = prev();
mpvPlayer.setQueue(playerData);
mpvPlayer.previous();
},
web: () => {
if (isFirstTrack) {
resetPlayers();
pause();
} else {
prev();
resetPlayers();
}
},
};
const handleRepeatOne = {
local: () => {
if (!isFirstTrack) {
const playerData = prev();
mpvPlayer.setQueue(playerData);
mpvPlayer.previous();
} else {
mpvPlayer.stop();
}
},
web: () => {
prev();
resetPlayers();
},
};
switch (repeat) {
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,
prev,
queue.length,
repeat,
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(() => {
mpvPlayerListener.rendererPlayPause(() => {
handlePlayPause();
});
mpvPlayerListener.rendererNext(() => {
handleNextTrack();
});
mpvPlayerListener.rendererPrevious(() => {
handlePrevTrack();
});
mpvPlayerListener.rendererPlay(() => {
handlePlay();
});
mpvPlayerListener.rendererPause(() => {
handlePause();
});
mpvPlayerListener.rendererStop(() => {
handleStop();
});
mpvPlayerListener.rendererCurrentTime((_event, 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,
prev,
setCurrentTime,
]);
return {
handleNextTrack,
handlePlayPause,
handlePrevTrack,
handleSeekSlider,
handleSkipBackward,
handleSkipForward,
handleStop,
handleToggleRepeat,
handleToggleShuffle,
};
};
@@ -0,0 +1,40 @@
import { useEffect } from 'react';
import { mpvPlayer } from '#preload';
import { usePlayerStore } from '../../../store';
export const useRightControls = () => {
const setVolume = usePlayerStore((state) => state.setVolume);
const volume = usePlayerStore((state) => state.volume);
const muted = usePlayerStore((state) => state.muted);
const setMuted = usePlayerStore((state) => state.setMuted);
// Ensure that the mpv player volume is set on startup
useEffect(() => {
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,
};
};
@@ -0,0 +1,23 @@
import { useState } from 'react';
import { usePlayerStore } from '/@/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 };
};
@@ -0,0 +1,4 @@
export * from './components/center-controls';
export * from './components/left-controls';
export * from './components/playerbar';
export * from './components/slider';
@@ -0,0 +1,84 @@
import { mpvPlayer } from '#preload';
import { useAuthStore, usePlayerStore } from '/@/store';
import { useSettingsStore } from '/@/store/settings.store';
import type { PlayQueueAddOptions } from '/@/types';
import { PlaybackType } from '/@/types';
import { Play } from '/@/types';
import { LibraryItem } from '/@/types';
import { queryKeys } from '../../../api/query-keys';
import { apiController } from '/@/api/controller';
import { queryClient } from '/@/lib/react-query';
import { ndNormalize } from '/@/api/navidrome.api';
import type { NDSong } from '/@/api/navidrome.types';
export const handlePlayQueueAdd = async (options: PlayQueueAddOptions) => {
const playerType = useSettingsStore.getState().player.type;
const deviceId = useAuthStore.getState().deviceId;
const server = useAuthStore.getState().currentServer;
console.log('options :>> ', options);
// if (options.byData) {
// // dispatchSongsToQueue(options.byData, options.play);
// }
if (options.byItemType) {
let songs = null;
// switch (options.byItemType.type) {
// case LibraryItem.ALBUM:
// const response = await queryClient.fetchQuery(
// queryKeys.albums.detail(options.byItemType.id),
// async ({ signal }) => apiController.getAlbumDetail({ id: options.byItemType.id }, signal),
// );
// songs = normalizeFn[currentServer!.type as keyof typeof normalizeFn]?.album(
// response?.items,
// currentServer,
// );
// break;
// }
// if (!songs | !response) return;
if (options.byItemType.type === LibraryItem.ALBUM) {
const albumDetail = await queryClient.fetchQuery(
queryKeys.albums.detail(options.byItemType.id),
async ({ signal }) => apiController.getAlbumDetail({ id: options.byItemType!.id }, signal),
);
if (!albumDetail) return;
switch (server?.type) {
case 'jellyfin':
break;
case 'navidrome':
songs = albumDetail.songs?.map((song) =>
ndNormalize.song(song as NDSong, server, deviceId),
);
break;
case 'subsonic':
break;
}
console.log('songs :>> ', songs);
}
if (!songs) return;
const playerData = usePlayerStore.getState().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().play();
}
}
};
@@ -0,0 +1,140 @@
import { useState } from 'react';
import { Checkbox, Stack, Group } from '@mantine/core';
import { useForm } from '@mantine/form';
import { useFocusTrap } from '@mantine/hooks';
import { jellyfinApi } from '/@/api/jellyfin.api';
import { navidromeApi } from '/@/api/navidrome.api';
import { subsonicApi } from '/@/api/subsonic.api';
import type { AuthResponse } from '/@/api/types';
import { ServerType } from '/@/types';
import { Button, PasswordInput, TextInput, SegmentedControl, toast } from '/@/components';
import { useAuthStoreActions } from '/@/store';
import { closeAllModals } from '@mantine/modals';
import { nanoid } from 'nanoid/non-secure';
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: AuthResponse = await authFunction({
legacy: values.legacyAuth,
password: values.password,
url: values.url,
username: values.username,
});
addServer({
credential: data.credential,
id: nanoid(),
name: values.name,
ndCredential: data.ndCredential,
type: values.type,
url: values.url,
username: data.username,
});
toast.success({ message: 'Server added' });
closeAllModals();
} catch (err: any) {
setIsLoading(false);
return toast.error({ message: err?.message });
}
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>
);
};
@@ -0,0 +1,132 @@
import { useState } from 'react';
import { Checkbox, Stack, Group } from '@mantine/core';
import { useForm } from '@mantine/form';
import { nanoid } from 'nanoid/non-secure';
import { RiInformationLine } from 'react-icons/ri';
import { ServerType } from '/@/types';
import { Button, PasswordInput, TextInput, toast } from '/@/components';
import type { ServerListItem } from '/@/store';
import { useAuthStoreActions } from '/@/store';
import { jellyfinApi } from '/@/api/jellyfin.api';
import { navidromeApi } from '/@/api/navidrome.api';
import { subsonicApi } from '/@/api/subsonic.api';
import type { AuthResponse } from '/@/api/types';
interface EditServerFormProps {
onCancel: () => void;
server: ServerListItem;
}
const AUTH_FUNCTIONS = {
[ServerType.JELLYFIN]: jellyfinApi.authenticate,
[ServerType.NAVIDROME]: navidromeApi.authenticate,
[ServerType.SUBSONIC]: subsonicApi.authenticate,
};
export const EditServerForm = ({ server, onCancel }: EditServerFormProps) => {
const { updateServer } = 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: AuthResponse = await authFunction({
legacy: values.legacyAuth,
password: values.password,
url: values.url,
username: values.username,
});
updateServer(server.id, {
credential: data.credential,
id: nanoid(),
name: values.name,
ndCredential: data.ndCredential,
type: values.type,
url: values.url,
username: data.username,
});
toast.success({ message: 'Server updated' });
} catch (err: any) {
setIsLoading(false);
return toast.error({ message: err?.message });
}
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>
);
};
@@ -0,0 +1,75 @@
import { Stack, Group, Divider } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { RiDeleteBin2Line, RiEdit2Fill } from 'react-icons/ri';
import { Button, TimeoutButton, Text } from '/@/components';
import { EditServerForm } from '/@/features/servers/components/edit-server-form';
import { ServerSection } from '/@/features/servers/components/server-section';
import type { ServerListItem as ServerItem } from '/@/store';
import { useAuthStoreActions } from '/@/store';
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>
</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>
);
};
@@ -0,0 +1,67 @@
import { Group } from '@mantine/core';
import { openContextModal } from '@mantine/modals';
import { RiAddFill, RiServerFill } from 'react-icons/ri';
import type { ContextModalVars } from '/@/components';
import { Button, Accordion } from '/@/components';
import { ServerListItem } from '/@/features/servers/components/server-list-item';
import { titleCase } from '/@/utils';
import { AddServerForm } from './add-server-form';
import { useServerList } from '/@/store';
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>
</>
);
};
@@ -0,0 +1,24 @@
import React from 'react';
import styled from 'styled-components';
import { Text } from '/@/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>
);
};
@@ -0,0 +1,2 @@
export * from './components/add-server-form';
export * from './components/server-list';
@@ -0,0 +1,62 @@
import axios from 'axios';
import md5 from 'md5';
import { ServerType } from '/@/types';
import { randomString } from '/@/utils';
export const validateServerCredential = async (options: {
legacyAuth: boolean;
password: string;
type: ServerType;
url: string;
username: string;
}) => {
const { type, url, username, password, legacyAuth } = options;
const cleanServerUrl = url.replace(/\/$/, '');
try {
if (type === ServerType.SUBSONIC) {
let testConnection;
let token;
if (legacyAuth) {
token = `u=${username}&p=${password}`;
testConnection = await axios.get(
`${cleanServerUrl}/rest/ping.view?v=1.13.0&c=Feishin&f=json&${token}`,
);
} else {
const salt = randomString();
const hash = md5(password + salt);
token = `u=${username}&s=${salt}&t=${hash}`;
testConnection = await axios.get(
`${cleanServerUrl}/rest/ping.view?v=1.13.0&c=Feishin&f=json&${token}`,
);
}
if (testConnection.data['subsonic-response'].status === 'failed') {
return {
error: `${testConnection.data['subsonic-response'].error.message}`,
};
}
return { token, userId: '' };
}
const { data } = await axios.post(
`${cleanServerUrl}/users/authenticatebyname`,
{ pw: password, username },
{
headers: {
'X-Emby-Authorization':
'MediaBrowser Client="Feishin", Device="PC", DeviceId="Feishin", Version="0.0.1-alpha1"',
},
},
);
return { token: data.AccessToken, userId: data.User.Id };
} catch (err) {
if (err instanceof Error) {
return { error: err.message };
}
}
return null;
};
@@ -0,0 +1,204 @@
import { Divider, Stack } from '@mantine/core';
import isElectron from 'is-electron';
import { Switch, Select } from '/@/components';
import { SettingsOptions } from '/@/features/settings/components/settings-option';
import { THEME_DATA } from '/@/hooks';
import { useSettingsStore } from '/@/store/settings.store';
import type { AppTheme } from '/@/themes/types';
const FONT_OPTIONS = [
{ label: 'AnekTamil', value: 'AnekTamil' },
{ label: 'Archivo', value: 'Archivo' },
{ label: 'Cormorant', value: 'Cormorant' },
{ 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: 'Hahmlet', value: 'Hahmlet' },
{ label: 'Inconsolata', value: 'Inconsolata' },
{ label: 'Inter', value: 'Inter' },
{ label: 'JetBrains Mono', value: 'JetBrainsMono' },
{ label: 'Manrope', value: 'Manrope' },
{ label: 'Montserrat', value: 'Montserrat' },
{ label: 'Oswald', value: 'Oswald' },
{ label: 'Oxygen', value: 'Oxygen' },
{ label: 'Poppins', value: 'Poppins' },
{ label: 'Raleway', value: 'Raleway' },
{ label: 'Roboto', value: 'Roboto' },
{ label: 'Sora', value: 'Sora' },
{ label: 'Spectral', value: 'Spectral' },
{ label: 'Work Sans', value: 'Work Sans' },
];
export const GeneralTab = () => {
const settings = useSettingsStore((state) => state.general);
const update = useSettingsStore((state) => state.setSettings);
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;
update({
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;
update({
general: {
...settings,
fontHeader: e,
},
});
}}
/>
),
description: 'Sets the application header font',
isHidden: false,
title: 'Font (Header)',
},
];
const themeOptions = [
{
control: (
<Switch
defaultChecked={settings.followSystemTheme}
onChange={(e) => {
update({
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) => {
update({
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) => {
update({
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) => {
update({
general: {
...settings,
themeLight: e as AppTheme,
},
});
}}
/>
),
description: 'Sets the light theme',
isHidden: !settings.followSystemTheme,
title: 'Theme (light)',
},
];
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}
/>
))}
</Stack>
);
};
@@ -0,0 +1,384 @@
import { useEffect, useState } from 'react';
import type { SelectItem } from '@mantine/core';
import { Divider, Group, Stack } from '@mantine/core';
import isElectron from 'is-electron';
import {
FileInput,
NumberInput,
SegmentedControl,
Select,
Slider,
Switch,
Text,
Textarea,
toast,
Tooltip,
} from '/@/components';
import { mpvPlayer } from '#preload';
import { SettingsOptions } from '/@/features/settings/components/settings-option';
import { getLocalSetting, setLocalSetting } from '/@/features/settings/utils/local-settings';
import { usePlayerStore } from '/@/store';
import { useSettingsStore } from '/@/store/settings.store';
import { Play, PlaybackStyle, PlaybackType, PlayerStatus, CrossfadeStyle } from '/@/types';
import { localSettings } from '#preload';
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 update = useSettingsStore((state) => state.setSettings);
const status = usePlayerStore((state) => state.current.status);
const [audioDevices, setAudioDevices] = useState<SelectItem[]>([]);
const [mpvPath, setMpvPath] = useState('');
const [mpvParameters, setMpvParameters] = useState('');
const handleSetMpvPath = (e: File) => {
setLocalSetting('mpv_path', e.path);
};
useEffect(() => {
const getMpvPath = async () => {
if (!isElectron()) return setMpvPath('');
const mpvPath = await getLocalSetting('mpv_path');
return setMpvPath(mpvPath);
};
const getMpvParameters = async () => {
if (!isElectron()) return setMpvPath('');
const mpvParametersFromSettings = await getLocalSetting('mpv_parameters');
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) => {
update({ player: { ...settings, type: e as PlaybackType } });
if (isElectron() && e === PlaybackType.LOCAL) {
const queueData = usePlayerStore.getState().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()) {
setLocalSetting('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) => update({ 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) => update({ 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) => update({ 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;
update({
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) => {
update({
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) =>
update({
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) =>
update({
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) =>
update({
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) =>
update({
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>
);
};
@@ -0,0 +1,68 @@
import React from 'react';
import { Group, Stack } from '@mantine/core';
import { RiInformationLine } from 'react-icons/ri';
import { Text, Tooltip } from '/@/components';
interface SettingsOptionProps {
control: React.ReactNode;
description?: React.ReactNode | string;
note?: string;
title: React.ReactNode | string;
}
export const SettingsOptions = ({ title, description, control, note }: SettingsOptionProps) => {
return (
<>
<Group
noWrap
position="apart"
sx={{ alignItems: 'center' }}
>
<Stack
spacing="xs"
sx={{
alignSelf: 'flex-start',
display: 'flex',
maxWidth: '50%',
}}
>
<Group>
<Text
$noSelect
size="sm"
>
{title}
</Text>
{note && (
<Tooltip
label={note}
openDelay={0}
>
<Group>
<RiInformationLine size={15} />
</Group>
</Tooltip>
)}
</Group>
{React.isValidElement(description) ? (
description
) : (
<Text
$noSelect
$secondary
size="sm"
>
{description}
</Text>
)}
</Stack>
<Group position="right">{control}</Group>
</Group>
</>
);
};
SettingsOptions.defaultProps = {
description: undefined,
note: undefined,
};
@@ -0,0 +1,70 @@
import { Box } from '@mantine/core';
import type { Variants } from 'framer-motion';
import { motion } from 'framer-motion';
import { Tabs } from '/@/components';
import { GeneralTab } from '/@/features/settings/components/general-tab';
import { PlaybackTab } from '/@/features/settings/components/playback-tab';
import { useSettingsStore } from '/@/store/settings.store';
export const Settings = () => {
const currentTab = useSettingsStore((state) => state.tab);
const update = useSettingsStore((state) => state.setSettings);
const tabVariants: Variants = {
in: {
opacity: 1,
transition: {
duration: 0.3,
},
x: 0,
},
out: {
opacity: 0,
x: 50,
},
};
return (
<Box
m={5}
sx={{ height: '800px', maxHeight: '50vh', overflowX: 'hidden' }}
>
<Tabs
keepMounted={false}
orientation="vertical"
styles={{
tab: {
fontSize: '1.1rem',
padding: '0.5rem 1rem',
},
}}
value={currentTab}
variant="pills"
onTabChange={(e) => e && update({ tab: e })}
>
<Tabs.List>
<Tabs.Tab value="general">General</Tabs.Tab>
<Tabs.Tab value="playback">Playback</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="general">
<motion.div
animate="in"
initial="out"
variants={tabVariants}
>
<GeneralTab />
</motion.div>
</Tabs.Panel>
<Tabs.Panel value="playback">
<motion.div
animate="in"
initial="out"
variants={tabVariants}
>
<PlaybackTab />
</motion.div>
</Tabs.Panel>
</Tabs>
</Box>
);
};
@@ -0,0 +1 @@
export * from './components/settings';
@@ -0,0 +1,19 @@
import isElectron from 'is-electron';
const ipc = isElectron() ? null : null;
export const getLocalSetting = (property: string) => ipc?.SETTINGS_GET({ property });
export const setLocalSetting = (property: string, value: any) => {
ipc?.SETTINGS_SET({ property, value });
};
export const restartApp = () => {
ipc?.APP_RESTART();
};
export const localSettings = {
get: getLocalSetting,
restart: restartApp,
set: setLocalSetting,
};
@@ -0,0 +1,32 @@
import type { ReactNode } from 'react';
import { motion } from 'framer-motion';
import styled from 'styled-components';
interface AnimatedPageProps {
children: ReactNode;
}
const StyledAnimatedPage = styled(motion.div)`
width: 100%;
height: 100%;
`;
const variants = {
animate: { opacity: 1 },
exit: { opacity: 0 },
initial: { opacity: 0 },
};
export const AnimatedPage = ({ children }: AnimatedPageProps) => {
return (
<StyledAnimatedPage
animate="animate"
exit="exit"
initial="initial"
transition={{ duration: 0.2, type: 'tween' }}
variants={variants}
>
{children}
</StyledAnimatedPage>
);
};
@@ -0,0 +1 @@
export * from './components/animated-page';
@@ -0,0 +1,79 @@
import type { ReactNode } from 'react';
import type { LinkProps } from 'react-router-dom';
import { Link } from 'react-router-dom';
import styled, { css } from 'styled-components';
interface ListItemProps {
children: ReactNode;
disabled?: boolean;
to?: string;
}
const StyledItem = styled.div`
display: flex;
width: 100%;
font-family: var(--content-font-family);
&:focus-visible {
border: 1px solid var(--primary-color);
}
`;
const ItemStyle = css`
display: flex;
width: 100%;
padding: 0.5rem 1rem;
color: var(--sidebar-btn-color);
font-family: var(--content-font-family);
border: 1px transparent solid;
transition: color 0.2s ease-in-out;
&:hover {
color: var(--sidebar-btn-color-hover);
}
`;
const Box = styled.div`
${ItemStyle}
`;
const ItemLink = styled(Link)<LinkProps & { disabled?: boolean }>`
opacity: ${(props) => props.disabled && 0.6};
pointer-events: ${(props) => props.disabled && 'none'};
&:focus-visible {
border: 1px solid var(--primary-color);
}
${ItemStyle}
`;
export const SidebarItem = ({ to, children, ...rest }: ListItemProps) => {
if (to) {
return (
<ItemLink
to={to}
{...rest}
>
{children}
</ItemLink>
);
}
return (
<StyledItem
tabIndex={0}
{...rest}
>
{children}
</StyledItem>
);
};
SidebarItem.Box = Box;
SidebarItem.Link = ItemLink;
SidebarItem.defaultProps = {
disabled: false,
to: undefined,
};
@@ -0,0 +1,269 @@
import { Stack, Group, Grid, Accordion, Center } from '@mantine/core';
import { SpotlightProvider } from '@mantine/spotlight';
import { AnimatePresence, motion } from 'framer-motion';
import { BsCollection } from 'react-icons/bs';
import {
RiAlbumLine,
RiArrowDownSLine,
RiArrowLeftSLine,
RiArrowRightSLine,
RiDatabaseLine,
RiDiscLine,
RiEyeLine,
RiFolder3Line,
RiHome5Line,
RiMusicLine,
RiPlayListLine,
RiSearchLine,
RiUserVoiceLine,
} from 'react-icons/ri';
import { useNavigate, Link } from 'react-router-dom';
import styled from 'styled-components';
import { Button, TextInput } from '/@/components';
import { AppRoute } from '/@/router/routes';
import { useAppStoreActions, usePlayerStore, useSidebarStore } from '/@/store';
import { fadeIn } from '/@/styles';
import { SidebarItem } from './sidebar-item';
const SidebarContainer = styled.div`
height: 100%;
max-height: calc(100vh - 120px); // Account for titlebar and playerbar
user-select: none;
`;
const ImageContainer = styled(motion(Link))<{ height: string }>`
position: relative;
height: ${(props) => props.height};
${fadeIn};
animation: fadein 0.2s ease-in-out;
button {
display: none;
}
&:hover button {
display: block;
}
`;
const SidebarImage = styled.img`
width: 100%;
height: 100%;
object-fit: cover;
background: var(--placeholder-bg);
`;
export const Sidebar = () => {
const navigate = useNavigate();
const sidebar = useSidebarStore();
const { setSidebar } = useAppStoreActions();
const imageUrl = usePlayerStore((state) => state.current?.song?.imageUrl);
const showImage = sidebar.image;
return (
<SidebarContainer>
<Stack
justify="space-between"
spacing={0}
sx={{ height: '100%' }}
>
<Stack
sx={{
maxHeight: showImage ? `calc(100% - ${sidebar.leftWidth})` : '100%',
}}
>
<Grid p={10}>
<Grid.Col span={8}>
<SpotlightProvider actions={[]}>
<TextInput
disabled
readOnly
icon={<RiSearchLine />}
placeholder="Search"
rightSectionWidth={90}
// onClick={() => openSpotlight()}
/>
</SpotlightProvider>
</Grid.Col>
<Grid.Col span={4}>
<Group
grow
spacing={5}
>
<Button
px={5}
sx={{ color: 'var(--titlebar-fg)' }}
variant="default"
onClick={() => navigate(-1)}
>
<RiArrowLeftSLine size={20} />
</Button>
<Button
px={5}
sx={{ color: 'var(--titlebar-fg)' }}
variant="default"
onClick={() => navigate(1)}
>
<RiArrowRightSLine size={20} />
</Button>
</Group>
</Grid.Col>
</Grid>
<Stack
spacing={0}
sx={{ overflowY: 'auto' }}
>
<SidebarItem to={AppRoute.HOME}>
<Group>
<RiHome5Line size={15} />
Home
</Group>
</SidebarItem>
<SidebarItem>
<SidebarItem.Link
disabled
to={AppRoute.EXPLORE}
>
<Group>
<RiEyeLine />
Explore
</Group>
</SidebarItem.Link>
</SidebarItem>
<Accordion
multiple
styles={{
item: { borderBottom: 'none' },
panel: {
borderLeft: '1px solid rgba(100,100,100,.5)',
marginLeft: '1.5rem',
},
}}
value={sidebar.expanded}
onChange={(e) => setSidebar({ expanded: e })}
>
<Accordion.Item value="library">
<Accordion.Control p="1rem">
<Group>
<RiDatabaseLine size={15} />
Library
</Group>
</Accordion.Control>
<Accordion.Panel>
<SidebarItem to={AppRoute.LIBRARY_ALBUMS}>
<Group>
<RiAlbumLine />
Albums
</Group>
</SidebarItem>
<SidebarItem
disabled
to={AppRoute.LIBRARY_SONGS}
>
<Group>
<RiMusicLine />
Tracks
</Group>
</SidebarItem>
<SidebarItem
disabled
to={AppRoute.LIBRARY_ALBUMARTISTS}
>
<Group>
<RiUserVoiceLine />
Artists
</Group>
</SidebarItem>
<SidebarItem
disabled
to={AppRoute.LIBRARY_FOLDERS}
>
<Group>
<RiFolder3Line />
Folders
</Group>
</SidebarItem>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="collections">
<Accordion.Control
disabled
p="1rem"
>
<Group>
<BsCollection size={15} />
Collections
</Group>
</Accordion.Control>
<Accordion.Panel />
</Accordion.Item>
<Accordion.Item value="playlists">
<Accordion.Control
disabled
p="1rem"
>
<Group>
<RiPlayListLine size={15} />
Playlists
</Group>
</Accordion.Control>
<Accordion.Panel />
</Accordion.Item>
</Accordion>
</Stack>
</Stack>
<AnimatePresence
initial={false}
mode="wait"
>
{showImage && (
<ImageContainer
key="sidebar-image"
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
height={sidebar.leftWidth}
initial={{ opacity: 0, y: 200 }}
to={AppRoute.NOW_PLAYING}
transition={{ duration: 0.3, ease: 'easeInOut' }}
>
{imageUrl ? (
<SidebarImage
loading="eager"
src={imageUrl}
/>
) : (
<Center sx={{ background: 'var(--placeholder-bg)', height: '100%' }}>
<RiDiscLine
color="var(--placeholder-fg)"
size={50}
/>
</Center>
)}
<Group
position="right"
sx={{ position: 'absolute', right: 0, top: 0 }}
>
<Button
compact
size="xs"
variant="default"
onClick={(e) => {
e.preventDefault();
setSidebar({ image: false });
}}
>
<RiArrowDownSLine
color="white"
size={20}
/>
</Button>
</Group>
</ImageContainer>
)}
</AnimatePresence>
</Stack>
</SidebarContainer>
);
};
@@ -0,0 +1,122 @@
import { Group } from '@mantine/core';
import { openModal } from '@mantine/modals';
import {
RiMenu3Fill,
RiSearch2Line,
RiSettings2Fill,
RiSettings2Line,
RiEdit2Line,
} from 'react-icons/ri';
import { JFAlbumListSort } from '/@/api/jellyfin.types';
import { NDAlbumListSort } from '/@/api/navidrome.types';
import { SortOrder } from '/@/api/types';
import { Button, DropdownMenu, Text } from '/@/components';
import { ServerList } from '/@/features/servers';
import { Settings } from '/@/features/settings';
import type { ServerListItem } from '/@/store';
import { useAppStore } from '/@/store';
import { useAppStoreActions } from '/@/store';
import { useAuthStoreActions, useCurrentServer, useServerList } from '/@/store';
import { ServerType } from '/@/types';
export const AppMenu = () => {
const currentServer = useCurrentServer();
const serverList = useServerList();
const { setCurrentServer } = useAuthStoreActions();
const { setPage } = useAppStoreActions();
const handleSetCurrentServer = (server: ServerListItem) => {
setCurrentServer(server);
const sortBy =
server.type === ServerType.NAVIDROME ? NDAlbumListSort.NAME : JFAlbumListSort.NAME;
// Reset filter when switching servers
setPage('albums', {
list: {
...useAppStore.getState().albums.list,
filter: {
...useAppStore.getState().albums.list.filter,
sortBy,
sortOrder: SortOrder.ASC,
},
},
});
};
const handleManageServersModal = () => {
openModal({
children: <ServerList />,
title: 'Manage Servers',
});
};
const handleSettingsModal = () => {
openModal({
children: <Settings />,
size: 'xl',
title: (
<Group position="center">
<RiSettings2Fill size={20} />
<Text>Settings</Text>
</Group>
),
});
};
return (
<DropdownMenu
withArrow
withinPortal
position="bottom"
width={200}
>
<DropdownMenu.Target>
<Button
px={5}
size="xs"
variant="subtle"
>
<RiMenu3Fill
color="var(--titlebar-fg)"
size={15}
/>
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Label>Select a server</DropdownMenu.Label>
{serverList.map((s) => {
return (
<DropdownMenu.Item
key={`server-${s.id}`}
$isActive={s.id === currentServer?.id}
onClick={() => handleSetCurrentServer(s)}
>
<Group>{s.name}</Group>
</DropdownMenu.Item>
);
})}
<DropdownMenu.Divider />
<DropdownMenu.Item
disabled
rightSection={<RiSearch2Line />}
>
Search
</DropdownMenu.Item>
<DropdownMenu.Item
rightSection={<RiSettings2Line />}
onClick={handleSettingsModal}
>
Settings
</DropdownMenu.Item>
<DropdownMenu.Divider />
<DropdownMenu.Item
rightSection={<RiEdit2Line />}
onClick={handleManageServersModal}
>
Manage servers
</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
);
};

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