From 8b10872e83caad636fcede2124cd728b37707162 Mon Sep 17 00:00:00 2001 From: REWHEX Date: Fri, 5 Sep 2025 18:59:39 +0400 Subject: [PATCH] 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 Co-authored-by: JellyBrick --- src/plugins/api-server/backend/main.ts | 15 ++++-- .../api-server/backend/routes/control.ts | 53 +++++++++++++++---- src/plugins/api-server/backend/types.ts | 4 +- src/plugins/shortcuts/mpris.ts | 8 +-- src/providers/song-controls.ts | 5 +- src/providers/song-info-front.ts | 50 +++++++++++++++-- src/types/datahost-get-state.ts | 5 ++ 7 files changed, 117 insertions(+), 23 deletions(-) diff --git a/src/plugins/api-server/backend/main.ts b/src/plugins/api-server/backend/main.ts index c558c4ff..df731e2f 100644 --- a/src/plugins/api-server/backend/main.ts +++ b/src/plugins/api-server/backend/main.ts @@ -13,7 +13,11 @@ import { registerAuth, registerControl } from './routes'; import { type APIServerConfig, AuthStrategy } from '../config'; 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({ async start(ctx) { @@ -27,6 +31,7 @@ export const backend = createBackend({ ctx.ipc.on('ytmd:player-api-loaded', () => { ctx.ipc.send('ytmd:setup-time-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'); }); @@ -37,7 +42,7 @@ export const backend = createBackend({ ctx.ipc.on( 'ytmd:volume-changed', - (newVolume: number) => (this.volume = newVolume), + (newVolumeState: VolumeState) => (this.volumeState = newVolumeState), ); this.run(config.hostname, config.port); @@ -103,7 +108,11 @@ export const backend = createBackend({ backendCtx, () => this.songInfo, () => this.currentRepeatMode, - () => this.volume, + () => + backendCtx.window.webContents.executeJavaScript( + 'document.querySelector("#like-button-renderer")?.likeStatus', + ) as Promise, + () => this.volumeState, ); registerAuth(this.app, backendCtx); diff --git a/src/plugins/api-server/backend/routes/control.ts b/src/plugins/api-server/backend/routes/control.ts index 9755bab0..28790729 100644 --- a/src/plugins/api-server/backend/routes/control.ts +++ b/src/plugins/api-server/backend/routes/control.ts @@ -4,6 +4,12 @@ import { ipcMain } from 'electron'; import getSongControls from '@/providers/song-controls'; +import { + LikeType, + type RepeatMode, + type VolumeState, +} from '@/types/datahost-get-state'; + import { AddSongToQueueSchema, GoBackSchema, @@ -20,7 +26,6 @@ import { type ResponseSongInfo, } from '../scheme'; -import type { RepeatMode } from '@/types/datahost-get-state'; import type { SongInfo } from '@/providers/song-info'; import type { BackendContext } from '@/types/contexts'; 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({ method: 'post', path: `/api/${API_VERSION}/like`, @@ -274,6 +297,7 @@ const routes = { 'application/json': { schema: z.object({ state: z.number(), + isMuted: z.boolean(), }), }, }, @@ -526,12 +550,15 @@ const routes = { }), }; +type PromiseOrValue = T | Promise; + export const register = ( app: HonoApp, { window }: BackendContext, - songInfoGetter: () => SongInfo | undefined, - repeatModeGetter: () => RepeatMode | undefined, - volumeGetter: () => number | undefined, + songInfoGetter: () => PromiseOrValue, + repeatModeGetter: () => PromiseOrValue, + likeTypeGetter: () => PromiseOrValue, + volumeStateGetter: () => PromiseOrValue, ) => { const controller = getSongControls(window); @@ -565,6 +592,10 @@ export const register = ( ctx.status(204); 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) => { controller.like(); @@ -624,9 +655,9 @@ export const register = ( return ctx.body(null); }); - app.openapi(routes.repeatMode, (ctx) => { + app.openapi(routes.repeatMode, async (ctx) => { ctx.status(200); - return ctx.json({ mode: repeatModeGetter() ?? null }); + return ctx.json({ mode: (await repeatModeGetter()) ?? null }); }); app.openapi(routes.switchRepeat, (ctx) => { const { iteration } = ctx.req.valid('json'); @@ -642,9 +673,11 @@ export const register = ( ctx.status(204); return ctx.body(null); }); - app.openapi(routes.getVolumeState, (ctx) => { + app.openapi(routes.getVolumeState, async (ctx) => { ctx.status(200); - return ctx.json({ state: volumeGetter() ?? 0 }); + return ctx.json( + (await volumeStateGetter()) ?? { state: 0, isMuted: false }, + ); }); app.openapi(routes.setFullscreen, (ctx) => { const { state } = ctx.req.valid('json'); @@ -678,8 +711,8 @@ export const register = ( return ctx.json({ state: fullscreen }); }); - const songInfo = (ctx: Context) => { - const info = songInfoGetter(); + const songInfo = async (ctx: Context) => { + const info = await songInfoGetter(); if (!info) { ctx.status(204); diff --git a/src/plugins/api-server/backend/types.ts b/src/plugins/api-server/backend/types.ts index 691216db..ea820c59 100644 --- a/src/plugins/api-server/backend/types.ts +++ b/src/plugins/api-server/backend/types.ts @@ -1,9 +1,9 @@ import { type OpenAPIHono as Hono } from '@hono/zod-openapi'; import { type serve } from '@hono/node-server'; +import type { RepeatMode, VolumeState } from '@/types/datahost-get-state'; import type { BackendContext } from '@/types/contexts'; import type { SongInfo } from '@/providers/song-info'; -import type { RepeatMode } from '@/types/datahost-get-state'; import type { APIServerConfig } from '../config'; export type HonoApp = Hono; @@ -13,7 +13,7 @@ export type BackendType = { oldConfig?: APIServerConfig; songInfo?: SongInfo; currentRepeatMode?: RepeatMode; - volume?: number; + volumeState?: VolumeState; init: (ctx: BackendContext) => void; run: (hostname: string, port: number) => void; diff --git a/src/plugins/shortcuts/mpris.ts b/src/plugins/shortcuts/mpris.ts index 5d0fc929..0baf7be7 100644 --- a/src/plugins/shortcuts/mpris.ts +++ b/src/plugins/shortcuts/mpris.ts @@ -22,7 +22,7 @@ import getSongControls from '@/providers/song-controls'; import config from '@/config'; 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'; class YTPlayer extends MprisPlayer { @@ -305,8 +305,10 @@ function registerMPRIS(win: BrowserWindow) { console.trace(error); }); - ipcMain.on('ytmd:volume-changed', (_, newVol) => { - player.volume = Number.parseFloat((newVol / 100).toFixed(2)); + ipcMain.on('ytmd:volume-changed', (_, newVolumeState: VolumeState) => { + player.volume = newVolumeState.isMuted + ? 0 + : Number.parseFloat((newVolumeState.state / 100).toFixed(2)); }); player.on('volume', async (newVolume: number) => { diff --git a/src/providers/song-controls.ts b/src/providers/song-controls.ts index 59f915ba..03a6b886 100644 --- a/src/providers/song-controls.ts +++ b/src/providers/song-controls.ts @@ -1,5 +1,6 @@ // This is used for to control the songs import { type BrowserWindow, ipcMain } from 'electron'; +import { LikeType } from '@/types/datahost-get-state'; // see protocol-handler.ts type ArgsType = T | string[] | undefined; @@ -42,8 +43,8 @@ export default (win: BrowserWindow) => { 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'), + like: () => win.webContents.send('ytmd:update-like', LikeType.Like), + dislike: () => win.webContents.send('ytmd:update-like', LikeType.Dislike), seekTo: (seconds: ArgsType) => { const secondsNumber = parseNumberFromArgsType(seconds); if (secondsNumber !== null) { diff --git a/src/providers/song-info-front.ts b/src/providers/song-info-front.ts index 1bd89fe6..747f86b2 100644 --- a/src/providers/song-info-front.ts +++ b/src/providers/song-info-front.ts @@ -1,7 +1,7 @@ import { singleton } from './decorators'; 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 { AlbumDetails, 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) => { 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. - window.ipcRenderer.send('ytmd:volume-changed', api.getVolume()); + window.ipcRenderer.send('ytmd:volume-changed', { + state: api.getVolume(), + isMuted: api.isMuted(), + }); }); export const setupShuffleChangedListener = singleton(() => { @@ -153,6 +193,10 @@ export default (api: YoutubePlayer) => { setupTimeChangedListener(); }); + window.ipcRenderer.on('ytmd:setup-like-changed-listener', () => { + setupLikeChangedListener(); + }); + window.ipcRenderer.on('ytmd:setup-repeat-changed-listener', () => { setupRepeatChangedListener(); }); diff --git a/src/types/datahost-get-state.ts b/src/types/datahost-get-state.ts index 99600d01..9a4de26e 100644 --- a/src/types/datahost-get-state.ts +++ b/src/types/datahost-get-state.ts @@ -45,6 +45,11 @@ export enum LikeType { Like = 'LIKE', } +export interface VolumeState { + state: number; + isMuted: boolean; +} + export interface MultiSelect { multiSelectedItems: Entities; latestMultiSelectIndex: number;