diff --git a/config/defaults.js b/config/defaults.js index d01d059a6f..1c762e2d52 100644 --- a/config/defaults.js +++ b/config/defaults.js @@ -104,6 +104,13 @@ const defaultConfig = { "skip-silences": { onlySkipBeginning: false, }, + "crossfade": { + enabled: false, + fadeInDuration: 1500, // ms + fadeOutDuration: 5000, // ms + secondsBeforeEnd: 10, // s + fadeScaling: "linear", // 'linear', 'logarithmic' or a positive number in dB + }, visualizer: { enabled: false, type: "butterchurn", diff --git a/config/dynamic.js b/config/dynamic.js index 34cb0ac363..55a38f3cac 100644 --- a/config/dynamic.js +++ b/config/dynamic.js @@ -2,6 +2,7 @@ const { ipcRenderer, ipcMain } = require("electron"); const defaultConfig = require("./defaults"); const { getOptions, setOptions, setMenuOptions } = require("./plugins"); +const { sendToFront } = require("../providers/app-controls"); const activePlugins = {}; /** @@ -58,6 +59,9 @@ module.exports.PluginConfig = class PluginConfig { #defaultConfig; #enableFront; + #subscribers = {}; + #allSubscribers = []; + constructor(name, { enableFront = false, initialOptions = undefined } = {}) { const pluginDefaultConfig = defaultConfig.plugins[name] || {}; const pluginConfig = initialOptions || getOptions(name) || {}; @@ -80,11 +84,13 @@ module.exports.PluginConfig = class PluginConfig { set = (option, value) => { this.#config[option] = value; + this.#onChange(option); this.#save(); }; toggle = (option) => { this.#config[option] = !this.#config[option]; + this.#onChange(option); this.#save(); }; @@ -93,7 +99,18 @@ module.exports.PluginConfig = class PluginConfig { }; setAll = (options) => { - this.#config = { ...this.#config, ...options }; + if (!options || typeof options !== "object") + throw new Error("Options must be an object."); + + let changed = false; + for (const [key, val] of Object.entries(options)) { + if (this.#config[key] !== val) { + this.#config[key] = val; + this.#onChange(key, false); + changed = true; + } + } + if (changed) this.#allSubscribers.forEach((fn) => fn(this.#config)); this.#save(); }; @@ -109,6 +126,15 @@ module.exports.PluginConfig = class PluginConfig { setAndMaybeRestart = (option, value) => { this.#config[option] = value; setMenuOptions(this.#name, this.#config); + this.#onChange(option); + }; + + subscribe = (valueName, fn) => { + this.#subscribers[valueName] = fn; + }; + + subscribeAll = (fn) => { + this.#allSubscribers.push(fn); }; /** Called only from back */ @@ -116,24 +142,64 @@ module.exports.PluginConfig = class PluginConfig { setOptions(this.#name, this.#config); } + #onChange(valueName, single = true) { + this.#subscribers[valueName]?.(this.#config[valueName]); + if (single) this.#allSubscribers.forEach((fn) => fn(this.#config)); + } + #setupFront() { + const ignoredMethods = ["subscribe", "subscribeAll"]; + if (process.type === "renderer") { for (const [fnName, fn] of Object.entries(this)) { - if (typeof fn !== "function") return; + if (typeof fn !== "function" || fn.name in ignoredMethods) return; this[fnName] = async (...args) => { return await ipcRenderer.invoke( `${this.name}-config-${fnName}`, ...args, ); }; + + this.subscribe = (valueName, fn) => { + if (valueName in this.#subscribers) { + console.error(`Already subscribed to ${valueName}`); + } + this.#subscribers[valueName] = fn; + ipcRenderer.on( + `${this.name}-config-changed-${valueName}`, + (_, value) => { + fn(value); + }, + ); + ipcRenderer.send(`${this.name}-config-subscribe`, valueName); + }; + + this.subscribeAll = (fn) => { + ipcRenderer.on(`${this.name}-config-changed`, (_, value) => { + fn(value); + }); + ipcRenderer.send(`${this.name}-config-subscribe-all`); + }; } } else if (process.type === "browser") { for (const [fnName, fn] of Object.entries(this)) { - if (typeof fn !== "function") return; + if (typeof fn !== "function" || fn.name in ignoredMethods) return; ipcMain.handle(`${this.name}-config-${fnName}`, (_, ...args) => { return fn(...args); }); } + + ipcMain.on(`${this.name}-config-subscribe`, (_, valueName) => { + this.subscribe(valueName, (value) => { + sendToFront(`${this.name}-config-changed-${valueName}`, value); + }); + }); + + ipcMain.on(`${this.name}-config-subscribe-all`, () => { + this.subscribeAll((value) => { + sendToFront(`${this.name}-config-changed`, value); + }); + }); } } }; diff --git a/package.json b/package.json index 1f60e8729a..f946d1c30a 100644 --- a/package.json +++ b/package.json @@ -115,7 +115,7 @@ "browser-id3-writer": "^4.4.0", "butterchurn": "^2.6.7", "butterchurn-presets": "^2.4.7", - "custom-electron-prompt": "^1.5.4", + "custom-electron-prompt": "^1.5.7", "custom-electron-titlebar": "^4.1.6", "electron-better-web-request": "^1.0.1", "electron-debug": "^3.2.0", diff --git a/plugins/crossfade/back.js b/plugins/crossfade/back.js index ee2dc679c2..0443c95abc 100644 --- a/plugins/crossfade/back.js +++ b/plugins/crossfade/back.js @@ -1,7 +1,9 @@ const { ipcMain } = require("electron"); const { Innertube } = require("youtubei.js"); -module.exports = async (win, options) => { +require("./config"); + +module.exports = async () => { const yt = await Innertube.create(); ipcMain.handle("audio-url", async (_, videoID) => { diff --git a/plugins/crossfade/config.js b/plugins/crossfade/config.js new file mode 100644 index 0000000000..6db3c562d9 --- /dev/null +++ b/plugins/crossfade/config.js @@ -0,0 +1,3 @@ +const { PluginConfig } = require("../../config/dynamic"); +const config = new PluginConfig("crossfade", { enableFront: true }); +module.exports = { ...config }; diff --git a/plugins/crossfade/front.js b/plugins/crossfade/front.js index f08d7d872b..7b52a5dab3 100644 --- a/plugins/crossfade/front.js +++ b/plugins/crossfade/front.js @@ -8,13 +8,12 @@ let transitionAudio; // Howler audio used to fade out the current music let firstVideo = true; let waitForTransition; -// Crossfade options that can be overridden in plugin options -let crossfadeOptions = { - fadeInDuration: 1500, // ms - fadeOutDuration: 5000, // ms - exitMusicBeforeEnd: 10, // s - fadeScaling: "linear", -}; +const defaultConfig = require("../../config/defaults").plugins.crossfade; + +const configProvider = require("./config"); +let config; + +const configGetNum = (key) => Number(config[key]) || defaultConfig[key]; const getStreamURL = async (videoID) => { const url = await ipcRenderer.invoke("audio-url", videoID); @@ -32,7 +31,7 @@ const isReadyToCrossfade = () => { const watchVideoIDChanges = (cb) => { navigation.addEventListener("navigate", (event) => { const currentVideoID = getVideoIDFromURL( - event.currentTarget.currentEntry.url + event.currentTarget.currentEntry.url, ); const nextVideoID = getVideoIDFromURL(event.destination.url); @@ -67,9 +66,10 @@ const createAudioForCrossfade = async (url) => { const syncVideoWithTransitionAudio = async () => { const video = document.querySelector("video"); + const videoFader = new VolumeFader(video, { - fadeScaling: crossfadeOptions.fadeScaling, - fadeDuration: crossfadeOptions.fadeInDuration, + fadeScaling: configGetNum("fadeScaling"), + fadeDuration: configGetNum("fadeInDuration"), }); await transitionAudio.play(); @@ -94,8 +94,7 @@ const syncVideoWithTransitionAudio = async () => { // Exit just before the end for the transition const transitionBeforeEnd = () => { if ( - video.currentTime >= - video.duration - crossfadeOptions.exitMusicBeforeEnd && + video.currentTime >= video.duration - configGetNum("secondsBeforeEnd") && isReadyToCrossfade() ) { video.removeEventListener("timeupdate", transitionBeforeEnd); @@ -115,7 +114,7 @@ const onApiLoaded = () => { }); }; -const crossfade = (cb) => { +const crossfade = async (cb) => { if (!isReadyToCrossfade()) { cb(); return; @@ -130,8 +129,8 @@ const crossfade = (cb) => { const fader = new VolumeFader(transitionAudio._sounds[0]._node, { initialVolume: video.volume, - fadeScaling: crossfadeOptions.fadeScaling, - fadeDuration: crossfadeOptions.fadeOutDuration, + fadeScaling: configGetNum("fadeScaling"), + fadeDuration: configGetNum("fadeOutDuration"), }); // Fade out the music @@ -142,11 +141,12 @@ const crossfade = (cb) => { }); }; -module.exports = (options) => { - crossfadeOptions = { - ...crossfadeOptions, - options, - }; +module.exports = async () => { + config = await configProvider.getAll(); + + configProvider.subscribeAll((newConfig) => { + config = newConfig; + }); document.addEventListener("apiLoaded", onApiLoaded, { once: true, diff --git a/plugins/crossfade/menu.js b/plugins/crossfade/menu.js new file mode 100644 index 0000000000..5ee728c752 --- /dev/null +++ b/plugins/crossfade/menu.js @@ -0,0 +1,72 @@ +const config = require("./config"); +const defaultOptions = require("../../config/defaults").plugins.crossfade; + +const prompt = require("custom-electron-prompt"); +const promptOptions = require("../../providers/prompt-options"); + +module.exports = (win) => [ + { + label: "Advanced", + click: async () => { + const newOptions = await promptCrossfadeValues(win, config.getAll()); + if (newOptions) config.setAll(newOptions); + }, + }, +]; + +async function promptCrossfadeValues(win, options) { + const res = await prompt( + { + title: "Crossfade Options", + type: "multiInput", + multiInputOptions: [ + { + label: "Fade in duration (ms)", + value: options.fadeInDuration || defaultOptions.fadeInDuration, + inputAttrs: { + type: "number", + required: true, + min: 0, + step: 100, + }, + }, + { + label: "Fade out duration (ms)", + value: options.fadeOutDuration || defaultOptions.fadeOutDuration, + inputAttrs: { + type: "number", + required: true, + min: 0, + step: 100, + }, + }, + { + label: "Crossfade x seconds before end", + value: + options.secondsBeforeEnd || defaultOptions.secondsBeforeEnd, + inputAttrs: { + type: "number", + required: true, + min: 0, + }, + }, + { + label: "Fade scaling", + selectOptions: { linear: "Linear", logarithmic: "Logarithmic" }, + value: options.fadeScaling || defaultOptions.fadeScaling, + }, + ], + resizable: true, + height: 360, + ...promptOptions(), + }, + win, + ).catch(console.error); + if (!res) return undefined; + return { + fadeInDuration: Number(res[0]), + fadeOutDuration: Number(res[1]), + secondsBeforeEnd: Number(res[2]), + fadeScaling: res[3], + }; +} diff --git a/providers/app-controls.js b/providers/app-controls.js index 3567e22abc..bed99157c6 100644 --- a/providers/app-controls.js +++ b/providers/app-controls.js @@ -1,12 +1,10 @@ const path = require("path"); -const is = require("electron-is"); - const { app, BrowserWindow, ipcMain, ipcRenderer } = require("electron"); const config = require("../config"); module.exports.restart = () => { - is.main() ? restart() : ipcRenderer.send('restart'); + process.type === 'browser' ? restart() : ipcRenderer.send('restart'); }; module.exports.setupAppControls = () => { @@ -21,3 +19,16 @@ function restart() { // execPath will be undefined if not running portable app, resulting in default behavior app.quit(); } + +function sendToFront(channel, ...args) { + BrowserWindow.getAllWindows().forEach(win => { + win.webContents.send(channel, ...args); + }); +} + +module.exports.sendToFront = + process.type === 'browser' + ? sendToFront + : () => { + console.error('sendToFront called from renderer'); + }; diff --git a/yarn.lock b/yarn.lock index f641fc4a3f..a72916edd5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2382,12 +2382,12 @@ __metadata: languageName: node linkType: hard -"custom-electron-prompt@npm:^1.5.4": - version: 1.5.4 - resolution: "custom-electron-prompt@npm:1.5.4" +"custom-electron-prompt@npm:^1.5.7": + version: 1.5.7 + resolution: "custom-electron-prompt@npm:1.5.7" peerDependencies: electron: ">=10.0.0" - checksum: 93995b5f0e9d14401a8c4fdd358af32d8b7585b59b111667cfa55f9505109c08914f3140953125b854e5d09e811de8c76c7fec718934c13e8a1ad09fe1b85270 + checksum: 7dd7b2fb6e0acdee35474893d0e98b5e701c411c76be716cc02c5c9ac42db4fdaa7d526e22fd8c7047c2f143559d185bed8731bd455a1d11982404512d5f5021 languageName: node linkType: hard @@ -8883,7 +8883,7 @@ __metadata: browser-id3-writer: ^4.4.0 butterchurn: ^2.6.7 butterchurn-presets: ^2.4.7 - custom-electron-prompt: ^1.5.4 + custom-electron-prompt: ^1.5.7 custom-electron-titlebar: ^4.1.6 del-cli: ^5.0.0 electron: ^22.0.2