diff --git a/config/defaults.js b/config/defaults.js index 2a18ce94..50906749 100644 --- a/config/defaults.js +++ b/config/defaults.js @@ -52,6 +52,17 @@ const defaultConfig = { enabled: false, urgency: "normal", unpauseNotification: false + }, + "precise-volume": { + enabled: false, + steps: 1, //percentage of volume to change + arrowsShortcut: true, //enable ArrowUp + ArrowDown local shortcuts + globalShortcuts: { + enabled: false, // enable global shortcuts + volumeUp: "Shift+PageUp", // Keybind default can be changed + volumeDown: "Shift+PageDown" + }, + savedVolume: undefined //plugin save volume between session here } }, }; diff --git a/plugins/precise-volume/back.js b/plugins/precise-volume/back.js new file mode 100644 index 00000000..93ebea9f --- /dev/null +++ b/plugins/precise-volume/back.js @@ -0,0 +1,23 @@ +const { isEnabled } = require("../../config/plugins"); + +/* +This is used to determine if plugin is actually active +(not if its only enabled in options) +*/ +let enabled = false; + +module.exports = (win) => { + enabled = true; + + // youtube-music register some of the target listeners after DOMContentLoaded + // did-finish-load is called after all elements finished loading, including said listeners + // Thats the reason the timing is controlled from main + win.webContents.once("did-finish-load", () => { + win.webContents.send("restoreAddEventListener"); + win.webContents.send("setupVideoPlayerVolumeMousewheel", !isEnabled("hide-video-player")); + }); +}; + +module.exports.enabled = () => { + return enabled; +}; diff --git a/plugins/precise-volume/front.js b/plugins/precise-volume/front.js new file mode 100644 index 00000000..a1b30fe4 --- /dev/null +++ b/plugins/precise-volume/front.js @@ -0,0 +1,207 @@ +const { ipcRenderer, remote } = require("electron"); + +const { setOptions } = require("../../config/plugins"); + +function $(selector) { return document.querySelector(selector); } + +module.exports = (options) => { + + setupPlaybar(options); + + setupSliderObserver(options); + + setupLocalArrowShortcuts(options); + + if (options.globalShortcuts?.enabled) { + setupGlobalShortcuts(options); + } + + firstRun(options); + + // This way the ipc listener gets cleared either way + ipcRenderer.once("setupVideoPlayerVolumeMousewheel", (_event, toEnable) => { + if (toEnable) + setupVideoPlayerOnwheel(options); + }); +}; + +/** Add onwheel event to video player */ +function setupVideoPlayerOnwheel(options) { + $("#main-panel").addEventListener("wheel", event => { + event.preventDefault(); + // Event.deltaY < 0 means wheel-up + changeVolume(event.deltaY < 0, options); + }); +} + +function toPercent(volume) { + return Math.round(Number.parseFloat(volume) * 100); +} + +function saveVolume(volume, options) { + options.savedVolume = volume; + setOptions("precise-volume", options); +} + +/** Restore saved volume and setup tooltip */ +function firstRun(options) { + const videoStream = $(".video-stream"); + const slider = $("#volume-slider"); + // Those elements load abit after DOMContentLoaded + if (videoStream && slider) { + // Set saved volume IF it pass checks + if (options.savedVolume + && options.savedVolume >= 0 && options.savedVolume <= 100 + && Math.abs(slider.value - options.savedVolume) < 5 + // If plugin was disabled and volume changed then diff>4 + ) { + videoStream.volume = options.savedVolume / 100; + slider.value = options.savedVolume; + } + // Set current volume as tooltip + setTooltip(toPercent(videoStream.volume)); + } else { + setTimeout(firstRun, 500, options); // Try again in 500 milliseconds + } +} + +/** Add onwheel event to play bar and also track if play bar is hovered*/ +function setupPlaybar(options) { + const playerbar = $("ytmusic-player-bar"); + + playerbar.addEventListener("wheel", event => { + event.preventDefault(); + // Event.deltaY < 0 means wheel-up + changeVolume(event.deltaY < 0, options); + }); + + // Keep track of mouse position for showVolumeSlider() + playerbar.addEventListener("mouseenter", () => { + playerbar.classList.add("on-hover"); + }); + + playerbar.addEventListener("mouseleave", () => { + playerbar.classList.remove("on-hover"); + }); +} + +/** if (toIncrease = false) then volume decrease */ +function changeVolume(toIncrease, options) { + // Need to change both the actual volume and the slider + const videoStream = $(".video-stream"); + const slider = $("#volume-slider"); + // Apply volume change if valid + const steps = (options.steps || 1) / 100; + videoStream.volume = toIncrease ? + Math.min(videoStream.volume + steps, 1) : + Math.max(videoStream.volume - steps, 0); + + // Save the new volume + saveVolume(toPercent(videoStream.volume), options); + // Slider value automatically rounds to multiples of 5 + slider.value = options.savedVolume; + // Change tooltips to new value + setTooltip(options.savedVolume); + // Show volume slider on volume change + showVolumeSlider(slider); +} + +let volumeHoverTimeoutID; + +function showVolumeSlider(slider) { + // This class display the volume slider if not in minimized mode + slider.classList.add("on-hover"); + // Reset timeout if previous one hasn't completed + if (volumeHoverTimeoutID) { + clearTimeout(volumeHoverTimeoutID); + } + // Timeout to remove volume preview after 3 seconds if playbar isn't hovered + volumeHoverTimeoutID = setTimeout(() => { + volumeHoverTimeoutID = null; + if (!$("ytmusic-player-bar").classList.contains("on-hover")) { + slider.classList.remove("on-hover"); + } + }, 3000); +} + +/** Save volume + Update the volume tooltip when volume-slider is manually changed */ +function setupSliderObserver(options) { + const sliderObserver = new MutationObserver(mutations => { + for (const mutation of mutations) { + // This checks that volume-slider was manually set + if (mutation.oldValue !== mutation.target.value && + (!options.savedVolume || Math.abs(options.savedVolume - mutation.target.value) > 4)) { + // Diff>4 means it was manually set + setTooltip(mutation.target.value); + saveVolume(mutation.target.value, options); + } + } + }); + + // Observing only changes in 'value' of volume-slider + sliderObserver.observe($("#volume-slider"), { + attributeFilter: ["value"], + attributeOldValue: true + }); +} + +// Set new volume as tooltip for volume slider and icon + expanding slider (appears when window size is small) +const tooltipTargets = [ + "#volume-slider", + "tp-yt-paper-icon-button.volume", + "#expand-volume-slider", + "#expand-volume" +]; + +function setTooltip(volume) { + for (target of tooltipTargets) { + $(target).title = `${volume}%`; + } +} + +function setupGlobalShortcuts(options) { + if (options.globalShortcuts.volumeUp) { + remote.globalShortcut.register((options.globalShortcuts.volumeUp), () => changeVolume(true, options)); + } + if (options.globalShortcuts.volumeDown) { + remote.globalShortcut.register((options.globalShortcuts.volumeDown), () => changeVolume(false, options)); + } +} + +function setupLocalArrowShortcuts(options) { + if (options.arrowsShortcut) { + addListener(); + } + + // Change options from renderer to keep sync + ipcRenderer.on("setArrowsShortcut", (_event, isEnabled) => { + options.arrowsShortcut = isEnabled; + setOptions("precise-volume", options); + // This allows changing this setting without restarting app + if (isEnabled) { + addListener(); + } else { + removeListener(); + } + }); + + function addListener() { + window.addEventListener('keydown', callback); + } + + function removeListener() { + window.removeEventListener("keydown", callback); + } + + function callback(event) { + event.preventDefault(); + switch (event.code) { + case "ArrowUp": + changeVolume(true, options); + break; + case "ArrowDown": + changeVolume(false, options); + break; + } + } +} diff --git a/plugins/precise-volume/menu.js b/plugins/precise-volume/menu.js new file mode 100644 index 00000000..d4ed37a7 --- /dev/null +++ b/plugins/precise-volume/menu.js @@ -0,0 +1,19 @@ +const { enabled } = require("./back"); +const { setOptions } = require("../../config/plugins"); + +module.exports = (win, options) => [ + { + label: "Arrowkeys controls", + type: "checkbox", + checked: !!options.arrowsShortcut, + click: (item) => { + // Dynamically change setting if plugin enabled + if (enabled()) { + win.webContents.send("setArrowsShortcut", item.checked); + } else { // Fallback to usual method if disabled + options.arrowsShortcut = item.checked; + setOptions("precise-volume", options); + } + } + } +]; diff --git a/plugins/precise-volume/preload.js b/plugins/precise-volume/preload.js new file mode 100644 index 00000000..bfd6a01c --- /dev/null +++ b/plugins/precise-volume/preload.js @@ -0,0 +1,28 @@ +const { ipcRenderer } = require("electron"); + +// Override specific listeners of volume-slider by modifying Element.prototype +function overrideAddEventListener() { + // Events to ignore + const nativeEvents = ["mousewheel", "keydown", "keyup"]; + // Save native addEventListener + Element.prototype._addEventListener = Element.prototype.addEventListener; + // Override addEventListener to Ignore specific events in volume-slider + Element.prototype.addEventListener = function (type, listener, useCapture = false) { + if (this.tagName === "TP-YT-PAPER-SLIDER") { // tagName of #volume-slider + for (const eventType of nativeEvents) { + if (eventType === type) { + return; + } + } + }//else + this._addEventListener(type, listener, useCapture); + }; +} + +module.exports = () => { + overrideAddEventListener(); + // Restore original function after did-finish-load to avoid keeping Element.prototype altered + ipcRenderer.once("restoreAddEventListener", () => { //called from Main to make sure page is completly loaded + Element.prototype.addEventListener = Element.prototype._addEventListener; + }); +}; diff --git a/preload.js b/preload.js index 94f2a72a..ab7927fc 100644 --- a/preload.js +++ b/preload.js @@ -1,6 +1,6 @@ const path = require("path"); -const { contextBridge, remote } = require("electron"); +const { remote } = require("electron"); const config = require("./config"); const { fileExists } = require("./plugins/utils"); @@ -8,9 +8,15 @@ const { fileExists } = require("./plugins/utils"); const plugins = config.plugins.getEnabled(); plugins.forEach(([plugin, options]) => { - const pluginPath = path.join(__dirname, "plugins", plugin, "actions.js"); - fileExists(pluginPath, () => { - const actions = require(pluginPath).actions || {}; + const preloadPath = path.join(__dirname, "plugins", plugin, "preload.js"); + fileExists(preloadPath, () => { + const run = require(preloadPath); + run(options); + }); + + const actionPath = path.join(__dirname, "plugins", plugin, "actions.js"); + fileExists(actionPath, () => { + const actions = require(actionPath).actions || {}; // TODO: re-enable once contextIsolation is set to true // contextBridge.exposeInMainWorld(plugin + "Actions", actions);