diff --git a/config/defaults.js b/config/defaults.js index b4d52923..008e4283 100644 --- a/config/defaults.js +++ b/config/defaults.js @@ -83,6 +83,13 @@ const defaultConfig = { mode: "custom", forceHide: false, }, + "picture-in-picture": { + "enabled": false, + "alwaysOnTop": true, + "savePosition": true, + "saveSize": false, + "hotkey": "P" + }, }, }; diff --git a/config/store.js b/config/store.js index 7ccd9f9f..85097c4e 100644 --- a/config/store.js +++ b/config/store.js @@ -2,8 +2,16 @@ const Store = require("electron-store"); const defaults = require("./defaults"); +const setDefaultPluginOptions = (store, plugin) => { + if (!store.get(`plugins.${plugin}`)) { + store.set(`plugins.${plugin}`, defaults.plugins[plugin]); + } +} + const migrations = { ">=1.17.0": (store) => { + setDefaultPluginOptions(store, "picture-in-picture"); + if (store.get("plugins.video-toggle.mode") === undefined) { store.set("plugins.video-toggle.mode", "custom"); } diff --git a/index.js b/index.js index 96de5491..d3badf25 100644 --- a/index.js +++ b/index.js @@ -175,6 +175,10 @@ function createMainWindow() { win.webContents.loadURL(urlToLoad); win.on("closed", onClosed); + const setPiPOptions = config.plugins.isEnabled("picture-in-picture") + ? (key, value) => require("./plugins/picture-in-picture/back").setOptions({ [key]: value }) + : () => {}; + win.on("move", () => { if (win.isMaximized()) return; let position = win.getPosition(); @@ -183,6 +187,8 @@ function createMainWindow() { config.plugins.getOptions("picture-in-picture")["isInPiP"]; if (!isPiPEnabled) { lateSave("window-position", { x: position[0], y: position[1] }); + } else if(config.plugins.getOptions("picture-in-picture")["savePosition"]) { + lateSave("pip-position", position, setPiPOptions); } }); @@ -190,31 +196,36 @@ function createMainWindow() { win.on("resize", () => { const windowSize = win.getSize(); - const isMaximized = win.isMaximized(); - if (winWasMaximized !== isMaximized) { - winWasMaximized = isMaximized; - config.set("window-maximized", isMaximized); - } + const isPiPEnabled = config.plugins.isEnabled("picture-in-picture") && config.plugins.getOptions("picture-in-picture")["isInPiP"]; - if (!isMaximized && !isPiPEnabled) { + + if (!isPiPEnabled && winWasMaximized !== isMaximized) { + winWasMaximized = isMaximized; + config.set("window-maximized", isMaximized); + } + if (isMaximized) return; + + if (!isPiPEnabled) { lateSave("window-size", { width: windowSize[0], height: windowSize[1], }); + } else if(config.plugins.getOptions("picture-in-picture")["saveSize"]) { + lateSave("pip-size", windowSize, setPiPOptions); } }); let savedTimeouts = {}; - function lateSave(key, value) { + function lateSave(key, value, fn = config.set) { if (savedTimeouts[key]) clearTimeout(savedTimeouts[key]); savedTimeouts[key] = setTimeout(() => { - config.set(key, value); + fn(key, value); savedTimeouts[key] = undefined; - }, 1000) + }, 600); } win.webContents.on("render-process-gone", (event, webContents, details) => { diff --git a/plugins/in-app-menu/front.js b/plugins/in-app-menu/front.js index bef60632..da5841f9 100644 --- a/plugins/in-app-menu/front.js +++ b/plugins/in-app-menu/front.js @@ -1,6 +1,7 @@ const { ipcRenderer } = require("electron"); const config = require("../../config"); const { Titlebar, Color } = require("custom-electron-titlebar"); +const { isEnabled } = require("../../config/plugins"); function $(selector) { return document.querySelector(selector); } module.exports = (options) => { @@ -29,6 +30,12 @@ module.exports = (options) => { } }); + if (isEnabled("picture-in-picture")) { + ipcRenderer.on("pip-toggle", (_, pipEnabled) => { + bar.refreshMenu(); + }); + } + ipcRenderer.on("hideIcon", (_, hide) => hideIcon(hide)); // Increases the right margin of Navbar background when the scrollbar is visible to avoid blocking it (z-index doesn't affect it) diff --git a/plugins/picture-in-picture/adaptors/in-app-menu.js b/plugins/picture-in-picture/adaptors/in-app-menu.js new file mode 100644 index 00000000..a96ae967 --- /dev/null +++ b/plugins/picture-in-picture/adaptors/in-app-menu.js @@ -0,0 +1,37 @@ +const { Menu, app } = require("electron"); +const { setApplicationMenu } = require("../../../menu"); + +module.exports = (win, options, setOptions, togglePip, isInPip) => { + if (isInPip) { + Menu.setApplicationMenu(Menu.buildFromTemplate([ + { + label: "App", + submenu: [ + { + label: "Exit Picture in Picture", + click: togglePip, + }, + { + label: "Always on top", + type: "checkbox", + checked: options.alwaysOnTop, + click: (item) => { + setOptions({ alwaysOnTop: item.checked }); + win.setAlwaysOnTop(item.checked); + }, + }, + { + label: "Restart", + click: () => { + app.relaunch(); + app.quit(); + }, + }, + { role: "quit" }, + ], + }, + ])); + } else { + setApplicationMenu(win); + } +}; diff --git a/plugins/picture-in-picture/back.js b/plugins/picture-in-picture/back.js index 7cda241f..fa679d79 100644 --- a/plugins/picture-in-picture/back.js +++ b/plugins/picture-in-picture/back.js @@ -1,79 +1,109 @@ const path = require("path"); const { app, ipcMain } = require("electron"); +const electronLocalshortcut = require("electron-localshortcut"); -const { setOptions } = require("../../config/plugins"); +const { setOptions, isEnabled } = require("../../config/plugins"); const { injectCSS } = require("../utils"); -let isInPiPMode = false; +let isInPiP = false; let originalPosition; let originalSize; +let originalFullScreen; +let originalMaximized; -const pipPosition = [10, 10]; -const pipSize = [450, 275]; +let win; +let options; -const togglePiP = async (win) => { - isInPiPMode = !isInPiPMode; - setOptions("picture-in-picture", { isInPiP: isInPiPMode }); +const pipPosition = () => (options.savePosition && options["pip-position"]) || [10, 10]; +const pipSize = () => (options.saveSize && options["pip-size"]) || [450, 275]; - if (isInPiPMode) { +const setLocalOptions = (_options) => { + options = { ...options, ..._options }; + setOptions("picture-in-picture", _options); +} + + +const adaptors = []; +const runAdaptors = () => adaptors.forEach(a => a()); + +if (isEnabled("in-app-menu")) { + let adaptor = require("./adaptors/in-app-menu"); + adaptors.push(() => adaptor(win, options, setLocalOptions, togglePiP, isInPiP)); +} + +const togglePiP = async () => { + isInPiP = !isInPiP; + setLocalOptions({ isInPiP }); + + if (isInPiP) { + originalFullScreen = win.isFullScreen(); + if (originalFullScreen) win.setFullScreen(false); + originalMaximized = win.isMaximized(); + if (originalMaximized) win.unmaximize(); + originalPosition = win.getPosition(); originalSize = win.getSize(); win.webContents.on("before-input-event", blockShortcutsInPiP); win.setFullScreenable(false); - await win.webContents.executeJavaScript( - // Go fullscreen - ` - if (!document.querySelector("ytmusic-player-page").playerPageOpen_) { - document.querySelector(".toggle-player-page-button").click(); - } - document.querySelector(".fullscreen-button").click(); - document.querySelector("ytmusic-player-bar").classList.add("pip"); - ` - ); - win.setFullScreenable(true); + + runAdaptors(); + win.webContents.send("pip-toggle", true); app.dock?.hide(); win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true, }); app.dock?.show(); - win.setAlwaysOnTop(true, "screen-saver", 1); + if (options.alwaysOnTop) { + win.setAlwaysOnTop(true, "screen-saver", 1); + } } else { win.webContents.removeListener("before-input-event", blockShortcutsInPiP); + win.setFullScreenable(true); - await win.webContents.executeJavaScript( - // Exit fullscreen - ` - document.querySelector(".exit-fullscreen-button").click(); - document.querySelector("ytmusic-player-bar").classList.remove("pip"); - ` - ); + runAdaptors(); + win.webContents.send("pip-toggle", false); win.setVisibleOnAllWorkspaces(false); win.setAlwaysOnTop(false); + + if (originalFullScreen) win.setFullScreen(true); + if (originalMaximized) win.maximize(); } - const [x, y] = isInPiPMode ? pipPosition : originalPosition; - const [w, h] = isInPiPMode ? pipSize : originalSize; + const [x, y] = isInPiP ? pipPosition() : originalPosition; + const [w, h] = isInPiP ? pipSize() : originalSize; win.setPosition(x, y); win.setSize(w, h); - win.setWindowButtonVisibility?.(!isInPiPMode); -}; - -module.exports = (win) => { - injectCSS(win.webContents, path.join(__dirname, "style.css")); - ipcMain.on("picture-in-picture", async () => { - await togglePiP(win); - }); + win.setWindowButtonVisibility?.(!isInPiP); }; const blockShortcutsInPiP = (event, input) => { - const blockedShortcuts = ["f", "escape"]; - if (blockedShortcuts.includes(input.key.toLowerCase())) { + const key = input.key.toLowerCase(); + + if (key === "f") { event.preventDefault(); + } else if (key === 'escape') { + togglePiP(); + event.preventDefault(); + }; +}; + +module.exports = (_win, _options) => { + options ??= _options; + win ??= _win; + setLocalOptions({ isInPiP }); + injectCSS(win.webContents, path.join(__dirname, "style.css")); + ipcMain.on("picture-in-picture", async () => { + await togglePiP(); + }); + if (options.hotkey) { + electronLocalshortcut.register(win, options.hotkey, togglePiP); } }; + +module.exports.setOptions = setLocalOptions; diff --git a/plugins/picture-in-picture/front.js b/plugins/picture-in-picture/front.js index 637c7fb6..685b15c1 100644 --- a/plugins/picture-in-picture/front.js +++ b/plugins/picture-in-picture/front.js @@ -3,6 +3,8 @@ const { ipcRenderer } = require("electron"); const { getSongMenu } = require("../../providers/dom-elements"); const { ElementFromFile, templatePath } = require("../utils"); +function $(selector) { return document.querySelector(selector); } + let menu = null; const pipButton = ElementFromFile( templatePath(__dirname, "picture-in-picture.html") @@ -14,7 +16,7 @@ const observer = new MutationObserver(() => { if (!menu) return; } if (menu.contains(pipButton)) return; - const menuUrl = document.querySelector( + const menuUrl = $( 'tp-yt-paper-listbox [tabindex="0"] #navigation-endpoint' )?.href; if (menuUrl && !menuUrl.includes("watch?")) return; @@ -26,11 +28,57 @@ global.togglePictureInPicture = () => { ipcRenderer.send("picture-in-picture"); }; +const listenForToggle = () => { + const originalExitButton = $(".exit-fullscreen-button"); + const clonedExitButton = originalExitButton.cloneNode(true); + clonedExitButton.onclick = () => togglePictureInPicture(); + + const appLayout = $("ytmusic-app-layout"); + const expandMenu = $('#expanding-menu'); + const middleControls = $('.middle-controls'); + const playerPage = $("ytmusic-player-page"); + const togglePlayerPageButton = $(".toggle-player-page-button"); + const fullScreenButton = $(".fullscreen-button"); + const player = $('#player'); + const onPlayerDblClick = player.onDoubleClick_; + + ipcRenderer.on('pip-toggle', (_, isPip) => { + if (isPip) { + $(".exit-fullscreen-button").replaceWith(clonedExitButton); + player.onDoubleClick_ = () => {}; + expandMenu.onmouseleave = () => middleControls.click(); + if (!playerPage.playerPageOpen_) { + togglePlayerPageButton.click(); + } + fullScreenButton.click(); + appLayout.classList.add("pip"); + } else { + $(".exit-fullscreen-button").replaceWith(originalExitButton); + player.onDoubleClick_ = onPlayerDblClick; + expandMenu.onmouseleave = undefined; + originalExitButton.click(); + appLayout.classList.remove("pip"); + } + }); +} + function observeMenu(options) { document.addEventListener( "apiLoaded", () => { - observer.observe(document.querySelector("ytmusic-popup-container"), { + listenForToggle(); + const minButton = $(".player-minimize-button"); + // remove native listeners + minButton.replaceWith(minButton.cloneNode(true)); + $(".player-minimize-button").onclick = () => { + global.togglePictureInPicture(); + setTimeout(() => $('#player').click()); + }; + + // allows easily closing the menu by programmatically clicking outside of it + $("#expanding-menu").removeAttribute("no-cancel-on-outside-click"); + // TODO: think about wether an additional button in songMenu is needed + observer.observe($("ytmusic-popup-container"), { childList: true, subtree: true, }); diff --git a/plugins/picture-in-picture/menu.js b/plugins/picture-in-picture/menu.js new file mode 100644 index 00000000..bdbc1cee --- /dev/null +++ b/plugins/picture-in-picture/menu.js @@ -0,0 +1,60 @@ +const prompt = require("custom-electron-prompt"); + +const promptOptions = require("../../providers/prompt-options"); +const { setOptions } = require("./back.js"); + +module.exports = (win, options) => [ + { + label: "Always on top", + type: "checkbox", + checked: options.alwaysOnTop, + click: (item) => { + setOptions({ alwaysOnTop: item.checked }); + win.setAlwaysOnTop(item.checked); + }, + }, + { + label: "Save window position", + type: "checkbox", + checked: options.savePosition, + click: (item) => { + setOptions({ savePosition: item.checked }); + }, + }, + { + label: "Save window size", + type: "checkbox", + checked: options.saveSize, + click: (item) => { + setOptions({ saveSize: item.checked }); + }, + }, + { + label: "Hotkey", + type: "checkbox", + checked: options.hotkey, + click: async (item) => { + const output = await prompt({ + title: "Picture in Picture Hotkey", + label: "Choose a hotkey for toggling Picture in Picture", + type: "keybind", + keybindOptions: [{ + value: "hotkey", + label: "Hotkey", + default: options.hotkey + }], + ...promptOptions() + }, win) + + if (output) { + const { value, accelerator } = output[0]; + setOptions({ [value]: accelerator }); + + item.checked = !!accelerator; + } else { + // Reset checkbox if prompt was canceled + item.checked = !item.checked; + } + }, + } +]; diff --git a/plugins/picture-in-picture/style.css b/plugins/picture-in-picture/style.css index adb962d8..1856e01b 100644 --- a/plugins/picture-in-picture/style.css +++ b/plugins/picture-in-picture/style.css @@ -1,11 +1,26 @@ -ytmusic-player-bar.pip svg, -ytmusic-player-bar.pip yt-formatted-string { - filter: drop-shadow(2px 4px 6px black); - color: white; +/* improve visibility of the player bar elements */ +ytmusic-app-layout.pip ytmusic-player-bar svg, +ytmusic-app-layout.pip ytmusic-player-bar .time-info, +ytmusic-app-layout.pip ytmusic-player-bar yt-formatted-string, +ytmusic-app-layout.pip ytmusic-player-bar .yt-formatted-string { + filter: drop-shadow(2px 4px 6px black); + color: white !important; + fill: white !important; } -ytmusic-player-bar.pip ytmusic-player-expanding-menu { +/* improve the style of the player bar expanding menu */ +ytmusic-app-layout.pip ytmusic-player-expanding-menu { border-radius: 30px; background-color: rgba(0, 0, 0, 0.3); backdrop-filter: blur(5px) brightness(20%); } + +/* fix volumeHud position when both in-app-menu and PiP are active */ +.cet-container ytmusic-app-layout.pip #volumeHud { + top: 22px !important; +} + +/* disable the video-toggle button when in PiP mode */ +ytmusic-app-layout.pip .video-switch-button { + display: none !important; +} diff --git a/plugins/quality-changer/front.js b/plugins/quality-changer/front.js index 669d39ba..4c553a9f 100644 --- a/plugins/quality-changer/front.js +++ b/plugins/quality-changer/front.js @@ -18,8 +18,7 @@ function setup(event) { $('.top-row-buttons.ytmusic-player').prepend(qualitySettingsButton); qualitySettingsButton.onclick = function chooseQuality() { - if (api.getPlayerState() === 2) api.playVideo(); - else if (api.getPlayerState() === 1) api.pauseVideo(); + setTimeout(() => $('#player').click()); const currentIndex = api.getAvailableQualityLevels().indexOf(api.getPlaybackQuality())