diff --git a/src/loader/main.ts b/src/loader/main.ts index 60f898a4..a3a0100d 100644 --- a/src/loader/main.ts +++ b/src/loader/main.ts @@ -8,9 +8,9 @@ import { BackendContext } from '@/types/contexts'; import config from '@/config'; import { startPlugin, stopPlugin } from '@/utils'; -const loadedPluginMap: Record = {}; +const loadedPluginMap: Record> = {}; -const createContext = (id: string, win: BrowserWindow): BackendContext => ({ +const createContext = (id: string, win: BrowserWindow): BackendContext => ({ getConfig: () => deepmerge( mainPlugins[id].config, @@ -33,6 +33,9 @@ const createContext = (id: string, win: BrowserWindow): BackendContext => ({ listener(...args); }); }, + removeHandler: (event: string) => { + ipcMain.removeHandler(event); + } }, window: win, @@ -123,7 +126,7 @@ export const unloadAllMainPlugins = (win: BrowserWindow) => { } }; -export const getLoadedMainPlugin = (id: string): PluginDef | undefined => { +export const getLoadedMainPlugin = (id: string): PluginDef | undefined => { return loadedPluginMap[id]; }; diff --git a/src/loader/menu.ts b/src/loader/menu.ts index d74cc15d..873af06b 100644 --- a/src/loader/menu.ts +++ b/src/loader/menu.ts @@ -44,7 +44,7 @@ export const loadAllMenuPlugins = (win: BrowserWindow) => { const pluginConfigs = config.plugins.getPlugins(); for (const [pluginId, pluginDef] of Object.entries(allPlugins)) { - const config = deepmerge(pluginDef.config, pluginConfigs[pluginId] ?? {}) as PluginConfig; + const config = deepmerge(pluginDef.config, pluginConfigs[pluginId] ?? {}); if (config.enabled) { forceLoadMenuPlugin(pluginId, win); diff --git a/src/loader/renderer.ts b/src/loader/renderer.ts index 8c26ba82..d0029b3e 100644 --- a/src/loader/renderer.ts +++ b/src/loader/renderer.ts @@ -25,6 +25,9 @@ const createContext = (id: string): RendererContext listener(...args); }); }, + removeAllListeners: (event: string) => { + window.ipcRenderer.removeAllListeners(event); + } }, }); diff --git a/src/plugins/captions-selector/index.ts b/src/plugins/captions-selector/index.ts index 40782c0b..e21417c6 100644 --- a/src/plugins/captions-selector/index.ts +++ b/src/plugins/captions-selector/index.ts @@ -2,17 +2,57 @@ import prompt from 'custom-electron-prompt'; import promptOptions from '@/providers/prompt-options'; import { createPlugin } from '@/utils'; +import { ElementFromHtml } from '@/plugins/utils/renderer'; -export default createPlugin({ +import CaptionsSettingsButtonHTML from './templates/captions-settings-template.html?raw'; + +import { YoutubePlayer } from '@/types/youtube-player'; + +interface LanguageOptions { + displayName: string; + id: string | null; + is_default: boolean; + is_servable: boolean; + is_translateable: boolean; + kind: string; + languageCode: string; // 2 length + languageName: string; + name: string | null; + vss_id: string; +} + +interface CaptionsSelectorConfig { + enabled: boolean; + disableCaptions: boolean; + autoload: boolean; + lastCaptionsCode: string; +} + +const captionsSettingsButton = ElementFromHtml(CaptionsSettingsButtonHTML); + +export default createPlugin< + unknown, + unknown, + { + captionTrackList: LanguageOptions[] | null; + api: YoutubePlayer | null; + config: CaptionsSelectorConfig | null; + setConfig: ((config: Partial) => void); + videoChangeListener: (() => void); + captionsButtonClickListener: (() => void); + }, + CaptionsSelectorConfig +>({ name: 'Captions Selector', config: { + enabled: false, disableCaptions: false, autoload: false, lastCaptionsCode: '', }, - menu({ getConfig, setConfig }) { - const config = getConfig(); + async menu({ getConfig, setConfig }) { + const config = await getConfig(); return [ { label: 'Automatically select last used caption', @@ -33,22 +73,108 @@ export default createPlugin({ ]; }, - backend({ ipc: { handle }, win }) { - handle( - 'captionsSelector', - async (_, captionLabels: Record, currentIndex: string) => - await prompt( - { - title: 'Choose Caption', - label: `Current Caption: ${captionLabels[currentIndex] || 'None'}`, - type: 'select', - value: currentIndex, - selectOptions: captionLabels, - resizable: true, - ...promptOptions(), - }, - win, - ), - ); + backend: { + start({ ipc: { handle }, window }) { + handle( + 'captionsSelector', + async (captionLabels: Record, currentIndex: string) => + await prompt( + { + title: 'Choose Caption', + label: `Current Caption: ${captionLabels[currentIndex] || 'None'}`, + type: 'select', + value: currentIndex, + selectOptions: captionLabels, + resizable: true, + ...promptOptions(), + }, + window, + ), + ); + }, + stop({ ipc: { removeHandler } }) { + removeHandler('captionsSelector'); + } + }, + + renderer: { + captionTrackList: null, + api: null, + config: null, + setConfig: () => {}, + async videoChangeListener() { + if (this.captionTrackList?.length) { + const currentCaptionTrack = this.api!.getOption('captions', 'track'); + let currentIndex = currentCaptionTrack + ? this.captionTrackList.indexOf(this.captionTrackList.find((track) => track.languageCode === currentCaptionTrack.languageCode)!) + : null; + + const captionLabels = [ + ...this.captionTrackList.map((track) => track.displayName), + 'None', + ]; + + currentIndex = await window.ipcRenderer.invoke('captionsSelector', captionLabels, currentIndex) as number; + if (currentIndex === null) { + return; + } + + const newCaptions = this.captionTrackList[currentIndex]; + this.setConfig({ lastCaptionsCode: newCaptions?.languageCode }); + if (newCaptions) { + this.api?.setOption('captions', 'track', { languageCode: newCaptions.languageCode }); + } else { + this.api?.setOption('captions', 'track', {}); + } + + setTimeout(() => this.api?.playVideo()); + } + }, + captionsButtonClickListener() { + if (this.config!.disableCaptions) { + setTimeout(() => this.api!.unloadModule('captions'), 100); + captionsSettingsButton.style.display = 'none'; + return; + } + + this.api!.loadModule('captions'); + + setTimeout(() => { + this.captionTrackList = this.api!.getOption('captions', 'tracklist') ?? []; + + if (this.config!.autoload && this.config!.lastCaptionsCode) { + this.api?.setOption('captions', 'track', { + languageCode: this.config!.lastCaptionsCode, + }); + } + + captionsSettingsButton.style.display = this.captionTrackList?.length + ? 'inline-block' + : 'none'; + }, 250); + }, + async start({ getConfig, setConfig }) { + this.config = await getConfig(); + this.setConfig = setConfig; + }, + stop() { + document.querySelector('.right-controls-buttons')?.removeChild(captionsSettingsButton); + document.querySelector('#movie_player')?.unloadModule('captions'); + document.querySelector('video')?.removeEventListener('srcChanged', this.videoChangeListener); + captionsSettingsButton.removeEventListener('click', this.captionsButtonClickListener); + }, + onPlayerApiReady(playerApi) { + this.api = playerApi; + + document.querySelector('.right-controls-buttons')?.append(captionsSettingsButton); + + this.captionTrackList = this.api.getOption('captions', 'tracklist') ?? []; + + document.querySelector('video')?.addEventListener('srcChanged', this.videoChangeListener); + captionsSettingsButton.addEventListener('click', this.captionsButtonClickListener); + }, + onConfigChange(newConfig) { + this.config = newConfig; + }, }, }); diff --git a/src/plugins/captions-selector/renderer.ts b/src/plugins/captions-selector/renderer.ts deleted file mode 100644 index fe709a0d..00000000 --- a/src/plugins/captions-selector/renderer.ts +++ /dev/null @@ -1,110 +0,0 @@ -import CaptionsSettingsButtonHTML from './templates/captions-settings-template.html?raw'; - -import builder from './index'; - -import { ElementFromHtml } from '../utils/renderer'; - -import type { YoutubePlayer } from '../../types/youtube-player'; - -interface LanguageOptions { - displayName: string; - id: string | null; - is_default: boolean; - is_servable: boolean; - is_translateable: boolean; - kind: string; - languageCode: string; // 2 length - languageName: string; - name: string | null; - vss_id: string; -} - -const $ = (selector: string): Element => document.querySelector(selector)!; - -const captionsSettingsButton = ElementFromHtml(CaptionsSettingsButtonHTML); - -export default builder.createRenderer(({ getConfig, setConfig }) => { - let config: Awaited>; - let captionTrackList: LanguageOptions[] | null = null; - let api: YoutubePlayer; - - const videoChangeListener = () => { - if (config.disableCaptions) { - setTimeout(() => api.unloadModule('captions'), 100); - captionsSettingsButton.style.display = 'none'; - return; - } - - api.loadModule('captions'); - - setTimeout(() => { - captionTrackList = api.getOption('captions', 'tracklist') ?? []; - - if (config.autoload && config.lastCaptionsCode) { - api.setOption('captions', 'track', { - languageCode: config.lastCaptionsCode, - }); - } - - captionsSettingsButton.style.display = captionTrackList?.length - ? 'inline-block' - : 'none'; - }, 250); - }; - - const captionsButtonClickListener = async () => { - if (captionTrackList?.length) { - const currentCaptionTrack = api.getOption('captions', 'track')!; - let currentIndex = currentCaptionTrack - ? captionTrackList.indexOf(captionTrackList.find((track) => track.languageCode === currentCaptionTrack.languageCode)!) - : null; - - const captionLabels = [ - ...captionTrackList.map((track) => track.displayName), - 'None', - ]; - - currentIndex = await window.ipcRenderer.invoke('captionsSelector', captionLabels, currentIndex) as number; - if (currentIndex === null) { - return; - } - - const newCaptions = captionTrackList[currentIndex]; - setConfig({ lastCaptionsCode: newCaptions?.languageCode }); - if (newCaptions) { - api.setOption('captions', 'track', { languageCode: newCaptions.languageCode }); - } else { - api.setOption('captions', 'track', {}); - } - - setTimeout(() => api.playVideo()); - } - }; - - const removeListener = () => { - $('.right-controls-buttons').removeChild(captionsSettingsButton); - $('#movie_player').unloadModule('captions'); - }; - - return { - async onLoad() { - config = await getConfig(); - }, - onPlayerApiReady(playerApi) { - api = playerApi; - - $('.right-controls-buttons').append(captionsSettingsButton); - - captionTrackList = api.getOption('captions', 'tracklist') ?? []; - - $('video').addEventListener('srcChanged', videoChangeListener); - captionsSettingsButton.addEventListener('click', captionsButtonClickListener); - }, - onUnload() { - removeListener(); - }, - onConfigChange(newConfig) { - config = newConfig; - } - }; -}); diff --git a/src/plugins/compact-sidebar/index.ts b/src/plugins/compact-sidebar/index.ts index da8035f6..7119ab0b 100644 --- a/src/plugins/compact-sidebar/index.ts +++ b/src/plugins/compact-sidebar/index.ts @@ -1,17 +1,38 @@ -import { createPluginBuilder } from '../utils/builder'; +import { createPlugin } from '@/utils'; -const builder = createPluginBuilder('compact-sidebar', { +export default createPlugin< + unknown, + unknown, + { + getCompactSidebar: () => HTMLElement | null; + isCompactSidebarDisabled: () => boolean; + } +>({ name: 'Compact Sidebar', restartNeeded: false, config: { enabled: false, }, + renderer: { + getCompactSidebar: () => document.querySelector('#mini-guide'), + isCompactSidebarDisabled() { + const compactSidebar = this.getCompactSidebar(); + return compactSidebar === null || window.getComputedStyle(compactSidebar).display === 'none'; + }, + start() { + if (this.isCompactSidebarDisabled()) { + document.querySelector('#button')?.click(); + } + }, + stop() { + if (this.isCompactSidebarDisabled()) { + document.querySelector('#button')?.click(); + } + }, + onConfigChange() { + if (this.isCompactSidebarDisabled()) { + document.querySelector('#button')?.click(); + } + } + }, }); - -export default builder; - -declare global { - interface PluginBuilderList { - [builder.id]: typeof builder; - } -} diff --git a/src/plugins/compact-sidebar/renderer.ts b/src/plugins/compact-sidebar/renderer.ts deleted file mode 100644 index c2268aa8..00000000 --- a/src/plugins/compact-sidebar/renderer.ts +++ /dev/null @@ -1,27 +0,0 @@ -import builder from './index'; - -export default builder.createRenderer(() => { - const getCompactSidebar = () => document.querySelector('#mini-guide'); - const isCompactSidebarDisabled = () => { - const compactSidebar = getCompactSidebar(); - return compactSidebar === null || window.getComputedStyle(compactSidebar).display === 'none'; - }; - - return { - onLoad() { - if (isCompactSidebarDisabled()) { - document.querySelector('#button')?.click(); - } - }, - onUnload() { - if (!isCompactSidebarDisabled()) { - document.querySelector('#button')?.click(); - } - }, - onConfigChange() { - if (isCompactSidebarDisabled()) { - document.querySelector('#button')?.click(); - } - } - }; -}); diff --git a/src/plugins/crossfade/index.ts b/src/plugins/crossfade/index.ts index 4ce07055..ba728e75 100644 --- a/src/plugins/crossfade/index.ts +++ b/src/plugins/crossfade/index.ts @@ -1,4 +1,16 @@ -import { createPluginBuilder } from '../utils/builder'; +import { Innertube } from 'youtubei.js'; + +import { BrowserWindow } from 'electron'; +import prompt from 'custom-electron-prompt'; + +import { Howl } from 'howler'; + +import promptOptions from '@/providers/prompt-options'; +import { getNetFetchAsFetch } from '@/plugins/utils/main'; +import { createPlugin } from '@/utils'; +import { VolumeFader } from '@/plugins/crossfade/fader'; + +import type { RendererContext } from '@/types/contexts'; export type CrossfadePluginConfig = { enabled: boolean; @@ -8,7 +20,15 @@ export type CrossfadePluginConfig = { fadeScaling: 'linear' | 'logarithmic' | number; } -const builder = createPluginBuilder('crossfade', { +export default createPlugin< + unknown, + unknown, + { + config: CrossfadePluginConfig | null; + ipc: RendererContext['ipc'] | null; + }, + CrossfadePluginConfig +>({ name: 'Crossfade [beta]', restartNeeded: true, config: { @@ -38,13 +58,241 @@ const builder = createPluginBuilder('crossfade', { * @default 'linear' */ fadeScaling: 'linear', - } as CrossfadePluginConfig, -}); + }, + menu({ window, getConfig, setConfig }) { + const promptCrossfadeValues = async (win: BrowserWindow, options: CrossfadePluginConfig): Promise | undefined> => { + const res = await prompt( + { + title: 'Crossfade Options', + type: 'multiInput', + multiInputOptions: [ + { + label: 'Fade in duration (ms)', + value: options.fadeInDuration, + inputAttrs: { + type: 'number', + required: true, + min: '0', + step: '100', + }, + }, + { + label: 'Fade out duration (ms)', + value: options.fadeOutDuration, + inputAttrs: { + type: 'number', + required: true, + min: '0', + step: '100', + }, + }, + { + label: 'Crossfade x seconds before end', + value: + options.secondsBeforeEnd, + inputAttrs: { + type: 'number', + required: true, + min: '0', + }, + }, + { + label: 'Fade scaling', + selectOptions: { linear: 'Linear', logarithmic: 'Logarithmic' }, + value: options.fadeScaling, + }, + ], + resizable: true, + height: 360, + ...promptOptions(), + }, + win, + ).catch(console.error); -export default builder; + if (!res) { + return undefined; + } -declare global { - interface PluginBuilderList { - [builder.id]: typeof builder; + let fadeScaling: 'linear' | 'logarithmic' | number; + if (res[3] === 'linear' || res[3] === 'logarithmic') { + fadeScaling = res[3]; + } else if (isFinite(Number(res[3]))) { + fadeScaling = Number(res[3]); + } else { + fadeScaling = options.fadeScaling; + } + + return { + fadeInDuration: Number(res[0]), + fadeOutDuration: Number(res[1]), + secondsBeforeEnd: Number(res[2]), + fadeScaling, + }; + }; + + return [ + { + label: 'Advanced', + async click() { + const newOptions = await promptCrossfadeValues(window, await getConfig()); + if (newOptions) { + setConfig(newOptions); + } + }, + }, + ]; + }, + + async backend({ ipc }) { + const yt = await Innertube.create({ + fetch: getNetFetchAsFetch(), + }); + + ipc.handle('audio-url', async (videoID: string) => { + const info = await yt.getBasicInfo(videoID); + return info.streaming_data?.formats[0].decipher(yt.session.player); + }); + }, + + renderer: { + config: null, + ipc: null, + + start({ ipc }) { + this.ipc = ipc; + }, + onConfigChange(newConfig) { + this.config = newConfig; + }, + onPlayerApiReady() { + let transitionAudio: Howl; // Howler audio used to fade out the current music + let firstVideo = true; + let waitForTransition: Promise; + + const getStreamURL = async (videoID: string): Promise => this.ipc?.invoke('audio-url', videoID); + + const getVideoIDFromURL = (url: string) => new URLSearchParams(url.split('?')?.at(-1)).get('v'); + + const isReadyToCrossfade = () => transitionAudio && transitionAudio.state() === 'loaded'; + + const watchVideoIDChanges = (cb: (id: string) => void) => { + window.navigation.addEventListener('navigate', (event) => { + const currentVideoID = getVideoIDFromURL( + (event.currentTarget as Navigation).currentEntry?.url ?? '', + ); + const nextVideoID = getVideoIDFromURL(event.destination.url ?? ''); + + if ( + nextVideoID + && currentVideoID + && (firstVideo || nextVideoID !== currentVideoID) + ) { + if (isReadyToCrossfade()) { + crossfade(() => { + cb(nextVideoID); + }); + } else { + cb(nextVideoID); + firstVideo = false; + } + } + }); + }; + + const createAudioForCrossfade = (url: string) => { + if (transitionAudio) { + transitionAudio.unload(); + } + + transitionAudio = new Howl({ + src: url, + html5: true, + volume: 0, + }); + syncVideoWithTransitionAudio(); + }; + + const syncVideoWithTransitionAudio = () => { + const video = document.querySelector('video')!; + + const videoFader = new VolumeFader(video, { + fadeScaling: this.config?.fadeScaling, + fadeDuration: this.config?.fadeInDuration, + }); + + transitionAudio.play(); + transitionAudio.seek(video.currentTime); + + video.addEventListener('seeking', () => { + transitionAudio.seek(video.currentTime); + }); + + video.addEventListener('pause', () => { + transitionAudio.pause(); + }); + + video.addEventListener('play', () => { + transitionAudio.play(); + transitionAudio.seek(video.currentTime); + + // Fade in + const videoVolume = video.volume; + video.volume = 0; + videoFader.fadeTo(videoVolume); + }); + + // Exit just before the end for the transition + const transitionBeforeEnd = () => { + if ( + video.currentTime >= video.duration - this.config!.secondsBeforeEnd + && isReadyToCrossfade() + ) { + video.removeEventListener('timeupdate', transitionBeforeEnd); + + // Go to next video - XXX: does not support "repeat 1" mode + document.querySelector('.next-button')?.click(); + } + }; + + video.addEventListener('timeupdate', transitionBeforeEnd); + }; + + const crossfade = (cb: () => void) => { + if (!isReadyToCrossfade()) { + cb(); + return; + } + + let resolveTransition: () => void; + waitForTransition = new Promise((resolve) => { + resolveTransition = resolve; + }); + + const video = document.querySelector('video')!; + + const fader = new VolumeFader(transitionAudio._sounds[0]._node, { + initialVolume: video.volume, + fadeScaling: this.config?.fadeScaling, + fadeDuration: this.config?.fadeOutDuration, + }); + + // Fade out the music + video.volume = 0; + fader.fadeOut(() => { + resolveTransition(); + cb(); + }); + }; + + watchVideoIDChanges(async (videoID) => { + await waitForTransition; + const url = await getStreamURL(videoID); + if (!url) { + return; + } + + createAudioForCrossfade(url); + }); + } } -} +}); diff --git a/src/plugins/crossfade/main.ts b/src/plugins/crossfade/main.ts deleted file mode 100644 index a2d69678..00000000 --- a/src/plugins/crossfade/main.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Innertube } from 'youtubei.js'; - -import builder from './index'; - -import { getNetFetchAsFetch } from '../utils/main'; - -export default builder.createMain(({ handle }) => ({ - async onLoad() { - const yt = await Innertube.create({ - fetch: getNetFetchAsFetch(), - }); - - handle('audio-url', async (_, videoID: string) => { - const info = await yt.getBasicInfo(videoID); - return info.streaming_data?.formats[0].decipher(yt.session.player); - }); - } -})); diff --git a/src/plugins/crossfade/menu.ts b/src/plugins/crossfade/menu.ts deleted file mode 100644 index ac92d717..00000000 --- a/src/plugins/crossfade/menu.ts +++ /dev/null @@ -1,91 +0,0 @@ -import prompt from 'custom-electron-prompt'; - -import { BrowserWindow } from 'electron'; - -import builder, { CrossfadePluginConfig } from './index'; - -import promptOptions from '../../providers/prompt-options'; - -export default builder.createMenu(({ window, getConfig, setConfig }) => { - const promptCrossfadeValues = async (win: BrowserWindow, options: CrossfadePluginConfig): Promise | undefined> => { - const res = await prompt( - { - title: 'Crossfade Options', - type: 'multiInput', - multiInputOptions: [ - { - label: 'Fade in duration (ms)', - value: options.fadeInDuration, - inputAttrs: { - type: 'number', - required: true, - min: '0', - step: '100', - }, - }, - { - label: 'Fade out duration (ms)', - value: options.fadeOutDuration, - inputAttrs: { - type: 'number', - required: true, - min: '0', - step: '100', - }, - }, - { - label: 'Crossfade x seconds before end', - value: - options.secondsBeforeEnd, - inputAttrs: { - type: 'number', - required: true, - min: '0', - }, - }, - { - label: 'Fade scaling', - selectOptions: { linear: 'Linear', logarithmic: 'Logarithmic' }, - value: options.fadeScaling, - }, - ], - resizable: true, - height: 360, - ...promptOptions(), - }, - win, - ).catch(console.error); - - if (!res) { - return undefined; - } - - let fadeScaling: 'linear' | 'logarithmic' | number; - if (res[3] === 'linear' || res[3] === 'logarithmic') { - fadeScaling = res[3]; - } else if (isFinite(Number(res[3]))) { - fadeScaling = Number(res[3]); - } else { - fadeScaling = options.fadeScaling; - } - - return { - fadeInDuration: Number(res[0]), - fadeOutDuration: Number(res[1]), - secondsBeforeEnd: Number(res[2]), - fadeScaling, - }; - }; - - return [ - { - label: 'Advanced', - async click() { - const newOptions = await promptCrossfadeValues(window, await getConfig()); - if (newOptions) { - setConfig(newOptions); - } - }, - }, - ]; -}); diff --git a/src/plugins/crossfade/renderer.ts b/src/plugins/crossfade/renderer.ts deleted file mode 100644 index 520e365e..00000000 --- a/src/plugins/crossfade/renderer.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { Howl } from 'howler'; - -// Extracted from https://github.com/bitfasching/VolumeFader -import { VolumeFader } from './fader'; - -import builder, { CrossfadePluginConfig } from './index'; - -export default builder.createRenderer(({ getConfig, invoke }) => { - let config: CrossfadePluginConfig; - - let transitionAudio: Howl; // Howler audio used to fade out the current music - let firstVideo = true; - let waitForTransition: Promise; - - const getStreamURL = async (videoID: string): Promise => invoke('audio-url', videoID); - - const getVideoIDFromURL = (url: string) => new URLSearchParams(url.split('?')?.at(-1)).get('v'); - - const isReadyToCrossfade = () => transitionAudio && transitionAudio.state() === 'loaded'; - - const watchVideoIDChanges = (cb: (id: string) => void) => { - window.navigation.addEventListener('navigate', (event) => { - const currentVideoID = getVideoIDFromURL( - (event.currentTarget as Navigation).currentEntry?.url ?? '', - ); - const nextVideoID = getVideoIDFromURL(event.destination.url ?? ''); - - if ( - nextVideoID - && currentVideoID - && (firstVideo || nextVideoID !== currentVideoID) - ) { - if (isReadyToCrossfade()) { - crossfade(() => { - cb(nextVideoID); - }); - } else { - cb(nextVideoID); - firstVideo = false; - } - } - }); - }; - - const createAudioForCrossfade = (url: string) => { - if (transitionAudio) { - transitionAudio.unload(); - } - - transitionAudio = new Howl({ - src: url, - html5: true, - volume: 0, - }); - syncVideoWithTransitionAudio(); - }; - - const syncVideoWithTransitionAudio = () => { - const video = document.querySelector('video')!; - - const videoFader = new VolumeFader(video, { - fadeScaling: config.fadeScaling, - fadeDuration: config.fadeInDuration, - }); - - transitionAudio.play(); - transitionAudio.seek(video.currentTime); - - video.addEventListener('seeking', () => { - transitionAudio.seek(video.currentTime); - }); - - video.addEventListener('pause', () => { - transitionAudio.pause(); - }); - - video.addEventListener('play', () => { - transitionAudio.play(); - transitionAudio.seek(video.currentTime); - - // Fade in - const videoVolume = video.volume; - video.volume = 0; - videoFader.fadeTo(videoVolume); - }); - - // Exit just before the end for the transition - const transitionBeforeEnd = () => { - if ( - video.currentTime >= video.duration - config.secondsBeforeEnd - && isReadyToCrossfade() - ) { - video.removeEventListener('timeupdate', transitionBeforeEnd); - - // Go to next video - XXX: does not support "repeat 1" mode - document.querySelector('.next-button')?.click(); - } - }; - - video.addEventListener('timeupdate', transitionBeforeEnd); - }; - - const onApiLoaded = () => { - watchVideoIDChanges(async (videoID) => { - await waitForTransition; - const url = await getStreamURL(videoID); - if (!url) { - return; - } - - createAudioForCrossfade(url); - }); - }; - - const crossfade = (cb: () => void) => { - if (!isReadyToCrossfade()) { - cb(); - return; - } - - let resolveTransition: () => void; - waitForTransition = new Promise((resolve) => { - resolveTransition = resolve; - }); - - const video = document.querySelector('video')!; - - const fader = new VolumeFader(transitionAudio._sounds[0]._node, { - initialVolume: video.volume, - fadeScaling: config.fadeScaling, - fadeDuration: config.fadeOutDuration, - }); - - // Fade out the music - video.volume = 0; - fader.fadeOut(() => { - resolveTransition(); - cb(); - }); - }; - - return { - async onLoad() { - config = await getConfig(); - }, - onPlayerApiReady() { - onApiLoaded(); - }, - onConfigChange(newConfig) { - config = newConfig; - }, - }; -}); diff --git a/src/plugins/disable-autoplay/index.ts b/src/plugins/disable-autoplay/index.ts index 06187017..e779586f 100644 --- a/src/plugins/disable-autoplay/index.ts +++ b/src/plugins/disable-autoplay/index.ts @@ -1,23 +1,74 @@ -import { createPluginBuilder } from '../utils/builder'; +import { createPlugin } from '@/utils'; +import {YoutubePlayer} from "@/types/youtube-player"; export type DisableAutoPlayPluginConfig = { enabled: boolean; applyOnce: boolean; } -const builder = createPluginBuilder('disable-autoplay', { +export default createPlugin< + unknown, + unknown, + { + config: DisableAutoPlayPluginConfig | null; + api: YoutubePlayer | null; + eventListener: (name: string) => void; + timeUpdateListener: (e: Event) => void; + }, + DisableAutoPlayPluginConfig +>({ name: 'Disable Autoplay', restartNeeded: false, config: { enabled: false, applyOnce: false, - } as DisableAutoPlayPluginConfig, + }, + menu: async ({ getConfig, setConfig }) => { + const config = await getConfig(); + + return [ + { + label: 'Applies only on startup', + type: 'checkbox', + checked: config.applyOnce, + async click() { + const nowConfig = await getConfig(); + setConfig({ + applyOnce: !nowConfig.applyOnce, + }); + }, + }, + ]; + }, + renderer: { + config: null, + api: null, + eventListener(name: string) { + if (this.config?.applyOnce) { + this.api?.removeEventListener('videodatachange', this.eventListener); + } + + if (name === 'dataloaded') { + this.api?.pauseVideo(); + document.querySelector('video')?.addEventListener('timeupdate', this.timeUpdateListener, { once: true }); + } + }, + timeUpdateListener(e: Event) { + if (e.target instanceof HTMLVideoElement) { + e.target.pause(); + } + }, + onPlayerApiReady(api) { + this.api = api; + + api.addEventListener('videodatachange', this.eventListener); + }, + stop() { + this.api?.removeEventListener('videodatachange', this.eventListener); + }, + onConfigChange(newConfig) { + this.config = newConfig; + } + } }); -export default builder; - -declare global { - interface PluginBuilderList { - [builder.id]: typeof builder; - } -} diff --git a/src/plugins/disable-autoplay/menu.ts b/src/plugins/disable-autoplay/menu.ts deleted file mode 100644 index 2fb132bf..00000000 --- a/src/plugins/disable-autoplay/menu.ts +++ /dev/null @@ -1,19 +0,0 @@ -import builder from './index'; - -export default builder.createMenu(async ({ getConfig, setConfig }) => { - const config = await getConfig(); - - return [ - { - label: 'Applies only on startup', - type: 'checkbox', - checked: config.applyOnce, - async click() { - const nowConfig = await getConfig(); - setConfig({ - applyOnce: !nowConfig.applyOnce, - }); - }, - }, - ]; -}); diff --git a/src/plugins/disable-autoplay/renderer.ts b/src/plugins/disable-autoplay/renderer.ts deleted file mode 100644 index c9557dc3..00000000 --- a/src/plugins/disable-autoplay/renderer.ts +++ /dev/null @@ -1,42 +0,0 @@ -import builder from './index'; - -import type { YoutubePlayer } from '../../types/youtube-player'; - -export default builder.createRenderer(({ getConfig }) => { - let config: Awaited>; - - let apiEvent: YoutubePlayer; - - const timeUpdateListener = (e: Event) => { - if (e.target instanceof HTMLVideoElement) { - e.target.pause(); - } - }; - - const eventListener = async (name: string) => { - if (config.applyOnce) { - apiEvent.removeEventListener('videodatachange', eventListener); - } - - if (name === 'dataloaded') { - apiEvent.pauseVideo(); - document.querySelector('video')?.addEventListener('timeupdate', timeUpdateListener, { once: true }); - } - }; - - return { - async onPlayerApiReady(api) { - config = await getConfig(); - - apiEvent = api; - - apiEvent.addEventListener('videodatachange', eventListener); - }, - onUnload() { - apiEvent.removeEventListener('videodatachange', eventListener); - }, - onConfigChange(newConfig) { - config = newConfig; - } - }; -}); diff --git a/src/plugins/discord/index.ts b/src/plugins/discord/index.ts index cd07454c..bfb3e4c7 100644 --- a/src/plugins/discord/index.ts +++ b/src/plugins/discord/index.ts @@ -1,4 +1,6 @@ -import { createPluginBuilder } from '../utils/builder'; +import { createPlugin } from '@/utils'; +import { onLoad, onUnload } from '@/plugins/discord/main'; +import {onMenu} from "@/plugins/discord/menu"; export type DiscordPluginConfig = { enabled: boolean; @@ -32,7 +34,7 @@ export type DiscordPluginConfig = { hideDurationLeft: boolean; } -const builder = createPluginBuilder('discord', { +export default createPlugin({ name: 'Discord Rich Presence', restartNeeded: false, config: { @@ -44,12 +46,12 @@ const builder = createPluginBuilder('discord', { hideGitHubButton: false, hideDurationLeft: false, } as DiscordPluginConfig, + menu: onMenu, + backend: { + async start({ window, getConfig }) { + await onLoad(window, await getConfig()); + }, + stop: onUnload, + } }); -export default builder; - -declare global { - interface PluginBuilderList { - [builder.id]: typeof builder; - } -} diff --git a/src/plugins/discord/main.ts b/src/plugins/discord/main.ts index 4dbab833..916f972b 100644 --- a/src/plugins/discord/main.ts +++ b/src/plugins/discord/main.ts @@ -4,10 +4,11 @@ import { dev } from 'electron-is'; import { SetActivity } from '@xhayper/discord-rpc/dist/structures/ClientUser'; -import builder from './index'; - import registerCallback, { type SongInfoCallback, type SongInfo } from '../../providers/song-info'; +import type { DiscordPluginConfig } from './index'; + + // Application ID registered by @Zo-Bro-23 const clientId = '1043858434585526382'; @@ -92,135 +93,6 @@ export const connect = (showError = false) => { let clearActivity: NodeJS.Timeout | undefined; let updateActivity: SongInfoCallback; - -export default builder.createMain(({ getConfig }) => { - return { - async onLoad(win) { - const options = await getConfig(); - - info.rpc.on('connected', () => { - if (dev()) { - console.log('discord connected'); - } - - for (const cb of refreshCallbacks) { - cb(); - } - }); - - info.rpc.on('ready', () => { - info.ready = true; - if (info.lastSongInfo) { - updateActivity(info.lastSongInfo); - } - }); - - info.rpc.on('disconnected', () => { - resetInfo(); - - if (info.autoReconnect) { - connectTimeout(); - } - }); - - info.autoReconnect = options.autoReconnect; - - window = win; - // We get multiple events - // Next song: PAUSE(n), PAUSE(n+1), PLAY(n+1) - // Skip time: PAUSE(N), PLAY(N) - updateActivity = (songInfo) => { - if (songInfo.title.length === 0 && songInfo.artist.length === 0) { - return; - } - - info.lastSongInfo = songInfo; - - // Stop the clear activity timout - clearTimeout(clearActivity); - - // Stop early if discord connection is not ready - // do this after clearTimeout to avoid unexpected clears - if (!info.rpc || !info.ready) { - return; - } - - // Clear directly if timeout is 0 - if (songInfo.isPaused && options.activityTimoutEnabled && options.activityTimoutTime === 0) { - info.rpc.user?.clearActivity().catch(console.error); - return; - } - - // Song information changed, so lets update the rich presence - // @see https://discord.com/developers/docs/topics/gateway#activity-object - // not all options are transfered through https://github.com/discordjs/RPC/blob/6f83d8d812c87cb7ae22064acd132600407d7d05/src/client.js#L518-530 - const hangulFillerUnicodeCharacter = '\u3164'; // This is an empty character - if (songInfo.title.length < 2) { - songInfo.title += hangulFillerUnicodeCharacter.repeat(2 - songInfo.title.length); - } - if (songInfo.artist.length < 2) { - songInfo.artist += hangulFillerUnicodeCharacter.repeat(2 - songInfo.title.length); - } - - const activityInfo: SetActivity = { - details: songInfo.title, - state: songInfo.artist, - largeImageKey: songInfo.imageSrc ?? '', - largeImageText: songInfo.album ?? '', - buttons: [ - ...(options.playOnYouTubeMusic ? [{ label: 'Play on YouTube Music', url: songInfo.url ?? '' }] : []), - ...(options.hideGitHubButton ? [] : [{ label: 'View App On GitHub', url: 'https://github.com/th-ch/youtube-music' }]), - ], - }; - - if (songInfo.isPaused) { - // Add a paused icon to show that the song is paused - activityInfo.smallImageKey = 'paused'; - activityInfo.smallImageText = 'Paused'; - // Set start the timer so the activity gets cleared after a while if enabled - if (options.activityTimoutEnabled) { - clearActivity = setTimeout(() => info.rpc.user?.clearActivity().catch(console.error), options.activityTimoutTime ?? 10_000); - } - } else if (!options.hideDurationLeft) { - // Add the start and end time of the song - const songStartTime = Date.now() - ((songInfo.elapsedSeconds ?? 0) * 1000); - activityInfo.startTimestamp = songStartTime; - activityInfo.endTimestamp - = songStartTime + (songInfo.songDuration * 1000); - } - - info.rpc.user?.setActivity(activityInfo).catch(console.error); - }; - - // If the page is ready, register the callback - win.once('ready-to-show', () => { - let lastSongInfo: SongInfo; - registerCallback((songInfo) => { - lastSongInfo = songInfo; - updateActivity(songInfo); - }); - connect(); - let lastSent = Date.now(); - ipcMain.on('timeChanged', (_, t: number) => { - const currentTime = Date.now(); - // if lastSent is more than 5 seconds ago, send the new time - if (currentTime - lastSent > 5000) { - lastSent = currentTime; - if (lastSongInfo) { - lastSongInfo.elapsedSeconds = t; - updateActivity(lastSongInfo); - } - } - }); - }); - app.on('window-all-closed', clear); - }, - onUnload() { - resetInfo(); - }, - }; -}); - export const clear = () => { if (info.rpc) { info.rpc.user?.clearActivity(); @@ -231,3 +103,127 @@ export const clear = () => { export const registerRefresh = (cb: () => void) => refreshCallbacks.push(cb); export const isConnected = () => info.rpc !== null; + +export const onLoad = async (win: Electron.BrowserWindow, options: DiscordPluginConfig) => { + info.rpc.on('connected', () => { + if (dev()) { + console.log('discord connected'); + } + + for (const cb of refreshCallbacks) { + cb(); + } + }); + + info.rpc.on('ready', () => { + info.ready = true; + if (info.lastSongInfo) { + updateActivity(info.lastSongInfo); + } + }); + + info.rpc.on('disconnected', () => { + resetInfo(); + + if (info.autoReconnect) { + connectTimeout(); + } + }); + + info.autoReconnect = options.autoReconnect; + + window = win; + // We get multiple events + // Next song: PAUSE(n), PAUSE(n+1), PLAY(n+1) + // Skip time: PAUSE(N), PLAY(N) + updateActivity = (songInfo) => { + if (songInfo.title.length === 0 && songInfo.artist.length === 0) { + return; + } + + info.lastSongInfo = songInfo; + + // Stop the clear activity timout + clearTimeout(clearActivity); + + // Stop early if discord connection is not ready + // do this after clearTimeout to avoid unexpected clears + if (!info.rpc || !info.ready) { + return; + } + + // Clear directly if timeout is 0 + if (songInfo.isPaused && options.activityTimoutEnabled && options.activityTimoutTime === 0) { + info.rpc.user?.clearActivity().catch(console.error); + return; + } + + // Song information changed, so lets update the rich presence + // @see https://discord.com/developers/docs/topics/gateway#activity-object + // not all options are transfered through https://github.com/discordjs/RPC/blob/6f83d8d812c87cb7ae22064acd132600407d7d05/src/client.js#L518-530 + const hangulFillerUnicodeCharacter = '\u3164'; // This is an empty character + if (songInfo.title.length < 2) { + songInfo.title += hangulFillerUnicodeCharacter.repeat(2 - songInfo.title.length); + } + if (songInfo.artist.length < 2) { + songInfo.artist += hangulFillerUnicodeCharacter.repeat(2 - songInfo.title.length); + } + + const activityInfo: SetActivity = { + details: songInfo.title, + state: songInfo.artist, + largeImageKey: songInfo.imageSrc ?? '', + largeImageText: songInfo.album ?? '', + buttons: [ + ...(options.playOnYouTubeMusic ? [{ label: 'Play on YouTube Music', url: songInfo.url ?? '' }] : []), + ...(options.hideGitHubButton ? [] : [{ label: 'View App On GitHub', url: 'https://github.com/th-ch/youtube-music' }]), + ], + }; + + if (songInfo.isPaused) { + // Add a paused icon to show that the song is paused + activityInfo.smallImageKey = 'paused'; + activityInfo.smallImageText = 'Paused'; + // Set start the timer so the activity gets cleared after a while if enabled + if (options.activityTimoutEnabled) { + clearActivity = setTimeout(() => info.rpc.user?.clearActivity().catch(console.error), options.activityTimoutTime ?? 10_000); + } + } else if (!options.hideDurationLeft) { + // Add the start and end time of the song + const songStartTime = Date.now() - ((songInfo.elapsedSeconds ?? 0) * 1000); + activityInfo.startTimestamp = songStartTime; + activityInfo.endTimestamp + = songStartTime + (songInfo.songDuration * 1000); + } + + info.rpc.user?.setActivity(activityInfo).catch(console.error); + }; + + // If the page is ready, register the callback + win.once('ready-to-show', () => { + let lastSongInfo: SongInfo; + registerCallback((songInfo) => { + lastSongInfo = songInfo; + updateActivity(songInfo); + }); + connect(); + let lastSent = Date.now(); + ipcMain.on('timeChanged', (_, t: number) => { + const currentTime = Date.now(); + // if lastSent is more than 5 seconds ago, send the new time + if (currentTime - lastSent > 5000) { + lastSent = currentTime; + if (lastSongInfo) { + lastSongInfo.elapsedSeconds = t; + updateActivity(lastSongInfo); + } + } + }); + }); + app.on('window-all-closed', clear); +}; + +export const onUnload = () => { + resetInfo(); +}; + diff --git a/src/plugins/discord/menu.ts b/src/plugins/discord/menu.ts index d0b021a6..0dc752cc 100644 --- a/src/plugins/discord/menu.ts +++ b/src/plugins/discord/menu.ts @@ -2,22 +2,20 @@ import prompt from 'custom-electron-prompt'; import { clear, connect, isConnected, registerRefresh } from './main'; -import builder from './index'; +import { singleton } from '@/providers/decorators'; +import { setMenuOptions } from '@/config/plugins'; +import { MenuContext } from '@/types/contexts'; +import { DiscordPluginConfig } from '@/plugins/discord/index'; -import { setMenuOptions } from '../../config/plugins'; import promptOptions from '../../providers/prompt-options'; -import { singleton } from '../../providers/decorators'; -import type { MenuTemplate } from '../../menu'; -import type { ConfigType } from '../../config/dynamic'; +import type { MenuTemplate } from '@/menu'; const registerRefreshOnce = singleton((refreshMenu: () => void) => { registerRefresh(refreshMenu); }); -type DiscordOptions = ConfigType<'discord'>; - -export default builder.createMenu(async ({ window, getConfig, setConfig, refresh }): Promise => { +export const onMenu = async ({ window, getConfig, setConfig, refresh }: MenuContext): Promise => { const config = await getConfig(); registerRefreshOnce(refresh); @@ -86,9 +84,9 @@ export default builder.createMenu(async ({ window, getConfig, setConfig, refresh click: () => setInactivityTimeout(window, config), }, ]; -}); +}; -async function setInactivityTimeout(win: Electron.BrowserWindow, options: DiscordOptions) { +async function setInactivityTimeout(win: Electron.BrowserWindow, options: DiscordPluginConfig) { const output = await prompt({ title: 'Set Inactivity Timeout', label: 'Enter inactivity timeout in seconds:', diff --git a/src/plugins/downloader/index.ts b/src/plugins/downloader/index.ts index 5cbb3f15..6660d77d 100644 --- a/src/plugins/downloader/index.ts +++ b/src/plugins/downloader/index.ts @@ -2,7 +2,9 @@ import { DefaultPresetList, Preset } from './types'; import style from './style.css?inline'; -import { createPluginBuilder } from '../utils/builder'; +import { createPlugin } from '@/utils'; +import { onConfigChange, onMainLoad } from '@/plugins/downloader/main'; +import { onPlayerApiReady, onRendererLoad } from '@/plugins/downloader/renderer'; export type DownloaderPluginConfig = { enabled: boolean; @@ -13,7 +15,7 @@ export type DownloaderPluginConfig = { playlistMaxItems?: number; } -const builder = createPluginBuilder('downloader', { +export default createPlugin({ name: 'Downloader', restartNeeded: true, config: { @@ -24,13 +26,14 @@ const builder = createPluginBuilder('downloader', { skipExisting: false, playlistMaxItems: undefined, } as DownloaderPluginConfig, - styles: [style], + stylesheets: [style], + backend: { + start: onMainLoad, + onConfigChange, + }, + renderer: { + start: onRendererLoad, + onPlayerApiReady, + } }); -export default builder; - -declare global { - interface PluginBuilderList { - [builder.id]: typeof builder; - } -} diff --git a/src/plugins/downloader/main/index.ts b/src/plugins/downloader/main/index.ts index 9443489e..e793aa42 100644 --- a/src/plugins/downloader/main/index.ts +++ b/src/plugins/downloader/main/index.ts @@ -28,15 +28,16 @@ import { setBadge, } from './utils'; +import { fetchFromGenius } from '@/plugins/lyrics-genius/main'; +import { isEnabled } from '@/config/plugins'; +import { cleanupName, getImage, SongInfo } from '@/providers/song-info'; +import { getNetFetchAsFetch } from '@/plugins/utils/main'; +import { cache } from '@/providers/decorators'; +import { BackendContext } from '@/types/contexts'; + import { YoutubeFormatList, type Preset, DefaultPresetList } from '../types'; -import builder, { DownloaderPluginConfig } from '../index'; - -import { fetchFromGenius } from '../../lyrics-genius/main'; -import { isEnabled } from '../../../config/plugins'; -import { cleanupName, getImage, SongInfo } from '../../../providers/song-info'; -import { getNetFetchAsFetch } from '../../utils/main'; -import { cache } from '../../../providers/decorators'; +import { DownloaderPluginConfig } from '../index'; import type { FormatOptions } from 'youtubei.js/dist/src/types/FormatUtils'; import type PlayerErrorMessage from 'youtubei.js/dist/src/parser/classes/PlayerErrorMessage'; @@ -44,7 +45,7 @@ import type { Playlist } from 'youtubei.js/dist/src/parser/ytmusic'; import type { VideoInfo } from 'youtubei.js/dist/src/parser/youtube'; import type TrackInfo from 'youtubei.js/dist/src/parser/ytmusic/TrackInfo'; -import type { GetPlayerResponse } from '../../../types/get-player-response'; +import type { GetPlayerResponse } from '@/types/get-player-response'; type CustomSongInfo = SongInfo & { trackId?: string }; @@ -89,31 +90,28 @@ export const getCookieFromWindow = async (win: BrowserWindow) => { .join(';'); }; -let config: DownloaderPluginConfig = builder.config; +let config: DownloaderPluginConfig; -export default builder.createMain(({ handle, getConfig, on }) => { - return { - async onLoad(_win) { - win = _win; - config = await getConfig(); +export const onMainLoad = async ({ window: _win, getConfig, ipc }: BackendContext) => { + win = _win; + config = await getConfig(); - yt = await Innertube.create({ - cache: new UniversalCache(false), - cookie: await getCookieFromWindow(win), - generate_session_locally: true, - fetch: getNetFetchAsFetch(), - }); - handle('download-song', (url: string) => downloadSong(url)); - on('video-src-changed', (data: GetPlayerResponse) => { - playingUrl = data.microformat.microformatDataRenderer.urlCanonical; - }); - handle('download-playlist-request', async (_event, url: string) => downloadPlaylist(url)); - }, - onConfigChange(newConfig) { - config = newConfig; - } - }; -}); + yt = await Innertube.create({ + cache: new UniversalCache(false), + cookie: await getCookieFromWindow(win), + generate_session_locally: true, + fetch: getNetFetchAsFetch(), + }); + ipc.handle('download-song', (url: string) => downloadSong(url)); + ipc.on('video-src-changed', (data: GetPlayerResponse) => { + playingUrl = data.microformat.microformatDataRenderer.urlCanonical; + }); + ipc.handle('download-playlist-request', async (url: string) => downloadPlaylist(url)); +}; + +export const onConfigChange = (newConfig: DownloaderPluginConfig) => { + config = newConfig; +}; export async function downloadSong( url: string, @@ -319,7 +317,7 @@ async function iterableStreamToTargetFile( contentLength: number, sendFeedback: (str: string, value?: number) => void, increasePlaylistProgress: (value: number) => void = () => {}, -) { +): Promise { const chunks = []; let downloaded = 0; for await (const chunk of stream) { @@ -379,6 +377,7 @@ async function iterableStreamToTargetFile( } finally { releaseFFmpegMutex(); } + return null; } const getCoverBuffer = cache(async (url: string) => { diff --git a/src/plugins/downloader/menu.ts b/src/plugins/downloader/menu.ts index 19a2c444..2b95dcb7 100644 --- a/src/plugins/downloader/menu.ts +++ b/src/plugins/downloader/menu.ts @@ -4,9 +4,12 @@ import { downloadPlaylist } from './main'; import { defaultMenuDownloadLabel, getFolder } from './main/utils'; import { DefaultPresetList } from './types'; -import builder from './index'; +import type { MenuContext } from '@/types/contexts'; +import type { MenuTemplate } from '@/menu'; -export default builder.createMenu(async ({ getConfig, setConfig }) => { +import type { DownloaderPluginConfig } from './index'; + +export const onMenu = async ({ getConfig, setConfig }: MenuContext): Promise => { const config = await getConfig(); return [ @@ -46,4 +49,4 @@ export default builder.createMenu(async ({ getConfig, setConfig }) => { }, }, ]; -}); +}; diff --git a/src/plugins/downloader/renderer.ts b/src/plugins/downloader/renderer.ts index 6bab7b9a..aa7a2d4c 100644 --- a/src/plugins/downloader/renderer.ts +++ b/src/plugins/downloader/renderer.ts @@ -1,11 +1,14 @@ import downloadHTML from './templates/download.html?raw'; -import builder from './index'; +import defaultConfig from '@/config/defaults'; +import { getSongMenu } from '@/providers/dom-elements'; +import { getSongInfo } from '@/providers/song-info-front'; -import defaultConfig from '../../config/defaults'; -import { getSongMenu } from '../../providers/dom-elements'; import { ElementFromHtml } from '../utils/renderer'; -import { getSongInfo } from '../../providers/song-info-front'; + +import type { RendererContext } from '@/types/contexts'; + +import type { DownloaderPluginConfig } from './index'; let menu: Element | null = null; let progress: Element | null = null; @@ -13,70 +16,67 @@ const downloadButton = ElementFromHtml(downloadHTML); let doneFirstLoad = false; -export default builder.createRenderer(({ invoke, on }) => { - const menuObserver = new MutationObserver(() => { +const menuObserver = new MutationObserver(() => { + if (!menu) { + menu = getSongMenu(); if (!menu) { - menu = getSongMenu(); - if (!menu) { + return; + } + } + + if (menu.contains(downloadButton)) { + return; + } + + const menuUrl = document.querySelector('tp-yt-paper-listbox [tabindex="-1"] #navigation-endpoint')?.href; + if (!menuUrl?.includes('watch?') && doneFirstLoad) { + return; + } + + menu.prepend(downloadButton); + progress = document.querySelector('#ytmcustom-download'); + + if (doneFirstLoad) { + return; + } + + setTimeout(() => doneFirstLoad ||= true, 500); +}); + +export const onRendererLoad = ({ ipc }: RendererContext) => { + window.download = () => { + let videoUrl = getSongMenu() + // Selector of first button which is always "Start Radio" + ?.querySelector('ytmusic-menu-navigation-item-renderer[tabindex="-1"] #navigation-endpoint') + ?.getAttribute('href'); + if (videoUrl) { + if (videoUrl.startsWith('watch?')) { + videoUrl = defaultConfig.url + '/' + videoUrl; + } + + if (videoUrl.includes('?playlist=')) { + ipc.invoke('download-playlist-request', videoUrl); return; } + } else { + videoUrl = getSongInfo().url || window.location.href; } - if (menu.contains(downloadButton)) { - return; - } - - const menuUrl = document.querySelector('tp-yt-paper-listbox [tabindex="-1"] #navigation-endpoint')?.href; - if (!menuUrl?.includes('watch?') && doneFirstLoad) { - return; - } - - menu.prepend(downloadButton); - progress = document.querySelector('#ytmcustom-download'); - - if (doneFirstLoad) { - return; - } - - setTimeout(() => doneFirstLoad ||= true, 500); - }); - - return { - onLoad() { - window.download = () => { - let videoUrl = getSongMenu() - // Selector of first button which is always "Start Radio" - ?.querySelector('ytmusic-menu-navigation-item-renderer[tabindex="-1"] #navigation-endpoint') - ?.getAttribute('href'); - if (videoUrl) { - if (videoUrl.startsWith('watch?')) { - videoUrl = defaultConfig.url + '/' + videoUrl; - } - - if (videoUrl.includes('?playlist=')) { - invoke('download-playlist-request', videoUrl); - return; - } - } else { - videoUrl = getSongInfo().url || window.location.href; - } - - invoke('download-song', videoUrl); - }; - - on('downloader-feedback', (feedback: string) => { - if (progress) { - progress.innerHTML = feedback || 'Download'; - } else { - console.warn('Cannot update progress'); - } - }); - }, - onPlayerApiReady() { - menuObserver.observe(document.querySelector('ytmusic-popup-container')!, { - childList: true, - subtree: true, - }); - }, + ipc.invoke('download-song', videoUrl); }; -}); + + ipc.on('downloader-feedback', (feedback: string) => { + if (progress) { + progress.innerHTML = feedback || 'Download'; + } else { + console.warn('Cannot update progress'); + } + }); +}; + +export const onPlayerApiReady = () => { + menuObserver.observe(document.querySelector('ytmusic-popup-container')!, { + childList: true, + subtree: true, + }); +}; diff --git a/src/plugins/exponential-volume/index.ts b/src/plugins/exponential-volume/index.ts index 765bb090..858e2f50 100644 --- a/src/plugins/exponential-volume/index.ts +++ b/src/plugins/exponential-volume/index.ts @@ -1,17 +1,50 @@ -import { createPluginBuilder } from '../utils/builder'; +import { createPlugin } from '@/utils'; -const builder = createPluginBuilder('exponential-volume', { +export default createPlugin({ name: 'Exponential Volume', restartNeeded: true, config: { enabled: false, }, -}); + renderer: { + onPlayerApiReady() { + // "YouTube Music fix volume ratio 0.4" by Marco Pfeiffer + // https://greasyfork.org/en/scripts/397686-youtube-music-fix-volume-ratio/ -export default builder; + // Manipulation exponent, higher value = lower volume + // 3 is the value used by pulseaudio, which Barteks2x figured out this gist here: https://gist.github.com/Barteks2x/a4e189a36a10c159bb1644ffca21c02a + // 0.05 (or 5%) is the lowest you can select in the UI which with an exponent of 3 becomes 0.000125 or 0.0125% + const EXPONENT = 3; -declare global { - interface PluginBuilderList { - [builder.id]: typeof builder; + const storedOriginalVolumes = new WeakMap(); + const propertyDescriptor = Object.getOwnPropertyDescriptor( + HTMLMediaElement.prototype, + 'volume', + ); + Object.defineProperty(HTMLMediaElement.prototype, 'volume', { + get(this: HTMLMediaElement) { + const lowVolume = propertyDescriptor?.get?.call(this) as number ?? 0; + const calculatedOriginalVolume = lowVolume ** (1 / EXPONENT); + + // The calculated value has some accuracy issues which can lead to problems for implementations that expect exact values. + // To avoid this, I'll store the unmodified volume to return it when read here. + // This mostly solves the issue, but the initial read has no stored value and the volume can also change though external influences. + // To avoid ill effects, I check if the stored volume is somewhere in the same range as the calculated volume. + const storedOriginalVolume = storedOriginalVolumes.get(this) ?? 0; + const storedDeviation = Math.abs( + storedOriginalVolume - calculatedOriginalVolume, + ); + + return storedDeviation < 0.01 + ? storedOriginalVolume + : calculatedOriginalVolume; + }, + set(this: HTMLMediaElement, originalVolume: number) { + const lowVolume = originalVolume ** EXPONENT; + storedOriginalVolumes.set(this, originalVolume); + propertyDescriptor?.set?.call(this, lowVolume); + }, + }); + } } -} +}); diff --git a/src/plugins/exponential-volume/renderer.ts b/src/plugins/exponential-volume/renderer.ts deleted file mode 100644 index ff2b3153..00000000 --- a/src/plugins/exponential-volume/renderer.ts +++ /dev/null @@ -1,47 +0,0 @@ -// "YouTube Music fix volume ratio 0.4" by Marco Pfeiffer -// https://greasyfork.org/en/scripts/397686-youtube-music-fix-volume-ratio/ - -import builder from './index'; - -const exponentialVolume = () => { - // Manipulation exponent, higher value = lower volume - // 3 is the value used by pulseaudio, which Barteks2x figured out this gist here: https://gist.github.com/Barteks2x/a4e189a36a10c159bb1644ffca21c02a - // 0.05 (or 5%) is the lowest you can select in the UI which with an exponent of 3 becomes 0.000125 or 0.0125% - const EXPONENT = 3; - - const storedOriginalVolumes = new WeakMap(); - const propertyDescriptor = Object.getOwnPropertyDescriptor( - HTMLMediaElement.prototype, - 'volume', - ); - Object.defineProperty(HTMLMediaElement.prototype, 'volume', { - get(this: HTMLMediaElement) { - const lowVolume = propertyDescriptor?.get?.call(this) as number ?? 0; - const calculatedOriginalVolume = lowVolume ** (1 / EXPONENT); - - // The calculated value has some accuracy issues which can lead to problems for implementations that expect exact values. - // To avoid this, I'll store the unmodified volume to return it when read here. - // This mostly solves the issue, but the initial read has no stored value and the volume can also change though external influences. - // To avoid ill effects, I check if the stored volume is somewhere in the same range as the calculated volume. - const storedOriginalVolume = storedOriginalVolumes.get(this) ?? 0; - const storedDeviation = Math.abs( - storedOriginalVolume - calculatedOriginalVolume, - ); - - return storedDeviation < 0.01 - ? storedOriginalVolume - : calculatedOriginalVolume; - }, - set(this: HTMLMediaElement, originalVolume: number) { - const lowVolume = originalVolume ** EXPONENT; - storedOriginalVolumes.set(this, originalVolume); - propertyDescriptor?.set?.call(this, lowVolume); - }, - }); -}; - -export default builder.createRenderer(() => ({ - onPlayerApiReady() { - exponentialVolume(); - }, -})); diff --git a/src/types/contexts.ts b/src/types/contexts.ts index eb6c0a0d..e9b48f7a 100644 --- a/src/types/contexts.ts +++ b/src/types/contexts.ts @@ -1,16 +1,17 @@ -import type { BrowserWindow } from 'electron'; +import type { IpcMain, IpcRenderer, WebContents, BrowserWindow } from 'electron'; import type { PluginConfig } from '@/types/plugins'; export interface BaseContext { - getConfig(): Promise; - setConfig(conf: Partial>): void; + getConfig(): Promise | Config; + setConfig(conf: Partial>): Promise | void; } export interface BackendContext extends BaseContext { ipc: { - send: (event: string, ...args: unknown[]) => void; + send: WebContents['send']; handle: (event: string, listener: CallableFunction) => void; on: (event: string, listener: CallableFunction) => void; + removeHandler: IpcMain['removeHandler']; }; window: BrowserWindow; @@ -25,8 +26,9 @@ export interface PreloadContext extends BaseContext export interface RendererContext extends BaseContext { ipc: { - send: (event: string, ...args: unknown[]) => void; - invoke: (event: string, ...args: unknown[]) => Promise; + send: IpcRenderer['send']; + invoke: IpcRenderer['invoke']; on: (event: string, listener: CallableFunction) => void; + removeAllListeners: (event: string) => void; }; } diff --git a/src/types/plugins.ts b/src/types/plugins.ts index 53c1dad6..03c8fb84 100644 --- a/src/types/plugins.ts +++ b/src/types/plugins.ts @@ -34,7 +34,7 @@ export interface PluginDef< description?: string; config?: Config; - menu?: (ctx: MenuContext) => Promise; + menu?: (ctx: MenuContext) => Promise | Electron.MenuItemConstructorOptions[]; stylesheets?: string[]; restartNeeded?: boolean; diff --git a/src/utils/index.ts b/src/utils/index.ts index 041f04af..baec8b1c 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -13,7 +13,7 @@ export const createPlugin = < BackendProperties, PreloadProperties, RendererProperties, - Config extends PluginConfig, + Config extends PluginConfig = PluginConfig, >( def: PluginDef< BackendProperties,