diff --git a/config/defaults.js b/config/defaults.js index e92a5711..863e8b25 100644 --- a/config/defaults.js +++ b/config/defaults.js @@ -55,8 +55,10 @@ const defaultConfig = { enabled: false, unpauseNotification: false, urgency: "normal", //has effect only on Linux - interactive: true, //has effect only on Windows - smallInteractive: false //has effect only on Windows + // the following has effect only on Windows + interactive: true, + toastStyle: 1, // see plugins/notifications/utils for more info + hideButtonText: false }, "precise-volume": { enabled: false, diff --git a/index.js b/index.js index 7b414352..eed0c040 100644 --- a/index.js +++ b/index.js @@ -75,6 +75,7 @@ function onClosed() { mainWindow = null; } +/** @param {Electron.BrowserWindow} win */ function loadPlugins(win) { injectCSS(win.webContents, path.join(__dirname, "youtube-music.css")); // Load user CSS diff --git a/plugins/notifications/back.js b/plugins/notifications/back.js index 98dde0f8..419e27d3 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,26 @@ 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) => { + config.init(options); // Register the callback for new song information is.windows() && options.interactive ? - require("./interactive")(win, options) : - setup(options); + require("./interactive")(win) : + setup(); }; diff --git a/plugins/notifications/config.js b/plugins/notifications/config.js new file mode 100644 index 00000000..dfba8ca5 --- /dev/null +++ b/plugins/notifications/config.js @@ -0,0 +1,22 @@ +const { setOptions, setMenuOptions } = require("../../config/plugins"); + +let config; + +module.exports.init = (options) => { + config = options; +} + +module.exports.setAndMaybeRestart = (option, value) => { + config[option] = value; + setMenuOptions("notifications", config); +} + +module.exports.set = (option, value) => { + config[option] = value; + setOptions("notifications", config); +} + +module.exports.get = (option) => { + let res = config[option]; + return res; +} diff --git a/plugins/notifications/interactive.js b/plugins/notifications/interactive.js index 1b3d434f..dac18700 100644 --- a/plugins/notifications/interactive.js +++ b/plugins/notifications/interactive.js @@ -1,31 +1,38 @@ -const { notificationImage, icons, save_temp_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 { changeProtocolHandler } = require("../../providers/protocol-handler"); -const { Notification, app } = require("electron"); +const { Notification, app, ipcMain } = require("electron"); const path = require('path'); -let songControls; -let config; -let savedNotification; +const config = require("./config"); -module.exports = (win, _config) => { +let songControls; +let savedNotification; +// TODO create banner function +/** @param {Electron.BrowserWindow} win */ +module.exports = (win) => { songControls = getSongControls(win); - config = _config; - if (app.isPackaged && !config.smallInteractive) save_temp_icons(); + + let currentSeconds = 0; + ipcMain.on('apiLoaded', () => win.webContents.send('setupTimeChangedListener')); + + ipcMain.on('timeChanged', (_, t) => currentSeconds = t); + + if (app.isPackaged) save_temp_icons(); let lastSongInfo = { url: undefined }; // Register songInfoCallback registerCallback(songInfo => { - if (!songInfo.isPaused && (songInfo.url !== lastSongInfo.url || config.unpauseNotification)) { + if (!songInfo.isPaused && (songInfo.url !== lastSongInfo.url || config.get("unpauseNotification"))) { lastSongInfo = { ...songInfo }; sendXML(songInfo); } }); - //TODO on app before close, close notification + // TODO on app before close, close notification app.once("before-quit", () => { savedNotification?.close(); }); @@ -34,9 +41,9 @@ module.exports = (win, _config) => { (cmd) => { if (Object.keys(songControls).includes(cmd)) { songControls[cmd](); - if (cmd === 'pause' || (cmd === 'play' && !config.unpauseNotification)) { + if (cmd === 'pause' || (cmd === 'play' && !config.get("unpauseNotification"))) { setImmediate(() => - sendXML({ ...lastSongInfo, isPaused: cmd === 'pause' }) + sendXML({ ...lastSongInfo, isPaused: cmd === 'pause', elapsedSeconds: currentSeconds }) ); } } @@ -45,20 +52,20 @@ module.exports = (win, _config) => { } function sendXML(songInfo) { - const imgSrc = notificationImage(songInfo, true); + const iconSrc = notificationImage(songInfo); savedNotification?.close(); savedNotification = new Notification({ title: songInfo.title || "Playing", body: songInfo.artist, - icon: imgSrc, + 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_custom(), + toastXml: get_xml(songInfo, iconSrc), }); savedNotification.on("close", (_) => { @@ -68,209 +75,131 @@ function sendXML(songInfo) { savedNotification.show(); } +const get_xml = (songInfo, iconSrc) => { + switch (config.get("style")) { + 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("style") === ToastStyles.legacy ) { + return `content="${icons[kind]}"`; + } else { + return `\ + content="${kind.charAt(0).toUpperCase() + kind.slice(1)}"\ + imageUri="file:///${path.resolve(__dirname, iconLocation, `${kind}.png`)}" + `; + } +} -const getButton = (kind) => +const getButton = (kind) => ``; -const display = (kind) => - config.smallInteractive ? - `content="${icons[kind]}"` : - `content="${kind.charAt(0).toUpperCase() + kind.slice(1)}" imageUri="file:///${path.resolve(__dirname, iconLocation, `${kind}.png`)}"`; - - -const get_xml = (songInfo, options, imgSrc) => ` - - ` + \ +`; -// **************************************************** // -// PREMADE TEMPLATES FOR TESTING -// DELETE AFTER TESTING -// **************************************************** // - -const get_xml_custom = () => xml_banner_centered_top; - -const xml_logo_ascii = ` - +const toast = (content, isPaused) => `\ + -`; + ${getButtons(isPaused)} +`; +const xml_logo = ({title, artist, isPaused}, imgSrc) => toast(`\ + + ${title} + ${artist}\ +`, isPaused); -const xml_logo_icons_notext =` - - -`; +const xml_banner_bottom = ({title, artist, isPaused}, imgSrc) => toast(`\ + + ${title} + ${artist}\ +`, isPaused); -const buttons_icons = ` - - - - - -`; - -const xml_logo_icons = ` - - -`; - -const xml_hero = ` - - -`; - -const xml_banner_bottom = ` - - -`; - -const xml_banner_top_custom = ` - - +const xml_more_data = ({ album, elapsedSeconds, songDuration })=> `\ + + ${album ? + `${album}` : ''} + ${secondsToMinutes(elapsedSeconds)} / ${secondsToMinutes(songDuration)} +\ `; -const xml_banner_centered_bottom = ` - - -`; - -const xml_banner_centered_top = ` - - -`; +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 7c2776f8..6d238dd7 100644 --- a/plugins/notifications/menu.js +++ b/plugins/notifications/menu.js @@ -1,5 +1,6 @@ -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() @@ -10,7 +11,7 @@ module.exports = (_win, options) => [ label: level.name, type: "radio", checked: options.urgency === level.value, - click: () => setOption(options, "urgency", level.value), + click: () => config.set("urgency", level.value), })), }, ] @@ -21,13 +22,12 @@ module.exports = (_win, options) => [ label: "Interactive Notifications", type: "checkbox", checked: options.interactive, - click: (item) => setOption(options, "interactive", item.checked), + // doesn't update until restart + click: (item) => config.setAndMaybeRestart("interactive", item.checked), }, { - label: "Smaller Interactive Notifications", - type: "checkbox", - checked: options.smallInteractive, - click: (item) => setOption(options, "smallInteractive", item.checked), + label: "Toast Style", + submenu: getToastStyleMenuItems(options) }, ] : []), @@ -35,6 +35,29 @@ module.exports = (_win, 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 + 1); + + arr[0] = { + label: "Hide Button Text", + type: "checkbox", + checked: options.hideButtonText, + click: (item) => config.set("hideButtonText", item.checked), + } + + // ToastStyles index starts from 1 + for (const [name, index] of Object.entries(ToastStyles)) { + arr[index] = { + label: snakeToCamel(name), + type: "radio", + checked: options.style === index, + click: () => config.set("style", index), + }; + } + + return arr; +} diff --git a/plugins/notifications/utils.js b/plugins/notifications/utils.js index 64309c02..5e531ecc 100644 --- a/plugins/notifications/utils.js +++ b/plugins/notifications/utils.js @@ -1,11 +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 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}", // ᐅ @@ -14,37 +25,47 @@ 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 = (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("style")) { + case module.exports.ToastStyles.logo: + case module.exports.ToastStyles.legacy: + return this.saveImage(nativeImageToLogo(songInfo.image), tempIcon); + default: + return this.saveImage(songInfo.image, tempBanner); + }; }; +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); + + return tempImage.crop({ + x: Math.round(margin / 2), + y: 0, + 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`); @@ -55,13 +76,16 @@ module.exports.save_temp_icons = () => { } }; -function centerNativeImage(nativeImage) { - const tempImage = nativeImage.resize({ height: 256 }); - const margin = Math.max((tempImage.getSize().width - 256), 0); - - return tempImage.crop({ - x: Math.round(margin / 2), - y: 0, - width: 256, height: 256 - }) +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/shortcuts/mpris.js b/plugins/shortcuts/mpris.js index bf427b58..d84f655e 100644 --- a/plugins/shortcuts/mpris.js +++ b/plugins/shortcuts/mpris.js @@ -18,6 +18,7 @@ function setupMPRIS() { return player; } +/** @param {Electron.BrowserWindow} win */ function registerMPRIS(win) { const songControls = getSongControls(win); const { playPause, next, previous, volumeMinus10, volumePlus10 } = songControls; @@ -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; diff --git a/plugins/tuna-obs/back.js b/plugins/tuna-obs/back.js index 0a2bc3a3..29ed9dd8 100644 --- a/plugins/tuna-obs/back.js +++ b/plugins/tuna-obs/back.js @@ -27,7 +27,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 3f1d23a5..6c6abae9 100644 --- a/preload.js +++ b/preload.js @@ -95,6 +95,8 @@ function listenForApiLoad() { function onApiLoaded() { document.dispatchEvent(new CustomEvent('apiLoaded', { detail: api })); + //setImmediate() + ipcRenderer.send('apiLoaded'); // Remove upgrade button if (config.get("options.removeUpgradeButton")) { 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-info-front.js b/providers/song-info-front.js index 03cf2957..8a57eda2 100644 --- a/providers/song-info-front.js +++ b/providers/song-info-front.js @@ -1,9 +1,6 @@ const { ipcRenderer } = require("electron"); -const is = require('electron-is'); const { getImage } = require("./song-info"); -const config = require("../config"); - global.songInfo = {}; const $ = s => document.querySelector(s); @@ -17,14 +14,63 @@ 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.title); + }); + repeatObserver.observe($('#right-controls .repeat'), { attributeFilter: ["title"] }); + + // Emit the initial value as well; as it's persistent between launches. + ipcRenderer.send('repeatChanged', $('#right-controls .repeat').title); +}); + +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) => { @@ -57,29 +103,3 @@ module.exports = () => { } }, { 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.title); - }); - repeatObserver.observe($('#right-controls .repeat'), { attributeFilter: ["title"] }); - - // Emit the initial value as well; as it's persistent between launches. - ipcRenderer.send('repeatChanged', $('#right-controls .repeat').title); -} - -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 8181e6ad..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); }