Add files

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

View file

@ -0,0 +1,9 @@
import '@testing-library/jest-dom';
import { render } from '@testing-library/react';
import { App } from '../renderer/app';
describe('App', () => {
it('should render', () => {
expect(render(<App />)).toBeTruthy();
});
});

32
src/i18n/i18n.js Normal file
View file

@ -0,0 +1,32 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
const en = require('./locales/en.json');
const resources = {
en: { translation: en },
};
export const Languages = [
{
label: 'English',
value: 'en',
},
];
i18n
.use(initReactI18next) // passes i18n down to react-i18next
.init({
fallbackLng: 'en',
// language to use, more information here: https://www.i18next.com/overview/configuration-options#languages-namespaces-resources
// you can use the i18n.changeLanguage function to change the language manually: https://www.i18next.com/overview/api#changelanguage
// if you're using a language detector, do not define the lng option
interpolation: {
escapeValue: false, // react already safes from xss
},
lng: 'en',
resources,
});
export default i18n;

View file

@ -0,0 +1,117 @@
// i18next-parser.config.js
module.exports = {
contextSeparator: '_',
// Key separator used in your translation keys
createOldCatalogs: true,
// Exit with an exit code of 1 when translations are updated (for CI purpose)
customValueTemplate: null,
// Save the \_old files
defaultNamespace: 'translation',
// Default namespace used in your i18next config
defaultValue: '',
// Exit with an exit code of 1 on warnings
failOnUpdate: false,
// Display info about the parsing including some stats
failOnWarnings: false,
// The locale to compare with default values to determine whether a default value has been changed.
// If this is set and a default value differs from a translation in the specified locale, all entries
// for that key across locales are reset to the default value, and existing translations are moved to
// the `_old` file.
i18nextOptions: null,
// Default value to give to empty keys
// You may also specify a function accepting the locale, namespace, and key as arguments
indentation: 2,
// Plural separator used in your translation keys
// If you want to use plain english keys, separators such as `_` might conflict. You might want to set `pluralSeparator` to a different string that does not occur in your keys.
input: [
'../components/**/*.{js,jsx,ts,tsx}',
'../features/**/*.{js,jsx,ts,tsx}',
'../layouts/**/*.{js,jsx,ts,tsx}',
'!../../src/node_modules/**',
'!../../src/**/*.prod.js',
],
// Indentation of the catalog files
keepRemoved: false,
// Keep keys from the catalog that are no longer in code
keySeparator: '.',
// Key separator used in your translation keys
// If you want to use plain english keys, separators such as `.` and `:` will conflict. You might want to set `keySeparator: false` and `namespaceSeparator: false`. That way, `t('Status: Loading...')` will not think that there are a namespace and three separator dots for instance.
// see below for more details
lexers: {
default: ['JavascriptLexer'],
handlebars: ['HandlebarsLexer'],
hbs: ['HandlebarsLexer'],
htm: ['HTMLLexer'],
html: ['HTMLLexer'],
js: ['JavascriptLexer'],
jsx: ['JsxLexer'],
mjs: ['JavascriptLexer'],
// if you're writing jsx inside .js files, change this to JsxLexer
ts: ['JavascriptLexer'],
tsx: ['JsxLexer'],
},
lineEnding: 'auto',
// Control the line ending. See options at https://github.com/ryanve/eol
locales: ['en'],
// An array of the locales in your applications
namespaceSeparator: false,
// Namespace separator used in your translation keys
// If you want to use plain english keys, separators such as `.` and `:` will conflict. You might want to set `keySeparator: false` and `namespaceSeparator: false`. That way, `t('Status: Loading...')` will not think that there are a namespace and three separator dots for instance.
output: 'src/renderer/i18n/locales/$LOCALE.json',
// Supports $LOCALE and $NAMESPACE injection
// Supports JSON (.json) and YAML (.yml) file formats
// Where to write the locale files relative to process.cwd()
pluralSeparator: '_',
// If you wish to customize the value output the value as an object, you can set your own format.
// ${defaultValue} is the default value you set in your translation function.
// Any other custom property will be automatically extracted.
//
// Example:
// {
// message: "${defaultValue}",
// description: "${maxLength}", //
// }
resetDefaultValueLocale: 'en',
// Whether or not to sort the catalog. Can also be a [compareFunction](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#parameters)
skipDefaultValues: false,
// An array of globs that describe where to look for source files
// relative to the location of the configuration file
sort: true,
// Whether to ignore default values
// You may also specify a function accepting the locale and namespace as arguments
useKeysAsDefaultValue: true,
// Whether to use the keys as the default value; ex. "Hello": "Hello", "World": "World"
// This option takes precedence over the `defaultValue` and `skipDefaultValues` options
// You may also specify a function accepting the locale and namespace as arguments
verbose: false,
// If you wish to customize options in internally used i18next instance, you can define an object with any
// configuration property supported by i18next (https://www.i18next.com/overview/configuration-options).
// { compatibilityJSON: 'v3' } can be used to generate v3 compatible plurals.
};

9
src/i18n/locales/en.json Normal file
View file

@ -0,0 +1,9 @@
{
"player": {
"next": "player.next",
"play": "player.play",
"prev": "player.prev",
"seekBack": "player.seekBack",
"seekForward": "player.seekForward"
}
}

View file

@ -0,0 +1,2 @@
import './player';
import './settings';

View file

@ -0,0 +1,161 @@
import { ipcMain } from 'electron';
import uniq from 'lodash/uniq';
import MpvAPI from 'node-mpv';
import { store } from '/@/main/features/core/settings';
import { getMainWindow } from '/@/main/main';
import { PlayerData } from '/@/renderer/store';
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().catch((error) => {
console.log('error', error);
});
mpv.on('status', (status) => {
if (status.property === 'playlist-pos') {
if (status.value !== 0) {
getMainWindow()?.webContents.send('renderer-player-auto-next');
}
}
});
// Automatically updates the play button when the player is playing
mpv.on('resumed', () => {
getMainWindow()?.webContents.send('renderer-player-play');
});
// Automatically updates the play button when the player is stopped
mpv.on('stopped', () => {
getMainWindow()?.webContents.send('renderer-player-stop');
});
// Automatically updates the play button when the player is paused
mpv.on('paused', () => {
getMainWindow()?.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) => {
getMainWindow()?.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();
});

View file

@ -0,0 +1,27 @@
/* eslint-disable promise/always-return */
import { BrowserWindow, 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');
};

View file

@ -0,0 +1,12 @@
import { ipcMain } from 'electron';
import Store from 'electron-store';
export const store = new Store();
ipcMain.handle('settings-get', (_event, data: { property: string }) => {
return store.get(`${data.property}`);
});
ipcMain.on('settings-set', (__event, data: { property: string; value: any }) => {
store.set(`${data.property}`, data.value);
});

View file

View file

@ -0,0 +1,4 @@
import './core';
// eslint-disable-next-line import/no-dynamic-require
require(`./${process.platform}`);

View file

View file

187
src/main/main.ts Normal file
View file

@ -0,0 +1,187 @@
/* eslint global-require: off, no-console: off, promise/always-return: off */
/**
* This module executes inside of electron's main process. You can start
* electron renderer process from here and communicate with the other processes
* through IPC.
*
* When running `npm run build` or `npm run build:main`, this file is compiled to
* `./src/main.js` using webpack. This gives us some performance wins.
*/
import path from 'path';
import { app, BrowserWindow, shell, ipcMain, globalShortcut } from 'electron';
import electronLocalShortcut from 'electron-localshortcut';
import log from 'electron-log';
import { autoUpdater } from 'electron-updater';
import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys';
import { store } from './features/core/settings/index';
import MenuBuilder from './menu';
import { resolveHtmlPath } from './utils';
import './features';
export default class AppUpdater {
constructor() {
log.transports.file.level = 'info';
autoUpdater.logger = log;
autoUpdater.checkForUpdatesAndNotify();
}
}
let mainWindow: BrowserWindow | null = null;
if (process.env.NODE_ENV === 'production') {
const sourceMapSupport = require('source-map-support');
sourceMapSupport.install();
}
const isDevelopment = process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true';
if (isDevelopment) {
require('electron-debug')();
}
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);
};
const createWindow = async () => {
if (isDevelopment) {
await installExtensions();
}
const RESOURCES_PATH = app.isPackaged
? path.join(process.resourcesPath, 'assets')
: path.join(__dirname, '../../assets');
const getAssetPath = (...paths: string[]): string => {
return path.join(RESOURCES_PATH, ...paths);
};
mainWindow = new BrowserWindow({
frame: false,
height: 900,
icon: getAssetPath('icon.png'),
minHeight: 600,
minWidth: 640,
show: false,
webPreferences: {
backgroundThrottling: false,
contextIsolation: true,
devTools: true,
nodeIntegration: true,
preload: app.isPackaged
? path.join(__dirname, 'preload.js')
: path.join(__dirname, '../../.erb/dll/preload.js'),
},
width: 1440,
});
electronLocalShortcut.register(mainWindow, 'Ctrl+Shift+I', () => {
mainWindow?.webContents.openDevTools();
});
ipcMain.on('window-maximize', () => {
mainWindow?.maximize();
});
ipcMain.on('window-unmaximize', () => {
mainWindow?.unmaximize();
});
ipcMain.on('window-minimize', () => {
mainWindow?.minimize();
});
ipcMain.on('window-close', () => {
mainWindow?.close();
});
ipcMain.on('app-restart', () => {
app.relaunch();
app.exit(0);
});
ipcMain.on('global-media-keys-enable', () => {
enableMediaKeys(mainWindow);
});
ipcMain.on('global-media-keys-disable', () => {
disableMediaKeys();
});
const globalMediaKeysEnabled = store.get('global_media_hotkeys') as boolean;
if (globalMediaKeysEnabled) {
enableMediaKeys(mainWindow);
}
mainWindow.loadURL(resolveHtmlPath('index.html'));
mainWindow.on('ready-to-show', () => {
if (!mainWindow) {
throw new Error('"mainWindow" is not defined');
}
if (process.env.START_MINIMIZED) {
mainWindow.minimize();
} else {
mainWindow.show();
}
});
mainWindow.on('closed', () => {
mainWindow = null;
});
const menuBuilder = new MenuBuilder(mainWindow);
menuBuilder.buildMenu();
// Open urls in the user's browser
mainWindow.webContents.setWindowOpenHandler((edata) => {
shell.openExternal(edata.url);
return { action: 'deny' };
});
// Remove this if your app does not use auto updates
// eslint-disable-next-line
new AppUpdater();
};
/**
* Add event listeners...
*/
app.commandLine.appendSwitch('disable-features', 'HardwareMediaKeyHandling,MediaSessionService');
export const getMainWindow = () => {
return mainWindow;
};
app.on('window-all-closed', () => {
// Respect the OSX convention of having the application in memory even
// after all windows have been closed
globalShortcut.unregisterAll();
if (process.platform !== 'darwin') {
app.quit();
}
});
app
.whenReady()
.then(() => {
createWindow();
app.on('activate', () => {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (mainWindow === null) createWindow();
});
})
.catch(console.log);

269
src/main/menu.ts Normal file
View file

@ -0,0 +1,269 @@
import { app, Menu, shell, BrowserWindow, MenuItemConstructorOptions } from 'electron';
interface DarwinMenuItemConstructorOptions extends MenuItemConstructorOptions {
selector?: string;
submenu?: DarwinMenuItemConstructorOptions[] | Menu;
}
export default class MenuBuilder {
mainWindow: BrowserWindow;
constructor(mainWindow: BrowserWindow) {
this.mainWindow = mainWindow;
}
buildMenu(): Menu {
if (process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true') {
this.setupDevelopmentEnvironment();
}
const template =
process.platform === 'darwin' ? this.buildDarwinTemplate() : this.buildDefaultTemplate();
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
return menu;
}
setupDevelopmentEnvironment(): void {
this.mainWindow.webContents.on('context-menu', (_, props) => {
const { x, y } = props;
Menu.buildFromTemplate([
{
click: () => {
this.mainWindow.webContents.inspectElement(x, y);
},
label: 'Inspect element',
},
]).popup({ window: this.mainWindow });
});
}
buildDarwinTemplate(): MenuItemConstructorOptions[] {
const subMenuAbout: DarwinMenuItemConstructorOptions = {
label: 'Electron',
submenu: [
{
label: 'About ElectronReact',
selector: 'orderFrontStandardAboutPanel:',
},
{ type: 'separator' },
{ label: 'Services', submenu: [] },
{ type: 'separator' },
{
accelerator: 'Command+H',
label: 'Hide ElectronReact',
selector: 'hide:',
},
{
accelerator: 'Command+Shift+H',
label: 'Hide Others',
selector: 'hideOtherApplications:',
},
{ label: 'Show All', selector: 'unhideAllApplications:' },
{ type: 'separator' },
{
accelerator: 'Command+Q',
click: () => {
app.quit();
},
label: 'Quit',
},
],
};
const subMenuEdit: DarwinMenuItemConstructorOptions = {
label: 'Edit',
submenu: [
{ accelerator: 'Command+Z', label: 'Undo', selector: 'undo:' },
{ accelerator: 'Shift+Command+Z', label: 'Redo', selector: 'redo:' },
{ type: 'separator' },
{ accelerator: 'Command+X', label: 'Cut', selector: 'cut:' },
{ accelerator: 'Command+C', label: 'Copy', selector: 'copy:' },
{ accelerator: 'Command+V', label: 'Paste', selector: 'paste:' },
{
accelerator: 'Command+A',
label: 'Select All',
selector: 'selectAll:',
},
],
};
const subMenuViewDev: MenuItemConstructorOptions = {
label: 'View',
submenu: [
{
accelerator: 'Command+R',
click: () => {
this.mainWindow.webContents.reload();
},
label: 'Reload',
},
{
accelerator: 'Ctrl+Command+F',
click: () => {
this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen());
},
label: 'Toggle Full Screen',
},
{
accelerator: 'Alt+Command+I',
click: () => {
this.mainWindow.webContents.toggleDevTools();
},
label: 'Toggle Developer Tools',
},
],
};
const subMenuViewProd: MenuItemConstructorOptions = {
label: 'View',
submenu: [
{
accelerator: 'Ctrl+Command+F',
click: () => {
this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen());
},
label: 'Toggle Full Screen',
},
],
};
const subMenuWindow: DarwinMenuItemConstructorOptions = {
label: 'Window',
submenu: [
{
accelerator: 'Command+M',
label: 'Minimize',
selector: 'performMiniaturize:',
},
{ accelerator: 'Command+W', label: 'Close', selector: 'performClose:' },
{ type: 'separator' },
{ label: 'Bring All to Front', selector: 'arrangeInFront:' },
],
};
const subMenuHelp: MenuItemConstructorOptions = {
label: 'Help',
submenu: [
{
click() {
shell.openExternal('https://electronjs.org');
},
label: 'Learn More',
},
{
click() {
shell.openExternal('https://github.com/electron/electron/tree/main/docs#readme');
},
label: 'Documentation',
},
{
click() {
shell.openExternal('https://www.electronjs.org/community');
},
label: 'Community Discussions',
},
{
click() {
shell.openExternal('https://github.com/electron/electron/issues');
},
label: 'Search Issues',
},
],
};
const subMenuView =
process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true'
? subMenuViewDev
: subMenuViewProd;
return [subMenuAbout, subMenuEdit, subMenuView, subMenuWindow, subMenuHelp];
}
buildDefaultTemplate() {
const templateDefault = [
{
label: '&File',
submenu: [
{
accelerator: 'Ctrl+O',
label: '&Open',
},
{
accelerator: 'Ctrl+W',
click: () => {
this.mainWindow.close();
},
label: '&Close',
},
],
},
{
label: '&View',
submenu:
process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true'
? [
{
accelerator: 'Ctrl+R',
click: () => {
this.mainWindow.webContents.reload();
},
label: '&Reload',
},
{
accelerator: 'F11',
click: () => {
this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen());
},
label: 'Toggle &Full Screen',
},
{
accelerator: 'Alt+Ctrl+I',
click: () => {
this.mainWindow.webContents.toggleDevTools();
},
label: 'Toggle &Developer Tools',
},
]
: [
{
accelerator: 'F11',
click: () => {
this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen());
},
label: 'Toggle &Full Screen',
},
],
},
{
label: 'Help',
submenu: [
{
click() {
shell.openExternal('https://electronjs.org');
},
label: 'Learn More',
},
{
click() {
shell.openExternal('https://github.com/electron/electron/tree/main/docs#readme');
},
label: 'Documentation',
},
{
click() {
shell.openExternal('https://www.electronjs.org/community');
},
label: 'Community Discussions',
},
{
click() {
shell.openExternal('https://github.com/electron/electron/issues');
},
label: 'Search Issues',
},
],
},
];
return templateDefault;
}
}

109
src/main/preload.ts Normal file
View file

@ -0,0 +1,109 @@
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron';
import { PlayerData } from '../renderer/store';
import { browser } from './preload/browser';
import { ipc } from './preload/ipc';
import { localSettings } from './preload/local-settings';
import { mpvPlayer, mpvPlayerListener } from './preload/mpv-player';
contextBridge.exposeInMainWorld('electron', {
browser,
ipc,
ipcRenderer: {
APP_RESTART() {
ipcRenderer.send('app-restart');
},
PLAYER_AUTO_NEXT(data: PlayerData) {
ipcRenderer.send('player-auto-next', data);
},
PLAYER_CURRENT_TIME() {
ipcRenderer.send('player-current-time');
},
PLAYER_MEDIA_KEYS_DISABLE() {
ipcRenderer.send('global-media-keys-disable');
},
PLAYER_MEDIA_KEYS_ENABLE() {
ipcRenderer.send('global-media-keys-enable');
},
PLAYER_MUTE() {
ipcRenderer.send('player-mute');
},
PLAYER_NEXT() {
ipcRenderer.send('player-next');
},
PLAYER_PAUSE() {
ipcRenderer.send('player-pause');
},
PLAYER_PLAY() {
ipcRenderer.send('player-play');
},
PLAYER_PREVIOUS() {
ipcRenderer.send('player-previous');
},
PLAYER_SEEK(seconds: number) {
ipcRenderer.send('player-seek', seconds);
},
PLAYER_SEEK_TO(seconds: number) {
ipcRenderer.send('player-seek-to', seconds);
},
PLAYER_SET_QUEUE(data: PlayerData) {
ipcRenderer.send('player-set-queue', data);
},
PLAYER_SET_QUEUE_NEXT(data: PlayerData) {
ipcRenderer.send('player-set-queue-next', data);
},
PLAYER_STOP() {
ipcRenderer.send('player-stop');
},
PLAYER_VOLUME(value: number) {
ipcRenderer.send('player-volume', value);
},
RENDERER_PLAYER_AUTO_NEXT(cb: (event: IpcRendererEvent, data: any) => void) {
ipcRenderer.on('renderer-player-auto-next', cb);
},
RENDERER_PLAYER_CURRENT_TIME(cb: (event: IpcRendererEvent, data: any) => void) {
ipcRenderer.on('renderer-player-current-time', cb);
},
RENDERER_PLAYER_NEXT(cb: (event: IpcRendererEvent, data: any) => void) {
ipcRenderer.on('renderer-player-next', cb);
},
RENDERER_PLAYER_PAUSE(cb: (event: IpcRendererEvent, data: any) => void) {
ipcRenderer.on('renderer-player-pause', cb);
},
RENDERER_PLAYER_PLAY(cb: (event: IpcRendererEvent, data: any) => void) {
ipcRenderer.on('renderer-player-play', cb);
},
RENDERER_PLAYER_PLAY_PAUSE(cb: (event: IpcRendererEvent, data: any) => void) {
ipcRenderer.on('renderer-player-play-pause', cb);
},
RENDERER_PLAYER_PREVIOUS(cb: (event: IpcRendererEvent, data: any) => void) {
ipcRenderer.on('renderer-player-previous', cb);
},
RENDERER_PLAYER_STOP(cb: (event: IpcRendererEvent, data: any) => void) {
ipcRenderer.on('renderer-player-stop', cb);
},
SETTINGS_GET(data: { property: string }) {
return ipcRenderer.invoke('settings-get', data);
},
SETTINGS_SET(data: { property: string; value: any }) {
ipcRenderer.send('settings-set', data);
},
removeAllListeners(value: string) {
ipcRenderer.removeAllListeners(value);
},
windowClose() {
ipcRenderer.send('window-close');
},
windowMaximize() {
ipcRenderer.send('window-maximize');
},
windowMinimize() {
ipcRenderer.send('window-minimize');
},
windowUnmaximize() {
ipcRenderer.send('window-unmaximize');
},
},
localSettings,
mpvPlayer,
mpvPlayerListener,
});

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 = {
exit,
maximize,
minimize,
unmaximize,
};

9
src/main/preload/ipc.ts Normal file
View file

@ -0,0 +1,9 @@
import { ipcRenderer } from 'electron';
const removeAllListeners = (channel: string) => {
ipcRenderer.removeAllListeners(channel);
};
export const ipc = {
removeAllListeners,
};

View file

@ -0,0 +1,33 @@
import { app, ipcRenderer } from 'electron';
import Store from 'electron-store';
const store = new Store();
const set = (property: string, value: string | Record<string, unknown> | boolean | string[]) => {
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 = {
disableMediaKeys,
enableMediaKeys,
get,
restart,
set,
};

View file

@ -0,0 +1,102 @@
import { ipcRenderer, IpcRendererEvent } from 'electron';
import { PlayerData } from '/@/renderer/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,
currentTime,
mute,
next,
pause,
play,
previous,
seek,
seekTo,
setQueue,
setQueueNext,
stop,
volume,
};
export const mpvPlayerListener = {
rendererAutoNext,
rendererCurrentTime,
rendererNext,
rendererPause,
rendererPlay,
rendererPlayPause,
rendererPrevious,
rendererStop,
};

31
src/main/utils.ts Normal file
View file

@ -0,0 +1,31 @@
/* eslint import/prefer-default-export: off, import/no-mutable-exports: off */
import path from 'path';
import process from 'process';
import { URL } from 'url';
export let resolveHtmlPath: (htmlFileName: string) => string;
if (process.env.NODE_ENV === 'development') {
const port = process.env.PORT || 4343;
resolveHtmlPath = (htmlFileName: string) => {
const url = new URL(`http://localhost:${port}`);
url.pathname = htmlFileName;
return url.href;
};
} else {
resolveHtmlPath = (htmlFileName: string) => {
return `file://${path.resolve(__dirname, '../renderer/', htmlFileName)}`;
};
}
export const isMacOS = () => {
return process.platform === 'darwin';
};
export const isWindows = () => {
return process.platform === 'win32';
};
export const isLinux = () => {
return process.platform === 'linux';
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,313 @@
export type NDAuthenticate = {
id: string;
isAdmin: boolean;
name: string;
subsonicSalt: string;
subsonicToken: string;
token: string;
username: string;
};
export type NDGenre = {
id: string;
name: string;
};
export type NDAlbum = {
albumArtist: string;
albumArtistId: string;
allArtistIds: string;
artist: string;
artistId: string;
compilation: boolean;
coverArtId: string;
coverArtPath: string;
createdAt: string;
duration: number;
fullText: string;
genre: string;
genres: NDGenre[];
id: string;
maxYear: number;
mbzAlbumArtistId: string;
mbzAlbumId: string;
minYear: number;
name: string;
orderAlbumArtistName: string;
orderAlbumName: string;
playCount: number;
playDate: string;
rating: number;
size: number;
songCount: number;
sortAlbumArtistName: string;
sortArtistName: string;
starred: boolean;
starredAt: string;
updatedAt: string;
};
export type NDSong = {
album: string;
albumArtist: string;
albumArtistId: string;
albumId: string;
artist: string;
artistId: string;
bitRate: number;
bookmarkPosition: number;
channels: number;
compilation: boolean;
createdAt: string;
discNumber: number;
duration: number;
fullText: string;
genre: string;
genres: NDGenre[];
hasCoverArt: boolean;
id: string;
mbzAlbumArtistId: string;
mbzAlbumId: string;
mbzArtistId: string;
mbzTrackId: string;
orderAlbumArtistName: string;
orderAlbumName: string;
orderArtistName: string;
orderTitle: string;
path: string;
playCount: number;
playDate: string;
rating: number;
size: number;
sortAlbumArtistName: string;
sortArtistName: string;
starred: boolean;
starredAt: string;
suffix: string;
title: string;
trackNumber: number;
updatedAt: string;
year: number;
};
export type NDAlbumArtist = {
albumCount: number;
biography: string;
externalInfoUpdatedAt: string;
externalUrl: string;
fullText: string;
genres: NDGenre[];
id: string;
largeImageUrl: string;
mbzArtistId: string;
mediumImageUrl: string;
name: string;
orderArtistName: string;
playCount: number;
playDate: string;
rating: number;
size: number;
smallImageUrl: string;
songCount: number;
starred: boolean;
starredAt: string;
};
export type NDAuthenticationResponse = NDAuthenticate;
export type NDAlbumArtistList = NDAlbumArtist[];
export type NDAlbumArtistDetail = NDAlbumArtist;
export type NDAlbumArtistDetailResponse = NDAlbumArtist;
export type NDGenreList = NDGenre[];
export type NDGenreListResponse = NDGenre[];
export type NDAlbumDetailResponse = NDAlbum;
export type NDAlbumDetail = NDAlbum & { songs?: NDSongListResponse };
export type NDAlbumListResponse = NDAlbum[];
export type NDAlbumList = {
items: NDAlbum[];
startIndex: number;
totalRecordCount: number;
};
export type NDSongDetail = NDSong;
export type NDSongDetailResponse = NDSong;
export type NDSongListResponse = NDSong[];
export type NDSongList = {
items: NDSong[];
startIndex: number;
totalRecordCount: number;
};
export type NDArtistListResponse = NDAlbumArtist[];
export type NDPagination = {
_end?: number;
_start?: number;
};
export enum NDSortOrder {
ASC = 'ASC',
DESC = 'DESC',
}
export type NDOrder = {
_order?: NDSortOrder;
};
export enum NDGenreListSort {
NAME = 'name',
}
export type NDGenreListParams = {
_sort?: NDGenreListSort;
id?: string;
} & NDPagination &
NDOrder;
export enum NDAlbumListSort {
ALBUM_ARTIST = 'albumArtist',
ARTIST = 'artist',
DURATION = 'duration',
NAME = 'name',
PLAY_COUNT = 'playCount',
PLAY_DATE = 'play_date',
RANDOM = 'random',
RATING = 'rating',
RECENTLY_ADDED = 'recently_added',
SONG_COUNT = 'songCount',
STARRED = 'starred',
YEAR = 'max_year',
}
export type NDAlbumListParams = {
_sort?: NDAlbumListSort;
album_id?: string;
artist_id?: string;
compilation?: boolean;
genre_id?: string;
has_rating?: boolean;
id?: string;
name?: string;
recently_played?: boolean;
starred?: boolean;
year?: number;
} & NDPagination &
NDOrder;
export enum NDSongListSort {
ALBUM = 'album',
ALBUM_ARTIST = 'albumArtist',
ARTIST = 'artist',
BPM = 'bpm',
CHANNELS = 'channels',
COMMENT = 'comment',
DURATION = 'duration',
FAVORITED = 'starred ASC, starredAt ASC',
GENRE = 'genre',
ID = 'id',
PLAY_COUNT = 'playCount',
PLAY_DATE = 'playDate',
RATING = 'rating',
TITLE = 'title',
TRACK = 'track',
YEAR = 'year',
}
export type NDSongListParams = {
_sort?: NDSongListSort;
genre_id?: string;
starred?: boolean;
} & NDPagination &
NDOrder;
export enum NDAlbumArtistListSort {
ALBUM_COUNT = 'albumCount',
FAVORITED = 'starred ASC, starredAt ASC',
NAME = 'name',
PLAY_COUNT = 'playCount',
RATING = 'rating',
SONG_COUNT = 'songCount',
}
export type NDAlbumArtistListParams = {
_sort?: NDAlbumArtistListSort;
genre_id?: string;
starred?: boolean;
} & NDPagination &
NDOrder;
export type NDCreatePlaylistParams = {
comment?: string;
name: string;
public: boolean;
};
export type NDCreatePlaylistResponse = {
id: string;
};
export type NDCreatePlaylist = NDCreatePlaylistResponse;
export type NDDeletePlaylistParams = {
id: string;
};
export type NDDeletePlaylistResponse = null;
export type NDDeletePlaylist = NDDeletePlaylistResponse;
export type NDPlaylist = {
comment: string;
createdAt: string;
duration: number;
evaluatedAt: string;
id: string;
name: string;
ownerId: string;
ownerName: string;
path: string;
public: boolean;
rules: null;
size: number;
songCount: number;
sync: boolean;
updatedAt: string;
};
export type NDPlaylistDetail = NDPlaylist;
export type NDPlaylistDetailResponse = NDPlaylist;
export type NDPlaylistList = {
items: NDPlaylist[];
startIndex: number;
totalRecordCount: number;
};
export type NDPlaylistListResponse = NDPlaylist[];
export enum NDPlaylistListSort {
DURATION = 'duration',
NAME = 'name',
OWNER = 'owner',
PUBLIC = 'public',
SONG_COUNT = 'songCount',
UPDATED_AT = 'updatedAt',
}
export type NDPlaylistListParams = {
_sort?: NDPlaylistListSort;
owner_id?: string;
} & NDPagination &
NDOrder;

View file

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

View file

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

View file

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

View file

@ -0,0 +1,184 @@
export type SSBaseResponse = {
serverVersion?: 'string';
status: 'string';
type?: 'string';
version: 'string';
};
export type SSMusicFolderList = SSMusicFolder[];
export type SSMusicFolderListResponse = {
musicFolders: {
musicFolder: SSMusicFolder[];
};
};
export type SSGenreList = SSGenre[];
export type SSGenreListResponse = {
genres: {
genre: SSGenre[];
};
};
export type SSAlbumArtistDetailParams = {
id: string;
};
export type SSAlbumArtistDetail = SSAlbumArtistListEntry & { album: SSAlbumListEntry[] };
export type SSAlbumArtistDetailResponse = {
artist: SSAlbumArtistListEntry & {
album: SSAlbumListEntry[];
};
};
export type SSAlbumArtistList = SSAlbumArtistListEntry[];
export type SSAlbumArtistListResponse = {
artists: {
ignoredArticles: string;
index: SSArtistIndex[];
lastModified: number;
};
};
export type SSAlbumList = {
items: SSAlbumListEntry[];
startIndex: number;
totalRecordCount: number | null;
};
export type SSAlbumListResponse = {
albumList2: {
album: SSAlbumListEntry[];
};
};
export type SSAlbumDetail = Omit<SSAlbum, 'song'> & { songs: SSSong[] };
export type SSAlbumDetailResponse = {
album: SSAlbum;
};
export type SSArtistInfoResponse = {
artistInfo2: SSArtistInfo;
};
export type SSArtistInfo = {
biography: string;
largeImageUrl?: string;
lastFmUrl?: string;
mediumImageUrl?: string;
musicBrainzId?: string;
smallImageUrl?: string;
};
export type SSMusicFolder = {
id: number;
name: string;
};
export type SSGenre = {
albumCount?: number;
songCount?: number;
value: string;
};
export type SSArtistIndex = {
artist: SSAlbumArtistListEntry[];
name: string;
};
export type SSAlbumArtistListEntry = {
albumCount: string;
artistImageUrl?: string;
coverArt?: string;
id: string;
name: string;
};
export type SSAlbumListEntry = {
album: string;
artist: string;
artistId: string;
coverArt: string;
created: string;
duration: number;
genre?: string;
id: string;
isDir: boolean;
isVideo: boolean;
name: string;
parent: string;
songCount: number;
starred?: boolean;
title: string;
userRating?: number;
year: number;
};
export type SSAlbum = {
song: SSSong[];
} & SSAlbumListEntry;
export type SSSong = {
album: string;
albumId: string;
artist: string;
artistId?: string;
bitRate: number;
contentType: string;
coverArt: string;
created: string;
discNumber?: number;
duration: number;
genre: string;
id: string;
isDir: boolean;
isVideo: boolean;
parent: string;
path: string;
playCount: number;
size: number;
starred?: boolean;
suffix: string;
title: string;
track: number;
type: string;
userRating?: number;
year: number;
};
export type SSAlbumListParams = {
fromYear?: number;
genre?: string;
musicFolderId?: string;
offset?: number;
size?: number;
toYear?: number;
type: string;
};
export type SSAlbumArtistListParams = {
musicFolderId?: string;
};
export type SSFavoriteParams = {
albumId?: string;
artistId?: string;
id?: string;
};
export type SSFavorite = null;
export type SSFavoriteResponse = null;
export type SSRatingParams = {
id: string;
rating: number;
};
export type SSRating = null;
export type SSRatingResponse = null;

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

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

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

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

View file

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

View file

@ -0,0 +1,191 @@
import { useImperativeHandle, forwardRef, useRef, useState, useCallback, useEffect } from 'react';
import isElectron from 'is-electron';
import type { ReactPlayerProps } from 'react-player';
import ReactPlayer from 'react-player';
import type { Song } from '/@/renderer/api/types';
import {
crossfadeHandler,
gaplessHandler,
} from '/@/renderer/components/audio-player/utils/list-handlers';
import { useSettingsStore } from '/@/renderer/store/settings.store';
import type { CrossfadeStyle } from '/@/renderer/types';
import { PlaybackStyle, PlayerStatus } from '/@/renderer/types';
interface AudioPlayerProps extends ReactPlayerProps {
crossfadeDuration: number;
crossfadeStyle: CrossfadeStyle;
currentPlayer: 1 | 2;
playbackStyle: PlaybackStyle;
player1: Song;
player2: Song;
status: PlayerStatus;
volume: number;
}
export type AudioPlayerProgress = {
loaded: number;
loadedSeconds: number;
played: number;
playedSeconds: number;
};
const getDuration = (ref: any) => {
return ref.current?.player?.player?.player?.duration;
};
export const AudioPlayer = forwardRef(
(
{
status,
playbackStyle,
crossfadeStyle,
crossfadeDuration,
currentPlayer,
autoNext,
player1,
player2,
muted,
volume,
}: AudioPlayerProps,
ref: any,
) => {
const player1Ref = useRef<any>(null);
const player2Ref = useRef<any>(null);
const [isTransitioning, setIsTransitioning] = useState(false);
const audioDeviceId = useSettingsStore((state) => state.player.audioDeviceId);
useImperativeHandle(ref, () => ({
get player1() {
return player1Ref?.current;
},
get player2() {
return player2Ref?.current;
},
}));
const handleOnEnded = () => {
autoNext();
setIsTransitioning(false);
};
useEffect(() => {
if (status === PlayerStatus.PLAYING) {
if (currentPlayer === 1) {
player1Ref.current?.getInternalPlayer()?.play();
} else {
player2Ref.current?.getInternalPlayer()?.play();
}
} else {
player1Ref.current?.getInternalPlayer()?.pause();
player2Ref.current?.getInternalPlayer()?.pause();
}
}, [currentPlayer, status]);
const handleCrossfade1 = useCallback(
(e: AudioPlayerProgress) => {
return crossfadeHandler({
currentPlayer,
currentPlayerRef: player1Ref,
currentTime: e.playedSeconds,
duration: getDuration(player1Ref),
fadeDuration: crossfadeDuration,
fadeType: crossfadeStyle,
isTransitioning,
nextPlayerRef: player2Ref,
player: 1,
setIsTransitioning,
volume,
});
},
[crossfadeDuration, crossfadeStyle, currentPlayer, isTransitioning, volume],
);
const handleCrossfade2 = useCallback(
(e: AudioPlayerProgress) => {
return crossfadeHandler({
currentPlayer,
currentPlayerRef: player2Ref,
currentTime: e.playedSeconds,
duration: getDuration(player2Ref),
fadeDuration: crossfadeDuration,
fadeType: crossfadeStyle,
isTransitioning,
nextPlayerRef: player1Ref,
player: 2,
setIsTransitioning,
volume,
});
},
[crossfadeDuration, crossfadeStyle, currentPlayer, isTransitioning, volume],
);
const handleGapless1 = useCallback(
(e: AudioPlayerProgress) => {
return gaplessHandler({
currentTime: e.playedSeconds,
duration: getDuration(player1Ref),
isFlac: player1?.container === 'flac',
isTransitioning,
nextPlayerRef: player2Ref,
setIsTransitioning,
});
},
[isTransitioning, player1?.container],
);
const handleGapless2 = useCallback(
(e: AudioPlayerProgress) => {
return gaplessHandler({
currentTime: e.playedSeconds,
duration: getDuration(player2Ref),
isFlac: player2?.container === 'flac',
isTransitioning,
nextPlayerRef: player1Ref,
setIsTransitioning,
});
},
[isTransitioning, player2?.container],
);
useEffect(() => {
if (isElectron()) {
if (audioDeviceId) {
player1Ref.current?.getInternalPlayer()?.setSinkId(audioDeviceId);
player2Ref.current?.getInternalPlayer()?.setSinkId(audioDeviceId);
} else {
player1Ref.current?.getInternalPlayer()?.setSinkId('');
player2Ref.current?.getInternalPlayer()?.setSinkId('');
}
}
}, [audioDeviceId]);
return (
<>
<ReactPlayer
ref={player1Ref}
height={0}
muted={muted}
playing={currentPlayer === 1 && status === PlayerStatus.PLAYING}
progressInterval={isTransitioning ? 10 : 250}
url={player1?.streamUrl}
volume={volume}
width={0}
onEnded={handleOnEnded}
onProgress={playbackStyle === PlaybackStyle.GAPLESS ? handleGapless1 : handleCrossfade1}
/>
<ReactPlayer
ref={player2Ref}
height={0}
muted={muted}
playing={currentPlayer === 2 && status === PlayerStatus.PLAYING}
progressInterval={isTransitioning ? 10 : 250}
url={player2?.streamUrl}
volume={volume}
width={0}
onEnded={handleOnEnded}
onProgress={playbackStyle === PlaybackStyle.GAPLESS ? handleGapless2 : handleCrossfade2}
/>
</>
);
},
);

View file

@ -0,0 +1,131 @@
/* eslint-disable no-nested-ternary */
import type { Dispatch } from 'react';
import { CrossfadeStyle } from '/@/renderer/types';
export const gaplessHandler = (args: {
currentTime: number;
duration: number;
isFlac: boolean;
isTransitioning: boolean;
nextPlayerRef: any;
setIsTransitioning: Dispatch<boolean>;
}) => {
const { nextPlayerRef, currentTime, duration, isTransitioning, setIsTransitioning, isFlac } =
args;
if (!isTransitioning) {
if (currentTime > duration - 2) {
return setIsTransitioning(true);
}
return null;
}
const durationPadding = isFlac ? 0.065 : 0.116;
if (currentTime + durationPadding >= duration) {
return nextPlayerRef.current.getInternalPlayer().play();
}
return null;
};
export const crossfadeHandler = (args: {
currentPlayer: 1 | 2;
currentPlayerRef: any;
currentTime: number;
duration: number;
fadeDuration: number;
fadeType: CrossfadeStyle;
isTransitioning: boolean;
nextPlayerRef: any;
player: 1 | 2;
setIsTransitioning: Dispatch<boolean>;
volume: number;
}) => {
const {
currentTime,
player,
currentPlayer,
currentPlayerRef,
nextPlayerRef,
fadeDuration,
fadeType,
duration,
volume,
isTransitioning,
setIsTransitioning,
} = args;
if (!isTransitioning || currentPlayer !== player) {
const shouldBeginTransition = currentTime >= duration - fadeDuration;
if (shouldBeginTransition) {
setIsTransitioning(true);
return nextPlayerRef.current.getInternalPlayer().play();
}
return null;
}
const timeLeft = duration - currentTime;
let currentPlayerVolumeCalculation;
let nextPlayerVolumeCalculation;
let percentageOfFadeLeft;
let n;
switch (fadeType) {
case 'equalPower':
// https://dsp.stackexchange.com/a/14755
percentageOfFadeLeft = (timeLeft / fadeDuration) * 2;
currentPlayerVolumeCalculation = Math.sqrt(0.5 * percentageOfFadeLeft) * volume;
nextPlayerVolumeCalculation = Math.sqrt(0.5 * (2 - percentageOfFadeLeft)) * volume;
break;
case 'linear':
currentPlayerVolumeCalculation = (timeLeft / fadeDuration) * volume;
nextPlayerVolumeCalculation = ((fadeDuration - timeLeft) / fadeDuration) * volume;
break;
case 'dipped':
// https://math.stackexchange.com/a/4622
percentageOfFadeLeft = timeLeft / fadeDuration;
currentPlayerVolumeCalculation = percentageOfFadeLeft ** 2 * volume;
nextPlayerVolumeCalculation = (percentageOfFadeLeft - 1) ** 2 * volume;
break;
case fadeType.match(/constantPower.*/)?.input:
// https://math.stackexchange.com/a/26159
n =
fadeType === 'constantPower'
? 0
: fadeType === 'constantPowerSlowFade'
? 1
: fadeType === 'constantPowerSlowCut'
? 3
: 10;
percentageOfFadeLeft = timeLeft / fadeDuration;
currentPlayerVolumeCalculation =
Math.cos((Math.PI / 4) * ((2 * percentageOfFadeLeft - 1) ** (2 * n + 1) - 1)) * volume;
nextPlayerVolumeCalculation =
Math.cos((Math.PI / 4) * ((2 * percentageOfFadeLeft - 1) ** (2 * n + 1) + 1)) * volume;
break;
default:
currentPlayerVolumeCalculation = (timeLeft / fadeDuration) * volume;
nextPlayerVolumeCalculation = ((fadeDuration - timeLeft) / fadeDuration) * volume;
break;
}
const currentPlayerVolume =
currentPlayerVolumeCalculation >= 0 ? currentPlayerVolumeCalculation : 0;
const nextPlayerVolume =
nextPlayerVolumeCalculation <= volume ? nextPlayerVolumeCalculation : volume;
if (currentPlayer === 1) {
currentPlayerRef.current.getInternalPlayer().volume = currentPlayerVolume;
nextPlayerRef.current.getInternalPlayer().volume = nextPlayerVolume;
} else {
currentPlayerRef.current.getInternalPlayer().volume = currentPlayerVolume;
nextPlayerRef.current.getInternalPlayer().volume = nextPlayerVolume;
}
// }
return null;
};

View file

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

View file

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

View file

@ -0,0 +1,304 @@
import React, { useCallback } from 'react';
import { Center } from '@mantine/core';
import { RiAlbumFill } from 'react-icons/ri';
import { generatePath, useNavigate } from 'react-router';
import { Link } from 'react-router-dom';
import { SimpleImg } from 'react-simple-img';
import styled from 'styled-components';
import { Text } from '/@/renderer/components/text';
import type { LibraryItem, CardRow, CardRoute, Play } from '/@/renderer/types';
import { Skeleton } from '/@/renderer/components/skeleton';
import CardControls from '/@/renderer/components/card/card-controls';
const CardWrapper = styled.div<{
link?: boolean;
}>`
padding: 1rem;
background: var(--card-default-bg);
border-radius: var(--card-default-radius);
cursor: ${({ link }) => link && 'pointer'};
transition: border 0.2s ease-in-out, background 0.2s ease-in-out;
&:hover {
background: var(--card-default-bg-hover);
}
&:hover div {
opacity: 1;
}
&:hover * {
&::before {
opacity: 0.5;
}
}
&:focus-visible {
outline: 1px solid #fff;
}
`;
const StyledCard = styled.div`
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
height: 100%;
padding: 0;
border-radius: var(--card-default-radius);
`;
const ImageSection = styled.div`
position: relative;
display: flex;
justify-content: center;
border-radius: var(--card-default-radius);
&::before {
position: absolute;
top: 0;
left: 0;
z-index: 1;
width: 100%;
height: 100%;
background: linear-gradient(0deg, rgba(0, 0, 0, 100%) 35%, rgba(0, 0, 0, 0%) 100%);
opacity: 0;
transition: all 0.2s ease-in-out;
content: '';
user-select: none;
}
`;
const Image = styled(SimpleImg)`
border-radius: var(--card-default-radius);
box-shadow: 2px 2px 10px 10px rgba(0, 0, 0, 20%);
`;
const ControlsContainer = styled.div`
position: absolute;
bottom: 0;
z-index: 50;
width: 100%;
opacity: 0;
transition: all 0.2s ease-in-out;
`;
const DetailSection = styled.div`
display: flex;
flex-direction: column;
`;
const Row = styled.div<{ $secondary?: boolean }>`
width: 100%;
max-width: 100%;
height: 22px;
padding: 0 0.2rem;
overflow: hidden;
color: ${({ $secondary }) => ($secondary ? 'var(--main-fg-secondary)' : 'var(--main-fg)')};
white-space: nowrap;
text-overflow: ellipsis;
user-select: none;
`;
interface BaseGridCardProps {
controls: {
cardRows: CardRow[];
itemType: LibraryItem;
playButtonBehavior: Play;
route: CardRoute;
};
data: any;
loading?: boolean;
size: number;
}
export const AlbumCard = ({ loading, size, data, controls }: BaseGridCardProps) => {
const navigate = useNavigate();
const { itemType, cardRows, route } = controls;
const handleNavigate = useCallback(() => {
navigate(
generatePath(
route.route,
route.slugs?.reduce((acc, slug) => {
return {
...acc,
[slug.slugProperty]: data[slug.idProperty],
};
}, {}),
),
);
}, [data, navigate, route.route, route.slugs]);
if (!loading) {
return (
<CardWrapper
link
onClick={handleNavigate}
>
<StyledCard>
<ImageSection>
{data?.imageUrl ? (
<Image
animationDuration={0.3}
height={size}
imgStyle={{ objectFit: 'cover' }}
placeholder="var(--card-default-bg)"
src={data?.imageUrl}
width={size}
/>
) : (
<Center
sx={{
background: 'var(--placeholder-bg)',
borderRadius: 'var(--card-default-radius)',
height: `${size}px`,
width: `${size}px`,
}}
>
<RiAlbumFill
color="var(--placeholder-fg)"
size={35}
/>
</Center>
)}
<ControlsContainer>
<CardControls
itemData={data}
itemType={itemType}
/>
</ControlsContainer>
</ImageSection>
<DetailSection>
{cardRows.map((row: CardRow, index: number) => {
if (row.arrayProperty && row.route) {
return (
<Row
key={`row-${row.property}-${index}`}
$secondary={index > 0}
>
{data[row.property].map((item: any, itemIndex: number) => (
<React.Fragment key={`${data.id}-${item.id}`}>
{itemIndex > 0 && (
<Text
$noSelect
sx={{
display: 'inline-block',
padding: '0 2px 0 1px',
}}
>
,
</Text>
)}{' '}
<Text
$link
$noSelect
$secondary={index > 0}
component={Link}
overflow="hidden"
size={index > 0 ? 'xs' : 'md'}
to={generatePath(
row.route!.route,
row.route!.slugs?.reduce((acc, slug) => {
return {
...acc,
[slug.slugProperty]: data[slug.idProperty],
};
}, {}),
)}
onClick={(e) => e.stopPropagation()}
>
{row.arrayProperty && item[row.arrayProperty]}
</Text>
</React.Fragment>
))}
</Row>
);
}
if (row.arrayProperty) {
return (
<Row key={`row-${row.property}`}>
{data[row.property].map((item: any) => (
<Text
key={`${data.id}-${item.id}`}
$noSelect
$secondary={index > 0}
overflow="hidden"
size={index > 0 ? 'xs' : 'md'}
>
{row.arrayProperty && item[row.arrayProperty]}
</Text>
))}
</Row>
);
}
return (
<Row key={`row-${row.property}`}>
{row.route ? (
<Text
$link
$noSelect
component={Link}
overflow="hidden"
to={generatePath(
row.route.route,
row.route.slugs?.reduce((acc, slug) => {
return {
...acc,
[slug.slugProperty]: data[slug.idProperty],
};
}, {}),
)}
onClick={(e) => e.stopPropagation()}
>
{data && data[row.property]}
</Text>
) : (
<Text
$noSelect
$secondary={index > 0}
overflow="hidden"
size={index > 0 ? 'xs' : 'md'}
>
{data && data[row.property]}
</Text>
)}
</Row>
);
})}
</DetailSection>
</StyledCard>
</CardWrapper>
);
}
return (
<CardWrapper>
<StyledCard style={{ alignItems: 'center', display: 'flex' }}>
<Skeleton
visible
height={size}
radius="sm"
width={size}
>
<ImageSection />
</Skeleton>
<DetailSection style={{ width: '100%' }}>
{cardRows.map((row: CardRow, index: number) => (
<Skeleton
visible
height={15}
my={3}
radius="md"
width={!data ? (index > 0 ? '50%' : '90%') : '100%'}
>
<Row />
</Skeleton>
))}
</DetailSection>
</StyledCard>
</CardWrapper>
);
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,364 @@
import React, { forwardRef } from 'react';
import type {
TextInputProps as MantineTextInputProps,
NumberInputProps as MantineNumberInputProps,
PasswordInputProps as MantinePasswordInputProps,
FileInputProps as MantineFileInputProps,
JsonInputProps as MantineJsonInputProps,
TextareaProps as MantineTextareaProps,
} from '@mantine/core';
import {
TextInput as MantineTextInput,
NumberInput as MantineNumberInput,
PasswordInput as MantinePasswordInput,
FileInput as MantineFileInput,
JsonInput as MantineJsonInput,
Textarea as MantineTextarea,
} from '@mantine/core';
import styled from 'styled-components';
interface TextInputProps extends MantineTextInputProps {
children?: React.ReactNode;
maxWidth?: number | string;
width?: number | string;
}
interface NumberInputProps extends MantineNumberInputProps {
children?: React.ReactNode;
maxWidth?: number | string;
width?: number | string;
}
interface PasswordInputProps extends MantinePasswordInputProps {
children?: React.ReactNode;
maxWidth?: number | string;
width?: number | string;
}
interface FileInputProps extends MantineFileInputProps {
children?: React.ReactNode;
maxWidth?: number | string;
width?: number | string;
}
interface JsonInputProps extends MantineJsonInputProps {
children?: React.ReactNode;
maxWidth?: number | string;
width?: number | string;
}
interface TextareaProps extends MantineTextareaProps {
children?: React.ReactNode;
maxWidth?: number | string;
width?: number | string;
}
const StyledTextInput = styled(MantineTextInput)<TextInputProps>`
& .mantine-TextInput-wrapper {
border-color: var(--primary-color);
}
& .mantine-TextInput-input {
color: var(--input-fg);
background: var(--input-bg);
&::placeholder {
color: var(--input-placeholder-fg);
}
}
& .mantine-Input-icon {
color: var(--input-placeholder-fg);
}
& .mantine-TextInput-required {
color: var(--secondary-color);
}
& .mantine-TextInput-label {
font-family: var(--label-font-faimly);
}
& .mantine-TextInput-disabled {
opacity: 0.6;
}
`;
const StyledNumberInput = styled(MantineNumberInput)<NumberInputProps>`
& .mantine-NumberInput-wrapper {
border-color: var(--primary-color);
}
& .mantine-NumberInput-input {
color: var(--input-fg);
background: var(--input-bg);
&::placeholder {
color: var(--input-placeholder-fg);
}
}
/* & .mantine-NumberInput-rightSection {
color: var(--input-placeholder-fg);
background: var(--input-bg);
} */
& .mantine-NumberInput-controlUp {
svg {
color: white;
fill: white;
}
}
& .mantine-Input-icon {
color: var(--input-placeholder-fg);
}
& .mantine-NumberInput-required {
color: var(--secondary-color);
}
& .mantine-NumberInput-label {
font-family: var(--label-font-faimly);
}
& .mantine-NumberInput-disabled {
opacity: 0.6;
}
`;
const StyledPasswordInput = styled(MantinePasswordInput)<PasswordInputProps>`
& .mantine-PasswordInput-input {
color: var(--input-fg);
background: var(--input-bg);
&::placeholder {
color: var(--input-placeholder-fg);
}
}
& .mantine-PasswordInput-icon {
color: var(--input-placeholder-fg);
}
& .mantine-PasswordInput-required {
color: var(--secondary-color);
}
& .mantine-PasswordInput-label {
font-family: var(--label-font-faimly);
}
& .mantine-PasswordInput-disabled {
opacity: 0.6;
}
`;
const StyledFileInput = styled(MantineFileInput)<FileInputProps>`
& .mantine-FileInput-input {
color: var(--input-fg);
background: var(--input-bg);
&::placeholder {
color: var(--input-placeholder-fg);
}
}
& .mantine-FileInput-icon {
color: var(--input-placeholder-fg);
}
& .mantine-FileInput-required {
color: var(--secondary-color);
}
& .mantine-FileInput-label {
font-family: var(--label-font-faimly);
}
& .mantine-FileInput-disabled {
opacity: 0.6;
}
`;
const StyledJsonInput = styled(MantineJsonInput)<JsonInputProps>`
& .mantine-JsonInput-input {
color: var(--input-fg);
background: var(--input-bg);
&::placeholder {
color: var(--input-placeholder-fg);
}
}
& .mantine-JsonInput-icon {
color: var(--input-placeholder-fg);
}
& .mantine-JsonInput-required {
color: var(--secondary-color);
}
& .mantine-JsonInput-label {
font-family: var(--label-font-faimly);
}
& .mantine-JsonInput-disabled {
opacity: 0.6;
}
`;
const StyledTextarea = styled(MantineTextarea)<TextareaProps>`
& .mantine-Textarea-input {
color: var(--input-fg);
background: var(--input-bg);
&::placeholder {
color: var(--input-placeholder-fg);
}
}
& .mantine-Textarea-icon {
color: var(--input-placeholder-fg);
}
& .mantine-Textarea-required {
color: var(--secondary-color);
}
& .mantine-Textarea-label {
font-family: var(--label-font-faimly);
}
& .mantine-Textarea-disabled {
opacity: 0.6;
}
`;
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
({ children, width, maxWidth, ...props }: TextInputProps, ref) => {
return (
<StyledTextInput
ref={ref}
spellCheck={false}
{...props}
sx={{ maxWidth, width }}
>
{children}
</StyledTextInput>
);
},
);
export const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(
({ children, width, maxWidth, ...props }: NumberInputProps, ref) => {
return (
<StyledNumberInput
ref={ref}
hideControls
spellCheck={false}
{...props}
sx={{ maxWidth, width }}
>
{children}
</StyledNumberInput>
);
},
);
export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
({ children, width, maxWidth, ...props }: PasswordInputProps, ref) => {
return (
<StyledPasswordInput
ref={ref}
{...props}
sx={{ maxWidth, width }}
>
{children}
</StyledPasswordInput>
);
},
);
export const FileInput = forwardRef<HTMLButtonElement, FileInputProps>(
({ children, width, maxWidth, ...props }: FileInputProps, ref) => {
return (
<StyledFileInput
ref={ref}
{...props}
styles={{
placeholder: {
color: 'var(--input-placeholder-fg)',
},
}}
sx={{ maxWidth, width }}
>
{children}
</StyledFileInput>
);
},
);
export const JsonInput = forwardRef<HTMLTextAreaElement, JsonInputProps>(
({ children, width, maxWidth, ...props }: JsonInputProps, ref) => {
return (
<StyledJsonInput
ref={ref}
{...props}
sx={{ maxWidth, width }}
>
{children}
</StyledJsonInput>
);
},
);
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
({ children, width, maxWidth, ...props }: TextareaProps, ref) => {
return (
<StyledTextarea
ref={ref}
{...props}
sx={{ maxWidth, width }}
>
{children}
</StyledTextarea>
);
},
);
TextInput.defaultProps = {
children: undefined,
maxWidth: undefined,
width: undefined,
};
NumberInput.defaultProps = {
children: undefined,
maxWidth: undefined,
width: undefined,
};
PasswordInput.defaultProps = {
children: undefined,
maxWidth: undefined,
width: undefined,
};
FileInput.defaultProps = {
children: undefined,
maxWidth: undefined,
width: undefined,
};
JsonInput.defaultProps = {
children: undefined,
maxWidth: undefined,
width: undefined,
};
Textarea.defaultProps = {
children: undefined,
maxWidth: undefined,
width: undefined,
};

View file

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

View file

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

View file

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

View file

@ -0,0 +1,38 @@
import type {
PopoverProps as MantinePopoverProps,
PopoverDropdownProps as MantinePopoverDropdownProps,
} from '@mantine/core';
import { Popover as MantinePopover } from '@mantine/core';
import styled from 'styled-components';
type PopoverProps = MantinePopoverProps;
type PopoverDropdownProps = MantinePopoverDropdownProps;
const StyledPopover = styled(MantinePopover)``;
const StyledDropdown = styled(MantinePopover.Dropdown)<PopoverDropdownProps>`
padding: 0.5rem;
font-size: 0.9em;
font-family: var(--content-font-family);
background-color: var(--dropdown-menu-bg);
`;
export const Popover = ({ children, ...props }: PopoverProps) => {
return (
<StyledPopover
withArrow
withinPortal
styles={{
dropdown: {
filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 50%))',
},
}}
{...props}
>
{children}
</StyledPopover>
);
};
Popover.Target = MantinePopover.Target;
Popover.Dropdown = StyledDropdown;

View file

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

View file

@ -0,0 +1,38 @@
import { forwardRef } from 'react';
import type { SegmentedControlProps as MantineSegmentedControlProps } from '@mantine/core';
import { SegmentedControl as MantineSegmentedControl } from '@mantine/core';
import styled from 'styled-components';
type SegmentedControlProps = MantineSegmentedControlProps;
const StyledSegmentedControl = styled(MantineSegmentedControl)<MantineSegmentedControlProps>`
& .mantine-SegmentedControl-label {
color: var(--input-fg);
font-family: var(--content-font-family);
}
background-color: var(--input-bg);
& .mantine-SegmentedControl-disabled {
opacity: 0.6;
}
& .mantine-SegmentedControl-active {
color: var(--input-active-fg);
background-color: var(--input-active-bg);
}
`;
export const SegmentedControl = forwardRef<HTMLDivElement, SegmentedControlProps>(
({ ...props }: SegmentedControlProps, ref) => {
return (
<StyledSegmentedControl
ref={ref}
styles={{}}
transitionDuration={250}
transitionTimingFunction="linear"
{...props}
/>
);
},
);

View file

@ -0,0 +1,144 @@
import type {
SelectProps as MantineSelectProps,
MultiSelectProps as MantineMultiSelectProps,
} from '@mantine/core';
import { Select as MantineSelect, MultiSelect as MantineMultiSelect } from '@mantine/core';
import styled from 'styled-components';
interface SelectProps extends MantineSelectProps {
maxWidth?: number | string;
width?: number | string;
}
interface MultiSelectProps extends MantineMultiSelectProps {
maxWidth?: number | string;
width?: number | string;
}
const StyledSelect = styled(MantineSelect)`
& [data-selected='true'] {
background: var(--input-bg);
}
& .mantine-Select-disabled {
background: var(--input-bg);
opacity: 0.6;
}
& .mantine-Select-itemsWrapper {
& .mantine-Select-item {
padding: 40px;
}
}
`;
export const Select = ({ width, maxWidth, ...props }: SelectProps) => {
return (
<StyledSelect
withinPortal
styles={{
dropdown: {
background: 'var(--dropdown-menu-bg)',
filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 20%))',
},
input: {
background: 'var(--input-bg)',
color: 'var(--input-fg)',
},
item: {
'&:hover': {
background: 'var(--dropdown-menu-bg-hover)',
},
'&[data-hovered]': {
background: 'var(--dropdown-menu-bg-hover)',
},
'&[data-selected="true"]': {
'&:hover': {
background: 'var(--dropdown-menu-bg-hover)',
},
background: 'none',
color: 'var(--primary-color)',
},
color: 'var(--dropdown-menu-fg)',
padding: '.3rem',
},
}}
sx={{ maxWidth, width }}
transition="pop"
transitionDuration={100}
{...props}
/>
);
};
const StyledMultiSelect = styled(MantineMultiSelect)`
& [data-selected='true'] {
background: var(--input-select-bg);
}
& .mantine-MultiSelect-disabled {
background: var(--input-select-bg);
opacity: 0.6;
}
& .mantine-MultiSelect-itemsWrapper {
& .mantine-Select-item {
padding: 40px;
}
}
`;
export const MultiSelect = ({ width, maxWidth, ...props }: MultiSelectProps) => {
return (
<StyledMultiSelect
withinPortal
styles={{
dropdown: {
background: 'var(--dropdown-menu-bg)',
filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 20%))',
},
input: {
background: 'var(--input-bg)',
color: 'var(--input-fg)',
},
item: {
'&:hover': {
background: 'var(--dropdown-menu-bg-hover)',
},
'&[data-hovered]': {
background: 'var(--dropdown-menu-bg-hover)',
},
'&[data-selected="true"]': {
'&:hover': {
background: 'var(--dropdown-menu-bg-hover)',
},
background: 'none',
color: 'var(--primary-color)',
},
color: 'var(--dropdown-menu-fg)',
padding: '.5rem .1rem',
},
value: {
margin: '.2rem',
paddingBottom: '1rem',
paddingLeft: '1rem',
paddingTop: '1rem',
},
}}
sx={{ maxWidth, width }}
transition="pop"
transitionDuration={100}
{...props}
/>
);
};
Select.defaultProps = {
maxWidth: undefined,
width: undefined,
};
MultiSelect.defaultProps = {
maxWidth: undefined,
width: undefined,
};

View file

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

View file

@ -0,0 +1,30 @@
import type { SliderProps as MantineSliderProps } from '@mantine/core';
import { Slider as MantineSlider } from '@mantine/core';
import styled from 'styled-components';
type SliderProps = MantineSliderProps;
const StyledSlider = styled(MantineSlider)`
& .mantine-Slider-track {
height: 0.5rem;
background-color: var(--slider-track-bg);
}
& .mantine-Slider-thumb {
width: 1rem;
height: 1rem;
background: var(--slider-thumb-bg);
border: none;
}
& .mantine-Slider-label {
padding: 0 1rem;
color: var(--tooltip-fg);
font-size: 1em;
background: var(--tooltip-bg);
}
`;
export const Slider = ({ ...props }: SliderProps) => {
return <StyledSlider {...props} />;
};

View file

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

View file

@ -0,0 +1,27 @@
import type { SwitchProps as MantineSwitchProps } from '@mantine/core';
import { Switch as MantineSwitch } from '@mantine/core';
import styled from 'styled-components';
type SwitchProps = MantineSwitchProps;
const StyledSwitch = styled(MantineSwitch)`
display: flex;
& .mantine-Switch-track {
background-color: var(--switch-track-bg);
}
& .mantine-Switch-input {
&:checked + .mantine-Switch-track {
background-color: var(--switch-track-enabled-bg);
}
}
& .mantine-Switch-thumb {
background-color: var(--switch-thumb-bg);
}
`;
export const Switch = ({ ...props }: SwitchProps) => {
return <StyledSwitch {...props} />;
};

View file

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

View file

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

View file

@ -0,0 +1,59 @@
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
import type { TextProps as MantineTextProps } from '@mantine/core';
import { createPolymorphicComponent, Text as MantineText } from '@mantine/core';
import styled from 'styled-components';
import type { Font } from '/@/renderer/styles';
import { textEllipsis } from '/@/renderer/styles';
type MantineTextDivProps = MantineTextProps & ComponentPropsWithoutRef<'div'>;
interface TextProps extends MantineTextDivProps {
$link?: boolean;
$noSelect?: boolean;
$secondary?: boolean;
children: ReactNode;
font?: Font;
overflow?: 'hidden' | 'visible';
to?: string;
weight?: number;
}
const StyledText = styled(MantineText)<TextProps>`
color: ${(props) => (props.$secondary ? 'var(--main-fg-secondary)' : 'var(--main-fg)')};
font-family: ${(props) => props.font};
cursor: ${(props) => props.$link && 'cursor'};
transition: color 0.2s ease-in-out;
user-select: ${(props) => (props.$noSelect ? 'none' : 'auto')};
${(props) => props.overflow === 'hidden' && textEllipsis}
&:hover {
color: ${(props) => props.$link && 'var(--main-fg)'};
text-decoration: ${(props) => (props.$link ? 'underline' : 'none')};
}
`;
const _Text = ({ children, $secondary, overflow, font, $noSelect, ...rest }: TextProps) => {
return (
<StyledText
$noSelect={$noSelect}
$secondary={$secondary}
font={font}
overflow={overflow}
{...rest}
>
{children}
</StyledText>
);
};
export const Text = createPolymorphicComponent<'div', TextProps>(_Text);
_Text.defaultProps = {
$link: false,
$noSelect: false,
$secondary: false,
font: undefined,
overflow: 'visible',
to: '',
weight: 400,
};

View file

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

View file

@ -0,0 +1,44 @@
import type { TooltipProps } from '@mantine/core';
import { Tooltip as MantineTooltip } from '@mantine/core';
import styled from 'styled-components';
const StyledTooltip = styled(MantineTooltip)`
& .mantine-Tooltip-tooltip {
margin: 20px;
}
`;
export const Tooltip = ({ children, ...rest }: TooltipProps) => {
return (
<StyledTooltip
multiline
withinPortal
pl={10}
pr={10}
py={5}
radius="xs"
styles={{
tooltip: {
background: 'var(--tooltip-bg)',
boxShadow: '4px 4px 10px 0px rgba(0,0,0,0.2)',
color: 'var(--tooltip-fg)',
fontSize: '1.1rem',
fontWeight: 550,
maxWidth: '250px',
},
}}
{...rest}
>
{children}
</StyledTooltip>
);
};
Tooltip.defaultProps = {
openDelay: 0,
position: 'top',
transition: 'fade',
transitionDuration: 250,
withArrow: true,
withinPortal: true,
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,99 @@
import React, { useMemo } from 'react';
import type { ICellRendererParams } from '@ag-grid-community/core';
import { motion } from 'framer-motion';
import { generatePath } from 'react-router';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import type { AlbumArtist, Artist } from '/@/renderer/api/types';
import { Text } from '/@/renderer/components/text';
import { AppRoute } from '/@/renderer/router/routes';
import { ServerType } from '/@/renderer/types';
const CellContainer = styled(motion.div)<{ height: number }>`
display: grid;
grid-auto-columns: 1fr;
grid-template-areas: 'image info';
grid-template-rows: 1fr;
grid-template-columns: ${(props) => props.height}px minmax(0, 1fr);
gap: 0.5rem;
width: 100%;
max-width: 100%;
height: 100%;
`;
const ImageWrapper = styled.div`
display: flex;
grid-area: image;
align-items: center;
justify-content: center;
height: 100%;
`;
const MetadataWrapper = styled.div`
display: flex;
flex-direction: column;
grid-area: info;
justify-content: center;
width: 100%;
`;
const StyledImage = styled.img`
object-fit: cover;
`;
export const CombinedTitleCell = ({ value, rowIndex, node }: ICellRendererParams) => {
const artists = useMemo(() => {
return value.type === ServerType.JELLYFIN ? value.artists : value.albumArtists;
}, [value]);
return (
<CellContainer height={node.rowHeight || 40}>
<ImageWrapper>
<StyledImage
alt="song-cover"
height={(node.rowHeight || 40) - 10}
loading="lazy"
src={value.imageUrl}
style={{}}
width={(node.rowHeight || 40) - 10}
/>
</ImageWrapper>
<MetadataWrapper>
<Text
overflow="hidden"
size="sm"
>
{value.name}
</Text>
<Text
$secondary
overflow="hidden"
size="sm"
>
{artists?.length ? (
artists.map((artist: Artist | AlbumArtist, index: number) => (
<React.Fragment key={`queue-${rowIndex}-artist-${artist.id}`}>
{index > 0 ? ', ' : null}
<Text
$link
$secondary
component={Link}
overflow="hidden"
size="sm"
sx={{ width: 'fit-content' }}
to={generatePath(AppRoute.LIBRARY_ALBUMARTISTS_DETAIL, {
albumArtistId: artist.id,
})}
>
{artist.name}
</Text>
</React.Fragment>
))
) : (
<Text $secondary></Text>
)}
</Text>
</MetadataWrapper>
</CellContainer>
);
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,163 @@
import type { ChangeEvent } from 'react';
import { Stack } from '@mantine/core';
import { MultiSelect, Slider, Switch, Text } from '/@/renderer/components';
import { useSettingsStoreActions, useSettingsStore } from '/@/renderer/store/settings.store';
import { TableColumn, TableType } from '/@/renderer/types';
export const tableColumns = [
{ label: 'Row Index', value: TableColumn.ROW_INDEX },
{ label: 'Title', value: TableColumn.TITLE },
{ label: 'Title (Combined)', value: TableColumn.TITLE_COMBINED },
{ label: 'Duration', value: TableColumn.DURATION },
{ label: 'Album', value: TableColumn.ALBUM },
{ label: 'Album Artist', value: TableColumn.ALBUM_ARTIST },
{ label: 'Artist', value: TableColumn.ARTIST },
{ label: 'Genre', value: TableColumn.GENRE },
{ label: 'Year', value: TableColumn.YEAR },
{ label: 'Release Date', value: TableColumn.RELEASE_DATE },
{ label: 'Disc Number', value: TableColumn.DISC_NUMBER },
{ label: 'Track Number', value: TableColumn.TRACK_NUMBER },
{ label: 'Bitrate', value: TableColumn.BIT_RATE },
// { label: 'Size', value: TableColumn.SIZE },
// { label: 'Skip', value: TableColumn.SKIP },
// { label: 'Path', value: TableColumn.PATH },
// { label: 'Play Count', value: TableColumn.PLAY_COUNT },
// { label: 'Favorite', value: TableColumn.FAVORITE },
// { label: 'Rating', value: TableColumn.RATING },
{ label: 'Date Added', value: TableColumn.DATE_ADDED },
];
interface TableConfigDropdownProps {
type: TableType;
}
export const TableConfigDropdown = ({ type }: TableConfigDropdownProps) => {
const { setSettings } = useSettingsStoreActions();
const tableConfig = useSettingsStore((state) => state.tables);
const handleAddOrRemoveColumns = (values: TableColumn[]) => {
const existingColumns = tableConfig[type].columns;
if (values.length === 0) {
setSettings({
tables: {
...useSettingsStore.getState().tables,
[type]: {
...useSettingsStore.getState().tables[type],
columns: [],
},
},
});
return;
}
// If adding a column
if (values.length > existingColumns.length) {
const newColumn = { column: values[values.length - 1] };
setSettings({
tables: {
...useSettingsStore.getState().tables,
[type]: {
...useSettingsStore.getState().tables[type],
columns: [...existingColumns, newColumn],
},
},
});
}
// If removing a column
else {
const removed = existingColumns.filter((column) => !values.includes(column.column));
const newColumns = existingColumns.filter((column) => !removed.includes(column));
setSettings({
tables: {
...useSettingsStore.getState().tables,
[type]: {
...useSettingsStore.getState().tables[type],
columns: newColumns,
},
},
});
}
};
const handleUpdateRowHeight = (value: number) => {
setSettings({
tables: {
...useSettingsStore.getState().tables,
[type]: {
...useSettingsStore.getState().tables[type],
rowHeight: value,
},
},
});
};
const handleUpdateAutoFit = (e: ChangeEvent<HTMLInputElement>) => {
setSettings({
tables: {
...useSettingsStore.getState().tables,
[type]: {
...useSettingsStore.getState().tables[type],
autoFit: e.currentTarget.checked,
},
},
});
};
const handleUpdateFollow = (e: ChangeEvent<HTMLInputElement>) => {
setSettings({
tables: {
...useSettingsStore.getState().tables,
[type]: {
...useSettingsStore.getState().tables[type],
followCurrentSong: e.currentTarget.checked,
},
},
});
};
return (
<Stack
p="1rem"
spacing="xl"
>
<Stack spacing="xs">
<Text>Table Columns</Text>
<MultiSelect
clearable
data={tableColumns}
defaultValue={tableConfig[type]?.columns.map((column) => column.column)}
dropdownPosition="top"
width={300}
onChange={handleAddOrRemoveColumns}
/>
</Stack>
<Stack spacing="xs">
<Text>Row Height</Text>
<Slider
defaultValue={tableConfig[type]?.rowHeight}
max={100}
min={25}
sx={{ width: 150 }}
onChangeEnd={handleUpdateRowHeight}
/>
</Stack>
<Stack spacing="xs">
<Text>Auto Fit Columns</Text>
<Switch
defaultChecked={tableConfig[type]?.autoFit}
onChange={handleUpdateAutoFit}
/>
</Stack>
<Stack spacing="xs">
<Text>Follow Current Song</Text>
<Switch
defaultChecked={tableConfig[type]?.followCurrentSong}
onChange={handleUpdateFollow}
/>
</Stack>
</Stack>
);
};

View file

@ -0,0 +1,26 @@
import { Stack, Group } from '@mantine/core';
import { RiAlertFill } from 'react-icons/ri';
import { Text } from '/@/renderer/components';
interface ActionRequiredContainerProps {
children: React.ReactNode;
title: string;
}
export const ActionRequiredContainer = ({ title, children }: ActionRequiredContainerProps) => (
<Stack sx={{ cursor: 'default', maxWidth: '700px' }}>
<Group>
<RiAlertFill
color="var(--warning-color)"
size={30}
/>
<Text
size="xl"
sx={{ textTransform: 'uppercase' }}
>
{title}
</Text>
</Group>
<Stack>{children}</Stack>
</Stack>
);

View file

@ -0,0 +1,83 @@
import { Box, Center, Divider, Group, Stack } from '@mantine/core';
import type { FallbackProps } from 'react-error-boundary';
import { RiErrorWarningLine, RiArrowLeftLine } from 'react-icons/ri';
import { useNavigate, useRouteError } from 'react-router';
import styled from 'styled-components';
import { Button, Text } from '/@/renderer/components';
const Container = styled(Box)`
background: var(--main-bg);
`;
export const ErrorFallback = ({ error, resetErrorBoundary }: FallbackProps) => {
return (
<Container>
<Center sx={{ height: '100vh' }}>
<Stack sx={{ maxWidth: '50%' }}>
<Group spacing="xs">
<RiErrorWarningLine
color="var(--danger-color)"
size={30}
/>
<Text size="lg">Something went wrong</Text>
</Group>
<Text>{error.message}</Text>
<Button
variant="filled"
onClick={resetErrorBoundary}
>
Reload
</Button>
</Stack>
</Center>
</Container>
);
};
export const RouteErrorBoundary = () => {
const navigate = useNavigate();
const error = useRouteError() as any;
console.log('error', error);
const handleReload = () => {
navigate(0);
};
const handleReturn = () => {
navigate(-1);
};
return (
<Container>
<Center sx={{ height: '100vh' }}>
<Stack sx={{ maxWidth: '50%' }}>
<Group>
<RiErrorWarningLine
color="var(--danger-color)"
size={30}
/>
<Text size="lg">Something went wrong</Text>
</Group>
<Divider my={5} />
<Text size="sm">{error?.message}</Text>
<Group grow>
<Button
leftIcon={<RiArrowLeftLine />}
sx={{ flex: 0.5 }}
variant="default"
onClick={handleReturn}
>
Go back
</Button>
<Button
variant="filled"
onClick={handleReload}
>
Reload
</Button>
</Group>
</Stack>
</Center>
</Container>
);
};

View file

@ -0,0 +1,43 @@
import { useEffect, useState } from 'react';
import isElectron from 'is-electron';
import { FileInput, Text, Button } from '/@/renderer/components';
const localSettings = window.electron.localSettings;
export const MpvRequired = () => {
const [mpvPath, setMpvPath] = useState('');
const handleSetMpvPath = (e: File) => {
localSettings.set('mpv_path', e.path);
};
useEffect(() => {
const getMpvPath = async () => {
if (!isElectron()) return setMpvPath('');
const mpvPath = localSettings.get('mpv_path') as string;
return setMpvPath(mpvPath);
};
getMpvPath();
}, []);
return (
<>
<Text size="lg">Set your MPV executable location below and restart the application.</Text>
<Text>
MPV is available at the following:{' '}
<a
href="https://mpv.io/installation/"
rel="noreferrer"
target="_blank"
>
https://mpv.io/
</a>
</Text>
<FileInput
placeholder={mpvPath}
onChange={handleSetMpvPath}
/>
<Button onClick={() => localSettings.restart()}>Restart</Button>
</>
);
};

View file

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

View file

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

View file

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

View file

@ -0,0 +1,99 @@
import { useState, useEffect } from 'react';
import { Center, Group, Stack } from '@mantine/core';
import isElectron from 'is-electron';
import { RiCheckFill } from 'react-icons/ri';
import { Link } from 'react-router-dom';
import { Button, Text } from '/@/renderer/components';
import { ActionRequiredContainer } from '/@/renderer/features/action-required/components/action-required-container';
import { MpvRequired } from '/@/renderer/features/action-required/components/mpv-required';
import { ServerCredentialRequired } from '/@/renderer/features/action-required/components/server-credential-required';
import { ServerRequired } from '/@/renderer/features/action-required/components/server-required';
import { AnimatedPage } from '/@/renderer/features/shared';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer } from '/@/renderer/store';
const localSettings = window.electron.localSettings;
const ActionRequiredRoute = () => {
const currentServer = useCurrentServer();
const [isMpvRequired, setIsMpvRequired] = useState(false);
const isServerRequired = !currentServer;
// const isCredentialRequired = currentServer?.noCredential && !serverToken;
const isCredentialRequired = false;
useEffect(() => {
const getMpvPath = async () => {
if (!isElectron()) return setIsMpvRequired(false);
const mpvPath = await localSettings.get('mpv_path');
return setIsMpvRequired(!mpvPath);
};
getMpvPath();
}, []);
const checks = [
{
component: <MpvRequired />,
title: 'MPV required',
valid: !isMpvRequired,
},
{
component: <ServerCredentialRequired />,
title: 'Credentials required',
valid: !isCredentialRequired,
},
{
component: <ServerRequired />,
title: 'Server required',
valid: !isServerRequired,
},
];
const canReturnHome = checks.every((c) => c.valid);
const displayedCheck = checks.find((c) => !c.valid);
return (
<AnimatedPage>
<Center sx={{ height: '100%', width: '100vw' }}>
<Stack
spacing="xl"
sx={{ maxWidth: '50%' }}
>
<Group noWrap>
{displayedCheck && (
<ActionRequiredContainer title={displayedCheck.title}>
{displayedCheck?.component}
</ActionRequiredContainer>
)}
</Group>
<Stack mt="2rem">
{canReturnHome && (
<>
<Group
noWrap
position="center"
>
<RiCheckFill
color="var(--success-color)"
size={30}
/>
<Text size="xl">No issues found</Text>
</Group>
<Button
component={Link}
disabled={!canReturnHome}
to={AppRoute.HOME}
variant="filled"
>
Go back
</Button>
</>
)}
</Stack>
</Stack>
</Center>
</AnimatedPage>
);
};
export default ActionRequiredRoute;

View file

@ -0,0 +1,38 @@
import { Center, Group, Stack } from '@mantine/core';
import { RiQuestionLine } from 'react-icons/ri';
import { useLocation, useNavigate } from 'react-router-dom';
import { Button, Text } from '/@/renderer/components';
import { AnimatedPage } from '/@/renderer/features/shared';
const InvalidRoute = () => {
const navigate = useNavigate();
const location = useLocation();
return (
<AnimatedPage>
<Center sx={{ height: '100%', width: '100%' }}>
<Stack>
<Group
noWrap
position="center"
>
<RiQuestionLine
color="var(--warning-color)"
size={30}
/>
<Text size="xl">Page not found</Text>
</Group>
<Text>{location.pathname}</Text>
<Button
variant="filled"
onClick={() => navigate(-1)}
>
Go back
</Button>
</Stack>
</Center>
</AnimatedPage>
);
};
export default InvalidRoute;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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