mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-11 10:31:47 +00:00
feat(api-server): Improved api-server volume and like/dislike state (#3592)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: rewhex <gitea@cluser.local> Co-authored-by: JellyBrick <shlee1503@naver.com>
This commit is contained in:
@ -13,7 +13,11 @@ import { registerAuth, registerControl } from './routes';
|
|||||||
import { type APIServerConfig, AuthStrategy } from '../config';
|
import { type APIServerConfig, AuthStrategy } from '../config';
|
||||||
|
|
||||||
import type { BackendType } from './types';
|
import type { BackendType } from './types';
|
||||||
import type { RepeatMode } from '@/types/datahost-get-state';
|
import type {
|
||||||
|
LikeType,
|
||||||
|
RepeatMode,
|
||||||
|
VolumeState,
|
||||||
|
} from '@/types/datahost-get-state';
|
||||||
|
|
||||||
export const backend = createBackend<BackendType, APIServerConfig>({
|
export const backend = createBackend<BackendType, APIServerConfig>({
|
||||||
async start(ctx) {
|
async start(ctx) {
|
||||||
@ -27,6 +31,7 @@ export const backend = createBackend<BackendType, APIServerConfig>({
|
|||||||
ctx.ipc.on('ytmd:player-api-loaded', () => {
|
ctx.ipc.on('ytmd:player-api-loaded', () => {
|
||||||
ctx.ipc.send('ytmd:setup-time-changed-listener');
|
ctx.ipc.send('ytmd:setup-time-changed-listener');
|
||||||
ctx.ipc.send('ytmd:setup-repeat-changed-listener');
|
ctx.ipc.send('ytmd:setup-repeat-changed-listener');
|
||||||
|
ctx.ipc.send('ytmd:setup-like-changed-listener');
|
||||||
ctx.ipc.send('ytmd:setup-volume-changed-listener');
|
ctx.ipc.send('ytmd:setup-volume-changed-listener');
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -37,7 +42,7 @@ export const backend = createBackend<BackendType, APIServerConfig>({
|
|||||||
|
|
||||||
ctx.ipc.on(
|
ctx.ipc.on(
|
||||||
'ytmd:volume-changed',
|
'ytmd:volume-changed',
|
||||||
(newVolume: number) => (this.volume = newVolume),
|
(newVolumeState: VolumeState) => (this.volumeState = newVolumeState),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.run(config.hostname, config.port);
|
this.run(config.hostname, config.port);
|
||||||
@ -103,7 +108,11 @@ export const backend = createBackend<BackendType, APIServerConfig>({
|
|||||||
backendCtx,
|
backendCtx,
|
||||||
() => this.songInfo,
|
() => this.songInfo,
|
||||||
() => this.currentRepeatMode,
|
() => this.currentRepeatMode,
|
||||||
() => this.volume,
|
() =>
|
||||||
|
backendCtx.window.webContents.executeJavaScript(
|
||||||
|
'document.querySelector("#like-button-renderer")?.likeStatus',
|
||||||
|
) as Promise<LikeType>,
|
||||||
|
() => this.volumeState,
|
||||||
);
|
);
|
||||||
registerAuth(this.app, backendCtx);
|
registerAuth(this.app, backendCtx);
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,12 @@ import { ipcMain } from 'electron';
|
|||||||
|
|
||||||
import getSongControls from '@/providers/song-controls';
|
import getSongControls from '@/providers/song-controls';
|
||||||
|
|
||||||
|
import {
|
||||||
|
LikeType,
|
||||||
|
type RepeatMode,
|
||||||
|
type VolumeState,
|
||||||
|
} from '@/types/datahost-get-state';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AddSongToQueueSchema,
|
AddSongToQueueSchema,
|
||||||
GoBackSchema,
|
GoBackSchema,
|
||||||
@ -20,7 +26,6 @@ import {
|
|||||||
type ResponseSongInfo,
|
type ResponseSongInfo,
|
||||||
} from '../scheme';
|
} from '../scheme';
|
||||||
|
|
||||||
import type { RepeatMode } from '@/types/datahost-get-state';
|
|
||||||
import type { SongInfo } from '@/providers/song-info';
|
import type { SongInfo } from '@/providers/song-info';
|
||||||
import type { BackendContext } from '@/types/contexts';
|
import type { BackendContext } from '@/types/contexts';
|
||||||
import type { APIServerConfig } from '../../config';
|
import type { APIServerConfig } from '../../config';
|
||||||
@ -87,6 +92,24 @@ const routes = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
getLikeState: createRoute({
|
||||||
|
method: 'get',
|
||||||
|
path: `/api/${API_VERSION}/like-state`,
|
||||||
|
summary: 'get like state',
|
||||||
|
description: 'Get the current like state',
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: 'Success',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: z.object({
|
||||||
|
state: z.enum(LikeType).nullable(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
like: createRoute({
|
like: createRoute({
|
||||||
method: 'post',
|
method: 'post',
|
||||||
path: `/api/${API_VERSION}/like`,
|
path: `/api/${API_VERSION}/like`,
|
||||||
@ -274,6 +297,7 @@ const routes = {
|
|||||||
'application/json': {
|
'application/json': {
|
||||||
schema: z.object({
|
schema: z.object({
|
||||||
state: z.number(),
|
state: z.number(),
|
||||||
|
isMuted: z.boolean(),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -526,12 +550,15 @@ const routes = {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PromiseOrValue<T> = T | Promise<T>;
|
||||||
|
|
||||||
export const register = (
|
export const register = (
|
||||||
app: HonoApp,
|
app: HonoApp,
|
||||||
{ window }: BackendContext<APIServerConfig>,
|
{ window }: BackendContext<APIServerConfig>,
|
||||||
songInfoGetter: () => SongInfo | undefined,
|
songInfoGetter: () => PromiseOrValue<SongInfo | undefined>,
|
||||||
repeatModeGetter: () => RepeatMode | undefined,
|
repeatModeGetter: () => PromiseOrValue<RepeatMode | undefined>,
|
||||||
volumeGetter: () => number | undefined,
|
likeTypeGetter: () => PromiseOrValue<LikeType | undefined>,
|
||||||
|
volumeStateGetter: () => PromiseOrValue<VolumeState | undefined>,
|
||||||
) => {
|
) => {
|
||||||
const controller = getSongControls(window);
|
const controller = getSongControls(window);
|
||||||
|
|
||||||
@ -565,6 +592,10 @@ export const register = (
|
|||||||
ctx.status(204);
|
ctx.status(204);
|
||||||
return ctx.body(null);
|
return ctx.body(null);
|
||||||
});
|
});
|
||||||
|
app.openapi(routes.getLikeState, async (ctx) => {
|
||||||
|
ctx.status(200);
|
||||||
|
return ctx.json({ state: (await likeTypeGetter()) ?? null });
|
||||||
|
});
|
||||||
app.openapi(routes.like, (ctx) => {
|
app.openapi(routes.like, (ctx) => {
|
||||||
controller.like();
|
controller.like();
|
||||||
|
|
||||||
@ -624,9 +655,9 @@ export const register = (
|
|||||||
return ctx.body(null);
|
return ctx.body(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.openapi(routes.repeatMode, (ctx) => {
|
app.openapi(routes.repeatMode, async (ctx) => {
|
||||||
ctx.status(200);
|
ctx.status(200);
|
||||||
return ctx.json({ mode: repeatModeGetter() ?? null });
|
return ctx.json({ mode: (await repeatModeGetter()) ?? null });
|
||||||
});
|
});
|
||||||
app.openapi(routes.switchRepeat, (ctx) => {
|
app.openapi(routes.switchRepeat, (ctx) => {
|
||||||
const { iteration } = ctx.req.valid('json');
|
const { iteration } = ctx.req.valid('json');
|
||||||
@ -642,9 +673,11 @@ export const register = (
|
|||||||
ctx.status(204);
|
ctx.status(204);
|
||||||
return ctx.body(null);
|
return ctx.body(null);
|
||||||
});
|
});
|
||||||
app.openapi(routes.getVolumeState, (ctx) => {
|
app.openapi(routes.getVolumeState, async (ctx) => {
|
||||||
ctx.status(200);
|
ctx.status(200);
|
||||||
return ctx.json({ state: volumeGetter() ?? 0 });
|
return ctx.json(
|
||||||
|
(await volumeStateGetter()) ?? { state: 0, isMuted: false },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
app.openapi(routes.setFullscreen, (ctx) => {
|
app.openapi(routes.setFullscreen, (ctx) => {
|
||||||
const { state } = ctx.req.valid('json');
|
const { state } = ctx.req.valid('json');
|
||||||
@ -678,8 +711,8 @@ export const register = (
|
|||||||
return ctx.json({ state: fullscreen });
|
return ctx.json({ state: fullscreen });
|
||||||
});
|
});
|
||||||
|
|
||||||
const songInfo = (ctx: Context) => {
|
const songInfo = async (ctx: Context) => {
|
||||||
const info = songInfoGetter();
|
const info = await songInfoGetter();
|
||||||
|
|
||||||
if (!info) {
|
if (!info) {
|
||||||
ctx.status(204);
|
ctx.status(204);
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import { type OpenAPIHono as Hono } from '@hono/zod-openapi';
|
import { type OpenAPIHono as Hono } from '@hono/zod-openapi';
|
||||||
import { type serve } from '@hono/node-server';
|
import { type serve } from '@hono/node-server';
|
||||||
|
|
||||||
|
import type { RepeatMode, VolumeState } from '@/types/datahost-get-state';
|
||||||
import type { BackendContext } from '@/types/contexts';
|
import type { BackendContext } from '@/types/contexts';
|
||||||
import type { SongInfo } from '@/providers/song-info';
|
import type { SongInfo } from '@/providers/song-info';
|
||||||
import type { RepeatMode } from '@/types/datahost-get-state';
|
|
||||||
import type { APIServerConfig } from '../config';
|
import type { APIServerConfig } from '../config';
|
||||||
|
|
||||||
export type HonoApp = Hono;
|
export type HonoApp = Hono;
|
||||||
@ -13,7 +13,7 @@ export type BackendType = {
|
|||||||
oldConfig?: APIServerConfig;
|
oldConfig?: APIServerConfig;
|
||||||
songInfo?: SongInfo;
|
songInfo?: SongInfo;
|
||||||
currentRepeatMode?: RepeatMode;
|
currentRepeatMode?: RepeatMode;
|
||||||
volume?: number;
|
volumeState?: VolumeState;
|
||||||
|
|
||||||
init: (ctx: BackendContext<APIServerConfig>) => void;
|
init: (ctx: BackendContext<APIServerConfig>) => void;
|
||||||
run: (hostname: string, port: number) => void;
|
run: (hostname: string, port: number) => void;
|
||||||
|
|||||||
@ -22,7 +22,7 @@ import getSongControls from '@/providers/song-controls';
|
|||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import { LoggerPrefix } from '@/utils';
|
import { LoggerPrefix } from '@/utils';
|
||||||
|
|
||||||
import type { RepeatMode } from '@/types/datahost-get-state';
|
import type { RepeatMode, VolumeState } from '@/types/datahost-get-state';
|
||||||
import type { QueueResponse } from '@/types/youtube-music-desktop-internal';
|
import type { QueueResponse } from '@/types/youtube-music-desktop-internal';
|
||||||
|
|
||||||
class YTPlayer extends MprisPlayer {
|
class YTPlayer extends MprisPlayer {
|
||||||
@ -305,8 +305,10 @@ function registerMPRIS(win: BrowserWindow) {
|
|||||||
console.trace(error);
|
console.trace(error);
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on('ytmd:volume-changed', (_, newVol) => {
|
ipcMain.on('ytmd:volume-changed', (_, newVolumeState: VolumeState) => {
|
||||||
player.volume = Number.parseFloat((newVol / 100).toFixed(2));
|
player.volume = newVolumeState.isMuted
|
||||||
|
? 0
|
||||||
|
: Number.parseFloat((newVolumeState.state / 100).toFixed(2));
|
||||||
});
|
});
|
||||||
|
|
||||||
player.on('volume', async (newVolume: number) => {
|
player.on('volume', async (newVolume: number) => {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
// This is used for to control the songs
|
// This is used for to control the songs
|
||||||
import { type BrowserWindow, ipcMain } from 'electron';
|
import { type BrowserWindow, ipcMain } from 'electron';
|
||||||
|
import { LikeType } from '@/types/datahost-get-state';
|
||||||
|
|
||||||
// see protocol-handler.ts
|
// see protocol-handler.ts
|
||||||
type ArgsType<T> = T | string[] | undefined;
|
type ArgsType<T> = T | string[] | undefined;
|
||||||
@ -42,8 +43,8 @@ export default (win: BrowserWindow) => {
|
|||||||
play: () => win.webContents.send('ytmd:play'),
|
play: () => win.webContents.send('ytmd:play'),
|
||||||
pause: () => win.webContents.send('ytmd:pause'),
|
pause: () => win.webContents.send('ytmd:pause'),
|
||||||
playPause: () => win.webContents.send('ytmd:toggle-play'),
|
playPause: () => win.webContents.send('ytmd:toggle-play'),
|
||||||
like: () => win.webContents.send('ytmd:update-like', 'LIKE'),
|
like: () => win.webContents.send('ytmd:update-like', LikeType.Like),
|
||||||
dislike: () => win.webContents.send('ytmd:update-like', 'DISLIKE'),
|
dislike: () => win.webContents.send('ytmd:update-like', LikeType.Dislike),
|
||||||
seekTo: (seconds: ArgsType<number>) => {
|
seekTo: (seconds: ArgsType<number>) => {
|
||||||
const secondsNumber = parseNumberFromArgsType(seconds);
|
const secondsNumber = parseNumberFromArgsType(seconds);
|
||||||
if (secondsNumber !== null) {
|
if (secondsNumber !== null) {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { singleton } from './decorators';
|
import { singleton } from './decorators';
|
||||||
|
|
||||||
import type { YoutubePlayer } from '@/types/youtube-player';
|
import type { YoutubePlayer } from '@/types/youtube-player';
|
||||||
import type { GetState } from '@/types/datahost-get-state';
|
import { LikeType, type GetState } from '@/types/datahost-get-state';
|
||||||
import type {
|
import type {
|
||||||
AlbumDetails,
|
AlbumDetails,
|
||||||
PlayerOverlays,
|
PlayerOverlays,
|
||||||
@ -79,12 +79,52 @@ export const setupRepeatChangedListener = singleton(() => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const mapLikeStatus = (status: string | null): LikeType =>
|
||||||
|
Object.values(LikeType).includes(status as LikeType)
|
||||||
|
? (status as LikeType)
|
||||||
|
: LikeType.Indifferent;
|
||||||
|
|
||||||
|
const LIKE_STATUS_ATTRIBUTE = 'like-status';
|
||||||
|
|
||||||
|
export const setupLikeChangedListener = singleton(() => {
|
||||||
|
const likeDislikeObserver = new MutationObserver((mutations) => {
|
||||||
|
window.ipcRenderer.send(
|
||||||
|
'ytmd:like-changed',
|
||||||
|
mapLikeStatus(
|
||||||
|
(mutations[0].target as HTMLElement)?.getAttribute?.(
|
||||||
|
LIKE_STATUS_ATTRIBUTE,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const likeButtonRenderer = document.querySelector('#like-button-renderer');
|
||||||
|
if (likeButtonRenderer) {
|
||||||
|
likeDislikeObserver.observe(likeButtonRenderer, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: [LIKE_STATUS_ATTRIBUTE],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit the initial value as well; as it's persistent between launches.
|
||||||
|
window.ipcRenderer.send(
|
||||||
|
'ytmd:like-changed',
|
||||||
|
mapLikeStatus(likeButtonRenderer.getAttribute?.(LIKE_STATUS_ATTRIBUTE)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export const setupVolumeChangedListener = singleton((api: YoutubePlayer) => {
|
export const setupVolumeChangedListener = singleton((api: YoutubePlayer) => {
|
||||||
document.querySelector('video')?.addEventListener('volumechange', () => {
|
document.querySelector('video')?.addEventListener('volumechange', () => {
|
||||||
window.ipcRenderer.send('ytmd:volume-changed', api.getVolume());
|
window.ipcRenderer.send('ytmd:volume-changed', {
|
||||||
|
state: api.getVolume(),
|
||||||
|
isMuted: api.isMuted(),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Emit the initial value as well; as it's persistent between launches.
|
// Emit the initial value as well; as it's persistent between launches.
|
||||||
window.ipcRenderer.send('ytmd:volume-changed', api.getVolume());
|
window.ipcRenderer.send('ytmd:volume-changed', {
|
||||||
|
state: api.getVolume(),
|
||||||
|
isMuted: api.isMuted(),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
export const setupShuffleChangedListener = singleton(() => {
|
export const setupShuffleChangedListener = singleton(() => {
|
||||||
@ -153,6 +193,10 @@ export default (api: YoutubePlayer) => {
|
|||||||
setupTimeChangedListener();
|
setupTimeChangedListener();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.ipcRenderer.on('ytmd:setup-like-changed-listener', () => {
|
||||||
|
setupLikeChangedListener();
|
||||||
|
});
|
||||||
|
|
||||||
window.ipcRenderer.on('ytmd:setup-repeat-changed-listener', () => {
|
window.ipcRenderer.on('ytmd:setup-repeat-changed-listener', () => {
|
||||||
setupRepeatChangedListener();
|
setupRepeatChangedListener();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -45,6 +45,11 @@ export enum LikeType {
|
|||||||
Like = 'LIKE',
|
Like = 'LIKE',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VolumeState {
|
||||||
|
state: number;
|
||||||
|
isMuted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MultiSelect {
|
export interface MultiSelect {
|
||||||
multiSelectedItems: Entities;
|
multiSelectedItems: Entities;
|
||||||
latestMultiSelectIndex: number;
|
latestMultiSelectIndex: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user