mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-11 18:41:47 +00:00
feat(api-server): remote control api (#1909)
Co-authored-by: JellyBrick <shlee1503@naver.com> Co-authored-by: Angelos Bouklis <angelbouklis.official@gmail.com> Co-authored-by: Angelos Bouklis <me@arjix.dev> Co-authored-by: Angelos Bouklis <53124886+ArjixWasTaken@users.noreply.github.com>
This commit is contained in:
@ -165,6 +165,10 @@
|
||||
"@ffmpeg.wasm/main": "0.12.0",
|
||||
"@floating-ui/dom": "1.6.11",
|
||||
"@foobar404/wave": "2.0.5",
|
||||
"@hono/node-server": "1.13.2",
|
||||
"@hono/swagger-ui": "0.4.1",
|
||||
"@hono/zod-openapi": "0.16.4",
|
||||
"@hono/zod-validator": "0.4.1",
|
||||
"@jellybrick/electron-better-web-request": "1.0.4",
|
||||
"@jellybrick/mpris-service": "2.1.4",
|
||||
"@skyra/jaro-winkler": "^1.1.1",
|
||||
@ -186,6 +190,7 @@
|
||||
"fast-average-color": "9.4.0",
|
||||
"fast-equals": "5.0.1",
|
||||
"filenamify": "6.0.0",
|
||||
"hono": "4.6.4",
|
||||
"howler": "2.2.4",
|
||||
"html-to-text": "9.0.5",
|
||||
"i18next": "23.15.2",
|
||||
@ -204,7 +209,8 @@
|
||||
"ts-morph": "24.0.0",
|
||||
"vudio": "2.1.1",
|
||||
"x11": "2.3.0",
|
||||
"youtubei.js": "10.5.0"
|
||||
"youtubei.js": "10.5.0",
|
||||
"zod": "3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.10.0",
|
||||
|
||||
108
pnpm-lock.yaml
generated
108
pnpm-lock.yaml
generated
@ -48,6 +48,18 @@ importers:
|
||||
'@foobar404/wave':
|
||||
specifier: 2.0.5
|
||||
version: 2.0.5
|
||||
'@hono/node-server':
|
||||
specifier: 1.13.2
|
||||
version: 1.13.2(hono@4.6.4)
|
||||
'@hono/swagger-ui':
|
||||
specifier: 0.4.1
|
||||
version: 0.4.1(hono@4.6.4)
|
||||
'@hono/zod-openapi':
|
||||
specifier: 0.16.4
|
||||
version: 0.16.4(hono@4.6.4)(zod@3.23.8)
|
||||
'@hono/zod-validator':
|
||||
specifier: 0.4.1
|
||||
version: 0.4.1(hono@4.6.4)(zod@3.23.8)
|
||||
'@jellybrick/electron-better-web-request':
|
||||
specifier: 1.0.4
|
||||
version: 1.0.4
|
||||
@ -111,6 +123,9 @@ importers:
|
||||
filenamify:
|
||||
specifier: 6.0.0
|
||||
version: 6.0.0
|
||||
hono:
|
||||
specifier: 4.6.4
|
||||
version: 4.6.4
|
||||
howler:
|
||||
specifier: 2.2.4
|
||||
version: 2.2.4
|
||||
@ -168,6 +183,9 @@ importers:
|
||||
youtubei.js:
|
||||
specifier: 10.5.0
|
||||
version: 10.5.0
|
||||
zod:
|
||||
specifier: 3.23.8
|
||||
version: 3.23.8
|
||||
devDependencies:
|
||||
'@eslint/js':
|
||||
specifier: ^9.10.0
|
||||
@ -306,6 +324,11 @@ packages:
|
||||
'@assemblyscript/loader@0.17.14':
|
||||
resolution: {integrity: sha512-+PVTOfla/0XMLRTQLJFPg4u40XcdTfon6GGea70hBGi8Pd7ZymIXyVUR+vK8wt5Jb4MVKTKPIz43Myyebw5mZA==}
|
||||
|
||||
'@asteasolutions/zod-to-openapi@7.2.0':
|
||||
resolution: {integrity: sha512-Va+Fq1QzKkSgmiYINSp3cASFhMsbdRH/kmCk2feijhC+yNjGoC056CRqihrVFhR8MY8HOZHdlYm2Ns2lmszCiw==}
|
||||
peerDependencies:
|
||||
zod: ^3.20.2
|
||||
|
||||
'@babel/code-frame@7.24.7':
|
||||
resolution: {integrity: sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@ -826,6 +849,36 @@ packages:
|
||||
'@foobar404/wave@2.0.5':
|
||||
resolution: {integrity: sha512-V/ydadtv5ObCw8aEg+Qy3YSq1eyinEWzJfRI43Ovmj7VmAvEdWAdL7MatoMbiIVYPATkNDVF7GOxX1xirxM9dA==}
|
||||
|
||||
'@hono/node-server@1.13.2':
|
||||
resolution: {integrity: sha512-0w8nEmAyx0Ul0CQp8BL2VtAG4YVdpzXd/mvvM+l0G5Oq22pUyHS+KeFFPSY+czLOF5NAiV3MUNPD1n14Ol5svg==}
|
||||
engines: {node: '>=18.14.1'}
|
||||
peerDependencies:
|
||||
hono: ^4
|
||||
|
||||
'@hono/swagger-ui@0.4.1':
|
||||
resolution: {integrity: sha512-kPaJatHffeYQ3yVkHo878hCqwfapqx54FczJVJ+eRWt8J4biyVVMIdCAJb6MyA8bcnHUoTmUpPc7OJAV1VTg2g==}
|
||||
peerDependencies:
|
||||
hono: '*'
|
||||
|
||||
'@hono/zod-openapi@0.16.4':
|
||||
resolution: {integrity: sha512-mnF6GthBaKex0D5PsY/4lYNtkaGJNE38bjeUI//EUqq7Ee4TNm2su35IUiFH4HcmJp5fWYMLyOJOpjnkClzEGw==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
peerDependencies:
|
||||
hono: '>=4.3.6'
|
||||
zod: 3.*
|
||||
|
||||
'@hono/zod-validator@0.3.0':
|
||||
resolution: {integrity: sha512-7XcTk3yYyk6ldrO/VuqsroE7stvDZxHJQcpATRAyha8rUxJNBPV3+6waDrARfgEqxOVlzIadm3/6sE/dPseXgQ==}
|
||||
peerDependencies:
|
||||
hono: '>=3.9.0'
|
||||
zod: ^3.19.1
|
||||
|
||||
'@hono/zod-validator@0.4.1':
|
||||
resolution: {integrity: sha512-I8LyfeJfvVmC5hPjZ2Iij7RjexlgSBT7QJudZ4JvNPLxn0JQ3sqclz2zydlwISAnw21D2n4LQ0nfZdoiv9fQQA==}
|
||||
peerDependencies:
|
||||
hono: '>=3.9.0'
|
||||
zod: ^3.19.1
|
||||
|
||||
'@humanfs/core@0.19.0':
|
||||
resolution: {integrity: sha512-2cbWIHbZVEweE853g8jymffCA+NCMiuqeECeBBLm8dg2oFdjuGJhgN4UAbI+6v0CKbbhvtXA4qV8YR5Ji86nmw==}
|
||||
engines: {node: '>=18.18.0'}
|
||||
@ -2458,6 +2511,10 @@ packages:
|
||||
resolution: {integrity: sha512-ciq6hFsSG/Bpt2DmrZJtv+56zpPdnq+NQ4ijEFrveKN0ZG1mhl/LdT1NQZ9se6ty1fACcI4d4vYqC9v8EYpH2A==}
|
||||
hasBin: true
|
||||
|
||||
hono@4.6.4:
|
||||
resolution: {integrity: sha512-T5WqBkTOcIQblqBKB5mpzaH/A+dSpvVe938xZJCHOmOuYfF7DSwE/9/10+BMvwSPq9N/f6LiQ38HxrZSQOsXKw==}
|
||||
engines: {node: '>=16.9.0'}
|
||||
|
||||
hosted-git-info@4.1.0:
|
||||
resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==}
|
||||
engines: {node: '>=10'}
|
||||
@ -3135,6 +3192,9 @@ packages:
|
||||
resolution: {integrity: sha512-mnkeQ1qP5Ue2wd+aivTD3NHd/lZ96Lu0jgf0pwktLPtx6cTZiH7tyeGRRHs0zX0rbrahXPnXlUnbeXyaBBuIaw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
openapi3-ts@4.4.0:
|
||||
resolution: {integrity: sha512-9asTNB9IkKEzWMcHmVZE7Ts3kC9G7AFHfs8i7caD8HbI76gEjdkId4z/AkP83xdZsH7PLAnnbl47qZkXuxpArw==}
|
||||
|
||||
optionator@0.9.3:
|
||||
resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@ -4026,6 +4086,11 @@ packages:
|
||||
yallist@4.0.0:
|
||||
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
|
||||
|
||||
yaml@2.5.1:
|
||||
resolution: {integrity: sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==}
|
||||
engines: {node: '>= 14'}
|
||||
hasBin: true
|
||||
|
||||
yargs-parser@21.1.1:
|
||||
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
||||
engines: {node: '>=12'}
|
||||
@ -4048,6 +4113,9 @@ packages:
|
||||
resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
zod@3.23.8:
|
||||
resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==}
|
||||
|
||||
snapshots:
|
||||
|
||||
7zip-bin@5.2.0: {}
|
||||
@ -4063,6 +4131,11 @@ snapshots:
|
||||
|
||||
'@assemblyscript/loader@0.17.14': {}
|
||||
|
||||
'@asteasolutions/zod-to-openapi@7.2.0(zod@3.23.8)':
|
||||
dependencies:
|
||||
openapi3-ts: 4.4.0
|
||||
zod: 3.23.8
|
||||
|
||||
'@babel/code-frame@7.24.7':
|
||||
dependencies:
|
||||
'@babel/highlight': 7.24.7
|
||||
@ -4530,6 +4603,31 @@ snapshots:
|
||||
|
||||
'@foobar404/wave@2.0.5': {}
|
||||
|
||||
'@hono/node-server@1.13.2(hono@4.6.4)':
|
||||
dependencies:
|
||||
hono: 4.6.4
|
||||
|
||||
'@hono/swagger-ui@0.4.1(hono@4.6.4)':
|
||||
dependencies:
|
||||
hono: 4.6.4
|
||||
|
||||
'@hono/zod-openapi@0.16.4(hono@4.6.4)(zod@3.23.8)':
|
||||
dependencies:
|
||||
'@asteasolutions/zod-to-openapi': 7.2.0(zod@3.23.8)
|
||||
'@hono/zod-validator': 0.3.0(hono@4.6.4)(zod@3.23.8)
|
||||
hono: 4.6.4
|
||||
zod: 3.23.8
|
||||
|
||||
'@hono/zod-validator@0.3.0(hono@4.6.4)(zod@3.23.8)':
|
||||
dependencies:
|
||||
hono: 4.6.4
|
||||
zod: 3.23.8
|
||||
|
||||
'@hono/zod-validator@0.4.1(hono@4.6.4)(zod@3.23.8)':
|
||||
dependencies:
|
||||
hono: 4.6.4
|
||||
zod: 3.23.8
|
||||
|
||||
'@humanfs/core@0.19.0': {}
|
||||
|
||||
'@humanfs/node@0.16.5':
|
||||
@ -6553,6 +6651,8 @@ snapshots:
|
||||
|
||||
hexy@0.2.11: {}
|
||||
|
||||
hono@4.6.4: {}
|
||||
|
||||
hosted-git-info@4.1.0:
|
||||
dependencies:
|
||||
lru-cache: 6.0.0
|
||||
@ -7191,6 +7291,10 @@ snapshots:
|
||||
is-inside-container: 1.0.0
|
||||
is-wsl: 3.1.0
|
||||
|
||||
openapi3-ts@4.4.0:
|
||||
dependencies:
|
||||
yaml: 2.5.1
|
||||
|
||||
optionator@0.9.3:
|
||||
dependencies:
|
||||
'@aashutoshrathi/word-wrap': 1.2.6
|
||||
@ -8107,6 +8211,8 @@ snapshots:
|
||||
|
||||
yallist@4.0.0: {}
|
||||
|
||||
yaml@2.5.1: {}
|
||||
|
||||
yargs-parser@21.1.1: {}
|
||||
|
||||
yargs@17.7.2:
|
||||
@ -8138,3 +8244,5 @@ snapshots:
|
||||
archiver-utils: 3.0.4
|
||||
compress-commons: 4.1.2
|
||||
readable-stream: 3.6.2
|
||||
|
||||
zod@3.23.8: {}
|
||||
|
||||
@ -279,6 +279,49 @@
|
||||
},
|
||||
"name": "Ambient Mode"
|
||||
},
|
||||
"api-server": {
|
||||
"description": "Adds an API server to control the player",
|
||||
"name": "API Server [Beta]",
|
||||
"dialog": {
|
||||
"request": {
|
||||
"title": "API authorization request",
|
||||
"message": "Allow {{ID}} ({{origin}}) to access the API?",
|
||||
"buttons": {
|
||||
"allow": "Allow",
|
||||
"deny": "Deny"
|
||||
}
|
||||
}
|
||||
},
|
||||
"menu": {
|
||||
"hostname": {
|
||||
"label": "Hostname"
|
||||
},
|
||||
"port": {
|
||||
"label": "Port"
|
||||
},
|
||||
"auth-strategy": {
|
||||
"label": "Authorization strategy",
|
||||
"submenu": {
|
||||
"auth-at-first": {
|
||||
"label": "Authorize at first request"
|
||||
},
|
||||
"none": {
|
||||
"label": "No authorization"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"prompt": {
|
||||
"hostname": {
|
||||
"title": "Hostname",
|
||||
"label": "Enter the hostname (like 0.0.0.0) for the API server:"
|
||||
},
|
||||
"port": {
|
||||
"title": "Port",
|
||||
"label": "Enter the port for the API server:"
|
||||
}
|
||||
}
|
||||
},
|
||||
"audio-compressor": {
|
||||
"description": "Apply compression to audio (lowers the volume of the loudest parts of the signal and raises the volume of the softest parts)",
|
||||
"name": "Audio Compressor"
|
||||
|
||||
1
src/plugins/api-server/backend/index.ts
Normal file
1
src/plugins/api-server/backend/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './main';
|
||||
99
src/plugins/api-server/backend/main.ts
Normal file
99
src/plugins/api-server/backend/main.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import { jwt } from 'hono/jwt';
|
||||
import { OpenAPIHono as Hono } from '@hono/zod-openapi';
|
||||
import { cors } from 'hono/cors';
|
||||
import { swaggerUI } from '@hono/swagger-ui';
|
||||
import { serve } from '@hono/node-server';
|
||||
|
||||
import registerCallback from '@/providers/song-info';
|
||||
import { createBackend } from '@/utils';
|
||||
|
||||
import { JWTPayloadSchema } from './scheme';
|
||||
import { registerAuth, registerControl } from './routes';
|
||||
|
||||
import type { APIServerConfig } from '../config';
|
||||
import type { BackendType } from './types';
|
||||
|
||||
export const backend = createBackend<BackendType, APIServerConfig>({
|
||||
async start(ctx) {
|
||||
const config = await ctx.getConfig();
|
||||
|
||||
this.init(ctx);
|
||||
registerCallback((songInfo) => {
|
||||
this.songInfo = songInfo;
|
||||
});
|
||||
|
||||
this.run(config.hostname, config.port);
|
||||
},
|
||||
stop() {
|
||||
this.end();
|
||||
},
|
||||
onConfigChange(config) {
|
||||
if (this.oldConfig?.hostname === config.hostname && this.oldConfig?.port === config.port) {
|
||||
this.oldConfig = config;
|
||||
return;
|
||||
}
|
||||
|
||||
this.end();
|
||||
this.run(config.hostname, config.port);
|
||||
this.oldConfig = config;
|
||||
},
|
||||
|
||||
// Custom
|
||||
async init(ctx) {
|
||||
const config = await ctx.getConfig();
|
||||
this.app = new Hono();
|
||||
|
||||
this.app.use('*', cors());
|
||||
|
||||
// middlewares
|
||||
this.app.use(
|
||||
'/api/*',
|
||||
jwt({
|
||||
secret: config.secret,
|
||||
}),
|
||||
);
|
||||
this.app.use('/api/*', async (ctx, next) => {
|
||||
const result = await JWTPayloadSchema.spa(await ctx.get('jwtPayload'));
|
||||
|
||||
const isAuthorized = result.success && config.authorizedClients.includes(result.data.id);
|
||||
if (!isAuthorized) {
|
||||
ctx.status(401);
|
||||
return ctx.body('Unauthorized');
|
||||
}
|
||||
|
||||
return await next();
|
||||
});
|
||||
|
||||
// routes
|
||||
registerControl(this.app, ctx, () => this.songInfo);
|
||||
registerAuth(this.app, ctx);
|
||||
|
||||
// swagger
|
||||
this.app.doc('/doc', {
|
||||
openapi: '3.1.0',
|
||||
info: {
|
||||
version: '1.0.0',
|
||||
title: 'Youtube Music API Server',
|
||||
},
|
||||
});
|
||||
|
||||
this.app.get('/swagger', swaggerUI({ url: '/doc' }));
|
||||
},
|
||||
run(hostname, port) {
|
||||
if (!this.app) return;
|
||||
|
||||
try {
|
||||
this.server = serve({
|
||||
fetch: this.app.fetch.bind(this.app),
|
||||
port,
|
||||
hostname,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
},
|
||||
end() {
|
||||
this.server?.close();
|
||||
this.server = undefined;
|
||||
},
|
||||
});
|
||||
90
src/plugins/api-server/backend/routes/auth.ts
Normal file
90
src/plugins/api-server/backend/routes/auth.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import { createRoute, z } from '@hono/zod-openapi';
|
||||
import { dialog } from 'electron';
|
||||
import { sign } from 'hono/jwt';
|
||||
|
||||
import { t } from '@/i18n';
|
||||
import { getConnInfo } from '@hono/node-server/conninfo';
|
||||
|
||||
import { APIServerConfig } from '../../config';
|
||||
import { JWTPayload } from '../scheme';
|
||||
|
||||
import type { HonoApp } from '../types';
|
||||
import type { BackendContext } from '@/types/contexts';
|
||||
|
||||
const routes = {
|
||||
request: createRoute({
|
||||
method: 'post',
|
||||
path: '/auth/{id}',
|
||||
summary: '',
|
||||
description: '',
|
||||
request: {
|
||||
params: z.object({
|
||||
id: z.string(),
|
||||
})
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Success',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: z.object({
|
||||
accessToken: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
403: {
|
||||
description: 'Forbidden',
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
export const register = (app: HonoApp, { getConfig, setConfig }: BackendContext<APIServerConfig>) => {
|
||||
app.openapi(routes.request, async (ctx) => {
|
||||
const config = await getConfig();
|
||||
const { id } = ctx.req.param();
|
||||
|
||||
if (config.authorizedClients.includes(id)) {
|
||||
// SKIP CHECK
|
||||
} else if (config.authStrategy === 'AUTH_AT_FIRST') {
|
||||
const result = await dialog.showMessageBox({
|
||||
title: t('plugins.api-server.dialog.request.title'),
|
||||
message: t('plugins.api-server.dialog.request.message', {
|
||||
origin: getConnInfo(ctx).remote.address,
|
||||
id,
|
||||
}),
|
||||
buttons: [t('plugins.api-server.dialog.request.buttons.allow'), t('plugins.api-server.dialog.request.deny')],
|
||||
defaultId: 1,
|
||||
cancelId: 1,
|
||||
});
|
||||
|
||||
if (result.response === 1) {
|
||||
ctx.status(403);
|
||||
return ctx.body(null);
|
||||
}
|
||||
} else if (config.authStrategy === 'NONE') {
|
||||
// SKIP CHECK
|
||||
}
|
||||
|
||||
setConfig({
|
||||
authorizedClients: [
|
||||
...config.authorizedClients,
|
||||
id,
|
||||
],
|
||||
});
|
||||
|
||||
const token = await sign(
|
||||
{
|
||||
id,
|
||||
iat: ~~(Date.now() / 1000),
|
||||
} satisfies JWTPayload,
|
||||
config.secret,
|
||||
);
|
||||
|
||||
ctx.status(200);
|
||||
return ctx.json({
|
||||
accessToken: token,
|
||||
});
|
||||
});
|
||||
};
|
||||
474
src/plugins/api-server/backend/routes/control.ts
Normal file
474
src/plugins/api-server/backend/routes/control.ts
Normal file
@ -0,0 +1,474 @@
|
||||
import { createRoute, z } from '@hono/zod-openapi';
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
|
||||
import getSongControls from '@/providers/song-controls';
|
||||
|
||||
import {
|
||||
AuthHeadersSchema,
|
||||
type ResponseSongInfo,
|
||||
SongInfoSchema,
|
||||
GoForwardScheme,
|
||||
GoBackSchema,
|
||||
SwitchRepeatSchema,
|
||||
SetVolumeSchema,
|
||||
SetFullscreenSchema,
|
||||
} from '../scheme';
|
||||
|
||||
import type { SongInfo } from '@/providers/song-info';
|
||||
import type { BackendContext } from '@/types/contexts';
|
||||
import type { APIServerConfig } from '../../config';
|
||||
import type { HonoApp } from '../types';
|
||||
import type { QueueResponse } from '@/types/youtube-music-desktop-internal';
|
||||
|
||||
const API_VERSION = 'v1';
|
||||
|
||||
const routes = {
|
||||
previous: createRoute({
|
||||
method: 'post',
|
||||
path: `/api/${API_VERSION}/previous`,
|
||||
summary: 'play previous song',
|
||||
description: 'Plays the previous song in the queue',
|
||||
request: {
|
||||
headers: AuthHeadersSchema,
|
||||
},
|
||||
responses: {
|
||||
204: {
|
||||
description: 'Success',
|
||||
},
|
||||
},
|
||||
}),
|
||||
next: createRoute({
|
||||
method: 'post',
|
||||
path: `/api/${API_VERSION}/next`,
|
||||
summary: 'play next song',
|
||||
description: 'Plays the next song in the queue',
|
||||
request: {
|
||||
headers: AuthHeadersSchema,
|
||||
},
|
||||
responses: {
|
||||
204: {
|
||||
description: 'Success',
|
||||
},
|
||||
},
|
||||
}),
|
||||
play: createRoute({
|
||||
method: 'post',
|
||||
path: `/api/${API_VERSION}/play`,
|
||||
summary: 'Play',
|
||||
description: 'Change the state of the player to play',
|
||||
request: {
|
||||
headers: AuthHeadersSchema,
|
||||
},
|
||||
responses: {
|
||||
204: {
|
||||
description: 'Success',
|
||||
},
|
||||
},
|
||||
}),
|
||||
pause: createRoute({
|
||||
method: 'post',
|
||||
path: `/api/${API_VERSION}/pause`,
|
||||
summary: 'Pause',
|
||||
description: 'Change the state of the player to pause',
|
||||
request: {
|
||||
headers: AuthHeadersSchema,
|
||||
},
|
||||
responses: {
|
||||
204: {
|
||||
description: 'Success',
|
||||
},
|
||||
},
|
||||
}),
|
||||
togglePlay: createRoute({
|
||||
method: 'post',
|
||||
path: `/api/${API_VERSION}/toggle-play`,
|
||||
summary: 'Toggle play/pause',
|
||||
description: 'Change the state of the player to play if paused, or pause if playing',
|
||||
request: {
|
||||
headers: AuthHeadersSchema,
|
||||
},
|
||||
responses: {
|
||||
204: {
|
||||
description: 'Success',
|
||||
},
|
||||
},
|
||||
}),
|
||||
like: createRoute({
|
||||
method: 'post',
|
||||
path: `/api/${API_VERSION}/like`,
|
||||
summary: 'like song',
|
||||
description: 'Set the current song as liked',
|
||||
request: {
|
||||
headers: AuthHeadersSchema,
|
||||
},
|
||||
responses: {
|
||||
204: {
|
||||
description: 'Success',
|
||||
},
|
||||
},
|
||||
}),
|
||||
dislike: createRoute({
|
||||
method: 'post',
|
||||
path: `/api/${API_VERSION}/dislike`,
|
||||
summary: 'dislike song',
|
||||
description: 'Set the current song as disliked',
|
||||
request: {
|
||||
headers: AuthHeadersSchema,
|
||||
},
|
||||
responses: {
|
||||
204: {
|
||||
description: 'Success',
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
goBack: createRoute({
|
||||
method: 'post',
|
||||
path: `/api/${API_VERSION}/go-back`,
|
||||
summary: 'go back',
|
||||
description: 'Move the current song back by a number of seconds',
|
||||
request: {
|
||||
headers: AuthHeadersSchema,
|
||||
body: {
|
||||
description: 'seconds to go back',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: GoBackSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
204: {
|
||||
description: 'Success',
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
goForward: createRoute({
|
||||
method: 'post',
|
||||
path: `/api/${API_VERSION}/go-forward`,
|
||||
summary: 'go forward',
|
||||
description: 'Move the current song forward by a number of seconds',
|
||||
request: {
|
||||
headers: AuthHeadersSchema,
|
||||
body: {
|
||||
description: 'seconds to go forward',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: GoForwardScheme,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
204: {
|
||||
description: 'Success',
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
shuffle: createRoute({
|
||||
method: 'post',
|
||||
path: `/api/${API_VERSION}/shuffle`,
|
||||
summary: 'shuffle',
|
||||
description: 'Shuffle the queue',
|
||||
request: {
|
||||
headers: AuthHeadersSchema,
|
||||
},
|
||||
responses: {
|
||||
204: {
|
||||
description: 'Success',
|
||||
},
|
||||
},
|
||||
}),
|
||||
switchRepeat: createRoute({
|
||||
method: 'post',
|
||||
path: `/api/${API_VERSION}/switch-repeat`,
|
||||
summary: 'switch repeat',
|
||||
description: 'Switch the repeat mode',
|
||||
request: {
|
||||
headers: AuthHeadersSchema,
|
||||
body: {
|
||||
description: 'number of times to click the repeat button',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: SwitchRepeatSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
204: {
|
||||
description: 'Success',
|
||||
},
|
||||
},
|
||||
}),
|
||||
setVolume: createRoute({
|
||||
method: 'post',
|
||||
path: `/api/${API_VERSION}/volume`,
|
||||
summary: 'set volume',
|
||||
description: 'Set the volume of the player',
|
||||
request: {
|
||||
headers: AuthHeadersSchema,
|
||||
body: {
|
||||
description: 'volume to set',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: SetVolumeSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
204: {
|
||||
description: 'Success',
|
||||
},
|
||||
},
|
||||
}),
|
||||
setFullscreen: createRoute({
|
||||
method: 'post',
|
||||
path: `/api/${API_VERSION}/fullscreen`,
|
||||
summary: 'set fullscreen',
|
||||
description: 'Set the fullscreen state of the player',
|
||||
request: {
|
||||
headers: AuthHeadersSchema,
|
||||
body: {
|
||||
description: 'fullscreen state',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: SetFullscreenSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
204: {
|
||||
description: 'Success',
|
||||
},
|
||||
},
|
||||
}),
|
||||
toggleMute: createRoute({
|
||||
method: 'post',
|
||||
path: `/api/${API_VERSION}/toggle-mute`,
|
||||
summary: 'toggle mute',
|
||||
description: 'Toggle the mute state of the player',
|
||||
request: {
|
||||
headers: AuthHeadersSchema,
|
||||
},
|
||||
responses: {
|
||||
204: {
|
||||
description: 'Success',
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
getFullscreenState: createRoute({
|
||||
method: 'get',
|
||||
path: `/api/${API_VERSION}/fullscreen`,
|
||||
summary: 'get fullscreen state',
|
||||
description: 'Get the current fullscreen state',
|
||||
request: {
|
||||
headers: AuthHeadersSchema,
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Success',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: z.object({
|
||||
state: z.boolean(),
|
||||
}),
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
queueInfo: createRoute({
|
||||
method: 'get',
|
||||
path: `/api/${API_VERSION}/queue-info`,
|
||||
summary: 'get current queue info',
|
||||
description: 'Get the current queue info',
|
||||
request: {
|
||||
headers: AuthHeadersSchema,
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Success',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: z.object({}),
|
||||
}
|
||||
},
|
||||
},
|
||||
204: {
|
||||
description: 'No queue info',
|
||||
},
|
||||
},
|
||||
}),
|
||||
songInfo: createRoute({
|
||||
method: 'get',
|
||||
path: `/api/${API_VERSION}/song-info`,
|
||||
summary: 'get current song info',
|
||||
description: 'Get the current song info',
|
||||
request: {
|
||||
headers: AuthHeadersSchema,
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Success',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: SongInfoSchema,
|
||||
}
|
||||
},
|
||||
},
|
||||
204: {
|
||||
description: 'No song info',
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
export const register = (app: HonoApp, { window }: BackendContext<APIServerConfig>, songInfoGetter: () => SongInfo | undefined) => {
|
||||
const controller = getSongControls(window);
|
||||
|
||||
app.openapi(routes.previous, (ctx) => {
|
||||
controller.previous();
|
||||
|
||||
ctx.status(204);
|
||||
return ctx.body(null);
|
||||
});
|
||||
app.openapi(routes.previous, (ctx) => {
|
||||
controller.previous();
|
||||
|
||||
ctx.status(204);
|
||||
return ctx.body(null);
|
||||
});
|
||||
app.openapi(routes.play, (ctx) => {
|
||||
controller.play();
|
||||
|
||||
ctx.status(204);
|
||||
return ctx.body(null);
|
||||
});
|
||||
app.openapi(routes.pause, (ctx) => {
|
||||
controller.pause();
|
||||
|
||||
ctx.status(204);
|
||||
return ctx.body(null);
|
||||
});
|
||||
app.openapi(routes.togglePlay, (ctx) => {
|
||||
controller.playPause();
|
||||
|
||||
ctx.status(204);
|
||||
return ctx.body(null);
|
||||
});
|
||||
app.openapi(routes.like, (ctx) => {
|
||||
controller.like();
|
||||
|
||||
ctx.status(204);
|
||||
return ctx.body(null);
|
||||
});
|
||||
app.openapi(routes.dislike, (ctx) => {
|
||||
controller.dislike();
|
||||
|
||||
ctx.status(204);
|
||||
return ctx.body(null);
|
||||
});
|
||||
app.openapi(routes.goBack, (ctx) => {
|
||||
const { seconds } = ctx.req.valid('json');
|
||||
controller.goBack(seconds);
|
||||
|
||||
ctx.status(204);
|
||||
return ctx.body(null);
|
||||
});
|
||||
app.openapi(routes.goForward, (ctx) => {
|
||||
const { seconds } = ctx.req.valid('json');
|
||||
controller.goForward(seconds);
|
||||
|
||||
ctx.status(204);
|
||||
return ctx.body(null);
|
||||
});
|
||||
app.openapi(routes.shuffle, (ctx) => {
|
||||
controller.shuffle();
|
||||
|
||||
ctx.status(204);
|
||||
return ctx.body(null);
|
||||
});
|
||||
app.openapi(routes.switchRepeat, (ctx) => {
|
||||
const { iteration } = ctx.req.valid('json');
|
||||
controller.switchRepeat(iteration);
|
||||
|
||||
ctx.status(204);
|
||||
return ctx.body(null);
|
||||
});
|
||||
app.openapi(routes.setVolume, (ctx) => {
|
||||
const { volume } = ctx.req.valid('json');
|
||||
controller.setVolume(volume);
|
||||
|
||||
ctx.status(204);
|
||||
return ctx.body(null);
|
||||
});
|
||||
app.openapi(routes.setFullscreen, (ctx) => {
|
||||
const { state } = ctx.req.valid('json');
|
||||
controller.setFullscreen(state);
|
||||
|
||||
ctx.status(204);
|
||||
return ctx.body(null);
|
||||
});
|
||||
app.openapi(routes.toggleMute, (ctx) => {
|
||||
controller.muteUnmute();
|
||||
|
||||
ctx.status(204);
|
||||
return ctx.body(null);
|
||||
});
|
||||
|
||||
app.openapi(routes.getFullscreenState, async (ctx) => {
|
||||
const stateResponsePromise = new Promise<boolean>((resolve) => {
|
||||
ipcMain.once('ytmd:set-fullscreen', (_, isFullscreen: boolean | undefined) => {
|
||||
return resolve(!!isFullscreen);
|
||||
});
|
||||
|
||||
controller.requestFullscreenInformation();
|
||||
});
|
||||
|
||||
const fullscreen = await stateResponsePromise;
|
||||
|
||||
ctx.status(200);
|
||||
return ctx.json({ state: fullscreen });
|
||||
});
|
||||
app.openapi(routes.queueInfo, async (ctx) => {
|
||||
const queueResponsePromise = new Promise<QueueResponse>((resolve) => {
|
||||
ipcMain.once('ytmd:get-queue-response', (_, queue: QueueResponse) => {
|
||||
return resolve(queue);
|
||||
});
|
||||
|
||||
controller.requestQueueInformation();
|
||||
});
|
||||
|
||||
const info = await queueResponsePromise;
|
||||
|
||||
if (!info) {
|
||||
ctx.status(204);
|
||||
return ctx.body(null);
|
||||
}
|
||||
|
||||
ctx.status(200);
|
||||
return ctx.json(info);
|
||||
});
|
||||
app.openapi(routes.songInfo, (ctx) => {
|
||||
const info = songInfoGetter();
|
||||
|
||||
if (!info) {
|
||||
ctx.status(204);
|
||||
return ctx.body(null);
|
||||
}
|
||||
|
||||
const body = { ...info };
|
||||
delete body.image;
|
||||
|
||||
ctx.status(200);
|
||||
return ctx.json(body satisfies ResponseSongInfo);
|
||||
});
|
||||
};
|
||||
2
src/plugins/api-server/backend/routes/index.ts
Normal file
2
src/plugins/api-server/backend/routes/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { register as registerControl } from './control';
|
||||
export { register as registerAuth } from './auth';
|
||||
13
src/plugins/api-server/backend/scheme/auth.ts
Normal file
13
src/plugins/api-server/backend/scheme/auth.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { z } from '@hono/zod-openapi';
|
||||
|
||||
export const AuthHeadersSchema = z.object({
|
||||
authorization: z.string().openapi({
|
||||
example: 'Bearer token',
|
||||
}),
|
||||
});
|
||||
|
||||
export type JWTPayload = z.infer<typeof JWTPayloadSchema>;
|
||||
export const JWTPayloadSchema = z.object({
|
||||
id: z.string(),
|
||||
iat: z.number(),
|
||||
});
|
||||
5
src/plugins/api-server/backend/scheme/go-back.ts
Normal file
5
src/plugins/api-server/backend/scheme/go-back.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { z } from '@hono/zod-openapi';
|
||||
|
||||
export const GoBackSchema = z.object({
|
||||
seconds: z.number(),
|
||||
});
|
||||
5
src/plugins/api-server/backend/scheme/go-forward.ts
Normal file
5
src/plugins/api-server/backend/scheme/go-forward.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { z } from '@hono/zod-openapi';
|
||||
|
||||
export const GoForwardScheme = z.object({
|
||||
seconds: z.number(),
|
||||
});
|
||||
8
src/plugins/api-server/backend/scheme/index.ts
Normal file
8
src/plugins/api-server/backend/scheme/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export * from './auth';
|
||||
export * from './song-info';
|
||||
export * from './go-back';
|
||||
export * from './go-forward';
|
||||
export * from './switch-repeat';
|
||||
export * from './set-volume';
|
||||
export * from './set-fullscreen';
|
||||
|
||||
5
src/plugins/api-server/backend/scheme/set-fullscreen.ts
Normal file
5
src/plugins/api-server/backend/scheme/set-fullscreen.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { z } from '@hono/zod-openapi';
|
||||
|
||||
export const SetFullscreenSchema = z.object({
|
||||
state: z.boolean(),
|
||||
});
|
||||
5
src/plugins/api-server/backend/scheme/set-volume.ts
Normal file
5
src/plugins/api-server/backend/scheme/set-volume.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { z } from '@hono/zod-openapi';
|
||||
|
||||
export const SetVolumeSchema = z.object({
|
||||
volume: z.number(),
|
||||
});
|
||||
26
src/plugins/api-server/backend/scheme/song-info.ts
Normal file
26
src/plugins/api-server/backend/scheme/song-info.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { z } from '@hono/zod-openapi';
|
||||
|
||||
import { MediaType } from '@/providers/song-info';
|
||||
|
||||
export type ResponseSongInfo = z.infer<typeof SongInfoSchema>;
|
||||
export const SongInfoSchema = z.object({
|
||||
title: z.string(),
|
||||
artist: z.string(),
|
||||
views: z.number(),
|
||||
uploadDate: z.string().optional(),
|
||||
imageSrc: z.string().nullable().optional(),
|
||||
isPaused: z.boolean().optional(),
|
||||
songDuration: z.number(),
|
||||
elapsedSeconds: z.number().optional(),
|
||||
url: z.string().optional(),
|
||||
album: z.string().nullable().optional(),
|
||||
videoId: z.string(),
|
||||
playlistId: z.string().optional(),
|
||||
mediaType: z.enum([
|
||||
MediaType.Audio,
|
||||
MediaType.OriginalMusicVideo,
|
||||
MediaType.UserGeneratedContent,
|
||||
MediaType.PodcastEpisode,
|
||||
MediaType.OtherVideo,
|
||||
]),
|
||||
});
|
||||
5
src/plugins/api-server/backend/scheme/switch-repeat.ts
Normal file
5
src/plugins/api-server/backend/scheme/switch-repeat.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { z } from '@hono/zod-openapi';
|
||||
|
||||
export const SwitchRepeatSchema = z.object({
|
||||
iteration: z.number(),
|
||||
});
|
||||
18
src/plugins/api-server/backend/types.ts
Normal file
18
src/plugins/api-server/backend/types.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { OpenAPIHono as Hono } from '@hono/zod-openapi';
|
||||
import { serve } from '@hono/node-server';
|
||||
|
||||
import type { BackendContext } from '@/types/contexts';
|
||||
import type { SongInfo } from '@/providers/song-info';
|
||||
import type { APIServerConfig } from '../config';
|
||||
|
||||
export type HonoApp = Hono;
|
||||
export type BackendType = {
|
||||
app?: HonoApp;
|
||||
server?: ReturnType<typeof serve>;
|
||||
oldConfig?: APIServerConfig;
|
||||
songInfo?: SongInfo;
|
||||
|
||||
init: (ctx: BackendContext<APIServerConfig>) => void;
|
||||
run: (hostname: string, port: number) => void;
|
||||
end: () => void;
|
||||
};
|
||||
19
src/plugins/api-server/config.ts
Normal file
19
src/plugins/api-server/config.ts
Normal file
@ -0,0 +1,19 @@
|
||||
export interface APIServerConfig {
|
||||
enabled: boolean;
|
||||
hostname: string;
|
||||
port: number;
|
||||
authStrategy: 'AUTH_AT_FIRST' | 'NONE';
|
||||
secret: string;
|
||||
|
||||
authorizedClients: string[];
|
||||
}
|
||||
|
||||
export const defaultAPIServerConfig: APIServerConfig = {
|
||||
enabled: true,
|
||||
hostname: '0.0.0.0',
|
||||
port: 26538,
|
||||
authStrategy: 'AUTH_AT_FIRST',
|
||||
secret: Date.now().toString(36),
|
||||
|
||||
authorizedClients: [],
|
||||
};
|
||||
17
src/plugins/api-server/index.ts
Normal file
17
src/plugins/api-server/index.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { createPlugin } from '@/utils';
|
||||
import { t } from '@/i18n';
|
||||
|
||||
import { defaultAPIServerConfig } from './config';
|
||||
import { onMenu } from './menu';
|
||||
import { backend } from './backend';
|
||||
|
||||
export default createPlugin({
|
||||
name: () => t('plugins.api-server.name'),
|
||||
description: () => t('plugins.api-server.description'),
|
||||
restartNeeded: false,
|
||||
config: defaultAPIServerConfig,
|
||||
addedVersion: '3.6.X',
|
||||
menu: onMenu,
|
||||
|
||||
backend,
|
||||
});
|
||||
85
src/plugins/api-server/menu.ts
Normal file
85
src/plugins/api-server/menu.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import prompt from 'custom-electron-prompt';
|
||||
|
||||
import { t } from '@/i18n';
|
||||
import promptOptions from '@/providers/prompt-options';
|
||||
|
||||
import { APIServerConfig, defaultAPIServerConfig } from './config';
|
||||
|
||||
import type { MenuContext } from '@/types/contexts';
|
||||
import type { MenuTemplate } from '@/menu';
|
||||
|
||||
export const onMenu = async ({
|
||||
getConfig,
|
||||
setConfig,
|
||||
window,
|
||||
}: MenuContext<APIServerConfig>): Promise<MenuTemplate> => {
|
||||
const config = await getConfig();
|
||||
|
||||
return [
|
||||
{
|
||||
label: t('plugins.api-server.menu.hostname.label'),
|
||||
type: 'normal',
|
||||
async click() {
|
||||
const config = await getConfig();
|
||||
|
||||
const newHostname = await prompt(
|
||||
{
|
||||
title: t('plugins.api-server.prompt.hostname.title'),
|
||||
label: t('plugins.api-server.prompt.hostname.label'),
|
||||
value: config.hostname,
|
||||
type: 'input',
|
||||
width: 380,
|
||||
...promptOptions(),
|
||||
},
|
||||
window,
|
||||
) ?? defaultAPIServerConfig.hostname;
|
||||
|
||||
setConfig({ ...config, hostname: newHostname });
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('plugins.api-server.menu.port'),
|
||||
type: 'normal',
|
||||
async click() {
|
||||
const config = await getConfig();
|
||||
|
||||
const newPort = await prompt(
|
||||
{
|
||||
title: t('plugins.api-server.prompt.port.title'),
|
||||
label: t('plugins.api-server.prompt.port.label'),
|
||||
value: config.port,
|
||||
type: 'counter',
|
||||
counterOptions: { minimum: 0, maximum: 65565, },
|
||||
width: 380,
|
||||
...promptOptions(),
|
||||
},
|
||||
window,
|
||||
) ?? defaultAPIServerConfig.port;
|
||||
|
||||
setConfig({ ...config, port: newPort });
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('plugins.api-server.menu.auth-strategy'),
|
||||
type: 'submenu',
|
||||
submenu: [
|
||||
{
|
||||
label: t('plugins.api-server.menu.auth-strategy.submenu.auth-at-first'),
|
||||
type: 'radio',
|
||||
checked: config.authStrategy === 'AUTH_AT_FIRST',
|
||||
click() {
|
||||
setConfig({ ...config, authStrategy: 'AUTH_AT_FIRST' });
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('plugins.api-server.menu.auth-strategy.submenu.none'),
|
||||
type: 'radio',
|
||||
checked: config.authStrategy === 'NONE',
|
||||
click() {
|
||||
setConfig({ ...config, authStrategy: 'NONE' });
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
};
|
||||
@ -29,6 +29,8 @@ export default (win: BrowserWindow) => {
|
||||
// Playback
|
||||
previous: () => win.webContents.send('ytmd:previous-video'),
|
||||
next: () => win.webContents.send('ytmd:next-video'),
|
||||
play: () => win.webContents.send('ytmd:play'),
|
||||
pause: () => win.webContents.send('ytmd:pause'),
|
||||
playPause: () => win.webContents.send('ytmd:toggle-play'),
|
||||
like: () => win.webContents.send('ytmd:update-like', 'LIKE'),
|
||||
dislike: () => win.webContents.send('ytmd:update-like', 'DISLIKE'),
|
||||
|
||||
@ -53,6 +53,12 @@ async function onApiLoaded() {
|
||||
window.ipcRenderer.on('ytmd:next-video', () => {
|
||||
document.querySelector<HTMLElement>('.next-button.ytmusic-player-bar')?.click();
|
||||
});
|
||||
window.ipcRenderer.on('ytmd:play', (_) => {
|
||||
api?.playVideo();
|
||||
});
|
||||
window.ipcRenderer.on('ytmd:pause', (_) => {
|
||||
api?.pauseVideo();
|
||||
});
|
||||
window.ipcRenderer.on('ytmd:toggle-play', (_) => {
|
||||
if (api?.getPlayerState() === 2) api?.playVideo();
|
||||
else api?.pauseVideo();
|
||||
|
||||
Reference in New Issue
Block a user