From aae523989bcf6ad168683d9fe84371e8fe3b2178 Mon Sep 17 00:00:00 2001 From: Benjas333 <67520048+Benjas333@users.noreply.github.com> Date: Fri, 5 Sep 2025 11:54:07 -0600 Subject: [PATCH] feat(plugin): Custom output device plugin (#3789) Co-authored-by: Angelos Bouklis Co-authored-by: JellyBrick --- src/i18n/resources/en.json | 13 ++++ src/i18n/resources/es.json | 13 ++++ src/plugins/custom-output-device/index.ts | 54 +++++++++++++++ src/plugins/custom-output-device/renderer.ts | 70 ++++++++++++++++++++ 4 files changed, 150 insertions(+) create mode 100644 src/plugins/custom-output-device/index.ts create mode 100644 src/plugins/custom-output-device/renderer.ts diff --git a/src/i18n/resources/en.json b/src/i18n/resources/en.json index f7da4f6d..e7cf9704 100644 --- a/src/i18n/resources/en.json +++ b/src/i18n/resources/en.json @@ -421,6 +421,19 @@ } } }, + "custom-output-device": { + "description": "Configure a custom output media device for songs", + "menu": { + "device-selector": "Select Device" + }, + "name": "Custom Output Device", + "prompt": { + "device-selector": { + "label": "Choose the output media device to be used", + "title": "Select Output Device" + } + } + }, "disable-autoplay": { "description": "Makes song start in \"paused\" mode", "menu": { diff --git a/src/i18n/resources/es.json b/src/i18n/resources/es.json index 1e8dce73..461bd0ad 100644 --- a/src/i18n/resources/es.json +++ b/src/i18n/resources/es.json @@ -421,6 +421,19 @@ } } }, + "custom-output-device": { + "description": "Configura un dispositivo de salida de audio personalizado para las canciones", + "menu": { + "device-selector": "Seleccionar un dispositivo" + }, + "name": "Dispositivo de audio personalizado", + "prompt": { + "device-selector": { + "label": "Escoge el dispositivo de salida de audio que se va a usar", + "title": "Seleccionar un dispositivo de audio" + } + } + }, "disable-autoplay": { "description": "Hace que la canción comience en modo \"pausado\"", "menu": { diff --git a/src/plugins/custom-output-device/index.ts b/src/plugins/custom-output-device/index.ts new file mode 100644 index 00000000..0ef1e7c5 --- /dev/null +++ b/src/plugins/custom-output-device/index.ts @@ -0,0 +1,54 @@ +import prompt from 'custom-electron-prompt'; + +import { t } from '@/i18n'; +import promptOptions from '@/providers/prompt-options'; +import { createPlugin } from '@/utils'; +import { renderer } from './renderer'; + +export interface CustomOutputPluginConfig { + enabled: boolean; + output: string; + devices: Record; +} + +export default createPlugin({ + name: () => t('plugins.custom-output-device.name'), + description: () => t('plugins.custom-output-device.description'), + restartNeeded: true, + config: { + enabled: false, + output: 'default', + devices: {}, + } as CustomOutputPluginConfig, + menu: ({ setConfig, getConfig, window }) => { + const promptDeviceSelector = async () => { + const options = await getConfig(); + + const response = await prompt( + { + title: t('plugins.custom-output-device.prompt.device-selector.title'), + label: t('plugins.custom-output-device.prompt.device-selector.label'), + value: options.output || 'default', + type: 'select', + selectOptions: options.devices, + width: 500, + ...promptOptions(), + }, + window, + ).catch(console.error); + + if (!response) return; + options.output = response; + setConfig(options); + }; + + return [ + { + label: t('plugins.custom-output-device.menu.device-selector'), + click: promptDeviceSelector, + }, + ]; + }, + + renderer, +}); diff --git a/src/plugins/custom-output-device/renderer.ts b/src/plugins/custom-output-device/renderer.ts new file mode 100644 index 00000000..cb0e631b --- /dev/null +++ b/src/plugins/custom-output-device/renderer.ts @@ -0,0 +1,70 @@ +import { createRenderer } from '@/utils'; + +import type { YoutubePlayer } from '@/types/youtube-player'; +import type { RendererContext } from '@/types/contexts'; +import type { CustomOutputPluginConfig } from './index'; + +const updateDeviceList = async ( + context: RendererContext, +) => { + const newDevices: Record = {}; + const devices = await navigator.mediaDevices + .enumerateDevices() + .then((devices) => + devices.filter((device) => device.kind === 'audiooutput'), + ); + for (const device of devices) { + newDevices[device.deviceId] = device.label; + } + const options = await context.getConfig(); + options.devices = newDevices; + context.setConfig(options); +}; + +const updateSinkId = async (audioContext?: AudioContext, sinkId?: string) => { + if (!audioContext || !sinkId) return; + if (!('setSinkId' in audioContext)) return; + if (typeof audioContext.setSinkId !== 'function') return; + + await audioContext.setSinkId(sinkId); +}; + +export const renderer = createRenderer< + { + options?: CustomOutputPluginConfig; + audioContext?: AudioContext; + audioCanPlayHandler: (event: CustomEvent) => Promise; + }, + CustomOutputPluginConfig +>({ + async audioCanPlayHandler({ detail: { audioContext } }) { + this.audioContext = audioContext; + await updateSinkId(audioContext, this.options!.output); + }, + + async onPlayerApiReady(_: YoutubePlayer, context) { + this.options = await context.getConfig(); + await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); + navigator.mediaDevices.ondevicechange = async () => + await updateDeviceList(context); + + document.addEventListener('ytmd:audio-can-play', this.audioCanPlayHandler, { + once: true, + passive: true, + }); + await updateDeviceList(context); + }, + + stop() { + document.removeEventListener( + 'ytmd:audio-can-play', + this.audioCanPlayHandler, + ); + navigator.mediaDevices.ondevicechange = null; + }, + + async onConfigChange(config) { + this.options = config; + await updateSinkId(this.audioContext, config.output); + }, +});