fix: remove xo, migration to eslint

This commit is contained in:
JellyBrick
2023-08-29 17:22:38 +09:00
parent 31a7588cee
commit c722896a73
142 changed files with 17210 additions and 18409 deletions

View File

@ -1,13 +1,13 @@
const { loadAdBlockerEngine } = require("./blocker");
const config = require("./config");
const { loadAdBlockerEngine } = require('./blocker');
const config = require('./config');
module.exports = async (win, options) => {
if (await config.shouldUseBlocklists()) {
loadAdBlockerEngine(
win.webContents.session,
options.cache,
options.additionalBlockLists,
options.disableDefaultLists,
);
}
if (await config.shouldUseBlocklists()) {
loadAdBlockerEngine(
win.webContents.session,
options.cache,
options.additionalBlockLists,
options.disableDefaultLists,
);
}
};

View File

@ -1,63 +1,63 @@
const { promises } = require("fs"); // used for caching
const path = require("path");
const { promises } = require('node:fs'); // Used for caching
const path = require('node:path');
const { ElectronBlocker } = require("@cliqz/adblocker-electron");
const fetch = require("node-fetch");
const { ElectronBlocker } = require('@cliqz/adblocker-electron');
const fetch = require('node-fetch');
const SOURCES = [
"https://raw.githubusercontent.com/kbinani/adblock-youtube-ads/master/signed.txt",
// uBlock Origin
"https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters.txt",
"https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters-2020.txt",
"https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters-2021.txt",
"https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters-2022.txt",
"https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters-2023.txt",
// Fanboy Annoyances
"https://secure.fanboy.co.nz/fanboy-annoyance_ubo.txt",
'https://raw.githubusercontent.com/kbinani/adblock-youtube-ads/master/signed.txt',
// UBlock Origin
'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters.txt',
'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters-2020.txt',
'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters-2021.txt',
'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters-2022.txt',
'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters-2023.txt',
// Fanboy Annoyances
'https://secure.fanboy.co.nz/fanboy-annoyance_ubo.txt',
];
const loadAdBlockerEngine = (
session = undefined,
cache = true,
additionalBlockLists = [],
disableDefaultLists = false
session = undefined,
cache = true,
additionalBlockLists = [],
disableDefaultLists = false,
) => {
// Only use cache if no additional blocklists are passed
const cachingOptions =
cache && additionalBlockLists.length === 0
? {
path: path.resolve(__dirname, "ad-blocker-engine.bin"),
read: promises.readFile,
write: promises.writeFile,
}
: undefined;
const lists = [
...(disableDefaultLists ? [] : SOURCES),
...additionalBlockLists,
];
// Only use cache if no additional blocklists are passed
const cachingOptions
= cache && additionalBlockLists.length === 0
? {
path: path.resolve(__dirname, 'ad-blocker-engine.bin'),
read: promises.readFile,
write: promises.writeFile,
}
: undefined;
const lists = [
...(disableDefaultLists ? [] : SOURCES),
...additionalBlockLists,
];
ElectronBlocker.fromLists(
fetch,
lists,
{
// when generating the engine for caching, do not load network filters
// So that enhancing the session works as expected
// Allowing to define multiple webRequest listeners
loadNetworkFilters: session !== undefined,
},
cachingOptions
)
.then((blocker) => {
if (session) {
blocker.enableBlockingInSession(session);
} else {
console.log("Successfully generated adBlocker engine.");
}
})
.catch((err) => console.log("Error loading adBlocker engine", err));
ElectronBlocker.fromLists(
fetch,
lists,
{
// When generating the engine for caching, do not load network filters
// So that enhancing the session works as expected
// Allowing to define multiple webRequest listeners
loadNetworkFilters: session !== undefined,
},
cachingOptions,
)
.then((blocker) => {
if (session) {
blocker.enableBlockingInSession(session);
} else {
console.log('Successfully generated adBlocker engine.');
}
})
.catch((error) => console.log('Error loading adBlocker engine', error));
};
module.exports = { loadAdBlockerEngine };
if (require.main === module) {
loadAdBlockerEngine(); // Generate the engine without enabling it
loadAdBlockerEngine(); // Generate the engine without enabling it
}

View File

@ -1,13 +1,13 @@
const { PluginConfig } = require("../../config/dynamic");
const { PluginConfig } = require('../../config/dynamic');
const config = new PluginConfig("adblocker", { enableFront: true });
const config = new PluginConfig('adblocker', { enableFront: true });
const blockers = {
WithBlocklists: "With blocklists",
InPlayer: "In player",
WithBlocklists: 'With blocklists',
InPlayer: 'In player',
};
const shouldUseBlocklists = async () =>
(await config.get("blocker")) !== blockers.InPlayer;
(await config.get('blocker')) !== blockers.InPlayer;
module.exports = { shouldUseBlocklists, blockers, ...config };

View File

@ -7,283 +7,426 @@
*/
{
let pruner = function (o) {
delete o.playerAds;
delete o.adPlacements;
//
if (o.playerResponse) {
delete o.playerResponse.playerAds;
delete o.playerResponse.adPlacements;
}
//
return o;
};
const pruner = function (o) {
delete o.playerAds;
delete o.adPlacements;
//
if (o.playerResponse) {
delete o.playerResponse.playerAds;
delete o.playerResponse.adPlacements;
}
JSON.parse = new Proxy(JSON.parse, {
apply: function () {
return pruner(Reflect.apply(...arguments));
},
});
//
return o;
};
Response.prototype.json = new Proxy(Response.prototype.json, {
apply: function () {
return Reflect.apply(...arguments).then((o) => pruner(o));
},
});
JSON.parse = new Proxy(JSON.parse, {
apply() {
return pruner(Reflect.apply(...arguments));
},
});
Response.prototype.json = new Proxy(Response.prototype.json, {
apply() {
return Reflect.apply(...arguments).then((o) => pruner(o));
},
});
}
(function () {
let cValue = "undefined";
const chain = "playerResponse.adPlacements";
const thisScript = document.currentScript;
//
if (cValue === "null") cValue = null;
else if (cValue === "''") cValue = "";
else if (cValue === "true") cValue = true;
else if (cValue === "false") cValue = false;
else if (cValue === "undefined") cValue = undefined;
else if (cValue === "noopFunc") cValue = function () {};
else if (cValue === "trueFunc")
cValue = function () {
return true;
};
else if (cValue === "falseFunc")
cValue = function () {
return false;
};
else if (/^\d+$/.test(cValue)) {
cValue = parseFloat(cValue);
//
if (isNaN(cValue)) return;
if (Math.abs(cValue) > 0x7fff) return;
} else {
return;
}
//
let aborted = false;
const mustAbort = function (v) {
if (aborted) return true;
aborted =
v !== undefined &&
v !== null &&
cValue !== undefined &&
cValue !== null &&
typeof v !== typeof cValue;
return aborted;
};
let cValue = 'undefined';
const chain = 'playerResponse.adPlacements';
const thisScript = document.currentScript;
//
switch (cValue) {
case 'null': {
cValue = null;
break;
}
/*
Support multiple trappers for the same property:
https://github.com/uBlockOrigin/uBlock-issues/issues/156
case "''": {
cValue = '';
break;
}
case 'true': {
cValue = true;
break;
}
case 'false': {
cValue = false;
break;
}
case 'undefined': {
cValue = undefined;
break;
}
case 'noopFunc': {
cValue = function () {
};
break;
}
case 'trueFunc': {
cValue = function () {
return true;
};
break;
}
case 'falseFunc': {
cValue = function () {
return false;
};
break;
}
default: {
if (/^\d+$/.test(cValue)) {
cValue = Number.parseFloat(cValue);
//
if (isNaN(cValue)) {
return;
}
if (Math.abs(cValue) > 0x7F_FF) {
return;
}
} else {
return;
}
}
}
//
let aborted = false;
const mustAbort = function (v) {
if (aborted) {
return true;
}
aborted
= v !== undefined
&& v !== null
&& cValue !== undefined
&& cValue !== null
&& typeof v !== typeof cValue;
return aborted;
};
/*
Support multiple trappers for the same property:
https://github.com/uBlockOrigin/uBlock-issues/issues/156
*/
const trapProp = function (owner, prop, configurable, handler) {
if (handler.init(owner[prop]) === false) {
return;
}
//
const odesc = Object.getOwnPropertyDescriptor(owner, prop);
let prevGetter, prevSetter;
if (odesc instanceof Object) {
if (odesc.configurable === false) return;
if (odesc.get instanceof Function) prevGetter = odesc.get;
if (odesc.set instanceof Function) prevSetter = odesc.set;
}
//
Object.defineProperty(owner, prop, {
configurable,
get() {
if (prevGetter !== undefined) {
prevGetter();
}
//
return handler.getter();
},
set(a) {
if (prevSetter !== undefined) {
prevSetter(a);
}
//
handler.setter(a);
},
});
};
const trapProp = function (owner, prop, configurable, handler) {
if (handler.init(owner[prop]) === false) {
return;
}
const trapChain = function (owner, chain) {
const pos = chain.indexOf(".");
if (pos === -1) {
trapProp(owner, chain, false, {
v: undefined,
getter: function () {
return document.currentScript === thisScript ? this.v : cValue;
},
setter: function (a) {
if (mustAbort(a) === false) return;
cValue = a;
},
init: function (v) {
if (mustAbort(v)) return false;
//
this.v = v;
return true;
},
});
//
return;
}
//
const prop = chain.slice(0, pos);
const v = owner[prop];
//
chain = chain.slice(pos + 1);
if (v instanceof Object || (typeof v === "object" && v !== null)) {
trapChain(v, chain);
return;
}
//
trapProp(owner, prop, true, {
v: undefined,
getter: function () {
return this.v;
},
setter: function (a) {
this.v = a;
if (a instanceof Object) trapChain(a, chain);
},
init: function (v) {
this.v = v;
return true;
},
});
};
//
trapChain(window, chain);
//
const odesc = Object.getOwnPropertyDescriptor(owner, prop);
let previousGetter;
let previousSetter;
if (odesc instanceof Object) {
if (odesc.configurable === false) {
return;
}
if (odesc.get instanceof Function) {
previousGetter = odesc.get;
}
if (odesc.set instanceof Function) {
previousSetter = odesc.set;
}
}
//
Object.defineProperty(owner, prop, {
configurable,
get() {
if (previousGetter !== undefined) {
previousGetter();
}
//
return handler.getter();
},
set(a) {
if (previousSetter !== undefined) {
previousSetter(a);
}
//
handler.setter(a);
},
});
};
const trapChain = function (owner, chain) {
const pos = chain.indexOf('.');
if (pos === -1) {
trapProp(owner, chain, false, {
v: undefined,
getter() {
return document.currentScript === thisScript ? this.v : cValue;
},
setter(a) {
if (mustAbort(a) === false) {
return;
}
cValue = a;
},
init(v) {
if (mustAbort(v)) {
return false;
}
//
this.v = v;
return true;
},
});
//
return;
}
//
const prop = chain.slice(0, pos);
const v = owner[prop];
//
chain = chain.slice(pos + 1);
if (v instanceof Object || (typeof v === 'object' && v !== null)) {
trapChain(v, chain);
return;
}
//
trapProp(owner, prop, true, {
v: undefined,
getter() {
return this.v;
},
setter(a) {
this.v = a;
if (a instanceof Object) {
trapChain(a, chain);
}
},
init(v) {
this.v = v;
return true;
},
});
};
//
trapChain(window, chain);
})();
(function () {
let cValue = "undefined";
const thisScript = document.currentScript;
const chain = "ytInitialPlayerResponse.adPlacements";
//
if (cValue === "null") cValue = null;
else if (cValue === "''") cValue = "";
else if (cValue === "true") cValue = true;
else if (cValue === "false") cValue = false;
else if (cValue === "undefined") cValue = undefined;
else if (cValue === "noopFunc") cValue = function () {};
else if (cValue === "trueFunc")
cValue = function () {
return true;
};
else if (cValue === "falseFunc")
cValue = function () {
return false;
};
else if (/^\d+$/.test(cValue)) {
cValue = parseFloat(cValue);
//
if (isNaN(cValue)) return;
if (Math.abs(cValue) > 0x7fff) return;
} else {
return;
}
//
let aborted = false;
const mustAbort = function (v) {
if (aborted) return true;
aborted =
v !== undefined &&
v !== null &&
cValue !== undefined &&
cValue !== null &&
typeof v !== typeof cValue;
return aborted;
};
let cValue = 'undefined';
const thisScript = document.currentScript;
const chain = 'ytInitialPlayerResponse.adPlacements';
//
switch (cValue) {
case 'null': {
cValue = null;
break;
}
/*
Support multiple trappers for the same property:
https://github.com/uBlockOrigin/uBlock-issues/issues/156
case "''": {
cValue = '';
break;
}
case 'true': {
cValue = true;
break;
}
case 'false': {
cValue = false;
break;
}
case 'undefined': {
cValue = undefined;
break;
}
case 'noopFunc': {
cValue = function () {
};
break;
}
case 'trueFunc': {
cValue = function () {
return true;
};
break;
}
case 'falseFunc': {
cValue = function () {
return false;
};
break;
}
default: {
if (/^\d+$/.test(cValue)) {
cValue = Number.parseFloat(cValue);
//
if (isNaN(cValue)) {
return;
}
if (Math.abs(cValue) > 0x7F_FF) {
return;
}
} else {
return;
}
}
}
//
let aborted = false;
const mustAbort = function (v) {
if (aborted) {
return true;
}
aborted
= v !== undefined
&& v !== null
&& cValue !== undefined
&& cValue !== null
&& typeof v !== typeof cValue;
return aborted;
};
/*
Support multiple trappers for the same property:
https://github.com/uBlockOrigin/uBlock-issues/issues/156
*/
const trapProp = function (owner, prop, configurable, handler) {
if (handler.init(owner[prop]) === false) {
return;
}
//
const odesc = Object.getOwnPropertyDescriptor(owner, prop);
let prevGetter, prevSetter;
if (odesc instanceof Object) {
if (odesc.configurable === false) return;
if (odesc.get instanceof Function) prevGetter = odesc.get;
if (odesc.set instanceof Function) prevSetter = odesc.set;
}
//
Object.defineProperty(owner, prop, {
configurable,
get() {
if (prevGetter !== undefined) {
prevGetter();
}
//
return handler.getter();
},
set(a) {
if (prevSetter !== undefined) {
prevSetter(a);
}
//
handler.setter(a);
},
});
};
const trapProp = function (owner, prop, configurable, handler) {
if (handler.init(owner[prop]) === false) {
return;
}
const trapChain = function (owner, chain) {
const pos = chain.indexOf(".");
if (pos === -1) {
trapProp(owner, chain, false, {
v: undefined,
getter: function () {
return document.currentScript === thisScript ? this.v : cValue;
},
setter: function (a) {
if (mustAbort(a) === false) return;
cValue = a;
},
init: function (v) {
if (mustAbort(v)) return false;
//
this.v = v;
return true;
},
});
//
return;
}
//
const prop = chain.slice(0, pos);
const v = owner[prop];
//
chain = chain.slice(pos + 1);
if (v instanceof Object || (typeof v === "object" && v !== null)) {
trapChain(v, chain);
return;
}
//
trapProp(owner, prop, true, {
v: undefined,
getter: function () {
return this.v;
},
setter: function (a) {
this.v = a;
if (a instanceof Object) trapChain(a, chain);
},
init: function (v) {
this.v = v;
return true;
},
});
};
//
trapChain(window, chain);
//
const odesc = Object.getOwnPropertyDescriptor(owner, prop);
let previousGetter;
let previousSetter;
if (odesc instanceof Object) {
if (odesc.configurable === false) {
return;
}
if (odesc.get instanceof Function) {
previousGetter = odesc.get;
}
if (odesc.set instanceof Function) {
previousSetter = odesc.set;
}
}
//
Object.defineProperty(owner, prop, {
configurable,
get() {
if (previousGetter !== undefined) {
previousGetter();
}
//
return handler.getter();
},
set(a) {
if (previousSetter !== undefined) {
previousSetter(a);
}
//
handler.setter(a);
},
});
};
const trapChain = function (owner, chain) {
const pos = chain.indexOf('.');
if (pos === -1) {
trapProp(owner, chain, false, {
v: undefined,
getter() {
return document.currentScript === thisScript ? this.v : cValue;
},
setter(a) {
if (mustAbort(a) === false) {
return;
}
cValue = a;
},
init(v) {
if (mustAbort(v)) {
return false;
}
//
this.v = v;
return true;
},
});
//
return;
}
//
const prop = chain.slice(0, pos);
const v = owner[prop];
//
chain = chain.slice(pos + 1);
if (v instanceof Object || (typeof v === 'object' && v !== null)) {
trapChain(v, chain);
return;
}
//
trapProp(owner, prop, true, {
v: undefined,
getter() {
return this.v;
},
setter(a) {
this.v = a;
if (a instanceof Object) {
trapChain(a, chain);
}
},
init(v) {
this.v = v;
return true;
},
});
};
//
trapChain(window, chain);
})();

View File

@ -1,15 +1,15 @@
const config = require("./config");
const config = require('./config');
module.exports = () => [
{
label: "Blocker",
submenu: Object.values(config.blockers).map((blocker) => ({
label: blocker,
type: "radio",
checked: (config.get("blocker") || config.blockers.WithBlocklists) === blocker,
click: () => {
config.set("blocker", blocker);
},
})),
},
{
label: 'Blocker',
submenu: Object.values(config.blockers).map((blocker) => ({
label: blocker,
type: 'radio',
checked: (config.get('blocker') || config.blockers.WithBlocklists) === blocker,
click() {
config.set('blocker', blocker);
},
})),
},
];

View File

@ -1,10 +1,10 @@
const config = require("./config");
const config = require('./config');
module.exports = async () => {
if (await config.shouldUseBlocklists()) {
// Preload adblocker to inject scripts/styles
require("@cliqz/adblocker-electron-preload");
} else if ((await config.get("blocker")) === config.blockers.InPlayer) {
require("./inject");
}
if (await config.shouldUseBlocklists()) {
// Preload adblocker to inject scripts/styles
require('@cliqz/adblocker-electron-preload');
} else if ((await config.get('blocker')) === config.blockers.InPlayer) {
require('./inject');
}
};

View File

@ -1,19 +1,19 @@
const applyCompressor = (e) => {
const audioContext = e.detail.audioContext;
const { audioContext } = e.detail;
const compressor = audioContext.createDynamicsCompressor();
compressor.threshold.value = -50;
compressor.ratio.value = 12;
compressor.knee.value = 40;
compressor.attack.value = 0;
compressor.release.value = 0.25;
const compressor = audioContext.createDynamicsCompressor();
compressor.threshold.value = -50;
compressor.ratio.value = 12;
compressor.knee.value = 40;
compressor.attack.value = 0;
compressor.release.value = 0.25;
e.detail.audioSource.connect(compressor);
compressor.connect(audioContext.destination);
e.detail.audioSource.connect(compressor);
compressor.connect(audioContext.destination);
};
module.exports = () =>
document.addEventListener("audioCanPlay", applyCompressor, {
once: true, // Only create the audio compressor once, not on each video
passive: true,
});
document.addEventListener('audioCanPlay', applyCompressor, {
once: true, // Only create the audio compressor once, not on each video
passive: true,
});

View File

@ -1,6 +1,7 @@
const path = require("path");
const { injectCSS } = require("../utils");
const path = require('node:path');
module.exports = win => {
injectCSS(win.webContents, path.join(__dirname, "style.css"));
const { injectCSS } = require('../utils');
module.exports = (win) => {
injectCSS(win.webContents, path.join(__dirname, 'style.css'));
};

View File

@ -1,10 +1,10 @@
#nav-bar-background,
#header.ytmusic-item-section-renderer,
ytmusic-tabs {
background: rgba(0, 0, 0, 0.3) !important;
backdrop-filter: blur(8px) !important;
background: rgba(0, 0, 0, 0.3) !important;
backdrop-filter: blur(8px) !important;
}
#nav-bar-divider {
display: none !important;
display: none !important;
}

View File

@ -1,4 +1,4 @@
module.exports = () => {
// See https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass#userscript
require("simple-youtube-age-restriction-bypass/dist/Simple-YouTube-Age-Restriction-Bypass.user.js");
// See https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass#userscript
require('simple-youtube-age-restriction-bypass/dist/Simple-YouTube-Age-Restriction-Bypass.user.js');
};

View File

@ -1,21 +1,19 @@
const { ipcMain } = require("electron");
const { ipcMain } = require('electron');
const prompt = require('custom-electron-prompt');
const prompt = require("custom-electron-prompt");
const promptOptions = require("../../providers/prompt-options");
const promptOptions = require('../../providers/prompt-options');
module.exports = (win) => {
ipcMain.handle("captionsSelector", async (_, captionLabels, currentIndex) => {
return await prompt(
{
title: "Choose Caption",
label: `Current Caption: ${captionLabels[currentIndex] || "None"}`,
type: "select",
value: currentIndex,
selectOptions: captionLabels,
resizable: true,
...promptOptions(),
},
win
);
});
ipcMain.handle('captionsSelector', async (_, captionLabels, currentIndex) => await prompt(
{
title: 'Choose Caption',
label: `Current Caption: ${captionLabels[currentIndex] || 'None'}`,
type: 'select',
value: currentIndex,
selectOptions: captionLabels,
resizable: true,
...promptOptions(),
},
win,
));
};

View File

@ -1,3 +1,4 @@
const { PluginConfig } = require("../../config/dynamic");
const config = new PluginConfig("captions-selector", { enableFront: true });
const { PluginConfig } = require('../../config/dynamic');
const config = new PluginConfig('captions-selector', { enableFront: true });
module.exports = { ...config };

View File

@ -1,77 +1,83 @@
const { ElementFromFile, templatePath } = require("../utils");
const { ipcRenderer } = require("electron");
const { ipcRenderer } = require('electron');
const configProvider = require('./config');
const { ElementFromFile, templatePath } = require('../utils');
const configProvider = require("./config");
let config;
function $(selector) { return document.querySelector(selector); }
function $(selector) {
return document.querySelector(selector);
}
const captionsSettingsButton = ElementFromFile(
templatePath(__dirname, "captions-settings-template.html")
templatePath(__dirname, 'captions-settings-template.html'),
);
module.exports = async () => {
config = await configProvider.getAll();
config = await configProvider.getAll();
configProvider.subscribeAll((newConfig) => {
config = newConfig;
});
document.addEventListener('apiLoaded', (event) => setup(event.detail), { once: true, passive: true });
}
configProvider.subscribeAll((newConfig) => {
config = newConfig;
});
document.addEventListener('apiLoaded', (event) => setup(event.detail), { once: true, passive: true });
};
function setup(api) {
$(".right-controls-buttons").append(captionsSettingsButton);
$('.right-controls-buttons').append(captionsSettingsButton);
let captionTrackList = api.getOption("captions", "tracklist");
let captionTrackList = api.getOption('captions', 'tracklist');
$("video").addEventListener("srcChanged", async () => {
if (config.disableCaptions) {
setTimeout(() => api.unloadModule("captions"), 100);
captionsSettingsButton.style.display = "none";
return;
}
api.loadModule("captions");
setTimeout(async () => {
captionTrackList = api.getOption("captions", "tracklist");
if (config.autoload && config.lastCaptionsCode) {
api.setOption("captions", "track", {
languageCode: config.lastCaptionsCode,
});
}
captionsSettingsButton.style.display = captionTrackList?.length
? "inline-block"
: "none";
}, 250);
});
captionsSettingsButton.onclick = async () => {
if (captionTrackList?.length) {
const currentCaptionTrack = api.getOption("captions", "track");
let currentIndex = !currentCaptionTrack ?
null :
captionTrackList.indexOf(captionTrackList.find(track => track.languageCode === currentCaptionTrack.languageCode));
const captionLabels = [
...captionTrackList.map(track => track.displayName),
'None'
];
currentIndex = await ipcRenderer.invoke('captionsSelector', captionLabels, currentIndex)
if (currentIndex === null) return;
const newCaptions = captionTrackList[currentIndex];
configProvider.set('lastCaptionsCode', newCaptions?.languageCode);
if (newCaptions) {
api.setOption("captions", "track", { languageCode: newCaptions.languageCode });
} else {
api.setOption("captions", "track", {});
}
setTimeout(() => api.playVideo());
}
$('video').addEventListener('srcChanged', async () => {
if (config.disableCaptions) {
setTimeout(() => api.unloadModule('captions'), 100);
captionsSettingsButton.style.display = 'none';
return;
}
api.loadModule('captions');
setTimeout(async () => {
captionTrackList = api.getOption('captions', 'tracklist');
if (config.autoload && config.lastCaptionsCode) {
api.setOption('captions', 'track', {
languageCode: config.lastCaptionsCode,
});
}
captionsSettingsButton.style.display = captionTrackList?.length
? 'inline-block'
: 'none';
}, 250);
});
captionsSettingsButton.addEventListener('click', async () => {
if (captionTrackList?.length) {
const currentCaptionTrack = api.getOption('captions', 'track');
let currentIndex = currentCaptionTrack
? captionTrackList.indexOf(captionTrackList.find((track) => track.languageCode === currentCaptionTrack.languageCode))
: null;
const captionLabels = [
...captionTrackList.map((track) => track.displayName),
'None',
];
currentIndex = await ipcRenderer.invoke('captionsSelector', captionLabels, currentIndex);
if (currentIndex === null) {
return;
}
const newCaptions = captionTrackList[currentIndex];
configProvider.set('lastCaptionsCode', newCaptions?.languageCode);
if (newCaptions) {
api.setOption('captions', 'track', { languageCode: newCaptions.languageCode });
} else {
api.setOption('captions', 'track', {});
}
setTimeout(() => api.playVideo());
}
});
}

View File

@ -1,20 +1,20 @@
const config = require("./config");
const config = require('./config');
module.exports = () => [
{
label: "Automatically select last used caption",
type: "checkbox",
checked: config.get("autoload"),
click: (item) => {
config.set('autoload', item.checked);
}
{
label: 'Automatically select last used caption',
type: 'checkbox',
checked: config.get('autoload'),
click(item) {
config.set('autoload', item.checked);
},
{
label: "No captions by default",
type: "checkbox",
checked: config.get("disabledCaptions"),
click: (item) => {
config.set('disableCaptions', item.checked);
},
}
},
{
label: 'No captions by default',
type: 'checkbox',
checked: config.get('disabledCaptions'),
click(item) {
config.set('disableCaptions', item.checked);
},
},
];

View File

@ -1,13 +1,17 @@
<tp-yt-paper-icon-button class="player-captions-button style-scope ytmusic-player" icon="yt-icons:subtitles"
title="Open captions selector" aria-label="Open captions selector" role="button" tabindex="0" aria-disabled="false">
<tp-yt-iron-icon id="icon" class="style-scope tp-yt-paper-icon-button"><svg viewBox="0 0 24 24"
preserveAspectRatio="xMidYMid meet" focusable="false" class="style-scope yt-icon"
style="pointer-events: none; display: block; width: 100%; height: 100%;">
<g class="style-scope yt-icon">
<path
d="M20 4H4c-1.103 0-2 .897-2 2v12c0 1.103.897 2 2 2h16c1.103 0 2-.897 2-2V6c0-1.103-.897-2-2-2zm-9 6H8v4h3v2H8c-1.103 0-2-.897-2-2v-4c0-1.103.897-2 2-2h3v2zm7 0h-3v4h3v2h-3c-1.103 0-2-.897-2-2v-4c0-1.103.897-2 2-2h3v2z"
class="style-scope tp-yt-iron-icon"></path>
</g>
</svg>
</tp-yt-iron-icon>
<tp-yt-paper-icon-button aria-disabled="false" aria-label="Open captions selector"
class="player-captions-button style-scope ytmusic-player" icon="yt-icons:subtitles"
role="button" tabindex="0"
title="Open captions selector">
<tp-yt-iron-icon class="style-scope tp-yt-paper-icon-button" id="icon">
<svg class="style-scope yt-icon"
focusable="false" preserveAspectRatio="xMidYMid meet"
style="pointer-events: none; display: block; width: 100%; height: 100%;"
viewBox="0 0 24 24">
<g class="style-scope yt-icon">
<path
class="style-scope tp-yt-iron-icon"
d="M20 4H4c-1.103 0-2 .897-2 2v12c0 1.103.897 2 2 2h16c1.103 0 2-.897 2-2V6c0-1.103-.897-2-2-2zm-9 6H8v4h3v2H8c-1.103 0-2-.897-2-2v-4c0-1.103.897-2 2-2h3v2zm7 0h-3v4h3v2h-3c-1.103 0-2-.897-2-2v-4c0-1.103.897-2 2-2h3v2z"></path>
</g>
</svg>
</tp-yt-iron-icon>
</tp-yt-paper-icon-button>

View File

@ -1,10 +1,10 @@
module.exports = () => {
const compactSidebar = document.querySelector("#mini-guide");
const isCompactSidebarDisabled =
compactSidebar === null ||
window.getComputedStyle(compactSidebar).display === "none";
const compactSidebar = document.querySelector('#mini-guide');
const isCompactSidebarDisabled
= compactSidebar === null
|| window.getComputedStyle(compactSidebar).display === 'none';
if (isCompactSidebarDisabled) {
document.querySelector("#button").click();
}
if (isCompactSidebarDisabled) {
document.querySelector('#button').click();
}
};

View File

@ -1,15 +1,15 @@
const { ipcMain } = require("electron");
const { Innertube } = require("youtubei.js");
const { ipcMain } = require('electron');
const { Innertube } = require('youtubei.js');
require("./config");
require('./config');
module.exports = async () => {
const yt = await Innertube.create();
const yt = await Innertube.create();
ipcMain.handle("audio-url", async (_, videoID) => {
const info = await yt.getBasicInfo(videoID);
const url = info.streaming_data?.formats[0].decipher(yt.session.player);
ipcMain.handle('audio-url', async (_, videoID) => {
const info = await yt.getBasicInfo(videoID);
const url = info.streaming_data?.formats[0].decipher(yt.session.player);
return url;
});
return url;
});
};

View File

@ -1,3 +1,4 @@
const { PluginConfig } = require("../../config/dynamic");
const config = new PluginConfig("crossfade", { enableFront: true });
const { PluginConfig } = require('../../config/dynamic');
const config = new PluginConfig('crossfade', { enableFront: true });
module.exports = { ...config };

View File

@ -16,345 +16,346 @@
*/
(function (root) {
"use strict";
'use strict';
// internal utility: check if value is a valid volume level and throw if not
let validateVolumeLevel = (value) => {
// number between 0 and 1?
if (!Number.isNaN(value) && value >= 0 && value <= 1) {
// yup, that's fine
return;
} else {
// abort and throw an exception
throw new TypeError("Number between 0 and 1 expected as volume!");
}
};
// Internal utility: check if value is a valid volume level and throw if not
const validateVolumeLevel = (value) => {
// Number between 0 and 1?
if (!Number.isNaN(value) && value >= 0 && value <= 1) {
// Yup, that's fine
// main class
class VolumeFader {
/**
* VolumeFader Constructor
*
* @param media {HTMLMediaElement} - audio or video element to be controlled
* @param options {Object} - an object with optional settings
* @throws {TypeError} if options.initialVolume or options.fadeDuration are invalid
*
* options:
* .logger: {Function} logging `function(stuff, …)` for execution information (default: no logging)
* .fadeScaling: {Mixed} either 'linear', 'logarithmic' or a positive number in dB (default: logarithmic)
* .initialVolume: {Number} media volume 0…1 to apply during setup (volume not touched by default)
* .fadeDuration: {Number} time in milliseconds to complete a fade (default: 1000 ms)
*/
constructor(media, options) {
// passed media element of correct type?
if (media instanceof HTMLMediaElement) {
// save reference to media element
this.media = media;
} else {
// abort and throw an exception
throw new TypeError("Media element expected!");
}
} else {
// Abort and throw an exception
throw new TypeError('Number between 0 and 1 expected as volume!');
}
};
// make sure options is an object
options = options || {};
// Main class
class VolumeFader {
/**
* VolumeFader Constructor
*
* @param media {HTMLMediaElement} - audio or video element to be controlled
* @param options {Object} - an object with optional settings
* @throws {TypeError} if options.initialVolume or options.fadeDuration are invalid
*
* options:
* .logger: {Function} logging `function(stuff, …)` for execution information (default: no logging)
* .fadeScaling: {Mixed} either 'linear', 'logarithmic' or a positive number in dB (default: logarithmic)
* .initialVolume: {Number} media volume 0…1 to apply during setup (volume not touched by default)
* .fadeDuration: {Number} time in milliseconds to complete a fade (default: 1000 ms)
*/
constructor(media, options) {
// Passed media element of correct type?
if (media instanceof HTMLMediaElement) {
// Save reference to media element
this.media = media;
} else {
// Abort and throw an exception
throw new TypeError('Media element expected!');
}
// log function passed?
if (typeof options.logger == "function") {
// set log function to the one specified
this.logger = options.logger;
} else {
// set log function explicitly to false
this.logger = false;
}
// Make sure options is an object
options = options || {};
// linear volume fading?
if (options.fadeScaling == "linear") {
// pass levels unchanged
this.scale = {
internalToVolume: (level) => level,
volumeToInternal: (level) => level,
};
// Log function passed?
if (typeof options.logger === 'function') {
// Set log function to the one specified
this.logger = options.logger;
} else {
// Set log function explicitly to false
this.logger = false;
}
// log setting
this.logger && this.logger("Using linear fading.");
}
// no linear, but logarithmic fading…
else {
let dynamicRange;
// Linear volume fading?
if (options.fadeScaling == 'linear') {
// Pass levels unchanged
this.scale = {
internalToVolume: (level) => level,
volumeToInternal: (level) => level,
};
// default dynamic range?
if (
options.fadeScaling === undefined ||
options.fadeScaling == "logarithmic"
) {
// set default of 60 dB
dynamicRange = 3;
}
// custom dynamic range?
else if (
!Number.isNaN(options.fadeScaling) &&
options.fadeScaling > 0
) {
// turn amplitude dB into a multiple of 10 power dB
dynamicRange = options.fadeScaling / 2 / 10;
}
// unsupported value
else {
// abort and throw exception
throw new TypeError(
"Expected 'linear', 'logarithmic' or a positive number as fade scaling preference!"
);
}
// Log setting
this.logger && this.logger('Using linear fading.');
}
// No linear, but logarithmic fading…
else {
let dynamicRange;
// use exponential/logarithmic scaler for expansion/compression
this.scale = {
internalToVolume: (level) =>
this.exponentialScaler(level, dynamicRange),
volumeToInternal: (level) =>
this.logarithmicScaler(level, dynamicRange),
};
// Default dynamic range?
if (
options.fadeScaling === undefined
|| options.fadeScaling == 'logarithmic'
) {
// Set default of 60 dB
dynamicRange = 3;
}
// Custom dynamic range?
else if (
!Number.isNaN(options.fadeScaling)
&& options.fadeScaling > 0
) {
// Turn amplitude dB into a multiple of 10 power dB
dynamicRange = options.fadeScaling / 2 / 10;
}
// Unsupported value
else {
// Abort and throw exception
throw new TypeError(
"Expected 'linear', 'logarithmic' or a positive number as fade scaling preference!",
);
}
// log setting if not default
options.fadeScaling &&
this.logger &&
this.logger(
"Using logarithmic fading with " +
String(10 * dynamicRange) +
" dB dynamic range."
);
}
// Use exponential/logarithmic scaler for expansion/compression
this.scale = {
internalToVolume: (level) =>
this.exponentialScaler(level, dynamicRange),
volumeToInternal: (level) =>
this.logarithmicScaler(level, dynamicRange),
};
// set initial volume?
if (options.initialVolume !== undefined) {
// validate volume level and throw if invalid
validateVolumeLevel(options.initialVolume);
// Log setting if not default
options.fadeScaling
&& this.logger
&& this.logger(
'Using logarithmic fading with '
+ String(10 * dynamicRange)
+ ' dB dynamic range.',
);
}
// set initial volume
this.media.volume = options.initialVolume;
// Set initial volume?
if (options.initialVolume !== undefined) {
// Validate volume level and throw if invalid
validateVolumeLevel(options.initialVolume);
// log setting
this.logger &&
this.logger(
"Set initial volume to " + String(this.media.volume) + "."
);
}
// Set initial volume
this.media.volume = options.initialVolume;
// fade duration given?
if (options.fadeDuration !== undefined) {
// try to set given fade duration (will log if successful and throw if not)
this.setFadeDuration(options.fadeDuration);
} else {
// set default fade duration (1000 ms)
this.fadeDuration = 1000;
}
// Log setting
this.logger
&& this.logger(
'Set initial volume to ' + String(this.media.volume) + '.',
);
}
// indicate that fader is not active yet
this.active = false;
// Fade duration given?
if (options.fadeDuration === undefined) {
// Set default fade duration (1000 ms)
this.fadeDuration = 1000;
} else {
// Try to set given fade duration (will log if successful and throw if not)
this.setFadeDuration(options.fadeDuration);
}
// initialization done
this.logger && this.logger("Initialized for", this.media);
}
// Indicate that fader is not active yet
this.active = false;
/**
* Re(start) the update cycle.
* (this.active must be truthy for volume updates to take effect)
*
* @return {Object} VolumeFader instance for chaining
*/
start() {
// set fader to be active
this.active = true;
// Initialization done
this.logger && this.logger('Initialized for', this.media);
}
// start by running the update method
this.updateVolume();
/**
* Re(start) the update cycle.
* (this.active must be truthy for volume updates to take effect)
*
* @return {Object} VolumeFader instance for chaining
*/
start() {
// Set fader to be active
this.active = true;
// return instance for chaining
return this;
}
// Start by running the update method
this.updateVolume();
/**
* Stop the update cycle.
* (interrupting any fade)
*
* @return {Object} VolumeFader instance for chaining
*/
stop() {
// set fader to be inactive
this.active = false;
// Return instance for chaining
return this;
}
// return instance for chaining
return this;
}
/**
* Stop the update cycle.
* (interrupting any fade)
*
* @return {Object} VolumeFader instance for chaining
*/
stop() {
// Set fader to be inactive
this.active = false;
/**
* Set fade duration.
* (used for future calls to fadeTo)
*
* @param {Number} fadeDuration - fading length in milliseconds
* @throws {TypeError} if fadeDuration is not a number greater than zero
* @return {Object} VolumeFader instance for chaining
*/
setFadeDuration(fadeDuration) {
// if duration is a valid number > 0…
if (!Number.isNaN(fadeDuration) && fadeDuration > 0) {
// set fade duration
this.fadeDuration = fadeDuration;
// Return instance for chaining
return this;
}
// log setting
this.logger &&
this.logger("Set fade duration to " + String(fadeDuration) + " ms.");
} else {
// abort and throw an exception
throw new TypeError("Positive number expected as fade duration!");
}
/**
* Set fade duration.
* (used for future calls to fadeTo)
*
* @param {Number} fadeDuration - fading length in milliseconds
* @throws {TypeError} if fadeDuration is not a number greater than zero
* @return {Object} VolumeFader instance for chaining
*/
setFadeDuration(fadeDuration) {
// If duration is a valid number > 0…
if (!Number.isNaN(fadeDuration) && fadeDuration > 0) {
// Set fade duration
this.fadeDuration = fadeDuration;
// return instance for chaining
return this;
}
// Log setting
this.logger
&& this.logger('Set fade duration to ' + String(fadeDuration) + ' ms.');
} else {
// Abort and throw an exception
throw new TypeError('Positive number expected as fade duration!');
}
/**
* Define a new fade and start fading.
*
* @param {Number} targetVolume - level to fade to in the range 0…1
* @param {Function} callback - (optional) function to be called when fade is complete
* @throws {TypeError} if targetVolume is not in the range 0…1
* @return {Object} VolumeFader instance for chaining
*/
fadeTo(targetVolume, callback) {
// validate volume and throw if invalid
validateVolumeLevel(targetVolume);
// Return instance for chaining
return this;
}
// define new fade
this.fade = {
// volume start and end point on internal fading scale
volume: {
start: this.scale.volumeToInternal(this.media.volume),
end: this.scale.volumeToInternal(targetVolume),
},
// time start and end point
time: {
start: Date.now(),
end: Date.now() + this.fadeDuration,
},
// optional callback function
callback: callback,
};
/**
* Define a new fade and start fading.
*
* @param {Number} targetVolume - level to fade to in the range 0…1
* @param {Function} callback - (optional) function to be called when fade is complete
* @throws {TypeError} if targetVolume is not in the range 0…1
* @return {Object} VolumeFader instance for chaining
*/
fadeTo(targetVolume, callback) {
// Validate volume and throw if invalid
validateVolumeLevel(targetVolume);
// start fading
this.start();
// Define new fade
this.fade = {
// Volume start and end point on internal fading scale
volume: {
start: this.scale.volumeToInternal(this.media.volume),
end: this.scale.volumeToInternal(targetVolume),
},
// Time start and end point
time: {
start: Date.now(),
end: Date.now() + this.fadeDuration,
},
// Optional callback function
callback,
};
// log new fade
this.logger && this.logger("New fade started:", this.fade);
// Start fading
this.start();
// return instance for chaining
return this;
}
// Log new fade
this.logger && this.logger('New fade started:', this.fade);
// convenience shorthand methods for common fades
fadeIn(callback) {
this.fadeTo(1, callback);
}
fadeOut(callback) {
this.fadeTo(0, callback);
}
// Return instance for chaining
return this;
}
/**
* Internal: Update media volume.
* (calls itself through requestAnimationFrame)
*
* @param {Number} targetVolume - linear level to fade to (0…1)
* @param {Function} callback - (optional) function to be called when fade is complete
*/
updateVolume() {
// fader active and fade available to process?
if (this.active && this.fade) {
// get current time
let now = Date.now();
// Convenience shorthand methods for common fades
fadeIn(callback) {
this.fadeTo(1, callback);
}
// time left for fading?
if (now < this.fade.time.end) {
// compute current fade progress
let progress =
(now - this.fade.time.start) /
(this.fade.time.end - this.fade.time.start);
fadeOut(callback) {
this.fadeTo(0, callback);
}
// compute current level on internal scale
let level =
progress * (this.fade.volume.end - this.fade.volume.start) +
this.fade.volume.start;
/**
* Internal: Update media volume.
* (calls itself through requestAnimationFrame)
*
* @param {Number} targetVolume - linear level to fade to (0…1)
* @param {Function} callback - (optional) function to be called when fade is complete
*/
updateVolume() {
// Fader active and fade available to process?
if (this.active && this.fade) {
// Get current time
const now = Date.now();
// map fade level to volume level and apply it to media element
this.media.volume = this.scale.internalToVolume(level);
// Time left for fading?
if (now < this.fade.time.end) {
// Compute current fade progress
const progress
= (now - this.fade.time.start)
/ (this.fade.time.end - this.fade.time.start);
// schedule next update
root.requestAnimationFrame(this.updateVolume.bind(this));
} else {
// log end of fade
this.logger &&
this.logger(
"Fade to " + String(this.fade.volume.end) + " complete."
);
// Compute current level on internal scale
const level
= progress * (this.fade.volume.end - this.fade.volume.start)
+ this.fade.volume.start;
// time is up, jump to target volume
this.media.volume = this.scale.internalToVolume(this.fade.volume.end);
// Map fade level to volume level and apply it to media element
this.media.volume = this.scale.internalToVolume(level);
// set fader to be inactive
this.active = false;
// Schedule next update
root.requestAnimationFrame(this.updateVolume.bind(this));
} else {
// Log end of fade
this.logger
&& this.logger(
'Fade to ' + String(this.fade.volume.end) + ' complete.',
);
// done, call back (if callable)
typeof this.fade.callback == "function" && this.fade.callback();
// Time is up, jump to target volume
this.media.volume = this.scale.internalToVolume(this.fade.volume.end);
// clear fade
this.fade = undefined;
}
}
}
// Set fader to be inactive
this.active = false;
/**
* Internal: Exponential scaler with dynamic range limit.
*
* @param {Number} input - logarithmic input level to be expanded (float, 0…1)
* @param {Number} dynamicRange - expanded output range, in multiples of 10 dB (float, 0…∞)
* @return {Number} - expanded level (float, 0…1)
*/
exponentialScaler(input, dynamicRange) {
// special case: make zero (or any falsy input) return zero
if (input == 0) {
// since the dynamic range is limited,
// allow a zero to produce a plain zero instead of a small faction
// (audio would not be recognized as silent otherwise)
return 0;
} else {
// scale 0…1 to minus something × 10 dB
input = (input - 1) * dynamicRange;
// Done, call back (if callable)
typeof this.fade.callback === 'function' && this.fade.callback();
// compute power of 10
return Math.pow(10, input);
}
}
// Clear fade
this.fade = undefined;
}
}
}
/**
* Internal: Logarithmic scaler with dynamic range limit.
*
* @param {Number} input - exponential input level to be compressed (float, 0…1)
* @param {Number} dynamicRange - coerced input range, in multiples of 10 dB (float, 0…∞)
* @return {Number} - compressed level (float, 0…1)
*/
logarithmicScaler(input, dynamicRange) {
// special case: make zero (or any falsy input) return zero
if (input == 0) {
// logarithm of zero would be -∞, which would map to zero anyway
return 0;
} else {
// compute base-10 logarithm
input = Math.log10(input);
/**
* Internal: Exponential scaler with dynamic range limit.
*
* @param {Number} input - logarithmic input level to be expanded (float, 0…1)
* @param {Number} dynamicRange - expanded output range, in multiples of 10 dB (float, 0…∞)
* @return {Number} - expanded level (float, 0…1)
*/
exponentialScaler(input, dynamicRange) {
// Special case: make zero (or any falsy input) return zero
if (input == 0) {
// Since the dynamic range is limited,
// allow a zero to produce a plain zero instead of a small faction
// (audio would not be recognized as silent otherwise)
return 0;
}
// scale minus something × 10 dB to 0…1 (clipping at 0)
return Math.max(1 + input / dynamicRange, 0);
}
}
}
// Scale 0…1 to minus something × 10 dB
input = (input - 1) * dynamicRange;
// export class to root scope
root.VolumeFader = VolumeFader;
// Compute power of 10
return 10 ** input;
}
/**
* Internal: Logarithmic scaler with dynamic range limit.
*
* @param {Number} input - exponential input level to be compressed (float, 0…1)
* @param {Number} dynamicRange - coerced input range, in multiples of 10 dB (float, 0…∞)
* @return {Number} - compressed level (float, 0…1)
*/
logarithmicScaler(input, dynamicRange) {
// Special case: make zero (or any falsy input) return zero
if (input == 0) {
// Logarithm of zero would be -∞, which would map to zero anyway
return 0;
}
// Compute base-10 logarithm
input = Math.log10(input);
// Scale minus something × 10 dB to 0…1 (clipping at 0)
return Math.max(1 + input / dynamicRange, 0);
}
}
// Export class to root scope
root.VolumeFader = VolumeFader;
})(window);

View File

@ -1,158 +1,160 @@
const { ipcRenderer } = require("electron");
const { Howl } = require("howler");
const { ipcRenderer } = require('electron');
const { Howl } = require('howler');
// Extracted from https://github.com/bitfasching/VolumeFader
require("./fader");
require('./fader');
let transitionAudio; // Howler audio used to fade out the current music
let firstVideo = true;
let waitForTransition;
const defaultConfig = require("../../config/defaults").plugins.crossfade;
const defaultConfig = require('../../config/defaults').plugins.crossfade;
const configProvider = require('./config');
const configProvider = require("./config");
let config;
const configGetNum = (key) => Number(config[key]) || defaultConfig[key];
const configGetNumber = (key) => Number(config[key]) || defaultConfig[key];
const getStreamURL = async (videoID) => {
const url = await ipcRenderer.invoke("audio-url", videoID);
return url;
const url = await ipcRenderer.invoke('audio-url', videoID);
return url;
};
const getVideoIDFromURL = (url) => {
return new URLSearchParams(url.split("?")?.at(-1)).get("v");
};
const getVideoIDFromURL = (url) => new URLSearchParams(url.split('?')?.at(-1)).get('v');
const isReadyToCrossfade = () => {
return transitionAudio && transitionAudio.state() === "loaded";
};
const isReadyToCrossfade = () => transitionAudio && transitionAudio.state() === 'loaded';
const watchVideoIDChanges = (cb) => {
navigation.addEventListener("navigate", (event) => {
const currentVideoID = getVideoIDFromURL(
event.currentTarget.currentEntry.url,
);
const nextVideoID = getVideoIDFromURL(event.destination.url);
navigation.addEventListener('navigate', (event) => {
const currentVideoID = getVideoIDFromURL(
event.currentTarget.currentEntry.url,
);
const nextVideoID = getVideoIDFromURL(event.destination.url);
if (
nextVideoID &&
currentVideoID &&
(firstVideo || nextVideoID !== currentVideoID)
) {
if (isReadyToCrossfade()) {
crossfade(() => {
cb(nextVideoID);
});
} else {
cb(nextVideoID);
firstVideo = false;
}
}
});
if (
nextVideoID
&& currentVideoID
&& (firstVideo || nextVideoID !== currentVideoID)
) {
if (isReadyToCrossfade()) {
crossfade(() => {
cb(nextVideoID);
});
} else {
cb(nextVideoID);
firstVideo = false;
}
}
});
};
const createAudioForCrossfade = async (url) => {
if (transitionAudio) {
transitionAudio.unload();
}
transitionAudio = new Howl({
src: url,
html5: true,
volume: 0,
});
await syncVideoWithTransitionAudio();
if (transitionAudio) {
transitionAudio.unload();
}
transitionAudio = new Howl({
src: url,
html5: true,
volume: 0,
});
await syncVideoWithTransitionAudio();
};
const syncVideoWithTransitionAudio = async () => {
const video = document.querySelector("video");
const video = document.querySelector('video');
const videoFader = new VolumeFader(video, {
fadeScaling: configGetNum("fadeScaling"),
fadeDuration: configGetNum("fadeInDuration"),
});
const videoFader = new VolumeFader(video, {
fadeScaling: configGetNumber('fadeScaling'),
fadeDuration: configGetNumber('fadeInDuration'),
});
await transitionAudio.play();
await transitionAudio.seek(video.currentTime);
await transitionAudio.play();
await transitionAudio.seek(video.currentTime);
video.onseeking = () => {
transitionAudio.seek(video.currentTime);
};
video.onpause = () => {
transitionAudio.pause();
};
video.onplay = async () => {
await transitionAudio.play();
await transitionAudio.seek(video.currentTime);
video.addEventListener('seeking', () => {
transitionAudio.seek(video.currentTime);
});
// Fade in
const videoVolume = video.volume;
video.volume = 0;
videoFader.fadeTo(videoVolume);
};
video.addEventListener('pause', () => {
transitionAudio.pause();
});
// Exit just before the end for the transition
const transitionBeforeEnd = () => {
if (
video.currentTime >= video.duration - configGetNum("secondsBeforeEnd") &&
isReadyToCrossfade()
) {
video.removeEventListener("timeupdate", transitionBeforeEnd);
video.addEventListener('play', async () => {
await transitionAudio.play();
await transitionAudio.seek(video.currentTime);
// Go to next video - XXX: does not support "repeat 1" mode
document.querySelector(".next-button").click();
}
};
video.ontimeupdate = transitionBeforeEnd;
// Fade in
const videoVolume = video.volume;
video.volume = 0;
videoFader.fadeTo(videoVolume);
});
// Exit just before the end for the transition
const transitionBeforeEnd = () => {
if (
video.currentTime >= video.duration - configGetNumber('secondsBeforeEnd')
&& isReadyToCrossfade()
) {
video.removeEventListener('timeupdate', transitionBeforeEnd);
// Go to next video - XXX: does not support "repeat 1" mode
document.querySelector('.next-button').click();
}
};
video.addEventListener('timeupdate', transitionBeforeEnd);
};
const onApiLoaded = () => {
watchVideoIDChanges(async (videoID) => {
await waitForTransition;
const url = await getStreamURL(videoID);
if (!url) {
return;
}
await createAudioForCrossfade(url);
});
watchVideoIDChanges(async (videoID) => {
await waitForTransition;
const url = await getStreamURL(videoID);
if (!url) {
return;
}
await createAudioForCrossfade(url);
});
};
const crossfade = async (cb) => {
if (!isReadyToCrossfade()) {
cb();
return;
}
if (!isReadyToCrossfade()) {
cb();
return;
}
let resolveTransition;
waitForTransition = new Promise(function (resolve, reject) {
resolveTransition = resolve;
});
let resolveTransition;
waitForTransition = new Promise((resolve, reject) => {
resolveTransition = resolve;
});
const video = document.querySelector("video");
const video = document.querySelector('video');
const fader = new VolumeFader(transitionAudio._sounds[0]._node, {
initialVolume: video.volume,
fadeScaling: configGetNum("fadeScaling"),
fadeDuration: configGetNum("fadeOutDuration"),
});
const fader = new VolumeFader(transitionAudio._sounds[0]._node, {
initialVolume: video.volume,
fadeScaling: configGetNumber('fadeScaling'),
fadeDuration: configGetNumber('fadeOutDuration'),
});
// Fade out the music
video.volume = 0;
fader.fadeOut(() => {
resolveTransition();
cb();
});
// Fade out the music
video.volume = 0;
fader.fadeOut(() => {
resolveTransition();
cb();
});
};
module.exports = async () => {
config = await configProvider.getAll();
config = await configProvider.getAll();
configProvider.subscribeAll((newConfig) => {
config = newConfig;
});
configProvider.subscribeAll((newConfig) => {
config = newConfig;
});
document.addEventListener("apiLoaded", onApiLoaded, {
once: true,
passive: true,
});
document.addEventListener('apiLoaded', onApiLoaded, {
once: true,
passive: true,
});
};

View File

@ -1,72 +1,79 @@
const config = require("./config");
const defaultOptions = require("../../config/defaults").plugins.crossfade;
const config = require('./config');
const prompt = require("custom-electron-prompt");
const promptOptions = require("../../providers/prompt-options");
const defaultOptions = require('../../config/defaults').plugins.crossfade;
const prompt = require('custom-electron-prompt');
const promptOptions = require('../../providers/prompt-options');
module.exports = (win) => [
{
label: "Advanced",
click: async () => {
const newOptions = await promptCrossfadeValues(win, config.getAll());
if (newOptions) config.setAll(newOptions);
},
},
{
label: 'Advanced',
async click() {
const newOptions = await promptCrossfadeValues(win, config.getAll());
if (newOptions) {
config.setAll(newOptions);
}
},
},
];
async function promptCrossfadeValues(win, options) {
const res = await prompt(
{
title: "Crossfade Options",
type: "multiInput",
multiInputOptions: [
{
label: "Fade in duration (ms)",
value: options.fadeInDuration || defaultOptions.fadeInDuration,
inputAttrs: {
type: "number",
required: true,
min: 0,
step: 100,
},
},
{
label: "Fade out duration (ms)",
value: options.fadeOutDuration || defaultOptions.fadeOutDuration,
inputAttrs: {
type: "number",
required: true,
min: 0,
step: 100,
},
},
{
label: "Crossfade x seconds before end",
value:
options.secondsBeforeEnd || defaultOptions.secondsBeforeEnd,
inputAttrs: {
type: "number",
required: true,
min: 0,
},
},
{
label: "Fade scaling",
selectOptions: { linear: "Linear", logarithmic: "Logarithmic" },
value: options.fadeScaling || defaultOptions.fadeScaling,
},
],
resizable: true,
height: 360,
...promptOptions(),
},
win,
).catch(console.error);
if (!res) return undefined;
return {
fadeInDuration: Number(res[0]),
fadeOutDuration: Number(res[1]),
secondsBeforeEnd: Number(res[2]),
fadeScaling: res[3],
};
const res = await prompt(
{
title: 'Crossfade Options',
type: 'multiInput',
multiInputOptions: [
{
label: 'Fade in duration (ms)',
value: options.fadeInDuration || defaultOptions.fadeInDuration,
inputAttrs: {
type: 'number',
required: true,
min: 0,
step: 100,
},
},
{
label: 'Fade out duration (ms)',
value: options.fadeOutDuration || defaultOptions.fadeOutDuration,
inputAttrs: {
type: 'number',
required: true,
min: 0,
step: 100,
},
},
{
label: 'Crossfade x seconds before end',
value:
options.secondsBeforeEnd || defaultOptions.secondsBeforeEnd,
inputAttrs: {
type: 'number',
required: true,
min: 0,
},
},
{
label: 'Fade scaling',
selectOptions: { linear: 'Linear', logarithmic: 'Logarithmic' },
value: options.fadeScaling || defaultOptions.fadeScaling,
},
],
resizable: true,
height: 360,
...promptOptions(),
},
win,
).catch(console.error);
if (!res) {
return undefined;
}
return {
fadeInDuration: Number(res[0]),
fadeOutDuration: Number(res[1]),
secondsBeforeEnd: Number(res[2]),
fadeScaling: res[3],
};
}

View File

@ -1,14 +1,14 @@
module.exports = () => {
document.addEventListener('apiLoaded', apiEvent => {
apiEvent.detail.addEventListener('videodatachange', name => {
if (name === 'dataloaded') {
apiEvent.detail.pauseVideo();
document.querySelector('video').ontimeupdate = e => {
e.target.pause();
}
} else {
document.querySelector('video').ontimeupdate = null;
}
})
}, { once: true, passive: true })
document.addEventListener('apiLoaded', (apiEvent) => {
apiEvent.detail.addEventListener('videodatachange', (name) => {
if (name === 'dataloaded') {
apiEvent.detail.pauseVideo();
document.querySelector('video').addEventListener('timeupdate', (e) => {
e.target.pause();
});
} else {
document.querySelector('video').ontimeupdate = null;
}
});
}, { once: true, passive: true });
};

View File

@ -1,12 +1,12 @@
"use strict";
const Discord = require("@xhayper/discord-rpc");
const { dev } = require("electron-is");
const { dialog, app } = require("electron");
'use strict';
const { dialog, app } = require('electron');
const Discord = require('@xhayper/discord-rpc');
const { dev } = require('electron-is');
const registerCallback = require("../../providers/song-info");
const registerCallback = require('../../providers/song-info');
// Application ID registered by @Zo-Bro-23
const clientId = "1043858434585526382";
const clientId = '1043858434585526382';
/**
* @typedef {Object} Info
@ -19,12 +19,12 @@ const clientId = "1043858434585526382";
* @type {Info}
*/
const info = {
rpc: new Discord.Client({
clientId
}),
ready: false,
autoReconnect: true,
lastSongInfo: null,
rpc: new Discord.Client({
clientId,
}),
ready: false,
autoReconnect: true,
lastSongInfo: null,
};
/**
@ -33,59 +33,87 @@ const info = {
const refreshCallbacks = [];
const resetInfo = () => {
info.ready = false;
clearTimeout(clearActivity);
if (dev()) console.log("discord disconnected");
refreshCallbacks.forEach(cb => cb());
info.ready = false;
clearTimeout(clearActivity);
if (dev()) {
console.log('discord disconnected');
}
for (const cb of refreshCallbacks) {
cb();
}
};
info.rpc.on("connected", () => {
if (dev()) console.log("discord connected");
refreshCallbacks.forEach(cb => cb());
info.rpc.on('connected', () => {
if (dev()) {
console.log('discord connected');
}
for (const cb of refreshCallbacks) {
cb();
}
});
info.rpc.on("ready", () => {
info.ready = true;
if (info.lastSongInfo) updateActivity(info.lastSongInfo)
info.rpc.on('ready', () => {
info.ready = true;
if (info.lastSongInfo) {
updateActivity(info.lastSongInfo);
}
});
info.rpc.on("disconnected", () => {
resetInfo();
info.rpc.on('disconnected', () => {
resetInfo();
if (info.autoReconnect) {
connectTimeout();
}
if (info.autoReconnect) {
connectTimeout();
}
});
const connectTimeout = () => new Promise((resolve, reject) => setTimeout(() => {
if (!info.autoReconnect || info.rpc.isConnected) return;
info.rpc.login().then(resolve).catch(reject);
if (!info.autoReconnect || info.rpc.isConnected) {
return;
}
info.rpc.login().then(resolve).catch(reject);
}, 5000));
const connectRecursive = () => {
if (!info.autoReconnect || info.rpc.isConnected) return;
connectTimeout().catch(connectRecursive);
}
if (!info.autoReconnect || info.rpc.isConnected) {
return;
}
connectTimeout().catch(connectRecursive);
};
let window;
const connect = (showErr = false) => {
if (info.rpc.isConnected) {
if (dev())
console.log('Attempted to connect with active connection');
return;
}
const connect = (showError = false) => {
if (info.rpc.isConnected) {
if (dev()) {
console.log('Attempted to connect with active connection');
}
info.ready = false;
return;
}
// Startup the rpc client
info.rpc.login({ clientId }).catch(err => {
resetInfo();
if (dev()) console.error(err);
if (info.autoReconnect) {
connectRecursive();
}
else if (showErr) dialog.showMessageBox(window, { title: 'Connection failed', message: err.message || String(err), type: 'error' });
});
info.ready = false;
// Startup the rpc client
info.rpc.login({ clientId }).catch((error) => {
resetInfo();
if (dev()) {
console.error(error);
}
if (info.autoReconnect) {
connectRecursive();
} else if (showError) {
dialog.showMessageBox(window, {
title: 'Connection failed',
message: error.message || String(error),
type: 'error',
});
}
});
};
let clearActivity;
@ -95,75 +123,80 @@ let clearActivity;
let updateActivity;
module.exports = (win, { autoReconnect, activityTimoutEnabled, activityTimoutTime, listenAlong, hideDurationLeft }) => {
info.autoReconnect = autoReconnect;
info.autoReconnect = autoReconnect;
window = win;
// We get multiple events
// Next song: PAUSE(n), PAUSE(n+1), PLAY(n+1)
// Skip time: PAUSE(N), PLAY(N)
updateActivity = songInfo => {
if (songInfo.title.length === 0 && songInfo.artist.length === 0) {
return;
}
info.lastSongInfo = songInfo;
window = win;
// We get multiple events
// Next song: PAUSE(n), PAUSE(n+1), PLAY(n+1)
// Skip time: PAUSE(N), PLAY(N)
updateActivity = (songInfo) => {
if (songInfo.title.length === 0 && songInfo.artist.length === 0) {
return;
}
// stop the clear activity timout
clearTimeout(clearActivity);
info.lastSongInfo = songInfo;
// stop early if discord connection is not ready
// do this after clearTimeout to avoid unexpected clears
if (!info.rpc || !info.ready) {
return;
}
// Stop the clear activity timout
clearTimeout(clearActivity);
// clear directly if timeout is 0
if (songInfo.isPaused && activityTimoutEnabled && activityTimoutTime === 0) {
info.rpc.user?.clearActivity().catch(console.error);
return;
}
// Stop early if discord connection is not ready
// do this after clearTimeout to avoid unexpected clears
if (!info.rpc || !info.ready) {
return;
}
// Song information changed, so lets update the rich presence
// @see https://discord.com/developers/docs/topics/gateway#activity-object
// not all options are transfered through https://github.com/discordjs/RPC/blob/6f83d8d812c87cb7ae22064acd132600407d7d05/src/client.js#L518-530
const activityInfo = {
details: songInfo.title,
state: songInfo.artist,
largeImageKey: songInfo.imageSrc,
largeImageText: songInfo.album,
buttons: listenAlong ? [
{ label: "Listen Along", url: songInfo.url },
] : undefined,
};
// Clear directly if timeout is 0
if (songInfo.isPaused && activityTimoutEnabled && activityTimoutTime === 0) {
info.rpc.user?.clearActivity().catch(console.error);
return;
}
if (songInfo.isPaused) {
// Add a paused icon to show that the song is paused
activityInfo.smallImageKey = "paused";
activityInfo.smallImageText = "Paused";
// Set start the timer so the activity gets cleared after a while if enabled
if (activityTimoutEnabled)
clearActivity = setTimeout(() => info.rpc.user?.clearActivity().catch(console.error), activityTimoutTime ?? 10000);
} else if (!hideDurationLeft) {
// Add the start and end time of the song
const songStartTime = Date.now() - songInfo.elapsedSeconds * 1000;
activityInfo.startTimestamp = songStartTime;
activityInfo.endTimestamp =
songStartTime + songInfo.songDuration * 1000;
}
// Song information changed, so lets update the rich presence
// @see https://discord.com/developers/docs/topics/gateway#activity-object
// not all options are transfered through https://github.com/discordjs/RPC/blob/6f83d8d812c87cb7ae22064acd132600407d7d05/src/client.js#L518-530
const activityInfo = {
details: songInfo.title,
state: songInfo.artist,
largeImageKey: songInfo.imageSrc,
largeImageText: songInfo.album,
buttons: listenAlong ? [
{ label: 'Listen Along', url: songInfo.url },
] : undefined,
};
info.rpc.user?.setActivity(activityInfo).catch(console.error);
};
if (songInfo.isPaused) {
// Add a paused icon to show that the song is paused
activityInfo.smallImageKey = 'paused';
activityInfo.smallImageText = 'Paused';
// Set start the timer so the activity gets cleared after a while if enabled
if (activityTimoutEnabled) {
clearActivity = setTimeout(() => info.rpc.user?.clearActivity().catch(console.error), activityTimoutTime ?? 10_000);
}
} else if (!hideDurationLeft) {
// Add the start and end time of the song
const songStartTime = Date.now() - songInfo.elapsedSeconds * 1000;
activityInfo.startTimestamp = songStartTime;
activityInfo.endTimestamp
= songStartTime + songInfo.songDuration * 1000;
}
// If the page is ready, register the callback
win.once("ready-to-show", () => {
registerCallback(updateActivity);
connect();
});
app.on('window-all-closed', module.exports.clear)
info.rpc.user?.setActivity(activityInfo).catch(console.error);
};
// If the page is ready, register the callback
win.once('ready-to-show', () => {
registerCallback(updateActivity);
connect();
});
app.on('window-all-closed', module.exports.clear);
};
module.exports.clear = () => {
if (info.rpc) info.rpc.user?.clearActivity();
clearTimeout(clearActivity);
if (info.rpc) {
info.rpc.user?.clearActivity();
}
clearTimeout(clearActivity);
};
module.exports.connect = connect;

View File

@ -1,84 +1,84 @@
const prompt = require("custom-electron-prompt");
const prompt = require('custom-electron-prompt');
const { setMenuOptions } = require("../../config/plugins");
const promptOptions = require("../../providers/prompt-options");
const { clear, connect, registerRefresh, isConnected } = require("./back");
const { clear, connect, registerRefresh, isConnected } = require('./back');
const { singleton } = require("../../providers/decorators")
const { setMenuOptions } = require('../../config/plugins');
const promptOptions = require('../../providers/prompt-options');
const { singleton } = require('../../providers/decorators');
const registerRefreshOnce = singleton((refreshMenu) => {
registerRefresh(refreshMenu);
registerRefresh(refreshMenu);
});
module.exports = (win, options, refreshMenu) => {
registerRefreshOnce(refreshMenu);
registerRefreshOnce(refreshMenu);
return [
{
label: isConnected() ? "Connected" : "Reconnect",
enabled: !isConnected(),
click: connect,
},
{
label: "Auto reconnect",
type: "checkbox",
checked: options.autoReconnect,
click: (item) => {
options.autoReconnect = item.checked;
setMenuOptions('discord', options);
},
},
{
label: "Clear activity",
click: clear,
},
{
label: "Clear activity after timeout",
type: "checkbox",
checked: options.activityTimoutEnabled,
click: (item) => {
options.activityTimoutEnabled = item.checked;
setMenuOptions('discord', options);
},
},
{
label: "Listen Along",
type: "checkbox",
checked: options.listenAlong,
click: (item) => {
options.listenAlong = item.checked;
setMenuOptions('discord', options);
},
},
{
label: "Hide duration left",
type: "checkbox",
checked: options.hideDurationLeft,
click: (item) => {
options.hideDurationLeft = item.checked;
setMenuOptions('discord', options);
}
},
{
label: "Set inactivity timeout",
click: () => setInactivityTimeout(win, options),
},
];
return [
{
label: isConnected() ? 'Connected' : 'Reconnect',
enabled: !isConnected(),
click: connect,
},
{
label: 'Auto reconnect',
type: 'checkbox',
checked: options.autoReconnect,
click(item) {
options.autoReconnect = item.checked;
setMenuOptions('discord', options);
},
},
{
label: 'Clear activity',
click: clear,
},
{
label: 'Clear activity after timeout',
type: 'checkbox',
checked: options.activityTimoutEnabled,
click(item) {
options.activityTimoutEnabled = item.checked;
setMenuOptions('discord', options);
},
},
{
label: 'Listen Along',
type: 'checkbox',
checked: options.listenAlong,
click(item) {
options.listenAlong = item.checked;
setMenuOptions('discord', options);
},
},
{
label: 'Hide duration left',
type: 'checkbox',
checked: options.hideDurationLeft,
click(item) {
options.hideDurationLeft = item.checked;
setMenuOptions('discord', options);
},
},
{
label: 'Set inactivity timeout',
click: () => setInactivityTimeout(win, options),
},
];
};
async function setInactivityTimeout(win, options) {
let output = await prompt({
title: 'Set Inactivity Timeout',
label: 'Enter inactivity timeout in seconds:',
value: Math.round((options.activityTimoutTime ?? 0) / 1e3),
type: "counter",
counterOptions: { minimum: 0, multiFire: true },
width: 450,
...promptOptions()
}, win)
const output = await prompt({
title: 'Set Inactivity Timeout',
label: 'Enter inactivity timeout in seconds:',
value: Math.round((options.activityTimoutTime ?? 0) / 1e3),
type: 'counter',
counterOptions: { minimum: 0, multiFire: true },
width: 450,
...promptOptions(),
}, win);
if (output) {
options.activityTimoutTime = Math.round(output * 1e3);
setMenuOptions("discord", options);
}
if (output) {
options.activityTimoutTime = Math.round(output * 1e3);
setMenuOptions('discord', options);
}
}

View File

@ -3,14 +3,26 @@ const {
mkdirSync,
createWriteStream,
writeFileSync,
} = require('fs');
const { join } = require('path');
} = require('node:fs');
const { join } = require('node:path');
const { randomBytes } = require('node:crypto');
const { ipcMain, app, dialog } = require('electron');
const is = require('electron-is');
const { Innertube, UniversalCache, Utils, ClientType } = require('youtubei.js');
const ytpl = require('ytpl'); // REPLACE with youtubei getplaylist https://github.com/LuanRT/YouTube.js#getplaylistid
const filenamify = require('filenamify');
const ID3Writer = require('browser-id3-writer');
const { Mutex } = require('async-mutex');
const ffmpeg = require('@ffmpeg/ffmpeg').createFFmpeg({
log: false,
logger() {
}, // Console.log,
progress() {
}, // Console.log,
});
const { fetchFromGenius } = require('../lyrics-genius/back');
const { isEnabled } = require('../../config/plugins');
const { getImage, cleanupName } = require('../../providers/song-info');
const { injectCSS } = require('../utils');
const { cache } = require("../../providers/decorators")
const {
presets,
cropMaxWidth,
@ -19,20 +31,12 @@ const {
sendFeedback: sendFeedback_,
} = require('./utils');
const { ipcMain, app, dialog } = require('electron');
const is = require('electron-is');
const { Innertube, UniversalCache, Utils, ClientType } = require('youtubei.js');
const ytpl = require('ytpl'); // REPLACE with youtubei getplaylist https://github.com/LuanRT/YouTube.js#getplaylistid
const { fetchFromGenius } = require('../lyrics-genius/back');
const { isEnabled } = require('../../config/plugins');
const { getImage, cleanupName } = require('../../providers/song-info');
const { injectCSS } = require('../utils');
const { cache } = require('../../providers/decorators');
const filenamify = require('filenamify');
const ID3Writer = require('browser-id3-writer');
const { randomBytes } = require('crypto');
const Mutex = require('async-mutex').Mutex;
const ffmpeg = require('@ffmpeg/ffmpeg').createFFmpeg({
log: false,
logger: () => {}, // console.log,
progress: () => {}, // console.log,
});
const ffmpegMutex = new Mutex();
const config = require('./config');
@ -40,12 +44,12 @@ const config = require('./config');
/** @type {Innertube} */
let yt;
let win;
let playingUrl = undefined;
let playingUrl;
const sendError = (error, source) => {
win.setProgressBar(-1); // close progress bar
setBadge(0); // close badge
sendFeedback_(win); // reset feedback
win.setProgressBar(-1); // Close progress bar
setBadge(0); // Close badge
sendFeedback_(win); // Reset feedback
const songNameMessage = source ? `\nin ${source}` : '';
const cause = error.cause ? `\n\n${error.cause.toString()}` : '';
@ -71,8 +75,8 @@ module.exports = async (win_) => {
});
ipcMain.on('download-song', (_, url) => downloadSong(url));
ipcMain.on('video-src-changed', async (_, data) => {
playingUrl =
JSON.parse(data)?.microformat?.microformatDataRenderer?.urlCanonical;
playingUrl
= JSON.parse(data)?.microformat?.microformatDataRenderer?.urlCanonical;
});
ipcMain.on('download-playlist-request', async (_event, url) =>
downloadPlaylist(url),
@ -86,13 +90,14 @@ async function downloadSong(
url,
playlistFolder = undefined,
trackId = undefined,
increasePlaylistProgress = () => {},
increasePlaylistProgress = () => {
},
) {
let resolvedName = undefined;
let resolvedName;
try {
await downloadSongUnsafe(
url,
name=>resolvedName=name,
(name) => resolvedName = name,
playlistFolder,
trackId,
increasePlaylistProgress,
@ -107,7 +112,8 @@ async function downloadSongUnsafe(
setName,
playlistFolder = undefined,
trackId = undefined,
increasePlaylistProgress = () => {},
increasePlaylistProgress = () => {
},
) {
const sendFeedback = (message, progress) => {
if (!playlistFolder) {
@ -128,42 +134,45 @@ async function downloadSongUnsafe(
}
const metadata = getMetadata(info);
if (metadata.album === 'N/A') metadata.album = '';
if (metadata.album === 'N/A') {
metadata.album = '';
}
metadata.trackId = trackId;
const dir =
playlistFolder || config.get('downloadFolder') || app.getPath('downloads');
const dir
= playlistFolder || config.get('downloadFolder') || app.getPath('downloads');
const name = `${metadata.artist ? `${metadata.artist} - ` : ''}${
metadata.title
}`;
setName(name);
let playabilityStatus = info.playability_status;
let bypassedResult = null;
if (playabilityStatus.status === "LOGIN_REQUIRED") {
// try to bypass the age restriction
bypassedResult = await getAndroidTvInfo(id);
playabilityStatus = bypassedResult.playability_status;
let playabilityStatus = info.playability_status;
let bypassedResult = null;
if (playabilityStatus.status === 'LOGIN_REQUIRED') {
// Try to bypass the age restriction
bypassedResult = await getAndroidTvInfo(id);
playabilityStatus = bypassedResult.playability_status;
if (playabilityStatus.status === "LOGIN_REQUIRED") {
throw new Error(
`[${playabilityStatus.status}] ${playabilityStatus.reason}`,
);
}
if (playabilityStatus.status === 'LOGIN_REQUIRED') {
throw new Error(
`[${playabilityStatus.status}] ${playabilityStatus.reason}`,
);
}
info = bypassedResult;
}
info = bypassedResult;
}
if (playabilityStatus.status === "UNPLAYABLE") {
/**
* @typedef {import('youtubei.js/dist/src/parser/classes/PlayerErrorMessage').default} PlayerErrorMessage
* @type {PlayerErrorMessage}
*/
const errorScreen = playabilityStatus.error_screen;
throw new Error(
`[${playabilityStatus.status}] ${errorScreen.reason.text}: ${errorScreen.subreason.text}`,
);
}
if (playabilityStatus.status === 'UNPLAYABLE') {
/**
* @typedef {import('youtubei.js/dist/src/parser/classes/PlayerErrorMessage').default} PlayerErrorMessage
* @type {PlayerErrorMessage}
*/
const errorScreen = playabilityStatus.error_screen;
throw new Error(
`[${playabilityStatus.status}] ${errorScreen.reason.text}: ${errorScreen.subreason.text}`,
);
}
const extension = presets[config.get('preset')]?.extension || 'mp3';
@ -179,9 +188,9 @@ async function downloadSongUnsafe(
}
const download_options = {
type: 'audio', // audio, video or video+audio
quality: 'best', // best, bestefficiency, 144p, 240p, 480p, 720p and so on.
format: 'any', // media container format
type: 'audio', // Audio, video or video+audio
quality: 'best', // Best, bestefficiency, 144p, 240p, 480p, 720p and so on.
format: 'any', // Media container format
};
const format = info.chooseFormat(download_options);
@ -197,16 +206,7 @@ async function downloadSongUnsafe(
mkdirSync(dir);
}
if (!presets[config.get('preset')]) {
const fileBuffer = await iterableStreamToMP3(
iterableStream,
metadata,
format.content_length,
sendFeedback,
increasePlaylistProgress,
);
writeFileSync(filePath, await writeID3(fileBuffer, metadata, sendFeedback));
} else {
if (presets[config.get('preset')]) {
const file = createWriteStream(filePath);
let downloaded = 0;
const total = format.content_length;
@ -219,12 +219,22 @@ async function downloadSongUnsafe(
increasePlaylistProgress(ratio);
file.write(chunk);
}
await ffmpegWriteTags(
filePath,
metadata,
presets[config.get('preset')]?.ffmpegArgs,
);
sendFeedback(null, -1);
} else {
const fileBuffer = await iterableStreamToMP3(
iterableStream,
metadata,
format.content_length,
sendFeedback,
increasePlaylistProgress,
);
writeFileSync(filePath, await writeID3(fileBuffer, metadata, sendFeedback));
}
sendFeedback(null, -1);
@ -236,7 +246,8 @@ async function iterableStreamToMP3(
metadata,
content_length,
sendFeedback,
increasePlaylistProgress = () => {},
increasePlaylistProgress = () => {
},
) {
const chunks = [];
let downloaded = 0;
@ -251,7 +262,8 @@ async function iterableStreamToMP3(
// This is a very rough estimate, trying to make the progress bar look nice
increasePlaylistProgress(ratio * 0.15);
}
sendFeedback('Loading…', 2); // indefinite progress bar after download
sendFeedback('Loading…', 2); // Indefinite progress bar after download
const buffer = Buffer.concat(chunks);
const safeVideoName = randomBytes(32).toString('hex');
@ -282,8 +294,8 @@ async function iterableStreamToMP3(
sendFeedback('Saving…');
return ffmpeg.FS('readFile', `${safeVideoName}.mp3`);
} catch (e) {
sendError(e, safeVideoName);
} catch (error) {
sendError(error, safeVideoName);
} finally {
releaseFFmpegMutex();
}
@ -307,6 +319,7 @@ async function writeID3(buffer, metadata, sendFeedback) {
if (metadata.album) {
writer.setFrame('TALB', metadata.album);
}
if (coverBuffer) {
writer.setFrame('APIC', {
type: 3,
@ -314,22 +327,25 @@ async function writeID3(buffer, metadata, sendFeedback) {
description: '',
});
}
if (isEnabled('lyrics-genius')) {
const lyrics = await fetchFromGenius(metadata);
if (lyrics) {
writer.setFrame('USLT', {
description: '',
lyrics: lyrics,
lyrics,
});
}
}
if (metadata.trackId) {
writer.setFrame('TRCK', metadata.trackId);
}
writer.addTag();
return Buffer.from(writer.arrayBuffer);
} catch (e) {
sendError(e, `${metadata.artist} - ${metadata.title}`);
} catch (error) {
sendError(error, `${metadata.artist} - ${metadata.title}`);
}
}
@ -339,10 +355,11 @@ async function downloadPlaylist(givenUrl) {
} catch {
givenUrl = undefined;
}
const playlistId =
getPlaylistID(givenUrl) ||
getPlaylistID(new URL(win.webContents.getURL())) ||
getPlaylistID(new URL(playingUrl));
const playlistId
= getPlaylistID(givenUrl)
|| getPlaylistID(new URL(win.webContents.getURL()))
|| getPlaylistID(new URL(playingUrl));
if (!playlistId) {
sendError(new Error('No playlist ID found'));
@ -356,24 +373,30 @@ async function downloadPlaylist(givenUrl) {
let playlist;
try {
playlist = await ytpl(playlistId, {
limit: config.get('playlistMaxItems') || Infinity,
limit: config.get('playlistMaxItems') || Number.POSITIVE_INFINITY,
});
} catch (e) {
} catch (error) {
sendError(
`Error getting playlist info: make sure it isn\'t a private or "Mixed for you" playlist\n\n${e}`,
`Error getting playlist info: make sure it isn\'t a private or "Mixed for you" playlist\n\n${error}`,
);
return;
}
if (playlist.items.length === 0) sendError(new Error('Playlist is empty'));
if (playlist.items.length === 0) {
sendError(new Error('Playlist is empty'));
}
if (playlist.items.length === 1) {
sendFeedback('Playlist has only one item, downloading it directly');
await downloadSong(playlist.items[0].url);
return;
}
const isAlbum = playlist.title.startsWith('Album - ');
if (isAlbum) {
playlist.title = playlist.title.slice(8);
}
const safePlaylistTitle = filenamify(playlist.title, { replacement: ' ' });
const folder = getFolder(config.get('downloadFolder'));
@ -401,7 +424,7 @@ async function downloadPlaylist(givenUrl) {
);
}
win.setProgressBar(2); // starts with indefinite bar
win.setProgressBar(2); // Starts with indefinite bar
setBadge(playlist.items.length);
@ -424,9 +447,9 @@ async function downloadPlaylist(givenUrl) {
playlistFolder,
trackId,
increaseProgress,
).catch((e) =>
).catch((error) =>
sendError(
`Error downloading "${song.author.name} - ${song.title}":\n ${e}`,
`Error downloading "${song.author.name} - ${song.title}":\n ${error}`,
),
);
@ -434,12 +457,12 @@ async function downloadPlaylist(givenUrl) {
setBadge(playlist.items.length - counter);
counter++;
}
} catch (e) {
sendError(e);
} catch (error) {
sendError(error);
} finally {
win.setProgressBar(-1); // close progress bar
setBadge(0); // close badge counter
sendFeedback(); // clear feedback
win.setProgressBar(-1); // Close progress bar
setBadge(0); // Close badge counter
sendFeedback(); // Clear feedback
}
}
@ -458,8 +481,8 @@ async function ffmpegWriteTags(filePath, metadata, ffmpegArgs = []) {
...ffmpegArgs,
filePath,
);
} catch (e) {
sendError(e);
} catch (error) {
sendError(error);
} finally {
releaseFFmpegMutex();
}
@ -482,11 +505,12 @@ function getFFmpegMetadataArgs(metadata) {
const INVALID_PLAYLIST_MODIFIER = 'RDAMPL';
const getPlaylistID = (aURL) => {
const result =
aURL?.searchParams.get('list') || aURL?.searchParams.get('playlist');
const result
= aURL?.searchParams.get('list') || aURL?.searchParams.get('playlist');
if (result?.startsWith(INVALID_PLAYLIST_MODIFIER)) {
return result.slice(INVALID_PLAYLIST_MODIFIER.length);
}
return result;
};
@ -494,6 +518,7 @@ const getVideoId = (url) => {
if (typeof url === 'string') {
url = new URL(url);
}
return url.searchParams.get('v');
};
@ -513,7 +538,7 @@ const getAndroidTvInfo = async (id) => {
retrieve_player: true,
});
const info = await innertube.getBasicInfo(id, 'TV_EMBEDDED');
// getInfo 404s with the bypass, so we use getBasicInfo instead
// GetInfo 404s with the bypass, so we use getBasicInfo instead
// that's fine as we only need the streaming data
return info;
}
};

View File

@ -1,3 +1,4 @@
const { PluginConfig } = require('../../config/dynamic');
const config = new PluginConfig('downloader');
module.exports = { ...config };

View File

@ -1,69 +1,81 @@
const { ipcRenderer } = require("electron");
const { ipcRenderer } = require('electron');
const { defaultConfig } = require("../../config");
const { getSongMenu } = require("../../providers/dom-elements");
const { ElementFromFile, templatePath } = require("../utils");
const { defaultConfig } = require('../../config');
const { getSongMenu } = require('../../providers/dom-elements');
const { ElementFromFile, templatePath } = require('../utils');
let menu = null;
let progress = null;
const downloadButton = ElementFromFile(
templatePath(__dirname, "download.html")
templatePath(__dirname, 'download.html'),
);
let doneFirstLoad = false;
const menuObserver = new MutationObserver(() => {
if (!menu) {
menu = getSongMenu();
if (!menu) return;
}
if (menu.contains(downloadButton)) return;
const menuUrl = document.querySelector('tp-yt-paper-listbox [tabindex="0"] #navigation-endpoint')?.href;
if (!menuUrl?.includes('watch?') && doneFirstLoad) return;
if (!menu) {
menu = getSongMenu();
if (!menu) {
return;
}
}
menu.prepend(downloadButton);
progress = document.querySelector("#ytmcustom-download");
if (menu.contains(downloadButton)) {
return;
}
if (doneFirstLoad) return;
setTimeout(() => doneFirstLoad ||= true, 500);
const menuUrl = document.querySelector('tp-yt-paper-listbox [tabindex="0"] #navigation-endpoint')?.href;
if (!menuUrl?.includes('watch?') && doneFirstLoad) {
return;
}
menu.prepend(downloadButton);
progress = document.querySelector('#ytmcustom-download');
if (doneFirstLoad) {
return;
}
setTimeout(() => doneFirstLoad ||= true, 500);
});
// TODO: re-enable once contextIsolation is set to true
// contextBridge.exposeInMainWorld("downloader", {
// download: () => {
global.download = () => {
let videoUrl = getSongMenu()
// selector of first button which is always "Start Radio"
?.querySelector('ytmusic-menu-navigation-item-renderer[tabindex="0"] #navigation-endpoint')
?.getAttribute("href");
if (videoUrl) {
if (videoUrl.startsWith('watch?')) {
videoUrl = defaultConfig.url + "/" + videoUrl;
}
if (videoUrl.includes('?playlist=')) {
ipcRenderer.send('download-playlist-request', videoUrl);
return;
}
} else {
videoUrl = global.songInfo.url || window.location.href;
}
let videoUrl = getSongMenu()
// Selector of first button which is always "Start Radio"
?.querySelector('ytmusic-menu-navigation-item-renderer[tabindex="0"] #navigation-endpoint')
?.getAttribute('href');
if (videoUrl) {
if (videoUrl.startsWith('watch?')) {
videoUrl = defaultConfig.url + '/' + videoUrl;
}
ipcRenderer.send('download-song', videoUrl);
if (videoUrl.includes('?playlist=')) {
ipcRenderer.send('download-playlist-request', videoUrl);
return;
}
} else {
videoUrl = global.songInfo.url || window.location.href;
}
ipcRenderer.send('download-song', videoUrl);
};
module.exports = () => {
document.addEventListener('apiLoaded', () => {
menuObserver.observe(document.querySelector('ytmusic-popup-container'), {
childList: true,
subtree: true,
});
}, { once: true, passive: true })
document.addEventListener('apiLoaded', () => {
menuObserver.observe(document.querySelector('ytmusic-popup-container'), {
childList: true,
subtree: true,
});
}, { once: true, passive: true });
ipcRenderer.on('downloader-feedback', (_, feedback) => {
if (!progress) {
console.warn("Cannot update progress");
} else {
progress.innerHTML = feedback || "Download";
}
});
ipcRenderer.on('downloader-feedback', (_, feedback) => {
if (progress) {
progress.innerHTML = feedback || 'Download';
} else {
console.warn('Cannot update progress');
}
});
};

View File

@ -1,45 +1,43 @@
const { dialog } = require("electron");
const { dialog } = require('electron');
const { downloadPlaylist } = require("./back");
const { defaultMenuDownloadLabel, getFolder, presets } = require("./utils");
const config = require("./config");
const { downloadPlaylist } = require('./back');
const { defaultMenuDownloadLabel, getFolder, presets } = require('./utils');
const config = require('./config');
module.exports = () => {
return [
{
label: defaultMenuDownloadLabel,
click: () => downloadPlaylist(),
},
{
label: "Choose download folder",
click: () => {
const result = dialog.showOpenDialogSync({
properties: ["openDirectory", "createDirectory"],
defaultPath: getFolder(config.get("downloadFolder")),
});
if (result) {
config.set("downloadFolder", result[0]);
} // else = user pressed cancel
},
},
{
label: "Presets",
submenu: Object.keys(presets).map((preset) => ({
label: preset,
type: "radio",
checked: config.get("preset") === preset,
click: () => {
config.set("preset", preset);
},
})),
},
{
label: "Skip existing files",
type: "checkbox",
checked: config.get("skipExisting"),
click: (item) => {
config.set("skipExisting", item.checked);
},
},
];
};
module.exports = () => [
{
label: defaultMenuDownloadLabel,
click: () => downloadPlaylist(),
},
{
label: 'Choose download folder',
click() {
const result = dialog.showOpenDialogSync({
properties: ['openDirectory', 'createDirectory'],
defaultPath: getFolder(config.get('downloadFolder')),
});
if (result) {
config.set('downloadFolder', result[0]);
} // Else = user pressed cancel
},
},
{
label: 'Presets',
submenu: Object.keys(presets).map((preset) => ({
label: preset,
type: 'radio',
checked: config.get('preset') === preset,
click() {
config.set('preset', preset);
},
})),
},
{
label: 'Skip existing files',
type: 'checkbox',
checked: config.get('skipExisting'),
click(item) {
config.set('skipExisting', item.checked);
},
},
];

View File

@ -1,21 +1,21 @@
.menu-item {
display: var(--ytmusic-menu-item_-_display);
height: var(--ytmusic-menu-item_-_height);
align-items: var(--ytmusic-menu-item_-_align-items);
padding: var(--ytmusic-menu-item_-_padding);
cursor: pointer;
display: var(--ytmusic-menu-item_-_display);
height: var(--ytmusic-menu-item_-_height);
align-items: var(--ytmusic-menu-item_-_align-items);
padding: var(--ytmusic-menu-item_-_padding);
cursor: pointer;
}
.menu-item > .yt-simple-endpoint:hover {
background-color: var(--ytmusic-menu-item-hover-background-color);
background-color: var(--ytmusic-menu-item-hover-background-color);
}
.menu-icon {
flex: var(--ytmusic-menu-item-icon_-_flex);
margin: var(--ytmusic-menu-item-icon_-_margin);
fill: var(--ytmusic-menu-item-icon_-_fill);
stroke: var(--iron-icon-stroke-color, none);
width: var(--iron-icon-width, 24px);
height: var(--iron-icon-height, 24px);
animation: var(--iron-icon_-_animation);
flex: var(--ytmusic-menu-item-icon_-_flex);
margin: var(--ytmusic-menu-item-icon_-_margin);
fill: var(--ytmusic-menu-item-icon_-_fill);
stroke: var(--iron-icon-stroke-color, none);
width: var(--iron-icon-width, 24px);
height: var(--iron-icon-height, 24px);
animation: var(--iron-icon_-_animation);
}

View File

@ -1,45 +1,45 @@
<div
class="style-scope menu-item ytmusic-menu-popup-renderer"
role="option"
tabindex="-1"
aria-disabled="false"
aria-selected="false"
onclick="download()"
aria-disabled="false"
aria-selected="false"
class="style-scope menu-item ytmusic-menu-popup-renderer"
onclick="download()"
role="option"
tabindex="-1"
>
<div
id="navigation-endpoint"
class="yt-simple-endpoint style-scope ytmusic-menu-navigation-item-renderer"
tabindex="-1"
>
<div
class="icon menu-icon style-scope ytmusic-menu-navigation-item-renderer"
>
<svg
viewBox="0 0 24 24"
preserveAspectRatio="xMidYMid meet"
focusable="false"
class="style-scope yt-icon"
style="pointer-events: none; display: block; width: 100%; height: 100%"
>
<g class="style-scope yt-icon">
<path
d="M25.462,19.105v6.848H4.515v-6.848H0.489v8.861c0,1.111,0.9,2.012,2.016,2.012h24.967c1.115,0,2.016-0.9,2.016-2.012v-8.861H25.462z"
class="style-scope yt-icon"
fill="#aaaaaa"
/>
<path
d="M14.62,18.426l-5.764-6.965c0,0-0.877-0.828,0.074-0.828s3.248,0,3.248,0s0-0.557,0-1.416c0-2.449,0-6.906,0-8.723c0,0-0.129-0.494,0.615-0.494c0.75,0,4.035,0,4.572,0c0.536,0,0.524,0.416,0.524,0.416c0,1.762,0,6.373,0,8.742c0,0.768,0,1.266,0,1.266s1.842,0,2.998,0c1.154,0,0.285,0.867,0.285,0.867s-4.904,6.51-5.588,7.193C15.092,18.979,14.62,18.426,14.62,18.426z"
class="style-scope yt-icon"
fill="#aaaaaa"
/>
</g>
</svg>
</div>
<div
class="text style-scope ytmusic-menu-navigation-item-renderer"
id="ytmcustom-download"
>
Download
</div>
</div>
<div
class="yt-simple-endpoint style-scope ytmusic-menu-navigation-item-renderer"
id="navigation-endpoint"
tabindex="-1"
>
<div
class="icon menu-icon style-scope ytmusic-menu-navigation-item-renderer"
>
<svg
class="style-scope yt-icon"
focusable="false"
preserveAspectRatio="xMidYMid meet"
style="pointer-events: none; display: block; width: 100%; height: 100%"
viewBox="0 0 24 24"
>
<g class="style-scope yt-icon">
<path
class="style-scope yt-icon"
d="M25.462,19.105v6.848H4.515v-6.848H0.489v8.861c0,1.111,0.9,2.012,2.016,2.012h24.967c1.115,0,2.016-0.9,2.016-2.012v-8.861H25.462z"
fill="#aaaaaa"
/>
<path
class="style-scope yt-icon"
d="M14.62,18.426l-5.764-6.965c0,0-0.877-0.828,0.074-0.828s3.248,0,3.248,0s0-0.557,0-1.416c0-2.449,0-6.906,0-8.723c0,0-0.129-0.494,0.615-0.494c0.75,0,4.035,0,4.572,0c0.536,0,0.524,0.416,0.524,0.416c0,1.762,0,6.373,0,8.742c0,0.768,0,1.266,0,1.266s1.842,0,2.998,0c1.154,0,0.285,0.867,0.285,0.867s-4.904,6.51-5.588,7.193C15.092,18.979,14.62,18.426,14.62,18.426z"
fill="#aaaaaa"
/>
</g>
</svg>
</div>
<div
class="text style-scope ytmusic-menu-navigation-item-renderer"
id="ytmcustom-download"
>
Download
</div>
</div>
</div>

View File

@ -1,38 +1,39 @@
const { app } = require("electron");
const { app } = require('electron');
const is = require('electron-is');
module.exports.getFolder = customFolder => customFolder || app.getPath("downloads");
module.exports.defaultMenuDownloadLabel = "Download playlist";
module.exports.getFolder = (customFolder) => customFolder || app.getPath('downloads');
module.exports.defaultMenuDownloadLabel = 'Download playlist';
module.exports.sendFeedback = (win, message) => {
win.webContents.send("downloader-feedback", message);
win.webContents.send('downloader-feedback', message);
};
module.exports.cropMaxWidth = (image) => {
const imageSize = image.getSize();
// standart youtube artwork width with margins from both sides is 280 + 720 + 280
if (imageSize.width === 1280 && imageSize.height === 720) {
return image.crop({
x: 280,
y: 0,
width: 720,
height: 720
});
}
return image;
}
const imageSize = image.getSize();
// Standart youtube artwork width with margins from both sides is 280 + 720 + 280
if (imageSize.width === 1280 && imageSize.height === 720) {
return image.crop({
x: 280,
y: 0,
width: 720,
height: 720,
});
}
return image;
};
// Presets for FFmpeg
module.exports.presets = {
"None (defaults to mp3)": undefined,
opus: {
extension: "opus",
ffmpegArgs: ["-acodec", "libopus"],
},
'None (defaults to mp3)': undefined,
'opus': {
extension: 'opus',
ffmpegArgs: ['-acodec', 'libopus'],
},
};
module.exports.setBadge = n => {
if (is.linux() || is.macOS()) {
app.setBadgeCount(n);
}
}
module.exports.setBadge = (n) => {
if (is.linux() || is.macOS()) {
app.setBadgeCount(n);
}
};

View File

@ -2,46 +2,46 @@
// https://greasyfork.org/en/scripts/397686-youtube-music-fix-volume-ratio/
const exponentialVolume = () => {
// manipulation exponent, higher value = lower volume
// 3 is the value used by pulseaudio, which Barteks2x figured out this gist here: https://gist.github.com/Barteks2x/a4e189a36a10c159bb1644ffca21c02a
// 0.05 (or 5%) is the lowest you can select in the UI which with an exponent of 3 becomes 0.000125 or 0.0125%
const EXPONENT = 3;
// Manipulation exponent, higher value = lower volume
// 3 is the value used by pulseaudio, which Barteks2x figured out this gist here: https://gist.github.com/Barteks2x/a4e189a36a10c159bb1644ffca21c02a
// 0.05 (or 5%) is the lowest you can select in the UI which with an exponent of 3 becomes 0.000125 or 0.0125%
const EXPONENT = 3;
const storedOriginalVolumes = new WeakMap();
const { get, set } = Object.getOwnPropertyDescriptor(
HTMLMediaElement.prototype,
"volume"
);
Object.defineProperty(HTMLMediaElement.prototype, "volume", {
get() {
const lowVolume = get.call(this);
const calculatedOriginalVolume = lowVolume ** (1 / EXPONENT);
const storedOriginalVolumes = new WeakMap();
const { get, set } = Object.getOwnPropertyDescriptor(
HTMLMediaElement.prototype,
'volume',
);
Object.defineProperty(HTMLMediaElement.prototype, 'volume', {
get() {
const lowVolume = get.call(this);
const calculatedOriginalVolume = lowVolume ** (1 / EXPONENT);
// The calculated value has some accuracy issues which can lead to problems for implementations that expect exact values.
// To avoid this, I'll store the unmodified volume to return it when read here.
// This mostly solves the issue, but the initial read has no stored value and the volume can also change though external influences.
// To avoid ill effects, I check if the stored volume is somewhere in the same range as the calculated volume.
const storedOriginalVolume = storedOriginalVolumes.get(this);
const storedDeviation = Math.abs(
storedOriginalVolume - calculatedOriginalVolume
);
// The calculated value has some accuracy issues which can lead to problems for implementations that expect exact values.
// To avoid this, I'll store the unmodified volume to return it when read here.
// This mostly solves the issue, but the initial read has no stored value and the volume can also change though external influences.
// To avoid ill effects, I check if the stored volume is somewhere in the same range as the calculated volume.
const storedOriginalVolume = storedOriginalVolumes.get(this);
const storedDeviation = Math.abs(
storedOriginalVolume - calculatedOriginalVolume,
);
const originalVolume =
storedDeviation < 0.01
? storedOriginalVolume
: calculatedOriginalVolume;
return originalVolume;
},
set(originalVolume) {
const lowVolume = originalVolume ** EXPONENT;
storedOriginalVolumes.set(this, originalVolume);
set.call(this, lowVolume);
},
});
const originalVolume
= storedDeviation < 0.01
? storedOriginalVolume
: calculatedOriginalVolume;
return originalVolume;
},
set(originalVolume) {
const lowVolume = originalVolume ** EXPONENT;
storedOriginalVolumes.set(this, originalVolume);
set.call(this, lowVolume);
},
});
};
module.exports = () =>
document.addEventListener("apiLoaded", exponentialVolume, {
once: true,
passive: true,
});
document.addEventListener('apiLoaded', exponentialVolume, {
once: true,
passive: true,
});

View File

@ -1,23 +1,23 @@
const path = require("path");
const electronLocalshortcut = require("electron-localshortcut");
const { injectCSS } = require("../utils");
const path = require('node:path');
const electronLocalshortcut = require('electron-localshortcut');
const { setupTitlebar, attachTitlebarToWindow } = require('custom-electron-titlebar/main');
const { injectCSS } = require('../utils');
setupTitlebar();
//tracks menu visibility
// Tracks menu visibility
module.exports = (win) => {
// css for custom scrollbar + disable drag area(was causing bugs)
injectCSS(win.webContents, path.join(__dirname, "style.css"));
// Css for custom scrollbar + disable drag area(was causing bugs)
injectCSS(win.webContents, path.join(__dirname, 'style.css'));
win.once("ready-to-show", () => {
attachTitlebarToWindow(win);
win.once('ready-to-show', () => {
attachTitlebarToWindow(win);
electronLocalshortcut.register(win, "`", () => {
win.webContents.send("toggleMenu");
});
});
electronLocalshortcut.register(win, '`', () => {
win.webContents.send('toggleMenu');
});
});
};

View File

@ -1,74 +1,78 @@
const { ipcRenderer } = require("electron");
const config = require("../../config");
const { Titlebar, Color } = require("custom-electron-titlebar");
const { isEnabled } = require("../../config/plugins");
function $(selector) { return document.querySelector(selector); }
const { ipcRenderer } = require('electron');
const { Titlebar, Color } = require('custom-electron-titlebar');
const config = require('../../config');
const { isEnabled } = require('../../config/plugins');
function $(selector) {
return document.querySelector(selector);
}
module.exports = (options) => {
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: config.get("options.hideMenu") ? null : undefined
});
bar.updateTitle(" ");
document.title = "Youtube Music";
const visible = () => Boolean($('.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: config.get('options.hideMenu') ? null : undefined,
});
bar.updateTitle(' ');
document.title = 'Youtube Music';
const toggleMenu = () => {
if (visible()) {
bar.updateMenu(null);
} else {
bar.refreshMenu();
}
};
const toggleMenu = () => {
if (visible()) {
bar.updateMenu(null);
} else {
bar.refreshMenu();
}
};
$('.cet-window-icon').addEventListener('click', toggleMenu);
ipcRenderer.on("toggleMenu", toggleMenu);
$('.cet-window-icon').addEventListener('click', toggleMenu);
ipcRenderer.on('toggleMenu', toggleMenu);
ipcRenderer.on("refreshMenu", () => {
if (visible()) {
bar.refreshMenu();
}
});
ipcRenderer.on('refreshMenu', () => {
if (visible()) {
bar.refreshMenu();
}
});
if (isEnabled("picture-in-picture")) {
ipcRenderer.on("pip-toggle", (_, pipEnabled) => {
bar.refreshMenu();
});
}
if (isEnabled('picture-in-picture')) {
ipcRenderer.on('pip-toggle', (_, pipEnabled) => {
bar.refreshMenu();
});
}
// 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 })
// 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 });
};
function setupSearchOpenObserver() {
const searchOpenObserver = new MutationObserver(mutations => {
$('#nav-bar-background').style.webkitAppRegion =
mutations[0].target.opened ? 'no-drag' : 'drag';
});
searchOpenObserver.observe($('ytmusic-search-box'), { attributeFilter: ["opened"] })
const searchOpenObserver = new MutationObserver((mutations) => {
$('#nav-bar-background').style.webkitAppRegion
= mutations[0].target.opened ? 'no-drag' : 'drag';
});
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"] })
const menuOpenObserver = new MutationObserver((mutations) => {
$('#nav-bar-background').style.webkitAppRegion
= [...$('.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_ ?
'0px' :
'12px';
$('#nav-bar-background').style.right
= $('ytmusic-app-layout').playerPageOpen_
? '0px'
: '12px';
}

View File

@ -1,105 +1,107 @@
/* increase font size for menu and menuItems */
.titlebar,
.menubar-menu-container .action-label {
font-size: 14px !important;
font-size: 14px !important;
}
/* fixes nav-bar-background opacity bug, reposition it, and allows clicking scrollbar through it */
#nav-bar-background {
opacity: 1 !important;
pointer-events: none !important;
top: 30px !important;
height: 75px !important;
opacity: 1 !important;
pointer-events: none !important;
top: 30px !important;
height: 75px !important;
}
/* fix top gap between nav-bar and browse-page */
#browse-page {
padding-top: 0 !important;
padding-top: 0 !important;
}
/* fix navbar hiding library items */
ytmusic-section-list-renderer[page-type="MUSIC_PAGE_TYPE_LIBRARY_CONTENT_LANDING_PAGE"],
ytmusic-section-list-renderer[page-type="MUSIC_PAGE_TYPE_PRIVATELY_OWNED_CONTENT_LANDING_PAGE"] {
top: 50px;
position: relative;
top: 50px;
position: relative;
}
/* remove window dragging for nav bar (conflict with titlebar drag) */
ytmusic-nav-bar,
.tab-titleiron-icon,
ytmusic-pivot-bar-item-renderer {
-webkit-app-region: unset !important;
-webkit-app-region: unset !important;
}
/* move up item selection renderers */
ytmusic-item-section-renderer.stuck #header.ytmusic-item-section-renderer,
ytmusic-tabs.stuck {
top: calc(var(--ytmusic-nav-bar-height) - 15px) !important;
top: calc(var(--ytmusic-nav-bar-height) - 15px) !important;
}
/* fix weird positioning in search screen*/
ytmusic-header-renderer.ytmusic-search-page {
position: unset !important;
position: unset !important;
}
/* Move navBar downwards */
ytmusic-nav-bar[slot="nav-bar"] {
top: 17px !important;
top: 17px !important;
}
/* fix page progress bar position*/
yt-page-navigation-progress,
#progress.yt-page-navigation-progress {
top: 30px !important;
top: 30px !important;
}
/* custom scrollbar */
::-webkit-scrollbar {
width: 12px;
background-color: #030303;
border-radius: 100px;
-moz-border-radius: 100px;
-webkit-border-radius: 100px;
width: 12px;
background-color: #030303;
border-radius: 100px;
-moz-border-radius: 100px;
-webkit-border-radius: 100px;
}
/* hover effect for both scrollbar area, and scrollbar 'thumb' */
::-webkit-scrollbar:hover {
background-color: rgba(15, 15, 15, 0.699);
background-color: rgba(15, 15, 15, 0.699);
}
/* the scrollbar 'thumb' ...that marque oval shape in a scrollbar */
::-webkit-scrollbar-thumb:vertical {
border: 2px solid rgba(0, 0, 0, 0);
border: 2px solid rgba(0, 0, 0, 0);
background: #3a3a3a;
background-clip: padding-box;
border-radius: 100px;
-moz-border-radius: 100px;
-webkit-border-radius: 100px;
background: #3a3a3a;
background-clip: padding-box;
border-radius: 100px;
-moz-border-radius: 100px;
-webkit-border-radius: 100px;
}
::-webkit-scrollbar-thumb:vertical:active {
background: #4d4c4c; /* some darker color when you click it */
border-radius: 100px;
-moz-border-radius: 100px;
-webkit-border-radius: 100px;
background: #4d4c4c; /* some darker color when you click it */
border-radius: 100px;
-moz-border-radius: 100px;
-webkit-border-radius: 100px;
}
.cet-menubar-menu-container .cet-action-item {
background-color: inherit
background-color: inherit
}
/** hideMenu toggler **/
.cet-window-icon {
-webkit-app-region: no-drag;
-webkit-app-region: no-drag;
}
.cet-window-icon img {
-webkit-user-drag: none;
filter: invert(50%);
-webkit-user-drag: none;
filter: invert(50%);
}
/** make navbar draggable **/
#nav-bar-background {
-webkit-app-region: drag;
-webkit-app-region: drag;
}
ytmusic-nav-bar input,
@ -107,5 +109,5 @@ ytmusic-nav-bar span,
ytmusic-nav-bar [role="button"],
ytmusic-nav-bar yt-icon,
tp-yt-iron-dropdown {
-webkit-app-region: no-drag;
-webkit-app-region: no-drag;
}

View File

@ -1,161 +1,171 @@
const { shell } = require('electron');
const fetch = require('node-fetch');
const md5 = require('md5');
const { shell } = require('electron');
const { setOptions } = require('../../config/plugins');
const registerCallback = require('../../providers/song-info');
const defaultConfig = require('../../config/defaults');
const createFormData = params => {
// creates the body for in the post request
const formData = new URLSearchParams();
for (const key in params) {
formData.append(key, params[key]);
}
return formData;
}
const createQueryString = (params, api_sig) => {
// creates a querystring
const queryData = [];
params.api_sig = api_sig;
for (const key in params) {
queryData.push(`${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`);
}
return '?'+queryData.join('&');
}
const createFormData = (parameters) => {
// Creates the body for in the post request
const formData = new URLSearchParams();
for (const key in parameters) {
formData.append(key, parameters[key]);
}
const createApiSig = (params, secret) => {
// this function creates the api signature, see: https://www.last.fm/api/authspec
const keys = [];
for (const key in params) {
keys.push(key);
}
keys.sort();
let sig = '';
for (const key of keys) {
if (String(key) === 'format')
continue
sig += `${key}${params[key]}`;
}
sig += secret;
sig = md5(sig);
return sig;
}
return formData;
};
const createQueryString = (parameters, api_sig) => {
// Creates a querystring
const queryData = [];
parameters.api_sig = api_sig;
for (const key in parameters) {
queryData.push(`${encodeURIComponent(key)}=${encodeURIComponent(parameters[key])}`);
}
return '?' + queryData.join('&');
};
const createApiSig = (parameters, secret) => {
// This function creates the api signature, see: https://www.last.fm/api/authspec
const keys = [];
for (const key in parameters) {
keys.push(key);
}
keys.sort();
let sig = '';
for (const key of keys) {
if (String(key) === 'format') {
continue;
}
sig += `${key}${parameters[key]}`;
}
sig += secret;
sig = md5(sig);
return sig;
};
const createToken = async ({ api_key, api_root, secret }) => {
// creates and stores the auth token
const data = {
method: 'auth.gettoken',
api_key: api_key,
format: 'json'
};
const api_sig = createApiSig(data, secret);
let response = await fetch(`${api_root}${createQueryString(data, api_sig)}`);
response = await response.json();
return response?.token;
}
// Creates and stores the auth token
const data = {
method: 'auth.gettoken',
api_key,
format: 'json',
};
const api_sig = createApiSig(data, secret);
let response = await fetch(`${api_root}${createQueryString(data, api_sig)}`);
response = await response.json();
return response?.token;
};
const authenticate = async config => {
// asks the user for authentication
config.token = await createToken(config);
setOptions('last-fm', config);
shell.openExternal(`https://www.last.fm/api/auth/?api_key=${config.api_key}&token=${config.token}`);
return config;
}
const authenticate = async (config) => {
// Asks the user for authentication
config.token = await createToken(config);
setOptions('last-fm', config);
shell.openExternal(`https://www.last.fm/api/auth/?api_key=${config.api_key}&token=${config.token}`);
return config;
};
const getAndSetSessionKey = async config => {
// get and store the session key
const data = {
api_key: config.api_key,
format: 'json',
method: 'auth.getsession',
token: config.token,
};
const api_sig = createApiSig(data, config.secret);
let res = await fetch(`${config.api_root}${createQueryString(data, api_sig)}`);
res = await res.json();
if (res.error)
await authenticate(config);
config.session_key = res?.session?.key;
setOptions('last-fm', config);
return config;
}
const getAndSetSessionKey = async (config) => {
// Get and store the session key
const data = {
api_key: config.api_key,
format: 'json',
method: 'auth.getsession',
token: config.token,
};
const api_sig = createApiSig(data, config.secret);
let res = await fetch(`${config.api_root}${createQueryString(data, api_sig)}`);
res = await res.json();
if (res.error) {
await authenticate(config);
}
config.session_key = res?.session?.key;
setOptions('last-fm', config);
return config;
};
const postSongDataToAPI = async (songInfo, config, data) => {
// this sends a post request to the api, and adds the common data
if (!config.session_key)
await getAndSetSessionKey(config);
// This sends a post request to the api, and adds the common data
if (!config.session_key) {
await getAndSetSessionKey(config);
}
const postData = {
track: songInfo.title,
duration: songInfo.songDuration,
artist: songInfo.artist,
...(songInfo.album ? { album: songInfo.album } : undefined), // will be undefined if current song is a video
api_key: config.api_key,
sk: config.session_key,
format: 'json',
...data,
};
const postData = {
track: songInfo.title,
duration: songInfo.songDuration,
artist: songInfo.artist,
...(songInfo.album ? { album: songInfo.album } : undefined), // Will be undefined if current song is a video
api_key: config.api_key,
sk: config.session_key,
format: 'json',
...data,
};
postData.api_sig = createApiSig(postData, config.secret);
fetch('https://ws.audioscrobbler.com/2.0/', {method: 'POST', body: createFormData(postData)})
.catch(res => {
if (res.response.data.error == 9) {
// session key is invalid, so remove it from the config and reauthenticate
config.session_key = undefined;
setOptions('last-fm', config);
authenticate(config);
}
});
}
postData.api_sig = createApiSig(postData, config.secret);
fetch('https://ws.audioscrobbler.com/2.0/', { method: 'POST', body: createFormData(postData) })
.catch((error) => {
if (error.response.data.error == 9) {
// Session key is invalid, so remove it from the config and reauthenticate
config.session_key = undefined;
setOptions('last-fm', config);
authenticate(config);
}
});
};
const addScrobble = (songInfo, config) => {
// this adds one scrobbled song to last.fm
const data = {
method: 'track.scrobble',
timestamp: ~~((Date.now() - songInfo.elapsedSeconds) / 1000),
};
postSongDataToAPI(songInfo, config, data);
}
// This adds one scrobbled song to last.fm
const data = {
method: 'track.scrobble',
timestamp: Math.trunc((Date.now() - songInfo.elapsedSeconds) / 1000),
};
postSongDataToAPI(songInfo, config, data);
};
const setNowPlaying = (songInfo, config) => {
// this sets the now playing status in last.fm
const data = {
method: 'track.updateNowPlaying',
};
postSongDataToAPI(songInfo, config, data);
}
// This sets the now playing status in last.fm
const data = {
method: 'track.updateNowPlaying',
};
postSongDataToAPI(songInfo, config, data);
};
// this will store the timeout that will trigger addScrobble
let scrobbleTimer = undefined;
// This will store the timeout that will trigger addScrobble
let scrobbleTimer;
const lastfm = async (_win, config) => {
if (!config.api_root) {
// settings are not present, creating them with the default values
config = defaultConfig.plugins['last-fm'];
config.enabled = true;
setOptions('last-fm', config);
}
if (!config.api_root) {
// Settings are not present, creating them with the default values
config = defaultConfig.plugins['last-fm'];
config.enabled = true;
setOptions('last-fm', config);
}
if (!config.session_key) {
// not authenticated
config = await getAndSetSessionKey(config);
}
if (!config.session_key) {
// Not authenticated
config = await getAndSetSessionKey(config);
}
registerCallback( songInfo => {
// set remove the old scrobble timer
clearTimeout(scrobbleTimer);
if (!songInfo.isPaused) {
setNowPlaying(songInfo, config);
// scrobble when the song is half way through, or has passed the 4 minute mark
const scrobbleTime = Math.min(Math.ceil(songInfo.songDuration / 2), 4 * 60);
if (scrobbleTime > songInfo.elapsedSeconds) {
// scrobble still needs to happen
const timeToWait = (scrobbleTime - songInfo.elapsedSeconds) * 1000;
scrobbleTimer = setTimeout(addScrobble, timeToWait, songInfo, config);
}
}
});
}
registerCallback((songInfo) => {
// Set remove the old scrobble timer
clearTimeout(scrobbleTimer);
if (!songInfo.isPaused) {
setNowPlaying(songInfo, config);
// Scrobble when the song is half way through, or has passed the 4 minute mark
const scrobbleTime = Math.min(Math.ceil(songInfo.songDuration / 2), 4 * 60);
if (scrobbleTime > songInfo.elapsedSeconds) {
// Scrobble still needs to happen
const timeToWait = (scrobbleTime - songInfo.elapsedSeconds) * 1000;
scrobbleTimer = setTimeout(addScrobble, timeToWait, songInfo, config);
}
}
});
};
module.exports = lastfm;

View File

@ -1,117 +1,123 @@
const { join } = require("path");
const { join } = require('node:path');
const { ipcMain } = require("electron");
const is = require("electron-is");
const { convert } = require("html-to-text");
const fetch = require("node-fetch");
const { ipcMain } = require('electron');
const is = require('electron-is');
const { convert } = require('html-to-text');
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;
const { cleanupName } = require('../../providers/song-info');
const { injectCSS } = require('../utils');
const eastAsianChars = /\p{Script=Han}|\p{Script=Katakana}|\p{Script=Hiragana}|\p{Script=Hangul}|\p{Script=Han}/u;
let revRomanized = false;
module.exports = async (win, options) => {
if(options.romanizedLyrics) {
revRomanized = true;
}
injectCSS(win.webContents, join(__dirname, "style.css"));
if (options.romanizedLyrics) {
revRomanized = true;
}
ipcMain.on("search-genius-lyrics", async (event, extractedSongInfo) => {
const metadata = JSON.parse(extractedSongInfo);
event.returnValue = await fetchFromGenius(metadata);
});
injectCSS(win.webContents, join(__dirname, 'style.css'));
ipcMain.on('search-genius-lyrics', async (event, extractedSongInfo) => {
const metadata = JSON.parse(extractedSongInfo);
event.returnValue = await fetchFromGenius(metadata);
});
};
const toggleRomanized = () => {
revRomanized = !revRomanized;
revRomanized = !revRomanized;
};
const fetchFromGenius = async (metadata) => {
const songTitle = `${cleanupName(metadata.title)}`;
const songArtist = `${cleanupName(metadata.artist)}`;
let lyrics;
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}`);
}
/* 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;
/* 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
* 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=${encodeURIComponent(queryString)}`
);
if (!response.ok) {
return null;
}
const response = await fetch(
`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 {
url = info.response.sections.filter((section) => section.type === "song")[0]
.hits[0].result.url;
} catch {
return null;
}
let lyrics = await getLyrics(url);
return lyrics;
}
/* 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 {
url = info.response.sections.find((section) => section.type === 'song')
.hits[0].result.url;
} catch {
return null;
}
const lyrics = await getLyrics(url);
return lyrics;
};
/**
*
* @param {*} 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: {
selectors: ['[class^="Lyrics__Container"]', ".lyrics"],
},
selectors: [
{
selector: "a",
format: "linkFormatter",
},
],
formatters: {
// Remove links by keeping only the content
linkFormatter: (elem, walk, builder) => {
walk(elem.children, builder);
},
},
});
return lyrics;
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: {
selectors: ['[class^="Lyrics__Container"]', '.lyrics'],
},
selectors: [
{
selector: 'a',
format: 'linkFormatter',
},
],
formatters: {
// Remove links by keeping only the content
linkFormatter(element, walk, builder) {
walk(element.children, builder);
},
},
});
return lyrics;
};
module.exports.toggleRomanized = toggleRomanized;
module.exports.fetchFromGenius = fetchFromGenius;
module.exports.fetchFromGenius = fetchFromGenius;

View File

@ -1,94 +1,95 @@
const { ipcRenderer } = require("electron");
const is = require("electron-is");
const { ipcRenderer } = require('electron');
const is = require('electron-is');
module.exports = () => {
ipcRenderer.on("update-song-info", (_, extractedSongInfo) => setTimeout(() => {
const tabList = document.querySelectorAll("tp-yt-paper-tab");
const tabs = {
upNext: tabList[0],
lyrics: tabList[1],
discover: tabList[2],
}
ipcRenderer.on('update-song-info', (_, extractedSongInfo) => setTimeout(() => {
const tabList = document.querySelectorAll('tp-yt-paper-tab');
const tabs = {
upNext: tabList[0],
lyrics: tabList[1],
discover: tabList[2],
};
// Check if disabled
if (!tabs.lyrics?.hasAttribute("disabled")) {
return;
}
// Check if disabled
if (!tabs.lyrics?.hasAttribute('disabled')) {
return;
}
let hasLyrics = true;
let hasLyrics = true;
const lyrics = ipcRenderer.sendSync(
"search-genius-lyrics",
extractedSongInfo
);
if (!lyrics) {
// Delete previous lyrics if tab is open and couldn't get new lyrics
checkLyricsContainer(() => {
hasLyrics = false;
setTabsOnclick(undefined);
});
return;
}
const lyrics = ipcRenderer.sendSync(
'search-genius-lyrics',
extractedSongInfo,
);
if (!lyrics) {
// Delete previous lyrics if tab is open and couldn't get new lyrics
checkLyricsContainer(() => {
hasLyrics = false;
setTabsOnclick(undefined);
});
return;
}
if (is.dev()) {
console.log("Fetched lyrics from Genius");
}
if (is.dev()) {
console.log('Fetched lyrics from Genius');
}
enableLyricsTab();
enableLyricsTab();
setTabsOnclick(enableLyricsTab);
setTabsOnclick(enableLyricsTab);
checkLyricsContainer();
checkLyricsContainer();
tabs.lyrics.onclick = () => {
const tabContainer = document.querySelector("ytmusic-tab-renderer");
const observer = new MutationObserver((_, observer) => {
checkLyricsContainer(() => observer.disconnect());
});
observer.observe(tabContainer, {
attributes: true,
childList: true,
subtree: true,
});
};
tabs.lyrics.addEventListener('click', () => {
const tabContainer = document.querySelector('ytmusic-tab-renderer');
const observer = new MutationObserver((_, observer) => {
checkLyricsContainer(() => observer.disconnect());
});
observer.observe(tabContainer, {
attributes: true,
childList: true,
subtree: true,
});
});
function checkLyricsContainer(callback = () => {}) {
const lyricsContainer = document.querySelector(
'[page-type="MUSIC_PAGE_TYPE_TRACK_LYRICS"] > ytmusic-message-renderer'
);
if (lyricsContainer) {
callback();
setLyrics(lyricsContainer)
}
}
function checkLyricsContainer(callback = () => {
}) {
const lyricsContainer = document.querySelector(
'[page-type="MUSIC_PAGE_TYPE_TRACK_LYRICS"] > ytmusic-message-renderer',
);
if (lyricsContainer) {
callback();
setLyrics(lyricsContainer);
}
}
function setLyrics(lyricsContainer) {
lyricsContainer.innerHTML = `<div id="contents" class="style-scope ytmusic-section-list-renderer description ytmusic-description-shelf-renderer genius-lyrics">
function setLyrics(lyricsContainer) {
lyricsContainer.innerHTML = `<div id="contents" class="style-scope ytmusic-section-list-renderer description ytmusic-description-shelf-renderer genius-lyrics">
${
hasLyrics
? lyrics.replace(/(?:\r\n|\r|\n)/g, "<br/>")
: "Could not retrieve lyrics from genius"
}
hasLyrics
? lyrics.replaceAll(/\r\n|\r|\n/g, '<br/>')
: 'Could not retrieve lyrics from genius'
}
</div>
<yt-formatted-string class="footer style-scope ytmusic-description-shelf-renderer" style="align-self: baseline"></yt-formatted-string>`;
if (hasLyrics) {
lyricsContainer.querySelector('.footer').textContent = 'Source: Genius';
enableLyricsTab();
}
}
if (hasLyrics) {
lyricsContainer.querySelector('.footer').textContent = 'Source: Genius';
enableLyricsTab();
}
}
function setTabsOnclick(callback) {
for (const tab of [tabs.upNext, tabs.discover]) {
if (tab) {
tab.onclick = callback;
}
}
}
function setTabsOnclick(callback) {
for (const tab of [tabs.upNext, tabs.discover]) {
if (tab) {
tab.addEventListener('click', callback);
}
}
}
function enableLyricsTab() {
tabs.lyrics.removeAttribute("disabled");
tabs.lyrics.removeAttribute("aria-disabled");
}
}, 500));
function enableLyricsTab() {
tabs.lyrics.removeAttribute('disabled');
tabs.lyrics.removeAttribute('aria-disabled');
}
}, 500));
};

View File

@ -1,17 +1,16 @@
const { setOptions } = require("../../config/plugins");
const { toggleRomanized } = require("./back");
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();
},
},
];
};
const { setOptions } = require('../../config/plugins');
module.exports = (win, options, refreshMenu) => [
{
label: 'Romanized Lyrics',
type: 'checkbox',
checked: options.romanizedLyrics,
click(item) {
options.romanizedLyrics = item.checked;
setOptions('lyrics-genius', options);
toggleRomanized();
},
},
];

View File

@ -1,12 +1,12 @@
/* Disable links in Genius lyrics */
.genius-lyrics a {
color: var(--ytmusic-text-primary);
display: inline-block;
pointer-events: none;
text-decoration: none;
color: var(--ytmusic-text-primary);
display: inline-block;
pointer-events: none;
text-decoration: none;
}
.description {
font-size: clamp(1.4rem, 1.1vmax, 3rem) !important;
text-align: center !important;
font-size: clamp(1.4rem, 1.1vmax, 3rem) !important;
text-align: center !important;
}

View File

@ -1,24 +1,24 @@
const { triggerAction } = require("../utils");
const { triggerAction } = require('../utils');
const CHANNEL = "navigation";
const CHANNEL = 'navigation';
const ACTIONS = {
NEXT: "next",
BACK: "back",
NEXT: 'next',
BACK: 'back',
};
function goToNextPage() {
triggerAction(CHANNEL, ACTIONS.NEXT);
triggerAction(CHANNEL, ACTIONS.NEXT);
}
function goToPreviousPage() {
triggerAction(CHANNEL, ACTIONS.BACK);
triggerAction(CHANNEL, ACTIONS.BACK);
}
module.exports = {
CHANNEL: CHANNEL,
ACTIONS: ACTIONS,
actions: {
goToNextPage: goToNextPage,
goToPreviousPage: goToPreviousPage,
},
CHANNEL,
ACTIONS,
actions: {
goToNextPage,
goToPreviousPage,
},
};

View File

@ -1,29 +1,37 @@
const path = require("path");
const path = require('node:path');
const { injectCSS, listenAction } = require("../utils");
const { ACTIONS, CHANNEL } = require("./actions.js");
const { ACTIONS, CHANNEL } = require('./actions.js');
const { injectCSS, listenAction } = require('../utils');
function handle(win) {
injectCSS(win.webContents, path.join(__dirname, "style.css"), () => {
win.webContents.send("navigation-css-ready");
});
injectCSS(win.webContents, path.join(__dirname, 'style.css'), () => {
win.webContents.send('navigation-css-ready');
});
listenAction(CHANNEL, (event, action) => {
switch (action) {
case ACTIONS.NEXT:
if (win.webContents.canGoForward()) {
win.webContents.goForward();
}
break;
case ACTIONS.BACK:
if (win.webContents.canGoBack()) {
win.webContents.goBack();
}
break;
default:
console.log("Unknown action: " + action);
}
});
listenAction(CHANNEL, (event, action) => {
switch (action) {
case ACTIONS.NEXT: {
if (win.webContents.canGoForward()) {
win.webContents.goForward();
}
break;
}
case ACTIONS.BACK: {
if (win.webContents.canGoBack()) {
win.webContents.goBack();
}
break;
}
default: {
console.log('Unknown action: ' + action);
}
}
});
}
module.exports = handle;

View File

@ -1,19 +1,19 @@
const { ipcRenderer } = require("electron");
const { ipcRenderer } = require('electron');
const { ElementFromFile, templatePath } = require("../utils");
const { ElementFromFile, templatePath } = require('../utils');
function run() {
ipcRenderer.on("navigation-css-ready", () => {
const forwardButton = ElementFromFile(
templatePath(__dirname, "forward.html")
);
const backButton = ElementFromFile(templatePath(__dirname, "back.html"));
const menu = document.querySelector("#right-content");
ipcRenderer.on('navigation-css-ready', () => {
const forwardButton = ElementFromFile(
templatePath(__dirname, 'forward.html'),
);
const backButton = ElementFromFile(templatePath(__dirname, 'back.html'));
const menu = document.querySelector('#right-content');
if (menu) {
menu.prepend(backButton, forwardButton);
}
});
if (menu) {
menu.prepend(backButton, forwardButton);
}
});
}
module.exports = run;

View File

@ -1,35 +1,35 @@
.navigation-item {
font-family: Roboto, Noto Naskh Arabic UI, Arial, sans-serif;
font-size: 20px;
line-height: var(--ytmusic-title-1_-_line-height);
font-weight: 500;
--yt-endpoint-color: #fff;
--yt-endpoint-hover-color: #fff;
--yt-endpoint-visited-color: #fff;
display: inline-flex;
align-items: center;
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
margin: 0 var(--ytd-rich-grid-item-margin);
font-family: Roboto, Noto Naskh Arabic UI, Arial, sans-serif;
font-size: 20px;
line-height: var(--ytmusic-title-1_-_line-height);
font-weight: 500;
--yt-endpoint-color: #fff;
--yt-endpoint-hover-color: #fff;
--yt-endpoint-visited-color: #fff;
display: inline-flex;
align-items: center;
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
margin: 0 var(--ytd-rich-grid-item-margin);
}
.navigation-item:hover {
color: #fff;
color: #fff;
}
.navigation-icon {
display: inline-flex;
-ms-flex-align: center;
-webkit-align-items: center;
align-items: center;
-ms-flex-pack: center;
-webkit-justify-content: center;
justify-content: center;
position: relative;
vertical-align: middle;
fill: var(--iron-icon-fill-color, currentcolor);
stroke: none;
width: var(--iron-icon-width, 24px);
height: var(--iron-icon-height, 24px);
animation: var(--iron-icon_-_animation);
display: inline-flex;
-ms-flex-align: center;
-webkit-align-items: center;
align-items: center;
-ms-flex-pack: center;
-webkit-justify-content: center;
justify-content: center;
position: relative;
vertical-align: middle;
fill: var(--iron-icon-fill-color, currentcolor);
stroke: none;
width: var(--iron-icon-width, 24px);
height: var(--iron-icon-height, 24px);
animation: var(--iron-icon_-_animation);
}

View File

@ -1,33 +1,33 @@
<div
class="style-scope ytmusic-pivot-bar-renderer navigation-item"
tab-id="FEmusic_back"
role="tab"
onclick="goToPreviousPage()"
class="style-scope ytmusic-pivot-bar-renderer navigation-item"
onclick="goToPreviousPage()"
role="tab"
tab-id="FEmusic_back"
>
<div
class="search-icon style-scope ytmusic-search-box"
role="button"
tabindex="0"
aria-disabled="false"
title="Go to previous page"
>
<div
id="icon"
class="tab-icon style-scope paper-icon-button navigation-icon"
>
<svg
viewBox="0 0 492 492"
preserveAspectRatio="xMidYMid meet"
focusable="false"
class="style-scope iron-icon"
style="pointer-events: none; display: block; width: 100%; height: 100%"
>
<g class="style-scope iron-icon">
<path
d="M109.3 265.2l218.9 218.9c5.1 5.1 11.8 7.9 19 7.9s14-2.8 19-7.9l16.1-16.1c10.5-10.5 10.5-27.6 0-38.1L198.6 246.1 382.7 62c5.1-5.1 7.9-11.8 7.9-19 0-7.2-2.8-14-7.9-19L366.5 7.9c-5.1-5.1-11.8-7.9-19-7.9-7.2 0-14 2.8-19 7.9L109.3 227c-5.1 5.1-7.9 11.9-7.8 19.1 0 7.2 2.8 14 7.8 19.1z"
></path>
</g>
</svg>
</div>
</div>
<div
aria-disabled="false"
class="search-icon style-scope ytmusic-search-box"
role="button"
tabindex="0"
title="Go to previous page"
>
<div
class="tab-icon style-scope paper-icon-button navigation-icon"
id="icon"
>
<svg
class="style-scope iron-icon"
focusable="false"
preserveAspectRatio="xMidYMid meet"
style="pointer-events: none; display: block; width: 100%; height: 100%"
viewBox="0 0 492 492"
>
<g class="style-scope iron-icon">
<path
d="M109.3 265.2l218.9 218.9c5.1 5.1 11.8 7.9 19 7.9s14-2.8 19-7.9l16.1-16.1c10.5-10.5 10.5-27.6 0-38.1L198.6 246.1 382.7 62c5.1-5.1 7.9-11.8 7.9-19 0-7.2-2.8-14-7.9-19L366.5 7.9c-5.1-5.1-11.8-7.9-19-7.9-7.2 0-14 2.8-19 7.9L109.3 227c-5.1 5.1-7.9 11.9-7.8 19.1 0 7.2 2.8 14 7.8 19.1z"
></path>
</g>
</svg>
</div>
</div>
</div>

View File

@ -1,35 +1,35 @@
<div
class="style-scope ytmusic-pivot-bar-renderer navigation-item"
tab-id="FEmusic_next"
role="tab"
onclick="goToNextPage()"
class="style-scope ytmusic-pivot-bar-renderer navigation-item"
onclick="goToNextPage()"
role="tab"
tab-id="FEmusic_next"
>
<div
class="search-icon style-scope ytmusic-search-box"
role="button"
tabindex="0"
aria-disabled="false"
title="Go to next page"
>
<div
id="icon"
class="tab-icon style-scope paper-icon-button navigation-icon"
>
<svg
viewBox="0 0 492 492"
preserveAspectRatio="xMidYMid meet"
focusable="false"
class="style-scope iron-icon"
style="pointer-events: none; display: block; width: 100%; height: 100%;"
>
<g class="style-scope iron-icon">
<path
d="M382.7,226.8L163.7,7.9c-5.1-5.1-11.8-7.9-19-7.9s-14,2.8-19,7.9L109.5,24c-10.5,10.5-10.5,27.6,0,38.1
<div
aria-disabled="false"
class="search-icon style-scope ytmusic-search-box"
role="button"
tabindex="0"
title="Go to next page"
>
<div
class="tab-icon style-scope paper-icon-button navigation-icon"
id="icon"
>
<svg
class="style-scope iron-icon"
focusable="false"
preserveAspectRatio="xMidYMid meet"
style="pointer-events: none; display: block; width: 100%; height: 100%;"
viewBox="0 0 492 492"
>
<g class="style-scope iron-icon">
<path
d="M382.7,226.8L163.7,7.9c-5.1-5.1-11.8-7.9-19-7.9s-14,2.8-19,7.9L109.5,24c-10.5,10.5-10.5,27.6,0,38.1
l183.9,183.9L109.3,430c-5.1,5.1-7.9,11.8-7.9,19c0,7.2,2.8,14,7.9,19l16.1,16.1c5.1,5.1,11.8,7.9,19,7.9s14-2.8,19-7.9L382.7,265
c5.1-5.1,7.9-11.9,7.8-19.1C390.5,238.7,387.8,231.9,382.7,226.8z"
></path>
</g>
</svg>
</div>
</div>
></path>
</g>
</svg>
</div>
</div>
</div>

View File

@ -1,6 +1,7 @@
const { injectCSS } = require("../utils");
const path = require("path");
const path = require('node:path');
module.exports = win => {
injectCSS(win.webContents, path.join(__dirname, "style.css"));
const { injectCSS } = require('../utils');
module.exports = (win) => {
injectCSS(win.webContents, path.join(__dirname, 'style.css'));
};

View File

@ -1,37 +1,37 @@
function removeLoginElements() {
const elementsToRemove = [
".sign-in-link.ytmusic-nav-bar",
'.ytmusic-pivot-bar-renderer[tab-id="FEmusic_liked"]',
];
const elementsToRemove = [
'.sign-in-link.ytmusic-nav-bar',
'.ytmusic-pivot-bar-renderer[tab-id="FEmusic_liked"]',
];
elementsToRemove.forEach((selector) => {
const node = document.querySelector(selector);
if (node) {
node.remove();
}
});
for (const selector of elementsToRemove) {
const node = document.querySelector(selector);
if (node) {
node.remove();
}
}
// Remove the library button
const libraryIconPath =
"M16,6v2h-2v5c0,1.1-0.9,2-2,2s-2-0.9-2-2s0.9-2,2-2c0.37,0,0.7,0.11,1,0.28V6H16z M18,20H4V6H3v15h15V20z M21,3H6v15h15V3z M7,4h13v13H7V4z";
const observer = new MutationObserver(() => {
menuEntries = document.querySelectorAll(
"#items ytmusic-guide-entry-renderer"
);
menuEntries.forEach((item) => {
const icon = item.querySelector("path");
if (icon) {
observer.disconnect();
if (icon.getAttribute("d") === libraryIconPath) {
item.remove();
}
}
});
});
observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
// Remove the library button
const libraryIconPath
= 'M16,6v2h-2v5c0,1.1-0.9,2-2,2s-2-0.9-2-2s0.9-2,2-2c0.37,0,0.7,0.11,1,0.28V6H16z M18,20H4V6H3v15h15V20z M21,3H6v15h15V3z M7,4h13v13H7V4z';
const observer = new MutationObserver(() => {
menuEntries = document.querySelectorAll(
'#items ytmusic-guide-entry-renderer',
);
for (const item of menuEntries) {
const icon = item.querySelector('path');
if (icon) {
observer.disconnect();
if (icon.getAttribute('d') === libraryIconPath) {
item.remove();
}
}
}
});
observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
}
module.exports = removeLoginElements;

View File

@ -2,5 +2,5 @@
ytmusic-guide-signin-promo-renderer,
a[href="/music_premium"],
.sign-in-link {
display: none !important;
display: none !important;
}

View File

@ -1,46 +1,49 @@
const { Notification } = require("electron");
const is = require("electron-is");
const registerCallback = require("../../providers/song-info");
const { notificationImage } = require("./utils");
const config = require("./config");
const { Notification } = require('electron');
const is = require('electron-is');
const { notificationImage } = require('./utils');
const config = require('./config');
const registerCallback = require('../../providers/song-info');
const notify = (info) => {
// Fill the notification with content
const notification = {
title: info.title || 'Playing',
body: info.artist,
icon: notificationImage(info),
silent: true,
urgency: config.get('urgency'),
};
// Fill the notification with content
const notification = {
title: info.title || "Playing",
body: info.artist,
icon: notificationImage(info),
silent: true,
urgency: config.get('urgency'),
};
// Send the notification
const currentNotification = new Notification(notification);
currentNotification.show();
// Send the notification
const currentNotification = new Notification(notification);
currentNotification.show()
return currentNotification;
return currentNotification;
};
const setup = () => {
let oldNotification;
let currentUrl;
let oldNotification;
let currentUrl;
registerCallback(songInfo => {
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) }, 10);
}
});
}
registerCallback((songInfo) => {
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);
}, 10);
}
});
};
/** @param {Electron.BrowserWindow} win */
module.exports = (win, options) => {
// Register the callback for new song information
is.windows() && options.interactive ?
require("./interactive")(win) :
setup();
// Register the callback for new song information
is.windows() && options.interactive
? require('./interactive')(win)
: setup();
};

View File

@ -1,5 +1,5 @@
const { PluginConfig } = require("../../config/dynamic");
const { PluginConfig } = require('../../config/dynamic');
const config = new PluginConfig("notifications");
const config = new PluginConfig('notifications');
module.exports = { ...config };

View File

@ -1,151 +1,170 @@
const { notificationImage, icons, save_temp_icons, secondsToMinutes, ToastStyles } = require("./utils");
const path = require('node:path');
const { Notification, app, ipcMain } = require('electron');
const { notificationImage, icons, save_temp_icons, secondsToMinutes, ToastStyles } = require('./utils');
const config = require('./config');
const getSongControls = require('../../providers/song-controls');
const registerCallback = require("../../providers/song-info");
const { changeProtocolHandler } = require("../../providers/protocol-handler");
const { setTrayOnClick, setTrayOnDoubleClick } = require("../../tray");
const registerCallback = require('../../providers/song-info');
const { changeProtocolHandler } = require('../../providers/protocol-handler');
const { setTrayOnClick, setTrayOnDoubleClick } = require('../../tray');
const { Notification, app, ipcMain } = require("electron");
const path = require('path');
const config = require("./config");
let songControls;
let savedNotification;
/** @param {Electron.BrowserWindow} win */
module.exports = (win) => {
songControls = getSongControls(win);
songControls = getSongControls(win);
let currentSeconds = 0;
ipcMain.on('apiLoaded', () => win.webContents.send('setupTimeChangedListener'));
let currentSeconds = 0;
ipcMain.on('apiLoaded', () => win.webContents.send('setupTimeChangedListener'));
ipcMain.on('timeChanged', (_, t) => currentSeconds = t);
ipcMain.on('timeChanged', (_, t) => currentSeconds = t);
if (app.isPackaged) save_temp_icons();
if (app.isPackaged) {
save_temp_icons();
}
let savedSongInfo;
let lastUrl;
let savedSongInfo;
let lastUrl;
// Register songInfoCallback
registerCallback(songInfo => {
if (!songInfo.artist && !songInfo.title) return;
savedSongInfo = { ...songInfo };
if (!songInfo.isPaused &&
(songInfo.url !== lastUrl || config.get("unpauseNotification"))
) {
lastUrl = songInfo.url
sendNotification(songInfo);
}
});
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();
})
// Register songInfoCallback
registerCallback((songInfo) => {
if (!songInfo.artist && !songInfo.title) {
return;
}
savedSongInfo = { ...songInfo };
if (!songInfo.isPaused
&& (songInfo.url !== lastUrl || config.get('unpauseNotification'))
) {
lastUrl = songInfo.url;
sendNotification(songInfo);
}
});
app.once("before-quit", () => {
savedNotification?.close();
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();
}
});
}
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
})
);
}
}
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,
}),
);
}
)
}
}
},
);
};
function sendNotification(songInfo) {
const iconSrc = notificationImage(songInfo);
const iconSrc = notificationImage(songInfo);
savedNotification?.close();
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 = 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.on('close', (_) => {
savedNotification = undefined;
});
savedNotification.show();
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);
};
}
switch (config.get('toastStyle')) {
default:
case ToastStyles.logo:
case ToastStyles.legacy: {
return xml_logo(songInfo, iconSrc);
}
const iconLocation = app.isPackaged ?
path.resolve(app.getPath("userData"), 'icons') :
path.resolve(__dirname, '..', '..', 'assets/media-icons-black');
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)}"\
if (config.get('toastStyle') === ToastStyles.legacy) {
return `content="${icons[kind]}"`;
}
return `\
content="${config.get('hideButtonText') ? '' : kind.charAt(0).toUpperCase() + kind.slice(1)}"\
imageUri="file:///${path.resolve(__dirname, iconLocation, `${kind}.png`)}"
`;
}
}
};
const getButton = (kind) =>
`<action ${display(kind)} activationType="protocol" arguments="youtubemusic://${kind}"/>`;
`<action ${display(kind)} activationType="protocol" arguments="youtubemusic://${kind}"/>`;
const getButtons = (isPaused) => `\
<actions>
@ -173,7 +192,6 @@ const xml_image = ({ title, artist, isPaused }, imgSrc, placement) => toast(`\
<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"');
@ -194,8 +212,8 @@ const xml_banner_top_custom = (songInfo, imgSrc) => toast(`\
const xml_more_data = ({ album, elapsedSeconds, songDuration }) => `\
<subgroup hint-textStacking="bottom">
${album ?
`<text hint-style="captionSubtle" hint-wrap="true" hint-align="right">${album}</text>` : ''}
${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>\
`;
@ -223,13 +241,17 @@ const xml_banner_centered_top = ({ title, artist, isPaused }, imgSrc) => toast(`
`, 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';
}
}
if (title.length <= 13) {
return 'Header';
}
if (title.length <= 22) {
return 'Subheader';
}
if (title.length <= 26) {
return 'Title';
}
return 'Subtitle';
};

View File

@ -1,80 +1,81 @@
const { urgencyLevels, ToastStyles, snakeToCamel } = require("./utils");
const is = require("electron-is");
const config = require("./config");
const is = require('electron-is');
const { urgencyLevels, ToastStyles, snakeToCamel } = require('./utils');
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: () => 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) => config.set("unpauseNotification", item.checked),
},
...(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) => config.set('unpauseNotification', item.checked),
},
];
function getToastStyleMenuItems(options) {
const arr = new Array(Object.keys(ToastStyles).length);
const array = Array.from({ length: 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),
};
}
// ToastStyles index starts from 1
for (const [name, index] of Object.entries(ToastStyles)) {
array[index - 1] = {
label: snakeToCamel(name),
type: 'radio',
checked: options.toastStyle === index,
click: () => config.set('toastStyle', index),
};
}
return arr;
return array;
}

View File

@ -1,93 +1,107 @@
const path = require("path");
const { app } = require("electron");
const fs = require("fs");
const config = require("./config");
const path = require('node:path');
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");
const fs = require('node:fs');
const { cache } = require("../../providers/decorators")
const { app } = require('electron');
const config = require('./config');
const icon = 'assets/youtube-music.png';
const userData = app.getPath('userData');
const temporaryIcon = path.join(userData, 'tempIcon.png');
const temporaryBanner = path.join(userData, 'tempBanner.png');
const { cache } = require('../../providers/decorators');
module.exports.ToastStyles = {
logo: 1,
banner_centered_top: 2,
hero: 3,
banner_top_custom: 4,
banner_centered_bottom: 5,
banner_bottom: 6,
legacy: 7
}
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}", // ᐅ
pause: "\u{2016}", // ‖
next: "\u{1433}", //
previous: "\u{1438}" //
}
play: '\u{1405}', // ᐅ
pause: '\u{2016}', // ‖
next: '\u{1433}', //
previous: '\u{1438}', //
};
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' },
];
const nativeImageToLogo = cache((nativeImage) => {
const tempImage = nativeImage.resize({ height: 256 });
const margin = Math.max(tempImage.getSize().width - 256, 0);
const temporaryImage = nativeImage.resize({ height: 256 });
const margin = Math.max(temporaryImage.getSize().width - 256, 0);
return tempImage.crop({
x: Math.round(margin / 2),
y: 0,
width: 256,
height: 256,
});
return temporaryImage.crop({
x: Math.round(margin / 2),
y: 0,
width: 256,
height: 256,
});
});
module.exports.notificationImage = (songInfo) => {
if (!songInfo.image) return icon;
if (!config.get("interactive")) return nativeImageToLogo(songInfo.image);
if (!songInfo.image) {
return icon;
}
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);
};
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), temporaryIcon);
}
default: {
return this.saveImage(songInfo.image, temporaryBanner);
}
}
};
module.exports.saveImage = cache((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;
try {
fs.writeFileSync(save_path, img.toPNG());
} catch (error) {
console.log(`Error writing song icon to disk:\n${error.toString()}`);
return icon;
}
return save_path;
});
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, () => { });
}
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.snakeToCamel = (string_) => string_.replaceAll(/([-_][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}`;
}
const minutes = Math.floor(seconds / 60);
const secondsLeft = seconds % 60;
return `${minutes}:${secondsLeft < 10 ? '0' : ''}${secondsLeft}`;
};

View File

@ -1,10 +1,10 @@
const path = require("path");
const path = require('node:path');
const { app, ipcMain } = require("electron");
const electronLocalshortcut = require("electron-localshortcut");
const { app, ipcMain } = require('electron');
const electronLocalshortcut = require('electron-localshortcut');
const { setOptions } = require("../../config/plugins");
const { injectCSS } = require("../utils");
const { setOptions } = require('../../config/plugins');
const { injectCSS } = require('../utils');
let isInPiP = false;
let originalPosition;
@ -15,83 +15,93 @@ let originalMaximized;
let win;
let options;
const pipPosition = () => (options.savePosition && options["pip-position"]) || [10, 10];
const pipSize = () => (options.saveSize && options["pip-size"]) || [450, 275];
const pipPosition = () => (options.savePosition && options['pip-position']) || [10, 10];
const pipSize = () => (options.saveSize && options['pip-size']) || [450, 275];
const setLocalOptions = (_options) => {
options = { ...options, ..._options };
setOptions("picture-in-picture", _options);
}
options = { ...options, ..._options };
setOptions('picture-in-picture', _options);
};
const togglePiP = async () => {
isInPiP = !isInPiP;
setLocalOptions({ isInPiP });
isInPiP = !isInPiP;
setLocalOptions({ isInPiP });
if (isInPiP) {
originalFullScreen = win.isFullScreen();
if (originalFullScreen) win.setFullScreen(false);
originalMaximized = win.isMaximized();
if (originalMaximized) win.unmaximize();
originalPosition = win.getPosition();
originalSize = win.getSize();
if (isInPiP) {
originalFullScreen = win.isFullScreen();
if (originalFullScreen) {
win.setFullScreen(false);
}
win.webContents.on("before-input-event", blockShortcutsInPiP);
originalMaximized = win.isMaximized();
if (originalMaximized) {
win.unmaximize();
}
win.setMaximizable(false);
win.setFullScreenable(false);
originalPosition = win.getPosition();
originalSize = win.getSize();
win.webContents.send("pip-toggle", true);
win.webContents.on('before-input-event', blockShortcutsInPiP);
app.dock?.hide();
win.setVisibleOnAllWorkspaces(true, {
visibleOnFullScreen: true,
});
app.dock?.show();
if (options.alwaysOnTop) {
win.setAlwaysOnTop(true, "screen-saver", 1);
}
} else {
win.webContents.removeListener("before-input-event", blockShortcutsInPiP);
win.setMaximizable(true);
win.setFullScreenable(true);
win.setMaximizable(false);
win.setFullScreenable(false);
win.webContents.send("pip-toggle", false);
win.webContents.send('pip-toggle', true);
win.setVisibleOnAllWorkspaces(false);
win.setAlwaysOnTop(false);
app.dock?.hide();
win.setVisibleOnAllWorkspaces(true, {
visibleOnFullScreen: true,
});
app.dock?.show();
if (options.alwaysOnTop) {
win.setAlwaysOnTop(true, 'screen-saver', 1);
}
} else {
win.webContents.removeListener('before-input-event', blockShortcutsInPiP);
win.setMaximizable(true);
win.setFullScreenable(true);
if (originalFullScreen) win.setFullScreen(true);
if (originalMaximized) win.maximize();
}
win.webContents.send('pip-toggle', false);
const [x, y] = isInPiP ? pipPosition() : originalPosition;
const [w, h] = isInPiP ? pipSize() : originalSize;
win.setPosition(x, y);
win.setSize(w, h);
win.setVisibleOnAllWorkspaces(false);
win.setAlwaysOnTop(false);
win.setWindowButtonVisibility?.(!isInPiP);
if (originalFullScreen) {
win.setFullScreen(true);
}
if (originalMaximized) {
win.maximize();
}
}
const [x, y] = isInPiP ? pipPosition() : originalPosition;
const [w, h] = isInPiP ? pipSize() : originalSize;
win.setPosition(x, y);
win.setSize(w, h);
win.setWindowButtonVisibility?.(!isInPiP);
};
const blockShortcutsInPiP = (event, input) => {
const key = input.key.toLowerCase();
const key = input.key.toLowerCase();
if (key === "f") {
event.preventDefault();
} else if (key === 'escape') {
togglePiP();
event.preventDefault();
};
if (key === 'f') {
event.preventDefault();
} else if (key === 'escape') {
togglePiP();
event.preventDefault();
}
};
module.exports = (_win, _options) => {
options ??= _options;
win ??= _win;
setLocalOptions({ isInPiP });
injectCSS(win.webContents, path.join(__dirname, "style.css"));
ipcMain.on("picture-in-picture", async () => {
await togglePiP();
});
options ??= _options;
win ??= _win;
setLocalOptions({ isInPiP });
injectCSS(win.webContents, path.join(__dirname, 'style.css'));
ipcMain.on('picture-in-picture', async () => {
await togglePiP();
});
};
module.exports.setOptions = setLocalOptions;

View File

@ -1,140 +1,156 @@
const { ipcRenderer } = require("electron");
const { ipcRenderer } = require('electron');
const { toKeyEvent } = require('keyboardevent-from-electron-accelerator');
const keyEventAreEqual = require('keyboardevents-areequal');
const { toKeyEvent } = require("keyboardevent-from-electron-accelerator");
const keyEventAreEqual = require("keyboardevents-areequal");
const { getSongMenu } = require('../../providers/dom-elements');
const { ElementFromFile, templatePath } = require('../utils');
const { getSongMenu } = require("../../providers/dom-elements");
const { ElementFromFile, templatePath } = require("../utils");
function $(selector) { return document.querySelector(selector); }
function $(selector) {
return document.querySelector(selector);
}
let useNativePiP = false;
let menu = null;
const pipButton = ElementFromFile(
templatePath(__dirname, "picture-in-picture.html")
templatePath(__dirname, 'picture-in-picture.html'),
);
// will also clone
// Will also clone
function replaceButton(query, button) {
const svg = button.querySelector("#icon svg").cloneNode(true);
button.replaceWith(button.cloneNode(true));
button.remove();
const newButton = $(query);
newButton.querySelector("#icon").appendChild(svg);
return newButton;
const svg = button.querySelector('#icon svg').cloneNode(true);
button.replaceWith(button.cloneNode(true));
button.remove();
const newButton = $(query);
newButton.querySelector('#icon').append(svg);
return newButton;
}
function cloneButton(query) {
replaceButton(query, $(query));
return $(query);
replaceButton(query, $(query));
return $(query);
}
const observer = new MutationObserver(() => {
if (!menu) {
menu = getSongMenu();
if (!menu) 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;
if (menuUrl && !menuUrl.includes("watch?")) return;
if (!menu) {
menu = getSongMenu();
if (!menu) {
return;
}
}
menu.prepend(pipButton);
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;
if (menuUrl && !menuUrl.includes('watch?')) {
return;
}
menu.prepend(pipButton);
});
global.togglePictureInPicture = async () => {
if (useNativePiP) {
const isInPiP = document.pictureInPictureElement !== null;
const video = $("video");
const togglePiP = () =>
isInPiP
? document.exitPictureInPicture.call(document)
: video.requestPictureInPicture.call(video);
if (useNativePiP) {
const isInPiP = document.pictureInPictureElement !== null;
const video = $('video');
const togglePiP = () =>
isInPiP
? document.exitPictureInPicture.call(document)
: video.requestPictureInPicture.call(video);
try {
await togglePiP();
$("#icon").click(); // Close the menu
return true;
} catch {}
}
try {
await togglePiP();
$('#icon').click(); // Close the menu
return true;
} catch {
}
}
ipcRenderer.send("picture-in-picture");
return false;
ipcRenderer.send('picture-in-picture');
return false;
};
const listenForToggle = () => {
const originalExitButton = $(".exit-fullscreen-button");
const appLayout = $("ytmusic-app-layout");
const expandMenu = $('#expanding-menu');
const middleControls = $('.middle-controls');
const playerPage = $("ytmusic-player-page");
const togglePlayerPageButton = $(".toggle-player-page-button");
const fullScreenButton = $(".fullscreen-button");
const player = $('#player');
const onPlayerDblClick = player.onDoubleClick_;
const originalExitButton = $('.exit-fullscreen-button');
const appLayout = $('ytmusic-app-layout');
const expandMenu = $('#expanding-menu');
const middleControls = $('.middle-controls');
const playerPage = $('ytmusic-player-page');
const togglePlayerPageButton = $('.toggle-player-page-button');
const fullScreenButton = $('.fullscreen-button');
const player = $('#player');
const onPlayerDblClick = player.onDoubleClick_;
const titlebar = $(".cet-titlebar");
const titlebar = $('.cet-titlebar');
ipcRenderer.on("pip-toggle", (_, isPip) => {
if (isPip) {
replaceButton(".exit-fullscreen-button", originalExitButton).onclick =
() => togglePictureInPicture();
player.onDoubleClick_ = () => {};
expandMenu.onmouseleave = () => middleControls.click();
if (!playerPage.playerPageOpen_) {
togglePlayerPageButton.click();
}
fullScreenButton.click();
appLayout.classList.add("pip");
if (titlebar) titlebar.style.display = "none";
} else {
$(".exit-fullscreen-button").replaceWith(originalExitButton);
player.onDoubleClick_ = onPlayerDblClick;
expandMenu.onmouseleave = undefined;
originalExitButton.click();
appLayout.classList.remove("pip");
if (titlebar) titlebar.style.display = "flex";
}
});
}
ipcRenderer.on('pip-toggle', (_, isPip) => {
if (isPip) {
replaceButton('.exit-fullscreen-button', originalExitButton).addEventListener('click', () => togglePictureInPicture());
player.onDoubleClick_ = () => {
};
expandMenu.addEventListener('mouseleave', () => middleControls.click());
if (!playerPage.playerPageOpen_) {
togglePlayerPageButton.click();
}
fullScreenButton.click();
appLayout.classList.add('pip');
if (titlebar) {
titlebar.style.display = 'none';
}
} else {
$('.exit-fullscreen-button').replaceWith(originalExitButton);
player.onDoubleClick_ = onPlayerDblClick;
expandMenu.onmouseleave = undefined;
originalExitButton.click();
appLayout.classList.remove('pip');
if (titlebar) {
titlebar.style.display = 'flex';
}
}
});
};
function observeMenu(options) {
useNativePiP = options.useNativePiP;
document.addEventListener(
"apiLoaded",
() => {
listenForToggle();
useNativePiP = options.useNativePiP;
document.addEventListener(
'apiLoaded',
() => {
listenForToggle();
cloneButton(".player-minimize-button").onclick = async () => {
await global.togglePictureInPicture();
setTimeout(() => $("#player").click());
};
cloneButton('.player-minimize-button').addEventListener('click', async () => {
await global.togglePictureInPicture();
setTimeout(() => $('#player').click());
});
// allows easily closing the menu by programmatically clicking outside of it
$("#expanding-menu").removeAttribute("no-cancel-on-outside-click");
// TODO: think about wether an additional button in songMenu is needed
observer.observe($("ytmusic-popup-container"), {
childList: true,
subtree: true,
});
},
{ once: true, passive: true }
);
// Allows easily closing the menu by programmatically clicking outside of it
$('#expanding-menu').removeAttribute('no-cancel-on-outside-click');
// TODO: think about wether an additional button in songMenu is needed
observer.observe($('ytmusic-popup-container'), {
childList: true,
subtree: true,
});
},
{ once: true, passive: true },
);
}
module.exports = (options) => {
observeMenu(options);
observeMenu(options);
if (options.hotkey) {
const hotkeyEvent = toKeyEvent(options.hotkey);
window.addEventListener("keydown", (event) => {
if (
keyEventAreEqual(event, hotkeyEvent) &&
!$("ytmusic-search-box").opened
) {
togglePictureInPicture();
}
});
}
if (options.hotkey) {
const hotkeyEvent = toKeyEvent(options.hotkey);
window.addEventListener('keydown', (event) => {
if (
keyEventAreEqual(event, hotkeyEvent)
&& !$('ytmusic-search-box').opened
) {
togglePictureInPicture();
}
});
}
};

View File

@ -1,68 +1,69 @@
const prompt = require("custom-electron-prompt");
const prompt = require('custom-electron-prompt');
const promptOptions = require("../../providers/prompt-options");
const { setOptions } = require("./back.js");
const { setOptions } = require('./back.js');
const promptOptions = require('../../providers/prompt-options');
module.exports = (win, options) => [
{
label: "Always on top",
type: "checkbox",
checked: options.alwaysOnTop,
click: (item) => {
setOptions({ alwaysOnTop: item.checked });
win.setAlwaysOnTop(item.checked);
},
{
label: 'Always on top',
type: 'checkbox',
checked: options.alwaysOnTop,
click(item) {
setOptions({ alwaysOnTop: item.checked });
win.setAlwaysOnTop(item.checked);
},
{
label: "Save window position",
type: "checkbox",
checked: options.savePosition,
click: (item) => {
setOptions({ savePosition: item.checked });
},
},
{
label: 'Save window position',
type: 'checkbox',
checked: options.savePosition,
click(item) {
setOptions({ savePosition: item.checked });
},
{
label: "Save window size",
type: "checkbox",
checked: options.saveSize,
click: (item) => {
setOptions({ saveSize: item.checked });
},
},
{
label: 'Save window size',
type: 'checkbox',
checked: options.saveSize,
click(item) {
setOptions({ saveSize: item.checked });
},
{
label: "Hotkey",
type: "checkbox",
checked: options.hotkey,
click: async (item) => {
const output = await prompt({
title: "Picture in Picture Hotkey",
label: "Choose a hotkey for toggling Picture in Picture",
type: "keybind",
keybindOptions: [{
value: "hotkey",
label: "Hotkey",
default: options.hotkey
}],
...promptOptions()
}, win)
},
{
label: 'Hotkey',
type: 'checkbox',
checked: options.hotkey,
async click(item) {
const output = await prompt({
title: 'Picture in Picture Hotkey',
label: 'Choose a hotkey for toggling Picture in Picture',
type: 'keybind',
keybindOptions: [{
value: 'hotkey',
label: 'Hotkey',
default: options.hotkey,
}],
...promptOptions(),
}, win);
if (output) {
const { value, accelerator } = output[0];
setOptions({ [value]: accelerator });
if (output) {
const { value, accelerator } = output[0];
setOptions({ [value]: accelerator });
item.checked = !!accelerator;
} else {
// Reset checkbox if prompt was canceled
item.checked = !item.checked;
}
},
item.checked = Boolean(accelerator);
} else {
// Reset checkbox if prompt was canceled
item.checked = !item.checked;
}
},
{
label: "Use native PiP",
type: "checkbox",
checked: options.useNativePiP,
click: (item) => {
setOptions({ useNativePiP: item.checked });
},
}
},
{
label: 'Use native PiP',
type: 'checkbox',
checked: options.useNativePiP,
click(item) {
setOptions({ useNativePiP: item.checked });
},
},
];

View File

@ -3,41 +3,41 @@ ytmusic-app-layout.pip ytmusic-player-bar svg,
ytmusic-app-layout.pip ytmusic-player-bar .time-info,
ytmusic-app-layout.pip ytmusic-player-bar yt-formatted-string,
ytmusic-app-layout.pip ytmusic-player-bar .yt-formatted-string {
filter: drop-shadow(2px 4px 6px black);
color: white !important;
fill: white !important;
filter: drop-shadow(2px 4px 6px black);
color: white !important;
fill: white !important;
}
/* improve the style of the player bar expanding menu */
ytmusic-app-layout.pip ytmusic-player-expanding-menu {
border-radius: 30px;
background-color: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(5px) brightness(20%);
border-radius: 30px;
background-color: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(5px) brightness(20%);
}
/* fix volumeHud position when both in-app-menu and PiP are active */
.cet-container ytmusic-app-layout.pip #volumeHud {
top: 22px !important;
top: 22px !important;
}
/* make player-bar not draggable if in-app-menu is enabled */
.cet-container ytmusic-app-layout.pip ytmusic-player-bar {
-webkit-app-region: no-drag !important;
-webkit-app-region: no-drag !important;
}
/* make player draggable if in-app-menu is enabled */
.cet-container ytmusic-app-layout.pip #player {
-webkit-app-region: drag !important;
-webkit-app-region: drag !important;
}
/* remove info, thumbnail and menu from player-bar */
ytmusic-app-layout.pip ytmusic-player-bar .content-info-wrapper,
ytmusic-app-layout.pip ytmusic-player-bar .thumbnail-image-wrapper,
ytmusic-app-layout.pip ytmusic-player-bar ytmusic-menu-renderer {
display: none !important;
display: none !important;
}
/* disable the video-toggle button when in PiP mode */
ytmusic-app-layout.pip .video-switch-button {
display: none !important;
display: none !important;
}

View File

@ -1,51 +1,50 @@
<div
class="style-scope menu-item ytmusic-menu-popup-renderer"
role="option"
tabindex="-1"
aria-disabled="false"
aria-selected="false"
onclick="togglePictureInPicture()"
aria-disabled="false"
aria-selected="false"
class="style-scope menu-item ytmusic-menu-popup-renderer"
onclick="togglePictureInPicture()"
role="option"
tabindex="-1"
>
<div
id="navigation-endpoint"
class="yt-simple-endpoint style-scope ytmusic-menu-navigation-item-renderer"
tabindex="-1"
>
<div
class="icon menu-icon style-scope ytmusic-menu-navigation-item-renderer"
>
<svg
version="1.1"
id="Layer_1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
x="0px"
y="0px"
viewBox="0 0 512 512"
style="enable-background: new 0 0 512 512"
xml:space="preserve"
>
<div
class="yt-simple-endpoint style-scope ytmusic-menu-navigation-item-renderer"
id="navigation-endpoint"
tabindex="-1"
>
<div
class="icon menu-icon style-scope ytmusic-menu-navigation-item-renderer"
>
<svg
id="Layer_1"
style="enable-background: new 0 0 512 512"
version="1.1"
viewBox="0 0 512 512"
x="0px"
xml:space="preserve"
xmlns="http://www.w3.org/2000/svg"
y="0px"
>
<style type="text/css">
.st0 {
fill: #aaaaaa;
}
fill: #aaaaaa;
}
</style>
<g id="XMLID_6_">
<g id="XMLID_6_">
<path
id="XMLID_11_"
class="st0"
d="M418.5,139.4H232.4v139.8h186.1V139.4z M464.8,46.7H46.3C20.5,46.7,0,68.1,0,93.1v325.9
class="st0"
d="M418.5,139.4H232.4v139.8h186.1V139.4z M464.8,46.7H46.3C20.5,46.7,0,68.1,0,93.1v325.9
c0,25.8,21.4,46.3,46.3,46.3h419.4c25.8,0,46.3-20.5,46.3-46.3V93.1C512,67.2,490.6,46.7,464.8,46.7z M464.8,418.9H46.3V92.2h419.4
v326.8H464.8z"
/>
id="XMLID_11_"
/>
</g>
</svg>
</div>
<div
class="text style-scope ytmusic-menu-navigation-item-renderer"
id="ytmcustom-pip"
>
Picture in picture
</div>
</div>
</div>
<div
class="text style-scope ytmusic-menu-navigation-item-renderer"
id="ytmcustom-pip"
>
Picture in picture
</div>
</div>
</div>

View File

@ -1,12 +1,14 @@
const { getSongMenu } = require("../../providers/dom-elements");
const { ElementFromFile, templatePath } = require("../utils");
const { singleton } = require("../../providers/decorators")
const { getSongMenu } = require('../../providers/dom-elements');
const { ElementFromFile, templatePath } = require('../utils');
const { singleton } = require('../../providers/decorators');
function $(selector) { return document.querySelector(selector); }
function $(selector) {
return document.querySelector(selector);
}
const slider = ElementFromFile(templatePath(__dirname, "slider.html"));
const slider = ElementFromFile(templatePath(__dirname, 'slider.html'));
const roundToTwo = n => Math.round(n * 1e2) / 1e2;
const roundToTwo = (n) => Math.round(n * 1e2) / 1e2;
const MIN_PLAYBACK_SPEED = 0.07;
const MAX_PLAYBACK_SPEED = 16;
@ -14,77 +16,79 @@ const MAX_PLAYBACK_SPEED = 16;
let playbackSpeed = 1;
const updatePlayBackSpeed = () => {
$('video').playbackRate = playbackSpeed;
$('video').playbackRate = playbackSpeed;
const playbackSpeedElement = $("#playback-speed-value");
if (playbackSpeedElement) {
playbackSpeedElement.innerHTML = playbackSpeed;
}
const playbackSpeedElement = $('#playback-speed-value');
if (playbackSpeedElement) {
playbackSpeedElement.innerHTML = playbackSpeed;
}
};
let menu;
const setupSliderListener = singleton(() => {
$('#playback-speed-slider').addEventListener('immediate-value-changed', e => {
playbackSpeed = e.detail.value || MIN_PLAYBACK_SPEED;
if (isNaN(playbackSpeed)) {
playbackSpeed = 1;
}
updatePlayBackSpeed();
})
$('#playback-speed-slider').addEventListener('immediate-value-changed', (e) => {
playbackSpeed = e.detail.value || MIN_PLAYBACK_SPEED;
if (isNaN(playbackSpeed)) {
playbackSpeed = 1;
}
updatePlayBackSpeed();
});
});
const observePopupContainer = () => {
const observer = new MutationObserver(() => {
if (!menu) {
menu = getSongMenu();
}
const observer = new MutationObserver(() => {
if (!menu) {
menu = getSongMenu();
}
if (menu && menu.parentElement.eventSink_?.matches('ytmusic-menu-renderer.ytmusic-player-bar') && !menu.contains(slider)) {
menu.prepend(slider);
setupSliderListener();
}
});
if (menu && menu.parentElement.eventSink_?.matches('ytmusic-menu-renderer.ytmusic-player-bar') && !menu.contains(slider)) {
menu.prepend(slider);
setupSliderListener();
}
});
observer.observe($('ytmusic-popup-container'), {
childList: true,
subtree: true,
});
observer.observe($('ytmusic-popup-container'), {
childList: true,
subtree: true,
});
};
const observeVideo = () => {
$('video').addEventListener('ratechange', forcePlaybackRate)
$('video').addEventListener('srcChanged', forcePlaybackRate)
}
$('video').addEventListener('ratechange', forcePlaybackRate);
$('video').addEventListener('srcChanged', forcePlaybackRate);
};
const setupWheelListener = () => {
slider.addEventListener('wheel', e => {
e.preventDefault();
if (isNaN(playbackSpeed)) {
playbackSpeed = 1;
}
// e.deltaY < 0 means wheel-up
playbackSpeed = roundToTwo(e.deltaY < 0 ?
Math.min(playbackSpeed + 0.01, MAX_PLAYBACK_SPEED) :
Math.max(playbackSpeed - 0.01, MIN_PLAYBACK_SPEED)
);
slider.addEventListener('wheel', (e) => {
e.preventDefault();
if (isNaN(playbackSpeed)) {
playbackSpeed = 1;
}
updatePlayBackSpeed();
// update slider position
$('#playback-speed-slider').value = playbackSpeed;
})
}
// E.deltaY < 0 means wheel-up
playbackSpeed = roundToTwo(e.deltaY < 0
? Math.min(playbackSpeed + 0.01, MAX_PLAYBACK_SPEED)
: Math.max(playbackSpeed - 0.01, MIN_PLAYBACK_SPEED),
);
updatePlayBackSpeed();
// Update slider position
$('#playback-speed-slider').value = playbackSpeed;
});
};
function forcePlaybackRate(e) {
if (e.target.playbackRate !== playbackSpeed) {
e.target.playbackRate = playbackSpeed
}
if (e.target.playbackRate !== playbackSpeed) {
e.target.playbackRate = playbackSpeed;
}
}
module.exports = () => {
document.addEventListener('apiLoaded', () => {
observePopupContainer();
observeVideo();
setupWheelListener();
}, { once: true, passive: true })
document.addEventListener('apiLoaded', () => {
observePopupContainer();
observeVideo();
setupWheelListener();
}, { once: true, passive: true });
};

View File

@ -1,88 +1,93 @@
<div
class="style-scope menu-item ytmusic-menu-popup-renderer"
role="option"
tabindex="-1"
aria-disabled="false"
aria-selected="false"
aria-disabled="false"
aria-selected="false"
class="style-scope menu-item ytmusic-menu-popup-renderer"
role="option"
tabindex="-1"
>
<div
id="navigation-endpoint"
class="yt-simple-endpoint style-scope ytmusic-menu-navigation-item-renderer"
tabindex="-1"
>
<tp-yt-paper-slider
id="playback-speed-slider"
class="volume-slider style-scope ytmusic-player-bar on-hover"
style="display: inherit !important"
max="2"
min="0"
step="0.125"
dir="ltr"
title="Playback speed"
aria-label="Playback speed"
role="slider"
tabindex="0"
aria-valuemin="0"
aria-valuemax="2"
aria-valuenow="1"
aria-disabled="false"
value="1"
><!--css-build:shady-->
<div id="sliderContainer" class="style-scope tp-yt-paper-slider">
<div class="bar-container style-scope tp-yt-paper-slider">
<tp-yt-paper-progress
id="sliderBar"
aria-hidden="true"
class="style-scope tp-yt-paper-slider"
role="progressbar"
value="1"
aria-valuenow="1"
aria-valuemin="0"
aria-valuemax="2"
aria-disabled="false"
style="touch-action: none"
><!--css-build:shady-->
<div
class="yt-simple-endpoint style-scope ytmusic-menu-navigation-item-renderer"
id="navigation-endpoint"
tabindex="-1"
>
<tp-yt-paper-slider
aria-disabled="false"
aria-label="Playback speed"
aria-valuemax="2"
aria-valuemin="0"
aria-valuenow="1"
class="volume-slider style-scope ytmusic-player-bar on-hover"
dir="ltr"
id="playback-speed-slider"
max="2"
min="0"
role="slider"
step="0.125"
style="display: inherit !important"
tabindex="0"
title="Playback speed"
value="1"
><!--css-build:shady-->
<div class="style-scope tp-yt-paper-slider" id="sliderContainer">
<div class="bar-container style-scope tp-yt-paper-slider">
<tp-yt-paper-progress
aria-disabled="false"
aria-hidden="true"
aria-valuemax="2"
aria-valuemin="0"
aria-valuenow="1"
class="style-scope tp-yt-paper-slider"
id="sliderBar"
role="progressbar"
style="touch-action: none"
value="1"
><!--css-build:shady-->
<div
id="progressContainer"
class="style-scope tp-yt-paper-progress"
>
<div
id="secondaryProgress"
class="style-scope tp-yt-paper-progress"
hidden="true"
style="transform: scaleX(0)"
></div>
<div
id="primaryProgress"
class="style-scope tp-yt-paper-progress"
style="transform: scaleX(0.5)"
></div>
</div>
</tp-yt-paper-progress>
</div>
<dom-if class="style-scope tp-yt-paper-slider"
><template is="dom-if"></template
></dom-if>
<div
id="sliderKnob"
class="slider-knob style-scope tp-yt-paper-slider"
style="left: 50%; touch-action: none"
>
<div
class="slider-knob-inner style-scope tp-yt-paper-slider"
value="1"
></div>
</div>
</div>
<dom-if class="style-scope tp-yt-paper-slider"
><template is="dom-if"></template></dom-if
></tp-yt-paper-slider>
<div
class="text style-scope ytmusic-menu-navigation-item-renderer"
id="ytmcustom-playback-speed"
>
Speed (<span id="playback-speed-value">1</span>)
</div>
</div>
<div
class="style-scope tp-yt-paper-progress"
id="progressContainer"
>
<div
class="style-scope tp-yt-paper-progress"
hidden="true"
id="secondaryProgress"
style="transform: scaleX(0)"
></div>
<div
class="style-scope tp-yt-paper-progress"
id="primaryProgress"
style="transform: scaleX(0.5)"
></div>
</div>
</tp-yt-paper-progress>
</div>
<dom-if class="style-scope tp-yt-paper-slider"
>
<template is="dom-if"></template
>
</dom-if>
<div
class="slider-knob style-scope tp-yt-paper-slider"
id="sliderKnob"
style="left: 50%; touch-action: none"
>
<div
class="slider-knob-inner style-scope tp-yt-paper-slider"
value="1"
></div>
</div>
</div>
<dom-if class="style-scope tp-yt-paper-slider"
>
<template is="dom-if"></template>
</dom-if
>
</tp-yt-paper-slider>
<div
class="text style-scope ytmusic-menu-navigation-item-renderer"
id="ytmcustom-playback-speed"
>
Speed (<span id="playback-speed-value">1</span>)
</div>
</div>
</div>

View File

@ -1,5 +1,6 @@
const { injectCSS } = require("../utils");
const path = require("path");
const { injectCSS } = require('../utils');
const path = require('node:path');
/*
This is used to determine if plugin is actually active
@ -10,15 +11,16 @@ let enabled = false;
const { globalShortcut } = require('electron');
module.exports = (win, options) => {
enabled = true;
injectCSS(win.webContents, path.join(__dirname, "volume-hud.css"));
enabled = true;
injectCSS(win.webContents, path.join(__dirname, 'volume-hud.css'));
if (options.globalShortcuts?.volumeUp) {
globalShortcut.register((options.globalShortcuts.volumeUp), () => win.webContents.send('changeVolume', true));
}
if (options.globalShortcuts?.volumeDown) {
globalShortcut.register((options.globalShortcuts.volumeDown), () => win.webContents.send('changeVolume', false));
}
}
if (options.globalShortcuts?.volumeUp) {
globalShortcut.register((options.globalShortcuts.volumeUp), () => win.webContents.send('changeVolume', true));
}
if (options.globalShortcuts?.volumeDown) {
globalShortcut.register((options.globalShortcuts.volumeDown), () => win.webContents.send('changeVolume', false));
}
};
module.exports.enabled = () => enabled;

View File

@ -1,232 +1,245 @@
const { ipcRenderer } = require("electron");
const { ipcRenderer } = require('electron');
const { setOptions, setMenuOptions, isEnabled } = require("../../config/plugins");
const { setOptions, setMenuOptions, isEnabled } = require('../../config/plugins');
function $(selector) { return document.querySelector(selector); }
function $(selector) {
return document.querySelector(selector);
}
const { debounce } = require("../../providers/decorators");
const { debounce } = require('../../providers/decorators');
let api, options;
let api;
let options;
module.exports = (_options) => {
options = _options;
document.addEventListener('apiLoaded', e => {
api = e.detail;
ipcRenderer.on('changeVolume', (_, toIncrease) => changeVolume(toIncrease));
ipcRenderer.on('setVolume', (_, value) => setVolume(value));
firstRun();
}, { once: true, passive: true })
options = _options;
document.addEventListener('apiLoaded', (e) => {
api = e.detail;
ipcRenderer.on('changeVolume', (_, toIncrease) => changeVolume(toIncrease));
ipcRenderer.on('setVolume', (_, value) => setVolume(value));
firstRun();
}, { once: true, passive: true });
};
//without this function it would rewrite config 20 time when volume change by 20
// Without this function it would rewrite config 20 time when volume change by 20
const writeOptions = debounce(() => {
setOptions("precise-volume", options);
setOptions('precise-volume', options);
}, 1000);
module.exports.moveVolumeHud = debounce((showVideo) => {
const volumeHud = $("#volumeHud");
if (!volumeHud) return;
volumeHud.style.top = showVideo
? `${($("ytmusic-player").clientHeight - $("video").clientHeight) / 2}px`
: 0;
const volumeHud = $('#volumeHud');
if (!volumeHud) {
return;
}
volumeHud.style.top = showVideo
? `${($('ytmusic-player').clientHeight - $('video').clientHeight) / 2}px`
: 0;
}, 250);
const hideVolumeHud = debounce((volumeHud) => {
volumeHud.style.opacity = 0;
volumeHud.style.opacity = 0;
}, 2000);
const hideVolumeSlider = debounce((slider) => {
slider.classList.remove("on-hover");
slider.classList.remove('on-hover');
}, 2500);
/** Restore saved volume and setup tooltip */
function firstRun() {
if (typeof options.savedVolume === "number") {
// Set saved volume as tooltip
setTooltip(options.savedVolume);
if (typeof options.savedVolume === 'number') {
// Set saved volume as tooltip
setTooltip(options.savedVolume);
if (api.getVolume() !== options.savedVolume) {
api.setVolume(options.savedVolume);
}
}
if (api.getVolume() !== options.savedVolume) {
api.setVolume(options.savedVolume);
}
}
setupPlaybar();
setupPlaybar();
setupLocalArrowShortcuts();
setupLocalArrowShortcuts();
const noVid = $("#main-panel")?.computedStyleMap().get("display").value === "none";
injectVolumeHud(noVid);
if (!noVid) {
setupVideoPlayerOnwheel();
if (!isEnabled('video-toggle')) {
//video-toggle handles hud positioning on its own
const videoMode = () => api.getPlayerResponse().videoDetails?.musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV';
$("video").addEventListener("srcChanged", () => moveVolumeHud(videoMode()));
}
}
const noVid = $('#main-panel')?.computedStyleMap().get('display').value === 'none';
injectVolumeHud(noVid);
if (!noVid) {
setupVideoPlayerOnwheel();
if (!isEnabled('video-toggle')) {
// Video-toggle handles hud positioning on its own
const videoMode = () => api.getPlayerResponse().videoDetails?.musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV';
$('video').addEventListener('srcChanged', () => moveVolumeHud(videoMode()));
}
}
// Change options from renderer to keep sync
ipcRenderer.on("setOptions", (_event, newOptions = {}) => {
Object.assign(options, newOptions)
setMenuOptions("precise-volume", options);
});
// Change options from renderer to keep sync
ipcRenderer.on('setOptions', (_event, newOptions = {}) => {
Object.assign(options, newOptions);
setMenuOptions('precise-volume', options);
});
}
function injectVolumeHud(noVid) {
if (noVid) {
const position = "top: 18px; right: 60px;";
const mainStyle = "font-size: xx-large;";
if (noVid) {
const position = 'top: 18px; right: 60px;';
const mainStyle = 'font-size: xx-large;';
$(".center-content.ytmusic-nav-bar").insertAdjacentHTML("beforeend",
`<span id="volumeHud" style="${position + mainStyle}"></span>`)
} else {
const position = `top: 10px; left: 10px;`;
const mainStyle = "font-size: xxx-large; webkit-text-stroke: 1px black; font-weight: 600;";
$('.center-content.ytmusic-nav-bar').insertAdjacentHTML('beforeend',
`<span id="volumeHud" style="${position + mainStyle}"></span>`);
} else {
const position = 'top: 10px; left: 10px;';
const mainStyle = 'font-size: xxx-large; webkit-text-stroke: 1px black; font-weight: 600;';
$("#song-video").insertAdjacentHTML('afterend',
`<span id="volumeHud" style="${position + mainStyle}"></span>`)
}
$('#song-video').insertAdjacentHTML('afterend',
`<span id="volumeHud" style="${position + mainStyle}"></span>`);
}
}
function showVolumeHud(volume) {
const volumeHud = $("#volumeHud");
if (!volumeHud) return;
const volumeHud = $('#volumeHud');
if (!volumeHud) {
return;
}
volumeHud.textContent = `${volume}%`;
volumeHud.style.opacity = 1;
volumeHud.textContent = `${volume}%`;
volumeHud.style.opacity = 1;
hideVolumeHud(volumeHud);
hideVolumeHud(volumeHud);
}
/** Add onwheel event to video player */
function setupVideoPlayerOnwheel() {
$("#main-panel").addEventListener("wheel", event => {
event.preventDefault();
// Event.deltaY < 0 means wheel-up
changeVolume(event.deltaY < 0);
});
$('#main-panel').addEventListener('wheel', (event) => {
event.preventDefault();
// Event.deltaY < 0 means wheel-up
changeVolume(event.deltaY < 0);
});
}
function saveVolume(volume) {
options.savedVolume = volume;
writeOptions();
options.savedVolume = volume;
writeOptions();
}
/** Add onwheel event to play bar and also track if play bar is hovered*/
/** Add onwheel event to play bar and also track if play bar is hovered */
function setupPlaybar() {
const playerbar = $("ytmusic-player-bar");
const playerbar = $('ytmusic-player-bar');
playerbar.addEventListener("wheel", event => {
event.preventDefault();
// Event.deltaY < 0 means wheel-up
changeVolume(event.deltaY < 0);
});
playerbar.addEventListener('wheel', (event) => {
event.preventDefault();
// Event.deltaY < 0 means wheel-up
changeVolume(event.deltaY < 0);
});
// Keep track of mouse position for showVolumeSlider()
playerbar.addEventListener("mouseenter", () => {
playerbar.classList.add("on-hover");
});
// Keep track of mouse position for showVolumeSlider()
playerbar.addEventListener('mouseenter', () => {
playerbar.classList.add('on-hover');
});
playerbar.addEventListener("mouseleave", () => {
playerbar.classList.remove("on-hover");
});
playerbar.addEventListener('mouseleave', () => {
playerbar.classList.remove('on-hover');
});
setupSliderObserver();
setupSliderObserver();
}
/** Save volume + Update the volume tooltip when volume-slider is manually changed */
function setupSliderObserver() {
const sliderObserver = new MutationObserver(mutations => {
for (const mutation of mutations) {
// This checks that volume-slider was manually set
if (mutation.oldValue !== mutation.target.value &&
(typeof options.savedVolume !== "number" || Math.abs(options.savedVolume - mutation.target.value) > 4)) {
// Diff>4 means it was manually set
setTooltip(mutation.target.value);
saveVolume(mutation.target.value);
}
}
});
const sliderObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
// This checks that volume-slider was manually set
if (mutation.oldValue !== mutation.target.value
&& (typeof options.savedVolume !== 'number' || Math.abs(options.savedVolume - mutation.target.value) > 4)) {
// Diff>4 means it was manually set
setTooltip(mutation.target.value);
saveVolume(mutation.target.value);
}
}
});
// Observing only changes in 'value' of volume-slider
sliderObserver.observe($("#volume-slider"), {
attributeFilter: ["value"],
attributeOldValue: true
});
// Observing only changes in 'value' of volume-slider
sliderObserver.observe($('#volume-slider'), {
attributeFilter: ['value'],
attributeOldValue: true,
});
}
function setVolume(value) {
api.setVolume(value);
// Save the new volume
saveVolume(value);
api.setVolume(value);
// Save the new volume
saveVolume(value);
// change slider position (important)
updateVolumeSlider();
// Change slider position (important)
updateVolumeSlider();
// Change tooltips to new value
setTooltip(value);
// Show volume slider
showVolumeSlider();
// Show volume HUD
showVolumeHud(value);
// Change tooltips to new value
setTooltip(value);
// Show volume slider
showVolumeSlider();
// Show volume HUD
showVolumeHud(value);
}
/** if (toIncrease = false) then volume decrease */
/** If (toIncrease = false) then volume decrease */
function changeVolume(toIncrease) {
// Apply volume change if valid
const steps = Number(options.steps || 1);
setVolume(toIncrease ?
Math.min(api.getVolume() + steps, 100) :
Math.max(api.getVolume() - steps, 0));
// Apply volume change if valid
const steps = Number(options.steps || 1);
setVolume(toIncrease
? Math.min(api.getVolume() + steps, 100)
: Math.max(api.getVolume() - steps, 0));
}
function updateVolumeSlider() {
// Slider value automatically rounds to multiples of 5
for (const slider of ["#volume-slider", "#expand-volume-slider"]) {
$(slider).value =
options.savedVolume > 0 && options.savedVolume < 5
? 5
: options.savedVolume;
}
// Slider value automatically rounds to multiples of 5
for (const slider of ['#volume-slider', '#expand-volume-slider']) {
$(slider).value
= options.savedVolume > 0 && options.savedVolume < 5
? 5
: options.savedVolume;
}
}
function showVolumeSlider() {
const slider = $("#volume-slider");
// This class display the volume slider if not in minimized mode
slider.classList.add("on-hover");
hideVolumeSlider(slider);
const slider = $('#volume-slider');
// This class display the volume slider if not in minimized mode
slider.classList.add('on-hover');
hideVolumeSlider(slider);
}
// Set new volume as tooltip for volume slider and icon + expanding slider (appears when window size is small)
const tooltipTargets = [
"#volume-slider",
"tp-yt-paper-icon-button.volume",
"#expand-volume-slider",
"#expand-volume"
'#volume-slider',
'tp-yt-paper-icon-button.volume',
'#expand-volume-slider',
'#expand-volume',
];
function setTooltip(volume) {
for (target of tooltipTargets) {
$(target).title = `${volume}%`;
}
for (target of tooltipTargets) {
$(target).title = `${volume}%`;
}
}
function setupLocalArrowShortcuts() {
if (options.arrowsShortcut) {
window.addEventListener('keydown', (event) => {
if ($('ytmusic-search-box').opened) return;
switch (event.code) {
case "ArrowUp":
event.preventDefault();
changeVolume(true);
break;
case "ArrowDown":
event.preventDefault();
changeVolume(false);
break;
}
});
}
if (options.arrowsShortcut) {
window.addEventListener('keydown', (event) => {
if ($('ytmusic-search-box').opened) {
return;
}
switch (event.code) {
case 'ArrowUp': {
event.preventDefault();
changeVolume(true);
break;
}
case 'ArrowDown': {
event.preventDefault();
changeVolume(false);
break;
}
}
});
}
}

View File

@ -1,82 +1,86 @@
const { enabled } = require("./back");
const { setMenuOptions } = require("../../config/plugins");
const prompt = require("custom-electron-prompt");
const promptOptions = require("../../providers/prompt-options");
const prompt = require('custom-electron-prompt');
const { enabled } = require('./back');
const { setMenuOptions } = require('../../config/plugins');
const promptOptions = require('../../providers/prompt-options');
function changeOptions(changedOptions, options, win) {
for (option in changedOptions) {
options[option] = changedOptions[option];
}
// Dynamically change setting if plugin is enabled
if (enabled()) {
win.webContents.send("setOptions", changedOptions);
} else { // Fallback to usual method if disabled
setMenuOptions("precise-volume", options);
}
for (option in changedOptions) {
options[option] = changedOptions[option];
}
// Dynamically change setting if plugin is enabled
if (enabled()) {
win.webContents.send('setOptions', changedOptions);
} else { // Fallback to usual method if disabled
setMenuOptions('precise-volume', options);
}
}
module.exports = (win, options) => [
{
label: "Local Arrowkeys Controls",
type: "checkbox",
checked: !!options.arrowsShortcut,
click: item => {
changeOptions({ arrowsShortcut: item.checked }, options, win);
}
},
{
label: "Global Hotkeys",
type: "checkbox",
checked: !!options.globalShortcuts.volumeUp || !!options.globalShortcuts.volumeDown,
click: item => promptGlobalShortcuts(win, options, item)
},
{
label: "Set Custom Volume Steps",
click: () => promptVolumeSteps(win, options)
}
{
label: 'Local Arrowkeys Controls',
type: 'checkbox',
checked: Boolean(options.arrowsShortcut),
click(item) {
changeOptions({ arrowsShortcut: item.checked }, options, win);
},
},
{
label: 'Global Hotkeys',
type: 'checkbox',
checked: Boolean(options.globalShortcuts.volumeUp) || Boolean(options.globalShortcuts.volumeDown),
click: (item) => promptGlobalShortcuts(win, options, item),
},
{
label: 'Set Custom Volume Steps',
click: () => promptVolumeSteps(win, options),
},
];
// Helper function for globalShortcuts prompt
const kb = (label_, value_, default_) => { return { value: value_, label: label_, default: default_ || undefined }; };
const kb = (label_, value_, default_) => ({ value: value_, label: label_, default: default_ || undefined });
async function promptVolumeSteps(win, options) {
const output = await prompt({
title: "Volume Steps",
label: "Choose Volume Increase/Decrease Steps",
value: options.steps || 1,
type: "counter",
counterOptions: { minimum: 0, maximum: 100, multiFire: true },
width: 380,
...promptOptions()
}, win)
const output = await prompt({
title: 'Volume Steps',
label: 'Choose Volume Increase/Decrease Steps',
value: options.steps || 1,
type: 'counter',
counterOptions: { minimum: 0, maximum: 100, multiFire: true },
width: 380,
...promptOptions(),
}, win);
if (output || output === 0) { // 0 is somewhat valid
changeOptions({ steps: output}, options, win);
}
if (output || output === 0) { // 0 is somewhat valid
changeOptions({ steps: output }, options, win);
}
}
async function promptGlobalShortcuts(win, options, item) {
const output = await prompt({
title: "Global Volume Keybinds",
label: "Choose Global Volume Keybinds:",
type: "keybind",
keybindOptions: [
kb("Increase Volume", "volumeUp", options.globalShortcuts?.volumeUp),
kb("Decrease Volume", "volumeDown", options.globalShortcuts?.volumeDown)
],
...promptOptions()
}, win)
const output = await prompt({
title: 'Global Volume Keybinds',
label: 'Choose Global Volume Keybinds:',
type: 'keybind',
keybindOptions: [
kb('Increase Volume', 'volumeUp', options.globalShortcuts?.volumeUp),
kb('Decrease Volume', 'volumeDown', options.globalShortcuts?.volumeDown),
],
...promptOptions(),
}, win);
if (output) {
let newGlobalShortcuts = {};
for (const { value, accelerator } of output) {
newGlobalShortcuts[value] = accelerator;
}
changeOptions({ globalShortcuts: newGlobalShortcuts }, options, win);
if (output) {
const newGlobalShortcuts = {};
for (const { value, accelerator } of output) {
newGlobalShortcuts[value] = accelerator;
}
item.checked = !!options.globalShortcuts.volumeUp || !!options.globalShortcuts.volumeDown;
} else {
// Reset checkbox if prompt was canceled
item.checked = !item.checked;
}
changeOptions({ globalShortcuts: newGlobalShortcuts }, options, win);
item.checked = Boolean(options.globalShortcuts.volumeUp) || Boolean(options.globalShortcuts.volumeDown);
} else {
// Reset checkbox if prompt was canceled
item.checked = !item.checked;
}
}

View File

@ -1,32 +1,32 @@
const is = require("electron-is");
const is = require('electron-is');
let ignored = {
id: ["volume-slider", "expand-volume-slider"],
types: ["mousewheel", "keydown", "keyup"]
id: ['volume-slider', 'expand-volume-slider'],
types: ['mousewheel', 'keydown', 'keyup'],
};
function overrideAddEventListener() {
// Save native addEventListener
Element.prototype._addEventListener = Element.prototype.addEventListener;
// Override addEventListener to Ignore specific events in volume-slider
Element.prototype.addEventListener = function (type, listener, useCapture = false) {
if (!(
ignored.id.includes(this.id) &&
ignored.types.includes(type)
)) {
this._addEventListener(type, listener, useCapture);
} else if (is.dev()) {
console.log(`Ignoring event: "${this.id}.${type}()"`);
}
};
// Save native addEventListener
Element.prototype._addEventListener = Element.prototype.addEventListener;
// Override addEventListener to Ignore specific events in volume-slider
Element.prototype.addEventListener = function (type, listener, useCapture = false) {
if (!(
ignored.id.includes(this.id)
&& ignored.types.includes(type)
)) {
this._addEventListener(type, listener, useCapture);
} else if (is.dev()) {
console.log(`Ignoring event: "${this.id}.${type}()"`);
}
};
}
module.exports = () => {
overrideAddEventListener();
// Restore original function after finished loading to avoid keeping Element.prototype altered
window.addEventListener('load', () => {
Element.prototype.addEventListener = Element.prototype._addEventListener;
Element.prototype._addEventListener = undefined;
ignored = undefined;
}, { once: true });
overrideAddEventListener();
// Restore original function after finished loading to avoid keeping Element.prototype altered
window.addEventListener('load', () => {
Element.prototype.addEventListener = Element.prototype._addEventListener;
Element.prototype._addEventListener = undefined;
ignored = undefined;
}, { once: true });
};

View File

@ -1,11 +1,11 @@
#volumeHud {
z-index: 999;
position: absolute;
transition: opacity 0.6s;
pointer-events: none;
padding: 10px;
z-index: 999;
position: absolute;
transition: opacity 0.6s;
pointer-events: none;
padding: 10px;
}
ytmusic-player[player-ui-state_="MINIPLAYER"] #volumeHud {
top: 0 !important;
top: 0 !important;
}

View File

@ -1,15 +1,13 @@
const { ipcMain, dialog } = require("electron");
const { ipcMain, dialog } = require('electron');
module.exports = () => {
ipcMain.handle('qualityChanger', async (_, qualityLabels, currentIndex) => {
return await dialog.showMessageBox({
type: "question",
buttons: qualityLabels,
defaultId: currentIndex,
title: "Choose Video Quality",
message: "Choose Video Quality:",
detail: `Current Quality: ${qualityLabels[currentIndex]}`,
cancelId: -1
})
})
ipcMain.handle('qualityChanger', async (_, qualityLabels, currentIndex) => await dialog.showMessageBox({
type: 'question',
buttons: qualityLabels,
defaultId: currentIndex,
title: 'Choose Video Quality',
message: 'Choose Video Quality:',
detail: `Current Quality: ${qualityLabels[currentIndex]}`,
cancelId: -1,
}));
};

View File

@ -1,34 +1,39 @@
const { ElementFromFile, templatePath } = require("../utils");
const { ipcRenderer } = require("electron");
const { ipcRenderer } = require('electron');
function $(selector) { return document.querySelector(selector); }
const { ElementFromFile, templatePath } = require('../utils');
function $(selector) {
return document.querySelector(selector);
}
const qualitySettingsButton = ElementFromFile(
templatePath(__dirname, "qualitySettingsTemplate.html")
templatePath(__dirname, 'qualitySettingsTemplate.html'),
);
module.exports = () => {
document.addEventListener('apiLoaded', setup, { once: true, passive: true });
}
document.addEventListener('apiLoaded', setup, { once: true, passive: true });
};
function setup(event) {
const api = event.detail;
const api = event.detail;
$('.top-row-buttons.ytmusic-player').prepend(qualitySettingsButton);
$('.top-row-buttons.ytmusic-player').prepend(qualitySettingsButton);
qualitySettingsButton.onclick = function chooseQuality() {
setTimeout(() => $('#player').click());
qualitySettingsButton.addEventListener('click', function chooseQuality() {
setTimeout(() => $('#player').click());
const qualityLevels = api.getAvailableQualityLevels();
const qualityLevels = api.getAvailableQualityLevels();
const currentIndex = qualityLevels.indexOf(api.getPlaybackQuality());
const currentIndex = qualityLevels.indexOf(api.getPlaybackQuality());
ipcRenderer.invoke('qualityChanger', api.getAvailableQualityLabels(), currentIndex).then(promise => {
if (promise.response === -1) return;
const newQuality = qualityLevels[promise.response];
api.setPlaybackQualityRange(newQuality);
api.setPlaybackQuality(newQuality)
});
}
ipcRenderer.invoke('qualityChanger', api.getAvailableQualityLabels(), currentIndex).then((promise) => {
if (promise.response === -1) {
return;
}
const newQuality = qualityLevels[promise.response];
api.setPlaybackQualityRange(newQuality);
api.setPlaybackQuality(newQuality);
});
});
}

View File

@ -1,13 +1,16 @@
<tp-yt-paper-icon-button class="player-quality-button style-scope ytmusic-player" icon="yt-icons:settings"
title="Open player quality changer" aria-label="Open player quality changer" role="button" tabindex="0" aria-disabled="false">
<tp-yt-iron-icon id="icon" class="style-scope tp-yt-paper-icon-button"><svg viewBox="0 0 24 24"
preserveAspectRatio="xMidYMid meet" focusable="false" class="style-scope yt-icon"
style="pointer-events: none; display: block; width: 100%; height: 100%;">
<g class="style-scope yt-icon">
<path
d="M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.1-1.65c.2-.15.25-.42.13-.64l-2-3.46c-.12-.22-.4-.3-.6-.22l-2.5 1c-.52-.4-1.08-.73-1.7-.98l-.37-2.65c-.06-.24-.27-.42-.5-.42h-4c-.27 0-.48.18-.5.42l-.4 2.65c-.6.25-1.17.6-1.7.98l-2.48-1c-.23-.1-.5 0-.6.22l-2 3.46c-.14.22-.08.5.1.64l2.12 1.65c-.04.32-.07.65-.07.98s.02.66.06.98l-2.1 1.65c-.2.15-.25.42-.13.64l2 3.46c.12.22.4.3.6.22l2.5-1c.52.4 1.08.73 1.7.98l.37 2.65c.04.24.25.42.5.42h4c.25 0 .46-.18.5-.42l.37-2.65c.6-.25 1.17-.6 1.7-.98l2.48 1c.23.1.5 0 .6-.22l2-3.46c.13-.22.08-.5-.1-.64l-2.12-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z"
class="style-scope yt-icon"></path>
</g>
</svg>
</tp-yt-iron-icon>
</tp-yt-paper-icon-button>
<tp-yt-paper-icon-button aria-disabled="false" aria-label="Open player quality changer"
class="player-quality-button style-scope ytmusic-player" icon="yt-icons:settings" role="button"
tabindex="0" title="Open player quality changer">
<tp-yt-iron-icon class="style-scope tp-yt-paper-icon-button" id="icon">
<svg class="style-scope yt-icon"
focusable="false" preserveAspectRatio="xMidYMid meet"
style="pointer-events: none; display: block; width: 100%; height: 100%;"
viewBox="0 0 24 24">
<g class="style-scope yt-icon">
<path
class="style-scope yt-icon"
d="M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.1-1.65c.2-.15.25-.42.13-.64l-2-3.46c-.12-.22-.4-.3-.6-.22l-2.5 1c-.52-.4-1.08-.73-1.7-.98l-.37-2.65c-.06-.24-.27-.42-.5-.42h-4c-.27 0-.48.18-.5.42l-.4 2.65c-.6.25-1.17.6-1.7.98l-2.48-1c-.23-.1-.5 0-.6.22l-2 3.46c-.14.22-.08.5.1.64l2.12 1.65c-.04.32-.07.65-.07.98s.02.66.06.98l-2.1 1.65c-.2.15-.25.42-.13.64l2 3.46c.12.22.4.3.6.22l2.5-1c.52.4 1.08.73 1.7.98l.37 2.65c.04.24.25.42.5.42h4c.25 0 .46-.18.5-.42l.37-2.65c.6-.25 1.17-.6 1.7-.98l2.48 1c.23.1.5 0 .6-.22l2-3.46c.13-.22.08-.5-.1-.64l-2.12-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z"></path>
</g>
</svg>
</tp-yt-iron-icon>
</tp-yt-paper-icon-button>

View File

@ -1,62 +1,66 @@
const { globalShortcut } = require("electron");
const is = require("electron-is");
const electronLocalshortcut = require("electron-localshortcut");
const getSongControls = require("../../providers/song-controls");
const registerMPRIS = require("./mpris");
const { globalShortcut } = require('electron');
const is = require('electron-is');
const electronLocalshortcut = require('electron-localshortcut');
const registerMPRIS = require('./mpris');
const getSongControls = require('../../providers/song-controls');
function _registerGlobalShortcut(webContents, shortcut, action) {
globalShortcut.register(shortcut, () => {
action(webContents);
});
globalShortcut.register(shortcut, () => {
action(webContents);
});
}
function _registerLocalShortcut(win, shortcut, action) {
electronLocalshortcut.register(win, shortcut, () => {
action(win.webContents);
});
electronLocalshortcut.register(win, shortcut, () => {
action(win.webContents);
});
}
function registerShortcuts(win, options) {
const songControls = getSongControls(win);
const { playPause, next, previous, search } = songControls;
const songControls = getSongControls(win);
const { playPause, next, previous, search } = songControls;
if (options.overrideMediaKeys) {
_registerGlobalShortcut(win.webContents, "MediaPlayPause", playPause);
_registerGlobalShortcut(win.webContents, "MediaNextTrack", next);
_registerGlobalShortcut(win.webContents, "MediaPreviousTrack", previous);
}
if (options.overrideMediaKeys) {
_registerGlobalShortcut(win.webContents, 'MediaPlayPause', playPause);
_registerGlobalShortcut(win.webContents, 'MediaNextTrack', next);
_registerGlobalShortcut(win.webContents, 'MediaPreviousTrack', previous);
}
_registerLocalShortcut(win, "CommandOrControl+F", search);
_registerLocalShortcut(win, "CommandOrControl+L", search);
_registerLocalShortcut(win, 'CommandOrControl+F', search);
_registerLocalShortcut(win, 'CommandOrControl+L', search);
if (is.linux()) registerMPRIS(win);
if (is.linux()) {
registerMPRIS(win);
}
const { global, local } = options;
const shortcutOptions = { global, local };
const { global, local } = options;
const shortcutOptions = { global, local };
for (const optionType in shortcutOptions) {
registerAllShortcuts(shortcutOptions[optionType], optionType);
}
for (const optionType in shortcutOptions) {
registerAllShortcuts(shortcutOptions[optionType], optionType);
}
function registerAllShortcuts(container, type) {
for (const action in container) {
if (!container[action]) {
continue; // Action accelerator is empty
}
function registerAllShortcuts(container, type) {
for (const action in container) {
if (!container[action]) {
continue; // Action accelerator is empty
}
console.debug(`Registering ${type} shortcut`, container[action], ":", action);
if (!songControls[action]) {
console.warn("Invalid action", action);
continue;
}
console.debug(`Registering ${type} shortcut`, container[action], ':', action);
if (!songControls[action]) {
console.warn('Invalid action', action);
continue;
}
if (type === "global") {
_registerGlobalShortcut(win.webContents, container[action], songControls[action]);
} else { // type === "local"
_registerLocalShortcut(win, local[action], songControls[action]);
}
}
}
if (type === 'global') {
_registerGlobalShortcut(win.webContents, container[action], songControls[action]);
} else { // Type === "local"
_registerLocalShortcut(win, local[action], songControls[action]);
}
}
}
}
module.exports = registerShortcuts;

View File

@ -1,53 +1,56 @@
const { setMenuOptions } = require("../../config/plugins");
const prompt = require("custom-electron-prompt");
const promptOptions = require("../../providers/prompt-options");
const prompt = require('custom-electron-prompt');
const { setMenuOptions } = require('../../config/plugins');
const promptOptions = require('../../providers/prompt-options');
module.exports = (win, options) => [
{
label: "Set Global Song Controls",
click: () => promptKeybind(options, win)
},
{
label: "Override MediaKeys",
type: "checkbox",
checked: options.overrideMediaKeys,
click: item => setOption(options, "overrideMediaKeys", item.checked)
}
{
label: 'Set Global Song Controls',
click: () => promptKeybind(options, win),
},
{
label: 'Override MediaKeys',
type: 'checkbox',
checked: options.overrideMediaKeys,
click: (item) => setOption(options, 'overrideMediaKeys', item.checked),
},
];
function setOption(options, key = null, newValue = null) {
if (key && newValue !== null) {
options[key] = newValue;
}
if (key && newValue !== null) {
options[key] = newValue;
}
setMenuOptions("shortcuts", options);
setMenuOptions('shortcuts', options);
}
// Helper function for keybind prompt
const kb = (label_, value_, default_) => { return { value: value_, label: label_, default: default_ }; };
const kb = (label_, value_, default_) => ({ value: value_, label: label_, default: default_ });
async function promptKeybind(options, win) {
const output = await prompt({
title: "Global Keybinds",
label: "Choose Global Keybinds for Songs Control:",
type: "keybind",
keybindOptions: [ // If default=undefined then no default is used
kb("Previous", "previous", options.global?.previous),
kb("Play / Pause", "playPause", options.global?.playPause),
kb("Next", "next", options.global?.next)
],
height: 270,
...promptOptions()
}, win);
const output = await prompt({
title: 'Global Keybinds',
label: 'Choose Global Keybinds for Songs Control:',
type: 'keybind',
keybindOptions: [ // If default=undefined then no default is used
kb('Previous', 'previous', options.global?.previous),
kb('Play / Pause', 'playPause', options.global?.playPause),
kb('Next', 'next', options.global?.next),
],
height: 270,
...promptOptions(),
}, win);
if (output) {
if (!options.global) {
options.global = {};
}
for (const { value, accelerator } of output) {
options.global[value] = accelerator;
}
setOption(options);
}
// else -> pressed cancel
if (output) {
if (!options.global) {
options.global = {};
}
for (const { value, accelerator } of output) {
options.global[value] = accelerator;
}
setOption(options);
}
// Else -> pressed cancel
}

View File

@ -1,164 +1,180 @@
const mpris = require("mpris-service");
const { ipcMain } = require("electron");
const registerCallback = require("../../providers/song-info");
const getSongControls = require("../../providers/song-controls");
const config = require("../../config");
const { ipcMain } = require('electron');
const mpris = require('mpris-service');
const registerCallback = require('../../providers/song-info');
const getSongControls = require('../../providers/song-controls');
const config = require('../../config');
function setupMPRIS() {
const player = mpris({
name: "youtube-music",
identity: "YouTube Music",
canRaise: true,
supportedUriSchemes: ["https"],
supportedMimeTypes: ["audio/mpeg"],
supportedInterfaces: ["player"],
desktopEntry: "youtube-music",
});
const player = mpris({
name: 'youtube-music',
identity: 'YouTube Music',
canRaise: true,
supportedUriSchemes: ['https'],
supportedMimeTypes: ['audio/mpeg'],
supportedInterfaces: ['player'],
desktopEntry: 'youtube-music',
});
return player;
return player;
}
/** @param {Electron.BrowserWindow} win */
function registerMPRIS(win) {
const songControls = getSongControls(win);
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);
const songControls = getSongControls(win);
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);
const seekTo = e => win.webContents.send("seekTo", microToSec(e.position));
const seekBy = o => win.webContents.send("seekBy", microToSec(o));
const seekTo = (e) => win.webContents.send('seekTo', microToSec(e.position));
const seekBy = (o) => win.webContents.send('seekBy', microToSec(o));
const player = setupMPRIS();
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('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)));
ipcMain.on('seeked', (_, t) => player.seeked(secToMicro(t)));
let currentSeconds = 0;
ipcMain.on('timeChanged', (_, t) => currentSeconds = t);
let currentSeconds = 0;
ipcMain.on('timeChanged', (_, t) => currentSeconds = t);
ipcMain.on("repeatChanged", (_, mode) => {
if (mode === "NONE")
player.loopStatus = mpris.LOOP_STATUS_NONE;
else if (mode === "ONE") //MPRIS Playlist and Track Codes are switched to look the same as yt-music icons
player.loopStatus = mpris.LOOP_STATUS_PLAYLIST;
else if (mode === "ALL")
player.loopStatus = mpris.LOOP_STATUS_TRACK;
});
player.on("loopStatus", (status) => {
// switchRepeat cycles between states in that order
const switches = [mpris.LOOP_STATUS_NONE, mpris.LOOP_STATUS_PLAYLIST, mpris.LOOP_STATUS_TRACK];
const currentIndex = switches.indexOf(player.loopStatus);
const targetIndex = switches.indexOf(status);
ipcMain.on('repeatChanged', (_, mode) => {
switch (mode) {
case 'NONE': {
player.loopStatus = mpris.LOOP_STATUS_NONE;
break;
}
// Get a delta in the range [0,2]
const delta = (targetIndex - currentIndex + 3) % 3;
songControls.switchRepeat(delta);
})
case 'ONE': {
player.loopStatus = mpris.LOOP_STATUS_PLAYLIST;
break;
}
player.getPosition = () => secToMicro(currentSeconds)
case 'ALL': {
{
player.loopStatus = mpris.LOOP_STATUS_TRACK;
// No default
}
player.on("raise", () => {
win.setSkipTaskbar(false);
win.show();
});
break;
}
}
});
player.on('loopStatus', (status) => {
// SwitchRepeat cycles between states in that order
const switches = [mpris.LOOP_STATUS_NONE, mpris.LOOP_STATUS_PLAYLIST, mpris.LOOP_STATUS_TRACK];
const currentIndex = switches.indexOf(player.loopStatus);
const targetIndex = switches.indexOf(status);
player.on("play", () => {
if (player.playbackStatus !== mpris.PLAYBACK_STATUS_PLAYING) {
player.playbackStatus = mpris.PLAYBACK_STATUS_PLAYING;
playPause()
}
});
player.on("pause", () => {
if (player.playbackStatus !== mpris.PLAYBACK_STATUS_PAUSED) {
player.playbackStatus = mpris.PLAYBACK_STATUS_PAUSED;
playPause()
}
});
player.on("playpause", () => {
player.playbackStatus = player.playbackStatus === mpris.PLAYBACK_STATUS_PLAYING ? mpris.PLAYBACK_STATUS_PAUSED : mpris.PLAYBACK_STATUS_PLAYING;
playPause();
});
// Get a delta in the range [0,2]
const delta = (targetIndex - currentIndex + 3) % 3;
songControls.switchRepeat(delta);
});
player.on("next", next);
player.on("previous", previous);
player.getPosition = () => secToMicro(currentSeconds);
player.on('seek', seekBy);
player.on('position', seekTo);
player.on('raise', () => {
win.setSkipTaskbar(false);
win.show();
});
player.on('shuffle', (enableShuffle) => {
shuffle();
});
player.on('play', () => {
if (player.playbackStatus !== mpris.PLAYBACK_STATUS_PLAYING) {
player.playbackStatus = mpris.PLAYBACK_STATUS_PLAYING;
playPause();
}
});
player.on('pause', () => {
if (player.playbackStatus !== mpris.PLAYBACK_STATUS_PAUSED) {
player.playbackStatus = mpris.PLAYBACK_STATUS_PAUSED;
playPause();
}
});
player.on('playpause', () => {
player.playbackStatus = player.playbackStatus === mpris.PLAYBACK_STATUS_PLAYING ? mpris.PLAYBACK_STATUS_PAUSED : mpris.PLAYBACK_STATUS_PLAYING;
playPause();
});
let mprisVolNewer = false;
let autoUpdate = false;
ipcMain.on('volumeChanged', (_, newVol) => {
if (parseInt(player.volume * 100) !== newVol) {
if (mprisVolNewer) {
mprisVolNewer = false;
autoUpdate = false;
} else {
autoUpdate = true;
player.volume = parseFloat((newVol / 100).toFixed(2));
mprisVolNewer = false;
autoUpdate = false;
}
}
});
player.on('next', next);
player.on('previous', previous);
player.on('volume', (newVolume) => {
if (config.plugins.isEnabled('precise-volume')) {
// 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) {
mprisVolNewer = true;
autoUpdate = false;
win.webContents.send('setVolume', newVol);
}
}
} else {
// With keyboard shortcuts we can only change the volume in increments of 10, so round it.
let deltaVolume = Math.round((newVolume - player.volume) * 10);
while (deltaVolume !== 0 && deltaVolume > 0) {
volumePlus10();
player.volume = player.volume + 0.1;
deltaVolume--;
}
while (deltaVolume !== 0 && deltaVolume < 0) {
volumeMinus10();
player.volume = player.volume - 0.1;
deltaVolume++;
}
}
});
player.on('seek', seekBy);
player.on('position', seekTo);
registerCallback(songInfo => {
if (player) {
const data = {
'mpris:length': secToMicro(songInfo.songDuration),
'mpris:artUrl': songInfo.imageSrc,
'xesam:title': songInfo.title,
'xesam:url': songInfo.url,
'xesam:artist': [songInfo.artist],
'mpris:trackid': '/'
};
if (songInfo.album) data['xesam:album'] = songInfo.album;
player.metadata = data;
player.seeked(secToMicro(songInfo.elapsedSeconds));
player.playbackStatus = songInfo.isPaused ? mpris.PLAYBACK_STATUS_PAUSED : mpris.PLAYBACK_STATUS_PLAYING;
}
})
player.on('shuffle', (enableShuffle) => {
shuffle();
});
} catch (e) {
console.warn("Error in MPRIS", e);
}
let mprisVolNewer = false;
let autoUpdate = false;
ipcMain.on('volumeChanged', (_, newVol) => {
if (Number.parseInt(player.volume * 100) !== newVol) {
if (mprisVolNewer) {
mprisVolNewer = false;
autoUpdate = false;
} else {
autoUpdate = true;
player.volume = Number.parseFloat((newVol / 100).toFixed(2));
mprisVolNewer = false;
autoUpdate = false;
}
}
});
player.on('volume', (newVolume) => {
if (config.plugins.isEnabled('precise-volume')) {
// With precise volume we can set the volume to the exact value.
const newVol = Number.parseInt(newVolume * 100);
if (Number.parseInt(player.volume * 100) !== newVol && !autoUpdate) {
mprisVolNewer = true;
autoUpdate = false;
win.webContents.send('setVolume', newVol);
}
} else {
// With keyboard shortcuts we can only change the volume in increments of 10, so round it.
let deltaVolume = Math.round((newVolume - player.volume) * 10);
while (deltaVolume !== 0 && deltaVolume > 0) {
volumePlus10();
player.volume += 0.1;
deltaVolume--;
}
while (deltaVolume !== 0 && deltaVolume < 0) {
volumeMinus10();
player.volume -= 0.1;
deltaVolume++;
}
}
});
registerCallback((songInfo) => {
if (player) {
const data = {
'mpris:length': secToMicro(songInfo.songDuration),
'mpris:artUrl': songInfo.imageSrc,
'xesam:title': songInfo.title,
'xesam:url': songInfo.url,
'xesam:artist': [songInfo.artist],
'mpris:trackid': '/',
};
if (songInfo.album) {
data['xesam:album'] = songInfo.album;
}
player.metadata = data;
player.seeked(secToMicro(songInfo.elapsedSeconds));
player.playbackStatus = songInfo.isPaused ? mpris.PLAYBACK_STATUS_PAUSED : mpris.PLAYBACK_STATUS_PLAYING;
}
});
} catch (error) {
console.warn('Error in MPRIS', error);
}
}
module.exports = registerMPRIS;

View File

@ -1,112 +1,114 @@
module.exports = (options) => {
let isSilent = false;
let hasAudioStarted = false;
let isSilent = false;
let hasAudioStarted = false;
const smoothing = 0.1;
const threshold = -100; // dB (-100 = absolute silence, 0 = loudest)
const interval = 2; // ms
const history = 10;
const speakingHistory = Array(history).fill(0);
const smoothing = 0.1;
const threshold = -100; // DB (-100 = absolute silence, 0 = loudest)
const interval = 2; // Ms
const history = 10;
const speakingHistory = Array.from({ length: history }).fill(0);
document.addEventListener(
"audioCanPlay",
(e) => {
const video = document.querySelector("video");
const audioContext = e.detail.audioContext;
const sourceNode = e.detail.audioSource;
document.addEventListener(
'audioCanPlay',
(e) => {
const video = document.querySelector('video');
const { audioContext } = e.detail;
const sourceNode = e.detail.audioSource;
// Use an audio analyser similar to Hark
// https://github.com/otalk/hark/blob/master/hark.bundle.js
const analyser = audioContext.createAnalyser();
analyser.fftSize = 512;
analyser.smoothingTimeConstant = smoothing;
const fftBins = new Float32Array(analyser.frequencyBinCount);
// Use an audio analyser similar to Hark
// https://github.com/otalk/hark/blob/master/hark.bundle.js
const analyser = audioContext.createAnalyser();
analyser.fftSize = 512;
analyser.smoothingTimeConstant = smoothing;
const fftBins = new Float32Array(analyser.frequencyBinCount);
sourceNode.connect(analyser);
analyser.connect(audioContext.destination);
sourceNode.connect(analyser);
analyser.connect(audioContext.destination);
const looper = () => {
setTimeout(() => {
const currentVolume = getMaxVolume(analyser, fftBins);
const looper = () => {
setTimeout(() => {
const currentVolume = getMaxVolume(analyser, fftBins);
let history = 0;
if (currentVolume > threshold && isSilent) {
// trigger quickly, short history
for (
let i = speakingHistory.length - 3;
i < speakingHistory.length;
i++
) {
history += speakingHistory[i];
}
if (history >= 2) {
// Not silent
isSilent = false;
hasAudioStarted = true;
}
} else if (currentVolume < threshold && !isSilent) {
for (let i = 0; i < speakingHistory.length; i++) {
history += speakingHistory[i];
}
if (history == 0) {
// Silent
if (
!(
video.paused ||
video.seeking ||
video.ended ||
video.muted ||
video.volume === 0
)
) {
isSilent = true;
skipSilence();
}
}
}
speakingHistory.shift();
speakingHistory.push(0 + (currentVolume > threshold));
let history = 0;
if (currentVolume > threshold && isSilent) {
// Trigger quickly, short history
for (
let i = speakingHistory.length - 3;
i < speakingHistory.length;
i++
) {
history += speakingHistory[i];
}
looper();
}, interval);
};
looper();
if (history >= 2) {
// Not silent
isSilent = false;
hasAudioStarted = true;
}
} else if (currentVolume < threshold && !isSilent) {
for (const element of speakingHistory) {
history += element;
}
const skipSilence = () => {
if (options.onlySkipBeginning && hasAudioStarted) {
return;
}
if (history == 0 // Silent
if (isSilent && !video.paused) {
video.currentTime += 0.2; // in s
}
};
&& !(
video.paused
|| video.seeking
|| video.ended
|| video.muted
|| video.volume === 0
)
) {
isSilent = true;
skipSilence();
}
}
video.addEventListener("play", function () {
hasAudioStarted = false;
skipSilence();
});
speakingHistory.shift();
speakingHistory.push(0 + (currentVolume > threshold));
video.addEventListener("seeked", function () {
hasAudioStarted = false;
skipSilence();
});
},
{
passive: true,
}
);
looper();
}, interval);
};
looper();
const skipSilence = () => {
if (options.onlySkipBeginning && hasAudioStarted) {
return;
}
if (isSilent && !video.paused) {
video.currentTime += 0.2; // In s
}
};
video.addEventListener('play', () => {
hasAudioStarted = false;
skipSilence();
});
video.addEventListener('seeked', () => {
hasAudioStarted = false;
skipSilence();
});
},
{
passive: true,
},
);
};
function getMaxVolume(analyser, fftBins) {
var maxVolume = -Infinity;
analyser.getFloatFrequencyData(fftBins);
let maxVolume = Number.NEGATIVE_INFINITY;
analyser.getFloatFrequencyData(fftBins);
for (var i = 4, ii = fftBins.length; i < ii; i++) {
if (fftBins[i] > maxVolume && fftBins[i] < 0) {
maxVolume = fftBins[i];
}
}
for (let i = 4, ii = fftBins.length; i < ii; i++) {
if (fftBins[i] > maxVolume && fftBins[i] < 0) {
maxVolume = fftBins[i];
}
}
return maxVolume;
return maxVolume;
}

View File

@ -1,51 +1,53 @@
const fetch = require("node-fetch");
const is = require("electron-is");
const { ipcMain } = require("electron");
const { ipcMain } = require('electron');
const fetch = require('node-fetch');
const is = require('electron-is');
const defaultConfig = require("../../config/defaults");
const { sortSegments } = require("./segments");
const { sortSegments } = require('./segments');
const defaultConfig = require('../../config/defaults');
let videoID;
module.exports = (win, options) => {
const { apiURL, categories } = {
...defaultConfig.plugins.sponsorblock,
...options,
};
const { apiURL, categories } = {
...defaultConfig.plugins.sponsorblock,
...options,
};
ipcMain.on("video-src-changed", async (_, data) => {
videoID = JSON.parse(data)?.videoDetails?.videoId;
const segments = await fetchSegments(apiURL, categories);
win.webContents.send("sponsorblock-skip", segments);
});
ipcMain.on('video-src-changed', async (_, data) => {
videoID = JSON.parse(data)?.videoDetails?.videoId;
const segments = await fetchSegments(apiURL, categories);
win.webContents.send('sponsorblock-skip', segments);
});
};
const fetchSegments = async (apiURL, categories) => {
const sponsorBlockURL = `${apiURL}/api/skipSegments?videoID=${videoID}&categories=${JSON.stringify(
categories
)}`;
try {
const resp = await fetch(sponsorBlockURL, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
redirect: "follow",
});
if (resp.status !== 200) {
return [];
}
const segments = await resp.json();
const sortedSegments = sortSegments(
segments.map((submission) => submission.segment)
);
const sponsorBlockURL = `${apiURL}/api/skipSegments?videoID=${videoID}&categories=${JSON.stringify(
categories,
)}`;
try {
const resp = await fetch(sponsorBlockURL, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
redirect: 'follow',
});
if (resp.status !== 200) {
return [];
}
return sortedSegments;
} catch (e) {
if (is.dev()) {
console.log('error on sponsorblock request:', e);
}
return [];
}
const segments = await resp.json();
const sortedSegments = sortSegments(
segments.map((submission) => submission.segment),
);
return sortedSegments;
} catch (error) {
if (is.dev()) {
console.log('error on sponsorblock request:', error);
}
return [];
}
};

View File

@ -1,31 +1,30 @@
const { ipcRenderer } = require("electron");
const is = require("electron-is");
const { ipcRenderer } = require('electron');
const is = require('electron-is');
let currentSegments = [];
module.exports = () => {
ipcRenderer.on("sponsorblock-skip", (_, segments) => {
currentSegments = segments;
});
ipcRenderer.on('sponsorblock-skip', (_, segments) => {
currentSegments = segments;
});
document.addEventListener('apiLoaded', () => {
const video = document.querySelector('video');
document.addEventListener('apiLoaded', () => {
const video = document.querySelector('video');
video.addEventListener('timeupdate', e => {
currentSegments.forEach((segment) => {
if (
e.target.currentTime >= segment[0] &&
e.target.currentTime < segment[1]
) {
e.target.currentTime = segment[1];
if (is.dev()) {
console.log("SponsorBlock: skipping segment", segment);
}
}
});
})
// Reset segments on song end
video.addEventListener('emptied', () => currentSegments = []);
}, { once: true, passive: true })
video.addEventListener('timeupdate', (e) => {
for (const segment of currentSegments) {
if (
e.target.currentTime >= segment[0]
&& e.target.currentTime < segment[1]
) {
e.target.currentTime = segment[1];
if (is.dev()) {
console.log('SponsorBlock: skipping segment', segment);
}
}
}
});
// Reset segments on song end
video.addEventListener('emptied', () => currentSegments = []);
}, { once: true, passive: true });
};

View File

@ -1,29 +1,30 @@
// Segments are an array [ [start, end], … ]
module.exports.sortSegments = (segments) => {
segments.sort((segment1, segment2) =>
segment1[0] === segment2[0]
? segment1[1] - segment2[1]
: segment1[0] - segment2[0]
);
segments.sort((segment1, segment2) =>
segment1[0] === segment2[0]
? segment1[1] - segment2[1]
: segment1[0] - segment2[0],
);
const compiledSegments = [];
let currentSegment;
const compiledSegments = [];
let currentSegment;
segments.forEach((segment) => {
if (!currentSegment) {
currentSegment = segment;
return;
}
for (const segment of segments) {
if (!currentSegment) {
currentSegment = segment;
continue;
}
if (currentSegment[1] < segment[0]) {
compiledSegments.push(currentSegment);
currentSegment = segment;
return;
}
if (currentSegment[1] < segment[0]) {
compiledSegments.push(currentSegment);
currentSegment = segment;
continue;
}
currentSegment[1] = Math.max(currentSegment[1], segment[1]);
});
compiledSegments.push(currentSegment);
currentSegment[1] = Math.max(currentSegment[1], segment[1]);
}
return compiledSegments;
compiledSegments.push(currentSegment);
return compiledSegments;
};

View File

@ -1,36 +1,36 @@
const { test, expect } = require("@playwright/test");
const { test, expect } = require('@playwright/test');
const { sortSegments } = require("../segments");
const { sortSegments } = require('../segments');
test("Segment sorting", () => {
expect(
sortSegments([
[0, 3],
[7, 8],
[5, 6],
])
).toEqual([
[0, 3],
[5, 6],
[7, 8],
]);
test('Segment sorting', () => {
expect(
sortSegments([
[0, 3],
[7, 8],
[5, 6],
]),
).toEqual([
[0, 3],
[5, 6],
[7, 8],
]);
expect(
sortSegments([
[0, 5],
[6, 8],
[4, 6],
])
).toEqual([[0, 8]]);
expect(
sortSegments([
[0, 5],
[6, 8],
[4, 6],
]),
).toEqual([[0, 8]]);
expect(
sortSegments([
[0, 6],
[7, 8],
[4, 6],
])
).toEqual([
[0, 6],
[7, 8],
]);
expect(
sortSegments([
[0, 6],
[7, 8],
[4, 6],
]),
).toEqual([
[0, 6],
[7, 8],
]);
});

View File

@ -1,53 +1,60 @@
const path = require('node:path');
const getSongControls = require('../../providers/song-controls');
const registerCallback = require('../../providers/song-info');
const path = require('path');
let controls;
let currentSongInfo;
module.exports = win => {
const { playPause, next, previous } = getSongControls(win);
controls = { playPause, next, previous };
module.exports = (win) => {
const { playPause, next, previous } = getSongControls(win);
controls = { playPause, next, previous };
registerCallback(songInfo => {
//update currentsonginfo for win.on('show')
currentSongInfo = songInfo;
// update thumbar
setThumbar(win, songInfo);
});
registerCallback((songInfo) => {
// Update currentsonginfo for win.on('show')
currentSongInfo = songInfo;
// Update thumbar
setThumbar(win, songInfo);
});
// need to set thumbar again after win.show
win.on("show", () => {
setThumbar(win, currentSongInfo)
})
// Need to set thumbar again after win.show
win.on('show', () => {
setThumbar(win, currentSongInfo);
});
};
function setThumbar(win, songInfo) {
// Wait for song to start before setting thumbar
if (!songInfo?.title) {
return;
}
// Wait for song to start before setting thumbar
if (!songInfo?.title) {
return;
}
// Win32 require full rewrite of components
win.setThumbarButtons([
{
tooltip: 'Previous',
icon: get('previous'),
click() { controls.previous(win.webContents); }
}, {
tooltip: 'Play/Pause',
// Update icon based on play state
icon: songInfo.isPaused ? get('play') : get('pause'),
click() { controls.playPause(win.webContents); }
}, {
tooltip: 'Next',
icon: get('next'),
click() { controls.next(win.webContents); }
}
]);
// Win32 require full rewrite of components
win.setThumbarButtons([
{
tooltip: 'Previous',
icon: get('previous'),
click() {
controls.previous(win.webContents);
},
}, {
tooltip: 'Play/Pause',
// Update icon based on play state
icon: songInfo.isPaused ? get('play') : get('pause'),
click() {
controls.playPause(win.webContents);
},
}, {
tooltip: 'Next',
icon: get('next'),
click() {
controls.next(win.webContents);
},
},
]);
}
// Util
function get(kind) {
return path.join(__dirname, "../../assets/media-icons-black", `${kind}.png`);
return path.join(__dirname, '../../assets/media-icons-black', `${kind}.png`);
}

View File

@ -1,18 +1,19 @@
const { TouchBar } = require("electron");
const { TouchBar } = require('electron');
const {
TouchBarButton,
TouchBarLabel,
TouchBarSpacer,
TouchBarSegmentedControl,
TouchBarScrubber,
TouchBarButton,
TouchBarLabel,
TouchBarSpacer,
TouchBarSegmentedControl,
TouchBarScrubber,
} = TouchBar;
const registerCallback = require("../../providers/song-info");
const getSongControls = require("../../providers/song-controls");
const registerCallback = require('../../providers/song-info');
const getSongControls = require('../../providers/song-controls');
// Songtitle label
const songTitle = new TouchBarLabel({
label: "",
label: '',
});
// This will store the song controls once available
let controls = [];
@ -25,62 +26,62 @@ const pausePlayButton = new TouchBarButton();
// The song control buttons (control functions are in the same order)
const buttons = new TouchBarSegmentedControl({
mode: "buttons",
segments: [
new TouchBarButton({
label: "⏮",
}),
pausePlayButton,
new TouchBarButton({
label: "⏭",
}),
new TouchBarButton({
label: "👎",
}),
new TouchBarButton({
label: "👍",
}),
],
change: (i) => controls[i](),
mode: 'buttons',
segments: [
new TouchBarButton({
label: '⏮',
}),
pausePlayButton,
new TouchBarButton({
label: '⏭',
}),
new TouchBarButton({
label: '👎',
}),
new TouchBarButton({
label: '👍',
}),
],
change: (i) => controls[i](),
});
// This is the touchbar object, this combines everything with proper layout
const touchBar = new TouchBar({
items: [
new TouchBarScrubber({
items: [songImage, songTitle],
continuous: false,
}),
new TouchBarSpacer({
size: "flexible",
}),
buttons,
],
items: [
new TouchBarScrubber({
items: [songImage, songTitle],
continuous: false,
}),
new TouchBarSpacer({
size: 'flexible',
}),
buttons,
],
});
module.exports = (win) => {
const { playPause, next, previous, dislike, like } = getSongControls(win);
const { playPause, next, previous, dislike, like } = getSongControls(win);
// If the page is ready, register the callback
win.once("ready-to-show", () => {
controls = [previous, playPause, next, dislike, like];
// If the page is ready, register the callback
win.once('ready-to-show', () => {
controls = [previous, playPause, next, dislike, like];
// Register the callback
registerCallback((songInfo) => {
// Song information changed, so lets update the touchBar
// Register the callback
registerCallback((songInfo) => {
// Song information changed, so lets update the touchBar
// Set the song title
songTitle.label = songInfo.title;
// Set the song title
songTitle.label = songInfo.title;
// Changes the pause button if paused
pausePlayButton.label = songInfo.isPaused ? "▶️" : "⏸";
// Changes the pause button if paused
pausePlayButton.label = songInfo.isPaused ? '▶️' : '⏸';
// Get image source
songImage.icon = songInfo.image
? songInfo.image.resize({ height: 23 })
: null;
// Get image source
songImage.icon = songInfo.image
? songInfo.image.resize({ height: 23 })
: null;
win.setTouchBar(touchBar);
});
});
win.setTouchBar(touchBar);
});
});
};

View File

@ -1,62 +1,72 @@
const { ipcMain } = require("electron");
const { ipcMain } = require('electron');
const fetch = require('node-fetch');
const registerCallback = require("../../providers/song-info");
const registerCallback = require('../../providers/song-info');
const secToMilisec = t => Math.round(Number(t) * 1e3);
const secToMilisec = (t) => Math.round(Number(t) * 1e3);
const data = {
cover: '',
cover_url: '',
title: '',
artists: [],
status: '',
progress: 0,
duration: 0,
album_url: '',
album: undefined
cover: '',
cover_url: '',
title: '',
artists: [],
status: '',
progress: 0,
duration: 0,
album_url: '',
album: undefined,
};
const post = async (data) => {
const port = 1608;
headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Access-Control-Allow-Headers': '*',
'Access-Control-Allow-Origin': '*'
}
const url = `http://localhost:${port}/`;
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}`));
}
const port = 1608;
headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Access-Control-Allow-Headers': '*',
'Access-Control-Allow-Origin': '*',
};
const url = `http://localhost:${port}/`;
fetch(url, {
method: 'POST',
headers,
body: JSON.stringify({ data }),
}).catch((error) => console.log(`Error: '${error.code || error.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);
post(data);
});
ipcMain.on('playPaused', (_, { isPaused, elapsedSeconds }) => {
if (!data.title) return;
data.status = isPaused ? 'stopped' : 'playing';
data.progress = secToMilisec(elapsedSeconds);
post(data);
});
ipcMain.on('apiLoaded', () => win.webContents.send('setupTimeChangedListener'));
ipcMain.on('timeChanged', async (_, t) => {
if (!data.title) {
return;
}
registerCallback((songInfo) => {
if (!songInfo.title && !songInfo.artist) {
return;
}
data.progress = secToMilisec(t);
post(data);
});
ipcMain.on('playPaused', (_, { isPaused, elapsedSeconds }) => {
if (!data.title) {
return;
}
data.duration = secToMilisec(songInfo.songDuration)
data.progress = secToMilisec(songInfo.elapsedSeconds)
data.cover = songInfo.imageSrc;
data.cover_url = songInfo.imageSrc;
data.album_url = songInfo.imageSrc;
data.title = songInfo.title;
data.artists = [songInfo.artist];
data.status = songInfo.isPaused ? 'stopped' : 'playing';
data.album = songInfo.album;
post(data);
})
}
data.status = isPaused ? 'stopped' : 'playing';
data.progress = secToMilisec(elapsedSeconds);
post(data);
});
registerCallback((songInfo) => {
if (!songInfo.title && !songInfo.artist) {
return;
}
data.duration = secToMilisec(songInfo.songDuration);
data.progress = secToMilisec(songInfo.elapsedSeconds);
data.cover = songInfo.imageSrc;
data.cover_url = songInfo.imageSrc;
data.album_url = songInfo.imageSrc;
data.title = songInfo.title;
data.artists = [songInfo.artist];
data.status = songInfo.isPaused ? 'stopped' : 'playing';
data.album = songInfo.album;
post(data);
});
};

View File

@ -1,75 +1,68 @@
const fs = require("fs");
const path = require("path");
const fs = require('node:fs');
const path = require('node:path');
const { ipcMain, ipcRenderer } = require("electron");
const { ipcMain, ipcRenderer } = require('electron');
// Creates a DOM element from a HTML string
module.exports.ElementFromHtml = (html) => {
var template = document.createElement("template");
html = html.trim(); // Never return a text node of whitespace as the result
template.innerHTML = html;
return template.content.firstChild;
const template = document.createElement('template');
html = html.trim(); // Never return a text node of whitespace as the result
template.innerHTML = html;
return template.content.firstChild;
};
// Creates a DOM element from a HTML file
module.exports.ElementFromFile = (filepath) => {
return module.exports.ElementFromHtml(fs.readFileSync(filepath, "utf8"));
};
module.exports.ElementFromFile = (filepath) => module.exports.ElementFromHtml(fs.readFileSync(filepath, 'utf8'));
module.exports.templatePath = (pluginPath, name) => {
return path.join(pluginPath, "templates", name);
};
module.exports.templatePath = (pluginPath, name) => path.join(pluginPath, 'templates', name);
module.exports.triggerAction = (channel, action, ...args) => {
return ipcRenderer.send(channel, action, ...args);
};
module.exports.triggerAction = (channel, action, ...args) => ipcRenderer.send(channel, action, ...args);
module.exports.triggerActionSync = (channel, action, ...args) => {
return ipcRenderer.sendSync(channel, action, ...args);
};
module.exports.triggerActionSync = (channel, action, ...args) => ipcRenderer.sendSync(channel, action, ...args);
module.exports.listenAction = (channel, callback) => {
return ipcMain.on(channel, callback);
};
module.exports.listenAction = (channel, callback) => ipcMain.on(channel, callback);
module.exports.fileExists = (
path,
callbackIfExists,
callbackIfError = undefined
path,
callbackIfExists,
callbackIfError = undefined,
) => {
fs.access(path, fs.F_OK, (err) => {
if (err) {
if (callbackIfError) {
callbackIfError();
}
return;
}
fs.access(path, fs.F_OK, (error) => {
if (error) {
if (callbackIfError) {
callbackIfError();
}
callbackIfExists();
});
return;
}
callbackIfExists();
});
};
const cssToInject = new Map();
module.exports.injectCSS = (webContents, filepath, cb = undefined) => {
if (!cssToInject.size) setupCssInjection(webContents);
if (cssToInject.size === 0) {
setupCssInjection(webContents);
}
cssToInject.set(filepath, cb);
cssToInject.set(filepath, cb);
};
const setupCssInjection = (webContents) => {
webContents.on("did-finish-load", () => {
cssToInject.forEach(async (cb, filepath) => {
await webContents.insertCSS(fs.readFileSync(filepath, "utf8"));
cb?.();
})
});
}
webContents.on('did-finish-load', () => {
cssToInject.forEach(async (cb, filepath) => {
await webContents.insertCSS(fs.readFileSync(filepath, 'utf8'));
cb?.();
});
});
};
module.exports.getAllPlugins = () => {
const isDirectory = (source) => fs.lstatSync(source).isDirectory();
return fs
.readdirSync(__dirname)
.map((name) => path.join(__dirname, name))
.filter(isDirectory)
.map((name) => path.basename(name));
const isDirectory = (source) => fs.lstatSync(source).isDirectory();
return fs
.readdirSync(__dirname)
.map((name) => path.join(__dirname, name))
.filter(isDirectory)
.map((name) => path.basename(name));
};

View File

@ -1,10 +1,11 @@
const { injectCSS } = require("../utils");
const path = require("path");
const path = require('node:path');
const { injectCSS } = require('../utils');
module.exports = (win, options) => {
if (options.forceHide) {
injectCSS(win.webContents, path.join(__dirname, "force-hide.css"));
} else if (!options.mode || options.mode === "custom") {
injectCSS(win.webContents, path.join(__dirname, "button-switcher.css"));
}
if (options.forceHide) {
injectCSS(win.webContents, path.join(__dirname, 'force-hide.css'));
} else if (!options.mode || options.mode === 'custom') {
injectCSS(win.webContents, path.join(__dirname, 'button-switcher.css'));
}
};

View File

@ -1,86 +1,86 @@
#main-panel.ytmusic-player-page {
align-items: unset !important;
align-items: unset !important;
}
#main-panel {
position: relative;
position: relative;
}
.video-switch-button {
z-index: 999;
box-sizing: border-box;
padding: 0;
margin-top: 20px;
margin-left: 10px;
background: rgba(33, 33, 33, 0.4);
border-radius: 30px;
overflow: hidden;
width: 240px;
text-align: center;
font-size: 18px;
letter-spacing: 1px;
color: #fff;
padding-right: 120px;
position: absolute;
z-index: 999;
box-sizing: border-box;
padding: 0;
margin-top: 20px;
margin-left: 10px;
background: rgba(33, 33, 33, 0.4);
border-radius: 30px;
overflow: hidden;
width: 240px;
text-align: center;
font-size: 18px;
letter-spacing: 1px;
color: #fff;
padding-right: 120px;
position: absolute;
}
.video-switch-button:before {
content: "Video";
position: absolute;
top: 0;
bottom: 0;
right: 0;
width: 120px;
display: flex;
align-items: center;
justify-content: center;
z-index: 3;
pointer-events: none;
content: "Video";
position: absolute;
top: 0;
bottom: 0;
right: 0;
width: 120px;
display: flex;
align-items: center;
justify-content: center;
z-index: 3;
pointer-events: none;
}
.video-switch-button-checkbox {
cursor: pointer;
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 100%;
height: 100%;
opacity: 0;
z-index: 2;
cursor: pointer;
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 100%;
height: 100%;
opacity: 0;
z-index: 2;
}
.video-switch-button-label-span {
position: relative;
position: relative;
}
.video-switch-button-checkbox:checked+.video-switch-button-label:before {
transform: translateX(120px);
transition: transform 300ms linear;
.video-switch-button-checkbox:checked + .video-switch-button-label:before {
transform: translateX(120px);
transition: transform 300ms linear;
}
.video-switch-button-checkbox+.video-switch-button-label {
position: relative;
padding: 15px 0;
display: block;
user-select: none;
pointer-events: none;
.video-switch-button-checkbox + .video-switch-button-label {
position: relative;
padding: 15px 0;
display: block;
user-select: none;
pointer-events: none;
}
.video-switch-button-checkbox+.video-switch-button-label:before {
content: "";
background: rgba(60, 60, 60, 0.4);
height: 100%;
width: 100%;
position: absolute;
left: 0;
top: 0;
border-radius: 30px;
transform: translateX(0);
transition: transform 300ms;
.video-switch-button-checkbox + .video-switch-button-label:before {
content: "";
background: rgba(60, 60, 60, 0.4);
height: 100%;
width: 100%;
position: absolute;
left: 0;
top: 0;
border-radius: 30px;
transform: translateX(0);
transition: transform 300ms;
}
/* disable the native toggler */
#av-id {
display: none;
display: none;
}

View File

@ -1,11 +1,11 @@
/* Hide the video player */
#main-panel {
display: none !important;
display: none !important;
}
/* Make the side-panel full width */
.side-panel.ytmusic-player-page {
max-width: 100% !important;
width: 100% !important;
margin: 0 !important;
max-width: 100% !important;
width: 100% !important;
margin: 0 !important;
}

View File

@ -1,142 +1,160 @@
const { ElementFromFile, templatePath } = require("../utils");
const { ElementFromFile, templatePath } = require('../utils');
const { setOptions, isEnabled } = require('../../config/plugins');
const { setOptions, isEnabled } = require("../../config/plugins");
const moveVolumeHud = isEnabled('precise-volume') ? require('../precise-volume/front').moveVolumeHud : () => {
};
const moveVolumeHud = isEnabled("precise-volume") ? require("../precise-volume/front").moveVolumeHud : ()=>{};
function $(selector) {
return document.querySelector(selector);
}
function $(selector) { return document.querySelector(selector); }
let options, player, video, api;
let options;
let player;
let video;
let api;
const switchButtonDiv = ElementFromFile(
templatePath(__dirname, "button_template.html")
templatePath(__dirname, 'button_template.html'),
);
module.exports = (_options) => {
if (_options.forceHide) return;
switch (_options.mode) {
case "native": {
$("ytmusic-player-page").setAttribute("has-av-switcher");
$("ytmusic-player").setAttribute("has-av-switcher");
return;
}
case "disabled": {
$("ytmusic-player-page").removeAttribute("has-av-switcher");
$("ytmusic-player").removeAttribute("has-av-switcher");
return;
}
default:
case "custom": {
options = _options;
document.addEventListener("apiLoaded", setup, { once: true, passive: true });
}
if (_options.forceHide) {
return;
}
switch (_options.mode) {
case 'native': {
$('ytmusic-player-page').setAttribute('has-av-switcher');
$('ytmusic-player').setAttribute('has-av-switcher');
return;
}
case 'disabled': {
$('ytmusic-player-page').removeAttribute('has-av-switcher');
$('ytmusic-player').removeAttribute('has-av-switcher');
return;
}
default:
case 'custom': {
options = _options;
document.addEventListener('apiLoaded', setup, { once: true, passive: true });
}
}
};
function setup(e) {
api = e.detail;
player = $('ytmusic-player');
video = $('video');
api = e.detail;
player = $('ytmusic-player');
video = $('video');
$('#main-panel').append(switchButtonDiv);
$('#main-panel').append(switchButtonDiv);
if (options.hideVideo) {
$('.video-switch-button-checkbox').checked = false;
changeDisplay(false);
forcePlaybackMode();
// fix black video
video.style.height = "auto";
if (options.hideVideo) {
$('.video-switch-button-checkbox').checked = false;
changeDisplay(false);
forcePlaybackMode();
// Fix black video
video.style.height = 'auto';
}
// Button checked = show video
switchButtonDiv.addEventListener('change', (e) => {
options.hideVideo = !e.target.checked;
changeDisplay(e.target.checked);
setOptions('video-toggle', options);
});
video.addEventListener('srcChanged', videoStarted);
observeThumbnail();
switch (options.align) {
case 'right': {
switchButtonDiv.style.left = 'calc(100% - 240px)';
return;
}
// button checked = show video
switchButtonDiv.addEventListener('change', (e) => {
options.hideVideo = !e.target.checked;
changeDisplay(e.target.checked);
setOptions("video-toggle", options);
})
video.addEventListener('srcChanged', videoStarted);
observeThumbnail();
switch (options.align) {
case "right": {
switchButtonDiv.style.left = "calc(100% - 240px)";
return;
}
case "middle": {
switchButtonDiv.style.left = "calc(50% - 120px)";
return;
}
default:
case "left": {
switchButtonDiv.style.left = "0px";
}
case 'middle': {
switchButtonDiv.style.left = 'calc(50% - 120px)';
return;
}
default:
case 'left': {
switchButtonDiv.style.left = '0px';
}
}
}
function changeDisplay(showVideo) {
player.style.margin = showVideo ? '' : 'auto 0px';
player.setAttribute('playback-mode', showVideo ? 'OMV_PREFERRED' : 'ATV_PREFERRED');
player.style.margin = showVideo ? '' : 'auto 0px';
player.setAttribute('playback-mode', showVideo ? 'OMV_PREFERRED' : 'ATV_PREFERRED');
$('#song-video.ytmusic-player').style.display = showVideo ? 'block' : 'none';
$('#song-image').style.display = showVideo ? 'none' : 'block';
$('#song-video.ytmusic-player').style.display = showVideo ? 'block' : 'none';
$('#song-image').style.display = showVideo ? 'none' : 'block';
if (showVideo && !video.style.top) {
video.style.top = `${(player.clientHeight - video.clientHeight) / 2}px`;
}
moveVolumeHud(showVideo);
if (showVideo && !video.style.top) {
video.style.top = `${(player.clientHeight - video.clientHeight) / 2}px`;
}
moveVolumeHud(showVideo);
}
function videoStarted() {
if (api.getPlayerResponse().videoDetails.musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV') {
// switch to high res thumbnail
forceThumbnail($('#song-image img'));
// show toggle button
switchButtonDiv.style.display = "initial";
// change display to video mode if video exist & video is hidden & option.hideVideo = false
if (!options.hideVideo && $('#song-video.ytmusic-player').style.display === "none") {
changeDisplay(true);
} else {
moveVolumeHud(!options.hideVideo);
}
if (api.getPlayerResponse().videoDetails.musicVideoType === 'MUSIC_VIDEO_TYPE_ATV') {
// Video doesn't exist -> switch to song mode
changeDisplay(false);
// Hide toggle button
switchButtonDiv.style.display = 'none';
} else {
// Switch to high res thumbnail
forceThumbnail($('#song-image img'));
// Show toggle button
switchButtonDiv.style.display = 'initial';
// Change display to video mode if video exist & video is hidden & option.hideVideo = false
if (!options.hideVideo && $('#song-video.ytmusic-player').style.display === 'none') {
changeDisplay(true);
} else {
// video doesn't exist -> switch to song mode
changeDisplay(false);
// hide toggle button
switchButtonDiv.style.display = "none";
moveVolumeHud(!options.hideVideo);
}
}
}
// on load, after a delay, the page overrides the playback-mode to 'OMV_PREFERRED' which causes weird aspect ratio in the image container
// On load, after a delay, the page overrides the playback-mode to 'OMV_PREFERRED' which causes weird aspect ratio in the image container
// this function fix the problem by overriding that override :)
function forcePlaybackMode() {
const playbackModeObserver = new MutationObserver(mutations => {
mutations.forEach(mutation => {
if (mutation.target.getAttribute('playback-mode') !== "ATV_PREFERRED") {
playbackModeObserver.disconnect();
mutation.target.setAttribute('playback-mode', "ATV_PREFERRED");
}
});
});
playbackModeObserver.observe(player, { attributeFilter: ["playback-mode"] });
const playbackModeObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.target.getAttribute('playback-mode') !== 'ATV_PREFERRED') {
playbackModeObserver.disconnect();
mutation.target.setAttribute('playback-mode', 'ATV_PREFERRED');
}
}
});
playbackModeObserver.observe(player, { attributeFilter: ['playback-mode'] });
}
function observeThumbnail() {
const playbackModeObserver = new MutationObserver(mutations => {
if (!player.videoMode_) return;
const playbackModeObserver = new MutationObserver((mutations) => {
if (!player.videoMode_) {
return;
}
mutations.forEach(mutation => {
if (!mutation.target.src.startsWith('data:')) return;
forceThumbnail(mutation.target)
});
});
playbackModeObserver.observe($('#song-image img'), { attributeFilter: ["src"] })
for (const mutation of mutations) {
if (!mutation.target.src.startsWith('data:')) {
continue;
}
forceThumbnail(mutation.target);
}
});
playbackModeObserver.observe($('#song-image img'), { attributeFilter: ['src'] });
}
function forceThumbnail(img) {
const thumbnails = $('#movie_player').getPlayerResponse()?.videoDetails?.thumbnail?.thumbnails;
if (thumbnails && thumbnails.length > 0) {
img.src = thumbnails[thumbnails.length - 1].url.split("?")[0];
}
const thumbnails = $('#movie_player').getPlayerResponse()?.videoDetails?.thumbnail?.thumbnails;
if (thumbnails && thumbnails.length > 0) {
img.src = thumbnails.at(-1).url.split('?')[0];
}
}

View File

@ -1,77 +1,77 @@
const { setMenuOptions } = require("../../config/plugins");
const { setMenuOptions } = require('../../config/plugins');
module.exports = (win, options) => [
{
label: "Mode",
submenu: [
{
label: "Custom toggle",
type: "radio",
checked: options.mode === 'custom',
click: () => {
options.mode = 'custom';
setMenuOptions("video-toggle", options);
}
},
{
label: "Native toggle",
type: "radio",
checked: options.mode === 'native',
click: () => {
options.mode = 'native';
setMenuOptions("video-toggle", options);
}
},
{
label: "Disabled",
type: "radio",
checked: options.mode === 'disabled',
click: () => {
options.mode = 'disabled';
setMenuOptions("video-toggle", options);
}
},
]
{
label: 'Mode',
submenu: [
{
label: 'Custom toggle',
type: 'radio',
checked: options.mode === 'custom',
click() {
options.mode = 'custom';
setMenuOptions('video-toggle', options);
},
},
{
label: 'Native toggle',
type: 'radio',
checked: options.mode === 'native',
click() {
options.mode = 'native';
setMenuOptions('video-toggle', options);
},
},
{
label: 'Disabled',
type: 'radio',
checked: options.mode === 'disabled',
click() {
options.mode = 'disabled';
setMenuOptions('video-toggle', options);
},
},
],
},
{
label: 'Alignment',
submenu: [
{
label: 'Left',
type: 'radio',
checked: options.align === 'left',
click() {
options.align = 'left';
setMenuOptions('video-toggle', options);
},
},
{
label: 'Middle',
type: 'radio',
checked: options.align === 'middle',
click() {
options.align = 'middle';
setMenuOptions('video-toggle', options);
},
},
{
label: 'Right',
type: 'radio',
checked: options.align === 'right',
click() {
options.align = 'right';
setMenuOptions('video-toggle', options);
},
},
],
},
{
label: 'Force Remove Video Tab',
type: 'checkbox',
checked: options.forceHide,
click(item) {
options.forceHide = item.checked;
setMenuOptions('video-toggle', options);
},
{
label: "Alignment",
submenu: [
{
label: "Left",
type: "radio",
checked: options.align === 'left',
click: () => {
options.align = 'left';
setMenuOptions("video-toggle", options);
}
},
{
label: "Middle",
type: "radio",
checked: options.align === 'middle',
click: () => {
options.align = 'middle';
setMenuOptions("video-toggle", options);
}
},
{
label: "Right",
type: "radio",
checked: options.align === 'right',
click: () => {
options.align = 'right';
setMenuOptions("video-toggle", options);
}
},
]
},
{
label: "Force Remove Video Tab",
type: "checkbox",
checked: options.forceHide,
click: item => {
options.forceHide = item.checked;
setMenuOptions("video-toggle", options);
}
}
},
];

View File

@ -1,4 +1,4 @@
<div class="video-switch-button">
<input class="video-switch-button-checkbox" type="checkbox" checked="true"></input>
<label class="video-switch-button-label" for=""><span class="video-switch-button-label-span">Song</span></label>
<input checked="true" class="video-switch-button-checkbox" type="checkbox"></input>
<label class="video-switch-button-label" for=""><span class="video-switch-button-label-span">Song</span></label>
</div>

View File

@ -1,6 +1,7 @@
const { injectCSS } = require("../utils");
const path = require("path");
const path = require('node:path');
const { injectCSS } = require('../utils');
module.exports = (win, options) => {
injectCSS(win.webContents, path.join(__dirname, "empty-player.css"));
injectCSS(win.webContents, path.join(__dirname, 'empty-player.css'));
};

View File

@ -1,9 +1,9 @@
#player {
margin: 0 !important;
background: black;
margin: 0 !important;
background: black;
}
#song-image,
#song-video {
display: none !important;
display: none !important;
}

View File

@ -1,61 +1,63 @@
const defaultConfig = require("../../config/defaults");
const defaultConfig = require('../../config/defaults');
module.exports = (options) => {
const optionsWithDefaults = {
...defaultConfig.plugins.visualizer,
...options,
};
const VisualizerType = require(`./visualizers/${optionsWithDefaults.type}`);
const optionsWithDefaults = {
...defaultConfig.plugins.visualizer,
...options,
};
const VisualizerType = require(`./visualizers/${optionsWithDefaults.type}`);
document.addEventListener(
"audioCanPlay",
(e) => {
const video = document.querySelector("video");
const visualizerContainer = document.querySelector("#player");
document.addEventListener(
'audioCanPlay',
(e) => {
const video = document.querySelector('video');
const visualizerContainer = document.querySelector('#player');
let canvas = document.getElementById("visualizer");
if (!canvas) {
canvas = document.createElement("canvas");
canvas.id = "visualizer";
canvas.style.position = "absolute";
canvas.style.background = "black";
visualizerContainer.append(canvas);
}
let canvas = document.querySelector('#visualizer');
if (!canvas) {
canvas = document.createElement('canvas');
canvas.id = 'visualizer';
canvas.style.position = 'absolute';
canvas.style.background = 'black';
visualizerContainer.append(canvas);
}
const resizeCanvas = () => {
canvas.width = visualizerContainer.clientWidth;
canvas.height = visualizerContainer.clientHeight;
};
resizeCanvas();
const resizeCanvas = () => {
canvas.width = visualizerContainer.clientWidth;
canvas.height = visualizerContainer.clientHeight;
};
const gainNode = e.detail.audioContext.createGain();
gainNode.gain.value = 1.25;
e.detail.audioSource.connect(gainNode);
resizeCanvas();
const visualizer = new VisualizerType(
e.detail.audioContext,
e.detail.audioSource,
visualizerContainer,
canvas,
gainNode,
video.captureStream(),
optionsWithDefaults[optionsWithDefaults.type]
);
const gainNode = e.detail.audioContext.createGain();
gainNode.gain.value = 1.25;
e.detail.audioSource.connect(gainNode);
const resizeVisualizer = (width, height) => {
resizeCanvas();
visualizer.resize(width, height);
};
resizeVisualizer(canvas.width, canvas.height);
const visualizerContainerObserver = new ResizeObserver((entries) => {
entries.forEach((entry) => {
resizeVisualizer(entry.contentRect.width, entry.contentRect.height);
});
});
visualizerContainerObserver.observe(visualizerContainer);
const visualizer = new VisualizerType(
e.detail.audioContext,
e.detail.audioSource,
visualizerContainer,
canvas,
gainNode,
video.captureStream(),
optionsWithDefaults[optionsWithDefaults.type],
);
visualizer.render();
},
{ passive: true }
);
const resizeVisualizer = (width, height) => {
resizeCanvas();
visualizer.resize(width, height);
};
resizeVisualizer(canvas.width, canvas.height);
const visualizerContainerObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
resizeVisualizer(entry.contentRect.width, entry.contentRect.height);
}
});
visualizerContainerObserver.observe(visualizerContainer);
visualizer.render();
},
{ passive: true },
);
};

View File

@ -1,23 +1,23 @@
const { readdirSync } = require("fs");
const path = require("path");
const { readdirSync } = require('node:fs');
const path = require('node:path');
const { setMenuOptions } = require("../../config/plugins");
const { setMenuOptions } = require('../../config/plugins');
const visualizerTypes = readdirSync(path.join(__dirname, "visualizers")).map(
(filename) => path.parse(filename).name
const visualizerTypes = readdirSync(path.join(__dirname, 'visualizers')).map(
(filename) => path.parse(filename).name,
);
module.exports = (win, options) => [
{
label: "Type",
submenu: visualizerTypes.map((visualizerType) => ({
label: visualizerType,
type: "radio",
checked: options.type === visualizerType,
click: () => {
options.type = visualizerType;
setMenuOptions("visualizer", options);
},
})),
},
{
label: 'Type',
submenu: visualizerTypes.map((visualizerType) => ({
label: visualizerType,
type: 'radio',
checked: options.type === visualizerType,
click() {
options.type = visualizerType;
setMenuOptions('visualizer', options);
},
})),
},
];

View File

@ -1,46 +1,47 @@
const butterchurn = require("butterchurn");
const butterchurnPresets = require("butterchurn-presets");
const butterchurn = require('butterchurn');
const butterchurnPresets = require('butterchurn-presets');
const presets = butterchurnPresets.getPresets();
class ButterchurnVisualizer {
constructor(
audioContext,
audioSource,
visualizerContainer,
canvas,
audioNode,
stream,
options
) {
this.visualizer = butterchurn.default.createVisualizer(
audioContext,
canvas,
{
width: canvas.width,
height: canvas.height,
}
);
constructor(
audioContext,
audioSource,
visualizerContainer,
canvas,
audioNode,
stream,
options,
) {
this.visualizer = butterchurn.default.createVisualizer(
audioContext,
canvas,
{
width: canvas.width,
height: canvas.height,
},
);
const preset = presets[options.preset];
this.visualizer.loadPreset(preset, options.blendTimeInSeconds);
const preset = presets[options.preset];
this.visualizer.loadPreset(preset, options.blendTimeInSeconds);
this.visualizer.connectAudio(audioNode);
this.visualizer.connectAudio(audioNode);
this.renderingFrequencyInMs = options.renderingFrequencyInMs;
}
this.renderingFrequencyInMs = options.renderingFrequencyInMs;
}
resize(width, height) {
this.visualizer.setRendererSize(width, height);
}
resize(width, height) {
this.visualizer.setRendererSize(width, height);
}
render() {
const renderVisualizer = () => {
requestAnimationFrame(() => renderVisualizer());
this.visualizer.render();
};
setTimeout(renderVisualizer(), this.renderingFrequencyInMs);
}
render() {
const renderVisualizer = () => {
requestAnimationFrame(() => renderVisualizer());
this.visualizer.render();
};
setTimeout(renderVisualizer(), this.renderingFrequencyInMs);
}
}
module.exports = ButterchurnVisualizer;

View File

@ -1,33 +1,33 @@
const Vudio = require("vudio/umd/vudio");
const Vudio = require('vudio/umd/vudio');
class VudioVisualizer {
constructor(
audioContext,
audioSource,
visualizerContainer,
canvas,
audioNode,
stream,
options
) {
this.visualizer = new Vudio(stream, canvas, {
width: canvas.width,
height: canvas.height,
// Visualizer config
...options,
});
}
constructor(
audioContext,
audioSource,
visualizerContainer,
canvas,
audioNode,
stream,
options,
) {
this.visualizer = new Vudio(stream, canvas, {
width: canvas.width,
height: canvas.height,
// Visualizer config
...options,
});
}
resize(width, height) {
this.visualizer.setOption({
width: width,
height: height,
});
}
resize(width, height) {
this.visualizer.setOption({
width,
height,
});
}
render() {
this.visualizer.dance();
}
render() {
this.visualizer.dance();
}
}
module.exports = VudioVisualizer;

View File

@ -1,31 +1,33 @@
const { Wave } = require("@foobar404/wave");
const { Wave } = require('@foobar404/wave');
class WaveVisualizer {
constructor(
audioContext,
audioSource,
visualizerContainer,
canvas,
audioNode,
stream,
options
) {
this.visualizer = new Wave(
{ context: audioContext, source: audioSource },
canvas
);
options.animations.forEach((animation) => {
this.visualizer.addAnimation(
eval(`new this.visualizer.animations.${animation.type}(
constructor(
audioContext,
audioSource,
visualizerContainer,
canvas,
audioNode,
stream,
options,
) {
this.visualizer = new Wave(
{ context: audioContext, source: audioSource },
canvas,
);
for (const animation of options.animations) {
this.visualizer.addAnimation(
eval(`new this.visualizer.animations.${animation.type}(
${JSON.stringify(animation.config)}
)`)
);
});
}
)`),
);
}
}
resize(width, height) {}
resize(width, height) {
}
render() {}
render() {
}
}
module.exports = WaveVisualizer;