2023-07-23 12:23:18 +00:00
|
|
|
import { Stats, promises } from 'fs';
|
|
|
|
|
import { readFile } from 'fs/promises';
|
|
|
|
|
import { IncomingMessage, Server, ServerResponse, createServer } from 'http';
|
|
|
|
|
import { join } from 'path';
|
|
|
|
|
import { deflate, gzip } from 'zlib';
|
|
|
|
|
import axios from 'axios';
|
|
|
|
|
import { app, ipcMain } from 'electron';
|
|
|
|
|
import { Server as WsServer, WebSocketServer, WebSocket } from 'ws';
|
2023-10-18 17:49:09 +00:00
|
|
|
import manifest from './manifest.json';
|
2023-07-23 12:23:18 +00:00
|
|
|
import { ClientEvent, ServerEvent } from '../../../../remote/types';
|
|
|
|
|
import { PlayerRepeat, SongUpdate } from '../../../../renderer/types';
|
|
|
|
|
import { getMainWindow } from '../../../main';
|
|
|
|
|
import { isLinux } from '../../../utils';
|
|
|
|
|
|
|
|
|
|
let mprisPlayer: any | undefined;
|
|
|
|
|
|
|
|
|
|
if (isLinux()) {
|
|
|
|
|
// eslint-disable-next-line global-require
|
|
|
|
|
mprisPlayer = require('../../linux/mpris').mprisPlayer;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface RemoteConfig {
|
|
|
|
|
enabled: boolean;
|
|
|
|
|
password: string;
|
|
|
|
|
port: number;
|
|
|
|
|
username: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface MimeType {
|
|
|
|
|
css: string;
|
|
|
|
|
html: string;
|
|
|
|
|
ico: string;
|
|
|
|
|
js: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface StatefulWebSocket extends WebSocket {
|
|
|
|
|
alive: boolean;
|
2023-09-25 00:31:33 +00:00
|
|
|
auth: boolean;
|
2023-07-23 12:23:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let server: Server | undefined;
|
|
|
|
|
let wsServer: WsServer<StatefulWebSocket> | undefined;
|
|
|
|
|
|
|
|
|
|
const settings: RemoteConfig = {
|
|
|
|
|
enabled: false,
|
|
|
|
|
password: '',
|
|
|
|
|
port: 4333,
|
|
|
|
|
username: '',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type SendData = ServerEvent & {
|
|
|
|
|
client: StatefulWebSocket;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function send({ client, event, data }: SendData): void {
|
|
|
|
|
if (client.readyState === WebSocket.OPEN) {
|
2023-09-25 00:31:33 +00:00
|
|
|
if (client.alive && client.auth) {
|
2023-09-25 00:02:25 +00:00
|
|
|
client.send(JSON.stringify({ data, event }));
|
|
|
|
|
}
|
2023-07-23 12:23:18 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function broadcast(message: ServerEvent): void {
|
|
|
|
|
if (wsServer) {
|
|
|
|
|
for (const client of wsServer.clients) {
|
|
|
|
|
send({ client, ...message });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const shutdownServer = () => {
|
|
|
|
|
if (wsServer) {
|
|
|
|
|
wsServer.clients.forEach((client) => client.close(4000));
|
|
|
|
|
wsServer.close();
|
|
|
|
|
wsServer = undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (server) {
|
|
|
|
|
server.close();
|
|
|
|
|
server = undefined;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const MIME_TYPES: MimeType = {
|
|
|
|
|
css: 'text/css',
|
|
|
|
|
html: 'text/html; charset=UTF-8',
|
|
|
|
|
ico: 'image/x-icon',
|
|
|
|
|
js: 'application/javascript',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const PING_TIMEOUT_MS = 10000;
|
|
|
|
|
const UP_TIMEOUT_MS = 5000;
|
|
|
|
|
|
|
|
|
|
enum Encoding {
|
|
|
|
|
GZIP = 'gzip',
|
|
|
|
|
NONE = 'none',
|
|
|
|
|
ZLIB = 'deflate',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const GZIP_REGEX = /\bgzip\b/;
|
|
|
|
|
const ZLIB_REGEX = /bdeflate\b/;
|
|
|
|
|
|
|
|
|
|
let currentSong: SongUpdate = {
|
|
|
|
|
currentTime: 0,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getEncoding = (encoding: string | string[]): Encoding => {
|
|
|
|
|
const encodingArray = Array.isArray(encoding) ? encoding : [encoding];
|
|
|
|
|
|
|
|
|
|
for (const code of encodingArray) {
|
|
|
|
|
if (code.match(GZIP_REGEX)) {
|
|
|
|
|
return Encoding.GZIP;
|
|
|
|
|
}
|
|
|
|
|
if (code.match(ZLIB_REGEX)) {
|
|
|
|
|
return Encoding.ZLIB;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Encoding.NONE;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const cache = new Map<string, Map<Encoding, [number, Buffer]>>();
|
|
|
|
|
|
|
|
|
|
function setOk(
|
|
|
|
|
res: ServerResponse,
|
|
|
|
|
mtimeMs: number,
|
|
|
|
|
extension: keyof MimeType,
|
|
|
|
|
encoding: Encoding,
|
|
|
|
|
data?: Buffer,
|
|
|
|
|
) {
|
|
|
|
|
res.statusCode = data ? 200 : 304;
|
|
|
|
|
|
|
|
|
|
res.setHeader('Content-Type', MIME_TYPES[extension]);
|
|
|
|
|
res.setHeader('ETag', `"${mtimeMs}"`);
|
|
|
|
|
res.setHeader('Cache-Control', 'public');
|
|
|
|
|
|
|
|
|
|
if (encoding !== 'none') res.setHeader('Content-Encoding', encoding);
|
|
|
|
|
res.end(data);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function serveFile(
|
|
|
|
|
req: IncomingMessage,
|
|
|
|
|
file: string,
|
|
|
|
|
extension: keyof MimeType,
|
|
|
|
|
res: ServerResponse,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const fileName = `${file}.${extension}`;
|
2023-09-25 00:02:25 +00:00
|
|
|
const path = app.isPackaged
|
|
|
|
|
? join(__dirname, '../remote', fileName)
|
|
|
|
|
: join(__dirname, '../../../../../.erb/dll', fileName);
|
2023-07-23 12:23:18 +00:00
|
|
|
|
|
|
|
|
let stats: Stats;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
stats = await promises.stat(path);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
res.statusCode = 404;
|
|
|
|
|
res.setHeader('Content-Type', 'text/plain');
|
|
|
|
|
res.end((error as Error).message);
|
|
|
|
|
// This is a resolve, even though it is an error, because we want specific (non 500) status
|
|
|
|
|
return Promise.resolve();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const encodings = req.headers['accept-encoding'] ?? '';
|
|
|
|
|
const selectedEncoding = getEncoding(encodings);
|
|
|
|
|
|
|
|
|
|
const ifMatch = req.headers['if-none-match'];
|
|
|
|
|
|
|
|
|
|
const fileInfo = cache.get(fileName);
|
|
|
|
|
let cached = fileInfo?.get(selectedEncoding);
|
|
|
|
|
|
|
|
|
|
if (cached && cached[0] !== stats.mtimeMs) {
|
|
|
|
|
cache.get(fileName)!.delete(selectedEncoding);
|
|
|
|
|
cached = undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (ifMatch && cached) {
|
|
|
|
|
const options = ifMatch.split(',');
|
|
|
|
|
|
|
|
|
|
for (const option of options) {
|
|
|
|
|
const mTime = Number(option.replaceAll('"', '').trim());
|
|
|
|
|
|
|
|
|
|
if (cached[0] === mTime) {
|
|
|
|
|
setOk(res, cached[0], extension, selectedEncoding);
|
|
|
|
|
return Promise.resolve();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!cached || cached[0] !== stats.mtimeMs) {
|
|
|
|
|
const content = await readFile(path);
|
|
|
|
|
|
|
|
|
|
switch (selectedEncoding) {
|
|
|
|
|
case Encoding.GZIP:
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
gzip(content, (error, result) => {
|
|
|
|
|
if (error) {
|
|
|
|
|
reject(error);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const newEntry: [number, Buffer] = [stats.mtimeMs, result];
|
|
|
|
|
|
|
|
|
|
if (fileInfo) {
|
|
|
|
|
fileInfo.set(selectedEncoding, newEntry);
|
|
|
|
|
} else {
|
|
|
|
|
cache.set(fileName, new Map([[selectedEncoding, newEntry]]));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setOk(res, stats.mtimeMs, extension, selectedEncoding, result);
|
|
|
|
|
resolve();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
case Encoding.ZLIB:
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
deflate(content, (error, result) => {
|
|
|
|
|
if (error) {
|
|
|
|
|
reject(error);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const newEntry: [number, Buffer] = [stats.mtimeMs, result];
|
|
|
|
|
|
|
|
|
|
if (fileInfo) {
|
|
|
|
|
fileInfo.set(selectedEncoding, newEntry);
|
|
|
|
|
} else {
|
|
|
|
|
cache.set(fileName, new Map([[selectedEncoding, newEntry]]));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setOk(res, stats.mtimeMs, extension, selectedEncoding, result);
|
|
|
|
|
resolve();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
default: {
|
|
|
|
|
const newEntry: [number, Buffer] = [stats.mtimeMs, content];
|
|
|
|
|
|
|
|
|
|
if (fileInfo) {
|
|
|
|
|
fileInfo.set(selectedEncoding, newEntry);
|
|
|
|
|
} else {
|
|
|
|
|
cache.set(fileName, new Map([[selectedEncoding, newEntry]]));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setOk(res, stats.mtimeMs, extension, selectedEncoding, content);
|
|
|
|
|
return Promise.resolve();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setOk(res, cached[0], extension, selectedEncoding, cached[1]);
|
|
|
|
|
|
|
|
|
|
return Promise.resolve();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function authorize(req: IncomingMessage): boolean {
|
|
|
|
|
if (settings.username || settings.password) {
|
|
|
|
|
// https://stackoverflow.com/questions/23616371/basic-http-authentication-with-node-and-express-4
|
|
|
|
|
|
|
|
|
|
const authorization = req.headers.authorization?.split(' ')[1] || '';
|
|
|
|
|
const [login, password] = Buffer.from(authorization, 'base64').toString().split(':');
|
|
|
|
|
|
|
|
|
|
return login === settings.username && password === settings.password;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const enableServer = (config: RemoteConfig): Promise<void> => {
|
|
|
|
|
return new Promise<void>((resolve, reject) => {
|
|
|
|
|
try {
|
|
|
|
|
if (server) {
|
|
|
|
|
server.close();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
server = createServer({}, async (req, res) => {
|
|
|
|
|
if (!authorize(req)) {
|
|
|
|
|
res.statusCode = 401;
|
|
|
|
|
res.setHeader('WWW-Authenticate', 'Basic realm="401"');
|
|
|
|
|
res.end('Authorization required');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
switch (req.url) {
|
|
|
|
|
case '/': {
|
|
|
|
|
await serveFile(req, 'index', 'html', res);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case '/favicon.ico': {
|
2023-09-25 00:02:25 +00:00
|
|
|
await serveFile(req, 'favicon', 'ico', res);
|
2023-07-23 12:23:18 +00:00
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case '/remote.css': {
|
|
|
|
|
await serveFile(req, 'remote', 'css', res);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case '/remote.js': {
|
|
|
|
|
await serveFile(req, 'remote', 'js', res);
|
|
|
|
|
break;
|
|
|
|
|
}
|
2023-10-18 17:49:09 +00:00
|
|
|
case '/manifest.json': {
|
|
|
|
|
res.statusCode = 200;
|
|
|
|
|
res.setHeader('Content-Type', 'application/json');
|
|
|
|
|
res.end(JSON.stringify(manifest));
|
|
|
|
|
break;
|
|
|
|
|
}
|
2023-09-25 00:02:25 +00:00
|
|
|
case '/credentials': {
|
|
|
|
|
res.statusCode = 200;
|
|
|
|
|
res.setHeader('Content-Type', 'text/plain');
|
|
|
|
|
res.end(req.headers.authorization);
|
|
|
|
|
break;
|
|
|
|
|
}
|
2023-07-23 12:23:18 +00:00
|
|
|
default: {
|
2023-10-18 17:49:09 +00:00
|
|
|
if (req.url?.startsWith('/worker.js')) {
|
|
|
|
|
await serveFile(req, 'worker', 'js', res);
|
|
|
|
|
} else {
|
|
|
|
|
res.statusCode = 404;
|
|
|
|
|
res.setHeader('Content-Type', 'text/plain');
|
|
|
|
|
res.end('Not Found');
|
|
|
|
|
}
|
2023-07-23 12:23:18 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
res.statusCode = 500;
|
|
|
|
|
res.setHeader('Content-Type', 'text/plain');
|
|
|
|
|
res.end((error as Error).message);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
server.listen(config.port, resolve);
|
|
|
|
|
wsServer = new WebSocketServer({ server });
|
|
|
|
|
|
2023-09-25 00:02:25 +00:00
|
|
|
wsServer.on('connection', (ws) => {
|
|
|
|
|
let authFail: number | undefined;
|
2023-09-25 00:31:33 +00:00
|
|
|
ws.alive = true;
|
2023-07-23 12:23:18 +00:00
|
|
|
|
2023-09-25 00:02:25 +00:00
|
|
|
if (!settings.username && !settings.password) {
|
2023-09-25 00:31:33 +00:00
|
|
|
ws.auth = true;
|
2023-09-25 00:02:25 +00:00
|
|
|
} else {
|
|
|
|
|
authFail = setTimeout(() => {
|
2023-09-25 00:31:33 +00:00
|
|
|
if (!ws.auth) {
|
2023-09-25 00:02:25 +00:00
|
|
|
ws.close();
|
|
|
|
|
}
|
|
|
|
|
}, 10000) as unknown as number;
|
|
|
|
|
}
|
2023-07-23 12:23:18 +00:00
|
|
|
|
|
|
|
|
ws.on('error', console.error);
|
|
|
|
|
|
|
|
|
|
ws.on('message', (data) => {
|
|
|
|
|
try {
|
|
|
|
|
const json = JSON.parse(data.toString()) as ClientEvent;
|
|
|
|
|
const event = json.event;
|
|
|
|
|
|
2023-09-25 00:31:33 +00:00
|
|
|
if (!ws.auth) {
|
|
|
|
|
if (event === 'authenticate') {
|
|
|
|
|
const auth = json.header.split(' ')[1];
|
|
|
|
|
const [login, password] = Buffer.from(auth, 'base64')
|
|
|
|
|
.toString()
|
|
|
|
|
.split(':');
|
|
|
|
|
|
|
|
|
|
if (login === settings.username && password === settings.password) {
|
|
|
|
|
ws.auth = true;
|
|
|
|
|
} else {
|
|
|
|
|
ws.close();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
clearTimeout(authFail);
|
|
|
|
|
} else {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-07-23 12:23:18 +00:00
|
|
|
switch (event) {
|
|
|
|
|
case 'pause': {
|
|
|
|
|
getMainWindow()?.webContents.send('renderer-player-pause');
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 'play': {
|
|
|
|
|
getMainWindow()?.webContents.send('renderer-player-play');
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 'next': {
|
|
|
|
|
getMainWindow()?.webContents.send('renderer-player-next');
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 'previous': {
|
|
|
|
|
getMainWindow()?.webContents.send('renderer-player-previous');
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 'proxy': {
|
|
|
|
|
const toFetch = currentSong.song?.imageUrl?.replaceAll(
|
|
|
|
|
/&(size|width|height=\d+)/g,
|
|
|
|
|
'',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (!toFetch) return;
|
|
|
|
|
|
|
|
|
|
axios
|
|
|
|
|
.get(toFetch, { responseType: 'arraybuffer' })
|
|
|
|
|
.then((resp) => {
|
|
|
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
|
|
|
send({
|
|
|
|
|
client: ws,
|
|
|
|
|
data: Buffer.from(resp.data, 'binary').toString(
|
|
|
|
|
'base64',
|
|
|
|
|
),
|
|
|
|
|
event: 'proxy',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
})
|
|
|
|
|
.catch((error) => {
|
|
|
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
|
|
|
send({
|
|
|
|
|
client: ws,
|
|
|
|
|
data: error.message,
|
|
|
|
|
event: 'error',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 'repeat': {
|
|
|
|
|
getMainWindow()?.webContents.send('renderer-player-toggle-repeat');
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 'shuffle': {
|
|
|
|
|
getMainWindow()?.webContents.send('renderer-player-toggle-shuffle');
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 'volume': {
|
|
|
|
|
let volume = Number(json.volume);
|
|
|
|
|
|
|
|
|
|
if (volume > 100) {
|
|
|
|
|
volume = 100;
|
|
|
|
|
} else if (volume < 0) {
|
|
|
|
|
volume = 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
currentSong.volume = volume;
|
|
|
|
|
|
|
|
|
|
broadcast({ data: { volume }, event: 'song' });
|
|
|
|
|
getMainWindow()?.webContents.send('request-volume', {
|
|
|
|
|
volume,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (mprisPlayer) {
|
|
|
|
|
mprisPlayer.volume = volume / 100;
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 'favorite': {
|
|
|
|
|
const { favorite, id } = json;
|
|
|
|
|
if (id && id === currentSong.song?.id) {
|
|
|
|
|
getMainWindow()?.webContents.send('request-favorite', {
|
|
|
|
|
favorite,
|
|
|
|
|
id,
|
|
|
|
|
serverId: currentSong.song.serverId,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 'rating': {
|
|
|
|
|
const { rating, id } = json;
|
|
|
|
|
if (id && id === currentSong.song?.id) {
|
|
|
|
|
getMainWindow()?.webContents.send('request-rating', {
|
|
|
|
|
id,
|
|
|
|
|
rating,
|
|
|
|
|
serverId: currentSong.song.serverId,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error(error);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ws.on('pong', () => {
|
|
|
|
|
ws.alive = true;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ws.send(JSON.stringify({ data: currentSong, event: 'song' }));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const heartBeat = setInterval(() => {
|
|
|
|
|
wsServer?.clients.forEach((ws) => {
|
|
|
|
|
if (!ws.alive) {
|
|
|
|
|
ws.terminate();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ws.alive = false;
|
|
|
|
|
ws.ping();
|
|
|
|
|
});
|
|
|
|
|
}, PING_TIMEOUT_MS);
|
|
|
|
|
|
|
|
|
|
wsServer.on('close', () => {
|
|
|
|
|
clearInterval(heartBeat);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
reject(new Error('Server did not come up'));
|
|
|
|
|
}, UP_TIMEOUT_MS);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
reject(error);
|
|
|
|
|
shutdownServer();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
ipcMain.handle('remote-enable', async (_event, enabled: boolean) => {
|
|
|
|
|
settings.enabled = enabled;
|
|
|
|
|
|
|
|
|
|
if (enabled) {
|
|
|
|
|
try {
|
|
|
|
|
await enableServer(settings);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
return (error as Error).message;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
shutdownServer();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ipcMain.handle('remote-port', async (_event, port: number) => {
|
|
|
|
|
settings.port = port;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ipcMain.on('remote-password', (_event, password: string) => {
|
|
|
|
|
settings.password = password;
|
|
|
|
|
wsServer?.clients.forEach((client) => client.close(4002));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ipcMain.handle(
|
|
|
|
|
'remote-settings',
|
|
|
|
|
async (_event, enabled: boolean, port: number, username: string, password: string) => {
|
|
|
|
|
settings.enabled = enabled;
|
|
|
|
|
settings.password = password;
|
|
|
|
|
settings.port = port;
|
|
|
|
|
settings.username = username;
|
|
|
|
|
|
|
|
|
|
if (enabled) {
|
|
|
|
|
try {
|
|
|
|
|
await enableServer(settings);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
return (error as Error).message;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
shutdownServer();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
ipcMain.on('remote-username', (_event, username: string) => {
|
|
|
|
|
settings.username = username;
|
|
|
|
|
wsServer?.clients.forEach((client) => client.close(4002));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ipcMain.on('update-favorite', (_event, favorite: boolean, serverId: string, ids: string[]) => {
|
|
|
|
|
if (currentSong.song?.serverId !== serverId) return;
|
|
|
|
|
|
|
|
|
|
const id = currentSong.song.id;
|
|
|
|
|
|
|
|
|
|
for (const songId of ids) {
|
|
|
|
|
if (songId === id) {
|
|
|
|
|
currentSong.song.userFavorite = favorite;
|
|
|
|
|
broadcast({ data: { favorite, id: songId }, event: 'favorite' });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ipcMain.on('update-rating', (_event, rating: number, serverId: string, ids: string[]) => {
|
|
|
|
|
if (currentSong.song?.serverId !== serverId) return;
|
|
|
|
|
|
|
|
|
|
const id = currentSong.song.id;
|
|
|
|
|
|
|
|
|
|
for (const songId of ids) {
|
|
|
|
|
if (songId === id) {
|
|
|
|
|
currentSong.song.userRating = rating;
|
|
|
|
|
broadcast({ data: { id: songId, rating }, event: 'rating' });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ipcMain.on('update-repeat', (_event, repeat: PlayerRepeat) => {
|
|
|
|
|
currentSong.repeat = repeat;
|
|
|
|
|
broadcast({ data: { repeat }, event: 'song' });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ipcMain.on('update-shuffle', (_event, shuffle: boolean) => {
|
|
|
|
|
currentSong.shuffle = shuffle;
|
|
|
|
|
broadcast({ data: { shuffle }, event: 'song' });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ipcMain.on('update-song', (_event, data: SongUpdate) => {
|
|
|
|
|
const { song, ...rest } = data;
|
|
|
|
|
const songChanged = song?.id !== currentSong.song?.id;
|
|
|
|
|
|
|
|
|
|
if (!song?.id) {
|
|
|
|
|
currentSong = {
|
|
|
|
|
...currentSong,
|
|
|
|
|
...data,
|
|
|
|
|
song: undefined,
|
|
|
|
|
};
|
|
|
|
|
} else {
|
|
|
|
|
currentSong = {
|
|
|
|
|
...currentSong,
|
|
|
|
|
...data,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (songChanged) {
|
|
|
|
|
broadcast({ data: { ...rest, song: song || null }, event: 'song' });
|
|
|
|
|
} else {
|
|
|
|
|
broadcast({ data: rest, event: 'song' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ipcMain.on('update-volume', (_event, volume: number) => {
|
|
|
|
|
currentSong.volume = volume;
|
|
|
|
|
broadcast({ data: { volume }, event: 'song' });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (mprisPlayer) {
|
|
|
|
|
mprisPlayer.on('loopStatus', (event: string) => {
|
|
|
|
|
const repeat =
|
|
|
|
|
event === 'Playlist'
|
|
|
|
|
? PlayerRepeat.ALL
|
|
|
|
|
: event === 'Track'
|
|
|
|
|
? PlayerRepeat.ONE
|
|
|
|
|
: PlayerRepeat.NONE;
|
|
|
|
|
|
|
|
|
|
currentSong.repeat = repeat;
|
|
|
|
|
broadcast({ data: { repeat }, event: 'song' });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
mprisPlayer.on('shuffle', (shuffle: boolean) => {
|
|
|
|
|
currentSong.shuffle = shuffle;
|
|
|
|
|
broadcast({ data: { shuffle }, event: 'song' });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
mprisPlayer.on('volume', (vol: number) => {
|
|
|
|
|
let volume = Math.round(vol * 100);
|
|
|
|
|
|
|
|
|
|
if (volume > 100) {
|
|
|
|
|
volume = 100;
|
|
|
|
|
} else if (volume < 0) {
|
|
|
|
|
volume = 0;
|
|
|
|
|
}
|
|
|
|
|
currentSong.volume = volume;
|
|
|
|
|
broadcast({ data: { volume }, event: 'song' });
|
|
|
|
|
});
|
|
|
|
|
}
|