mirror of
https://github.com/antebudimir/feishin.git
synced 2025-12-31 18:13:31 +00:00
Add files
This commit is contained in:
commit
e87c814068
266 changed files with 63938 additions and 0 deletions
9
src/__tests__/App.test.tsx
Normal file
9
src/__tests__/App.test.tsx
Normal 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
32
src/i18n/i18n.js
Normal 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;
|
||||
117
src/i18n/i18next-parser.config.js
Normal file
117
src/i18n/i18next-parser.config.js
Normal 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
9
src/i18n/locales/en.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"player": {
|
||||
"next": "player.next",
|
||||
"play": "player.play",
|
||||
"prev": "player.prev",
|
||||
"seekBack": "player.seekBack",
|
||||
"seekForward": "player.seekForward"
|
||||
}
|
||||
}
|
||||
2
src/main/features/core/index.ts
Normal file
2
src/main/features/core/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import './player';
|
||||
import './settings';
|
||||
161
src/main/features/core/player/index.ts
Normal file
161
src/main/features/core/player/index.ts
Normal 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();
|
||||
});
|
||||
27
src/main/features/core/player/media-keys.ts
Normal file
27
src/main/features/core/player/media-keys.ts
Normal 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');
|
||||
};
|
||||
12
src/main/features/core/settings/index.ts
Normal file
12
src/main/features/core/settings/index.ts
Normal 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);
|
||||
});
|
||||
0
src/main/features/darwin/index.ts
Normal file
0
src/main/features/darwin/index.ts
Normal file
4
src/main/features/index.ts
Normal file
4
src/main/features/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import './core';
|
||||
|
||||
// eslint-disable-next-line import/no-dynamic-require
|
||||
require(`./${process.platform}`);
|
||||
0
src/main/features/linux/index.ts
Normal file
0
src/main/features/linux/index.ts
Normal file
0
src/main/features/win32/index.ts
Normal file
0
src/main/features/win32/index.ts
Normal file
187
src/main/main.ts
Normal file
187
src/main/main.ts
Normal 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
269
src/main/menu.ts
Normal 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
109
src/main/preload.ts
Normal 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,
|
||||
});
|
||||
21
src/main/preload/browser.ts
Normal file
21
src/main/preload/browser.ts
Normal 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
9
src/main/preload/ipc.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { ipcRenderer } from 'electron';
|
||||
|
||||
const removeAllListeners = (channel: string) => {
|
||||
ipcRenderer.removeAllListeners(channel);
|
||||
};
|
||||
|
||||
export const ipc = {
|
||||
removeAllListeners,
|
||||
};
|
||||
33
src/main/preload/local-settings.ts
Normal file
33
src/main/preload/local-settings.ts
Normal 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,
|
||||
};
|
||||
102
src/main/preload/mpv-player.ts
Normal file
102
src/main/preload/mpv-player.ts
Normal 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
31
src/main/utils.ts
Normal 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';
|
||||
};
|
||||
190
src/renderer/api/controller.ts
Normal file
190
src/renderer/api/controller.ts
Normal 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,
|
||||
};
|
||||
7
src/renderer/api/index.ts
Normal file
7
src/renderer/api/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { controller } from '/@/renderer/api/controller';
|
||||
import { normalize } from '/@/renderer/api/normalize';
|
||||
|
||||
export const api = {
|
||||
controller,
|
||||
normalize,
|
||||
};
|
||||
683
src/renderer/api/jellyfin.api.ts
Normal file
683
src/renderer/api/jellyfin.api.ts
Normal 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,
|
||||
};
|
||||
574
src/renderer/api/jellyfin.types.ts
Normal file
574
src/renderer/api/jellyfin.types.ts
Normal 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;
|
||||
499
src/renderer/api/navidrome.api.ts
Normal file
499
src/renderer/api/navidrome.api.ts
Normal 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,
|
||||
};
|
||||
313
src/renderer/api/navidrome.types.ts
Normal file
313
src/renderer/api/navidrome.types.ts
Normal 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;
|
||||
51
src/renderer/api/normalize.ts
Normal file
51
src/renderer/api/normalize.ts
Normal 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,
|
||||
};
|
||||
24
src/renderer/api/query-keys.ts
Normal file
24
src/renderer/api/query-keys.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
297
src/renderer/api/subsonic.api.ts
Normal file
297
src/renderer/api/subsonic.api.ts
Normal 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,
|
||||
};
|
||||
184
src/renderer/api/subsonic.types.ts
Normal file
184
src/renderer/api/subsonic.types.ts
Normal 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
795
src/renderer/api/types.ts
Normal 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
109
src/renderer/app.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
23
src/renderer/components/accordion/index.tsx
Normal file
23
src/renderer/components/accordion/index.tsx
Normal 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;
|
||||
191
src/renderer/components/audio-player/index.tsx
Normal file
191
src/renderer/components/audio-player/index.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
131
src/renderer/components/audio-player/utils/list-handlers.ts
Normal file
131
src/renderer/components/audio-player/utils/list-handlers.ts
Normal 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;
|
||||
};
|
||||
34
src/renderer/components/badge/index.tsx
Normal file
34
src/renderer/components/badge/index.tsx
Normal 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);
|
||||
290
src/renderer/components/button/index.tsx
Normal file
290
src/renderer/components/button/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
304
src/renderer/components/card/album-card.tsx
Normal file
304
src/renderer/components/card/album-card.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
196
src/renderer/components/card/card-controls.tsx
Normal file
196
src/renderer/components/card/card-controls.tsx
Normal 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;
|
||||
1
src/renderer/components/card/index.tsx
Normal file
1
src/renderer/components/card/index.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './album-card';
|
||||
50
src/renderer/components/date-picker/index.tsx
Normal file
50
src/renderer/components/date-picker/index.tsx
Normal 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,
|
||||
};
|
||||
116
src/renderer/components/dropdown-menu/index.tsx
Normal file
116
src/renderer/components/dropdown-menu/index.tsx
Normal 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;
|
||||
33
src/renderer/components/dropzone/index.tsx
Normal file
33
src/renderer/components/dropzone/index.tsx
Normal 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;
|
||||
206
src/renderer/components/feature-carousel/index.tsx
Normal file
206
src/renderer/components/feature-carousel/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
210
src/renderer/components/grid-carousel/index.tsx
Normal file
210
src/renderer/components/grid-carousel/index.tsx
Normal 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;
|
||||
29
src/renderer/components/index.ts
Normal file
29
src/renderer/components/index.ts
Normal 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';
|
||||
364
src/renderer/components/input/index.tsx
Normal file
364
src/renderer/components/input/index.tsx
Normal 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,
|
||||
};
|
||||
43
src/renderer/components/modal/index.tsx
Normal file
43
src/renderer/components/modal/index.tsx
Normal 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,
|
||||
};
|
||||
54
src/renderer/components/page-header/index.tsx
Normal file
54
src/renderer/components/page-header/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
15
src/renderer/components/paper/index.tsx
Normal file
15
src/renderer/components/paper/index.tsx
Normal 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>;
|
||||
};
|
||||
38
src/renderer/components/popover/index.tsx
Normal file
38
src/renderer/components/popover/index.tsx
Normal 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;
|
||||
31
src/renderer/components/scroll-area/index.tsx
Normal file
31
src/renderer/components/scroll-area/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
38
src/renderer/components/segmented-control/index.tsx
Normal file
38
src/renderer/components/segmented-control/index.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
144
src/renderer/components/select/index.tsx
Normal file
144
src/renderer/components/select/index.tsx
Normal 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,
|
||||
};
|
||||
39
src/renderer/components/skeleton/index.tsx
Normal file
39
src/renderer/components/skeleton/index.tsx
Normal 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} />;
|
||||
};
|
||||
30
src/renderer/components/slider/index.tsx
Normal file
30
src/renderer/components/slider/index.tsx
Normal 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} />;
|
||||
};
|
||||
23
src/renderer/components/spinner/index.tsx
Normal file
23
src/renderer/components/spinner/index.tsx
Normal 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,
|
||||
};
|
||||
27
src/renderer/components/switch/index.tsx
Normal file
27
src/renderer/components/switch/index.tsx
Normal 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} />;
|
||||
};
|
||||
52
src/renderer/components/tabs/index.tsx
Normal file
52
src/renderer/components/tabs/index.tsx
Normal 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;
|
||||
55
src/renderer/components/text-title/index.tsx
Normal file
55
src/renderer/components/text-title/index.tsx
Normal 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,
|
||||
};
|
||||
59
src/renderer/components/text/index.tsx
Normal file
59
src/renderer/components/text/index.tsx
Normal 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,
|
||||
};
|
||||
71
src/renderer/components/toast/index.tsx
Normal file
71
src/renderer/components/toast/index.tsx
Normal 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 }),
|
||||
};
|
||||
44
src/renderer/components/tooltip/index.tsx
Normal file
44
src/renderer/components/tooltip/index.tsx
Normal 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,
|
||||
};
|
||||
335
src/renderer/components/virtual-grid/grid-card/default-card.tsx
Normal file
335
src/renderer/components/virtual-grid/grid-card/default-card.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
61
src/renderer/components/virtual-grid/grid-card/index.tsx
Normal file
61
src/renderer/components/virtual-grid/grid-card/index.tsx
Normal 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);
|
||||
329
src/renderer/components/virtual-grid/grid-card/poster-card.tsx
Normal file
329
src/renderer/components/virtual-grid/grid-card/poster-card.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
2
src/renderer/components/virtual-grid/index.ts
Normal file
2
src/renderer/components/virtual-grid/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './virtual-grid-wrapper';
|
||||
export * from './virtual-infinite-grid';
|
||||
110
src/renderer/components/virtual-grid/virtual-grid-wrapper.tsx
Normal file
110
src/renderer/components/virtual-grid/virtual-grid-wrapper.tsx
Normal 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;
|
||||
`;
|
||||
154
src/renderer/components/virtual-grid/virtual-infinite-grid.tsx
Normal file
154
src/renderer/components/virtual-grid/virtual-infinite-grid.tsx
Normal 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,
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
47
src/renderer/components/virtual-table/cells/artist-cell.tsx
Normal file
47
src/renderer/components/virtual-table/cells/artist-cell.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
63
src/renderer/components/virtual-table/cells/generic-cell.tsx
Normal file
63
src/renderer/components/virtual-table/cells/generic-cell.tsx
Normal 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,
|
||||
};
|
||||
43
src/renderer/components/virtual-table/cells/genre-cell.tsx
Normal file
43
src/renderer/components/virtual-table/cells/genre-cell.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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} />;
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
190
src/renderer/components/virtual-table/index.tsx
Normal file
190
src/renderer/components/virtual-table/index.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
163
src/renderer/components/virtual-table/table-config-dropdown.tsx
Normal file
163
src/renderer/components/virtual-table/table-config-dropdown.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 '{currentServer?.name}' requires an additional login to
|
||||
access.
|
||||
</Text>
|
||||
<Text size="lg">
|
||||
Add your credentials in the 'manage servers' menu or switch to a different server.
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
1
src/renderer/features/action-required/index.ts
Normal file
1
src/renderer/features/action-required/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './components/error-fallback';
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
256
src/renderer/features/albums/components/album-list-header.tsx
Normal file
256
src/renderer/features/albums/components/album-list-header.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
2
src/renderer/features/albums/index.ts
Normal file
2
src/renderer/features/albums/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './queries/album-detail-query';
|
||||
export * from './queries/album-list-query';
|
||||
16
src/renderer/features/albums/queries/album-detail-query.ts
Normal file
16
src/renderer/features/albums/queries/album-detail-query.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
40
src/renderer/features/albums/queries/album-list-query.ts
Normal file
40
src/renderer/features/albums/queries/album-list-query.ts
Normal 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,
|
||||
// });
|
||||
// };
|
||||
127
src/renderer/features/albums/routes/album-list-route.tsx
Normal file
127
src/renderer/features/albums/routes/album-list-route.tsx
Normal 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;
|
||||
58
src/renderer/features/home/queries/recently-played-query.ts
Normal file
58
src/renderer/features/home/queries/recently-played-query.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
261
src/renderer/features/home/routes/home-route.tsx
Normal file
261
src/renderer/features/home/routes/home-route.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
236
src/renderer/features/now-playing/components/play-queue.tsx
Normal file
236
src/renderer/features/now-playing/components/play-queue.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
4
src/renderer/features/now-playing/index.ts
Normal file
4
src/renderer/features/now-playing/index.ts
Normal 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
Loading…
Add table
Add a link
Reference in a new issue