add initial files

This commit is contained in:
jeffvli
2022-07-25 19:40:16 -07:00
commit e8b612c974
283 changed files with 62820 additions and 0 deletions
+10
View File
@@ -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();
// });
});
+32
View File
@@ -0,0 +1,32 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
const en = require('./locales/en.json');
const resources = {
en: { translation: en },
};
export const Languages = [
{
label: 'English',
value: 'en',
},
];
i18n
.use(initReactI18next) // passes i18n down to react-i18next
.init({
fallbackLng: 'en',
// language to use, more information here: https://www.i18next.com/overview/configuration-options#languages-namespaces-resources
// you can use the i18n.changeLanguage function to change the language manually: https://www.i18next.com/overview/api#changelanguage
// if you're using a language detector, do not define the lng option
interpolation: {
escapeValue: false, // react already safes from xss
},
lng: 'en',
resources,
});
export default i18n;
+117
View File
@@ -0,0 +1,117 @@
// i18next-parser.config.js
module.exports = {
contextSeparator: '_',
// Key separator used in your translation keys
createOldCatalogs: true,
// Exit with an exit code of 1 when translations are updated (for CI purpose)
customValueTemplate: null,
// Save the \_old files
defaultNamespace: 'translation',
// Default namespace used in your i18next config
defaultValue: '',
// Exit with an exit code of 1 on warnings
failOnUpdate: false,
// Display info about the parsing including some stats
failOnWarnings: false,
// The locale to compare with default values to determine whether a default value has been changed.
// If this is set and a default value differs from a translation in the specified locale, all entries
// for that key across locales are reset to the default value, and existing translations are moved to
// the `_old` file.
i18nextOptions: null,
// Default value to give to empty keys
// You may also specify a function accepting the locale, namespace, and key as arguments
indentation: 2,
// Plural separator used in your translation keys
// If you want to use plain english keys, separators such as `_` might conflict. You might want to set `pluralSeparator` to a different string that does not occur in your keys.
input: [
'../components/**/*.{js,jsx,ts,tsx}',
'../features/**/*.{js,jsx,ts,tsx}',
'../layouts/**/*.{js,jsx,ts,tsx}',
'!../../src/node_modules/**',
'!../../src/**/*.prod.js',
],
// Indentation of the catalog files
keepRemoved: false,
// Keep keys from the catalog that are no longer in code
keySeparator: '.',
// Key separator used in your translation keys
// If you want to use plain english keys, separators such as `.` and `:` will conflict. You might want to set `keySeparator: false` and `namespaceSeparator: false`. That way, `t('Status: Loading...')` will not think that there are a namespace and three separator dots for instance.
// see below for more details
lexers: {
default: ['JavascriptLexer'],
handlebars: ['HandlebarsLexer'],
hbs: ['HandlebarsLexer'],
htm: ['HTMLLexer'],
html: ['HTMLLexer'],
js: ['JavascriptLexer'],
jsx: ['JsxLexer'],
mjs: ['JavascriptLexer'],
// if you're writing jsx inside .js files, change this to JsxLexer
ts: ['JavascriptLexer'],
tsx: ['JsxLexer'],
},
lineEnding: 'auto',
// Control the line ending. See options at https://github.com/ryanve/eol
locales: ['en'],
// An array of the locales in your applications
namespaceSeparator: false,
// Namespace separator used in your translation keys
// If you want to use plain english keys, separators such as `.` and `:` will conflict. You might want to set `keySeparator: false` and `namespaceSeparator: false`. That way, `t('Status: Loading...')` will not think that there are a namespace and three separator dots for instance.
output: 'src/renderer/i18n/locales/$LOCALE.json',
// Supports $LOCALE and $NAMESPACE injection
// Supports JSON (.json) and YAML (.yml) file formats
// Where to write the locale files relative to process.cwd()
pluralSeparator: '_',
// If you wish to customize the value output the value as an object, you can set your own format.
// ${defaultValue} is the default value you set in your translation function.
// Any other custom property will be automatically extracted.
//
// Example:
// {
// message: "${defaultValue}",
// description: "${maxLength}", //
// }
resetDefaultValueLocale: 'en',
// Whether or not to sort the catalog. Can also be a [compareFunction](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#parameters)
skipDefaultValues: false,
// An array of globs that describe where to look for source files
// relative to the location of the configuration file
sort: true,
// Whether to ignore default values
// You may also specify a function accepting the locale and namespace as arguments
useKeysAsDefaultValue: true,
// Whether to use the keys as the default value; ex. "Hello": "Hello", "World": "World"
// This option takes precedence over the `defaultValue` and `skipDefaultValues` options
// You may also specify a function accepting the locale and namespace as arguments
verbose: false,
// If you wish to customize options in internally used i18next instance, you can define an object with any
// configuration property supported by i18next (https://www.i18next.com/overview/configuration-options).
// { compatibilityJSON: 'v3' } can be used to generate v3 compatible plurals.
};
+9
View File
@@ -0,0 +1,9 @@
{
"player": {
"next": "player.next",
"play": "player.play",
"prev": "player.prev",
"seekBack": "player.seekBack",
"seekForward": "player.seekForward"
}
}
+1
View File
@@ -0,0 +1 @@
import './player';
+117
View File
@@ -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();
});
+1
View File
@@ -0,0 +1 @@
declare module 'node-mpv';
View File
+4
View File
@@ -0,0 +1,4 @@
import './core';
// eslint-disable-next-line import/no-dynamic-require
require(`./${process.platform}`);
View File
View File
+165
View File
@@ -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);
+290
View File
@@ -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;
}
}
+74
View File
@@ -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');
},
},
});
+31
View File
@@ -0,0 +1,31 @@
/* eslint import/prefer-default-export: off, import/no-mutable-exports: off */
import path from 'path';
import process from 'process';
import { URL } from 'url';
export let resolveHtmlPath: (htmlFileName: string) => string;
if (process.env.NODE_ENV === 'development') {
const port = process.env.PORT || 4343;
resolveHtmlPath = (htmlFileName: string) => {
const url = new URL(`http://localhost:${port}`);
url.pathname = htmlFileName;
return url.href;
};
} else {
resolveHtmlPath = (htmlFileName: string) => {
return `file://${path.resolve(__dirname, '../renderer/', htmlFileName)}`;
};
}
export const isMacOS = () => {
return process.platform === 'darwin';
};
export const isWindows = () => {
return process.platform === 'win32';
};
export const isLinux = () => {
return process.platform === 'linux';
};
+28
View File
@@ -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,
};
+38
View File
@@ -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,
};
+10
View File
@@ -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),
});
};
+8
View File
@@ -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,
};
+22
View File
@@ -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,
};
View File
+162
View File
@@ -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;
};
+11
View File
@@ -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,
};
+54
View File
@@ -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;
};
+10
View File
@@ -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,
};
+4
View File
@@ -0,0 +1,4 @@
export * from './tooltip/Tooltip';
export * from './audio-player/AudioPlayer';
export * from './icon-button/IconButton';
export * from './text/Text';
+27
View File
@@ -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>
)}
</>
);
};
+95
View File
@@ -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: {},
};
+1
View File
@@ -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>
);
};
+1
View File
@@ -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,
};
};
+4
View File
@@ -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}
/>
)}
</>
))}
</>
);
};
+5
View File
@@ -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));
};
+1
View File
@@ -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>
);
};
+1
View File
@@ -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()}
/>
</>
);
};
+1
View File
@@ -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()}
/>
</>
);
};
+1
View File
@@ -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