diff --git a/config/defaults.js b/config/defaults.js index 50906749..2a084737 100644 --- a/config/defaults.js +++ b/config/defaults.js @@ -50,8 +50,9 @@ const defaultConfig = { }, notifications: { enabled: false, - urgency: "normal", - unpauseNotification: false + unpauseNotification: false, + urgency: "normal", //has effect only on Linux + interactive: false //has effect only on Windows }, "precise-volume": { enabled: false, diff --git a/package.json b/package.json index d27c0860..6d0d90a9 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "filenamify": "^4.2.0", "md5": "^2.3.0", "node-fetch": "^2.6.1", + "node-notifier": "^9.0.1", "open": "^8.0.3", "ytdl-core": "^4.5.0", "ytpl": "^2.1.1" diff --git a/plugins/notifications/back.js b/plugins/notifications/back.js index dedd9379..d1ef3114 100644 --- a/plugins/notifications/back.js +++ b/plugins/notifications/back.js @@ -1,18 +1,17 @@ const { Notification } = require("electron"); +const is = require("electron-is"); const getSongInfo = require("../../providers/song-info"); +const { notificationImage } = require("./utils"); + +const { setupInteractive, notifyInteractive } = require("./interactive") const notify = (info, options) => { - let notificationImage = "assets/youtube-music.png"; - - if (info.image) { - notificationImage = info.image.resize({ height: 256, width: 256 }); - } // Fill the notification with content const notification = { title: info.title || "Playing", body: info.artist, - icon: notificationImage, + icon: notificationImage(info), silent: true, urgency: options.urgency, }; @@ -25,10 +24,15 @@ const notify = (info, options) => { }; module.exports = (win, options) => { + const isInteractive = is.windows() && options.interactive; + //setup interactive notifications for windows + if (isInteractive) { + setupInteractive(win, options.unpauseNotification); + } const registerCallback = getSongInfo(win); let oldNotification; let oldURL = ""; - win.on("ready-to-show", () => { + win.once("ready-to-show", () => { // Register the callback for new song information registerCallback(songInfo => { // on pause - reset url? and skip notification @@ -42,10 +46,14 @@ module.exports = (win, options) => { // If url isn't the same as last one - send notification if (songInfo.url !== oldURL) { oldURL = songInfo.url; - // Close the old notification - oldNotification?.close(); - // This fixes a weird bug that would cause the notification to be updated instead of showing - setTimeout(()=>{ oldNotification = notify(songInfo, options) }, 10); + if (isInteractive) { + notifyInteractive(songInfo); + } else { + // Close the old notification + oldNotification?.close(); + // This fixes a weird bug that would cause the notification to be updated instead of showing + setTimeout(() => { oldNotification = notify(songInfo, options) }, 10); + } } }); }); diff --git a/plugins/notifications/interactive.js b/plugins/notifications/interactive.js new file mode 100644 index 00000000..cb487cae --- /dev/null +++ b/plugins/notifications/interactive.js @@ -0,0 +1,93 @@ +const { notificationImage, icons } = require("./utils"); +const getSongControls = require('../../providers/song-controls'); +const notifier = require("node-notifier"); + +//store song controls reference on launch +let controls; +let notificationOnPause; + +//Save controls and onPause option +module.exports.setupInteractive = (win, unpauseNotification) => { + const { playPause, next, previous } = getSongControls(win); + controls = { playPause, next, previous }; + + notificationOnPause = unpauseNotification; + + win.webContents.once("closed", () => { + deleteNotification() + }); +} + +//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) + + toDelete = undefined; + } +} + +//New notification +module.exports.notifyInteractive = function sendToaster(songInfo) { + deleteNotification(); + //download image and get path + let imgSrc = notificationImage(songInfo, true); + toDelete = { + //app id undefined - will break buttons + 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 (!notificationOnPause) { + 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(); + } + } + + ); +} diff --git a/plugins/notifications/menu.js b/plugins/notifications/menu.js index a61cac67..3f239909 100644 --- a/plugins/notifications/menu.js +++ b/plugins/notifications/menu.js @@ -1,19 +1,30 @@ -const {urgencyLevels, setUrgency, setUnpause} = require("./utils"); +const { urgencyLevels, setOption } = require("./utils"); +const is = require("electron-is"); module.exports = (win, options) => [ - { - label: "Notification Priority", - submenu: urgencyLevels.map(level => ({ - label: level.name, - type: "radio", - checked: options.urgency === level.value, - click: () => setUrgency(options, level.value) - })), - }, + ...(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) + }] : + []), { label: "Show notification on unpause", type: "checkbox", checked: options.unpauseNotification, - click: (item) => setUnpause(options, item.checked) - } + click: (item) => setOption(options, "unpauseNotification", item.checked) + }, ]; diff --git a/plugins/notifications/utils.js b/plugins/notifications/utils.js index c43ecb20..34c73a77 100644 --- a/plugins/notifications/utils.js +++ b/plugins/notifications/utils.js @@ -1,19 +1,56 @@ -const {setOptions} = require("../../config/plugins"); +const { setOptions } = require("../../config/plugins"); +const path = require("path"); +const { app } = require("electron"); +const fs = require("fs"); + +const icon = "assets/youtube-music.png"; +const tempIcon = path.join(app.getPath("userData"), "tempIcon.png"); + +module.exports.icons = { + play: "\u{1405}", // ᐅ + pause: "\u{2016}", // ‖ + next: "\u{1433}", // ᐳ + previous: "\u{1438}" // ᐸ +} + +module.exports.setOption = (options, option, value) => { + options[option] = value; + setOptions("notifications", options) +} module.exports.urgencyLevels = [ - {name: "Low", value: "low"}, - {name: "Normal", value: "normal"}, - {name: "High", value: "critical"}, + { name: "Low", value: "low" }, + { name: "Normal", value: "normal" }, + { name: "High", value: "critical" }, ]; -module.exports.setUrgency = (options, level) => { - options.urgency = level - setOption(options) -}; -module.exports.setUnpause = (options, value) => { - options.unpauseNotification = value - setOption(options) + +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 }; -let setOption = options => { - setOptions("notifications", options) -}; +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 + }) +} diff --git a/providers/song-info.js b/providers/song-info.js index 9b434361..a7aa651d 100644 --- a/providers/song-info.js +++ b/providers/song-info.js @@ -29,6 +29,18 @@ const getPausedStatus = async (win) => { return !title.includes("-"); }; +const getArtist = async (win) => { + return await win.webContents.executeJavaScript( + ` + var bar = document.getElementsByClassName('subtitle ytmusic-player-bar')[0]; + var artistName = (bar.getElementsByClassName('yt-formatted-string')[0]) || (bar.getElementsByClassName('byline ytmusic-player-bar')[0]); + if (artistName) { + artistName.textContent; + } + ` + ) +} + // Fill songInfo with empty values const songInfo = { title: "", @@ -46,7 +58,7 @@ const songInfo = { const handleData = async (responseText, win) => { let data = JSON.parse(responseText); songInfo.title = data?.videoDetails?.title; - songInfo.artist = data?.videoDetails?.author; + songInfo.artist = await getArtist(win) || data?.videoDetails?.author; songInfo.views = data?.videoDetails?.viewCount; songInfo.imageSrc = data?.videoDetails?.thumbnail?.thumbnails?.pop()?.url; songInfo.songDuration = data?.videoDetails?.lengthSeconds; diff --git a/yarn.lock b/yarn.lock index 50bc9144..af2b6f94 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6537,6 +6537,23 @@ node-notifier@^8.0.0: uuid "^8.3.0" which "^2.0.2" +node-notifier@^9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/node-notifier/-/node-notifier-9.0.1.tgz#cea837f4c5e733936c7b9005e6545cea825d1af4" + integrity sha512-fPNFIp2hF/Dq7qLDzSg4vZ0J4e9v60gJR+Qx7RbjbWqzPDdEqeVpEx5CFeDAELIl+A/woaaNn1fQ5nEVerMxJg== + dependencies: + growly "^1.3.0" + is-wsl "^2.2.0" + semver "^7.3.2" + shellwords "^0.1.1" + uuid "^8.3.0" + which "^2.0.2" + +noop-logger@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/noop-logger/-/noop-logger-0.1.1.tgz#94a2b1633c4f1317553007d8966fd0e841b6a4c2" + integrity sha1-lKKxYzxPExdVMAfYlm/Q6EG2pMI= + node-releases@^1.1.70: version "1.1.71" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.71.tgz#cb1334b179896b1c89ecfdd4b725fb7bbdfc7dbb"