mirror of
https://github.com/jeffvli/feishin.git
synced 2026-08-11 14:53:00 +02:00
add initial files
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
import '@testing-library/jest-dom';
|
||||
// import { render } from '@testing-library/react';
|
||||
// import { App } from 'renderer/app';
|
||||
|
||||
describe('App', () => {
|
||||
// eslint-disable-next-line jest/no-commented-out-tests
|
||||
// it('should render', () => {
|
||||
// expect(render(<App />)).toBeTruthy();
|
||||
// });
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"player": {
|
||||
"next": "player.next",
|
||||
"play": "player.play",
|
||||
"prev": "player.prev",
|
||||
"seekBack": "player.seekBack",
|
||||
"seekForward": "player.seekForward"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import './player';
|
||||
@@ -0,0 +1,117 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import MpvAPI from 'node-mpv';
|
||||
import { PlayerData } from '../../../../renderer/store';
|
||||
import { getMainWindow } from '../../../main';
|
||||
|
||||
const mpv = new MpvAPI(
|
||||
{
|
||||
audio_only: true,
|
||||
auto_restart: true,
|
||||
binary: 'C:/ProgramData/chocolatey/lib/mpv.install/tools/mpv.exe',
|
||||
time_update: 1,
|
||||
},
|
||||
['--gapless-audio=yes', '--prefetch-playlist']
|
||||
);
|
||||
|
||||
mpv.start().catch((error: any) => {
|
||||
console.log('error', error);
|
||||
});
|
||||
|
||||
mpv.on('status', (status: any) => {
|
||||
if (status.property === 'playlist-pos') {
|
||||
if (status.value !== 0) {
|
||||
getMainWindow()?.webContents.send('renderer-player-set-queue-next');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Automatically updates the play button when the player is playing
|
||||
mpv.on('started', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
mpv.on('seek', () => {
|
||||
console.log('mpv seek');
|
||||
});
|
||||
|
||||
// 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();
|
||||
});
|
||||
|
||||
// Stops the player
|
||||
ipcMain.on('player-next', async () => {
|
||||
await mpv.next();
|
||||
});
|
||||
|
||||
// Stops the player
|
||||
ipcMain.on('player-previous', async () => {
|
||||
await mpv.previous();
|
||||
});
|
||||
|
||||
// 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 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');
|
||||
}
|
||||
});
|
||||
|
||||
// Sets the next song in the queue when reaching the end of the queue
|
||||
ipcMain.on('player-set-queue-next', async (_event, data: PlayerData) => {
|
||||
if (data.queue.next) {
|
||||
await mpv.load(data.queue.next.streamUrl, 'append');
|
||||
}
|
||||
});
|
||||
|
||||
// Sets the volume to the given value (0-100)
|
||||
ipcMain.on('player-volume', async (_event, value: number) => {
|
||||
mpv.volume(value);
|
||||
});
|
||||
|
||||
// Toggles the mute status
|
||||
ipcMain.on('player-mute', async () => {
|
||||
mpv.mute();
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
declare module 'node-mpv';
|
||||
@@ -0,0 +1,4 @@
|
||||
import './core';
|
||||
|
||||
// eslint-disable-next-line import/no-dynamic-require
|
||||
require(`./${process.platform}`);
|
||||
@@ -0,0 +1,165 @@
|
||||
/* 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 } from 'electron';
|
||||
import log from 'electron-log';
|
||||
import { autoUpdater } from 'electron-updater';
|
||||
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: 728,
|
||||
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: 1024,
|
||||
});
|
||||
|
||||
ipcMain.on('window-maximize', () => {
|
||||
mainWindow?.maximize();
|
||||
});
|
||||
|
||||
ipcMain.on('window-unmaximize', () => {
|
||||
mainWindow?.unmaximize();
|
||||
});
|
||||
|
||||
ipcMain.on('window-minimize', () => {
|
||||
mainWindow?.minimize();
|
||||
});
|
||||
|
||||
ipcMain.on('window-close', () => {
|
||||
mainWindow?.close();
|
||||
});
|
||||
|
||||
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
|
||||
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);
|
||||
@@ -0,0 +1,290 @@
|
||||
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([
|
||||
{
|
||||
label: 'Inspect element',
|
||||
click: () => {
|
||||
this.mainWindow.webContents.inspectElement(x, y);
|
||||
},
|
||||
},
|
||||
]).popup({ window: this.mainWindow });
|
||||
});
|
||||
}
|
||||
|
||||
buildDarwinTemplate(): MenuItemConstructorOptions[] {
|
||||
const subMenuAbout: DarwinMenuItemConstructorOptions = {
|
||||
label: 'Electron',
|
||||
submenu: [
|
||||
{
|
||||
label: 'About ElectronReact',
|
||||
selector: 'orderFrontStandardAboutPanel:',
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ label: 'Services', submenu: [] },
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Hide ElectronReact',
|
||||
accelerator: 'Command+H',
|
||||
selector: 'hide:',
|
||||
},
|
||||
{
|
||||
label: 'Hide Others',
|
||||
accelerator: 'Command+Shift+H',
|
||||
selector: 'hideOtherApplications:',
|
||||
},
|
||||
{ label: 'Show All', selector: 'unhideAllApplications:' },
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Quit',
|
||||
accelerator: 'Command+Q',
|
||||
click: () => {
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const subMenuEdit: DarwinMenuItemConstructorOptions = {
|
||||
label: 'Edit',
|
||||
submenu: [
|
||||
{ label: 'Undo', accelerator: 'Command+Z', selector: 'undo:' },
|
||||
{ label: 'Redo', accelerator: 'Shift+Command+Z', selector: 'redo:' },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Cut', accelerator: 'Command+X', selector: 'cut:' },
|
||||
{ label: 'Copy', accelerator: 'Command+C', selector: 'copy:' },
|
||||
{ label: 'Paste', accelerator: 'Command+V', selector: 'paste:' },
|
||||
{
|
||||
label: 'Select All',
|
||||
accelerator: 'Command+A',
|
||||
selector: 'selectAll:',
|
||||
},
|
||||
],
|
||||
};
|
||||
const subMenuViewDev: MenuItemConstructorOptions = {
|
||||
label: 'View',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Reload',
|
||||
accelerator: 'Command+R',
|
||||
click: () => {
|
||||
this.mainWindow.webContents.reload();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Toggle Full Screen',
|
||||
accelerator: 'Ctrl+Command+F',
|
||||
click: () => {
|
||||
this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen());
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Toggle Developer Tools',
|
||||
accelerator: 'Alt+Command+I',
|
||||
click: () => {
|
||||
this.mainWindow.webContents.toggleDevTools();
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const subMenuViewProd: MenuItemConstructorOptions = {
|
||||
label: 'View',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Toggle Full Screen',
|
||||
accelerator: 'Ctrl+Command+F',
|
||||
click: () => {
|
||||
this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen());
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const subMenuWindow: DarwinMenuItemConstructorOptions = {
|
||||
label: 'Window',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Minimize',
|
||||
accelerator: 'Command+M',
|
||||
selector: 'performMiniaturize:',
|
||||
},
|
||||
{ label: 'Close', accelerator: 'Command+W', selector: 'performClose:' },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Bring All to Front', selector: 'arrangeInFront:' },
|
||||
],
|
||||
};
|
||||
const subMenuHelp: MenuItemConstructorOptions = {
|
||||
label: 'Help',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Learn More',
|
||||
click() {
|
||||
shell.openExternal('https://electronjs.org');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Documentation',
|
||||
click() {
|
||||
shell.openExternal(
|
||||
'https://github.com/electron/electron/tree/main/docs#readme'
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Community Discussions',
|
||||
click() {
|
||||
shell.openExternal('https://www.electronjs.org/community');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Search Issues',
|
||||
click() {
|
||||
shell.openExternal('https://github.com/electron/electron/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: [
|
||||
{
|
||||
label: '&Open',
|
||||
accelerator: 'Ctrl+O',
|
||||
},
|
||||
{
|
||||
label: '&Close',
|
||||
accelerator: 'Ctrl+W',
|
||||
click: () => {
|
||||
this.mainWindow.close();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '&View',
|
||||
submenu:
|
||||
process.env.NODE_ENV === 'development' ||
|
||||
process.env.DEBUG_PROD === 'true'
|
||||
? [
|
||||
{
|
||||
label: '&Reload',
|
||||
accelerator: 'Ctrl+R',
|
||||
click: () => {
|
||||
this.mainWindow.webContents.reload();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Toggle &Full Screen',
|
||||
accelerator: 'F11',
|
||||
click: () => {
|
||||
this.mainWindow.setFullScreen(
|
||||
!this.mainWindow.isFullScreen()
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Toggle &Developer Tools',
|
||||
accelerator: 'Alt+Ctrl+I',
|
||||
click: () => {
|
||||
this.mainWindow.webContents.toggleDevTools();
|
||||
},
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
label: 'Toggle &Full Screen',
|
||||
accelerator: 'F11',
|
||||
click: () => {
|
||||
this.mainWindow.setFullScreen(
|
||||
!this.mainWindow.isFullScreen()
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Help',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Learn More',
|
||||
click() {
|
||||
shell.openExternal('https://electronjs.org');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Documentation',
|
||||
click() {
|
||||
shell.openExternal(
|
||||
'https://github.com/electron/electron/tree/main/docs#readme'
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Community Discussions',
|
||||
click() {
|
||||
shell.openExternal('https://www.electronjs.org/community');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Search Issues',
|
||||
click() {
|
||||
shell.openExternal('https://github.com/electron/electron/issues');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return templateDefault;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron';
|
||||
import { PlayerData } from 'renderer/store';
|
||||
|
||||
contextBridge.exposeInMainWorld('electron', {
|
||||
ipcRenderer: {
|
||||
PLAYER_CURRENT_TIME() {
|
||||
ipcRenderer.send('player-current-time');
|
||||
},
|
||||
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_CURRENT_TIME(
|
||||
cb: (event: IpcRendererEvent, data: any) => void
|
||||
) {
|
||||
ipcRenderer.on('renderer-player-current-time', 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_SET_QUEUE_NEXT(
|
||||
cb: (event: IpcRendererEvent, data: any) => void
|
||||
) {
|
||||
ipcRenderer.on('renderer-player-set-queue-next', cb);
|
||||
},
|
||||
RENDERER_PLAYER_STOP(cb: (event: IpcRendererEvent, data: any) => void) {
|
||||
ipcRenderer.on('renderer-player-stop', cb);
|
||||
},
|
||||
windowClose() {
|
||||
ipcRenderer.send('window-close');
|
||||
},
|
||||
windowMaximize() {
|
||||
ipcRenderer.send('window-maximize');
|
||||
},
|
||||
windowMinimize() {
|
||||
ipcRenderer.send('window-minimize');
|
||||
},
|
||||
windowUnmaximize() {
|
||||
ipcRenderer.send('window-unmaximize');
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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';
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { api } from 'renderer/lib';
|
||||
import { AlbumsResponse, BasePaginationRequest } from './types';
|
||||
|
||||
export interface AlbumsRequest extends BasePaginationRequest {
|
||||
orderBy: string;
|
||||
serverFolderIds?: string;
|
||||
sortBy: string;
|
||||
}
|
||||
|
||||
const getAlbum = async (params: { id: number }, signal?: AbortSignal) => {
|
||||
const { data } = await api.get<AlbumsResponse>(`/albums/${params.id}`, {
|
||||
signal,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
const getAlbums = async (params: AlbumsRequest, signal?: AbortSignal) => {
|
||||
const { data } = await api.get<AlbumsResponse>(`/albums`, {
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
export const albumsApi = {
|
||||
getAlbum,
|
||||
getAlbums,
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
// import axios from 'axios';
|
||||
import axios from 'axios';
|
||||
import { LoginResponse, PingResponse } from './types';
|
||||
|
||||
const login = async (
|
||||
serverUrl: string,
|
||||
body: {
|
||||
password: string;
|
||||
username: string;
|
||||
}
|
||||
) => {
|
||||
const { data } = await axios.post<LoginResponse>(
|
||||
`${serverUrl}/api/auth/login`,
|
||||
body
|
||||
);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const ping = async (serverUrl: string) => {
|
||||
const { data } = await axios.get<PingResponse>(`${serverUrl}/api/auth/ping`, {
|
||||
timeout: 2000,
|
||||
});
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const refresh = async (serverUrl: string, body: { refreshToken: string }) => {
|
||||
const { data } = await axios.post(`${serverUrl}/api/auth/refresh`, body);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
export const authApi = {
|
||||
login,
|
||||
ping,
|
||||
refresh,
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useQuery } from 'react-query';
|
||||
import { albumsApi } from '../albumsApi';
|
||||
import { queryKeys } from '../queryKeys';
|
||||
|
||||
export const useAlbum = (albumId: number) => {
|
||||
return useQuery({
|
||||
queryFn: () => albumsApi.getAlbum(albumId),
|
||||
queryKey: queryKeys.album(albumId),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { AlbumsRequest } from './albumsApi';
|
||||
|
||||
export const queryKeys = {
|
||||
album: (albumId: number) => ['album', albumId] as const,
|
||||
albums: (params: AlbumsRequest) => ['albums', params] as const,
|
||||
ping: (url: string) => ['ping', url] as const,
|
||||
servers: ['servers'] as const,
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { api } from 'renderer/lib';
|
||||
|
||||
const getServers = async () => {
|
||||
const { data } = await api.get<any[]>('/servers');
|
||||
return data;
|
||||
};
|
||||
|
||||
const createServer = async (body: {
|
||||
name: string;
|
||||
remoteUserId: string;
|
||||
token: string;
|
||||
url: string;
|
||||
username: string;
|
||||
}) => {
|
||||
const { data } = await api.post<any>('/servers', body);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const serversApi = {
|
||||
createServer,
|
||||
getServers,
|
||||
};
|
||||
@@ -0,0 +1,162 @@
|
||||
export interface BaseResponse<T> {
|
||||
data: T;
|
||||
error?: string | any;
|
||||
response: 'Success' | 'Error';
|
||||
statusCode: number;
|
||||
}
|
||||
|
||||
export interface BasePaginatedResponse<T> {
|
||||
data: T;
|
||||
error?: string | any;
|
||||
pagination: {
|
||||
nextPage: string | null;
|
||||
prevPage: string | null;
|
||||
startIndex: number;
|
||||
totalEntries: number;
|
||||
};
|
||||
response: 'Success' | 'Error';
|
||||
statusCode: number;
|
||||
}
|
||||
|
||||
export interface BasePaginationRequest {
|
||||
limit: number;
|
||||
page: number;
|
||||
}
|
||||
|
||||
export type ServerResponse = {
|
||||
createdAt: string;
|
||||
id: number;
|
||||
name: string;
|
||||
remoteUserId: string;
|
||||
serverFolder?: ServerFolderResponse[];
|
||||
serverType: string;
|
||||
token: string;
|
||||
updatedAt: string;
|
||||
url: string;
|
||||
username: string;
|
||||
};
|
||||
|
||||
export type ServerFolderResponse = {
|
||||
createdAt: string;
|
||||
enabled: boolean;
|
||||
id: number;
|
||||
isPublic: boolean;
|
||||
name: string;
|
||||
remoteId: string;
|
||||
serverId: number;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type User = {
|
||||
createdAt: string;
|
||||
enabled: boolean;
|
||||
id: number;
|
||||
isAdmin: boolean;
|
||||
password: string;
|
||||
updatedAt: string;
|
||||
username: string;
|
||||
};
|
||||
|
||||
export type Login = {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
} & User;
|
||||
|
||||
export type Ping = {
|
||||
description: string;
|
||||
name: string;
|
||||
version: string;
|
||||
};
|
||||
|
||||
export type GenreResponse = {
|
||||
createdAt: string;
|
||||
id: number;
|
||||
name: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type ArtistResponse = {
|
||||
biography: string | null;
|
||||
createdAt: string;
|
||||
id: number;
|
||||
name: string;
|
||||
remoteCreatedAt: string | null;
|
||||
remoteId: string;
|
||||
serverFolderId: number;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type ExternalResponse = {
|
||||
createdAt: string;
|
||||
id: number;
|
||||
name: string;
|
||||
updatedAt: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type ImageResponse = {
|
||||
createdAt: string;
|
||||
id: number;
|
||||
name: string;
|
||||
updatedAt: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type PingResponse = BaseResponse<Ping>;
|
||||
|
||||
export type LoginResponse = BaseResponse<Login>;
|
||||
|
||||
export type UserResponse = BaseResponse<User>;
|
||||
|
||||
export type AlbumResponse = BaseResponse<Album>;
|
||||
|
||||
export type AlbumsResponse = BasePaginatedResponse<Album[]>;
|
||||
|
||||
export interface Album {
|
||||
_count: Count;
|
||||
albumArtistId: number;
|
||||
createdAt: string;
|
||||
date: string;
|
||||
genres: GenreResponse[];
|
||||
id: number;
|
||||
name: string;
|
||||
remoteCreatedAt: string;
|
||||
remoteId: string;
|
||||
serverFolderId: number;
|
||||
songs: Song[];
|
||||
updatedAt: string;
|
||||
year: number;
|
||||
}
|
||||
|
||||
export interface Song {
|
||||
album?: Partial<Album>;
|
||||
albumId: number;
|
||||
artistName: null;
|
||||
artists?: ArtistResponse[];
|
||||
bitRate: number;
|
||||
container: string;
|
||||
createdAt: string;
|
||||
date: string;
|
||||
disc: number;
|
||||
duration: number;
|
||||
externals?: ExternalResponse[];
|
||||
id: number;
|
||||
images?: ImageResponse[];
|
||||
name: string;
|
||||
remoteCreatedAt: string;
|
||||
remoteId: string;
|
||||
serverFolderId: number;
|
||||
track: number;
|
||||
updatedAt: string;
|
||||
year: number;
|
||||
}
|
||||
|
||||
export type Count = {
|
||||
artists?: number;
|
||||
externals?: number;
|
||||
favorites?: number;
|
||||
genres?: number;
|
||||
images?: number;
|
||||
ratings?: number;
|
||||
songs?: number;
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { api } from 'renderer/lib';
|
||||
import { UserResponse } from './types';
|
||||
|
||||
const getUsers = async () => {
|
||||
const { data } = await api.get<UserResponse>('/users');
|
||||
return data;
|
||||
};
|
||||
|
||||
export const usersApi = {
|
||||
getUsers,
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ReactNode, useEffect } from 'react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import { useLocalStorage } from '@mantine/hooks';
|
||||
import isElectron from 'is-electron';
|
||||
import { BrowserRouter, HashRouter } from 'react-router-dom';
|
||||
import { useDefaultSettings } from './features/settings';
|
||||
import { AppRouter } from './router/AppRouter';
|
||||
import './styles/global.scss';
|
||||
|
||||
const SelectRouter = ({ children }: { children: ReactNode }) => {
|
||||
if (isElectron()) {
|
||||
return <HashRouter>{children}</HashRouter>;
|
||||
}
|
||||
|
||||
return <BrowserRouter>{children}</BrowserRouter>;
|
||||
};
|
||||
|
||||
export const App = () => {
|
||||
const [theme] = useLocalStorage({
|
||||
defaultValue: 'dark',
|
||||
key: 'theme',
|
||||
});
|
||||
|
||||
useDefaultSettings();
|
||||
|
||||
useEffect(() => {
|
||||
document.body.setAttribute('data-theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<MantineProvider
|
||||
theme={{
|
||||
colorScheme: 'dark',
|
||||
defaultRadius: 'xs',
|
||||
focusRing: 'auto',
|
||||
fontSizes: {
|
||||
lg: 16,
|
||||
md: 14,
|
||||
sm: 12,
|
||||
xl: 18,
|
||||
xs: 10,
|
||||
},
|
||||
other: {},
|
||||
spacing: {
|
||||
xs: 2,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SelectRouter>
|
||||
<AppRouter />
|
||||
</SelectRouter>
|
||||
</MantineProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,184 @@
|
||||
import {
|
||||
useImperativeHandle,
|
||||
forwardRef,
|
||||
useRef,
|
||||
useState,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
import ReactPlayer, { ReactPlayerProps } from 'react-player';
|
||||
import {
|
||||
CrossfadeStyle,
|
||||
PlaybackStyle,
|
||||
PlayerStatus,
|
||||
Song,
|
||||
} from '../../../types';
|
||||
import { crossfadeHandler, gaplessHandler } from './utils/listenHandlers';
|
||||
|
||||
interface AudioPlayerProps extends ReactPlayerProps {
|
||||
crossfadeDuration: number;
|
||||
crossfadeStyle: CrossfadeStyle;
|
||||
currentPlayer: 1 | 2;
|
||||
player1: Song;
|
||||
player2: Song;
|
||||
status: PlayerStatus;
|
||||
style: PlaybackStyle;
|
||||
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,
|
||||
style,
|
||||
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);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
get player1() {
|
||||
return player1Ref?.current;
|
||||
},
|
||||
get player2() {
|
||||
return player2Ref?.current;
|
||||
},
|
||||
}));
|
||||
|
||||
const handleOnEnded = () => {
|
||||
autoNext();
|
||||
setIsTransitioning(false);
|
||||
};
|
||||
|
||||
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?.suffix === 'flac',
|
||||
isTransitioning,
|
||||
nextPlayerRef: player2Ref,
|
||||
setIsTransitioning,
|
||||
});
|
||||
},
|
||||
[isTransitioning, player1?.suffix]
|
||||
);
|
||||
|
||||
const handleGapless2 = useCallback(
|
||||
(e: AudioPlayerProgress) => {
|
||||
return gaplessHandler({
|
||||
currentTime: e.playedSeconds,
|
||||
duration: getDuration(player2Ref),
|
||||
isFlac: player2?.suffix === 'flac',
|
||||
isTransitioning,
|
||||
nextPlayerRef: player1Ref,
|
||||
setIsTransitioning,
|
||||
});
|
||||
},
|
||||
[isTransitioning, player2?.suffix]
|
||||
);
|
||||
|
||||
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={
|
||||
style === 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={
|
||||
style === PlaybackStyle.Gapless ? handleGapless2 : handleCrossfade2
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,147 @@
|
||||
/* eslint-disable no-nested-ternary */
|
||||
import { Dispatch } from 'react';
|
||||
import { CrossfadeStyle } from '../../../../types';
|
||||
|
||||
export const gaplessHandler = (args: {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
isFlac: boolean;
|
||||
isTransitioning: boolean;
|
||||
nextPlayerRef: any;
|
||||
setIsTransitioning: Dispatch<boolean>;
|
||||
}) => {
|
||||
const {
|
||||
nextPlayerRef,
|
||||
currentTime,
|
||||
duration,
|
||||
isTransitioning,
|
||||
setIsTransitioning,
|
||||
isFlac,
|
||||
} = args;
|
||||
|
||||
if (!isTransitioning) {
|
||||
if (currentTime > duration - 2) {
|
||||
return setIsTransitioning(true);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const durationPadding = isFlac ? 0.065 : 0.116;
|
||||
if (currentTime + durationPadding >= duration) {
|
||||
return nextPlayerRef.current.getInternalPlayer().play();
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const crossfadeHandler = (args: {
|
||||
currentPlayer: 1 | 2;
|
||||
currentPlayerRef: any;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
fadeDuration: number;
|
||||
fadeType: CrossfadeStyle;
|
||||
isTransitioning: boolean;
|
||||
nextPlayerRef: any;
|
||||
player: 1 | 2;
|
||||
setIsTransitioning: Dispatch<boolean>;
|
||||
volume: number;
|
||||
}) => {
|
||||
const {
|
||||
currentTime,
|
||||
player,
|
||||
currentPlayer,
|
||||
currentPlayerRef,
|
||||
nextPlayerRef,
|
||||
fadeDuration,
|
||||
fadeType,
|
||||
duration,
|
||||
volume,
|
||||
isTransitioning,
|
||||
setIsTransitioning,
|
||||
} = args;
|
||||
|
||||
if (!isTransitioning || currentPlayer !== player) {
|
||||
const shouldBeginTransition = currentTime >= duration - fadeDuration;
|
||||
|
||||
if (shouldBeginTransition) {
|
||||
setIsTransitioning(true);
|
||||
return nextPlayerRef.current.getInternalPlayer().play();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const timeLeft = duration - currentTime;
|
||||
let currentPlayerVolumeCalculation;
|
||||
let nextPlayerVolumeCalculation;
|
||||
let percentageOfFadeLeft;
|
||||
let n;
|
||||
switch (fadeType) {
|
||||
case 'equalPower':
|
||||
// https://dsp.stackexchange.com/a/14755
|
||||
percentageOfFadeLeft = (timeLeft / fadeDuration) * 2;
|
||||
currentPlayerVolumeCalculation =
|
||||
Math.sqrt(0.5 * percentageOfFadeLeft) * volume;
|
||||
nextPlayerVolumeCalculation =
|
||||
Math.sqrt(0.5 * (2 - percentageOfFadeLeft)) * volume;
|
||||
break;
|
||||
case 'linear':
|
||||
currentPlayerVolumeCalculation = (timeLeft / fadeDuration) * volume;
|
||||
nextPlayerVolumeCalculation =
|
||||
((fadeDuration - timeLeft) / fadeDuration) * volume;
|
||||
break;
|
||||
case 'dipped':
|
||||
// https://math.stackexchange.com/a/4622
|
||||
percentageOfFadeLeft = timeLeft / fadeDuration;
|
||||
currentPlayerVolumeCalculation = percentageOfFadeLeft ** 2 * volume;
|
||||
nextPlayerVolumeCalculation = (percentageOfFadeLeft - 1) ** 2 * volume;
|
||||
break;
|
||||
case fadeType.match(/constantPower.*/)?.input:
|
||||
// https://math.stackexchange.com/a/26159
|
||||
n =
|
||||
fadeType === 'constantPower'
|
||||
? 0
|
||||
: fadeType === 'constantPowerSlowFade'
|
||||
? 1
|
||||
: fadeType === 'constantPowerSlowCut'
|
||||
? 3
|
||||
: 10;
|
||||
|
||||
percentageOfFadeLeft = timeLeft / fadeDuration;
|
||||
currentPlayerVolumeCalculation =
|
||||
Math.cos(
|
||||
(Math.PI / 4) * ((2 * percentageOfFadeLeft - 1) ** (2 * n + 1) - 1)
|
||||
) * volume;
|
||||
nextPlayerVolumeCalculation =
|
||||
Math.cos(
|
||||
(Math.PI / 4) * ((2 * percentageOfFadeLeft - 1) ** (2 * n + 1) + 1)
|
||||
) * volume;
|
||||
break;
|
||||
|
||||
default:
|
||||
currentPlayerVolumeCalculation = (timeLeft / fadeDuration) * volume;
|
||||
nextPlayerVolumeCalculation =
|
||||
((fadeDuration - timeLeft) / fadeDuration) * volume;
|
||||
break;
|
||||
}
|
||||
|
||||
const currentPlayerVolume =
|
||||
currentPlayerVolumeCalculation >= 0 ? currentPlayerVolumeCalculation : 0;
|
||||
|
||||
const nextPlayerVolume =
|
||||
nextPlayerVolumeCalculation <= volume
|
||||
? nextPlayerVolumeCalculation
|
||||
: volume;
|
||||
|
||||
if (currentPlayer === 1) {
|
||||
currentPlayerRef.current.getInternalPlayer().volume = currentPlayerVolume;
|
||||
nextPlayerRef.current.getInternalPlayer().volume = nextPlayerVolume;
|
||||
} else {
|
||||
currentPlayerRef.current.getInternalPlayer().volume = currentPlayerVolume;
|
||||
nextPlayerRef.current.getInternalPlayer().volume = nextPlayerVolume;
|
||||
}
|
||||
// }
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Button as MantineButton } from '@mantine/core';
|
||||
|
||||
interface ButtonProps {
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
export const Button = ({ icon }: ButtonProps) => {
|
||||
return <MantineButton>Button</MantineButton>;
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { ActionIcon, ActionIconProps, TooltipProps } from '@mantine/core';
|
||||
import { Tooltip } from '../tooltip/Tooltip';
|
||||
|
||||
type MantineIconButtonProps = ActionIconProps &
|
||||
React.ComponentPropsWithoutRef<'button'>;
|
||||
|
||||
interface IconButtonProps extends MantineIconButtonProps {
|
||||
active?: boolean;
|
||||
icon: ReactNode;
|
||||
tooltip?: Omit<TooltipProps, 'children'>;
|
||||
}
|
||||
|
||||
export const IconButton = ({
|
||||
active,
|
||||
tooltip,
|
||||
icon,
|
||||
...rest
|
||||
}: IconButtonProps) => {
|
||||
if (tooltip) {
|
||||
return (
|
||||
<Tooltip {...tooltip}>
|
||||
<ActionIcon {...rest}>{icon}</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return <ActionIcon {...rest}>{icon}</ActionIcon>;
|
||||
};
|
||||
|
||||
IconButton.defaultProps = {
|
||||
active: false,
|
||||
tooltip: undefined,
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './tooltip/Tooltip';
|
||||
export * from './audio-player/AudioPlayer';
|
||||
export * from './icon-button/IconButton';
|
||||
export * from './text/Text';
|
||||
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
Modal as MantineModal,
|
||||
ModalProps as MantineModalProps,
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
|
||||
interface ModalProps extends MantineModalProps {
|
||||
condition: boolean;
|
||||
}
|
||||
|
||||
export const Modal = ({ condition, children, ...rest }: ModalProps) => {
|
||||
const [opened, handlers] = useDisclosure(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{condition && (
|
||||
<MantineModal
|
||||
{...rest}
|
||||
opened={opened}
|
||||
onClose={() => handlers.close()}
|
||||
>
|
||||
{children}
|
||||
</MantineModal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import { ReactNode } from 'react';
|
||||
import {
|
||||
Text as MantineText,
|
||||
TextProps as MantineTextProps,
|
||||
} from '@mantine/core';
|
||||
import { Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { Font } from 'renderer/styles';
|
||||
import { textEllipsis } from 'renderer/styles/mixins';
|
||||
|
||||
interface TextProps extends MantineTextProps<'div'> {
|
||||
children: ReactNode;
|
||||
font?: Font;
|
||||
link?: boolean;
|
||||
noSelect?: boolean;
|
||||
overflow?: 'hidden' | 'visible';
|
||||
secondary?: boolean;
|
||||
to?: string;
|
||||
weight?: number;
|
||||
}
|
||||
|
||||
interface LinkTextProps extends MantineTextProps<'Link'> {
|
||||
children: ReactNode;
|
||||
font?: Font;
|
||||
link?: boolean;
|
||||
overflow?: 'hidden' | 'visible';
|
||||
secondary?: boolean;
|
||||
to: string;
|
||||
weight?: number;
|
||||
}
|
||||
|
||||
const BaseText = styled(MantineText)<any>`
|
||||
color: ${(props) =>
|
||||
props.$secondary
|
||||
? 'var(--playerbar-text-secondary-color)'
|
||||
: 'var(--playerbar-text-primary-color)'};
|
||||
font-family: ${(props) => props.font || Font.GOTHAM};
|
||||
cursor: ${(props) => (props.link ? 'cursor' : 'default')};
|
||||
user-select: ${(props) => (props.$noSelect ? 'none' : 'auto')};
|
||||
${(props) => props.overflow === 'hidden' && textEllipsis}
|
||||
`;
|
||||
|
||||
const StyledText = styled(BaseText)<TextProps>``;
|
||||
|
||||
const StyledLinkText = styled(BaseText)<LinkTextProps>``;
|
||||
|
||||
export const Text = ({
|
||||
children,
|
||||
link,
|
||||
secondary,
|
||||
overflow,
|
||||
font,
|
||||
to,
|
||||
noSelect,
|
||||
...rest
|
||||
}: TextProps) => {
|
||||
if (link) {
|
||||
return (
|
||||
<StyledLinkText<typeof Link>
|
||||
$noSelect={noSelect}
|
||||
$secondary={secondary}
|
||||
component={Link}
|
||||
font={font}
|
||||
link="true"
|
||||
overflow={overflow}
|
||||
to={to || ''}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</StyledLinkText>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledText
|
||||
$noSelect={noSelect}
|
||||
$secondary={secondary}
|
||||
font={font}
|
||||
overflow={overflow}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</StyledText>
|
||||
);
|
||||
};
|
||||
|
||||
Text.defaultProps = {
|
||||
font: Font.GOTHAM,
|
||||
link: false,
|
||||
noSelect: false,
|
||||
overflow: 'visible',
|
||||
secondary: false,
|
||||
to: '',
|
||||
weight: 500,
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
.body {
|
||||
padding: 5px;
|
||||
color: var(--tooltip-text-color);
|
||||
background: var(--tooltip-bg);
|
||||
}
|
||||
|
||||
.arrow {
|
||||
background: var(--tooltip-bg);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Tooltip as MantineTooltip, TooltipProps } from '@mantine/core';
|
||||
import styles from './Tooltip.module.scss';
|
||||
|
||||
export const Tooltip = ({ children, ...rest }: TooltipProps) => {
|
||||
return (
|
||||
<MantineTooltip
|
||||
classNames={{ arrow: styles.arrow, body: styles.body }}
|
||||
radius="xs"
|
||||
styles={{
|
||||
arrow: {
|
||||
background: 'var(--tooltip-bg)',
|
||||
},
|
||||
body: {
|
||||
background: 'var(--tooltip-bg)',
|
||||
color: 'var(--tooltip-text-color)',
|
||||
padding: '5px',
|
||||
},
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</MantineTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
Tooltip.defaultProps = {
|
||||
openDelay: 0,
|
||||
placement: 'center',
|
||||
transition: 'fade',
|
||||
transitionDuration: 250,
|
||||
withArrow: true,
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Card } from '@mantine/core';
|
||||
import { motion } from 'framer-motion';
|
||||
import styled from 'styled-components';
|
||||
import { CardRow } from 'renderer/types';
|
||||
import { Text } from '../text/Text';
|
||||
import { GridCardControls } from './GridCardControls';
|
||||
|
||||
const CardWrapper = styled(motion.div)<{
|
||||
itemGap: number;
|
||||
itemHeight: number;
|
||||
itemWidth: number;
|
||||
}>`
|
||||
display: flex;
|
||||
flex: ${({ itemWidth }) => `0 0 ${itemWidth}px`};
|
||||
width: ${({ itemWidth }) => `${itemWidth}px`};
|
||||
height: ${({ itemHeight }) => `${itemHeight}px`};
|
||||
margin: ${({ itemGap }) => `0 ${itemGap / 2}px`};
|
||||
border-radius: 3px;
|
||||
filter: drop-shadow(0 4px 4px #000);
|
||||
user-select: none;
|
||||
pointer-events: auto; // https://github.com/bvaughn/react-window/issues/128#issuecomment-460166682
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px solid #fff;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledCard = styled(Card)`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
background-color: rgb(50, 50, 50, 50%);
|
||||
border-radius: 3px;
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
background-color: rgb(50, 50, 50, 60%);
|
||||
}
|
||||
`;
|
||||
|
||||
const ImageSection = styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const Image = styled(motion.div)<{ height: number; src: string }>`
|
||||
height: ${({ height }) => `${height}px`};
|
||||
background: ${({ src }) => `url(${src})`};
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
border: 0;
|
||||
`;
|
||||
|
||||
const ControlsContainer = styled.div`
|
||||
display: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
${StyledCard}:hover & {
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
|
||||
const DetailSection = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const Row = styled.div`
|
||||
height: 25px;
|
||||
padding: 0 0.2rem;
|
||||
`;
|
||||
|
||||
export const GridCard = ({ data, index, style, isScrolling }: any) => {
|
||||
const {
|
||||
itemHeight,
|
||||
itemWidth,
|
||||
columnCount,
|
||||
itemGap,
|
||||
itemCount,
|
||||
cardControls,
|
||||
cardRows,
|
||||
itemData,
|
||||
handlePlayQueueAdd,
|
||||
} = data;
|
||||
|
||||
const startIndex = index * columnCount;
|
||||
const stopIndex = Math.min(itemCount - 1, startIndex + columnCount - 1);
|
||||
const cards = [];
|
||||
|
||||
for (let i = startIndex; i <= stopIndex; i += 1) {
|
||||
cards.push(
|
||||
<CardWrapper
|
||||
key={`card-${i}-${index}`}
|
||||
itemGap={itemGap}
|
||||
itemHeight={itemHeight}
|
||||
itemWidth={itemWidth}
|
||||
tabIndex={0}
|
||||
>
|
||||
<StyledCard>
|
||||
<ImageSection>
|
||||
<Image
|
||||
animate={{
|
||||
opacity: 1,
|
||||
}}
|
||||
height={itemWidth}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
}}
|
||||
src={itemData[i]?.image}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
ease: 'anticipate',
|
||||
}}
|
||||
>
|
||||
{!isScrolling && (
|
||||
<ControlsContainer>
|
||||
<GridCardControls
|
||||
cardControls={cardControls}
|
||||
handlePlayQueueAdd={handlePlayQueueAdd}
|
||||
itemData={itemData[i]}
|
||||
/>
|
||||
</ControlsContainer>
|
||||
)}
|
||||
</Image>
|
||||
</ImageSection>
|
||||
<DetailSection>
|
||||
{cardRows.map((row: CardRow) => (
|
||||
<Row key={`row-${row.prop}`}>
|
||||
<Text overflow="hidden" weight={500}>
|
||||
{itemData[i] && itemData[i][row.prop]}
|
||||
</Text>
|
||||
</Row>
|
||||
))}
|
||||
</DetailSection>
|
||||
</StyledCard>
|
||||
</CardWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...style,
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
justifyContent: 'start',
|
||||
}}
|
||||
>
|
||||
{cards}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import { UnstyledButton, UnstyledButtonProps } from '@mantine/core';
|
||||
import { motion } from 'framer-motion';
|
||||
import styled from 'styled-components';
|
||||
import { PlayerPlay } from 'tabler-icons-react';
|
||||
|
||||
type PlayButtonType = UnstyledButtonProps &
|
||||
React.ComponentPropsWithoutRef<'button'>;
|
||||
|
||||
const PlayButton = styled(UnstyledButton)<PlayButtonType>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
background-color: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
cursor: default;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
svg {
|
||||
fill: #000;
|
||||
stroke: #000;
|
||||
}
|
||||
`;
|
||||
|
||||
const GridCardControlsContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const ControlsRow = styled(motion.div)`
|
||||
width: 100%;
|
||||
height: calc(100% / 3);
|
||||
`;
|
||||
|
||||
const TopControls = styled(ControlsRow)`
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const CenterControls = styled(ControlsRow)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const BottomControls = styled(ControlsRow)`
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
export const GridCardControls = ({
|
||||
itemData,
|
||||
handlePlayQueueAdd,
|
||||
cardControls,
|
||||
}: any) => {
|
||||
return (
|
||||
<GridCardControlsContainer>
|
||||
<TopControls />
|
||||
<CenterControls animate={{ opacity: 1 }} initial={{ opacity: 0 }}>
|
||||
<PlayButton
|
||||
onClick={() => {
|
||||
handlePlayQueueAdd({
|
||||
byItemType: {
|
||||
endpoint: cardControls.endpoint,
|
||||
id: itemData[cardControls.idProperty],
|
||||
type: cardControls.type,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<PlayerPlay />
|
||||
</PlayButton>
|
||||
</CenterControls>
|
||||
<BottomControls />
|
||||
</GridCardControlsContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Ref, useMemo } from 'react';
|
||||
import { FixedSizeList, FixedSizeListProps } from 'react-window';
|
||||
import { usePlayQueueHandler } from 'renderer/features/player/hooks/usePlayQueueHandler';
|
||||
import { CardRow } from 'renderer/types';
|
||||
import { GridCard } from './GridCard';
|
||||
|
||||
export const VirtualGridWrapper = ({
|
||||
refInstance,
|
||||
cardControls,
|
||||
cardRows,
|
||||
itemGap,
|
||||
itemWidth,
|
||||
itemHeight,
|
||||
itemCount,
|
||||
columnCount,
|
||||
rowCount,
|
||||
...rest
|
||||
}: Omit<FixedSizeListProps, 'ref' | 'itemSize' | 'children'> & {
|
||||
cardControls: any;
|
||||
cardRows: CardRow[];
|
||||
columnCount: number;
|
||||
itemGap: number;
|
||||
itemHeight: number;
|
||||
itemWidth: number;
|
||||
refInstance: Ref<any>;
|
||||
rowCount: number;
|
||||
}) => {
|
||||
const { handlePlayQueueAdd } = usePlayQueueHandler();
|
||||
|
||||
const itemData = useMemo(
|
||||
() => ({
|
||||
cardControls,
|
||||
cardRows,
|
||||
columnCount,
|
||||
handlePlayQueueAdd,
|
||||
itemCount,
|
||||
itemData: rest.itemData,
|
||||
itemGap,
|
||||
itemHeight,
|
||||
itemWidth,
|
||||
}),
|
||||
[
|
||||
cardRows,
|
||||
cardControls,
|
||||
columnCount,
|
||||
itemCount,
|
||||
rest.itemData,
|
||||
itemGap,
|
||||
itemHeight,
|
||||
itemWidth,
|
||||
handlePlayQueueAdd,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<FixedSizeList
|
||||
style={{ scrollBehavior: 'smooth' }}
|
||||
{...rest}
|
||||
ref={refInstance}
|
||||
initialScrollOffset={0}
|
||||
itemCount={rowCount}
|
||||
itemData={itemData}
|
||||
itemSize={itemHeight + itemGap}
|
||||
overscanCount={10}
|
||||
>
|
||||
{GridCard}
|
||||
</FixedSizeList>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
import { forwardRef, Ref, useState } from 'react';
|
||||
import debounce from 'lodash/debounce';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { FixedSizeListProps } from 'react-window';
|
||||
import InfiniteLoader from 'react-window-infinite-loader';
|
||||
import { CardRow } from 'renderer/types';
|
||||
import { VirtualGridWrapper } from './VirtualGridWrapper';
|
||||
|
||||
interface VirtualGridProps
|
||||
extends Omit<
|
||||
FixedSizeListProps,
|
||||
'children' | 'itemSize' | 'height' | 'width'
|
||||
> {
|
||||
cardControls: any;
|
||||
cardRows: CardRow[];
|
||||
itemGap?: number;
|
||||
itemSize: number;
|
||||
minimumBatchSize?: number;
|
||||
query: (props: any) => Promise<any>;
|
||||
queryParams?: Record<string, any>;
|
||||
}
|
||||
|
||||
export const VirtualInfiniteGrid = forwardRef(
|
||||
(
|
||||
{
|
||||
itemCount,
|
||||
itemGap,
|
||||
itemSize,
|
||||
cardControls,
|
||||
cardRows,
|
||||
minimumBatchSize,
|
||||
query,
|
||||
queryParams,
|
||||
}: VirtualGridProps,
|
||||
ref: Ref<InfiniteLoader>
|
||||
) => {
|
||||
const [itemData, setItemData] = useState<any[]>([]);
|
||||
|
||||
const isItemLoaded = (index: number, columnCount: number) => {
|
||||
const itemIndex = index * columnCount;
|
||||
|
||||
return (
|
||||
itemIndex < itemData.length * columnCount &&
|
||||
itemData[itemIndex] !== undefined
|
||||
);
|
||||
};
|
||||
|
||||
const loadMoreItems = async (
|
||||
startIndex: number,
|
||||
stopIndex: number,
|
||||
limit: number,
|
||||
columnCount: number
|
||||
) => {
|
||||
const currentPage = Math.ceil(startIndex / minimumBatchSize!);
|
||||
|
||||
const t = await query({
|
||||
limit,
|
||||
page: currentPage,
|
||||
...queryParams,
|
||||
});
|
||||
|
||||
// Need to multiply by columnCount due to the grid layout
|
||||
const start = startIndex * columnCount;
|
||||
const end = (stopIndex + 1) * columnCount;
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
const newData: any[] = [...itemData];
|
||||
|
||||
let itemIndex = 0;
|
||||
for (let rowIndex = start; rowIndex < end; rowIndex += 1) {
|
||||
newData[rowIndex] = t?.data[itemIndex];
|
||||
itemIndex += 1;
|
||||
}
|
||||
|
||||
setItemData(newData);
|
||||
resolve();
|
||||
});
|
||||
};
|
||||
|
||||
const debouncedLoadMoreItems = debounce(loadMoreItems, 300);
|
||||
|
||||
return (
|
||||
<AutoSizer>
|
||||
{({ height, width }) => {
|
||||
const itemHeight = itemSize! + cardRows.length * 25;
|
||||
|
||||
const columnCount = Math.floor(
|
||||
(Number(width) - itemGap! + 3) / (itemSize! + itemGap! + 2)
|
||||
);
|
||||
|
||||
const rowCount = Math.ceil(itemCount / columnCount);
|
||||
|
||||
const pageItemLimit = columnCount * minimumBatchSize!;
|
||||
|
||||
return (
|
||||
<InfiniteLoader
|
||||
ref={ref}
|
||||
isItemLoaded={(index) => isItemLoaded(index, columnCount)}
|
||||
itemCount={itemCount || 0}
|
||||
loadMoreItems={(startIndex, stopIndex) =>
|
||||
debouncedLoadMoreItems(
|
||||
startIndex,
|
||||
stopIndex,
|
||||
pageItemLimit,
|
||||
columnCount
|
||||
)
|
||||
}
|
||||
minimumBatchSize={minimumBatchSize}
|
||||
threshold={10}
|
||||
>
|
||||
{({ onItemsRendered, ref: infiniteLoaderRef }) => (
|
||||
<VirtualGridWrapper
|
||||
useIsScrolling
|
||||
cardControls={cardControls}
|
||||
cardRows={cardRows}
|
||||
columnCount={columnCount}
|
||||
height={height}
|
||||
itemCount={itemCount || 0}
|
||||
itemData={itemData}
|
||||
itemGap={itemGap!}
|
||||
itemHeight={itemHeight!}
|
||||
itemWidth={itemSize}
|
||||
refInstance={infiniteLoaderRef}
|
||||
rowCount={rowCount}
|
||||
width={width}
|
||||
onItemsRendered={onItemsRendered}
|
||||
/>
|
||||
)}
|
||||
</InfiniteLoader>
|
||||
);
|
||||
}}
|
||||
</AutoSizer>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
VirtualInfiniteGrid.defaultProps = {
|
||||
itemGap: 10,
|
||||
minimumBatchSize: 20,
|
||||
queryParams: {},
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './routes/LoginRoute';
|
||||
@@ -0,0 +1,38 @@
|
||||
import md5 from 'md5';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { useMutation } from 'react-query';
|
||||
import { authApi } from 'renderer/api/authApi';
|
||||
import { useAuthStore } from 'renderer/store';
|
||||
|
||||
export const useLogin = (
|
||||
serverUrl: string,
|
||||
body: {
|
||||
password: string;
|
||||
username: string;
|
||||
}
|
||||
) => {
|
||||
const login = useAuthStore((state) => state.login);
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => authApi.login(serverUrl, body),
|
||||
onSuccess: (res) => {
|
||||
const key = md5(serverUrl);
|
||||
login({ key, serverUrl });
|
||||
|
||||
if (!localStorage.getItem('device_id')) {
|
||||
localStorage.setItem('device_id', nanoid());
|
||||
}
|
||||
|
||||
localStorage.setItem(
|
||||
'authentication',
|
||||
JSON.stringify({
|
||||
accessToken: res.data.accessToken,
|
||||
isAuthenticated: true,
|
||||
key,
|
||||
refreshToken: res.data.refreshToken,
|
||||
serverUrl,
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useQuery } from 'react-query';
|
||||
import { authApi } from 'renderer/api/authApi';
|
||||
import { queryKeys } from 'renderer/api/queryKeys';
|
||||
|
||||
export const usePingServer = (server: string) => {
|
||||
return useQuery({
|
||||
enabled: !!server,
|
||||
queryFn: () => authApi.ping(server),
|
||||
queryKey: queryKeys.ping(server),
|
||||
retry: false,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
.container {
|
||||
min-width: 400px;
|
||||
max-width: 50%;
|
||||
margin: auto;
|
||||
padding: 3rem;
|
||||
background: rgba(50, 50, 50, 0.4);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.button {
|
||||
background: var(--primary-color);
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
background: var(--primary-color);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
TextInput,
|
||||
PasswordInput,
|
||||
Button,
|
||||
Stack,
|
||||
Title,
|
||||
Loader,
|
||||
Alert,
|
||||
} from '@mantine/core';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { AlertCircle, CircleCheck } from 'tabler-icons-react';
|
||||
import { normalizeServerUrl } from 'renderer/utils';
|
||||
import { useLogin } from '../queries/useLogin';
|
||||
import { usePingServer } from '../queries/usePingServer';
|
||||
import styles from './LoginRoute.module.scss';
|
||||
|
||||
export const LoginRoute = () => {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const [username, setUsername] = useState(searchParams.get('username') || '');
|
||||
const [password, setPassword] = useState(searchParams.get('password') || '');
|
||||
const [server, setServer] = useState(
|
||||
searchParams.get('server') || 'http://localhost:9321'
|
||||
);
|
||||
const [debouncedServer] = useDebouncedValue(server, 500);
|
||||
|
||||
const {
|
||||
mutate: handleLogin,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useLogin(normalizeServerUrl(server), {
|
||||
password,
|
||||
username,
|
||||
});
|
||||
|
||||
const {
|
||||
isLoading: isCheckingServer,
|
||||
isSuccess: isValidServer,
|
||||
isFetched,
|
||||
} = usePingServer(normalizeServerUrl(debouncedServer));
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Title>{t('auth.login')}</Title>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleLogin(undefined, {
|
||||
onError: () => {},
|
||||
onSuccess: () => {},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Stack spacing="md">
|
||||
<TextInput
|
||||
required
|
||||
disabled={isLoading}
|
||||
error={!isValidServer && isFetched}
|
||||
label={`${t('auth.server.label')}`}
|
||||
placeholder={`${t('auth.server.placeholder')}`}
|
||||
rightSection={
|
||||
isCheckingServer ? (
|
||||
<Loader size="xs" />
|
||||
) : isValidServer ? (
|
||||
<CircleCheck />
|
||||
) : null
|
||||
}
|
||||
value={server}
|
||||
variant="default"
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setServer(e.currentTarget.value)
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
required
|
||||
disabled={isLoading}
|
||||
label={`${t('auth.username.label')}`}
|
||||
placeholder={`${t('auth.username.placeholder')}`}
|
||||
value={username}
|
||||
variant="default"
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setUsername(e.currentTarget.value)
|
||||
}
|
||||
/>
|
||||
<PasswordInput
|
||||
required
|
||||
disabled={isLoading}
|
||||
label={`${t('auth.password.label')}`}
|
||||
placeholder={`${t('auth.password.placeholder')}`}
|
||||
value={password}
|
||||
variant="default"
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setPassword(e.currentTarget.value)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
className={styles.button}
|
||||
disabled={!isValidServer}
|
||||
type="submit"
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
{isError && (
|
||||
<Alert color="red" icon={<AlertCircle />} variant="outline">
|
||||
{t('Invalid username or password.')}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './routes/DashboardRoute';
|
||||
@@ -0,0 +1,3 @@
|
||||
export const DashboardRoute = () => {
|
||||
return <></>;
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Link, LinkProps } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { fontGotham } from 'renderer/styles';
|
||||
|
||||
interface LibraryTabProps extends LinkProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const StyledLibraryTab = styled(motion.div)`
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(150, 150, 150, 30%);
|
||||
}
|
||||
`;
|
||||
|
||||
const TabLink = styled(Link)`
|
||||
${fontGotham(60)}
|
||||
color: #fff;
|
||||
font-size: 1.2em;
|
||||
`;
|
||||
|
||||
const LibraryTab = ({ children, ...rest }: LibraryTabProps) => {
|
||||
return (
|
||||
<TabLink {...rest}>
|
||||
<StyledLibraryTab>{children}</StyledLibraryTab>
|
||||
</TabLink>
|
||||
);
|
||||
};
|
||||
|
||||
export default LibraryTab;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useInfiniteQuery, useQuery } from 'react-query';
|
||||
import { queryKeys } from 'renderer/api/queryKeys';
|
||||
import { albumsApi, AlbumsRequest } from '../../../api/albumsApi';
|
||||
|
||||
export const useAlbums = (params: AlbumsRequest) => {
|
||||
return useQuery({
|
||||
queryFn: () => albumsApi.getAlbums(params),
|
||||
queryKey: queryKeys.albums(),
|
||||
});
|
||||
};
|
||||
|
||||
export const useAlbumsInfinite = (params: AlbumsRequest) => {
|
||||
return useInfiniteQuery({
|
||||
getNextPageParam: (lastPage) => {
|
||||
return !!lastPage.pagination.nextPage;
|
||||
},
|
||||
getPreviousPageParam: (firstPage) => {
|
||||
return !!firstPage.pagination.prevPage;
|
||||
},
|
||||
queryFn: ({ pageParam }) =>
|
||||
albumsApi.getAlbums({ ...(pageParam || params) }),
|
||||
queryKey: queryKeys.albums(params),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
/* eslint-disable no-plusplus */
|
||||
import { useRef } from 'react';
|
||||
import InfiniteLoader from 'react-window-infinite-loader';
|
||||
import { albumsApi } from 'renderer/api/albumsApi';
|
||||
import { VirtualInfiniteGrid } from 'renderer/components/virtual-grid/VirtualInfiniteGrid';
|
||||
import { AnimatedPage } from 'renderer/features/shared/components/AnimatedPage';
|
||||
import { AppRoute } from 'renderer/router/utils/routes';
|
||||
import { Item } from 'types';
|
||||
import { useAlbums } from '../queries/getAlbums';
|
||||
|
||||
export const LibraryAlbumsRoute = () => {
|
||||
const infiniteLoaderRef = useRef<InfiniteLoader>(null);
|
||||
|
||||
const params = {
|
||||
orderBy: 'asc',
|
||||
sortBy: 'title',
|
||||
};
|
||||
|
||||
const { data: albums } = useAlbums({
|
||||
limit: 0,
|
||||
page: 0,
|
||||
...params,
|
||||
});
|
||||
|
||||
return (
|
||||
<AnimatedPage>
|
||||
{albums && (
|
||||
<VirtualInfiniteGrid
|
||||
ref={infiniteLoaderRef}
|
||||
cardControls={{
|
||||
endpoint: albumsApi.getAlbum,
|
||||
idProperty: 'id',
|
||||
type: Item.Album,
|
||||
}}
|
||||
cardRows={[
|
||||
{
|
||||
align: 'center',
|
||||
prop: 'name',
|
||||
route: {
|
||||
prop: 'id',
|
||||
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
|
||||
},
|
||||
},
|
||||
{
|
||||
align: 'center',
|
||||
prop: 'year',
|
||||
},
|
||||
]}
|
||||
itemCount={albums.pagination.totalEntries}
|
||||
itemGap={20}
|
||||
itemSize={180}
|
||||
query={albumsApi.getAlbums}
|
||||
queryParams={params}
|
||||
/>
|
||||
)}
|
||||
</AnimatedPage>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
/* eslint-disable no-plusplus */
|
||||
import { useRef } from 'react';
|
||||
import InfiniteLoader from 'react-window-infinite-loader';
|
||||
import { AnimatedPage } from 'renderer/features/shared/components/AnimatedPage';
|
||||
import { useAlbums } from '../queries/getAlbums';
|
||||
|
||||
export const LibraryArtistsRoute = () => {
|
||||
const infiniteLoaderRef = useRef<InfiniteLoader>(null);
|
||||
|
||||
const params = {
|
||||
orderBy: 'asc',
|
||||
sortBy: 'title',
|
||||
};
|
||||
|
||||
const { data: albums } = useAlbums({
|
||||
limit: 0,
|
||||
page: 0,
|
||||
...params,
|
||||
});
|
||||
|
||||
return <AnimatedPage />;
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import styled from 'styled-components';
|
||||
import { AnimatedPage } from 'renderer/features/shared/components/AnimatedPage';
|
||||
import { AppRoute } from 'renderer/router/utils/routes';
|
||||
import LibraryTab from '../components/LibraryTab';
|
||||
|
||||
const TabContainer = styled.div`
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
gap: 1rem;
|
||||
`;
|
||||
|
||||
export const LibraryRoute = () => {
|
||||
return (
|
||||
<AnimatedPage>
|
||||
<TabContainer>
|
||||
<LibraryTab to={AppRoute.LIBRARY_ALBUMS}>Albums</LibraryTab>
|
||||
<LibraryTab to={AppRoute.LIBRARY_ALBUMARTISTS}>
|
||||
Album Artists
|
||||
</LibraryTab>
|
||||
<LibraryTab to={AppRoute.LIBRARY_ARTISTS}>Artists</LibraryTab>
|
||||
</TabContainer>
|
||||
</AnimatedPage>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import format from 'format-duration';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import styled from 'styled-components';
|
||||
import {
|
||||
PlayerPause,
|
||||
PlayerPlay,
|
||||
PlayerSkipBack,
|
||||
PlayerSkipForward,
|
||||
PlayerTrackNext,
|
||||
PlayerTrackPrev,
|
||||
} from 'tabler-icons-react';
|
||||
import { Text } from 'renderer/components';
|
||||
import { usePlayerStore } from 'renderer/store';
|
||||
import { Font } from 'renderer/styles';
|
||||
import { PlaybackType, PlayerStatus } from 'types';
|
||||
import { useCenterControls } from '../hooks/useCenterControls';
|
||||
import { PlayerButton } from './PlayerButton';
|
||||
import { Slider } from './Slider';
|
||||
|
||||
interface CenterControlsProps {
|
||||
playersRef: any;
|
||||
}
|
||||
|
||||
const ControlsContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
height: 35px;
|
||||
`;
|
||||
|
||||
const ButtonsContainer = styled.div`
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const SliderContainer = styled.div`
|
||||
display: flex;
|
||||
height: 20px;
|
||||
`;
|
||||
|
||||
const SliderValueWrapper = styled.div<{ position: 'left' | 'right' }>`
|
||||
flex: 1;
|
||||
align-self: center;
|
||||
max-width: 50px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const SliderWrapper = styled.div`
|
||||
display: flex;
|
||||
flex: 6;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
export const CenterControls = ({ playersRef }: CenterControlsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [isSeeking, setIsSeeking] = useState(false);
|
||||
const playerData = usePlayerStore((state) => state.getPlayerData());
|
||||
const player1 = playersRef?.current?.player1?.player;
|
||||
const player2 = playersRef?.current?.player2?.player;
|
||||
const { status, player } = usePlayerStore((state) => state.current);
|
||||
const settings = usePlayerStore((state) => state.settings);
|
||||
const setCurrentTime = usePlayerStore((state) => state.setCurrentTime);
|
||||
|
||||
const {
|
||||
handleNextTrack,
|
||||
handlePlayPause,
|
||||
handlePrevTrack,
|
||||
handleSeekSlider,
|
||||
handleSkipBackward,
|
||||
handleSkipForward,
|
||||
} = useCenterControls({ playersRef });
|
||||
|
||||
const currentTime = usePlayerStore((state) => state.current.time);
|
||||
|
||||
const currentPlayerRef = player === 1 ? player1 : player2;
|
||||
|
||||
const duration = useMemo(
|
||||
() => format((playerData.queue.current?.duration || 0) * 1000),
|
||||
[playerData.queue]
|
||||
);
|
||||
|
||||
const formattedTime = useMemo(
|
||||
() => format(currentTime * 1000 || 0),
|
||||
[currentTime]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let interval: any;
|
||||
|
||||
if (status === PlayerStatus.Playing && !isSeeking) {
|
||||
if (settings.type === PlaybackType.Web) {
|
||||
interval = setInterval(() => {
|
||||
setCurrentTime(currentPlayerRef.getCurrentTime());
|
||||
}, 1000);
|
||||
}
|
||||
} else {
|
||||
clearInterval(interval);
|
||||
}
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [currentPlayerRef, isSeeking, setCurrentTime, settings.type, status]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ControlsContainer onScroll={(e) => console.log(e)}>
|
||||
<ButtonsContainer>
|
||||
<PlayerButton
|
||||
icon={<PlayerSkipBack size={15} />}
|
||||
tooltip={{ label: `${t('player.prev')}` }}
|
||||
variant="secondary"
|
||||
onClick={handlePrevTrack}
|
||||
/>
|
||||
<PlayerButton
|
||||
icon={<PlayerTrackPrev size={15} />}
|
||||
tooltip={{ label: `${t('player.skipBack')}` }}
|
||||
variant="secondary"
|
||||
onClick={handleSkipBackward}
|
||||
/>
|
||||
<PlayerButton
|
||||
icon={
|
||||
status === PlayerStatus.Paused ? (
|
||||
<PlayerPlay size={20} />
|
||||
) : (
|
||||
<PlayerPause size={20} />
|
||||
)
|
||||
}
|
||||
tooltip={{
|
||||
label:
|
||||
status === PlayerStatus.Paused
|
||||
? `${t('player.play')}`
|
||||
: `${t('player.pause')}`,
|
||||
}}
|
||||
variant="main"
|
||||
onClick={handlePlayPause}
|
||||
/>
|
||||
<PlayerButton
|
||||
icon={<PlayerTrackNext size={15} />}
|
||||
tooltip={{ label: `${t('player.skipForward')}` }}
|
||||
variant="secondary"
|
||||
onClick={handleSkipForward}
|
||||
/>
|
||||
<PlayerButton
|
||||
icon={<PlayerSkipForward size={15} />}
|
||||
tooltip={{ label: `${t('player.next')}` }}
|
||||
variant="secondary"
|
||||
onClick={handleNextTrack}
|
||||
/>
|
||||
</ButtonsContainer>
|
||||
</ControlsContainer>
|
||||
<SliderContainer>
|
||||
<SliderValueWrapper position="left">
|
||||
<Text noSelect secondary font={Font.POPPINS} size="xs" weight={600}>
|
||||
{formattedTime}
|
||||
</Text>
|
||||
</SliderValueWrapper>
|
||||
<SliderWrapper>
|
||||
<Slider
|
||||
height="100%"
|
||||
max={playerData.queue.current?.duration}
|
||||
min={0}
|
||||
tooltipType="time"
|
||||
value={currentTime}
|
||||
onAfterChange={(e) => {
|
||||
handleSeekSlider(e);
|
||||
setIsSeeking(false);
|
||||
}}
|
||||
/>
|
||||
</SliderWrapper>
|
||||
<SliderValueWrapper position="right">
|
||||
<Text noSelect secondary font={Font.POPPINS} size="xs" weight={600}>
|
||||
{duration}
|
||||
</Text>
|
||||
</SliderValueWrapper>
|
||||
</SliderContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import { LazyLoadImage as Image } from 'react-lazy-load-image-component';
|
||||
import styled from 'styled-components';
|
||||
import { Text } from 'renderer/components';
|
||||
import { usePlayerStore } from 'renderer/store';
|
||||
import { Font } from 'renderer/styles';
|
||||
|
||||
const LeftControlsContainer = styled.div`
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const ImageWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem;
|
||||
`;
|
||||
|
||||
const MetadataStack = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
export const LeftControls = () => {
|
||||
const song = usePlayerStore((state) => state.current.song);
|
||||
const title = song?.title;
|
||||
const artists = song?.artist?.map((artist) => artist?.title).join(', ');
|
||||
const album = song?.album;
|
||||
|
||||
return (
|
||||
<LeftControlsContainer>
|
||||
<ImageWrapper>
|
||||
<Image height={60} src={song?.image} width={60} />
|
||||
</ImageWrapper>
|
||||
<MetadataStack>
|
||||
<Text
|
||||
font={Font.POPPINS}
|
||||
link={!!title}
|
||||
overflow="hidden"
|
||||
size="sm"
|
||||
to="/nowplaying"
|
||||
weight={500}
|
||||
>
|
||||
{title || '—'}
|
||||
</Text>
|
||||
<Text
|
||||
secondary
|
||||
font={Font.POPPINS}
|
||||
link={!!artists}
|
||||
overflow="hidden"
|
||||
size="sm"
|
||||
to="/nowplaying"
|
||||
weight={500}
|
||||
>
|
||||
{artists || '—'}
|
||||
</Text>
|
||||
<Text
|
||||
secondary
|
||||
font={Font.POPPINS}
|
||||
link={!!album}
|
||||
overflow="hidden"
|
||||
size="sm"
|
||||
to="/nowplaying"
|
||||
weight={500}
|
||||
>
|
||||
{album || '—'}
|
||||
</Text>
|
||||
</MetadataStack>
|
||||
</LeftControlsContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
import { ReactNode } from 'react';
|
||||
import {
|
||||
TooltipProps,
|
||||
UnstyledButton,
|
||||
UnstyledButtonProps,
|
||||
} from '@mantine/core';
|
||||
import { motion } from 'framer-motion';
|
||||
import styled, { css } from 'styled-components';
|
||||
import { Tooltip } from 'renderer/components';
|
||||
|
||||
interface PlayerButtonProps extends UnstyledButtonProps<'button'> {
|
||||
icon: ReactNode;
|
||||
tooltip?: Omit<TooltipProps, 'children'>;
|
||||
variant: 'main' | 'secondary';
|
||||
}
|
||||
|
||||
const WrapperMainVariant = css`
|
||||
margin: 0 0.5rem;
|
||||
`;
|
||||
|
||||
type MotionWrapperProps = { variant: PlayerButtonProps['variant'] };
|
||||
|
||||
const MotionWrapper = styled(motion.div)<MotionWrapperProps>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
${({ variant }) => variant === 'main' && WrapperMainVariant};
|
||||
`;
|
||||
|
||||
const ButtonMainVariant = css`
|
||||
padding: 0.5rem;
|
||||
background: var(--playerbar-btn-color);
|
||||
border-radius: 50%;
|
||||
|
||||
svg {
|
||||
display: flex;
|
||||
fill: black;
|
||||
stroke: black;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--playerbar-btn-color-hover);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
background: var(--playerbar-btn-color-hover);
|
||||
}
|
||||
`;
|
||||
|
||||
const ButtonSecondaryVariant = css`
|
||||
padding: 0.5rem;
|
||||
|
||||
svg {
|
||||
display: flex;
|
||||
fill: var(--playerbar-btn-color);
|
||||
stroke: var(--playerbar-btn-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
svg {
|
||||
fill: var(--playerbar-btn-color-hover);
|
||||
stroke: var(--playerbar-btn-color-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
svg {
|
||||
fill: var(--playerbar-btn-color-hover);
|
||||
stroke: var(--playerbar-btn-color-hover);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type StyledPlayerButtonProps = Omit<PlayerButtonProps, 'icon'>;
|
||||
|
||||
const StyledPlayerButton = styled(UnstyledButton)<StyledPlayerButtonProps>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
overflow: visible;
|
||||
all: unset;
|
||||
cursor: default;
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px var(--primary-color) solid;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
${({ variant }) =>
|
||||
variant === 'main' ? ButtonMainVariant : ButtonSecondaryVariant}
|
||||
`;
|
||||
|
||||
export const PlayerButton = ({
|
||||
tooltip,
|
||||
variant,
|
||||
icon,
|
||||
...rest
|
||||
}: PlayerButtonProps) => {
|
||||
if (tooltip) {
|
||||
return (
|
||||
<Tooltip {...tooltip}>
|
||||
<MotionWrapper variant={variant}>
|
||||
<StyledPlayerButton variant={variant} {...rest}>
|
||||
{icon}
|
||||
</StyledPlayerButton>
|
||||
</MotionWrapper>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MotionWrapper variant={variant}>
|
||||
<StyledPlayerButton variant={variant} {...rest}>
|
||||
{icon}
|
||||
</StyledPlayerButton>
|
||||
</MotionWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
PlayerButton.defaultProps = {
|
||||
tooltip: undefined,
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useRef } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { AudioPlayer } from 'renderer/components';
|
||||
import { usePlayerStore } from 'renderer/store';
|
||||
import { PlaybackType } from 'types';
|
||||
import { CenterControls } from './CenterControls';
|
||||
import { LeftControls } from './LeftControls';
|
||||
import { RightControls } from './RightControls';
|
||||
|
||||
const PlayerbarContainer = styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-top: var(--playerbar-border-top);
|
||||
`;
|
||||
|
||||
const PlayerbarControlsGrid = styled.div`
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const RightGridItem = styled.div`
|
||||
align-self: center;
|
||||
width: calc(100% / 3);
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const LeftGridItem = styled.div`
|
||||
width: calc(100% / 3);
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const CenterGridItem = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
width: calc(100% / 3);
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
export const Playerbar = () => {
|
||||
const playersRef = useRef<any>();
|
||||
const settings = usePlayerStore((state) => state.settings);
|
||||
const volume = usePlayerStore((state) => state.settings.volume);
|
||||
const player1 = usePlayerStore((state) => state.player1());
|
||||
const player2 = usePlayerStore((state) => state.player2());
|
||||
const status = usePlayerStore((state) => state.current.status);
|
||||
const player = usePlayerStore((state) => state.current.player);
|
||||
const autoNext = usePlayerStore((state) => state.autoNext);
|
||||
|
||||
return (
|
||||
<PlayerbarContainer>
|
||||
<PlayerbarControlsGrid>
|
||||
<LeftGridItem>
|
||||
<LeftControls />
|
||||
</LeftGridItem>
|
||||
<CenterGridItem>
|
||||
<CenterControls playersRef={playersRef} />
|
||||
</CenterGridItem>
|
||||
<RightGridItem>
|
||||
<RightControls />
|
||||
</RightGridItem>
|
||||
</PlayerbarControlsGrid>
|
||||
{settings.type === PlaybackType.Web && (
|
||||
<AudioPlayer
|
||||
ref={playersRef}
|
||||
autoNext={autoNext}
|
||||
crossfadeDuration={settings.crossfadeDuration}
|
||||
crossfadeStyle={settings.crossfadeStyle}
|
||||
currentPlayer={player}
|
||||
muted={settings.muted}
|
||||
player1={player1}
|
||||
player2={player2}
|
||||
status={status}
|
||||
style={settings.style}
|
||||
volume={volume}
|
||||
/>
|
||||
)}
|
||||
</PlayerbarContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import styled from 'styled-components';
|
||||
import { Volume2, Volume3 } from 'tabler-icons-react';
|
||||
import { IconButton } from 'renderer/components';
|
||||
import { usePlayerStore } from 'renderer/store';
|
||||
import { useRightControls } from '../hooks/useRightControls';
|
||||
import { Slider } from './Slider';
|
||||
|
||||
const RightControlsContainer = styled.div`
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0.5rem;
|
||||
`;
|
||||
|
||||
const VolumeSliderWrapper = styled.div`
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
align-items: center;
|
||||
width: 90px;
|
||||
`;
|
||||
|
||||
const MetadataStack = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
overflow: visible;
|
||||
`;
|
||||
|
||||
export const RightControls = () => {
|
||||
const volume = usePlayerStore((state) => state.settings.volume);
|
||||
const muted = usePlayerStore((state) => state.settings.muted);
|
||||
const { handleVolumeSlider, handleVolumeSliderState, handleMute } =
|
||||
useRightControls();
|
||||
|
||||
return (
|
||||
<RightControlsContainer>
|
||||
<MetadataStack>
|
||||
<VolumeSliderWrapper>
|
||||
<IconButton
|
||||
icon={
|
||||
muted ? (
|
||||
<Volume3 size={20} strokeWidth={1.5} />
|
||||
) : (
|
||||
<Volume2 size={20} strokeWidth={1.5} />
|
||||
)
|
||||
}
|
||||
size={20}
|
||||
tooltip={{ label: muted ? 'Muted' : volume }}
|
||||
variant="transparent"
|
||||
onClick={handleMute}
|
||||
/>
|
||||
|
||||
<Slider
|
||||
hasTooltip
|
||||
height="100%"
|
||||
max={100}
|
||||
min={0}
|
||||
value={volume}
|
||||
onAfterChange={handleVolumeSliderState}
|
||||
onChange={handleVolumeSlider}
|
||||
/>
|
||||
</VolumeSliderWrapper>
|
||||
</MetadataStack>
|
||||
</RightControlsContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import format from 'format-duration';
|
||||
import ReactSlider, { ReactSliderProps } from 'react-slider';
|
||||
import styled from 'styled-components';
|
||||
|
||||
interface SliderProps extends ReactSliderProps {
|
||||
hasTooltip?: boolean;
|
||||
height: string;
|
||||
tooltipType?: 'text' | 'time';
|
||||
}
|
||||
|
||||
const StyledSlider = styled(ReactSlider)<SliderProps | any>`
|
||||
width: 100%;
|
||||
height: ${(props) => props.height};
|
||||
outline: none;
|
||||
|
||||
.thumb {
|
||||
top: 37%;
|
||||
opacity: 1;
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: -25px;
|
||||
left: -18px;
|
||||
display: ${(props) =>
|
||||
props.$isDragging && props.$hasToolTip ? 'block' : 'none'};
|
||||
padding: 2px 6px;
|
||||
color: var(--tooltip-text-color);
|
||||
white-space: nowrap;
|
||||
background: var(--tooltip-bg);
|
||||
border-radius: 4px;
|
||||
content: attr(data-tooltip);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
text-align: center;
|
||||
background-color: #fff;
|
||||
border: 1px var(--primary-color) solid;
|
||||
border-radius: 100%;
|
||||
outline: none;
|
||||
transform: translate(-12px, -4px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.track-0 {
|
||||
background: ${(props) => props.$isDragging && 'var(--primary-color)'};
|
||||
transition: background 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.track {
|
||||
top: 37%;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.track-0 {
|
||||
background: var(--primary-color);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const MemoizedThumb = ({ props, state, toolTipType }: any) => {
|
||||
const { value } = state;
|
||||
const formattedValue = useMemo(() => {
|
||||
if (toolTipType === 'text') {
|
||||
return value;
|
||||
}
|
||||
|
||||
return format(value * 1000);
|
||||
}, [toolTipType, value]);
|
||||
|
||||
return <div {...props} data-tooltip={formattedValue} />;
|
||||
};
|
||||
|
||||
const StyledTrack = styled.div<any>`
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
height: 5px;
|
||||
background: ${(props) =>
|
||||
props.index === 1
|
||||
? 'var(--playerbar-slider-track-bg)'
|
||||
: 'var(--playerbar-slider-track-progress-bg)'};
|
||||
`;
|
||||
|
||||
const Track = (props: any, state: any) => (
|
||||
// eslint-disable-next-line react/destructuring-assignment
|
||||
<StyledTrack {...props} index={state.index} />
|
||||
);
|
||||
const Thumb = (props: any, state: any, toolTipType: any) => (
|
||||
<MemoizedThumb
|
||||
key="slider"
|
||||
props={props}
|
||||
state={state}
|
||||
tabIndex={0}
|
||||
toolTipType={toolTipType}
|
||||
/>
|
||||
);
|
||||
|
||||
export const Slider = ({
|
||||
height,
|
||||
tooltipType: toolTipType,
|
||||
hasTooltip: hasToolTip,
|
||||
...rest
|
||||
}: SliderProps) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
return (
|
||||
<StyledSlider
|
||||
{...rest}
|
||||
$hasToolTip={hasToolTip}
|
||||
$isDragging={isDragging}
|
||||
className="player-slider"
|
||||
defaultValue={0}
|
||||
height={height}
|
||||
renderThumb={(props: any, state: any) => {
|
||||
return Thumb(props, state, toolTipType);
|
||||
}}
|
||||
renderTrack={Track}
|
||||
onAfterChange={(e: number, index: number) => {
|
||||
if (rest.onAfterChange) {
|
||||
rest.onAfterChange(e, index);
|
||||
}
|
||||
setIsDragging(false);
|
||||
}}
|
||||
onBeforeChange={(e: number, index: number) => {
|
||||
if (rest.onBeforeChange) {
|
||||
rest.onBeforeChange(e, index);
|
||||
}
|
||||
setIsDragging(true);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Slider.defaultProps = {
|
||||
hasTooltip: true,
|
||||
tooltipType: 'text',
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useCallback } from 'react';
|
||||
import { usePlayerStore } from 'renderer/store';
|
||||
import { PlaybackType, PlayerStatus } from '../../../../types';
|
||||
import { mpvPlayer } from '../utils/mpvPlayer';
|
||||
|
||||
export const useCenterControls = (args: { playersRef: any }) => {
|
||||
const { playersRef } = args;
|
||||
|
||||
const settings = usePlayerStore((state) => state.settings);
|
||||
const play = usePlayerStore((state) => state.play);
|
||||
const pause = usePlayerStore((state) => state.pause);
|
||||
const prev = usePlayerStore((state) => state.prev);
|
||||
const next = usePlayerStore((state) => state.next);
|
||||
const queue = usePlayerStore((state) => state.queue.default);
|
||||
const playerStatus = usePlayerStore((state) => state.current.status);
|
||||
const currentPlayer = usePlayerStore((state) => state.current.player);
|
||||
const currentTime = usePlayerStore((state) => state.current.time);
|
||||
const setCurrentTime = usePlayerStore((state) => state.setCurrentTime);
|
||||
|
||||
const player1Ref = playersRef?.current?.player1;
|
||||
const player2Ref = playersRef?.current?.player2;
|
||||
const currentPlayerRef = currentPlayer === 1 ? player1Ref : player2Ref;
|
||||
const nextPlayerRef = currentPlayer === 1 ? player2Ref : player1Ref;
|
||||
|
||||
const resetPlayers = useCallback(() => {
|
||||
player1Ref.getInternalPlayer().currentTime = 0;
|
||||
player2Ref.getInternalPlayer().currentTime = 0;
|
||||
player1Ref.getInternalPlayer().pause();
|
||||
player2Ref.getInternalPlayer().pause();
|
||||
}, [player1Ref, player2Ref]);
|
||||
|
||||
const resetNextPlayer = useCallback(() => {
|
||||
currentPlayerRef.getInternalPlayer().volume = 0.1;
|
||||
nextPlayerRef.getInternalPlayer().currentTime = 0;
|
||||
nextPlayerRef.getInternalPlayer().pause();
|
||||
}, [currentPlayerRef, nextPlayerRef]);
|
||||
|
||||
const stopPlayback = () => {
|
||||
player1Ref.getInternalPlayer().pause();
|
||||
player2Ref.getInternalPlayer().pause();
|
||||
resetPlayers();
|
||||
};
|
||||
|
||||
const handlePlay = useCallback(() => {
|
||||
if (settings.type === PlaybackType.Local) {
|
||||
mpvPlayer.play();
|
||||
} else {
|
||||
currentPlayerRef.getInternalPlayer().play();
|
||||
}
|
||||
|
||||
play();
|
||||
}, [currentPlayerRef, play, settings]);
|
||||
|
||||
const handlePause = useCallback(() => {
|
||||
if (settings.type === PlaybackType.Local) {
|
||||
mpvPlayer.pause();
|
||||
}
|
||||
|
||||
pause();
|
||||
}, [pause, settings]);
|
||||
|
||||
const handleStop = () => {
|
||||
if (settings.type === PlaybackType.Local) {
|
||||
mpvPlayer.stop();
|
||||
} else {
|
||||
stopPlayback();
|
||||
}
|
||||
|
||||
setCurrentTime(0);
|
||||
pause();
|
||||
};
|
||||
|
||||
const handleNextTrack = useCallback(() => {
|
||||
const playerData = next();
|
||||
|
||||
if (settings.type === PlaybackType.Local) {
|
||||
mpvPlayer.setQueue(playerData);
|
||||
mpvPlayer.next();
|
||||
} else {
|
||||
resetPlayers();
|
||||
}
|
||||
|
||||
setCurrentTime(0);
|
||||
}, [next, resetPlayers, setCurrentTime, settings]);
|
||||
|
||||
const handlePrevTrack = useCallback(() => {
|
||||
const playerData = prev();
|
||||
|
||||
if (settings.type === PlaybackType.Local) {
|
||||
mpvPlayer.setQueue(playerData);
|
||||
mpvPlayer.previous();
|
||||
} else {
|
||||
resetPlayers();
|
||||
}
|
||||
|
||||
setCurrentTime(0);
|
||||
}, [prev, resetPlayers, setCurrentTime, settings]);
|
||||
|
||||
const handlePlayPause = useCallback(() => {
|
||||
if (queue) {
|
||||
if (playerStatus === PlayerStatus.Paused) {
|
||||
return handlePlay();
|
||||
}
|
||||
|
||||
return handlePause();
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [handlePause, handlePlay, playerStatus, queue]);
|
||||
|
||||
const handleSkipBackward = () => {
|
||||
const skipBackwardSec = 5;
|
||||
|
||||
if (settings.type === PlaybackType.Local) {
|
||||
const newTime = currentTime - skipBackwardSec;
|
||||
mpvPlayer.seek(-skipBackwardSec);
|
||||
setCurrentTime(newTime < 0 ? 0 : newTime);
|
||||
} else {
|
||||
const newTime = currentPlayerRef.getCurrentTime() - skipBackwardSec;
|
||||
resetNextPlayer();
|
||||
setCurrentTime(newTime);
|
||||
currentPlayerRef.seekTo(newTime);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkipForward = () => {
|
||||
const skipForwardSec = 5;
|
||||
|
||||
if (settings.type === PlaybackType.Local) {
|
||||
const newTime = currentTime + skipForwardSec;
|
||||
mpvPlayer.seek(skipForwardSec);
|
||||
setCurrentTime(newTime);
|
||||
} else {
|
||||
const checkNewTime = currentPlayerRef.getCurrentTime() + skipForwardSec;
|
||||
const songDuration = currentPlayerRef.player.player.duration;
|
||||
|
||||
const newTime =
|
||||
checkNewTime >= songDuration ? songDuration - 1 : checkNewTime;
|
||||
|
||||
resetNextPlayer();
|
||||
setCurrentTime(newTime);
|
||||
currentPlayerRef.seekTo(newTime);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSeekSlider = useCallback(
|
||||
(e: number | any) => {
|
||||
setCurrentTime(e);
|
||||
|
||||
if (settings.type === PlaybackType.Local) {
|
||||
mpvPlayer.seekTo(e);
|
||||
} else {
|
||||
currentPlayerRef.seekTo(e);
|
||||
}
|
||||
},
|
||||
[currentPlayerRef, setCurrentTime, settings]
|
||||
);
|
||||
|
||||
// const handleVolumeSlider = useCallback(
|
||||
// (e: number | any) => {
|
||||
// // dispatch(setVolume(e));
|
||||
// if (settings.type === PlaybackType.Local) {
|
||||
// // playerApi.volume(currentTime, e);
|
||||
// }
|
||||
|
||||
// setSettings({ volume: (e / 100) ** 2 });
|
||||
// },
|
||||
// [currentTime, setSettings, settings]
|
||||
// );
|
||||
|
||||
return {
|
||||
handleNextTrack,
|
||||
handlePlayPause,
|
||||
handlePrevTrack,
|
||||
handleSeekSlider,
|
||||
handleSkipBackward,
|
||||
handleSkipForward,
|
||||
handleStop,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { usePlayerStore } from 'renderer/store';
|
||||
import {
|
||||
getJellyfinStreamUrl,
|
||||
getServerFolderAuth,
|
||||
getSubsonicStreamUrl,
|
||||
} from 'renderer/utils';
|
||||
import { Item, Play } from 'types';
|
||||
import { mpvPlayer } from '../utils/mpvPlayer';
|
||||
|
||||
const getEndpoint = (item: Item) => {
|
||||
switch (item) {
|
||||
case Item.Album:
|
||||
return 'getAlbum';
|
||||
case Item.Artist:
|
||||
return 'getArtistSongs';
|
||||
case Item.Playlist:
|
||||
return 'getPlaylist';
|
||||
default:
|
||||
return 'getAlbum';
|
||||
}
|
||||
};
|
||||
|
||||
export const usePlayQueueHandler = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const addQueue = usePlayerStore((state) => state.add);
|
||||
|
||||
// const dispatchSongsToQueue = useCallback(
|
||||
// (entries: Song[], play: Play) => {
|
||||
// const filteredSongs = filterPlayQueue(config.playback.filters, entries);
|
||||
|
||||
// if (play === Play.Play) {
|
||||
// if (filteredSongs.entries.length > 0) {
|
||||
// } else {
|
||||
// }
|
||||
// }
|
||||
|
||||
// if (play === Play.Next || play === Play.Later) {
|
||||
// if (filteredSongs.entries.length > 0) {
|
||||
// }
|
||||
// }
|
||||
|
||||
// notifyToast(
|
||||
// 'info',
|
||||
// getPlayedSongsNotification({
|
||||
// ...filteredSongs.count,
|
||||
// type: play === Play.Play ? 'play' : 'add',
|
||||
// })
|
||||
// );
|
||||
// },
|
||||
// [config.playback.filters, dispatch]
|
||||
// );
|
||||
|
||||
const handlePlayQueueAdd = async (options: {
|
||||
byData?: any[];
|
||||
byItemType?: {
|
||||
endpoint: (params: Record<string, any>) => any;
|
||||
id: string;
|
||||
type: Item;
|
||||
};
|
||||
play: Play;
|
||||
}) => {
|
||||
if (options.byData) {
|
||||
// dispatchSongsToQueue(options.byData, options.play);
|
||||
}
|
||||
|
||||
if (options.byItemType) {
|
||||
const deviceId = localStorage.getItem('device_id');
|
||||
const { serverUrl } = JSON.parse(
|
||||
localStorage.getItem('authentication') || '{}'
|
||||
);
|
||||
|
||||
if (deviceId) {
|
||||
const data = await options.byItemType.endpoint({
|
||||
id: options.byItemType.id,
|
||||
});
|
||||
|
||||
const songs = data.data.songs.map((song) => {
|
||||
const auth = getServerFolderAuth(serverUrl, song.serverFolderId);
|
||||
|
||||
if (auth) {
|
||||
const streamUrl =
|
||||
auth.type === 'jellyfin'
|
||||
? getJellyfinStreamUrl(auth, song, deviceId)
|
||||
: getSubsonicStreamUrl(auth, song, deviceId);
|
||||
|
||||
return {
|
||||
albumId: song.albumId,
|
||||
artistName: song.artistName,
|
||||
duration: song.duration,
|
||||
id: song.id,
|
||||
streamUrl,
|
||||
title: song.name,
|
||||
year: song.year,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
albumId: song.albumId,
|
||||
artistName: song.artistName,
|
||||
duration: song.duration,
|
||||
id: song.id,
|
||||
streamUrl: song.streamUrl,
|
||||
title: song.name,
|
||||
year: song.year,
|
||||
};
|
||||
});
|
||||
|
||||
const pData = addQueue(songs);
|
||||
mpvPlayer.setQueue(pData);
|
||||
}
|
||||
|
||||
// const data = await apiController({
|
||||
// args: { id: options.byItemType.id, musicFolder: options.musicFolder },
|
||||
// endpoint:
|
||||
// options.byItemType.endpoint || getEndpoint(options.byItemType.item),
|
||||
// serverType: config.serverType,
|
||||
// });
|
||||
// if (options.byItemType.item === Item.Album) {
|
||||
// queryClient.setQueryData(['album', options.byItemType.id], data);
|
||||
// } else if (options.byItemType.item === Item.Artist) {
|
||||
// queryClient.setQueryData(['artistSongs', options.byItemType.id], data);
|
||||
// } else if (options.byItemType.item === Item.Playlist) {
|
||||
// queryClient.setQueryData(['playlist', options.byItemType.id], data);
|
||||
// }
|
||||
// if (data?.song) {
|
||||
// dispatchSongsToQueue(data.song, options.play);
|
||||
// } else {
|
||||
// dispatchSongsToQueue(data, options.play);
|
||||
// }
|
||||
}
|
||||
};
|
||||
|
||||
return { handlePlayQueueAdd };
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useEffect } from 'react';
|
||||
import { usePlayerStore } from 'renderer/store';
|
||||
import { mpvPlayer } from '../utils/mpvPlayer';
|
||||
|
||||
export const useRightControls = () => {
|
||||
const setSettings = usePlayerStore((state) => state.setSettings);
|
||||
const volume = usePlayerStore((state) => state.settings.volume);
|
||||
const muted = usePlayerStore((state) => state.settings.muted);
|
||||
|
||||
// Ensure that the mpv player volume is set on startup
|
||||
useEffect(() => {
|
||||
mpvPlayer.volume(volume);
|
||||
}, [volume]);
|
||||
|
||||
// Ensure that the mpv player mute status is set on startup
|
||||
useEffect(() => {
|
||||
if (muted) {
|
||||
mpvPlayer.mute();
|
||||
}
|
||||
}, [muted]);
|
||||
|
||||
const handleVolumeSlider = (e: number) => {
|
||||
mpvPlayer.volume(e);
|
||||
};
|
||||
|
||||
const handleVolumeSliderState = (e: number) => {
|
||||
setSettings({ volume: e });
|
||||
};
|
||||
|
||||
const handleMute = () => {
|
||||
setSettings({ muted: !muted });
|
||||
mpvPlayer.mute();
|
||||
};
|
||||
|
||||
return {
|
||||
handleMute,
|
||||
handleVolumeSlider,
|
||||
handleVolumeSliderState,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './components/CenterControls';
|
||||
export * from './components/LeftControls';
|
||||
export * from './components/Playerbar';
|
||||
export * from './components/Slider';
|
||||
@@ -0,0 +1,65 @@
|
||||
import isElectron from 'is-electron';
|
||||
import { PlayerData, usePlayerStore } from 'renderer/store';
|
||||
|
||||
const ipc = isElectron() ? window.electron.ipcRenderer : null;
|
||||
|
||||
const play = () => ipc?.PLAYER_PLAY();
|
||||
|
||||
const pause = () => ipc?.PLAYER_PAUSE();
|
||||
|
||||
const stop = () => ipc?.PLAYER_STOP();
|
||||
|
||||
const currentTime = () => ipc?.PLAYER_CURRENT_TIME();
|
||||
|
||||
const next = () => ipc?.PLAYER_NEXT();
|
||||
|
||||
const previous = () => ipc?.PLAYER_PREVIOUS();
|
||||
|
||||
const setQueue = (data: PlayerData) => ipc?.PLAYER_SET_QUEUE(data);
|
||||
|
||||
const setQueueNext = (data: PlayerData) => ipc?.PLAYER_SET_QUEUE_NEXT(data);
|
||||
|
||||
const seek = (seconds: number) => ipc?.PLAYER_SEEK(seconds);
|
||||
|
||||
const seekTo = (seconds: number) => ipc?.PLAYER_SEEK_TO(seconds);
|
||||
|
||||
const volume = (value: number) => ipc?.PLAYER_VOLUME(value);
|
||||
|
||||
const mute = () => ipc?.PLAYER_MUTE();
|
||||
|
||||
const {
|
||||
setCurrentTime,
|
||||
play: setPlay,
|
||||
pause: setPause,
|
||||
autoNext,
|
||||
} = usePlayerStore.getState();
|
||||
|
||||
ipc?.RENDERER_PLAYER_PLAY(() => setPlay());
|
||||
|
||||
ipc?.RENDERER_PLAYER_PAUSE(() => setPause());
|
||||
|
||||
ipc?.RENDERER_PLAYER_STOP(() => setPause());
|
||||
|
||||
ipc?.RENDERER_PLAYER_CURRENT_TIME((_event, time) => setCurrentTime(time));
|
||||
|
||||
ipc?.RENDERER_PLAYER_SET_QUEUE_NEXT(() => {
|
||||
const playerData = autoNext();
|
||||
if (playerData.queue.next) {
|
||||
setQueueNext(playerData);
|
||||
}
|
||||
});
|
||||
|
||||
export const mpvPlayer = {
|
||||
currentTime,
|
||||
mute,
|
||||
next,
|
||||
pause,
|
||||
play,
|
||||
previous,
|
||||
seek,
|
||||
seekTo,
|
||||
setQueue,
|
||||
setQueueNext,
|
||||
stop,
|
||||
volume,
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Modal,
|
||||
ModalProps,
|
||||
PasswordInput,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCreateServer, validateServer } from '../queries/useCreateServer';
|
||||
|
||||
export const AddServerModal = ({ ...rest }: ModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
legacyAuth: false,
|
||||
name: '',
|
||||
password: '',
|
||||
serverType: 'jellyfin',
|
||||
url: 'http://',
|
||||
username: '',
|
||||
},
|
||||
});
|
||||
|
||||
const createServerMutation = useCreateServer();
|
||||
|
||||
return (
|
||||
<Modal centered title={t('server.add.title')} {...rest}>
|
||||
<form
|
||||
onSubmit={form.onSubmit(async (values) => {
|
||||
const res = await validateServer(values);
|
||||
|
||||
if (res?.token) {
|
||||
createServerMutation.mutateAsync({
|
||||
...values,
|
||||
remoteUserId: res.userId,
|
||||
token: res.token,
|
||||
});
|
||||
}
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<SegmentedControl
|
||||
data={[
|
||||
{ label: 'Jellyfin', value: 'jellyfin' },
|
||||
{ label: 'Subsonic', value: 'subsonic' },
|
||||
]}
|
||||
{...form.getInputProps('serverType')}
|
||||
/>
|
||||
<TextInput
|
||||
required
|
||||
label={t('server.name')}
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
<TextInput
|
||||
required
|
||||
label={t('server.url')}
|
||||
{...form.getInputProps('url')}
|
||||
/>
|
||||
<TextInput
|
||||
required
|
||||
label={t('server.username')}
|
||||
{...form.getInputProps('username')}
|
||||
/>
|
||||
<PasswordInput
|
||||
required
|
||||
label={t('server.password')}
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
{form.getInputProps('serverType').value === 'subsonic' && (
|
||||
<Checkbox
|
||||
label={t('server.legacyauth')}
|
||||
{...form.getInputProps('legacyAuth', { type: 'checkbox' })}
|
||||
/>
|
||||
)}
|
||||
<Button type="submit">{t('server.submit')}</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
ModalProps,
|
||||
PasswordInput,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ServerResponse } from 'renderer/api/types';
|
||||
|
||||
interface EditServerModalProps extends ModalProps {
|
||||
server: ServerResponse | undefined;
|
||||
}
|
||||
|
||||
export const EditServerModal = ({ server }: EditServerModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
legacyAuth: false,
|
||||
name: server?.name,
|
||||
password: '',
|
||||
serverType: server?.serverType,
|
||||
url: server?.url,
|
||||
username: server?.username,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(async () => {})}>
|
||||
<Stack>
|
||||
<SegmentedControl
|
||||
disabled
|
||||
data={[
|
||||
{ label: 'Jellyfin', value: 'jellyfin' },
|
||||
{ label: 'Subsonic', value: 'subsonic' },
|
||||
]}
|
||||
{...form.getInputProps('serverType')}
|
||||
/>
|
||||
<TextInput label={t('server.name')} {...form.getInputProps('name')} />
|
||||
<TextInput label={t('server.url')} {...form.getInputProps('url')} />
|
||||
<TextInput
|
||||
label={t('server.username')}
|
||||
{...form.getInputProps('username')}
|
||||
/>
|
||||
<PasswordInput
|
||||
label={t('server.password')}
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
{form.getInputProps('serverType').value === 'subsonic' && (
|
||||
<Checkbox
|
||||
label={t('server.legacyauth')}
|
||||
{...form.getInputProps('legacyAuth', { type: 'checkbox' })}
|
||||
/>
|
||||
)}
|
||||
<Button type="submit">{t('server.submit')}</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
.item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
max-width: 50vw;
|
||||
margin: 1rem;
|
||||
padding: 1rem;
|
||||
outline: 1px #fff solid;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Text } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { EditCircle } from 'tabler-icons-react';
|
||||
import { ServerResponse } from 'renderer/api/types';
|
||||
import { IconButton } from 'renderer/components';
|
||||
import { useServers } from '../queries/useServers';
|
||||
import { EditServerModal } from './EditServerModal';
|
||||
import styles from './ServerList.module.scss';
|
||||
|
||||
export const ServerList = () => {
|
||||
const { data: servers } = useServers();
|
||||
const [editServerModal, editServerHandlers] = useDisclosure(false);
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
const [selectedServer, setSelectedServer] = useState<
|
||||
ServerResponse | undefined
|
||||
>();
|
||||
|
||||
const handleClickServer = (server: ServerResponse) => {
|
||||
setSelectedServer(server);
|
||||
editServerHandlers.open();
|
||||
};
|
||||
|
||||
const handleKeyDownServer = (
|
||||
e: React.KeyboardEvent<HTMLDivElement>,
|
||||
server: ServerResponse
|
||||
) => {
|
||||
if (e.key === ' ' || e.key === 'Enter') {
|
||||
setSelectedServer(server);
|
||||
editServerHandlers.open();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setOpened(false);
|
||||
editServerHandlers.close();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (editServerModal === true) {
|
||||
setTimeout(() => setOpened(true));
|
||||
} else {
|
||||
setTimeout(() => setSelectedServer(undefined), 100);
|
||||
}
|
||||
}, [editServerModal]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{servers &&
|
||||
servers.data.map((server) => (
|
||||
<>
|
||||
<div
|
||||
className={styles.item}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleClickServer(server)}
|
||||
onKeyDown={(e) => handleKeyDownServer(e, server)}
|
||||
>
|
||||
<div>
|
||||
{server.name}
|
||||
<Text>Hello</Text>
|
||||
</div>
|
||||
<IconButton
|
||||
icon={<EditCircle />}
|
||||
onClick={() => editServerHandlers.toggle()}
|
||||
>
|
||||
Edit
|
||||
</IconButton>
|
||||
</div>
|
||||
{selectedServer && (
|
||||
<EditServerModal
|
||||
opened={opened}
|
||||
server={selectedServer}
|
||||
onClose={handleCloseModal}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './routes/ServersRoute';
|
||||
export * from './queries/useCreateServer';
|
||||
export * from './queries/useServers';
|
||||
export * from './components/AddServerModal';
|
||||
export * from './components/ServerList';
|
||||
@@ -0,0 +1,70 @@
|
||||
import axios from 'axios';
|
||||
import md5 from 'md5';
|
||||
import { useMutation } from 'react-query';
|
||||
import { serversApi } from 'renderer/api/serversApi';
|
||||
import { randomString } from 'renderer/utils';
|
||||
|
||||
export const validateServer = async (options: {
|
||||
legacyAuth: boolean;
|
||||
password: string;
|
||||
serverType: string;
|
||||
url: string;
|
||||
username: string;
|
||||
}) => {
|
||||
const { serverType, url, username, password, legacyAuth } = options;
|
||||
const cleanServerUrl = url.replace(/\/$/, '');
|
||||
|
||||
try {
|
||||
if (serverType === 'subsonic') {
|
||||
let testConnection;
|
||||
let token;
|
||||
if (legacyAuth) {
|
||||
token = `u=${username}&p=${password}`;
|
||||
testConnection = await axios.get(
|
||||
`${cleanServerUrl}/rest/ping.view?v=1.13.0&c=sonixd&f=json&${token}`
|
||||
);
|
||||
} else {
|
||||
const salt = randomString();
|
||||
const hash = md5(password + salt);
|
||||
token = `u=${username}&s=${salt}&t=${hash}`;
|
||||
|
||||
testConnection = await axios.get(
|
||||
`${cleanServerUrl}/rest/ping.view?v=1.13.0&c=sonixd&f=json&${token}`
|
||||
);
|
||||
}
|
||||
|
||||
if (testConnection.data['subsonic-response'].status === 'failed') {
|
||||
return {
|
||||
error: `${testConnection.data['subsonic-response'].error.message}`,
|
||||
};
|
||||
}
|
||||
return { token, userId: '' };
|
||||
}
|
||||
|
||||
const { data } = await axios.post(
|
||||
`${cleanServerUrl}/users/authenticatebyname`,
|
||||
{ pw: password, username },
|
||||
{
|
||||
headers: {
|
||||
'X-Emby-Authorization': `MediaBrowser Client="Sonixd", Device="PC", DeviceId="Sonixd", Version="1.0.0-alpha1"`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return { token: data.AccessToken, userId: data.User.Id };
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
return { error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const useCreateServer = () => {
|
||||
return useMutation({
|
||||
mutationFn: serversApi.createServer,
|
||||
onError: (e) => console.log(e),
|
||||
onSuccess: (e) => console.log(e),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import md5 from 'md5';
|
||||
import { useQuery } from 'react-query';
|
||||
import { queryKeys } from 'renderer/api/queryKeys';
|
||||
import { serversApi } from 'renderer/api/serversApi';
|
||||
import { ServerFolderResponse } from 'renderer/api/types';
|
||||
import { ServerFolderAuth } from 'types';
|
||||
|
||||
export const useServers = () => {
|
||||
return useQuery({
|
||||
onSuccess: (servers) => {
|
||||
const { serverUrl } = JSON.parse(
|
||||
localStorage.getItem('authentication') || '{}'
|
||||
);
|
||||
const storedServersKey = `servers_${md5(serverUrl)}`;
|
||||
const serversFromLocalStorage = localStorage.getItem(storedServersKey);
|
||||
|
||||
// If a custom account/token is set for a server, use that instead of the default one
|
||||
if (serversFromLocalStorage) {
|
||||
const existingServers = JSON.parse(serversFromLocalStorage);
|
||||
|
||||
// The 'locked' property determines whether or not to skip updating the server auth
|
||||
const skipped = existingServers.filter(
|
||||
(server: ServerFolderAuth) => server.locked
|
||||
);
|
||||
|
||||
const store = servers?.data?.flatMap((server) =>
|
||||
server.serverFolder?.map((serverFolder: ServerFolderResponse) => {
|
||||
if (skipped.includes(serverFolder.id)) {
|
||||
return existingServers.find(
|
||||
(s: ServerFolderAuth) => s.id === serverFolder.id
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id: serverFolder.id,
|
||||
locked: false,
|
||||
serverId: server.id,
|
||||
token: server.token,
|
||||
type: server.serverType,
|
||||
url: server.url,
|
||||
userId: server.remoteUserId,
|
||||
username: server.username,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return localStorage.setItem(storedServersKey, JSON.stringify(store));
|
||||
}
|
||||
|
||||
const store = servers?.data?.flatMap((server) =>
|
||||
server.serverFolder?.map((serverFolder: ServerFolderResponse) => ({
|
||||
id: serverFolder.id,
|
||||
locked: false,
|
||||
serverId: server.id,
|
||||
token: server.token,
|
||||
type: server.serverType,
|
||||
url: server.url,
|
||||
userId: server.remoteUserId,
|
||||
username: server.username,
|
||||
}))
|
||||
);
|
||||
|
||||
return localStorage.setItem(storedServersKey, JSON.stringify(store));
|
||||
},
|
||||
queryFn: () => serversApi.getServers(),
|
||||
queryKey: queryKeys.servers,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Title } from '@mantine/core';
|
||||
import { ServerList } from '../components/ServerList';
|
||||
|
||||
export const ServersRoute = () => {
|
||||
return (
|
||||
<div>
|
||||
<Title>Servers</Title>
|
||||
<ServerList />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import isElectron from 'is-electron';
|
||||
import { PlayerState } from 'renderer/store';
|
||||
import {
|
||||
CrossfadeStyle,
|
||||
PlaybackStyle,
|
||||
PlaybackType,
|
||||
PlayerRepeat,
|
||||
} from 'types';
|
||||
|
||||
export interface WebSettings {
|
||||
player: PlayerSettings;
|
||||
}
|
||||
|
||||
export type PlayerSettings = PlayerState['settings'];
|
||||
|
||||
const DEFAULT_SETTINGS: WebSettings = {
|
||||
player: {
|
||||
crossfadeDuration: 5,
|
||||
crossfadeStyle: CrossfadeStyle.EqualPower,
|
||||
muted: false,
|
||||
repeat: PlayerRepeat.All,
|
||||
shuffle: false,
|
||||
style: PlaybackStyle.Gapless,
|
||||
type: isElectron() ? PlaybackType.Local : PlaybackType.Web,
|
||||
volume: 0.5,
|
||||
},
|
||||
};
|
||||
|
||||
export const useDefaultSettings = () => {
|
||||
const currentSettings = localStorage.getItem('settings');
|
||||
|
||||
if (currentSettings) {
|
||||
return JSON.parse(currentSettings);
|
||||
}
|
||||
|
||||
return localStorage.setItem('settings', JSON.stringify(DEFAULT_SETTINGS));
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './hooks/useDefaultSettings';
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import styled from 'styled-components';
|
||||
|
||||
interface AnimatedPageProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const StyledAnimatedPage = styled(motion.div)`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const variants = {
|
||||
animate: { opacity: 1 },
|
||||
exit: { opacity: 0 },
|
||||
initial: { opacity: 0 },
|
||||
};
|
||||
|
||||
export const AnimatedPage = ({ children }: AnimatedPageProps) => {
|
||||
return (
|
||||
<StyledAnimatedPage
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
initial="initial"
|
||||
transition={{ duration: 0.2, type: 'tween' }}
|
||||
variants={variants}
|
||||
>
|
||||
{children}
|
||||
</StyledAnimatedPage>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Link, LinkProps } from 'react-router-dom';
|
||||
import styled, { css } from 'styled-components';
|
||||
import { fontInter } from 'renderer/styles';
|
||||
|
||||
interface ListItemProps {
|
||||
children: ReactNode;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
const StyledListItem = styled(motion.div)`
|
||||
${fontInter(600)}
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const ItemStyle = css`
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.5rem 1rem;
|
||||
color: var(--sidebar-btn-color);
|
||||
transition: color 0.2s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
color: var(--sidebar-btn-color-hover);
|
||||
}
|
||||
`;
|
||||
|
||||
const Box = styled.div`
|
||||
${ItemStyle}
|
||||
`;
|
||||
|
||||
const ItemLink = styled(Link)<LinkProps>`
|
||||
${ItemStyle}
|
||||
`;
|
||||
|
||||
export const ListItem = ({ icon, children, ...rest }: ListItemProps) => {
|
||||
return <StyledListItem {...rest}>{children}</StyledListItem>;
|
||||
};
|
||||
|
||||
ListItem.Box = Box;
|
||||
|
||||
ListItem.Link = ItemLink;
|
||||
|
||||
ListItem.defaultProps = {
|
||||
icon: <></>,
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import styled from 'styled-components';
|
||||
import { Home, Notebook, Search } from 'tabler-icons-react';
|
||||
import { UserMenu } from 'renderer/features/user-menu';
|
||||
import { AppRoute } from 'renderer/router/utils/routes';
|
||||
import { ListItem } from './ListItem';
|
||||
|
||||
const SidebarContainer = styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const ItemGroup = styled.ul``;
|
||||
|
||||
const Item = styled.li``;
|
||||
|
||||
const StyledSidebar = styled.div``;
|
||||
|
||||
export const Sidebar = () => {
|
||||
|
||||
return (
|
||||
<StyledSidebar>
|
||||
<ListItem icon={<Home />}>
|
||||
<ListItem.Link to={AppRoute.HOME}>
|
||||
<Home />
|
||||
Home
|
||||
</ListItem.Link>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItem.Link to={AppRoute.SEARCH}>
|
||||
<Search />
|
||||
Search
|
||||
</ListItem.Link>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItem.Link to={AppRoute.LIBRARY}>
|
||||
<Notebook />
|
||||
Your Library
|
||||
</ListItem.Link>
|
||||
</ListItem>
|
||||
<UserMenu />
|
||||
</StyledSidebar>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './components/Sidebar';
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Group } from '@mantine/core';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { ChevronLeft, ChevronRight } from 'tabler-icons-react';
|
||||
import { IconButton } from 'renderer/components';
|
||||
import { WindowControls } from 'renderer/features/window-controls';
|
||||
|
||||
interface TitlebarProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const TitlebarContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
-webkit-app-region: drag;
|
||||
`;
|
||||
|
||||
const Left = styled.div`
|
||||
height: 100%;
|
||||
|
||||
button {
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
svg {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Right = styled.div`
|
||||
height: 100%;
|
||||
-webkit-app-region: no-drag;
|
||||
`;
|
||||
|
||||
export const Titlebar = ({ children }: TitlebarProps) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<>
|
||||
<TitlebarContainer>
|
||||
<Left>
|
||||
<Group spacing="xs">
|
||||
<IconButton
|
||||
icon={<ChevronLeft size={25} strokeWidth={1.5} />}
|
||||
size={25}
|
||||
onClick={() => navigate(-1)}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<ChevronRight size={25} strokeWidth={1.5} />}
|
||||
size={25}
|
||||
onClick={() => navigate(1)}
|
||||
/>
|
||||
</Group>
|
||||
</Left>
|
||||
<Right>
|
||||
{children}
|
||||
|
||||
<WindowControls />
|
||||
</Right>
|
||||
</TitlebarContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Titlebar.defaultProps = {
|
||||
children: <></>,
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Button, Menu } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Logout, Server, Settings } from 'tabler-icons-react';
|
||||
import { AddServerModal } from 'renderer/features/servers';
|
||||
import { useAuthStore } from 'renderer/store';
|
||||
|
||||
export const UserMenu = () => {
|
||||
const navigate = useNavigate();
|
||||
const [addServerModal, addServerHandlers] = useDisclosure(false);
|
||||
const logout = useAuthStore((state) => state.logout);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
localStorage.removeItem('authentication');
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu
|
||||
control={
|
||||
<Button radius="lg" size="xs" variant="default">
|
||||
User
|
||||
</Button>
|
||||
}
|
||||
position="bottom"
|
||||
size="md"
|
||||
>
|
||||
<Menu.Item
|
||||
icon={<Server size={20} />}
|
||||
onClick={() => addServerHandlers.open()}
|
||||
>
|
||||
Servers
|
||||
</Menu.Item>
|
||||
<Menu.Item icon={<Settings size={20} />}>Settings</Menu.Item>
|
||||
<Menu.Item icon={<Logout size={20} />} onClick={handleLogout}>
|
||||
Logout
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
<AddServerModal
|
||||
opened={addServerModal}
|
||||
onClose={() => addServerHandlers.close()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './components/Titlebar';
|
||||
@@ -0,0 +1,9 @@
|
||||
.root {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 150px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Button, Menu } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Logout, Server, Settings } from 'tabler-icons-react';
|
||||
import { useAuthStore } from 'renderer/store';
|
||||
import { AddServerModal } from '../servers/components/AddServerModal';
|
||||
import styles from './UserMenu.module.scss';
|
||||
|
||||
export const UserMenu = () => {
|
||||
const navigate = useNavigate();
|
||||
const [addServerModal, addServerHandlers] = useDisclosure(false);
|
||||
const logout = useAuthStore((state) => state.logout);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
localStorage.removeItem('authentication');
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu
|
||||
classNames={{ itemIcon: styles.icon, root: styles.root }}
|
||||
control={
|
||||
<Button radius="lg" size="xs" variant="default">
|
||||
User
|
||||
</Button>
|
||||
}
|
||||
position="bottom"
|
||||
size="md"
|
||||
>
|
||||
<Menu.Item
|
||||
icon={<Server size={20} />}
|
||||
onClick={() => addServerHandlers.open()}
|
||||
>
|
||||
Servers
|
||||
</Menu.Item>
|
||||
<Menu.Item icon={<Settings size={20} />}>Settings</Menu.Item>
|
||||
<Menu.Item icon={<Logout size={20} />} onClick={handleLogout}>
|
||||
Logout
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
<AddServerModal
|
||||
opened={addServerModal}
|
||||
onClose={() => addServerHandlers.close()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './UserMenu';
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,85 @@
|
||||
import { useState } from 'react';
|
||||
import isElectron from 'is-electron';
|
||||
import styled from 'styled-components';
|
||||
import windowsClose from '../assets/close-w-10.png';
|
||||
import windowsMax from '../assets/max-w-10.png';
|
||||
import windowsMin from '../assets/min-w-10.png';
|
||||
|
||||
interface WindowControlsProps {
|
||||
style?: 'macos' | 'windows' | 'linux';
|
||||
}
|
||||
|
||||
const WindowControlsContainer = styled.div`
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const WindowsButtonGroup = styled.div`
|
||||
display: flex;
|
||||
width: 130px;
|
||||
height: 100%;
|
||||
-webkit-app-region: no-drag;
|
||||
`;
|
||||
|
||||
const WindowsButton = styled.div`
|
||||
flex-grow: 1;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
&:hover {
|
||||
background: rgba(125, 125, 125, 30%);
|
||||
}
|
||||
`;
|
||||
|
||||
const close = () => window.electron.ipcRenderer.windowClose();
|
||||
|
||||
const minimize = () => window.electron.ipcRenderer.windowMinimize();
|
||||
|
||||
const maximize = () => window.electron.ipcRenderer.windowMaximize();
|
||||
|
||||
const unmaximize = () => window.electron.ipcRenderer.windowUnmaximize();
|
||||
|
||||
export const WindowControls = ({ style }: WindowControlsProps) => {
|
||||
const [max, setMax] = useState(false);
|
||||
|
||||
const handleMinimize = () => minimize();
|
||||
|
||||
const handleMaximize = () => {
|
||||
if (max) {
|
||||
unmaximize();
|
||||
} else {
|
||||
maximize();
|
||||
}
|
||||
setMax(!max);
|
||||
};
|
||||
|
||||
const handleClose = () => close();
|
||||
|
||||
return (
|
||||
<WindowControlsContainer>
|
||||
{isElectron() && (
|
||||
<>
|
||||
{style === 'windows' && (
|
||||
<>
|
||||
<WindowsButtonGroup>
|
||||
<WindowsButton role="button" onClick={handleMinimize}>
|
||||
<img alt="minimize" src={windowsMin} />
|
||||
</WindowsButton>
|
||||
<WindowsButton role="button" onClick={handleMaximize}>
|
||||
<img alt="maximize" src={windowsMax} />
|
||||
</WindowsButton>
|
||||
<WindowsButton role="button" onClick={handleClose}>
|
||||
<img alt="exit" src={windowsClose} />
|
||||
</WindowsButton>
|
||||
</WindowsButtonGroup>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</WindowControlsContainer>
|
||||
);
|
||||
};
|
||||
|
||||
WindowControls.defaultProps = {
|
||||
style: 'windows',
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './components/WindowControls';
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user