mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-10 18:21:47 +00:00
Merge branch 'master' into lastfm
This commit is contained in:
@ -15,21 +15,22 @@ const defaultConfig = {
|
||||
trayClickPlayPause: false,
|
||||
autoResetAppCache: false,
|
||||
resumeOnStart: true,
|
||||
proxy: "",
|
||||
},
|
||||
plugins: {
|
||||
// Enabled plugins
|
||||
navigation: {
|
||||
enabled: true,
|
||||
},
|
||||
shortcuts: {
|
||||
enabled: true,
|
||||
},
|
||||
adblocker: {
|
||||
enabled: true,
|
||||
cache: true,
|
||||
additionalBlockLists: [], // Additional list of filters, e.g "https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters.txt"
|
||||
},
|
||||
// Disabled plugins
|
||||
shortcuts: {
|
||||
enabled: false,
|
||||
},
|
||||
downloader: {
|
||||
enabled: false,
|
||||
ffmpegArgs: [], // e.g. ["-b:a", "192k"] for an audio bitrate of 192kb/s
|
||||
@ -43,9 +44,15 @@ const defaultConfig = {
|
||||
suffixesToRemove: [' - Topic', 'VEVO'] // removes suffixes of the artist name, for better recognition
|
||||
},
|
||||
discord: {
|
||||
enabled: false,
|
||||
activityTimoutEnabled: true, // if enabled, the discord rich presence gets cleared when music paused after the time specified below
|
||||
activityTimoutTime: 10 * 60 * 1000 // 10 minutes
|
||||
},
|
||||
notifications: {
|
||||
enabled: false,
|
||||
urgency: "normal",
|
||||
unpauseNotification: false
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
13
index.js
13
index.js
@ -32,6 +32,10 @@ if (config.get("options.disableHardwareAcceleration")) {
|
||||
app.disableHardwareAcceleration();
|
||||
}
|
||||
|
||||
if (config.get("options.proxy")) {
|
||||
app.commandLine.appendSwitch("proxy-server", config.get("options.proxy"));
|
||||
}
|
||||
|
||||
// Adds debug features like hotkeys for triggering dev tools and reload
|
||||
require("electron-debug")();
|
||||
|
||||
@ -75,6 +79,7 @@ function createMainWindow() {
|
||||
const windowSize = config.get("window-size");
|
||||
const windowMaximized = config.get("window-maximized");
|
||||
const windowPosition = config.get("window-position");
|
||||
const useInlineMenu = config.plugins.isEnabled("in-app-menu");
|
||||
|
||||
const win = new electron.BrowserWindow({
|
||||
icon: icon,
|
||||
@ -99,8 +104,12 @@ function createMainWindow() {
|
||||
}
|
||||
: undefined),
|
||||
},
|
||||
frame: !is.macOS(),
|
||||
titleBarStyle: is.macOS() ? "hiddenInset" : "default",
|
||||
frame: !is.macOS() && !useInlineMenu,
|
||||
titleBarStyle: useInlineMenu
|
||||
? "hidden"
|
||||
: is.macOS()
|
||||
? "hiddenInset"
|
||||
: "default",
|
||||
autoHideMenuBar: config.get("options.hideMenu"),
|
||||
});
|
||||
if (windowPosition) {
|
||||
|
||||
100
menu.js
100
menu.js
@ -7,7 +7,7 @@ const is = require("electron-is");
|
||||
const { getAllPlugins } = require("./plugins/utils");
|
||||
const config = require("./config");
|
||||
|
||||
const pluginEnabledMenu = (plugin, label = "") => ({
|
||||
const pluginEnabledMenu = (win, plugin, label = "", hasSubmenu = false) => ({
|
||||
label: label || plugin,
|
||||
type: "checkbox",
|
||||
checked: config.plugins.isEnabled(plugin),
|
||||
@ -17,26 +17,29 @@ const pluginEnabledMenu = (plugin, label = "") => ({
|
||||
} else {
|
||||
config.plugins.disable(plugin);
|
||||
}
|
||||
if (hasSubmenu) {
|
||||
this.setApplicationMenu(win);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const mainMenuTemplate = (win) => [
|
||||
const mainMenuTemplate = (win, withRoles = true, isTray = false) => [
|
||||
{
|
||||
label: "Plugins",
|
||||
submenu: [
|
||||
...getAllPlugins().map((plugin) => {
|
||||
const pluginPath = path.join(__dirname, "plugins", plugin, "menu.js");
|
||||
|
||||
if (!config.plugins.isEnabled(plugin)) {
|
||||
return pluginEnabledMenu(plugin);
|
||||
}
|
||||
|
||||
if (existsSync(pluginPath)) {
|
||||
if (!config.plugins.isEnabled(plugin)) {
|
||||
return pluginEnabledMenu(win, plugin, "", true);
|
||||
}
|
||||
|
||||
const getPluginMenu = require(pluginPath);
|
||||
return {
|
||||
label: plugin,
|
||||
submenu: [
|
||||
pluginEnabledMenu(plugin, "Enabled"),
|
||||
pluginEnabledMenu(win, plugin, "Enabled", true),
|
||||
...getPluginMenu(win, config.plugins.getOptions(plugin), () =>
|
||||
module.exports.setApplicationMenu(win)
|
||||
),
|
||||
@ -44,7 +47,7 @@ const mainMenuTemplate = (win) => [
|
||||
};
|
||||
}
|
||||
|
||||
return pluginEnabledMenu(plugin);
|
||||
return pluginEnabledMenu(win, plugin);
|
||||
}),
|
||||
{ type: "separator" },
|
||||
{
|
||||
@ -189,17 +192,59 @@ const mainMenuTemplate = (win) => [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "View",
|
||||
submenu: [
|
||||
{ role: "reload" },
|
||||
{ role: "forceReload" },
|
||||
{ type: "separator" },
|
||||
{ role: "zoomIn" },
|
||||
{ role: "zoomOut" },
|
||||
{ role: "resetZoom" },
|
||||
],
|
||||
},
|
||||
...(!isTray
|
||||
? [
|
||||
{
|
||||
label: "View",
|
||||
submenu: withRoles
|
||||
? [
|
||||
{ role: "reload" },
|
||||
{ role: "forceReload" },
|
||||
{ type: "separator" },
|
||||
{ role: "zoomIn" },
|
||||
{ role: "zoomOut" },
|
||||
{ role: "resetZoom" },
|
||||
]
|
||||
: [
|
||||
{
|
||||
label: "Reload",
|
||||
click: () => {
|
||||
win.webContents.reload();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Force Reload",
|
||||
click: () => {
|
||||
win.webContents.reloadIgnoringCache();
|
||||
},
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Zoom In",
|
||||
click: () => {
|
||||
win.webContents.setZoomLevel(
|
||||
win.webContents.getZoomLevel() + 1
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Zoom Out",
|
||||
click: () => {
|
||||
win.webContents.setZoomLevel(
|
||||
win.webContents.getZoomLevel() - 1
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Reset Zoom",
|
||||
click: () => {
|
||||
win.webContents.setZoomLevel(0);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: "Navigation",
|
||||
submenu: [
|
||||
@ -219,6 +264,23 @@ const mainMenuTemplate = (win) => [
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Restart App",
|
||||
click: () => {
|
||||
app.relaunch();
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
...(!isTray
|
||||
? [
|
||||
{
|
||||
label: "Quit App",
|
||||
click: () => {
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@ -62,12 +62,13 @@
|
||||
"npm": "Please use yarn and not npm"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cliqz/adblocker-electron": "^1.20.0",
|
||||
"@cliqz/adblocker-electron": "^1.20.1",
|
||||
"@ffmpeg/core": "^0.8.5",
|
||||
"@ffmpeg/ffmpeg": "^0.9.7",
|
||||
"YoutubeNonStop": "git://github.com/lawfx/YoutubeNonStop.git#v0.8.1",
|
||||
"async-mutex": "^0.3.1",
|
||||
"browser-id3-writer": "^4.4.0",
|
||||
"custom-electron-titlebar": "^3.2.6",
|
||||
"discord-rpc": "^3.2.0",
|
||||
"downloads-folder": "^3.0.1",
|
||||
"electron-debug": "^3.2.0",
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
const { writeFileSync } = require("fs");
|
||||
const { join } = require("path");
|
||||
|
||||
const ID3Writer = require("browser-id3-writer");
|
||||
const { dialog, ipcMain } = require("electron");
|
||||
const { dialog } = require("electron");
|
||||
|
||||
const getSongInfo = require("../../providers/song-info");
|
||||
const { injectCSS, listenAction } = require("../utils");
|
||||
@ -40,35 +38,6 @@ function handle(win) {
|
||||
console.log("Unknown action: " + action);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on("add-metadata", (event, filePath, songBuffer) => {
|
||||
let fileBuffer = songBuffer;
|
||||
|
||||
try {
|
||||
const writer = new ID3Writer(songBuffer);
|
||||
if (metadata.image) {
|
||||
const coverBuffer = metadata.image.toPNG();
|
||||
|
||||
// Create the metadata tags
|
||||
writer
|
||||
.setFrame("TIT2", metadata.title)
|
||||
.setFrame("TPE1", [metadata.artist])
|
||||
.setFrame("APIC", {
|
||||
type: 3,
|
||||
data: coverBuffer,
|
||||
description: "",
|
||||
});
|
||||
writer.addTag();
|
||||
}
|
||||
fileBuffer = Buffer.from(writer.arrayBuffer);
|
||||
} catch (error) {
|
||||
sendError(win, error);
|
||||
}
|
||||
|
||||
writeFileSync(filePath, fileBuffer);
|
||||
// Notify the youtube-dl file
|
||||
event.reply("add-metadata-done");
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = handle;
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
const { contextBridge } = require("electron");
|
||||
|
||||
const { defaultConfig } = require("../../config");
|
||||
const { getSongMenu } = require("../../providers/dom-elements");
|
||||
const { ElementFromFile, templatePath, triggerAction } = require("../utils");
|
||||
const { ACTIONS, CHANNEL } = require("./actions.js");
|
||||
@ -31,11 +32,19 @@ const reinit = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const baseUrl = defaultConfig.url;
|
||||
|
||||
// TODO: re-enable once contextIsolation is set to true
|
||||
// contextBridge.exposeInMainWorld("downloader", {
|
||||
// download: () => {
|
||||
global.download = () => {
|
||||
const videoUrl = window.location.href;
|
||||
let videoUrl = getSongMenu()
|
||||
.querySelector("ytmusic-menu-navigation-item-renderer")
|
||||
.querySelector("#navigation-endpoint")
|
||||
.getAttribute("href");
|
||||
videoUrl = !videoUrl
|
||||
? global.songInfo.url || window.location.href
|
||||
: baseUrl + videoUrl;
|
||||
|
||||
downloadVideoToMP3(
|
||||
videoUrl,
|
||||
@ -51,7 +60,8 @@ global.download = () => {
|
||||
reinit();
|
||||
},
|
||||
reinit,
|
||||
pluginOptions
|
||||
pluginOptions,
|
||||
global.songInfo
|
||||
);
|
||||
};
|
||||
// });
|
||||
|
||||
@ -2,62 +2,89 @@ const { existsSync, mkdirSync } = require("fs");
|
||||
const { join } = require("path");
|
||||
const { URL } = require("url");
|
||||
|
||||
const { ipcMain } = require("electron");
|
||||
const { dialog, ipcMain } = require("electron");
|
||||
const is = require("electron-is");
|
||||
const ytpl = require("ytpl");
|
||||
|
||||
const { setOptions } = require("../../config/plugins");
|
||||
const getSongInfo = require("../../providers/song-info");
|
||||
const { sendError } = require("./back");
|
||||
const { defaultMenuDownloadLabel, getFolder } = require("./utils");
|
||||
|
||||
let downloadLabel = defaultMenuDownloadLabel;
|
||||
let metadataURL = undefined;
|
||||
let callbackIsRegistered = false;
|
||||
|
||||
module.exports = (win, options, refreshMenu) => [
|
||||
{
|
||||
label: downloadLabel,
|
||||
click: async () => {
|
||||
const currentURL = win.webContents.getURL();
|
||||
const playlistID = new URL(currentURL).searchParams.get("list");
|
||||
if (!playlistID) {
|
||||
sendError(win, new Error("No playlist ID found"));
|
||||
return;
|
||||
}
|
||||
module.exports = (win, options, refreshMenu) => {
|
||||
if (!callbackIsRegistered) {
|
||||
const registerCallback = getSongInfo(win);
|
||||
registerCallback((info) => {
|
||||
metadataURL = info.url;
|
||||
});
|
||||
callbackIsRegistered = true;
|
||||
}
|
||||
|
||||
const playlist = await ytpl(playlistID);
|
||||
const playlistTitle = playlist.title;
|
||||
return [
|
||||
{
|
||||
label: downloadLabel,
|
||||
click: async () => {
|
||||
const currentURL = metadataURL || win.webContents.getURL();
|
||||
const playlistID = new URL(currentURL).searchParams.get("list");
|
||||
if (!playlistID) {
|
||||
sendError(win, new Error("No playlist ID found"));
|
||||
return;
|
||||
}
|
||||
|
||||
const folder = getFolder(options.downloadFolder);
|
||||
const playlistFolder = join(folder, playlistTitle);
|
||||
if (existsSync(playlistFolder)) {
|
||||
sendError(
|
||||
win,
|
||||
new Error(`The folder ${playlistFolder} already exists`)
|
||||
);
|
||||
return;
|
||||
}
|
||||
mkdirSync(playlistFolder, { recursive: true });
|
||||
const playlist = await ytpl(playlistID);
|
||||
const playlistTitle = playlist.title;
|
||||
|
||||
ipcMain.on("downloader-feedback", (_, feedback) => {
|
||||
downloadLabel = feedback;
|
||||
const folder = getFolder(options.downloadFolder);
|
||||
const playlistFolder = join(folder, playlistTitle);
|
||||
if (existsSync(playlistFolder)) {
|
||||
sendError(
|
||||
win,
|
||||
new Error(`The folder ${playlistFolder} already exists`)
|
||||
);
|
||||
return;
|
||||
}
|
||||
mkdirSync(playlistFolder, { recursive: true });
|
||||
|
||||
ipcMain.on("downloader-feedback", (_, feedback) => {
|
||||
downloadLabel = feedback;
|
||||
refreshMenu();
|
||||
});
|
||||
|
||||
downloadLabel = `Downloading "${playlistTitle}"`;
|
||||
refreshMenu();
|
||||
});
|
||||
|
||||
downloadLabel = `Downloading "${playlistTitle}"`;
|
||||
refreshMenu();
|
||||
if (is.dev()) {
|
||||
console.log(
|
||||
`Downloading playlist "${playlistTitle}" (${playlist.items.length} songs)`
|
||||
);
|
||||
}
|
||||
|
||||
if (is.dev()) {
|
||||
console.log(
|
||||
`Downloading playlist "${playlistTitle}" (${playlist.items.length} songs)`
|
||||
);
|
||||
}
|
||||
|
||||
playlist.items.slice(0, options.playlistMaxItems).forEach((song) => {
|
||||
win.webContents.send(
|
||||
"downloader-download-playlist",
|
||||
song,
|
||||
playlistTitle,
|
||||
options
|
||||
);
|
||||
});
|
||||
playlist.items.slice(0, options.playlistMaxItems).forEach((song) => {
|
||||
win.webContents.send(
|
||||
"downloader-download-playlist",
|
||||
song,
|
||||
playlistTitle,
|
||||
options
|
||||
);
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
{
|
||||
label: "Choose download folder",
|
||||
click: () => {
|
||||
let result = dialog.showOpenDialogSync({
|
||||
properties: ["openDirectory", "createDirectory"],
|
||||
defaultPath: getFolder(options.downloadFolder),
|
||||
});
|
||||
if (result) {
|
||||
options.downloadFolder = result[0];
|
||||
setOptions("downloader", options);
|
||||
} // else = user pressed cancel
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
@ -19,11 +19,11 @@
|
||||
<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"
|
||||
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"
|
||||
class="style-scope yt-icon" fill="#aaaaaa"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
@ -3,6 +3,7 @@ const { writeFileSync } = require("fs");
|
||||
const { join } = require("path");
|
||||
|
||||
const Mutex = require("async-mutex").Mutex;
|
||||
const ID3Writer = require("browser-id3-writer");
|
||||
const { ipcRenderer } = require("electron");
|
||||
const is = require("electron-is");
|
||||
const filenamify = require("filenamify");
|
||||
@ -120,7 +121,7 @@ const toMP3 = async (
|
||||
);
|
||||
|
||||
const folder = getFolder(options.downloadFolder);
|
||||
const name = metadata
|
||||
const name = metadata.title
|
||||
? `${metadata.artist ? `${metadata.artist} - ` : ""}${metadata.title}`
|
||||
: videoName;
|
||||
const filename = filenamify(name + "." + extension, {
|
||||
@ -130,15 +131,29 @@ const toMP3 = async (
|
||||
const filePath = join(folder, subfolder, filename);
|
||||
const fileBuffer = ffmpeg.FS("readFile", safeVideoName + "." + extension);
|
||||
|
||||
if (existingMetadata) {
|
||||
writeFileSync(filePath, fileBuffer);
|
||||
// Add the metadata
|
||||
try {
|
||||
const writer = new ID3Writer(fileBuffer);
|
||||
if (metadata.image) {
|
||||
const coverBuffer = metadata.image.toPNG();
|
||||
|
||||
// Create the metadata tags
|
||||
writer
|
||||
.setFrame("TIT2", metadata.title)
|
||||
.setFrame("TPE1", [metadata.artist])
|
||||
.setFrame("APIC", {
|
||||
type: 3,
|
||||
data: coverBuffer,
|
||||
description: "",
|
||||
});
|
||||
writer.addTag();
|
||||
}
|
||||
|
||||
writeFileSync(filePath, Buffer.from(writer.arrayBuffer));
|
||||
} catch (error) {
|
||||
sendError(error);
|
||||
} finally {
|
||||
reinit();
|
||||
} else {
|
||||
// Add the metadata
|
||||
sendFeedback("Adding metadata…");
|
||||
ipcRenderer.send("add-metadata", filePath, fileBuffer);
|
||||
ipcRenderer.once("add-metadata-done", reinit);
|
||||
sendFeedback("Finished converting", metadata);
|
||||
}
|
||||
} catch (e) {
|
||||
sendError(e);
|
||||
|
||||
76
plugins/in-app-menu/back.js
Normal file
76
plugins/in-app-menu/back.js
Normal file
@ -0,0 +1,76 @@
|
||||
const path = require("path");
|
||||
|
||||
const { Menu } = require("electron");
|
||||
const electronLocalshortcut = require("electron-localshortcut");
|
||||
|
||||
const config = require("../../config");
|
||||
const { setApplicationMenu } = require("../../menu");
|
||||
const { injectCSS } = require("../utils");
|
||||
|
||||
//check that menu doesn't get created twice
|
||||
let done = false;
|
||||
// win hook for fixing menu
|
||||
let win;
|
||||
|
||||
const originalBuildMenu = Menu.buildFromTemplate;
|
||||
// This function natively gets called on all submenu so no more reason to use recursion
|
||||
Menu.buildFromTemplate = (template) => {
|
||||
// Fix checkboxes and radio buttons
|
||||
updateCheckboxesAndRadioButtons(win, template);
|
||||
|
||||
// return as normal
|
||||
return originalBuildMenu(template);
|
||||
};
|
||||
|
||||
module.exports = (winImport) => {
|
||||
win = winImport;
|
||||
|
||||
// css for custom scrollbar + disable drag area(was causing bugs)
|
||||
injectCSS(win.webContents, path.join(__dirname, "style.css"));
|
||||
|
||||
win.on("ready-to-show", () => {
|
||||
// (apparently ready-to-show is called twice)
|
||||
if (done) {
|
||||
return;
|
||||
}
|
||||
done = true;
|
||||
|
||||
setApplicationMenu(win);
|
||||
|
||||
//register keyboard shortcut && hide menu if hideMenu is enabled
|
||||
if (config.get("options.hideMenu")) {
|
||||
switchMenuVisibility(win);
|
||||
electronLocalshortcut.register(win, "Esc", () => {
|
||||
switchMenuVisibility(win);
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let visible = true;
|
||||
function switchMenuVisibility(win) {
|
||||
visible = !visible;
|
||||
win.webContents.send("updateMenu", visible);
|
||||
}
|
||||
|
||||
function checkCheckbox(win, item) {
|
||||
//check item
|
||||
item.checked = !item.checked;
|
||||
//update menu (closes it)
|
||||
win.webContents.send("updateMenu", true);
|
||||
}
|
||||
|
||||
// Update checkboxes/radio buttons
|
||||
function updateCheckboxesAndRadioButtons(win, template) {
|
||||
for (let item of template) {
|
||||
// Change onClick of checkbox+radio
|
||||
if ((item.type === "checkbox" || item.type === "radio") && !item.fixed) {
|
||||
let originalOnclick = item.click;
|
||||
item.click = (itemClicked) => {
|
||||
originalOnclick(itemClicked);
|
||||
checkCheckbox(win, itemClicked);
|
||||
};
|
||||
item.fixed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
24
plugins/in-app-menu/front.js
Normal file
24
plugins/in-app-menu/front.js
Normal file
@ -0,0 +1,24 @@
|
||||
const { remote, ipcRenderer } = require("electron");
|
||||
|
||||
const customTitlebar = require("custom-electron-titlebar");
|
||||
|
||||
module.exports = () => {
|
||||
const bar = new customTitlebar.Titlebar({
|
||||
backgroundColor: customTitlebar.Color.fromHex("#050505"),
|
||||
itemBackgroundColor: customTitlebar.Color.fromHex("#121212"),
|
||||
});
|
||||
bar.updateTitle(" ");
|
||||
document.title = "Youtube Music";
|
||||
|
||||
ipcRenderer.on("updateMenu", function (event, menu) {
|
||||
if (menu) {
|
||||
bar.updateMenu(remote.Menu.getApplicationMenu());
|
||||
} else {
|
||||
try {
|
||||
bar.updateMenu(null);
|
||||
} catch (e) {
|
||||
//will always throw type error - null isn't menu, but it works
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
69
plugins/in-app-menu/style.css
Normal file
69
plugins/in-app-menu/style.css
Normal file
@ -0,0 +1,69 @@
|
||||
/* increase font size for menu and menuItems */
|
||||
.titlebar,
|
||||
.menubar-menu-container .action-label {
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
/* allow submenu's to show correctly */
|
||||
.menubar-menu-container {
|
||||
overflow-y: visible !important;
|
||||
}
|
||||
/* fixes scrollbar positioning relative to nav bar */
|
||||
#nav-bar-background.ytmusic-app-layout {
|
||||
right: 15px !important;
|
||||
}
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* Move navBar downwards and make it opaque */
|
||||
ytmusic-app-layout {
|
||||
--ytmusic-nav-bar-height: 120px;
|
||||
}
|
||||
|
||||
ytmusic-search-box.ytmusic-nav-bar {
|
||||
margin-top: 29px;
|
||||
}
|
||||
|
||||
.center-content.ytmusic-nav-bar {
|
||||
background: #030303;
|
||||
}
|
||||
yt-page-navigation-progress,
|
||||
#progress.yt-page-navigation-progress,
|
||||
ytmusic-item-section-renderer[has-item-section-tabbed-header-renderer_]
|
||||
#header.ytmusic-item-section-renderer,
|
||||
ytmusic-header-renderer.ytmusic-search-page {
|
||||
top: 90px !important;
|
||||
}
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
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);
|
||||
}
|
||||
|
||||
/* The scrollbar 'thumb' ...that marque oval shape in a scrollbar */
|
||||
::-webkit-scrollbar-thumb:vertical {
|
||||
background-clip: padding-box;
|
||||
border: 2px solid rgba(0, 0, 0, 0);
|
||||
|
||||
background: rgb(49, 0, 0);
|
||||
border-radius: 100px;
|
||||
-moz-border-radius: 100px;
|
||||
-webkit-border-radius: 100px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:vertical:active {
|
||||
background: rgb(56, 0, 0); /* Some darker color when you click it */
|
||||
border-radius: 100px;
|
||||
-moz-border-radius: 100px;
|
||||
-webkit-border-radius: 100px;
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
const { Notification } = require("electron");
|
||||
const getSongInfo = require("../../providers/song-info");
|
||||
|
||||
const notify = info => {
|
||||
const notify = (info, options) => {
|
||||
let notificationImage = "assets/youtube-music.png";
|
||||
|
||||
if (info.image) {
|
||||
@ -14,27 +14,38 @@ const notify = info => {
|
||||
body: info.artist,
|
||||
icon: notificationImage,
|
||||
silent: true,
|
||||
urgency: options.urgency,
|
||||
};
|
||||
|
||||
|
||||
// Send the notification
|
||||
currentNotification = new Notification(notification);
|
||||
const currentNotification = new Notification(notification);
|
||||
currentNotification.show()
|
||||
|
||||
|
||||
return currentNotification;
|
||||
};
|
||||
|
||||
module.exports = (win) => {
|
||||
module.exports = (win, options) => {
|
||||
const registerCallback = getSongInfo(win);
|
||||
let oldNotification;
|
||||
let oldURL = "";
|
||||
win.on("ready-to-show", () => {
|
||||
// Register the callback for new song information
|
||||
registerCallback(songInfo => {
|
||||
// If song is playing send notification
|
||||
if (!songInfo.isPaused) {
|
||||
// on pause - reset url? and skip notification
|
||||
if (songInfo.isPaused) {
|
||||
//reset oldURL if unpause notification option is on
|
||||
if (options.unpauseNotification) {
|
||||
oldURL = "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
// If url isn't the same as last one - send notification
|
||||
if (songInfo.url !== oldURL) {
|
||||
oldURL = songInfo.url;
|
||||
// Close the old notification
|
||||
oldNotification?.close();
|
||||
// This fixes a weird bug that would cause the notification to be updated instead of showing
|
||||
setTimeout(()=>{ oldNotification = notify(songInfo) }, 10);
|
||||
setTimeout(()=>{ oldNotification = notify(songInfo, options) }, 10);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
19
plugins/notifications/menu.js
Normal file
19
plugins/notifications/menu.js
Normal file
@ -0,0 +1,19 @@
|
||||
const {urgencyLevels, setUrgency, setUnpause} = require("./utils");
|
||||
|
||||
module.exports = (win, options) => [
|
||||
{
|
||||
label: "Notification Priority",
|
||||
submenu: urgencyLevels.map(level => ({
|
||||
label: level.name,
|
||||
type: "radio",
|
||||
checked: options.urgency === level.value,
|
||||
click: () => setUrgency(options, level.value)
|
||||
})),
|
||||
},
|
||||
{
|
||||
label: "Show notification on unpause",
|
||||
type: "checkbox",
|
||||
checked: options.unpauseNotification,
|
||||
click: (item) => setUnpause(options, item.checked)
|
||||
}
|
||||
];
|
||||
19
plugins/notifications/utils.js
Normal file
19
plugins/notifications/utils.js
Normal file
@ -0,0 +1,19 @@
|
||||
const {setOptions} = require("../../config/plugins");
|
||||
|
||||
module.exports.urgencyLevels = [
|
||||
{name: "Low", value: "low"},
|
||||
{name: "Normal", value: "normal"},
|
||||
{name: "High", value: "critical"},
|
||||
];
|
||||
module.exports.setUrgency = (options, level) => {
|
||||
options.urgency = level
|
||||
setOption(options)
|
||||
};
|
||||
module.exports.setUnpause = (options, value) => {
|
||||
options.unpauseNotification = value
|
||||
setOption(options)
|
||||
};
|
||||
|
||||
let setOption = options => {
|
||||
setOptions("notifications", options)
|
||||
};
|
||||
BIN
plugins/taskbar-mediacontrol/assets/backward.png
Normal file
BIN
plugins/taskbar-mediacontrol/assets/backward.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 269 B |
BIN
plugins/taskbar-mediacontrol/assets/forward.png
Normal file
BIN
plugins/taskbar-mediacontrol/assets/forward.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 250 B |
BIN
plugins/taskbar-mediacontrol/assets/pause.png
Normal file
BIN
plugins/taskbar-mediacontrol/assets/pause.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 192 B |
BIN
plugins/taskbar-mediacontrol/assets/play.png
Normal file
BIN
plugins/taskbar-mediacontrol/assets/play.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 265 B |
58
plugins/taskbar-mediacontrol/back.js
Normal file
58
plugins/taskbar-mediacontrol/back.js
Normal file
@ -0,0 +1,58 @@
|
||||
const getSongControls = require('../../providers/song-controls');
|
||||
const getSongInfo = require('../../providers/song-info');
|
||||
const path = require('path');
|
||||
|
||||
module.exports = win => {
|
||||
win.hide = function () {
|
||||
win.minimize();
|
||||
win.setSkipTaskbar(true);
|
||||
};
|
||||
|
||||
const show = win.show;
|
||||
win.show = function () {
|
||||
win.restore();
|
||||
win.focus();
|
||||
win.setSkipTaskbar(false);
|
||||
show.apply(win);
|
||||
};
|
||||
|
||||
win.isVisible = function () {
|
||||
return !win.isMinimized();
|
||||
};
|
||||
|
||||
const registerCallback = getSongInfo(win);
|
||||
const {playPause, next, previous} = getSongControls(win);
|
||||
|
||||
// If the page is ready, register the callback
|
||||
win.on('ready-to-show', () => {
|
||||
registerCallback(songInfo => {
|
||||
// Wait for song to start before setting thumbar
|
||||
if (songInfo.title === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Win32 require full rewrite of components
|
||||
win.setThumbarButtons([
|
||||
{
|
||||
tooltip: 'Previous',
|
||||
icon: get('backward.png'),
|
||||
click() {previous(win.webContents);}
|
||||
}, {
|
||||
tooltip: 'Play/Pause',
|
||||
// Update icon based on play state
|
||||
icon: songInfo.isPaused ? get('play.png') : get('pause.png'),
|
||||
click() {playPause(win.webContents);}
|
||||
}, {
|
||||
tooltip: 'Next',
|
||||
icon: get('forward.png'),
|
||||
click() {next(win.webContents);}
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Util
|
||||
function get(file) {
|
||||
return path.join(__dirname,"assets", file);
|
||||
}
|
||||
@ -1,22 +1,32 @@
|
||||
const { ipcRenderer } = require("electron");
|
||||
|
||||
const { getImage } = require("./song-info");
|
||||
|
||||
global.songInfo = {};
|
||||
|
||||
ipcRenderer.on("update-song-info", async (_, extractedSongInfo) => {
|
||||
global.songInfo = JSON.parse(extractedSongInfo);
|
||||
global.songInfo.image = await getImage(global.songInfo.imageSrc);
|
||||
});
|
||||
|
||||
const injectListener = () => {
|
||||
var oldXHR = window.XMLHttpRequest;
|
||||
function newXHR() {
|
||||
var realXHR = new oldXHR();
|
||||
realXHR.addEventListener("readystatechange", () => {
|
||||
if(realXHR.readyState==4 && realXHR.status==200){
|
||||
if (realXHR.responseURL.includes('/player')){
|
||||
// if the request is the contains the song info send the response to ipcMain
|
||||
ipcRenderer.send(
|
||||
"song-info-request",
|
||||
realXHR.responseText
|
||||
);
|
||||
realXHR.addEventListener(
|
||||
"readystatechange",
|
||||
() => {
|
||||
if (realXHR.readyState == 4 && realXHR.status == 200) {
|
||||
if (realXHR.responseURL.includes("/player")) {
|
||||
// if the request contains the song info, send the response to ipcMain
|
||||
ipcRenderer.send("song-info-request", realXHR.responseText);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, false);
|
||||
},
|
||||
false
|
||||
);
|
||||
return realXHR;
|
||||
}
|
||||
window.XMLHttpRequest = newXHR;
|
||||
}
|
||||
};
|
||||
module.exports = injectListener;
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
const { ipcMain } = require("electron");
|
||||
const { nativeImage } = require("electron");
|
||||
const { ipcMain, nativeImage } = require("electron");
|
||||
|
||||
const fetch = require("node-fetch");
|
||||
|
||||
@ -38,21 +37,25 @@ const songInfo = {
|
||||
uploadDate: "",
|
||||
imageSrc: "",
|
||||
image: null,
|
||||
isPaused: true,
|
||||
isPaused: undefined,
|
||||
songDuration: 0,
|
||||
elapsedSeconds: 0,
|
||||
url: "",
|
||||
};
|
||||
|
||||
const handleData = async (_event, responseText) => {
|
||||
data = JSON.parse(responseText);
|
||||
const handleData = async (responseText, win) => {
|
||||
let data = JSON.parse(responseText);
|
||||
songInfo.title = data?.videoDetails?.title;
|
||||
songInfo.artist = data?.videoDetails?.author;
|
||||
songInfo.views = data?.videoDetails?.viewCount;
|
||||
songInfo.imageSrc = data?.videoDetails?.thumbnail?.thumbnails?.[0]?.url;
|
||||
songInfo.imageSrc = data?.videoDetails?.thumbnail?.thumbnails?.pop()?.url;
|
||||
songInfo.songDuration = data?.videoDetails?.lengthSeconds;
|
||||
songInfo.image = await getImage(songInfo.imageSrc);
|
||||
songInfo.uploadDate = data?.microformat?.microformatDataRenderer?.uploadDate;
|
||||
}
|
||||
songInfo.url = data?.microformat?.microformatDataRenderer?.urlCanonical;
|
||||
|
||||
win.webContents.send("update-song-info", JSON.stringify(songInfo));
|
||||
};
|
||||
|
||||
const registerProvider = (win) => {
|
||||
// This variable will be filled with the callbacks once they register
|
||||
@ -77,9 +80,15 @@ const registerProvider = (win) => {
|
||||
});
|
||||
|
||||
// This will be called when the song-info-front finds a new request with song data
|
||||
ipcMain.on('song-info-request', handleData);
|
||||
ipcMain.on("song-info-request", async (_, responseText) => {
|
||||
await handleData(responseText, win);
|
||||
callbacks.forEach((c) => {
|
||||
c(songInfo);
|
||||
});
|
||||
});
|
||||
|
||||
return registerCallback;
|
||||
};
|
||||
|
||||
module.exports = registerProvider;
|
||||
module.exports.getImage = getImage;
|
||||
|
||||
8
tray.js
8
tray.js
@ -32,7 +32,7 @@ module.exports.setUpTray = (app, win) => {
|
||||
}
|
||||
});
|
||||
|
||||
const trayMenu = Menu.buildFromTemplate([
|
||||
let template = [
|
||||
{
|
||||
label: "Play/Pause",
|
||||
click: () => {
|
||||
@ -57,13 +57,15 @@ module.exports.setUpTray = (app, win) => {
|
||||
win.show();
|
||||
},
|
||||
},
|
||||
...mainMenuTemplate(win),
|
||||
...mainMenuTemplate(win, true, true),
|
||||
{
|
||||
label: "Quit",
|
||||
click: () => {
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
]);
|
||||
];
|
||||
|
||||
const trayMenu = Menu.buildFromTemplate(template);
|
||||
tray.setContextMenu(trayMenu);
|
||||
};
|
||||
|
||||
67
yarn.lock
67
yarn.lock
@ -282,45 +282,45 @@
|
||||
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
|
||||
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
|
||||
|
||||
"@cliqz/adblocker-content@^1.20.0":
|
||||
version "1.20.0"
|
||||
resolved "https://registry.yarnpkg.com/@cliqz/adblocker-content/-/adblocker-content-1.20.0.tgz#fcfa2845a577ba8d9af282afbae2fc437b3f1c70"
|
||||
integrity sha512-KcokmK2B+tAnVMi7nGHgzXUVf78wAODG1Uk+K3tBPf9VAo3mwldYZ472uTj6LUfZv5oeTwe4PwfmPWXWZy3Eew==
|
||||
"@cliqz/adblocker-content@^1.20.3":
|
||||
version "1.20.3"
|
||||
resolved "https://registry.yarnpkg.com/@cliqz/adblocker-content/-/adblocker-content-1.20.3.tgz#198c8719cd62ef3c67a5c98e7a54336b7812ed86"
|
||||
integrity sha512-aCBTiIiNgVbmDIQyUcsn0j3n+umvs0DuVlL6dccPE3qfeFxT4whUvMwjxUS2/dIBfJK9A1LywmvVke2eSPw9wg==
|
||||
dependencies:
|
||||
"@cliqz/adblocker-extended-selectors" "^1.20.0"
|
||||
"@cliqz/adblocker-extended-selectors" "^1.20.3"
|
||||
|
||||
"@cliqz/adblocker-electron-preload@^1.20.0":
|
||||
version "1.20.0"
|
||||
resolved "https://registry.yarnpkg.com/@cliqz/adblocker-electron-preload/-/adblocker-electron-preload-1.20.0.tgz#997b694fbb1b1206e04b1fd570690234cc7ef630"
|
||||
integrity sha512-brNQFjIoGTMClmFphtoK0EnjOlbqfxr6sA3CrOZiHfG0e07Id5GoU95re8+s8xA+/nd1GrJl/k5/b4aks+S9Gw==
|
||||
"@cliqz/adblocker-electron-preload@^1.20.3":
|
||||
version "1.20.3"
|
||||
resolved "https://registry.yarnpkg.com/@cliqz/adblocker-electron-preload/-/adblocker-electron-preload-1.20.3.tgz#17dff446ad742cb6e68a4572e7a75cff1fa33f95"
|
||||
integrity sha512-fWAFEGj+F0VOUKZd2FqWLuguXmGzkRQz5wTCqasvndX4HSe0P8Pd2666pWK9RJW1dLJE7U61mQfTbYqlUFVTMA==
|
||||
dependencies:
|
||||
"@cliqz/adblocker-content" "^1.20.0"
|
||||
"@cliqz/adblocker-content" "^1.20.3"
|
||||
|
||||
"@cliqz/adblocker-electron@^1.20.0":
|
||||
version "1.20.0"
|
||||
resolved "https://registry.yarnpkg.com/@cliqz/adblocker-electron/-/adblocker-electron-1.20.0.tgz#bacfb9feaf1d3dab339b992e3defa111a4b5ed3c"
|
||||
integrity sha512-zD881g+YxxO4BM6NB5qZtSevg9Cj7QtlCJ4tkcKZnD9MDQsNXQVIFFEWwqhd00kLkTUS0+jT0px9b81cigAPNg==
|
||||
"@cliqz/adblocker-electron@^1.20.1":
|
||||
version "1.20.3"
|
||||
resolved "https://registry.yarnpkg.com/@cliqz/adblocker-electron/-/adblocker-electron-1.20.3.tgz#f2b4bf5dddf90f64251c46f89238526dc0037384"
|
||||
integrity sha512-ZcEl3W7R/aoUA0IPIMtvdn7gVE6O9+rDQ9OllIH/s/gVeElXZsgPEtpPMSuoJWbi9d2mlr8yo3UFvkV3u7c4gw==
|
||||
dependencies:
|
||||
"@cliqz/adblocker" "^1.20.0"
|
||||
"@cliqz/adblocker-electron-preload" "^1.20.0"
|
||||
"@cliqz/adblocker" "^1.20.3"
|
||||
"@cliqz/adblocker-electron-preload" "^1.20.3"
|
||||
tldts-experimental "^5.6.21"
|
||||
|
||||
"@cliqz/adblocker-extended-selectors@^1.20.0":
|
||||
version "1.20.0"
|
||||
resolved "https://registry.yarnpkg.com/@cliqz/adblocker-extended-selectors/-/adblocker-extended-selectors-1.20.0.tgz#95ede657b670f627b39f92d85a97093cecee6ffe"
|
||||
integrity sha512-dnBPIngGe1eDWvYX49eP2yyCE2AY1QD5E+8SaXW6lslnjS0GQnkcXCAkkGR2am4Qdk78HAiWTXL65Zt9hdkupA==
|
||||
"@cliqz/adblocker-extended-selectors@^1.20.3":
|
||||
version "1.20.3"
|
||||
resolved "https://registry.yarnpkg.com/@cliqz/adblocker-extended-selectors/-/adblocker-extended-selectors-1.20.3.tgz#a817915948ec4e64c8b878a80a71d911ea0412c8"
|
||||
integrity sha512-Xsrqg4qgpNVx80UJrAz/nS8jcbgCTIGvir0MrjoXrw0GheqRxsgE540XXP9JA7QlifLNVEOO44DpHvhUmISkQw==
|
||||
|
||||
"@cliqz/adblocker@^1.20.0":
|
||||
version "1.20.0"
|
||||
resolved "https://registry.yarnpkg.com/@cliqz/adblocker/-/adblocker-1.20.0.tgz#514746e9ee72fcd886f1e2e1aaf13b28fc63f232"
|
||||
integrity sha512-lkEj0Pj1ikwMURrvoFv0YnLfaXFuJI+jexI7zdh4fDmlwRppzDDgOhPXgCczoAlYacJk5x2mf7pan6JybRD9Kw==
|
||||
"@cliqz/adblocker@^1.20.3":
|
||||
version "1.20.3"
|
||||
resolved "https://registry.yarnpkg.com/@cliqz/adblocker/-/adblocker-1.20.3.tgz#4e8d03ed03c476f7b4388d25f910b1b9e0b15cc9"
|
||||
integrity sha512-Dqj8fJ399kFsFQ53uW0ajA5jH5VJ5ppawOjtoV2s+7NILj1ydvw40jTrr3l/ObMvxaAGaDUj2Euo4beg3/EtRQ==
|
||||
dependencies:
|
||||
"@cliqz/adblocker-content" "^1.20.0"
|
||||
"@cliqz/adblocker-extended-selectors" "^1.20.0"
|
||||
"@cliqz/adblocker-content" "^1.20.3"
|
||||
"@cliqz/adblocker-extended-selectors" "^1.20.3"
|
||||
"@remusao/guess-url-type" "^1.1.2"
|
||||
"@remusao/small" "^1.1.2"
|
||||
"@remusao/smaz" "^1.7.1"
|
||||
"@types/chrome" "^0.0.128"
|
||||
"@types/chrome" "^0.0.133"
|
||||
"@types/firefox-webext-browser" "^82.0.0"
|
||||
tldts-experimental "^5.6.21"
|
||||
|
||||
@ -1014,10 +1014,10 @@
|
||||
"@types/node" "*"
|
||||
"@types/responselike" "*"
|
||||
|
||||
"@types/chrome@^0.0.128":
|
||||
version "0.0.128"
|
||||
resolved "https://registry.yarnpkg.com/@types/chrome/-/chrome-0.0.128.tgz#5dbd8b2539a367353fbe4386f119b510105f8b6a"
|
||||
integrity sha512-eGc599TDtersMBW1cSnExHm0IHrXrO5xdk6Sa2Dq30ED+hR1rpT1ez0NNcCgvGO52nmktGfyvd3Uyquzv3LL4g==
|
||||
"@types/chrome@^0.0.133":
|
||||
version "0.0.133"
|
||||
resolved "https://registry.yarnpkg.com/@types/chrome/-/chrome-0.0.133.tgz#9e1d55441584ba2d5274ca84db36427da9c5dc6e"
|
||||
integrity sha512-G8uIUdaCTBILprQvQXBWGXZxjAWbkCkFQit17cdH3zYQEwU8f/etNl8+M7e8MRz9Xj8daHaVpysneMZMx8/ldQ==
|
||||
dependencies:
|
||||
"@types/filesystem" "*"
|
||||
"@types/har-format" "*"
|
||||
@ -2653,6 +2653,11 @@ cssstyle@^2.2.0:
|
||||
dependencies:
|
||||
cssom "~0.3.6"
|
||||
|
||||
custom-electron-titlebar@^3.2.6:
|
||||
version "3.2.6"
|
||||
resolved "https://registry.yarnpkg.com/custom-electron-titlebar/-/custom-electron-titlebar-3.2.6.tgz#4cd064efa5020954c09732efa8c667a7ee3636e3"
|
||||
integrity sha512-P3ZGEr0eouUHqhdBBXllpuy2bFhfSmp+32HQBPcwzujjIsUhQxQj/nCpJiFa4SUGAEp1ifu/icuZdDKNNX72Tw==
|
||||
|
||||
dashdash@^1.12.0:
|
||||
version "1.14.1"
|
||||
resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0"
|
||||
|
||||
Reference in New Issue
Block a user