Merge pull request #946 from Araxeus/use-ToastXML

[Notifications] [Windows] Native interactive notifications
This commit is contained in:
th-ch
2023-03-08 20:59:34 +01:00
committed by GitHub
26 changed files with 599 additions and 282 deletions

View File

Before

Width:  |  Height:  |  Size: 250 B

After

Width:  |  Height:  |  Size: 250 B

View File

Before

Width:  |  Height:  |  Size: 192 B

After

Width:  |  Height:  |  Size: 192 B

View File

Before

Width:  |  Height:  |  Size: 265 B

After

Width:  |  Height:  |  Size: 265 B

View File

Before

Width:  |  Height:  |  Size: 269 B

After

Width:  |  Height:  |  Size: 269 B

View File

@ -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,

View File

@ -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");

View File

@ -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(
"js-flags",
@ -82,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
@ -352,9 +346,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;
@ -366,8 +357,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";
}
@ -380,7 +370,6 @@ app.on("ready", () => {
cwd: path.dirname(appLocation),
description: "YouTube Music Desktop App - including custom plugins",
appUserModelId: appID,
toastActivatorClsid
}
);
}
@ -391,6 +380,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"),

12
menu.js
View File

@ -131,16 +131,14 @@ const mainMenuTemplate = (win) => {
],
},
{
label: "Single instance lock",
label: "Release 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();
},
},
{

View File

@ -133,7 +133,6 @@
"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": "^2.9.0",

View File

@ -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.unpauseNotification) :
setup(options);
require("./interactive")(win) :
setup();
};

View File

@ -0,0 +1,23 @@
const { setOptions, setMenuOptions } = require("../../config/plugins");
const defaultConfig = require("../../config/defaults");
let config = defaultConfig.plugins["notifications"];
module.exports.init = (options) => {
config = { ...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;
};

View File

@ -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) =>
`<action ${display(kind)} activationType="protocol" arguments="youtubemusic://${kind}"/>`;
);
const getButtons = (isPaused) => `\
<actions>
${getButton('previous')}
${isPaused ? getButton('play') : getButton('pause')}
${getButton('next')}
</actions>\
`;
const toast = (content, isPaused) => `\
<toast>
<audio silent="true" />
<visual>
<binding template="ToastGeneric">
${content}
</binding>
</visual>
${getButtons(isPaused)}
</toast>`;
const xml_image = ({ title, artist, isPaused }, imgSrc, placement) => toast(`\
<image id="1" src="${imgSrc}" name="Image" ${placement}/>
<text id="1">${title}</text>
<text id="2">${artist}</text>\
`, 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(`\
<image id="1" src="${imgSrc}" name="Image" />
<text></text>
<group>
<subgroup>
<text hint-style="body">${songInfo.title}</text>
<text hint-style="captionSubtle">${songInfo.artist}</text>
</subgroup>
${xml_more_data(songInfo)}
</group>\
`, songInfo.isPaused);
const xml_more_data = ({ album, elapsedSeconds, songDuration }) => `\
<subgroup hint-textStacking="bottom">
${album ?
`<text hint-style="captionSubtle" hint-wrap="true" hint-align="right">${album}</text>` : ''}
<text hint-style="captionSubtle" hint-wrap="true" hint-align="right">${secondsToMinutes(elapsedSeconds)} / ${secondsToMinutes(songDuration)}</text>
</subgroup>\
`;
const xml_banner_centered_bottom = ({ title, artist, isPaused }, imgSrc) => toast(`\
<text></text>
<group>
<subgroup hint-weight="1" hint-textStacking="center">
<text hint-align="center" hint-style="${titleFontPicker(title)}">${title}</text>
<text hint-align="center" hint-style="SubtitleSubtle">${artist}</text>
</subgroup>
</group>
<image id="1" src="${imgSrc}" name="Image" hint-removeMargin="true" />\
`, isPaused);
const xml_banner_centered_top = ({ title, artist, isPaused }, imgSrc) => toast(`\
<image id="1" src="${imgSrc}" name="Image" />
<text></text>
<group>
<subgroup hint-weight="1" hint-textStacking="center">
<text hint-align="center" hint-style="${titleFontPicker(title)}">${title}</text>
<text hint-align="center" hint-style="SubtitleSubtle">${artist}</text>
</subgroup>
</group>\
`, 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';
}
}

View File

@ -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;
}

View File

@ -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}`;
}

View File

@ -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);

View File

@ -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`);
}

View File

@ -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);

View File

@ -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")) {

View File

@ -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));
});
};

View File

@ -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,
};

View File

@ -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 })
};

View File

@ -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
};
};

View File

@ -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,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.__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) => {
@ -55,31 +101,5 @@ module.exports = () => {
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());
}

View File

@ -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");
});
})
};

23
tray.js
View File

@ -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");
// macOS only
tray.setIgnoreDoubleClickEvents(true);
tray.on("click", () => {
if (config.get("options.trayClickPlayPause")) {
playPause();

View File

@ -3060,15 +3060,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
@ -4184,7 +4184,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:
@ -4417,13 +4417,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"
@ -4818,13 +4811,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
@ -5723,11 +5716,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
@ -6319,20 +6312,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"
@ -7683,13 +7662,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"
@ -8392,9 +8364,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
@ -8592,7 +8564,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:
@ -9032,7 +9004,6 @@ __metadata:
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