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
+3
View File
@@ -0,0 +1,3 @@
import './player';
import './media-keys';
import './settings';
@@ -0,0 +1,27 @@
import type { BrowserWindow } from 'electron';
import { globalShortcut } from 'electron';
export const enableMediaKeys = (window: BrowserWindow | null) => {
globalShortcut.register('MediaStop', () => {
window?.webContents.send('renderer-player-stop');
});
globalShortcut.register('MediaPlayPause', () => {
window?.webContents.send('renderer-player-play-pause');
});
globalShortcut.register('MediaNextTrack', () => {
window?.webContents.send('renderer-player-next');
});
globalShortcut.register('MediaPreviousTrack', () => {
window?.webContents.send('renderer-player-previous');
});
};
export const disableMediaKeys = () => {
globalShortcut.unregister('MediaStop');
globalShortcut.unregister('MediaPlayPause');
globalShortcut.unregister('MediaNextTrack');
globalShortcut.unregister('MediaPreviousTrack');
};
@@ -0,0 +1,167 @@
import { ipcMain } from 'electron';
import MpvAPI from 'node-mpv';
import { store } from '../settings';
import uniq from 'lodash/uniq';
import type { PlayerData } from '../../../../../renderer/src/store/player.store';
import { getBrowserWindow } from '/@/mainWindow';
declare module 'node-mpv';
const BINARY_PATH = store.get('mpv_path') as string | undefined;
const MPV_PARAMETERS = store.get('mpv_parameters') as Array<string> | undefined;
const DEFAULT_MPV_PARAMETERS = () => {
const parameters = [];
if (
!MPV_PARAMETERS?.includes('--gapless-audio=weak') ||
!MPV_PARAMETERS?.includes('--gapless-audio=no') ||
!MPV_PARAMETERS?.includes('--gapless-audio=yes') ||
!MPV_PARAMETERS?.includes('--gapless-audio')
) {
parameters.push('--gapless-audio=yes');
}
if (
!MPV_PARAMETERS?.includes('--prefetch-playlist=no') ||
!MPV_PARAMETERS?.includes('--prefetch-playlist=yes') ||
!MPV_PARAMETERS?.includes('--prefetch-playlist')
) {
parameters.push('--prefetch-playlist=yes');
}
return parameters;
};
const mpv = new MpvAPI(
{
audio_only: true,
auto_restart: true,
binary: BINARY_PATH || '',
time_update: 1,
},
MPV_PARAMETERS
? uniq([...DEFAULT_MPV_PARAMETERS(), ...MPV_PARAMETERS])
: DEFAULT_MPV_PARAMETERS(),
);
mpv
.start()
.then(async () => {
// await mpv.load('https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3', 'replace');
// await mpv.play();
})
.catch((error) => {
console.log('error', error);
});
mpv.on('status', (status) => {
if (status.property === 'playlist-pos') {
if (status.value !== 0) {
getBrowserWindow()?.webContents.send('renderer-player-auto-next');
}
}
});
// Automatically updates the play button when the player is playing
mpv.on('resumed', () => {
getBrowserWindow()?.webContents.send('renderer-player-play');
});
// Automatically updates the play button when the player is stopped
mpv.on('stopped', () => {
getBrowserWindow()?.webContents.send('renderer-player-stop');
});
// Automatically updates the play button when the player is paused
mpv.on('paused', () => {
getBrowserWindow()?.webContents.send('renderer-player-pause');
});
mpv.on('quit', () => {
console.log('mpv quit');
});
// Event output every interval set by time_update, used to update the current time
mpv.on('timeposition', (time: number) => {
getBrowserWindow()?.webContents.send('renderer-player-current-time', time);
});
// Starts the player
ipcMain.on('player-play', async () => {
await mpv.play();
});
// Pauses the player
ipcMain.on('player-pause', async () => {
await mpv.pause();
});
// Stops the player
ipcMain.on('player-stop', async () => {
await mpv.stop();
});
// Goes to the next track in the playlist
ipcMain.on('player-next', async () => {
await mpv.next();
});
// Goes to the previous track in the playlist
ipcMain.on('player-previous', async () => {
await mpv.prev();
});
// Seeks forward or backward by the given amount of seconds
ipcMain.on('player-seek', async (_event, time: number) => {
await mpv.seek(time);
});
// Seeks to the given time in seconds
ipcMain.on('player-seek-to', async (_event, time: number) => {
await mpv.goToPosition(time);
});
// Sets the queue in position 0 and 1 to the given data. Used when manually starting a song or using the next/prev buttons
ipcMain.on('player-set-queue', async (_event, data: PlayerData) => {
if (data.queue.current) {
await mpv.load(data.queue.current.streamUrl, 'replace');
}
if (data.queue.next) {
await mpv.load(data.queue.next.streamUrl, 'append');
}
});
// Replaces the queue in position 1 to the given data
ipcMain.on('player-set-queue-next', async (_event, data: PlayerData) => {
const size = await mpv.getPlaylistSize();
if (size > 1) {
await mpv.playlistRemove(1);
}
if (data.queue.next) {
await mpv.load(data.queue.next.streamUrl, 'append');
}
});
// Sets the next song in the queue when reaching the end of the queue
ipcMain.on('player-auto-next', async (_event, data: PlayerData) => {
// Always keep the current song as position 0 in the mpv queue
// This allows us to easily set update the next song in the queue without
// disturbing the currently playing song
await mpv.playlistRemove(0);
if (data.queue.next) {
await mpv.load(data.queue.next.streamUrl, 'append');
}
});
// Sets the volume to the given value (0-100)
ipcMain.on('player-volume', async (_event, value: number) => {
mpv.volume(value);
});
// Toggles the mute status
ipcMain.on('player-mute', async () => {
mpv.mute();
});
@@ -0,0 +1,3 @@
import Store from 'electron-store';
export const store = new Store();
+3
View File
@@ -0,0 +1,3 @@
import './core';
// require(`./${process.platform}`);
+71
View File
@@ -0,0 +1,71 @@
import { app, ipcMain } from 'electron';
import './security-restrictions';
import { restoreOrCreateWindow } from '/@/mainWindow';
ipcMain.on('app-restart', () => {
app.relaunch();
app.exit(0);
});
/**
* Prevent electron from running multiple instances.
*/
const isSingleInstance = app.requestSingleInstanceLock();
if (!isSingleInstance) {
app.quit();
process.exit(0);
}
app.on('second-instance', restoreOrCreateWindow);
/**
* Disable Hardware Acceleration to save more system resources.
*/
app.disableHardwareAcceleration();
/**
* Shout down background process if all windows was closed
*/
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
/**
* @see https://www.electronjs.org/docs/latest/api/app#event-activate-macos Event: 'activate'.
*/
app.on('activate', restoreOrCreateWindow);
/**
* Create the application window when the background process is ready.
*/
app
.whenReady()
.then(restoreOrCreateWindow)
.catch((e) => console.error('Failed create window:', e));
/**
* Install Vue.js or any other extension in development mode only.
* Note: You must install `electron-devtools-installer` manually
*/
// if (import.meta.env.DEV) {
// app.whenReady()
// .then(() => import('electron-devtools-installer'))
// .then(({default: installExtension, VUEJS3_DEVTOOLS}) => installExtension(VUEJS3_DEVTOOLS, {
// loadExtensionOptions: {
// allowFileAccess: true,
// },
// }))
// .catch(e => console.error('Failed install extension:', e));
// }
/**
* Check for new version of the application - production mode only.
*/
if (import.meta.env.PROD) {
app
.whenReady()
.then(() => import('electron-updater'))
.then(({ autoUpdater }) => autoUpdater.checkForUpdatesAndNotify())
.catch((e) => console.error('Failed check updates:', e));
}
+137
View File
@@ -0,0 +1,137 @@
import { app, BrowserWindow, ipcMain } from 'electron';
import { join } from 'path';
import { URL } from 'url';
import { disableMediaKeys, enableMediaKeys } from './features/core/media-keys';
import { store } from './features/core/settings';
import electronLocalShortcut from 'electron-localshortcut';
import './features';
const isDevelopment = process.env.NODE_ENV === 'development';
const installExtensions = async () => {
const installer = require('electron-devtools-installer');
const forceDownload = !!process.env.UPGRADE_EXTENSIONS;
const extensions = ['REACT_DEVELOPER_TOOLS', 'REDUX_DEVTOOLS'];
return installer
.default(
extensions.map((name) => installer[name]),
forceDownload,
)
.catch(console.log);
};
let browserWindow: BrowserWindow | null = null;
async function createWindow() {
if (isDevelopment) {
await installExtensions();
}
browserWindow = new BrowserWindow({
show: false, // Use the 'ready-to-show' event to show the instantiated BrowserWindow.
frame: false,
minWidth: 640,
minHeight: 600,
height: 900,
width: 1440,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
sandbox: false, // Sandbox disabled because the demo of preload script depend on the Node.js api
webviewTag: false, // The webview tag is not recommended. Consider alternatives like an iframe or Electron's BrowserView. @see https://www.electronjs.org/docs/latest/api/webview-tag#warning
preload: join(app.getAppPath(), 'packages/preload/dist/index.cjs'),
backgroundThrottling: false,
},
});
/**
* If the 'show' property of the BrowserWindow's constructor is omitted from the initialization options,
* it then defaults to 'true'. This can cause flickering as the window loads the html content,
* and it also has show problematic behaviour with the closing of the window.
* Use `show: false` and listen to the `ready-to-show` event to show the window.
*
* @see https://github.com/electron/electron/issues/25012 for the afford mentioned issue.
*/
browserWindow.on('ready-to-show', () => {
browserWindow?.show();
if (import.meta.env.DEV) {
browserWindow?.webContents.openDevTools();
}
});
ipcMain.on('window-maximize', () => {
browserWindow?.maximize();
});
ipcMain.on('window-unmaximize', () => {
browserWindow?.unmaximize();
});
ipcMain.on('window-minimize', () => {
browserWindow?.minimize();
});
ipcMain.on('window-close', () => {
browserWindow?.close();
});
electronLocalShortcut.register(browserWindow, 'Ctrl+Shift+I', () => {
browserWindow?.webContents.openDevTools();
});
const globalMediaKeysEnabled = store.get('global_media_hotkeys') as boolean;
if (globalMediaKeysEnabled) {
enableMediaKeys(browserWindow);
}
ipcMain.on('global-media-keys-enable', () => {
enableMediaKeys(browserWindow);
});
ipcMain.on('global-media-keys-disable', () => {
disableMediaKeys();
});
/**
* URL for main window.
* Vite dev server for development.
* `file://../renderer/index.html` for production and test.
*/
const pageUrl =
import.meta.env.DEV && import.meta.env.VITE_DEV_SERVER_URL !== undefined
? import.meta.env.VITE_DEV_SERVER_URL
: new URL('../renderer/dist/index.html', 'file://' + __dirname).toString();
await browserWindow.loadURL(pageUrl);
return browserWindow;
}
/**
* Restore an existing BrowserWindow or Create a new BrowserWindow.
*/
export async function restoreOrCreateWindow() {
let window = BrowserWindow.getAllWindows().find((w) => !w.isDestroyed());
if (window === undefined) {
window = await createWindow();
}
if (window.isMinimized()) {
window.restore();
}
window.focus();
}
ipcMain.on('app-restart', () => {
app.relaunch();
app.exit(0);
});
export const getBrowserWindow = () => {
return browserWindow;
};
+135
View File
@@ -0,0 +1,135 @@
import {app, shell} from 'electron';
import {URL} from 'url';
type Permissions =
| 'clipboard-read'
| 'media'
| 'display-capture'
| 'mediaKeySystem'
| 'geolocation'
| 'notifications'
| 'midi'
| 'midiSysex'
| 'pointerLock'
| 'fullscreen'
| 'openExternal'
| 'unknown';
/**
* A list of origins that you allow open INSIDE the application and permissions for them.
*
* In development mode you need allow open `VITE_DEV_SERVER_URL`.
*/
const ALLOWED_ORIGINS_AND_PERMISSIONS = new Map<string, Set<Permissions>>(
import.meta.env.DEV && import.meta.env.VITE_DEV_SERVER_URL
? [[new URL(import.meta.env.VITE_DEV_SERVER_URL).origin, new Set()]]
: [],
);
/**
* A list of origins that you allow open IN BROWSER.
* Navigation to the origins below is only possible if the link opens in a new window.
*
* @example
* <a
* target="_blank"
* href="https://github.com/"
* >
*/
const ALLOWED_EXTERNAL_ORIGINS = new Set<`https://${string}`>(['https://github.com']);
app.on('web-contents-created', (_, contents) => {
/**
* Block navigation to origins not on the allowlist.
*
* Navigation exploits are quite common. If an attacker can convince the app to navigate away from its current page,
* they can possibly force the app to open arbitrary web resources/websites on the web.
*
* @see https://www.electronjs.org/docs/latest/tutorial/security#13-disable-or-limit-navigation
*/
contents.on('will-navigate', (event, url) => {
const {origin} = new URL(url);
if (ALLOWED_ORIGINS_AND_PERMISSIONS.has(origin)) {
return;
}
// Prevent navigation
event.preventDefault();
if (import.meta.env.DEV) {
console.warn(`Blocked navigating to disallowed origin: ${origin}`);
}
});
/**
* Block requests for disallowed permissions.
* By default, Electron will automatically approve all permission requests.
*
* @see https://www.electronjs.org/docs/latest/tutorial/security#5-handle-session-permission-requests-from-remote-content
*/
contents.session.setPermissionRequestHandler((webContents, permission, callback) => {
const {origin} = new URL(webContents.getURL());
const permissionGranted = !!ALLOWED_ORIGINS_AND_PERMISSIONS.get(origin)?.has(permission);
callback(permissionGranted);
if (!permissionGranted && import.meta.env.DEV) {
console.warn(`${origin} requested permission for '${permission}', but was rejected.`);
}
});
/**
* Hyperlinks leading to allowed sites are opened in the default browser.
*
* The creation of new `webContents` is a common attack vector. Attackers attempt to convince the app to create new windows,
* frames, or other renderer processes with more privileges than they had before; or with pages opened that they couldn't open before.
* You should deny any unexpected window creation.
*
* @see https://www.electronjs.org/docs/latest/tutorial/security#14-disable-or-limit-creation-of-new-windows
* @see https://www.electronjs.org/docs/latest/tutorial/security#15-do-not-use-openexternal-with-untrusted-content
*/
contents.setWindowOpenHandler(({url}) => {
const {origin} = new URL(url);
// @ts-expect-error Type checking is performed in runtime.
if (ALLOWED_EXTERNAL_ORIGINS.has(origin)) {
// Open url in default browser.
shell.openExternal(url).catch(console.error);
} else if (import.meta.env.DEV) {
console.warn(`Blocked the opening of a disallowed origin: ${origin}`);
}
// Prevent creating a new window.
return {action: 'deny'};
});
/**
* Verify webview options before creation.
*
* Strip away preload scripts, disable Node.js integration, and ensure origins are on the allowlist.
*
* @see https://www.electronjs.org/docs/latest/tutorial/security#12-verify-webview-options-before-creation
*/
contents.on('will-attach-webview', (event, webPreferences, params) => {
const {origin} = new URL(params.src);
if (!ALLOWED_ORIGINS_AND_PERMISSIONS.has(origin)) {
if (import.meta.env.DEV) {
console.warn(`A webview tried to attach ${params.src}, but was blocked.`);
}
event.preventDefault();
return;
}
// Strip away preload scripts if unused or verify their location is legitimate.
delete webPreferences.preload;
// @ts-expect-error `preloadURL` exists. - @see https://www.electronjs.org/docs/latest/api/web-contents#event-will-attach-webview
delete webPreferences.preloadURL;
// Disable Node.js integration
webPreferences.nodeIntegration = false;
// Enable contextIsolation
webPreferences.contextIsolation = true;
});
});
+72
View File
@@ -0,0 +1,72 @@
import type {MockedClass} from 'vitest';
import {beforeEach, expect, test, vi} from 'vitest';
import {restoreOrCreateWindow} from '../src/mainWindow';
import {BrowserWindow} from 'electron';
/**
* Mock real electron BrowserWindow API
*/
vi.mock('electron', () => {
// Use "as unknown as" because vi.fn() does not have static methods
const bw = vi.fn() as unknown as MockedClass<typeof BrowserWindow>;
bw.getAllWindows = vi.fn(() => bw.mock.instances);
bw.prototype.loadURL = vi.fn((_: string, __?: Electron.LoadURLOptions) => Promise.resolve());
// Use "any" because the on function is overloaded
// eslint-disable-next-line @typescript-eslint/no-explicit-any
bw.prototype.on = vi.fn<any>();
bw.prototype.destroy = vi.fn();
bw.prototype.isDestroyed = vi.fn();
bw.prototype.isMinimized = vi.fn();
bw.prototype.focus = vi.fn();
bw.prototype.restore = vi.fn();
const app: Pick<Electron.App, 'getAppPath'> = {
getAppPath(): string {
return '';
},
};
return {BrowserWindow: bw, app};
});
beforeEach(() => {
vi.clearAllMocks();
});
test('Should create a new window', async () => {
const {mock} = vi.mocked(BrowserWindow);
expect(mock.instances).toHaveLength(0);
await restoreOrCreateWindow();
expect(mock.instances).toHaveLength(1);
expect(mock.instances[0].loadURL).toHaveBeenCalledOnce();
expect(mock.instances[0].loadURL).toHaveBeenCalledWith(expect.stringMatching(/index\.html$/));
});
test('Should restore an existing window', async () => {
const {mock} = vi.mocked(BrowserWindow);
// Create a window and minimize it.
await restoreOrCreateWindow();
expect(mock.instances).toHaveLength(1);
const appWindow = vi.mocked(mock.instances[0]);
appWindow.isMinimized.mockReturnValueOnce(true);
await restoreOrCreateWindow();
expect(mock.instances).toHaveLength(1);
expect(appWindow.restore).toHaveBeenCalledOnce();
});
test('Should create a new window if the previous one was destroyed', async () => {
const {mock} = vi.mocked(BrowserWindow);
// Create a window and destroy it.
await restoreOrCreateWindow();
expect(mock.instances).toHaveLength(1);
const appWindow = vi.mocked(mock.instances[0]);
appWindow.isDestroyed.mockReturnValueOnce(true);
await restoreOrCreateWindow();
expect(mock.instances).toHaveLength(2);
});
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"module": "esnext",
"target": "esnext",
"sourceMap": false,
"moduleResolution": "Node",
"esModuleInterop": true,
"skipLibCheck": true,
"strict": true,
"isolatedModules": true,
"types": ["node"],
"baseUrl": ".",
"paths": {
"/@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "../../types/**/*.d.ts"],
"exclude": ["**/*.spec.ts", "**/*.test.ts"]
}
+43
View File
@@ -0,0 +1,43 @@
import {node} from '../../.electron-vendors.cache.json';
import {join} from 'node:path';
import {injectAppVersion} from '../../version/inject-app-version-plugin.mjs';
const PACKAGE_ROOT = __dirname;
const PROJECT_ROOT = join(PACKAGE_ROOT, '../..');
/**
* @type {import('vite').UserConfig}
* @see https://vitejs.dev/config/
*/
const config = {
mode: process.env.MODE,
root: PACKAGE_ROOT,
envDir: PROJECT_ROOT,
resolve: {
alias: {
'/@/': join(PACKAGE_ROOT, 'src') + '/',
},
},
build: {
ssr: true,
sourcemap: 'inline',
target: `node${node}`,
outDir: 'dist',
assetsDir: '.',
minify: process.env.MODE !== 'development',
lib: {
entry: 'src/index.ts',
formats: ['cjs'],
},
rollupOptions: {
output: {
entryFileNames: '[name].cjs',
},
},
emptyOutDir: true,
reportCompressedSize: false,
},
plugins: [injectAppVersion(PROJECT_ROOT)],
};
export default config;
+21
View File
@@ -0,0 +1,21 @@
import { ipcRenderer } from 'electron';
const exit = () => {
ipcRenderer.send('window-close');
};
const maximize = () => {
ipcRenderer.send('window-maximize');
};
const minimize = () => {
ipcRenderer.send('window-minimize');
};
const unmaximize = () => {
ipcRenderer.send('window-unmaximize');
};
export const browser = {
minimize,
maximize,
unmaximize,
exit,
};
+10
View File
@@ -0,0 +1,10 @@
/**
* @module preload
*/
export { sha256sum } from './node-crypto';
export { versions } from './versions';
export { localSettings } from './local-settings';
export { browser } from './browser';
export { mpvPlayer, mpvPlayerListener } from './mpv-player';
export { ipc } from './ipc';
+9
View File
@@ -0,0 +1,9 @@
import { ipcRenderer } from 'electron';
const removeAllListeners = (channel: string) => {
ipcRenderer.removeAllListeners(channel);
};
export const ipc = {
removeAllListeners,
};
+33
View File
@@ -0,0 +1,33 @@
import Store from 'electron-store';
import { app, ipcRenderer } from 'electron';
const store = new Store();
const set = (property: string, value: string | Record<string, unknown> | boolean) => {
store.set(`${property}`, value);
};
const get = (property: string) => {
return store.get(`${property}`);
};
const restart = () => {
app.relaunch();
app.exit(0);
};
const enableMediaKeys = () => {
ipcRenderer.send('global-media-keys-enable');
};
const disableMediaKeys = () => {
ipcRenderer.send('global-media-keys-disable');
};
export const localSettings = {
set,
get,
enableMediaKeys,
disableMediaKeys,
restart,
};
+103
View File
@@ -0,0 +1,103 @@
import type { IpcRendererEvent } from 'electron';
import { ipcRenderer } from 'electron';
import type { PlayerData } from '../../renderer/src/store/player.store';
const autoNext = (data: PlayerData) => {
ipcRenderer.send('player-auto-next', data);
};
const currentTime = () => {
ipcRenderer.send('player-current-time');
};
const mute = () => {
ipcRenderer.send('player-mute');
};
const next = () => {
ipcRenderer.send('player-next');
};
const pause = () => {
ipcRenderer.send('player-pause');
};
const play = () => {
ipcRenderer.send('player-play');
};
const previous = () => {
ipcRenderer.send('player-previous');
};
const seek = (seconds: number) => {
ipcRenderer.send('player-seek', seconds);
};
const seekTo = (seconds: number) => {
ipcRenderer.send('player-seek-to', seconds);
};
const setQueue = (data: PlayerData) => {
ipcRenderer.send('player-set-queue', data);
};
const setQueueNext = (data: PlayerData) => {
ipcRenderer.send('player-set-queue-next', data);
};
const stop = () => {
ipcRenderer.send('player-stop');
};
const volume = (value: number) => {
ipcRenderer.send('player-volume', value);
};
const rendererAutoNext = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-auto-next', cb);
};
const rendererCurrentTime = (cb: (event: IpcRendererEvent, data: number) => void) => {
ipcRenderer.on('renderer-player-current-time', cb);
};
const rendererNext = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-next', cb);
};
const rendererPause = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-pause', cb);
};
const rendererPlay = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-play', cb);
};
const rendererPlayPause = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-play-pause', cb);
};
const rendererPrevious = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-previous', cb);
};
const rendererStop = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-stop', cb);
};
export const mpvPlayer = {
autoNext,
mute,
next,
previous,
pause,
seek,
seekTo,
setQueue,
setQueueNext,
stop,
volume,
play,
currentTime,
};
export const mpvPlayerListener = {
rendererAutoNext,
rendererCurrentTime,
rendererNext,
rendererPause,
rendererPlay,
rendererPlayPause,
rendererPrevious,
rendererStop,
};
+5
View File
@@ -0,0 +1,5 @@
import { type BinaryLike, createHash } from 'crypto';
export function sha256sum(data: BinaryLike) {
return createHash('sha256').update(data).digest('hex');
}
+1
View File
@@ -0,0 +1 @@
export const versions = process.versions;
+15
View File
@@ -0,0 +1,15 @@
import {createHash} from 'crypto';
import {expect, test} from 'vitest';
import {sha256sum, versions} from '../src';
test('versions', async () => {
expect(versions).toBe(process.versions);
});
test('nodeCrypto', async () => {
// Test hashing a random string.
const testString = Math.random().toString(36).slice(2, 7);
const expectedHash = createHash('sha256').update(testString).digest('hex');
expect(sha256sum(testString)).toBe(expectedHash);
});
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"module": "esnext",
"target": "esnext",
"sourceMap": false,
"moduleResolution": "Node",
"esModuleInterop": true,
"skipLibCheck": true,
"strict": true,
"isolatedModules": true,
"types": ["node"],
"baseUrl": "."
},
"include": ["src/**/*.ts", "../../types/**/*.d.ts"],
"exclude": ["**/*.spec.ts", "**/*.test.ts"]
}
+39
View File
@@ -0,0 +1,39 @@
import {chrome} from '../../.electron-vendors.cache.json';
import {preload} from 'unplugin-auto-expose';
import {join} from 'node:path';
import {injectAppVersion} from '../../version/inject-app-version-plugin.mjs';
const PACKAGE_ROOT = __dirname;
const PROJECT_ROOT = join(PACKAGE_ROOT, '../..');
/**
* @type {import('vite').UserConfig}
* @see https://vitejs.dev/config/
*/
const config = {
mode: process.env.MODE,
root: PACKAGE_ROOT,
envDir: PROJECT_ROOT,
build: {
ssr: true,
sourcemap: 'inline',
target: `chrome${chrome}`,
outDir: 'dist',
assetsDir: '.',
minify: process.env.MODE !== 'development',
lib: {
entry: 'src/index.ts',
formats: ['cjs'],
},
rollupOptions: {
output: {
entryFileNames: '[name].cjs',
},
},
emptyOutDir: true,
reportCompressedSize: false,
},
plugins: [preload.vite(), injectAppVersion(PROJECT_ROOT)],
};
export default config;
+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,
};
};

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