diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e5c7e8a0..962b4adb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,7 +1,9 @@ name: Build YouTube Music on: - - push + push: + branches: [ master ] + pull_request: env: NODE_VERSION: "16.x" diff --git a/.github/workflows/winget-submission.yml b/.github/workflows/winget-submission.yml new file mode 100644 index 00000000..89d5e4c1 --- /dev/null +++ b/.github/workflows/winget-submission.yml @@ -0,0 +1,26 @@ +name: Submit to Windows Package Manager Community Repository + +on: + release: + types: [released] + workflow_dispatch: + inputs: + tag_name: + description: "Specific tag name" + required: true + type: string + +jobs: + winget: + name: Publish winget package + runs-on: windows-latest + steps: + - name: Submit package to Windows Package Manager Community Repository + uses: vedantmgoyal2009/winget-releaser@v2 + with: + identifier: th-ch.YouTubeMusic + installers-regex: '^YouTube-Music-Setup-[\d\.]+\.exe$' + version: ${{ inputs.tag_name || github.event.release.tag_name }} + release-tag: ${{ inputs.tag_name || github.event.release.tag_name }} + token: ${{ secrets.WINGET_ACC_TOKEN }} + fork-user: youtube-music-winget diff --git a/plugins/taskbar-mediacontrol/assets/forward.png b/assets/media-icons-black/next.png similarity index 100% rename from plugins/taskbar-mediacontrol/assets/forward.png rename to assets/media-icons-black/next.png diff --git a/plugins/taskbar-mediacontrol/assets/pause.png b/assets/media-icons-black/pause.png similarity index 100% rename from plugins/taskbar-mediacontrol/assets/pause.png rename to assets/media-icons-black/pause.png diff --git a/plugins/taskbar-mediacontrol/assets/play.png b/assets/media-icons-black/play.png similarity index 100% rename from plugins/taskbar-mediacontrol/assets/play.png rename to assets/media-icons-black/play.png diff --git a/plugins/taskbar-mediacontrol/assets/backward.png b/assets/media-icons-black/previous.png similarity index 100% rename from plugins/taskbar-mediacontrol/assets/backward.png rename to assets/media-icons-black/previous.png diff --git a/config/defaults.js b/config/defaults.js index 329f3851..d01d059a 100644 --- a/config/defaults.js +++ b/config/defaults.js @@ -55,8 +55,13 @@ const defaultConfig = { notifications: { enabled: false, unpauseNotification: false, - urgency: "normal", //has effect only on Linux - interactive: false //has effect only on Windows + urgency: "normal", //has effect only on Linux + // the following has effect only on Windows + interactive: true, + toastStyle: 1, // see plugins/notifications/utils for more info + refreshOnPlayPause: false, + trayControls: true, + hideButtonText: false }, "precise-volume": { enabled: false, diff --git a/config/dynamic.js b/config/dynamic.js new file mode 100644 index 00000000..34cb0ac3 --- /dev/null +++ b/config/dynamic.js @@ -0,0 +1,139 @@ +const { ipcRenderer, ipcMain } = require("electron"); + +const defaultConfig = require("./defaults"); +const { getOptions, setOptions, setMenuOptions } = require("./plugins"); + +const activePlugins = {}; +/** + * [!IMPORTANT!] + * The method is **sync** in the main process and **async** in the renderer process. + */ +module.exports.getActivePlugins = + process.type === "renderer" + ? async () => ipcRenderer.invoke("get-active-plugins") + : () => activePlugins; + +if (process.type === "browser") { + ipcMain.handle("get-active-plugins", this.getActivePlugins); +} + +/** + * [!IMPORTANT!] + * The method is **sync** in the main process and **async** in the renderer process. + */ +module.exports.isActive = + process.type === "renderer" + ? async (plugin) => + plugin in (await ipcRenderer.invoke("get-active-plugins")) + : (plugin) => plugin in activePlugins; + +/** + * This class is used to create a dynamic synced config for plugins. + * + * [!IMPORTANT!] + * The methods are **sync** in the main process and **async** in the renderer process. + * + * @param {string} name - The name of the plugin. + * @param {boolean} [options.enableFront] - Whether the config should be available in front.js. Default: false. + * @param {object} [options.initialOptions] - The initial options for the plugin. Default: loaded from store. + * + * @example + * const { PluginConfig } = require("../../config/dynamic"); + * const config = new PluginConfig("plugin-name", { enableFront: true }); + * module.exports = { ...config }; + * + * // or + * + * module.exports = (win, options) => { + * const config = new PluginConfig("plugin-name", { + * enableFront: true, + * initialOptions: options, + * }); + * setupMyPlugin(win, config); + * }; + */ +module.exports.PluginConfig = class PluginConfig { + #name; + #config; + #defaultConfig; + #enableFront; + + constructor(name, { enableFront = false, initialOptions = undefined } = {}) { + const pluginDefaultConfig = defaultConfig.plugins[name] || {}; + const pluginConfig = initialOptions || getOptions(name) || {}; + + this.#name = name; + this.#enableFront = enableFront; + this.#defaultConfig = pluginDefaultConfig; + this.#config = { ...pluginDefaultConfig, ...pluginConfig }; + + if (this.#enableFront) { + this.#setupFront(); + } + + activePlugins[name] = this; + } + + get = (option) => { + return this.#config[option]; + }; + + set = (option, value) => { + this.#config[option] = value; + this.#save(); + }; + + toggle = (option) => { + this.#config[option] = !this.#config[option]; + this.#save(); + }; + + getAll = () => { + return { ...this.#config }; + }; + + setAll = (options) => { + this.#config = { ...this.#config, ...options }; + this.#save(); + }; + + getDefaultConfig = () => { + return this.#defaultConfig; + }; + + /** + * Use this method to set an option and restart the app if `appConfig.restartOnConfigChange === true` + * + * Used for options that require a restart to take effect. + */ + setAndMaybeRestart = (option, value) => { + this.#config[option] = value; + setMenuOptions(this.#name, this.#config); + }; + + /** Called only from back */ + #save() { + setOptions(this.#name, this.#config); + } + + #setupFront() { + if (process.type === "renderer") { + for (const [fnName, fn] of Object.entries(this)) { + if (typeof fn !== "function") return; + this[fnName] = async (...args) => { + return await ipcRenderer.invoke( + `${this.name}-config-${fnName}`, + ...args, + ); + }; + } + } else if (process.type === "browser") { + for (const [fnName, fn] of Object.entries(this)) { + if (typeof fn !== "function") return; + ipcMain.handle(`${this.name}-config-${fnName}`, (_, ...args) => { + return fn(...args); + }); + } + } + } +}; diff --git a/config/store.js b/config/store.js index c528d6a4..07984820 100644 --- a/config/store.js +++ b/config/store.js @@ -11,6 +11,14 @@ const setDefaultPluginOptions = (store, plugin) => { const migrations = { ">=1.20.0": (store) => { setDefaultPluginOptions(store, "visualizer"); + + if (store.get("plugins.notifications.toastStyle") === undefined) { + const pluginOptions = store.get("plugins.notifications") || {}; + store.set("plugins.notifications", { + ...defaults.plugins.notifications, + ...pluginOptions, + }); + } }, ">=1.17.0": (store) => { setDefaultPluginOptions(store, "picture-in-picture"); diff --git a/index.js b/index.js index 19a2e2f9..dcc23eb7 100644 --- a/index.js +++ b/index.js @@ -14,6 +14,7 @@ const { isTesting } = require("./utils/testing"); const { setUpTray } = require("./tray"); const { setupSongInfo } = require("./providers/song-info"); const { setupAppControls, restart } = require("./providers/app-controls"); +const { APP_PROTOCOL, setupProtocolHandler, handleProtocol } = require("./providers/protocol-handler"); // Catch errors and log them unhandled({ @@ -29,17 +30,9 @@ const app = electron.app; let mainWindow; autoUpdater.autoDownload = false; -if(config.get("options.singleInstanceLock")){ - const gotTheLock = app.requestSingleInstanceLock(); - if (!gotTheLock) app.quit(); - app.on('second-instance', () => { - if (!mainWindow) return; - if (mainWindow.isMinimized()) mainWindow.restore(); - if (!mainWindow.isVisible()) mainWindow.show(); - mainWindow.focus(); - }); -} +const gotTheLock = app.requestSingleInstanceLock(); +if (!gotTheLock) app.exit(); app.commandLine.appendSwitch("enable-features", "SharedArrayBuffer"); // Required for downloader app.allowRendererProcessReuse = true; // https://github.com/electron/electron/issues/18397 @@ -77,6 +70,7 @@ function onClosed() { mainWindow = null; } +/** @param {Electron.BrowserWindow} win */ function loadPlugins(win) { injectCSS(win.webContents, path.join(__dirname, "youtube-music.css")); // Load user CSS @@ -347,9 +341,6 @@ app.on("ready", () => { // Register appID on windows if (is.windows()) { - // Depends on SnoreToast version https://github.com/KDE/snoretoast/blob/master/CMakeLists.txt#L5 - const toastActivatorClsid = "eb1fdd5b-8f70-4b5a-b230-998a2dc19303"; - const appID = "com.github.th-ch.youtube-music"; app.setAppUserModelId(appID); const appLocation = process.execPath; @@ -361,8 +352,7 @@ app.on("ready", () => { const shortcutDetails = electron.shell.readShortcutLink(shortcutPath); // throw error if doesn't exist yet if ( shortcutDetails.target !== appLocation || - shortcutDetails.appUserModelId !== appID || - shortcutDetails.toastActivatorClsid !== toastActivatorClsid + shortcutDetails.appUserModelId !== appID ) { throw "needUpdate"; } @@ -375,7 +365,6 @@ app.on("ready", () => { cwd: path.dirname(appLocation), description: "YouTube Music Desktop App - including custom plugins", appUserModelId: appID, - toastActivatorClsid } ); } @@ -386,6 +375,23 @@ app.on("ready", () => { setApplicationMenu(mainWindow); setUpTray(app, mainWindow); + setupProtocolHandler(mainWindow); + + app.on('second-instance', (_event, commandLine, _workingDirectory) => { + const uri = `${APP_PROTOCOL}://`; + const protocolArgv = commandLine.find(arg => arg.startsWith(uri)); + if (protocolArgv) { + const command = protocolArgv.slice(uri.length, -1); + if (is.dev()) console.debug(`Received command over protocol: "${command}"`); + handleProtocol(command); + return; + } + if (!mainWindow) return; + if (mainWindow.isMinimized()) mainWindow.restore(); + if (!mainWindow.isVisible()) mainWindow.show(); + mainWindow.focus(); + }); + // Autostart at login app.setLoginItemSettings({ openAtLogin: config.get("options.startAtLogin"), diff --git a/menu.js b/menu.js index 068393f6..a741cd5d 100644 --- a/menu.js +++ b/menu.js @@ -133,14 +133,12 @@ const mainMenuTemplate = (win) => { { label: "Single instance lock", type: "checkbox", - checked: config.get("options.singleInstanceLock"), + checked: false, click: (item) => { - config.setMenuOption("options.singleInstanceLock", item.checked); - if (item.checked && !app.hasSingleInstanceLock()) { - app.requestSingleInstanceLock(); - } else if (!item.checked && app.hasSingleInstanceLock()) { + if (item.checked && app.hasSingleInstanceLock()) app.releaseSingleInstanceLock(); - } + else if (!item.checked && !app.hasSingleInstanceLock()) + app.requestSingleInstanceLock(); }, }, { @@ -163,7 +161,7 @@ const mainMenuTemplate = (win) => { if (item.checked && !config.get("options.hideMenuWarned")) { dialog.showMessageBox(win, { type: 'info', title: 'Hide Menu Enabled', - message: "Menu will be hidden on next launch, use 'Alt' to show it (or 'Escape' if using in-app-menu)" + message: "Menu will be hidden on next launch, use [Alt] to show it (or backtick [`] if using in-app-menu)" }); } }, diff --git a/package.json b/package.json index f8f40050..8ffd68d8 100644 --- a/package.json +++ b/package.json @@ -87,11 +87,11 @@ "generate:package": "node utils/generate-package-json.js", "postinstall": "yarn run icon && yarn run plugins", "clean": "del-cli dist", - "build": "yarn run clean && electron-builder --win --mac --linux", - "build:linux": "yarn run clean && electron-builder --linux", - "build:mac": "yarn run clean && electron-builder --mac dmg:x64", - "build:mac:arm64": "yarn run clean && electron-builder --mac dmg:arm64", - "build:win": "yarn run clean && electron-builder --win", + "build": "yarn run clean && electron-builder --win --mac --linux -p never", + "build:linux": "yarn run clean && electron-builder --linux -p never", + "build:mac": "yarn run clean && electron-builder --mac dmg:x64 -p never", + "build:mac:arm64": "yarn run clean && electron-builder --mac dmg:arm64 -p never", + "build:win": "yarn run clean && electron-builder --win -p never", "lint": "xo", "changelog": "auto-changelog", "plugins": "yarn run plugin:adblocker && yarn run plugin:bypass-age-restrictions", @@ -115,8 +115,8 @@ "browser-id3-writer": "^4.4.0", "butterchurn": "^2.6.7", "butterchurn-presets": "^2.4.7", - "custom-electron-prompt": "^1.5.1", - "custom-electron-titlebar": "^4.1.5", + "custom-electron-prompt": "^1.5.4", + "custom-electron-titlebar": "^4.1.6", "electron-better-web-request": "^1.0.1", "electron-debug": "^3.2.0", "electron-is": "^3.0.0", @@ -126,13 +126,12 @@ "electron-updater": "^5.3.0", "filenamify": "^4.3.0", "howler": "^2.2.3", - "html-to-text": "^9.0.3", + "html-to-text": "^9.0.4", "keyboardevent-from-electron-accelerator": "^2.0.0", "keyboardevents-areequal": "^0.2.2", "md5": "^2.3.0", "mpris-service": "^2.1.2", "node-fetch": "^2.6.8", - "node-notifier": "^10.0.1", "simple-youtube-age-restriction-bypass": "https://gitpkg.now.sh/api/pkg.tgz?url=zerodytrash/Simple-YouTube-Age-Restriction-Bypass&commit=v2.5.4", "vudio": "^2.1.1", "youtubei.js": "^3.1.1", diff --git a/plugins/captions-selector/front.js b/plugins/captions-selector/front.js index 6ed83d90..bd5203bb 100644 --- a/plugins/captions-selector/front.js +++ b/plugins/captions-selector/front.js @@ -11,28 +11,32 @@ module.exports = (options) => { document.addEventListener('apiLoaded', (event) => setup(event, options), { once: true, passive: true }); } -/** - * If captions are disabled by default, - * unload "captions" module when video changes. - */ -const videoChanged = (api, options) => { - if (options.disableCaptions) { - setTimeout(() => api.unloadModule("captions"), 100); - } -} - function setup(event, options) { const api = event.detail; - $("video").addEventListener("srcChanged", () => videoChanged(api, options)); - $(".right-controls-buttons").append(captionsSettingsButton); + let captionTrackList = api.getOption("captions", "tracklist"); + + $("video").addEventListener("srcChanged", () => { + if (options.disableCaptions) { + setTimeout(() => api.unloadModule("captions"), 100); + captionsSettingsButton.style.display = "none"; + return; + } + + api.loadModule("captions"); + + setTimeout(() => { + captionTrackList = api.getOption("captions", "tracklist"); + + captionsSettingsButton.style.display = captionTrackList?.length + ? "inline-block" + : "none"; + }, 250); + }); + captionsSettingsButton.onclick = async () => { - api.loadModule("captions"); - - const captionTrackList = api.getOption("captions", "tracklist"); - if (captionTrackList?.length) { const currentCaptionTrack = api.getOption("captions", "track"); let currentIndex = !currentCaptionTrack ? diff --git a/plugins/in-app-menu/back.js b/plugins/in-app-menu/back.js index b75e8a32..d0a54605 100644 --- a/plugins/in-app-menu/back.js +++ b/plugins/in-app-menu/back.js @@ -2,14 +2,12 @@ const path = require("path"); const electronLocalshortcut = require("electron-localshortcut"); -const config = require("../../config"); const { injectCSS } = require("../utils"); const { setupTitlebar, attachTitlebarToWindow } = require('custom-electron-titlebar/main'); setupTitlebar(); //tracks menu visibility -let visible = !config.get("options.hideMenu"); module.exports = (win) => { // css for custom scrollbar + disable drag area(was causing bugs) @@ -18,16 +16,8 @@ module.exports = (win) => { win.once("ready-to-show", () => { attachTitlebarToWindow(win); - //register keyboard shortcut && hide menu if hideMenu is enabled - if (config.get("options.hideMenu")) { - electronLocalshortcut.register(win, "Esc", () => { - setMenuVisibility(!visible); - }); - } + electronLocalshortcut.register(win, "`", () => { + win.webContents.send("toggleMenu"); + }); }); - - function setMenuVisibility(value) { - visible = value; - win.webContents.send("refreshMenu", visible); - } }; diff --git a/plugins/in-app-menu/front.js b/plugins/in-app-menu/front.js index 17c89d4e..e0fcfded 100644 --- a/plugins/in-app-menu/front.js +++ b/plugins/in-app-menu/front.js @@ -5,28 +5,31 @@ const { isEnabled } = require("../../config/plugins"); function $(selector) { return document.querySelector(selector); } module.exports = (options) => { - let visible = !config.get("options.hideMenu"); + let visible = () => !!$('.cet-menubar').firstChild; const bar = new Titlebar({ + icon: "https://cdn-icons-png.flaticon.com/512/5358/5358672.png", backgroundColor: Color.fromHex("#050505"), itemBackgroundColor: Color.fromHex("#1d1d1d"), svgColor: Color.WHITE, - menu: visible ? undefined : null + menu: config.get("options.hideMenu") ? null : undefined }); bar.updateTitle(" "); document.title = "Youtube Music"; - const hideIcon = hide => $('.cet-window-icon').style.display = hide ? 'none' : 'flex'; - - if (options.hideIcon) hideIcon(true); - - ipcRenderer.on("refreshMenu", (_, showMenu) => { - if (showMenu === undefined && !visible) return; - if (showMenu === false) { + const toggleMenu = () => { + if (visible()) { bar.updateMenu(null); - visible = false; } else { bar.refreshMenu(); - visible = true; + } + }; + + $('.cet-window-icon').addEventListener('click', toggleMenu); + ipcRenderer.on("toggleMenu", toggleMenu); + + ipcRenderer.on("refreshMenu", () => { + if (visible()) { + bar.refreshMenu(); } }); @@ -36,14 +39,13 @@ module.exports = (options) => { }); } - 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) document.addEventListener('apiLoaded', () => { setNavbarMargin(); const playPageObserver = new MutationObserver(setNavbarMargin); playPageObserver.observe($('ytmusic-app-layout'), { attributeFilter: ['player-page-open_', 'playerPageOpen_'] }) setupSearchOpenObserver(); + setupMenuOpenObserver(); }, { once: true, passive: true }) }; @@ -55,6 +57,15 @@ function setupSearchOpenObserver() { searchOpenObserver.observe($('ytmusic-search-box'), { attributeFilter: ["opened"] }) } +function setupMenuOpenObserver() { + const menuOpenObserver = new MutationObserver(mutations => { + $('#nav-bar-background').style.webkitAppRegion = + Array.from($('.cet-menubar').childNodes).some(c => c.classList.contains('open')) ? + 'no-drag' : 'drag'; + }); + menuOpenObserver.observe($('.cet-menubar'), { subtree: true, attributeFilter: ["class"] }) +} + function setNavbarMargin() { $('#nav-bar-background').style.right = $('ytmusic-app-layout').playerPageOpen_ ? diff --git a/plugins/in-app-menu/menu.js b/plugins/in-app-menu/menu.js deleted file mode 100644 index dbfd88a3..00000000 --- a/plugins/in-app-menu/menu.js +++ /dev/null @@ -1,14 +0,0 @@ -const { setOptions } = require("../../config/plugins"); - -module.exports = (win, options) => [ - { - label: "Hide Icon", - type: "checkbox", - checked: options.hideIcon, - click: (item) => { - win.webContents.send("hideIcon", item.checked); - options.hideIcon = item.checked; - setOptions("in-app-menu", options); - }, - } -]; diff --git a/plugins/in-app-menu/style.css b/plugins/in-app-menu/style.css index c27b2d57..0148b38a 100644 --- a/plugins/in-app-menu/style.css +++ b/plugins/in-app-menu/style.css @@ -12,11 +12,17 @@ height: 75px !important; } -/* fixes top gap between nav-bar and browse-page */ +/* fix top gap between nav-bar and browse-page */ #browse-page { padding-top: 0 !important; } +/* fix navbar hiding library items */ +ytmusic-section-list-renderer[page-type="MUSIC_PAGE_TYPE_LIBRARY_CONTENT_LANDING_PAGE"] { + top: 50px; + position: relative; +} + /* remove window dragging for nav bar (conflict with titlebar drag) */ ytmusic-nav-bar, .tab-titleiron-icon, @@ -46,7 +52,7 @@ yt-page-navigation-progress, top: 30px !important; } -/* Custom scrollbar */ +/* custom scrollbar */ ::-webkit-scrollbar { width: 12px; background-color: #030303; @@ -59,7 +65,7 @@ yt-page-navigation-progress, background-color: rgba(15, 15, 15, 0.699); } -/* The scrollbar 'thumb' ...that marque oval shape in a scrollbar */ +/* the scrollbar 'thumb' ...that marque oval shape in a scrollbar */ ::-webkit-scrollbar-thumb:vertical { border: 2px solid rgba(0, 0, 0, 0); @@ -70,7 +76,7 @@ yt-page-navigation-progress, -webkit-border-radius: 100px; } ::-webkit-scrollbar-thumb:vertical:active { - background: #4d4c4c; /* Some darker color when you click it */ + background: #4d4c4c; /* some darker color when you click it */ border-radius: 100px; -moz-border-radius: 100px; -webkit-border-radius: 100px; @@ -80,6 +86,17 @@ yt-page-navigation-progress, background-color: inherit } +/** hideMenu toggler **/ +.cet-window-icon { + -webkit-app-region: no-drag; +} + +.cet-window-icon img { + -webkit-user-drag: none; + filter: invert(50%); +} + +/** make navbar draggable **/ #nav-bar-background { -webkit-app-region: drag; } diff --git a/plugins/lyrics-genius/back.js b/plugins/lyrics-genius/back.js index 05548648..cb781ca8 100644 --- a/plugins/lyrics-genius/back.js +++ b/plugins/lyrics-genius/back.js @@ -7,8 +7,13 @@ const fetch = require("node-fetch"); const { cleanupName } = require("../../providers/song-info"); const { injectCSS } = require("../utils"); +let eastAsianChars = /\p{Script=Han}|\p{Script=Katakana}|\p{Script=Hiragana}|\p{Script=Hangul}|\p{Script=Han}/u; +let revRomanized = false; -module.exports = async (win) => { +module.exports = async (win, options) => { + if(options.romanizedLyrics) { + revRomanized = true; + } injectCSS(win.webContents, join(__dirname, "style.css")); ipcMain.on("search-genius-lyrics", async (event, extractedSongInfo) => { @@ -17,17 +22,51 @@ module.exports = async (win) => { }); }; +const toggleRomanized = () => { + revRomanized = !revRomanized; +}; + const fetchFromGenius = async (metadata) => { - const queryString = `${cleanupName(metadata.artist)} ${cleanupName( - metadata.title - )}`; + const songTitle = `${cleanupName(metadata.title)}`; + const songArtist = `${cleanupName(metadata.artist)}`; + let lyrics; + + /* Uses Regex to test the title and artist first for said characters if romanization is enabled. Otherwise normal + Genius Lyrics behavior is observed. + */ + let hasAsianChars = false; + if (revRomanized && (eastAsianChars.test(songTitle) || eastAsianChars.test(songArtist))) { + lyrics = await getLyricsList(`${songArtist} ${songTitle} Romanized`); + hasAsianChars = true; + } else { + lyrics = await getLyricsList(`${songArtist} ${songTitle}`); + } + + /* If the romanization toggle is on, and we did not detect any characters in the title or artist, we do a check + for characters in the lyrics themselves. If this check proves true, we search for Romanized lyrics. + */ + if(revRomanized && !hasAsianChars && eastAsianChars.test(lyrics)) { + lyrics = await getLyricsList(`${songArtist} ${songTitle} Romanized`); + } + return lyrics; +}; + +/** + * Fetches a JSON of songs which is then parsed and passed into getLyrics to get the lyrical content of the first song + * @param {*} queryString + * @returns The lyrics of the first song found using the Genius-Lyrics API + */ +const getLyricsList = async (queryString) => { let response = await fetch( - `https://genius.com/api/search/multi?per_page=5&q=${encodeURI(queryString)}` + `https://genius.com/api/search/multi?per_page=5&q=${encodeURIComponent(queryString)}` ); if (!response.ok) { return null; } + /* Fetch the first URL with the api, giving a collection of song results. + Pick the first song, parsing the json given by the API. + */ const info = await response.json(); let url = ""; try { @@ -36,16 +75,23 @@ const fetchFromGenius = async (metadata) => { } catch { return null; } + let lyrics = await getLyrics(url); + return lyrics; +} - if (is.dev()) { - console.log("Fetching lyrics from Genius:", url); - } - +/** + * + * @param {*} url + * @returns The lyrics of the song URL provided, null if none + */ +const getLyrics = async (url) => { response = await fetch(url); if (!response.ok) { return null; } - + if (is.dev()) { + console.log("Fetching lyrics from Genius:", url); + } const html = await response.text(); const lyrics = convert(html, { baseElements: { @@ -64,8 +110,8 @@ const fetchFromGenius = async (metadata) => { }, }, }); - return lyrics; }; -module.exports.fetchFromGenius = fetchFromGenius; +module.exports.toggleRomanized = toggleRomanized; +module.exports.fetchFromGenius = fetchFromGenius; \ No newline at end of file diff --git a/plugins/lyrics-genius/front.js b/plugins/lyrics-genius/front.js index c62bfb0c..5725d1e1 100644 --- a/plugins/lyrics-genius/front.js +++ b/plugins/lyrics-genius/front.js @@ -2,7 +2,7 @@ const { ipcRenderer } = require("electron"); const is = require("electron-is"); module.exports = () => { - ipcRenderer.on("update-song-info", (_, extractedSongInfo) => { + ipcRenderer.on("update-song-info", (_, extractedSongInfo) => setTimeout(() => { const tabList = document.querySelectorAll("tp-yt-paper-tab"); const tabs = { upNext: tabList[0], @@ -90,5 +90,5 @@ module.exports = () => { tabs.lyrics.removeAttribute("disabled"); tabs.lyrics.removeAttribute("aria-disabled"); } - }); + }, 500)); }; diff --git a/plugins/lyrics-genius/menu.js b/plugins/lyrics-genius/menu.js new file mode 100644 index 00000000..5d8c390e --- /dev/null +++ b/plugins/lyrics-genius/menu.js @@ -0,0 +1,17 @@ +const { setOptions } = require("../../config/plugins"); +const { toggleRomanized } = require("./back"); + +module.exports = (win, options, refreshMenu) => { + return [ + { + label: "Romanized Lyrics", + type: "checkbox", + checked: options.romanizedLyrics, + click: (item) => { + options.romanizedLyrics = item.checked; + setOptions('lyrics-genius', options); + toggleRomanized(); + }, + }, + ]; +}; \ No newline at end of file diff --git a/plugins/notifications/back.js b/plugins/notifications/back.js index f3282ed3..385ecca7 100644 --- a/plugins/notifications/back.js +++ b/plugins/notifications/back.js @@ -2,8 +2,9 @@ const { Notification } = require("electron"); const is = require("electron-is"); const registerCallback = require("../../providers/song-info"); const { notificationImage } = require("./utils"); +const config = require("./config"); -const notify = (info, options) => { +const notify = (info) => { // Fill the notification with content const notification = { @@ -11,7 +12,7 @@ const notify = (info, options) => { body: info.artist, icon: notificationImage(info), silent: true, - urgency: options.urgency, + urgency: config.get('urgency'), }; // Send the notification @@ -21,24 +22,25 @@ const notify = (info, options) => { return currentNotification; }; -const setup = (options) => { +const setup = () => { let oldNotification; let currentUrl; registerCallback(songInfo => { - if (!songInfo.isPaused && (songInfo.url !== currentUrl || options.unpauseNotification)) { + if (!songInfo.isPaused && (songInfo.url !== currentUrl || config.get('unpauseNotification'))) { // Close the old notification oldNotification?.close(); currentUrl = songInfo.url; // This fixes a weird bug that would cause the notification to be updated instead of showing - setTimeout(() => { oldNotification = notify(songInfo, options) }, 10); + setTimeout(() => { oldNotification = notify(songInfo) }, 10); } }); } +/** @param {Electron.BrowserWindow} win */ module.exports = (win, options) => { // Register the callback for new song information is.windows() && options.interactive ? - require("./interactive")(win, options.unpauseNotification) : - setup(options); + require("./interactive")(win) : + setup(); }; diff --git a/plugins/notifications/config.js b/plugins/notifications/config.js new file mode 100644 index 00000000..d0898dc3 --- /dev/null +++ b/plugins/notifications/config.js @@ -0,0 +1,5 @@ +const { PluginConfig } = require("../../config/dynamic"); + +const config = new PluginConfig("notifications"); + +module.exports = { ...config }; diff --git a/plugins/notifications/interactive.js b/plugins/notifications/interactive.js index b7535ea0..7bda6944 100644 --- a/plugins/notifications/interactive.js +++ b/plugins/notifications/interactive.js @@ -1,106 +1,235 @@ -const { notificationImage, icons } = require("./utils"); +const { notificationImage, icons, save_temp_icons, secondsToMinutes, ToastStyles } = require("./utils"); const getSongControls = require('../../providers/song-controls'); const registerCallback = require("../../providers/song-info"); -const is = require("electron-is"); -const WindowsToaster = require('node-notifier').WindowsToaster; +const { changeProtocolHandler } = require("../../providers/protocol-handler"); +const { setTrayOnClick, setTrayOnDoubleClick } = require("../../tray"); -const notifier = new WindowsToaster({ withFallback: true }); +const { Notification, app, ipcMain } = require("electron"); +const path = require('path'); -//store song controls reference on launch -let controls; -let notificationOnUnpause; +const config = require("./config"); -module.exports = (win, unpauseNotification) => { - //Save controls and onPause option - const { playPause, next, previous } = getSongControls(win); - controls = { playPause, next, previous }; - notificationOnUnpause = unpauseNotification; +let songControls; +let savedNotification; - let currentUrl; +/** @param {Electron.BrowserWindow} win */ +module.exports = (win) => { + songControls = getSongControls(win); + + let currentSeconds = 0; + ipcMain.on('apiLoaded', () => win.webContents.send('setupTimeChangedListener')); + + ipcMain.on('timeChanged', (_, t) => currentSeconds = t); + + if (app.isPackaged) save_temp_icons(); + + let savedSongInfo; + let lastUrl; // Register songInfoCallback registerCallback(songInfo => { - if (!songInfo.isPaused && (songInfo.url !== currentUrl || notificationOnUnpause)) { - currentUrl = songInfo.url; - sendToaster(songInfo); + if (!songInfo.artist && !songInfo.title) return; + savedSongInfo = { ...songInfo }; + if (!songInfo.isPaused && + (songInfo.url !== lastUrl || config.get("unpauseNotification")) + ) { + lastUrl = songInfo.url + sendNotification(songInfo); } }); - win.webContents.once("closed", () => { - deleteNotification() + if (config.get("trayControls")) { + setTrayOnClick(() => { + if (savedNotification) { + savedNotification.close(); + savedNotification = undefined; + } else if (savedSongInfo) { + sendNotification({ + ...savedSongInfo, + elapsedSeconds: currentSeconds + }) + } + }); + + setTrayOnDoubleClick(() => { + if (win.isVisible()) { + win.hide(); + } else win.show(); + }) + } + + + app.once("before-quit", () => { + savedNotification?.close(); }); + + + changeProtocolHandler( + (cmd) => { + if (Object.keys(songControls).includes(cmd)) { + songControls[cmd](); + if (config.get("refreshOnPlayPause") && ( + cmd === 'pause' || + (cmd === 'play' && !config.get("unpauseNotification")) + ) + ) { + setImmediate(() => + sendNotification({ + ...savedSongInfo, + isPaused: cmd === 'pause', + elapsedSeconds: currentSeconds + }) + ); + } + } + } + ) } -//delete old notification -let toDelete; -function deleteNotification() { - if (toDelete !== undefined) { - // To remove the notification it has to be done this way - const removeNotif = Object.assign(toDelete, { - remove: toDelete.id - }) - notifier.notify(removeNotif) +function sendNotification(songInfo) { + const iconSrc = notificationImage(songInfo); - toDelete = undefined; + savedNotification?.close(); + + savedNotification = new Notification({ + title: songInfo.title || "Playing", + body: songInfo.artist, + icon: iconSrc, + silent: true, + // https://learn.microsoft.com/en-us/uwp/schemas/tiles/toastschema/schema-root + // https://learn.microsoft.com/en-us/windows/apps/design/shell/tiles-and-notifications/toast-schema + // https://learn.microsoft.com/en-us/windows/apps/design/shell/tiles-and-notifications/adaptive-interactive-toasts?tabs=xml + // https://learn.microsoft.com/en-us/uwp/api/windows.ui.notifications.toasttemplatetype + toastXml: get_xml(songInfo, iconSrc), + }); + + savedNotification.on("close", (_) => { + savedNotification = undefined; + }); + + savedNotification.show(); +} + +const get_xml = (songInfo, iconSrc) => { + switch (config.get("toastStyle")) { + default: + case ToastStyles.logo: + case ToastStyles.legacy: + return xml_logo(songInfo, iconSrc); + case ToastStyles.banner_top_custom: + return xml_banner_top_custom(songInfo, iconSrc); + case ToastStyles.hero: + return xml_hero(songInfo, iconSrc); + case ToastStyles.banner_bottom: + return xml_banner_bottom(songInfo, iconSrc); + case ToastStyles.banner_centered_bottom: + return xml_banner_centered_bottom(songInfo, iconSrc); + case ToastStyles.banner_centered_top: + return xml_banner_centered_top(songInfo, iconSrc); + }; +} + +const iconLocation = app.isPackaged ? + path.resolve(app.getPath("userData"), 'icons') : + path.resolve(__dirname, '..', '..', 'assets/media-icons-black'); + +const display = (kind) => { + if (config.get("toastStyle") === ToastStyles.legacy) { + return `content="${icons[kind]}"`; + } else { + return `\ + content="${config.get("hideButtonText") ? "" : kind.charAt(0).toUpperCase() + kind.slice(1)}"\ + imageUri="file:///${path.resolve(__dirname, iconLocation, `${kind}.png`)}" + `; } } -//New notification -function sendToaster(songInfo) { - deleteNotification(); - //download image and get path - let imgSrc = notificationImage(songInfo, true); - toDelete = { - appID: "com.github.th-ch.youtube-music", - title: songInfo.title || "Playing", - message: songInfo.artist, - id: parseInt(Math.random() * 1000000, 10), - icon: imgSrc, - actions: [ - icons.previous, - songInfo.isPaused ? icons.play : icons.pause, - icons.next - ], - sound: false, - }; - //send notification - notifier.notify( - toDelete, - (err, data) => { - // Will also wait until notification is closed. - if (err) { - console.log(`ERROR = ${err.toString()}\n DATA = ${data}`); - } - switch (data) { - //buttons - case icons.previous.normalize(): - controls.previous(); - return; - case icons.next.normalize(): - controls.next(); - return; - case icons.play.normalize(): - controls.playPause(); - // dont delete notification on play/pause - toDelete = undefined; - //manually send notification if not sending automatically - if (!notificationOnUnpause) { - songInfo.isPaused = false; - sendToaster(songInfo); - } - return; - case icons.pause.normalize(): - controls.playPause(); - songInfo.isPaused = true; - toDelete = undefined; - sendToaster(songInfo); - return; - //Native datatype - case "dismissed": - case "timeout": - deleteNotification(); - } - } +const getButton = (kind) => + ``; - ); +const getButtons = (isPaused) => `\ + + ${getButton('previous')} + ${isPaused ? getButton('play') : getButton('pause')} + ${getButton('next')} + \ +`; + +const toast = (content, isPaused) => `\ + + `; + +const xml_image = ({ title, artist, isPaused }, imgSrc, placement) => toast(`\ + + ${title} + ${artist}\ +`, isPaused); + + +const xml_logo = (songInfo, imgSrc) => xml_image(songInfo, imgSrc, 'placement="appLogoOverride"'); + +const xml_hero = (songInfo, imgSrc) => xml_image(songInfo, imgSrc, 'placement="hero"'); + +const xml_banner_bottom = (songInfo, imgSrc) => xml_image(songInfo, imgSrc, ''); + +const xml_banner_top_custom = (songInfo, imgSrc) => toast(`\ + + + + + ${songInfo.title} + ${songInfo.artist} + + ${xml_more_data(songInfo)} + \ +`, songInfo.isPaused); + +const xml_more_data = ({ album, elapsedSeconds, songDuration }) => `\ + + ${album ? + `${album}` : ''} + ${secondsToMinutes(elapsedSeconds)} / ${secondsToMinutes(songDuration)} +\ +`; + +const xml_banner_centered_bottom = ({ title, artist, isPaused }, imgSrc) => toast(`\ + + + + ${title} + ${artist} + + + \ +`, isPaused); + +const xml_banner_centered_top = ({ title, artist, isPaused }, imgSrc) => toast(`\ + + + + + ${title} + ${artist} + + \ +`, isPaused); + +const titleFontPicker = (title) => { + if (title.length <= 13) { + return 'Header'; + } else if (title.length <= 22) { + return 'Subheader'; + } else if (title.length <= 26) { + return 'Title'; + } else { + return 'Subtitle'; + } } diff --git a/plugins/notifications/menu.js b/plugins/notifications/menu.js index 3f239909..014be184 100644 --- a/plugins/notifications/menu.js +++ b/plugins/notifications/menu.js @@ -1,30 +1,80 @@ -const { urgencyLevels, setOption } = require("./utils"); +const { urgencyLevels, ToastStyles, snakeToCamel } = require("./utils"); const is = require("electron-is"); +const config = require("./config"); -module.exports = (win, options) => [ - ...(is.linux() ? - [{ - label: "Notification Priority", - submenu: urgencyLevels.map(level => ({ - label: level.name, - type: "radio", - checked: options.urgency === level.value, - click: () => setOption(options, "urgency", level.value) - })), - }] : - []), - ...(is.windows() ? - [{ - label: "Interactive Notifications", - type: "checkbox", - checked: options.interactive, - click: (item) => setOption(options, "interactive", item.checked) - }] : - []), +module.exports = (_win, options) => [ + ...(is.linux() + ? [ + { + label: "Notification Priority", + submenu: urgencyLevels.map((level) => ({ + label: level.name, + type: "radio", + checked: options.urgency === level.value, + click: () => config.set("urgency", level.value), + })), + }, + ] + : []), + ...(is.windows() + ? [ + { + label: "Interactive Notifications", + type: "checkbox", + checked: options.interactive, + // doesn't update until restart + click: (item) => config.setAndMaybeRestart("interactive", item.checked), + }, + { + // submenu with settings for interactive notifications (name shouldn't be too long) + label: "Interactive Settings", + submenu: [ + { + label: "Open/Close on tray click", + type: "checkbox", + checked: options.trayControls, + click: (item) => config.set("trayControls", item.checked), + }, + { + label: "Hide Button Text", + type: "checkbox", + checked: options.hideButtonText, + click: (item) => config.set("hideButtonText", item.checked), + }, + { + label: "Refresh on Play/Pause", + type: "checkbox", + checked: options.refreshOnPlayPause, + click: (item) => config.set("refreshOnPlayPause", item.checked), + } + ] + }, + { + label: "Style", + submenu: getToastStyleMenuItems(options) + }, + ] + : []), { label: "Show notification on unpause", type: "checkbox", checked: options.unpauseNotification, - click: (item) => setOption(options, "unpauseNotification", item.checked) + click: (item) => config.set("unpauseNotification", item.checked), }, ]; + +function getToastStyleMenuItems(options) { + const arr = new Array(Object.keys(ToastStyles).length); + + // ToastStyles index starts from 1 + for (const [name, index] of Object.entries(ToastStyles)) { + arr[index - 1] = { + label: snakeToCamel(name), + type: "radio", + checked: options.toastStyle === index, + click: () => config.set("toastStyle", index), + }; + } + + return arr; +} diff --git a/plugins/notifications/utils.js b/plugins/notifications/utils.js index 7cb9e61e..8717b3a1 100644 --- a/plugins/notifications/utils.js +++ b/plugins/notifications/utils.js @@ -1,10 +1,22 @@ -const { setMenuOptions } = require("../../config/plugins"); const path = require("path"); const { app } = require("electron"); const fs = require("fs"); +const config = require("./config"); const icon = "assets/youtube-music.png"; -const tempIcon = path.join(app.getPath("userData"), "tempIcon.png"); +const userData = app.getPath("userData"); +const tempIcon = path.join(userData, "tempIcon.png"); +const tempBanner = path.join(userData, "tempBanner.png"); + +module.exports.ToastStyles = { + logo: 1, + banner_centered_top: 2, + hero: 3, + banner_top_custom: 4, + banner_centered_bottom: 5, + banner_bottom: 6, + legacy: 7 +} module.exports.icons = { play: "\u{1405}", // ᐅ @@ -13,38 +25,37 @@ module.exports.icons = { previous: "\u{1438}" // ᐸ } -module.exports.setOption = (options, option, value) => { - options[option] = value; - setMenuOptions("notifications", options) -} - module.exports.urgencyLevels = [ { name: "Low", value: "low" }, { name: "Normal", value: "normal" }, { name: "High", value: "critical" }, ]; -module.exports.notificationImage = function (songInfo, saveIcon = false) { - //return local path to temp icon - if (saveIcon && !!songInfo.image) { - try { - fs.writeFileSync(tempIcon, - centerNativeImage(songInfo.image) - .toPNG() - ); - } catch (err) { - console.log(`Error writing song icon to disk:\n${err.toString()}`) - return icon; - } - return tempIcon; - } - //else: return image - return songInfo.image - ? centerNativeImage(songInfo.image) - : icon +module.exports.notificationImage = (songInfo) => { + if (!songInfo.image) return icon; + if (!config.get("interactive")) return nativeImageToLogo(songInfo.image); + + switch (config.get("toastStyle")) { + case module.exports.ToastStyles.logo: + case module.exports.ToastStyles.legacy: + return this.saveImage(nativeImageToLogo(songInfo.image), tempIcon); + default: + return this.saveImage(songInfo.image, tempBanner); + }; }; -function centerNativeImage(nativeImage) { +module.exports.saveImage = (img, save_path) => { + try { + fs.writeFileSync(save_path, img.toPNG()); + } catch (err) { + console.log(`Error writing song icon to disk:\n${err.toString()}`) + return icon; + } + return save_path; +} + + +function nativeImageToLogo(nativeImage) { const tempImage = nativeImage.resize({ height: 256 }); const margin = Math.max((tempImage.getSize().width - 256), 0); @@ -54,3 +65,27 @@ function centerNativeImage(nativeImage) { width: 256, height: 256 }) } + +module.exports.save_temp_icons = () => { + for (const kind of Object.keys(module.exports.icons)) { + const destinationPath = path.join(userData, 'icons', `${kind}.png`); + if (fs.existsSync(destinationPath)) continue; + const iconPath = path.resolve(__dirname, "../../assets/media-icons-black", `${kind}.png`); + fs.mkdirSync(path.dirname(destinationPath), { recursive: true }); + fs.copyFile(iconPath, destinationPath, () => { }); + } +}; + +module.exports.snakeToCamel = (str) => { + return str.replace(/([-_][a-z]|^[a-z])/g, (group) => + group.toUpperCase() + .replace('-', ' ') + .replace('_', ' ') + ); +} + +module.exports.secondsToMinutes = (seconds) => { + const minutes = Math.floor(seconds / 60); + const secondsLeft = seconds % 60; + return `${minutes}:${secondsLeft < 10 ? '0' : ''}${secondsLeft}`; +} diff --git a/plugins/picture-in-picture/front.js b/plugins/picture-in-picture/front.js index 941a4333..27600641 100644 --- a/plugins/picture-in-picture/front.js +++ b/plugins/picture-in-picture/front.js @@ -34,7 +34,7 @@ const observer = new MutationObserver(() => { menu = getSongMenu(); if (!menu) return; } - if (menu.contains(pipButton)) return; + if (menu.contains(pipButton) || !menu.parentElement.eventSink_?.matches('ytmusic-menu-renderer.ytmusic-player-bar')) return; const menuUrl = $( 'tp-yt-paper-listbox [tabindex="0"] #navigation-endpoint' )?.href; diff --git a/plugins/playback-speed/front.js b/plugins/playback-speed/front.js index 6f972d14..da01182c 100644 --- a/plugins/playback-speed/front.js +++ b/plugins/playback-speed/front.js @@ -30,7 +30,7 @@ const observePopupContainer = () => { menu = getSongMenu(); } - if (menu && menu.lastElementChild.lastElementChild.innerText.startsWith('Stats') && !menu.contains(slider)) { + if (menu && menu.parentElement.eventSink_?.matches('ytmusic-menu-renderer.ytmusic-player-bar') && !menu.contains(slider)) { menu.prepend(slider); if (!observingSlider) { setupSliderListener(); diff --git a/plugins/shortcuts/mpris.js b/plugins/shortcuts/mpris.js index 90c1406a..e0ab3468 100644 --- a/plugins/shortcuts/mpris.js +++ b/plugins/shortcuts/mpris.js @@ -1,5 +1,5 @@ const mpris = require("mpris-service"); -const {ipcMain} = require("electron"); +const { ipcMain } = require("electron"); const registerCallback = require("../../providers/song-info"); const getSongControls = require("../../providers/song-controls"); const config = require("../../config"); @@ -18,9 +18,10 @@ function setupMPRIS() { return player; } +/** @param {Electron.BrowserWindow} win */ function registerMPRIS(win) { const songControls = getSongControls(win); - const {playPause, next, previous, volumeMinus10, volumePlus10, shuffle} = songControls; + const { playPause, next, previous, volumeMinus10, volumePlus10, shuffle } = songControls; try { const secToMicro = n => Math.round(Number(n) * 1e6); const microToSec = n => Math.round(Number(n) / 1e6); @@ -30,6 +31,13 @@ function registerMPRIS(win) { const player = setupMPRIS(); + ipcMain.on("apiLoaded", () => { + win.webContents.send("setupSeekedListener", "mpris"); + win.webContents.send("setupTimeChangedListener", "mpris"); + win.webContents.send("setupRepeatChangedListener", "mpris"); + win.webContents.send("setupVolumeChangedListener", "mpris"); + }); + ipcMain.on('seeked', (_, t) => player.seeked(secToMicro(t))); let currentSeconds = 0; @@ -109,7 +117,7 @@ function registerMPRIS(win) { // With precise volume we can set the volume to the exact value. let newVol = parseInt(newVolume * 100); if (parseInt(player.volume * 100) !== newVol) { - if (!autoUpdate){ + if (!autoUpdate) { mprisVolNewer = true; autoUpdate = false; win.webContents.send('setVolume', newVol); diff --git a/plugins/taskbar-mediacontrol/back.js b/plugins/taskbar-mediacontrol/back.js index 26a11732..dce9a6b3 100644 --- a/plugins/taskbar-mediacontrol/back.js +++ b/plugins/taskbar-mediacontrol/back.js @@ -32,22 +32,22 @@ function setThumbar(win, songInfo) { win.setThumbarButtons([ { tooltip: 'Previous', - icon: get('backward.png'), + icon: get('previous'), click() { controls.previous(win.webContents); } }, { tooltip: 'Play/Pause', // Update icon based on play state - icon: songInfo.isPaused ? get('play.png') : get('pause.png'), + icon: songInfo.isPaused ? get('play') : get('pause'), click() { controls.playPause(win.webContents); } }, { tooltip: 'Next', - icon: get('forward.png'), + icon: get('next'), click() { controls.next(win.webContents); } } ]); } // Util -function get(file) { - return path.join(__dirname, "assets", file); +function get(kind) { + return path.join(__dirname, "../../assets/media-icons-black", `${kind}.png`); } diff --git a/plugins/tuna-obs/back.js b/plugins/tuna-obs/back.js index 574562f1..5b3eec65 100644 --- a/plugins/tuna-obs/back.js +++ b/plugins/tuna-obs/back.js @@ -28,7 +28,9 @@ const post = async (data) => { fetch(url, { method: 'POST', headers, body: JSON.stringify({ data }) }).catch(e => console.log(`Error: '${e.code || e.errno}' - when trying to access obs-tuna webserver at port ${port}`)); } +/** @param {Electron.BrowserWindow} win */ module.exports = async (win) => { + ipcMain.on('apiLoaded', () => win.webContents.send('setupTimeChangedListener')); ipcMain.on('timeChanged', async (_, t) => { if (!data.title) return; data.progress = secToMilisec(t); diff --git a/preload.js b/preload.js index 3dc5aa89..492f3192 100644 --- a/preload.js +++ b/preload.js @@ -1,9 +1,9 @@ -require("./providers/front-logger")(); const config = require("./config"); const { fileExists } = require("./plugins/utils"); const setupSongInfo = require("./providers/song-info-front"); const { setupSongControls } = require("./providers/song-controls-front"); const { ipcRenderer } = require("electron"); +const is = require("electron-is"); const plugins = config.plugins.getEnabled(); @@ -69,6 +69,13 @@ document.addEventListener("DOMContentLoaded", () => { // Blocks the "Are You Still There?" popup by setting the last active time to Date.now every 15min setInterval(() => window._lact = Date.now(), 900000); + + // setup back to front logger + if (is.dev()) { + ipcRenderer.on("log", (_event, log) => { + console.log(JSON.parse(log)); + }); + } }); function listenForApiLoad() { @@ -118,6 +125,7 @@ function onApiLoaded() { ); document.dispatchEvent(new CustomEvent('apiLoaded', { detail: api })); + ipcRenderer.send('apiLoaded'); // Remove upgrade button if (config.get("options.removeUpgradeButton")) { diff --git a/providers/front-logger.js b/providers/front-logger.js deleted file mode 100644 index 99da2329..00000000 --- a/providers/front-logger.js +++ /dev/null @@ -1,13 +0,0 @@ -const { ipcRenderer } = require("electron"); - -function logToString(log) { - return (typeof log === "string") ? - log : - JSON.stringify(log, null, "\t"); -} - -module.exports = () => { - ipcRenderer.on("log", (_event, log) => { - console.log(logToString(log)); - }); -}; diff --git a/providers/protocol-handler.js b/providers/protocol-handler.js new file mode 100644 index 00000000..6b0d36e1 --- /dev/null +++ b/providers/protocol-handler.js @@ -0,0 +1,44 @@ +const { app } = require("electron"); +const path = require("path"); +const getSongControls = require("./song-controls"); + +const APP_PROTOCOL = "youtubemusic"; + +let protocolHandler; + +function setupProtocolHandler(win) { + if (process.defaultApp && process.argv.length >= 2) { + app.setAsDefaultProtocolClient( + APP_PROTOCOL, + process.execPath, + [path.resolve(process.argv[1])] + ); + } else { + app.setAsDefaultProtocolClient(APP_PROTOCOL) + } + + const songControls = getSongControls(win); + + protocolHandler = (cmd) => { + if (Object.keys(songControls).includes(cmd)) { + songControls[cmd](); + } + } +} + +function handleProtocol(cmd) { + protocolHandler(cmd); +} + +function changeProtocolHandler(f) { + protocolHandler = f; +} + +module.exports = { + APP_PROTOCOL, + setupProtocolHandler, + handleProtocol, + changeProtocolHandler, +}; + + diff --git a/providers/song-controls-front.js b/providers/song-controls-front.js index 07c50542..4bc33274 100644 --- a/providers/song-controls-front.js +++ b/providers/song-controls-front.js @@ -1,13 +1,8 @@ const { ipcRenderer } = require("electron"); -const config = require("../config"); -const is = require("electron-is"); module.exports.setupSongControls = () => { document.addEventListener('apiLoaded', e => { ipcRenderer.on("seekTo", (_, t) => e.detail.seekTo(t)); ipcRenderer.on("seekBy", (_, t) => e.detail.seekBy(t)); - if (is.linux() && config.plugins.isEnabled('shortcuts')) { // MPRIS Enabled - document.querySelector('video').addEventListener('seeked', v => ipcRenderer.send('seeked', v.target.currentTime)); - } }, { once: true, passive: true }) }; diff --git a/providers/song-controls.js b/providers/song-controls.js index a23190eb..93f352d1 100644 --- a/providers/song-controls.js +++ b/providers/song-controls.js @@ -8,7 +8,7 @@ const pressKey = (window, key, modifiers = []) => { }; module.exports = (win) => { - return { + const commands = { // Playback previous: () => pressKey(win, "k"), next: () => pressKey(win, "j"), @@ -21,8 +21,7 @@ module.exports = (win) => { go1sForward: () => pressKey(win, "l", ["shift"]), shuffle: () => pressKey(win, "s"), switchRepeat: (n = 1) => { - for (let i = 0; i < n; i++) - pressKey(win, "r"); + for (let i = 0; i < n; i++) pressKey(win, "r"); }, // General volumeMinus10: () => pressKey(win, "-"), @@ -50,4 +49,9 @@ module.exports = (win) => { search: () => pressKey(win, "/"), showShortcuts: () => pressKey(win, "/", ["shift"]), }; + return { + ...commands, + play: commands.playPause, + pause: commands.playPause + }; }; diff --git a/providers/song-info-front.js b/providers/song-info-front.js index 958bcd0a..f033847c 100644 --- a/providers/song-info-front.js +++ b/providers/song-info-front.js @@ -1,8 +1,5 @@ -const {ipcRenderer} = require("electron"); -const is = require('electron-is'); -const {getImage} = require("./song-info"); - -const config = require("../config"); +const { ipcRenderer } = require("electron"); +const { getImage } = require("./song-info"); global.songInfo = {}; @@ -17,20 +14,69 @@ ipcRenderer.on("update-song-info", async (_, extractedSongInfo) => { // used because 'loadeddata' or 'loadedmetadata' weren't firing on song start for some users (https://github.com/th-ch/youtube-music/issues/473) const srcChangedEvent = new CustomEvent('srcChanged'); +const singleton = (fn) => { + let called = false; + return (...args) => { + if (called) return; + called = true; + return fn(...args); + } +} + +module.exports.setupSeekedListener = singleton(() => { + document.querySelector('video')?.addEventListener('seeked', v => ipcRenderer.send('seeked', v.target.currentTime)); +}); + +module.exports.setupTimeChangedListener = singleton(() => { + const progressObserver = new MutationObserver(mutations => { + ipcRenderer.send('timeChanged', mutations[0].target.value); + global.songInfo.elapsedSeconds = mutations[0].target.value; + }); + progressObserver.observe($('#progress-bar'), { attributeFilter: ["value"] }); +}); + +module.exports.setupRepeatChangedListener = singleton(() => { + const repeatObserver = new MutationObserver(mutations => { + ipcRenderer.send('repeatChanged', mutations[0].target.__dataHost.getState().queue.repeatMode); + }); + repeatObserver.observe($('#right-controls .repeat'), { attributeFilter: ["title"] }); + + // Emit the initial value as well; as it's persistent between launches. + ipcRenderer.send('repeatChanged', $('ytmusic-player-bar').getState().queue.repeatMode); +}); + +module.exports.setupVolumeChangedListener = singleton((api) => { + $('video').addEventListener('volumechange', (_) => { + ipcRenderer.send('volumeChanged', api.getVolume()); + }); + // Emit the initial value as well; as it's persistent between launches. + ipcRenderer.send('volumeChanged', api.getVolume()); +}); + module.exports = () => { document.addEventListener('apiLoaded', apiEvent => { - if (config.plugins.isEnabled('tuna-obs') || - (is.linux() && config.plugins.isEnabled('shortcuts'))) { - setupTimeChangeListener(); - setupRepeatChangeListener(); - setupVolumeChangeListener(apiEvent.detail); - } + ipcRenderer.on("setupTimeChangedListener", async () => { + this.setupTimeChangedListener(); + }); + + ipcRenderer.on("setupRepeatChangedListener", async () => { + this.setupRepeatChangedListener(); + }); + + ipcRenderer.on("setupVolumeChangedListener", async () => { + this.setupVolumeChangedListener(apiEvent.detail); + }); + + ipcRenderer.on("setupSeekedListener", async () => { + this.setupSeekedListener(); + }); + const video = $('video'); // name = "dataloaded" and abit later "dataupdated" apiEvent.detail.addEventListener('videodatachange', (name, _dataEvent) => { if (name !== 'dataloaded') return; video.dispatchEvent(srcChangedEvent); - sendSongInfo(); + setTimeout(sendSongInfo()); }) for (const status of ['playing', 'pause']) { @@ -49,37 +95,14 @@ module.exports = () => { data.videoDetails.album = $$( ".byline.ytmusic-player-bar > .yt-simple-endpoint" - ).find(e => e.href?.includes("browse"))?.textContent; + ).find(e => + e.href?.includes("browse/FEmusic_library_privately_owned_release") + || e.href?.includes("browse/MPREb") + )?.textContent; data.videoDetails.elapsedSeconds = Math.floor(video.currentTime); data.videoDetails.isPaused = false; ipcRenderer.send("video-src-changed", JSON.stringify(data)); } - }, {once: true, passive: true}); + }, { once: true, passive: true }); }; - -function setupTimeChangeListener() { - const progressObserver = new MutationObserver(mutations => { - ipcRenderer.send('timeChanged', mutations[0].target.value); - global.songInfo.elapsedSeconds = mutations[0].target.value; - }); - progressObserver.observe($('#progress-bar'), {attributeFilter: ["value"]}) -} - -function setupRepeatChangeListener() { - const repeatObserver = new MutationObserver(mutations => { - ipcRenderer.send('repeatChanged', mutations[0].target.__dataHost.getState().queue.repeatMode) - }); - repeatObserver.observe($('#right-controls .repeat'), {attributeFilter: ["title"]}); - - // Emit the initial value as well; as it's persistent between launches. - ipcRenderer.send('repeatChanged', $('ytmusic-player-bar').getState().queue.repeatMode); -} - -function setupVolumeChangeListener(api) { - $('video').addEventListener('volumechange', (_) => { - ipcRenderer.send('volumeChanged', api.getVolume()); - }); - // Emit the initial value as well; as it's persistent between launches. - ipcRenderer.send('volumeChanged', api.getVolume()); -} diff --git a/providers/song-info.js b/providers/song-info.js index 88f757a3..1e26b99c 100644 --- a/providers/song-info.js +++ b/providers/song-info.js @@ -61,7 +61,8 @@ const handleData = async (responseText, win) => { songInfo.album = data?.videoDetails?.album; // Will be undefined if video exist const oldUrl = songInfo.imageSrc; - songInfo.imageSrc = videoDetails.thumbnail?.thumbnails?.pop()?.url.split("?")[0]; + const thumbnails = videoDetails.thumbnail?.thumbnails; + songInfo.imageSrc = thumbnails[thumbnails.length - 1]?.url.split("?")[0]; if (oldUrl !== songInfo.imageSrc) { songInfo.image = await getImage(songInfo.imageSrc); } @@ -95,7 +96,7 @@ const registerProvider = (win) => { await handleData(responseText, win); handlingData = false; callbacks.forEach((c) => { - c(songInfo); + c(songInfo, "video-src-changed"); }); }); ipcMain.on("playPaused", (_, { isPaused, elapsedSeconds }) => { @@ -103,7 +104,7 @@ const registerProvider = (win) => { songInfo.elapsedSeconds = elapsedSeconds; if (handlingData) return; callbacks.forEach((c) => { - c(songInfo); + c(songInfo, "playPaused"); }); }) }; diff --git a/readme.md b/readme.md index bf086f7f..7682c40b 100644 --- a/readme.md +++ b/readme.md @@ -118,7 +118,7 @@ winget install th-ch.YouTubeMusic - **Auto confirm when paused** (Always Enabled): disable the ["Continue Watching?"](https://user-images.githubusercontent.com/61631665/129977894-01c60740-7ec6-4bf0-9a2c-25da24491b0e.png) popup that pause music after a certain time -> If using `Hide Menu` option - you can show the menu with the `alt` key (or `escape` if using the in-app-menu plugin) +> If `Hide Menu` option is on - you can show the menu with the alt key (or \` [backtick] if using the in-app-menu plugin) ## Themes diff --git a/tray.js b/tray.js index cf407aa2..46b76f83 100644 --- a/tray.js +++ b/tray.js @@ -1,14 +1,29 @@ const path = require("path"); -const { app, Menu, nativeImage, Tray } = require("electron"); +const { Menu, nativeImage, Tray } = require("electron"); const { restart } = require("./providers/app-controls"); const config = require("./config"); const getSongControls = require("./providers/song-controls"); // Prevent tray being garbage collected + +/** @type {Electron.Tray} */ let tray; +module.exports.setTrayOnClick = (fn) => { + if (!tray) return; + tray.removeAllListeners('click'); + tray.on("click", fn); +}; + +// wont do anything on macos since its disabled +module.exports.setTrayOnDoubleClick = (fn) => { + if (!tray) return; + tray.removeAllListeners('double-click'); + tray.on("double-click", fn); +}; + module.exports.setUpTray = (app, win) => { if (!config.get("options.tray")) { tray = undefined; @@ -17,13 +32,19 @@ module.exports.setUpTray = (app, win) => { const { playPause, next, previous } = getSongControls(win); const iconPath = path.join(__dirname, "assets", "youtube-music-tray.png"); + let trayIcon = nativeImage.createFromPath(iconPath).resize({ width: 16, height: 16, }); + tray = new Tray(trayIcon); - tray.setToolTip("Youtube Music"); + + tray.setToolTip("YouTube Music"); + + // macOS only tray.setIgnoreDoubleClickEvents(true); + tray.on("click", () => { if (config.get("options.trayClickPlayPause")) { playPause(); diff --git a/yarn.lock b/yarn.lock index 971b652a..6a69ab79 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2382,16 +2382,16 @@ __metadata: languageName: node linkType: hard -"custom-electron-prompt@npm:^1.5.1": - version: 1.5.1 - resolution: "custom-electron-prompt@npm:1.5.1" +"custom-electron-prompt@npm:^1.5.4": + version: 1.5.4 + resolution: "custom-electron-prompt@npm:1.5.4" peerDependencies: electron: ">=10.0.0" - checksum: 43a0d72a7a3471135822cb210d580285f70080d9d3a7b03f82cd4be403059fe20ea05ebdd1f9534928c386ab25a353e678f2cfb3f4ca016b41f3366bff700767 + checksum: 93995b5f0e9d14401a8c4fdd358af32d8b7585b59b111667cfa55f9505109c08914f3140953125b854e5d09e811de8c76c7fec718934c13e8a1ad09fe1b85270 languageName: node linkType: hard -"custom-electron-titlebar@npm:^4.1.5": +"custom-electron-titlebar@npm:^4.1.6": version: 4.1.6 resolution: "custom-electron-titlebar@npm:4.1.6" peerDependencies: @@ -2528,7 +2528,7 @@ __metadata: languageName: node linkType: hard -"deepmerge@npm:^4.2.2": +"deepmerge@npm:^4.3.0": version: 4.3.0 resolution: "deepmerge@npm:4.3.0" checksum: c7980eb5c5be040b371f1df0d566473875cfabed9f672ccc177b81ba8eee5686ce2478de2f1d0076391621cbe729e5eacda397179a59ef0f68901849647db126 @@ -3017,15 +3017,15 @@ __metadata: linkType: hard "electron@npm:^22.0.2": - version: 22.2.0 - resolution: "electron@npm:22.2.0" + version: 22.2.1 + resolution: "electron@npm:22.2.1" dependencies: "@electron/get": ^2.0.0 "@types/node": ^16.11.26 extract-zip: ^2.0.1 bin: electron: cli.js - checksum: 096434fe95408928c86de4782e87fcad8b933043a9a7b5447e87964c6c597352584d413157cca43a9f1fd4bf669d2e344d6604ff5c499367e3ca6f1008e2fd5f + checksum: d7331f1e4fbdaf7cb2e5093c3636cb2b64bd437a31b4664f67d4353caf1d021ab582f88584dd2e170a282ebf11158b17cc2f6846432beae3a4b5bc371555fd6d languageName: node linkType: hard @@ -4122,7 +4122,7 @@ __metadata: languageName: node linkType: hard -"get-intrinsic@npm:^1.0.2, get-intrinsic@npm:^1.1.1, get-intrinsic@npm:^1.1.3": +"get-intrinsic@npm:^1.0.2, get-intrinsic@npm:^1.1.1, get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.0": version: 1.2.0 resolution: "get-intrinsic@npm:1.2.0" dependencies: @@ -4355,13 +4355,6 @@ __metadata: languageName: node linkType: hard -"growly@npm:^1.3.0": - version: 1.3.0 - resolution: "growly@npm:1.3.0" - checksum: 53cdecd4c16d7d9154a9061a9ccb87d602e957502ca69b529d7d1b2436c2c0b700ec544fc6b3e4cd115d59b81e62e44ce86bd0521403b579d3a2a97d7ce72a44 - languageName: node - linkType: hard - "handlebars@npm:^4.7.7": version: 4.7.7 resolution: "handlebars@npm:4.7.7" @@ -4531,16 +4524,16 @@ __metadata: languageName: node linkType: hard -"html-to-text@npm:^9.0.3": - version: 9.0.3 - resolution: "html-to-text@npm:9.0.3" +"html-to-text@npm:^9.0.4": + version: 9.0.4 + resolution: "html-to-text@npm:9.0.4" dependencies: "@selderee/plugin-htmlparser2": ^0.10.0 - deepmerge: ^4.2.2 + deepmerge: ^4.3.0 dom-serializer: ^2.0.0 htmlparser2: ^8.0.1 selderee: ^0.10.0 - checksum: 4509edd5c03a9aae163a3362a05b26aa0f9e7deaaa856a5f64c6885d9d9d7235190efb905c6794e5a696bdd2536d6e19432953670e3243b1399048727c232134 + checksum: 5431f7fa5501ba05cdc7e7eb90b9d3f7607e9779f313abc6a48bf493e144947f3bde63426679ca153e085ca77d7c0983bb2cf160a30b68b1598d1fb174a0ca05 languageName: node linkType: hard @@ -4756,13 +4749,13 @@ __metadata: linkType: hard "internal-slot@npm:^1.0.4": - version: 1.0.4 - resolution: "internal-slot@npm:1.0.4" + version: 1.0.5 + resolution: "internal-slot@npm:1.0.5" dependencies: - get-intrinsic: ^1.1.3 + get-intrinsic: ^1.2.0 has: ^1.0.3 side-channel: ^1.0.4 - checksum: 8974588d06bab4f675573a3b52975370facf6486df51bc0567a982c7024fa29495f10b76c0d4dc742dd951d1b72024fdc1e31bb0bedf1678dc7aacacaf5a4f73 + checksum: 97e84046bf9e7574d0956bd98d7162313ce7057883b6db6c5c7b5e5f05688864b0978ba07610c726d15d66544ffe4b1050107d93f8a39ebc59b15d8b429b497a languageName: node linkType: hard @@ -5652,11 +5645,11 @@ __metadata: linkType: hard "locate-path@npm:^7.1.0": - version: 7.1.1 - resolution: "locate-path@npm:7.1.1" + version: 7.2.0 + resolution: "locate-path@npm:7.2.0" dependencies: p-locate: ^6.0.0 - checksum: 1d88af5b512d6e6398026252e17382907126683ab09ae5d6b8918d0bc72ca2642e1ad6e2fe635c5920840e369618e5d748c08deb57ba537fdd3f78e87ca993e0 + checksum: c1b653bdf29beaecb3d307dfb7c44d98a2a98a02ebe353c9ad055d1ac45d6ed4e1142563d222df9b9efebc2bcb7d4c792b507fad9e7150a04c29530b7db570f8 languageName: node linkType: hard @@ -6238,20 +6231,6 @@ __metadata: languageName: node linkType: hard -"node-notifier@npm:^10.0.1": - version: 10.0.1 - resolution: "node-notifier@npm:10.0.1" - dependencies: - growly: ^1.3.0 - is-wsl: ^2.2.0 - semver: ^7.3.5 - shellwords: ^0.1.1 - uuid: ^8.3.2 - which: ^2.0.2 - checksum: ac09456152e433462dd3ca277048de7a60c6d63fc657e00ac72805841baf9bb2573e8d3f64c4b64af73546d1ed39733af6b0036c38b57a83c883aa33fff35a2e - languageName: node - linkType: hard - "nopt@npm:^6.0.0": version: 6.0.0 resolution: "nopt@npm:6.0.0" @@ -7586,13 +7565,6 @@ __metadata: languageName: node linkType: hard -"shellwords@npm:^0.1.1": - version: 0.1.1 - resolution: "shellwords@npm:0.1.1" - checksum: 8d73a5e9861f5e5f1068e2cfc39bc0002400fe58558ab5e5fa75630d2c3adf44ca1fac81957609c8320d5533e093802fcafc72904bf1a32b95de3c19a0b1c0d4 - languageName: node - linkType: hard - "side-channel@npm:^1.0.4": version: 1.0.4 resolution: "side-channel@npm:1.0.4" @@ -8295,9 +8267,9 @@ __metadata: linkType: hard "type-fest@npm:^3.1.0": - version: 3.5.6 - resolution: "type-fest@npm:3.5.6" - checksum: ce80d778c35280e967796b99989b796a75f8c2b6c8f1b88eb62990107f5fa90c30fdb1826fe5e2ab192e5c1eef7de6f29d133b443e80c753cbc020b01a32487a + version: 3.5.7 + resolution: "type-fest@npm:3.5.7" + checksum: 06358352daa706d6f582d2041945e629fdd236c3c94678c4d87efb5d2e77bab78740337449f44bbd09a736c966e70570e901e2e2576b59b369891ffc1bf87bb6 languageName: node linkType: hard @@ -8495,7 +8467,7 @@ __metadata: languageName: node linkType: hard -"uuid@npm:^8.3.1, uuid@npm:^8.3.2": +"uuid@npm:^8.3.1": version: 8.3.2 resolution: "uuid@npm:8.3.2" bin: @@ -8911,8 +8883,8 @@ __metadata: browser-id3-writer: ^4.4.0 butterchurn: ^2.6.7 butterchurn-presets: ^2.4.7 - custom-electron-prompt: ^1.5.1 - custom-electron-titlebar: ^4.1.5 + custom-electron-prompt: ^1.5.4 + custom-electron-titlebar: ^4.1.6 del-cli: ^5.0.0 electron: ^22.0.2 electron-better-web-request: ^1.0.1 @@ -8927,14 +8899,13 @@ __metadata: electron-updater: ^5.3.0 filenamify: ^4.3.0 howler: ^2.2.3 - html-to-text: ^9.0.3 + html-to-text: ^9.0.4 keyboardevent-from-electron-accelerator: ^2.0.0 keyboardevents-areequal: ^0.2.2 md5: ^2.3.0 mpris-service: ^2.1.2 node-fetch: ^2.6.8 node-gyp: ^9.3.1 - node-notifier: ^10.0.1 playwright: ^1.29.2 simple-youtube-age-restriction-bypass: "https://gitpkg.now.sh/api/pkg.tgz?url=zerodytrash/Simple-YouTube-Age-Restriction-Bypass&commit=v2.5.4" vudio: ^2.1.1 diff --git a/youtube-music.css b/youtube-music.css index ee48dc3a..c0d3cc4e 100644 --- a/youtube-music.css +++ b/youtube-music.css @@ -44,3 +44,9 @@ ytmusic-cast-button { .ytp-chrome-top-buttons { display: none !important; } + +/* Make youtube-music logo un-draggable */ +ytmusic-nav-bar>div.left-content>a, +ytmusic-nav-bar>div.left-content>a>picture>img { + -webkit-user-drag: none; +}