// ==UserScript==
// @name BetterYTM
// @namespace https://github.com/Sv443/BetterYTM
// @version 1.0.1
// @description Configurable layout and UX improvements for YouTube Music
// @description:de Konfigurierbares Layout und UX-Verbesserungen für YouTube Music
// @homepageURL https://github.com/Sv443/BetterYTM#readme
// @supportURL https://github.com/Sv443/BetterYTM/issues
// @license MIT
// @author Sv443
// @copyright Sv443 (https://github.com/Sv443)
// @icon https://raw.githubusercontent.com/Sv443/BetterYTM/main/assets/icon/icon_48.png
// @match https://music.youtube.com/*
// @match https://www.youtube.com/*
// @run-at document-start
// @connect api.sv443.net
// @grant GM.getValue
// @grant GM.setValue
// @grant GM.getResourceUrl
// @grant GM.setClipboard
// @grant unsafeWindow
// @noframes
// @resource icon https://raw.githubusercontent.com/Sv443/BetterYTM/main/assets/icon/icon_48.png
// @resource close https://raw.githubusercontent.com/Sv443/BetterYTM/main/assets/close.png
// @resource delete https://raw.githubusercontent.com/Sv443/BetterYTM/main/assets/delete.svg
// @resource error https://raw.githubusercontent.com/Sv443/BetterYTM/main/assets/error.svg
// @resource lyrics https://raw.githubusercontent.com/Sv443/BetterYTM/main/assets/lyrics.svg
// @resource spinner https://raw.githubusercontent.com/Sv443/BetterYTM/main/assets/spinner.svg
// @resource arrow_down https://raw.githubusercontent.com/Sv443/BetterYTM/main/assets/arrow_down.svg
// @resource skip_to https://raw.githubusercontent.com/Sv443/BetterYTM/main/assets/skip_to.svg
// @resource github https://raw.githubusercontent.com/Sv443/BetterYTM/main/assets/external/github.png
// @resource greasyfork https://raw.githubusercontent.com/Sv443/BetterYTM/main/assets/external/greasyfork.png
// ==/UserScript==
/*
▄▄▄ ▄ ▄▄▄▄▄▄ ▄
█ █ ▄▄▄ █ █ ▄▄▄ ▄ ▄█ █ █ █▀▄▀█
█▀▀▄ █▄█ █▀ █▀ █▄█ █▀ █ █ █ █
█▄▄▀ ▀▄▄ ▀▄▄ ▀▄▄ ▀▄▄ █ █ █ █ █
Made with ❤️ by Sv443
I welcome every contribution on GitHub!
https://github.com/Sv443/BetterYTM
*/
/* Disclaimer: I am not affiliated with or endorsed by YouTube, Google, Alphabet, Genius or anyone else */
/* C&D this 🖕 */
var __webpack_exports__ = {};
;// CONCATENATED MODULE: ./node_modules/@sv443-network/userutils/dist/index.mjs
var __defProp = Object.defineProperty;
var __defProps = Object.defineProperties;
var __getOwnPropDescs = Object.getOwnPropertyDescriptors;
var __getOwnPropSymbols = Object.getOwnPropertySymbols;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __propIsEnum = Object.prototype.propertyIsEnumerable;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __spreadValues = (a, b) => {
for (var prop in b || (b = {}))
if (__hasOwnProp.call(b, prop))
__defNormalProp(a, prop, b[prop]);
if (__getOwnPropSymbols)
for (var prop of __getOwnPropSymbols(b)) {
if (__propIsEnum.call(b, prop))
__defNormalProp(a, prop, b[prop]);
}
return a;
};
var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b));
var __publicField = (obj, key, value) => {
__defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
return value;
};
var __async = (__this, __arguments, generator) => {
return new Promise((resolve, reject) => {
var fulfilled = (value) => {
try {
step(generator.next(value));
} catch (e) {
reject(e);
}
};
var rejected = (value) => {
try {
step(generator.throw(value));
} catch (e) {
reject(e);
}
};
var step = (x) => x.done ? resolve(x.value) : Promise.resolve(x.value).then(fulfilled, rejected);
step((generator = generator.apply(__this, __arguments)).next());
});
};
// lib/math.ts
function clamp(value, min, max) {
return Math.max(Math.min(value, max), min);
}
function mapRange(value, range_1_min, range_1_max, range_2_min, range_2_max) {
if (Number(range_1_min) === 0 && Number(range_2_min) === 0)
return value * (range_2_max / range_1_max);
return (value - range_1_min) * ((range_2_max - range_2_min) / (range_1_max - range_1_min)) + range_2_min;
}
function randRange(...args) {
let min, max;
if (typeof args[0] === "number" && typeof args[1] === "number") {
[min, max] = args;
} else if (typeof args[0] === "number" && typeof args[1] !== "number") {
min = 0;
max = args[0];
} else
throw new TypeError(`Wrong parameter(s) provided - expected: "number" and "number|undefined", got: "${typeof args[0]}" and "${typeof args[1]}"`);
min = Number(min);
max = Number(max);
if (isNaN(min) || isNaN(max))
throw new TypeError(`Parameters "min" and "max" can't be NaN`);
if (min > max)
throw new TypeError(`Parameter "min" can't be bigger than "max"`);
return Math.floor(Math.random() * (max - min + 1)) + min;
}
// lib/array.ts
function randomItem(array) {
return randomItemIndex(array)[0];
}
function randomItemIndex(array) {
if (array.length === 0)
return [void 0, void 0];
const idx = randRange(array.length - 1);
return [array[idx], idx];
}
function takeRandomItem(arr) {
const [itm, idx] = randomItemIndex(arr);
if (idx === void 0)
return void 0;
arr.splice(idx, 1);
return itm;
}
function randomizeArray(array) {
const retArray = [...array];
if (array.length === 0)
return array;
for (let i = retArray.length - 1; i > 0; i--) {
const j = Math.floor(randRange(0, 1e4) / 1e4 * (i + 1));
[retArray[i], retArray[j]] = [retArray[j], retArray[i]];
}
return retArray;
}
// lib/config.ts
var ConfigManager = class {
/**
* Creates an instance of ConfigManager to manage a user configuration that is cached in memory and persistently saved across sessions.
* Supports migrating data from older versions of the configuration to newer ones and populating the cache with default data if no persistent data is found.
*
* ⚠️ Requires the directives `@grant GM.getValue` and `@grant GM.setValue`
* ⚠️ Make sure to call `loadData()` at least once after creating an instance, or the returned data will be the same as `options.defaultConfig`
*
* @template TData The type of the data that is saved in persistent storage (will be automatically inferred from `config.defaultConfig`) - this should also be the type of the data format associated with the current `options.formatVersion`
* @param options The options for this ConfigManager instance
*/
constructor(options) {
__publicField(this, "id");
__publicField(this, "formatVersion");
__publicField(this, "defaultConfig");
__publicField(this, "cachedConfig");
__publicField(this, "migrations");
this.id = options.id;
this.formatVersion = options.formatVersion;
this.defaultConfig = options.defaultConfig;
this.cachedConfig = options.defaultConfig;
this.migrations = options.migrations;
}
/**
* Loads the data saved in persistent storage into the in-memory cache and also returns it.
* Automatically populates persistent storage with default data if it doesn't contain any data yet.
* Also runs all necessary migration functions if the data format has changed since the last time the data was saved.
*/
loadData() {
return __async(this, null, function* () {
try {
const gmData = yield GM.getValue(`_uucfg-${this.id}`, this.defaultConfig);
let gmFmtVer = Number(yield GM.getValue(`_uucfgver-${this.id}`));
if (typeof gmData !== "string") {
yield this.saveDefaultData();
return this.defaultConfig;
}
if (isNaN(gmFmtVer))
yield GM.setValue(`_uucfgver-${this.id}`, gmFmtVer = this.formatVersion);
let parsed = JSON.parse(gmData);
if (gmFmtVer < this.formatVersion && this.migrations)
parsed = yield this.runMigrations(parsed, gmFmtVer);
return this.cachedConfig = typeof parsed === "object" ? parsed : void 0;
} catch (err) {
yield this.saveDefaultData();
return this.defaultConfig;
}
});
}
/** Returns a copy of the data from the in-memory cache. Use `loadData()` to get fresh data from persistent storage (usually not necessary since the cache should always exactly reflect persistent storage). */
getData() {
return this.deepCopy(this.cachedConfig);
}
/** Saves the data synchronously to the in-memory cache and asynchronously to the persistent storage */
setData(data) {
this.cachedConfig = data;
return new Promise((resolve) => __async(this, null, function* () {
yield Promise.all([
GM.setValue(`_uucfg-${this.id}`, JSON.stringify(data)),
GM.setValue(`_uucfgver-${this.id}`, this.formatVersion)
]);
resolve();
}));
}
/** Saves the default configuration data passed in the constructor synchronously to the in-memory cache and asynchronously to persistent storage */
saveDefaultData() {
return __async(this, null, function* () {
this.cachedConfig = this.defaultConfig;
return new Promise((resolve) => __async(this, null, function* () {
yield Promise.all([
GM.setValue(`_uucfg-${this.id}`, JSON.stringify(this.defaultConfig)),
GM.setValue(`_uucfgver-${this.id}`, this.formatVersion)
]);
resolve();
}));
});
}
/**
* Call this method to clear all persistently stored data associated with this ConfigManager instance.
* The in-memory cache will be left untouched, so you may still access the data with `getData()`.
* Calling `loadData()` or `setData()` after this method was called will recreate persistent storage with the cached or default data.
*
* ⚠️ This requires the additional directive `@grant GM.deleteValue`
*/
deleteConfig() {
return __async(this, null, function* () {
yield Promise.all([
GM.deleteValue(`_uucfg-${this.id}`),
GM.deleteValue(`_uucfgver-${this.id}`)
]);
});
}
/** Runs all necessary migration functions consecutively - may be overwritten in a subclass */
runMigrations(oldData, oldFmtVer) {
return __async(this, null, function* () {
if (!this.migrations)
return oldData;
let newData = oldData;
const sortedMigrations = Object.entries(this.migrations).sort(([a], [b]) => Number(a) - Number(b));
let lastFmtVer = oldFmtVer;
for (const [fmtVer, migrationFunc] of sortedMigrations) {
const ver = Number(fmtVer);
if (oldFmtVer < this.formatVersion && oldFmtVer < ver) {
try {
const migRes = migrationFunc(newData);
newData = migRes instanceof Promise ? yield migRes : migRes;
lastFmtVer = oldFmtVer = ver;
} catch (err) {
console.error(`Error while running migration function for format version ${fmtVer}:`, err);
}
}
}
yield Promise.all([
GM.setValue(`_uucfg-${this.id}`, JSON.stringify(newData)),
GM.setValue(`_uucfgver-${this.id}`, lastFmtVer)
]);
return newData;
});
}
/** Copies a JSON-compatible object and loses its internal references */
deepCopy(obj) {
return JSON.parse(JSON.stringify(obj));
}
};
// lib/dom.ts
function getUnsafeWindow() {
try {
return unsafeWindow;
} catch (e) {
return window;
}
}
function insertAfter(beforeElement, afterElement) {
var _a;
(_a = beforeElement.parentNode) == null ? void 0 : _a.insertBefore(afterElement, beforeElement.nextSibling);
return afterElement;
}
function addParent(element, newParent) {
const oldParent = element.parentNode;
if (!oldParent)
throw new Error("Element doesn't have a parent node");
oldParent.replaceChild(newParent, element);
newParent.appendChild(element);
return newParent;
}
function addGlobalStyle(style) {
const styleElem = document.createElement("style");
styleElem.innerHTML = style;
document.head.appendChild(styleElem);
}
function preloadImages(srcUrls, rejects = false) {
const promises = srcUrls.map((src) => new Promise((res, rej) => {
const image = new Image();
image.src = src;
image.addEventListener("load", () => res(image));
image.addEventListener("error", (evt) => rejects && rej(evt));
}));
return Promise.allSettled(promises);
}
function openInNewTab(href) {
const openElem = document.createElement("a");
Object.assign(openElem, {
className: "userutils-open-in-new-tab",
target: "_blank",
rel: "noopener noreferrer",
href
});
openElem.style.display = "none";
document.body.appendChild(openElem);
openElem.click();
setTimeout(openElem.remove, 50);
}
function interceptEvent(eventObject, eventName, predicate) {
if (typeof Error.stackTraceLimit === "number" && Error.stackTraceLimit < 1e3) {
Error.stackTraceLimit = 1e3;
}
(function(original) {
eventObject.__proto__.addEventListener = function(...args) {
var _a, _b;
const origListener = typeof args[1] === "function" ? args[1] : (_b = (_a = args[1]) == null ? void 0 : _a.handleEvent) != null ? _b : () => void 0;
args[1] = function(...a) {
if (args[0] === eventName && predicate(Array.isArray(a) ? a[0] : a))
return;
else
return origListener.apply(this, a);
};
original.apply(this, args);
};
})(eventObject.__proto__.addEventListener);
}
function interceptWindowEvent(eventName, predicate) {
return interceptEvent(getUnsafeWindow(), eventName, predicate);
}
function amplifyMedia(mediaElement, multiplier = 1) {
const context = new (window.AudioContext || window.webkitAudioContext)();
const result = {
mediaElement,
amplify: (multiplier2) => {
result.gain.gain.value = multiplier2;
},
getAmpLevel: () => result.gain.gain.value,
context,
source: context.createMediaElementSource(mediaElement),
gain: context.createGain()
};
result.source.connect(result.gain);
result.gain.connect(context.destination);
result.amplify(multiplier);
return result;
}
function isScrollable(element) {
const { overflowX, overflowY } = getComputedStyle(element);
return {
vertical: (overflowY === "scroll" || overflowY === "auto") && element.scrollHeight > element.clientHeight,
horizontal: (overflowX === "scroll" || overflowX === "auto") && element.scrollWidth > element.clientWidth
};
}
// lib/misc.ts
function autoPlural(word, num) {
if (Array.isArray(num) || num instanceof NodeList)
num = num.length;
return `${word}${num === 1 ? "" : "s"}`;
}
function pauseFor(time) {
return new Promise((res) => {
setTimeout(() => res(), time);
});
}
function debounce(func, timeout = 300) {
let timer;
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => func.apply(this, args), timeout);
};
}
function fetchAdvanced(_0) {
return __async(this, arguments, function* (url, options = {}) {
const { timeout = 1e4 } = options;
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeout);
const res = yield fetch(url, __spreadProps(__spreadValues({}, options), {
signal: controller.signal
}));
clearTimeout(id);
return res;
});
}
// lib/onSelector.ts
var selectorMap = /* @__PURE__ */ new Map();
function onSelector(selector, options) {
let selectorMapItems = [];
if (selectorMap.has(selector))
selectorMapItems = selectorMap.get(selector);
selectorMapItems.push(options);
selectorMap.set(selector, selectorMapItems);
checkSelectorExists(selector, selectorMapItems);
}
function removeOnSelector(selector) {
return selectorMap.delete(selector);
}
function checkSelectorExists(selector, options) {
const deleteIndices = [];
options.forEach((option, i) => {
try {
const elements = option.all ? document.querySelectorAll(selector) : document.querySelector(selector);
if (elements !== null && elements instanceof NodeList && elements.length > 0 || elements !== null) {
option.listener(elements);
if (!option.continuous)
deleteIndices.push(i);
}
} catch (err) {
console.error(`Couldn't call listener for selector '${selector}'`, err);
}
});
if (deleteIndices.length > 0) {
const newOptsArray = options.filter((_, i) => !deleteIndices.includes(i));
if (newOptsArray.length === 0)
selectorMap.delete(selector);
else {
selectorMap.set(selector, newOptsArray);
}
}
}
function initOnSelector(options = {}) {
const observer = new MutationObserver(() => {
for (const [selector, options2] of selectorMap.entries())
checkSelectorExists(selector, options2);
});
observer.observe(document.body, __spreadValues({
subtree: true,
childList: true
}, options));
}
function getSelectorMap() {
return selectorMap;
}
;// CONCATENATED MODULE: ./src/constants.ts
const modeRaw = "production";
const branchRaw = "main";
/** The mode in which the script was built (production or development) */
const mode = (modeRaw.match(/^{{.+}}$/) ? "production" : modeRaw);
/** The branch to use in various URLs that point to the GitHub repo */
const branch = (branchRaw.match(/^{{.+}}$/) ? "main" : branchRaw);
/**
* How much info should be logged to the devtools console
* 0 = Debug (show everything) or 1 = Info (show only important stuff)
*/
const defaultLogLevel = mode === "production" ? 1 : 0;
/** Info about the userscript, parsed from the userscript header (tools/post-build.js) */
const constants_scriptInfo = {
name: GM.info.script.name,
version: GM.info.script.version,
namespace: GM.info.script.namespace,
buildNumber: "ea97e21", // assert as generic string instead of literal
};
;// CONCATENATED MODULE: ./src/utils.ts
let curLogLevel = 1;
/** Common prefix to be able to tell logged messages apart and filter them in devtools */
const consPrefix = `[${constants_scriptInfo.name}]`;
const consPrefixDbg = (/* unused pure expression or super */ null && (`[${scriptInfo.name}/#DEBUG]`));
/** Sets the current log level. 0 = Debug, 1 = Info */
function setLogLevel(level) {
if (curLogLevel !== level)
console.log(consPrefix, "Setting log level to", level === 0 ? "Debug" : "Info");
curLogLevel = level;
}
/** Extracts the log level from the last item from spread arguments - returns 0 if the last item is not a number or too low or high */
function getLogLevel(args) {
const minLogLvl = 0, maxLogLvl = 1;
if (typeof args.at(-1) === "number")
return clamp(args.splice(args.length - 1)[0], minLogLvl, maxLogLvl);
return 0;
}
/**
* Logs all passed values to the console, as long as the log level is sufficient.
* @param args Last parameter is log level (0 = Debug, 1/undefined = Info) - any number as the last parameter will be stripped out! Convert to string if they shouldn't be.
*/
function log(...args) {
if (curLogLevel <= getLogLevel(args))
console.log(consPrefix, ...args);
}
/**
* Logs all passed values to the console as info, as long as the log level is sufficient.
* @param args Last parameter is log level (0 = Debug, 1/undefined = Info) - any number as the last parameter will be stripped out! Convert to string if they shouldn't be.
*/
function utils_info(...args) {
if (curLogLevel <= getLogLevel(args))
console.info(consPrefix, ...args);
}
/** Logs all passed values to the console as a warning, no matter the log level. */
function warn(...args) {
console.warn(consPrefix, ...args);
}
/** Logs all passed values to the console as an error, no matter the log level. */
function error(...args) {
console.error(consPrefix, ...args);
}
/** Logs all passed values to the console with a debug-specific prefix */
function dbg(...args) {
console.log(consPrefixDbg, ...args);
}
/**
* Returns the current video time in seconds
* Dispatches mouse movement events in case the video time can't be estimated
* @returns Returns null if the video time is unavailable
*/
function getVideoTime() {
return new Promise((res) => {
const domain = getDomain();
try {
if (domain === "ytm") {
onSelector("#progress-bar", {
listener: (pbEl) => res(!isNaN(Number(pbEl.value)) ? Number(pbEl.value) : null)
});
}
else if (domain === "yt") {
// YT doesn't update the progress bar when it's hidden (contrary to YTM which never hides it)
ytForceShowVideoTime();
const pbSelector = ".ytp-chrome-bottom div.ytp-progress-bar[role=\"slider\"]";
let videoTime = -1;
const mut = new MutationObserver(() => {
// .observe() is only called when the element exists - no need to check for null
videoTime = Number(document.querySelector(pbSelector).getAttribute("aria-valuenow"));
});
const observe = (progElem) => {
mut.observe(progElem, {
attributes: true,
attributeFilter: ["aria-valuenow"],
});
if (videoTime >= 0 && !isNaN(videoTime)) {
res(videoTime);
mut.disconnect();
}
else
setTimeout(() => {
res(videoTime >= 0 && !isNaN(videoTime) ? videoTime : null);
mut.disconnect();
}, 500);
};
onSelector(pbSelector, { listener: observe });
}
}
catch (err) {
error("Couldn't get video time due to error:", err);
res(null);
}
});
}
/**
* Sends events that force the video controls to become visible for about 3 seconds.
* This only works once, then the page needs to be reloaded!
*/
function ytForceShowVideoTime() {
const player = document.querySelector("#movie_player");
if (!player)
return false;
const defaultProps = {
// needed because otherwise YTM errors out - see https://github.com/Sv443/BetterYTM/issues/18#show_issue
view: getUnsafeWindow(),
bubbles: true,
cancelable: false,
};
player.dispatchEvent(new MouseEvent("mouseenter", defaultProps));
const { x, y, width, height } = player.getBoundingClientRect();
const screenY = Math.round(y + height / 2);
const screenX = x + Math.min(50, Math.round(width / 3));
player.dispatchEvent(new MouseEvent("mousemove", Object.assign(Object.assign({}, defaultProps), { screenY,
screenX, movementX: 5, movementY: 0 })));
return true;
}
/**
* Returns the current domain as a constant string representation
* @throws Throws if script runs on an unexpected website
*/
function getDomain() {
if (location.hostname.match(/^music\.youtube/))
return "ytm";
else if (location.hostname.match(/youtube\./))
return "yt";
else
throw new Error("BetterYTM is running on an unexpected website. Please don't tamper with the @match directives in the userscript header.");
}
/** Returns the URL of a resource by its name, as defined in `assets/resources.json`, from GM resource cache - [see GM.getResourceUrl docs](https://wiki.greasespot.net/GM.getResourceUrl) */
function getResourceUrl(name) {
return GM.getResourceUrl(name);
}
;// CONCATENATED MODULE: ./node_modules/nanoevents/index.js
let createNanoEvents = () => ({
emit(event, ...args) {
let callbacks = this.events[event] || []
for (let i = 0, length = callbacks.length; i < length; i++) {
callbacks[i](...args)
}
},
events: {},
on(event, cb) {
this.events[event]?.push(cb) || (this.events[event] = [cb])
return () => {
this.events[event] = this.events[event]?.filter(i => cb !== i)
}
}
})
;// CONCATENATED MODULE: ./src/events.ts
var __awaiter = (undefined && undefined.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
/** EventEmitter instance that is used to detect changes to the site */
const siteEvents = createNanoEvents();
let observers = [];
/** Disconnects and deletes all observers. Run `initSiteEvents()` again to create new ones. */
function removeAllObservers() {
observers.forEach((observer, i) => {
observer.disconnect();
delete observers[i];
});
observers = [];
}
/** Creates MutationObservers that check if parts of the site have changed, then emit an event on the `siteEvents` instance. */
function initSiteEvents() {
return __awaiter(this, void 0, void 0, function* () {
try {
// the queue container always exists so it doesn't need an extra init function
const queueObs = new MutationObserver(([{ addedNodes, removedNodes, target }]) => {
if (addedNodes.length > 0 || removedNodes.length > 0) {
utils_info(`Detected queue change - added nodes: ${[...addedNodes.values()].length} - removed nodes: ${[...removedNodes.values()].length}`);
siteEvents.emit("queueChanged", target);
}
});
// only observe added or removed elements
queueObs.observe(document.querySelector(".side-panel.modular #contents.ytmusic-player-queue"), {
childList: true,
});
const autoplayObs = new MutationObserver(([{ addedNodes, removedNodes, target }]) => {
if (addedNodes.length > 0 || removedNodes.length > 0) {
utils_info(`Detected autoplay queue change - added nodes: ${[...addedNodes.values()].length} - removed nodes: ${[...removedNodes.values()].length}`);
siteEvents.emit("autoplayQueueChanged", target);
}
});
autoplayObs.observe(document.querySelector(".side-panel.modular ytmusic-player-queue #automix-contents"), {
childList: true,
});
utils_info("Successfully initialized SiteEvents observers");
observers = observers.concat([
queueObs,
autoplayObs,
]);
}
catch (err) {
error("Couldn't initialize SiteEvents observers due to an error:\n", err);
}
});
}
;// CONCATENATED MODULE: ./changelog.md
// Module
var code = "<h2 id=\"101\">1.0.1</h2> <ul> <li><strong>Changes:</strong><ul> <li>Added a greasyfork link to the configuration menu</li> </ul> </li> </ul> <br> <h2 id=\"100\">1.0.0</h2> <ul> <li><strong>Added Features:</strong><ul> <li>Added configuration menu to toggle and configure all features</li> <li>Added lyrics button to each song in the queue</li> <li>Added "remove from queue" button to each song in the queue</li> <li>Use number keys to skip to a specific point in the song</li> <li>Added feature to make volume slider bigger and volume control finer</li> <li>Added percentage label next to the volume slider & title on hover</li> <li>Improvements to link hitboxes & more links in general</li> <li>Permanent toast notifications can be automatically closed now</li> <li>Remove tracking parameter <code>&si=...</code> from links in the share menu</li> <li>Fix spacing issues throughout the site</li> <li>Added a button to scroll to the currently active song in the queue</li> <li>Added an easter egg to the watermark and config menu option :)</li> </ul> </li> <li><strong>Changes & Fixes:</strong><ul> <li>Now the lyrics button will directly link to the lyrics (using my API <a href=\"https://github.com/Sv443/geniURL\">geniURL</a>)</li> <li>Video time is now kept when switching site on regular YT too</li> <li>Fixed compatibility with the new site design</li> <li>A loading indicator is shown while the lyrics are loading</li> <li>Images are now smaller and cached by the userscript extension</li> <li>Song names with hyphens are now resolved better for lyrics lookup</li> <li>Site switch with <kbd>F9</kbd> will now keep the video time</li> <li>Moved lots of utility code to my new library <a href=\"https://github.com/Sv443-Network/UserUtils\">UserUtils</a></li> </ul> </li> </ul> <br> <h2 id=\"020\">0.2.0</h2> <ul> <li><strong>Added Features:</strong><ul> <li>Switch between YouTube and YT Music (with <kbd>F9</kbd> by default)</li> <li>Search for song lyrics with new button in media controls</li> <li>Remove "Upgrade to YTM Premium" tab</li> </ul> </li> </ul> <br> <h2 id=\"010\">0.1.0</h2> <ul> <li>Added support for arrow keys to skip forward or backward (currently only by fixed 10 second interval)</li> </ul> ";
// Exports
/* harmony default export */ var changelog = (code);
;// CONCATENATED MODULE: ./src/menu/menu_old.ts
var menu_old_awaiter = (undefined && undefined.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
let isMenuOpen = false;
/** Threshold in pixels from the top of the options container that dictates for how long the scroll indicator is shown */
const scrollIndicatorOffsetThreshold = 30;
let scrollIndicatorEnabled = true;
/**
* Adds an element to open the BetterYTM menu
* @deprecated to be replaced with new menu - see https://github.com/Sv443/BetterYTM/issues/23
*/
function addMenu() {
var _a, _b;
return menu_old_awaiter(this, void 0, void 0, function* () {
const backgroundElem = document.createElement("div");
backgroundElem.id = "bytm-cfg-menu-bg";
backgroundElem.classList.add("bytm-menu-bg");
backgroundElem.title = "Click here to close the menu";
backgroundElem.style.visibility = "hidden";
backgroundElem.style.display = "none";
backgroundElem.addEventListener("click", (e) => {
var _a;
if (isMenuOpen && ((_a = e.target) === null || _a === void 0 ? void 0 : _a.id) === "bytm-cfg-menu-bg")
closeMenu(e);
});
document.body.addEventListener("keydown", (e) => {
if (isMenuOpen && e.key === "Escape")
closeMenu(e);
});
const menuContainer = document.createElement("div");
menuContainer.title = ""; // prevent bg title from propagating downwards
menuContainer.classList.add("bytm-menu");
menuContainer.id = "bytm-cfg-menu";
const headerElem = document.createElement("div");
headerElem.classList.add("bytm-menu-header");
const titleCont = document.createElement("div");
titleCont.id = "bytm-menu-titlecont";
titleCont.role = "heading";
titleCont.ariaLevel = "1";
const titleElem = document.createElement("h2");
titleElem.id = "bytm-menu-title";
titleElem.innerText = `${constants_scriptInfo.name} - Configuration`;
const linksCont = document.createElement("div");
linksCont.id = "bytm-menu-linkscont";
const addLink = (imgSrc, href, title) => {
const anchorElem = document.createElement("a");
anchorElem.className = "bytm-menu-link bytm-no-select";
anchorElem.rel = "noopener noreferrer";
anchorElem.target = "_blank";
anchorElem.href = href;
anchorElem.title = title;
const imgElem = document.createElement("img");
imgElem.className = "bytm-menu-img";
imgElem.src = imgSrc;
imgElem.style.width = "32px";
imgElem.style.height = "32px";
anchorElem.appendChild(imgElem);
linksCont.appendChild(anchorElem);
};
addLink(yield getResourceUrl("github"), constants_scriptInfo.namespace, `Open ${constants_scriptInfo.name} on GitHub`);
addLink(yield getResourceUrl("greasyfork"), "https://gf.qytechs.cn/en/scripts/475682-betterytm", `Open ${constants_scriptInfo.name} on GreasyFork`);
const closeElem = document.createElement("img");
closeElem.classList.add("bytm-menu-close");
closeElem.src = yield getResourceUrl("close");
closeElem.title = "Click to close the menu";
closeElem.addEventListener("click", closeMenu);
titleCont.appendChild(titleElem);
titleCont.appendChild(linksCont);
headerElem.appendChild(titleCont);
headerElem.appendChild(closeElem);
const featuresCont = document.createElement("div");
featuresCont.id = "bytm-menu-opts";
/** Gets called whenever the feature config is changed */
const confChanged = debounce((key, initialVal, newVal) => menu_old_awaiter(this, void 0, void 0, function* () {
const fmt = (val) => typeof val === "object" ? JSON.stringify(val) : String(val);
utils_info(`Feature config changed at key '${key}', from value '${fmt(initialVal)}' to '${fmt(newVal)}'`);
const featConf = Object.assign({}, getFeatures());
featConf[key] = newVal;
yield saveFeatures(featConf);
}));
const featureCfg = getFeatures();
const featureCfgWithCategories = Object.entries(featInfo)
.reduce((acc, [key, { category }]) => {
if (!acc[category])
acc[category] = {};
acc[category][key] = featureCfg[key];
return acc;
}, {});
const fmtVal = (v) => String(v).trim();
const toggleLabelText = (toggled) => toggled ? "On" : "Off";
for (const category in featureCfgWithCategories) {
const featObj = featureCfgWithCategories[category];
const catHeaderElem = document.createElement("h3");
catHeaderElem.classList.add("bytm-ftconf-category-header");
catHeaderElem.role = "heading";
catHeaderElem.ariaLevel = "2";
catHeaderElem.innerText = `${categoryNames[category]}:`;
featuresCont.appendChild(catHeaderElem);
for (const featKey in featObj) {
const ftInfo = featInfo[featKey];
// @ts-ignore
if (!ftInfo || ftInfo.hidden === true)
continue;
const { desc, type, default: ftDefault } = ftInfo;
// @ts-ignore
const step = (_a = ftInfo === null || ftInfo === void 0 ? void 0 : ftInfo.step) !== null && _a !== void 0 ? _a : undefined;
const val = featureCfg[featKey];
const initialVal = (_b = val !== null && val !== void 0 ? val : ftDefault) !== null && _b !== void 0 ? _b : undefined;
const ftConfElem = document.createElement("div");
ftConfElem.classList.add("bytm-ftitem");
{
const textElem = document.createElement("div");
textElem.innerText = desc;
ftConfElem.appendChild(textElem);
}
{
let inputType = "text";
let inputTag = "input";
switch (type) {
case "toggle":
inputType = "checkbox";
break;
case "slider":
inputType = "range";
break;
case "number":
inputType = "number";
break;
case "select":
inputTag = "select";
inputType = undefined;
break;
}
const inputElemId = `bytm-ftconf-${featKey}-input`;
const ctrlElem = document.createElement("span");
ctrlElem.classList.add("bytm-ftconf-ctrl");
const inputElem = document.createElement(inputTag);
inputElem.classList.add("bytm-ftconf-input");
inputElem.id = inputElemId;
if (inputType)
inputElem.type = inputType;
if (typeof initialVal !== "undefined")
inputElem.value = String(initialVal);
if (type === "number" && step)
inputElem.step = step;
// @ts-ignore
if (typeof ftInfo.min !== "undefined" && ftInfo.max !== "undefined") {
// @ts-ignore
inputElem.min = ftInfo.min;
// @ts-ignore
inputElem.max = ftInfo.max;
}
if (type === "toggle" && typeof initialVal !== "undefined")
inputElem.checked = Boolean(initialVal);
// @ts-ignore
const unitTxt = typeof ftInfo.unit === "string" ? " " + ftInfo.unit : "";
let labelElem;
if (type === "slider") {
labelElem = document.createElement("label");
labelElem.classList.add("bytm-ftconf-label", "bytm-slider-label");
labelElem.htmlFor = inputElemId;
labelElem.innerText = fmtVal(initialVal) + unitTxt;
inputElem.addEventListener("input", () => {
if (labelElem)
labelElem.innerText = fmtVal(parseInt(inputElem.value)) + unitTxt;
});
}
else if (type === "toggle") {
labelElem = document.createElement("label");
labelElem.classList.add("bytm-ftconf-label", "bytm-toggle-label");
labelElem.htmlFor = inputElemId;
labelElem.innerText = toggleLabelText(Boolean(initialVal)) + unitTxt;
inputElem.addEventListener("input", () => {
if (labelElem)
labelElem.innerText = toggleLabelText(inputElem.checked) + unitTxt;
});
}
else if (type === "select") {
for (const { value, label } of ftInfo.options) {
const optionElem = document.createElement("option");
optionElem.value = String(value);
optionElem.innerText = label;
if (value === initialVal)
optionElem.selected = true;
inputElem.appendChild(optionElem);
}
}
inputElem.addEventListener("input", () => {
let v = Number(String(inputElem.value).trim());
if (isNaN(v))
v = Number(inputElem.value);
if (typeof initialVal !== "undefined")
confChanged(featKey, initialVal, (type !== "toggle" ? v : inputElem.checked));
});
if (labelElem) {
labelElem.id = `bytm-ftconf-${featKey}-label`;
ctrlElem.appendChild(labelElem);
}
ctrlElem.appendChild(inputElem);
ftConfElem.appendChild(ctrlElem);
}
featuresCont.appendChild(ftConfElem);
}
}
siteEvents.on("rebuildCfgMenu", (newConfig) => {
for (const ftKey in featInfo) {
const ftElem = document.querySelector(`#bytm-ftconf-${ftKey}-input`);
const labelElem = document.querySelector(`#bytm-ftconf-${ftKey}-label`);
if (!ftElem)
continue;
const ftInfo = featInfo[ftKey];
const value = newConfig[ftKey];
if (ftInfo.type === "toggle")
ftElem.checked = Boolean(value);
else
ftElem.value = String(value);
if (!labelElem)
continue;
// @ts-ignore
const unitTxt = typeof ftInfo.unit === "string" ? " " + ftInfo.unit : "";
if (ftInfo.type === "slider")
labelElem.innerText = fmtVal(Number(value)) + unitTxt;
else if (ftInfo.type === "toggle")
labelElem.innerText = toggleLabelText(Boolean(value)) + unitTxt;
}
});
const scrollIndicator = document.createElement("img");
scrollIndicator.id = "bytm-menu-scroll-indicator";
scrollIndicator.src = yield getResourceUrl("arrow_down");
scrollIndicator.role = "button";
scrollIndicator.title = "Click to scroll to the bottom";
featuresCont.appendChild(scrollIndicator);
scrollIndicator.addEventListener("click", () => {
const bottomAnchor = document.querySelector("#bytm-menu-bottom-anchor");
bottomAnchor === null || bottomAnchor === void 0 ? void 0 : bottomAnchor.scrollIntoView({
behavior: "smooth",
});
});
featuresCont.addEventListener("scroll", (evt) => {
var _a, _b;
const scrollPos = (_b = (_a = evt.target) === null || _a === void 0 ? void 0 : _a.scrollTop) !== null && _b !== void 0 ? _b : 0;
const scrollIndicator = document.querySelector("#bytm-menu-scroll-indicator");
if (!scrollIndicator)
return;
if (scrollIndicatorEnabled && scrollPos > scrollIndicatorOffsetThreshold && !scrollIndicator.classList.contains("bytm-hidden")) {
scrollIndicator.classList.add("bytm-hidden");
}
else if (scrollIndicatorEnabled && scrollPos <= scrollIndicatorOffsetThreshold && scrollIndicator.classList.contains("bytm-hidden")) {
scrollIndicator.classList.remove("bytm-hidden");
}
});
const bottomAnchor = document.createElement("div");
bottomAnchor.id = "bytm-menu-bottom-anchor";
featuresCont.appendChild(bottomAnchor);
const footerCont = document.createElement("div");
footerCont.id = "bytm-menu-footer-cont";
const footerElem = document.createElement("div");
footerElem.classList.add("bytm-menu-footer");
footerElem.innerText = "You need to reload the page to apply changes";
const reloadElem = document.createElement("button");
reloadElem.classList.add("bytm-btn");
reloadElem.style.marginLeft = "10px";
reloadElem.innerText = "Reload now";
reloadElem.title = "Click to reload the page";
reloadElem.addEventListener("click", () => {
closeMenu();
location.reload();
});
footerElem.appendChild(reloadElem);
const resetElem = document.createElement("button");
resetElem.classList.add("bytm-btn");
resetElem.title = "Click to reset all settings to their default values";
resetElem.innerText = "Reset";
resetElem.addEventListener("click", () => menu_old_awaiter(this, void 0, void 0, function* () {
if (confirm("Do you really want to reset all settings to their default values?\nThe page will be automatically reloaded.")) {
yield setDefaultFeatures();
closeMenu();
location.reload();
}
}));
const exportElem = document.createElement("button");
exportElem.classList.add("bytm-btn");
exportElem.title = "Click to export your current configuration";
exportElem.innerText = "Export";
exportElem.addEventListener("click", () => menu_old_awaiter(this, void 0, void 0, function* () {
closeMenu();
openExportMenu();
}));
const importElem = document.createElement("button");
importElem.classList.add("bytm-btn");
importElem.title = "Click to import a configuration you have previously exported";
importElem.innerText = "Import";
importElem.addEventListener("click", () => menu_old_awaiter(this, void 0, void 0, function* () {
closeMenu();
openImportMenu();
}));
const buttonsCont = document.createElement("div");
buttonsCont.id = "bytm-menu-footer-buttons-cont";
buttonsCont.appendChild(exportElem);
buttonsCont.appendChild(importElem);
buttonsCont.appendChild(resetElem);
footerCont.appendChild(footerElem);
footerCont.appendChild(buttonsCont);
menuContainer.appendChild(headerElem);
menuContainer.appendChild(featuresCont);
const versionCont = document.createElement("div");
versionCont.id = "bytm-menu-version-cont";
const versionElem = document.createElement("a");
versionElem.id = "bytm-menu-version";
versionElem.role = "button";
versionElem.title = `Version ${constants_scriptInfo.version} (build ${constants_scriptInfo.buildNumber}) - click to open the changelog`;
versionElem.innerText = `v${constants_scriptInfo.version} (${constants_scriptInfo.buildNumber})`;
versionElem.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
openChangelogMenu();
});
versionCont.appendChild(versionElem);
menuContainer.appendChild(footerCont);
menuContainer.appendChild(versionCont);
backgroundElem.appendChild(menuContainer);
document.body.appendChild(backgroundElem);
window.addEventListener("resize", debounce(checkToggleScrollIndicator, 150));
yield addChangelogMenu();
yield addExportMenu();
yield addImportMenu();
log("Added menu element");
});
}
/** Closes the menu if it is open. If a bubbling event is passed, its propagation will be prevented. */
function closeMenu(evt) {
if (!isMenuOpen)
return;
isMenuOpen = false;
(evt === null || evt === void 0 ? void 0 : evt.bubbles) && evt.stopPropagation();
document.body.classList.remove("bytm-disable-scroll");
const menuBg = document.querySelector("#bytm-cfg-menu-bg");
if (!menuBg)
return;
menuBg.style.visibility = "hidden";
menuBg.style.display = "none";
}
/** Opens the menu if it is closed */
function openMenu() {
if (isMenuOpen)
return;
isMenuOpen = true;
document.body.classList.add("bytm-disable-scroll");
const menuBg = document.querySelector("#bytm-cfg-menu-bg");
if (!menuBg)
return;
menuBg.style.visibility = "visible";
menuBg.style.display = "block";
checkToggleScrollIndicator();
}
/** Checks if the features container is scrollable and toggles the scroll indicator accordingly */
function checkToggleScrollIndicator() {
const featuresCont = document.querySelector("#bytm-menu-opts");
const scrollIndicator = document.querySelector("#bytm-menu-scroll-indicator");
// disable scroll indicator if container doesn't scroll
if (featuresCont && scrollIndicator) {
const verticalScroll = isScrollable(featuresCont).vertical;
/** If true, the indicator's threshold is under the available scrollable space and so it should be disabled */
const underThreshold = featuresCont.scrollHeight - featuresCont.clientHeight <= scrollIndicatorOffsetThreshold;
if (!underThreshold && verticalScroll && !scrollIndicatorEnabled) {
scrollIndicatorEnabled = true;
scrollIndicator.classList.remove("bytm-hidden");
}
if ((!verticalScroll && scrollIndicatorEnabled) || underThreshold) {
scrollIndicatorEnabled = false;
scrollIndicator.classList.add("bytm-hidden");
}
}
}
let isExportMenuOpen = false;
/** Adds a menu to copy the current configuration as JSON (hidden by default) */
function addExportMenu() {
return menu_old_awaiter(this, void 0, void 0, function* () {
const menuBgElem = document.createElement("div");
menuBgElem.id = "bytm-export-menu-bg";
menuBgElem.classList.add("bytm-menu-bg");
menuBgElem.title = "Click here to close the menu";
menuBgElem.style.visibility = "hidden";
menuBgElem.style.display = "none";
menuBgElem.addEventListener("click", (e) => {
var _a;
if (isExportMenuOpen && ((_a = e.target) === null || _a === void 0 ? void 0 : _a.id) === "bytm-export-menu-bg") {
closeExportMenu(e);
openMenu();
}
});
document.body.addEventListener("keydown", (e) => {
if (isExportMenuOpen && e.key === "Escape") {
closeExportMenu(e);
openMenu();
}
});
const menuContainer = document.createElement("div");
menuContainer.title = ""; // prevent bg title from propagating downwards
menuContainer.classList.add("bytm-menu");
menuContainer.id = "bytm-export-menu";
const headerElem = document.createElement("div");
headerElem.classList.add("bytm-menu-header");
const titleCont = document.createElement("div");
titleCont.id = "bytm-menu-titlecont";
titleCont.role = "heading";
titleCont.ariaLevel = "1";
const titleElem = document.createElement("h2");
titleElem.id = "bytm-menu-title";
titleElem.innerText = `${constants_scriptInfo.name} - Export Configuration`;
const closeElem = document.createElement("img");
closeElem.classList.add("bytm-menu-close");
closeElem.src = yield getResourceUrl("close");
closeElem.title = "Click to close the menu";
closeElem.addEventListener("click", (e) => {
closeExportMenu(e);
openMenu();
});
titleCont.appendChild(titleElem);
headerElem.appendChild(titleCont);
headerElem.appendChild(closeElem);
const menuBodyElem = document.createElement("div");
menuBodyElem.classList.add("bytm-menu-body");
const textElem = document.createElement("div");
textElem.id = "bytm-export-menu-text";
textElem.innerText = "Copy the following text to export your configuration:";
const textAreaElem = document.createElement("textarea");
textAreaElem.id = "bytm-export-menu-textarea";
textAreaElem.readOnly = true;
textAreaElem.value = JSON.stringify({ formatVersion: formatVersion, data: getFeatures() });
siteEvents.on("configChanged", (data) => {
const textAreaElem = document.querySelector("#bytm-export-menu-textarea");
if (textAreaElem)
textAreaElem.value = JSON.stringify({ formatVersion: formatVersion, data });
});
const footerElem = document.createElement("div");
footerElem.classList.add("bytm-menu-footer-right");
const copyBtnElem = document.createElement("button");
copyBtnElem.classList.add("bytm-btn");
copyBtnElem.innerText = "Copy to clipboard";
copyBtnElem.title = "Click to copy the configuration to your clipboard";
const copiedTextElem = document.createElement("span");
copiedTextElem.classList.add("bytm-menu-footer-copied");
copiedTextElem.innerText = "Copied!";
copiedTextElem.style.display = "none";
copyBtnElem.addEventListener("click", (evt) => menu_old_awaiter(this, void 0, void 0, function* () {
(evt === null || evt === void 0 ? void 0 : evt.bubbles) && evt.stopPropagation();
const textAreaElem = document.querySelector("#bytm-export-menu-textarea");
if (textAreaElem) {
GM.setClipboard(textAreaElem.value);
copiedTextElem.style.display = "inline-block";
setTimeout(() => {
copiedTextElem.style.display = "none";
}, 3000);
}
}));
// flex-direction is row-reverse
footerElem.appendChild(copyBtnElem);
footerElem.appendChild(copiedTextElem);
menuBodyElem.appendChild(textElem);
menuBodyElem.appendChild(textAreaElem);
menuBodyElem.appendChild(footerElem);
menuContainer.appendChild(headerElem);
menuContainer.appendChild(menuBodyElem);
menuBgElem.appendChild(menuContainer);
document.body.appendChild(menuBgElem);
});
}
/** Closes the export menu if it is open. If a bubbling event is passed, its propagation will be prevented. */
function closeExportMenu(evt) {
if (!isExportMenuOpen)
return;
isExportMenuOpen = false;
(evt === null || evt === void 0 ? void 0 : evt.bubbles) && evt.stopPropagation();
document.body.classList.remove("bytm-disable-scroll");
const menuBg = document.querySelector("#bytm-export-menu-bg");
if (!menuBg)
return warn("Couldn't find export menu background element");
menuBg.style.visibility = "hidden";
menuBg.style.display = "none";
}
/** Opens the export menu if it is closed */
function openExportMenu() {
if (isExportMenuOpen)
return;
isExportMenuOpen = true;
document.body.classList.add("bytm-disable-scroll");
const menuBg = document.querySelector("#bytm-export-menu-bg");
if (!menuBg)
return warn("Couldn't find export menu background element");
menuBg.style.visibility = "visible";
menuBg.style.display = "block";
}
let isImportMenuOpen = false;
/** Adds a menu to import a configuration from JSON (hidden by default) */
function addImportMenu() {
return menu_old_awaiter(this, void 0, void 0, function* () {
const menuBgElem = document.createElement("div");
menuBgElem.id = "bytm-import-menu-bg";
menuBgElem.classList.add("bytm-menu-bg");
menuBgElem.title = "Click here to close the menu";
menuBgElem.style.visibility = "hidden";
menuBgElem.style.display = "none";
menuBgElem.addEventListener("click", (e) => {
var _a;
if (isImportMenuOpen && ((_a = e.target) === null || _a === void 0 ? void 0 : _a.id) === "bytm-import-menu-bg") {
closeImportMenu(e);
openMenu();
}
});
document.body.addEventListener("keydown", (e) => {
if (isImportMenuOpen && e.key === "Escape") {
closeImportMenu(e);
openMenu();
}
});
const menuContainer = document.createElement("div");
menuContainer.title = ""; // prevent bg title from propagating downwards
menuContainer.classList.add("bytm-menu");
menuContainer.id = "bytm-import-menu";
const headerElem = document.createElement("div");
headerElem.classList.add("bytm-menu-header");
const titleCont = document.createElement("div");
titleCont.id = "bytm-menu-titlecont";
titleCont.role = "heading";
titleCont.ariaLevel = "1";
const titleElem = document.createElement("h2");
titleElem.id = "bytm-menu-title";
titleElem.innerText = `${constants_scriptInfo.name} - Import Configuration`;
const closeElem = document.createElement("img");
closeElem.classList.add("bytm-menu-close");
closeElem.src = yield getResourceUrl("close");
closeElem.title = "Click to close the menu";
closeElem.addEventListener("click", (e) => {
closeImportMenu(e);
openMenu();
});
titleCont.appendChild(titleElem);
headerElem.appendChild(titleCont);
headerElem.appendChild(closeElem);
const menuBodyElem = document.createElement("div");
menuBodyElem.classList.add("bytm-menu-body");
const textElem = document.createElement("div");
textElem.id = "bytm-import-menu-text";
textElem.innerText = "Paste the configuration you want to import into the field below, then click the import button";
const textAreaElem = document.createElement("textarea");
textAreaElem.id = "bytm-import-menu-textarea";
const footerElem = document.createElement("div");
footerElem.classList.add("bytm-menu-footer-right");
const importBtnElem = document.createElement("button");
importBtnElem.classList.add("bytm-btn");
importBtnElem.innerText = "Import";
importBtnElem.title = "Click to import the configuration";
importBtnElem.addEventListener("click", (evt) => menu_old_awaiter(this, void 0, void 0, function* () {
(evt === null || evt === void 0 ? void 0 : evt.bubbles) && evt.stopPropagation();
const textAreaElem = document.querySelector("#bytm-import-menu-textarea");
if (!textAreaElem)
return warn("Couldn't find import menu textarea element");
try {
const parsed = JSON.parse(textAreaElem.value.trim());
if (typeof parsed !== "object")
return alert("The imported data is not an object");
if (typeof parsed.formatVersion !== "number")
return alert("The imported data does not contain a format version");
if (typeof parsed.data !== "object")
return alert("The imported object does not contain any data");
if (parsed.formatVersion < formatVersion) {
let newData = JSON.parse(JSON.stringify(parsed.data));
const sortedMigrations = Object.entries(migrations)
.sort(([a], [b]) => Number(a) - Number(b));
let curFmtVer = Number(parsed.formatVersion);
for (const [fmtVer, migrationFunc] of sortedMigrations) {
const ver = Number(fmtVer);
if (curFmtVer < formatVersion && curFmtVer < ver) {
try {
const migRes = JSON.parse(JSON.stringify(migrationFunc(newData)));
newData = migRes instanceof Promise ? yield migRes : migRes;
curFmtVer = ver;
}
catch (err) {
console.error(`Error while running migration function for format version ${fmtVer}:`, err);
}
}
}
parsed.formatVersion = curFmtVer;
parsed.data = newData;
}
else if (parsed.formatVersion !== formatVersion)
return alert(`The imported data is in an unsupported format version (expected ${formatVersion} or lower, got ${parsed.formatVersion})`);
yield saveFeatures(parsed.data);
if (confirm("Successfully imported the configuration.\nDo you want to reload the page now to apply changes?"))
return location.reload();
siteEvents.emit("rebuildCfgMenu", parsed.data);
closeImportMenu();
openMenu();
}
catch (err) {
warn("Couldn't import configuration:", err);
alert("The imported data is not a valid configuration");
}
}));
footerElem.appendChild(importBtnElem);
menuBodyElem.appendChild(textElem);
menuBodyElem.appendChild(textAreaElem);
menuBodyElem.appendChild(footerElem);
menuContainer.appendChild(headerElem);
menuContainer.appendChild(menuBodyElem);
menuBgElem.appendChild(menuContainer);
document.body.appendChild(menuBgElem);
});
}
/** Closes the import menu if it is open. If a bubbling event is passed, its propagation will be prevented. */
function closeImportMenu(evt) {
if (!isImportMenuOpen)
return;
isImportMenuOpen = false;
(evt === null || evt === void 0 ? void 0 : evt.bubbles) && evt.stopPropagation();
document.body.classList.remove("bytm-disable-scroll");
const menuBg = document.querySelector("#bytm-import-menu-bg");
const textAreaElem = document.querySelector("#bytm-import-menu-textarea");
if (textAreaElem)
textAreaElem.value = "";
if (!menuBg)
return warn("Couldn't find import menu background element");
menuBg.style.visibility = "hidden";
menuBg.style.display = "none";
}
/** Opens the import menu if it is closed */
function openImportMenu() {
if (isImportMenuOpen)
return;
isImportMenuOpen = true;
document.body.classList.add("bytm-disable-scroll");
const menuBg = document.querySelector("#bytm-import-menu-bg");
if (!menuBg)
return warn("Couldn't find import menu background element");
menuBg.style.visibility = "visible";
menuBg.style.display = "block";
}
let isChangelogMenuOpen = false;
/** Adds a changelog menu (hidden by default) */
function addChangelogMenu() {
return menu_old_awaiter(this, void 0, void 0, function* () {
const menuBgElem = document.createElement("div");
menuBgElem.id = "bytm-changelog-menu-bg";
menuBgElem.classList.add("bytm-menu-bg");
menuBgElem.title = "Click here to close the menu";
menuBgElem.style.visibility = "hidden";
menuBgElem.style.display = "none";
menuBgElem.addEventListener("click", (e) => {
var _a;
if (isChangelogMenuOpen && ((_a = e.target) === null || _a === void 0 ? void 0 : _a.id) === "bytm-changelog-menu-bg") {
closeChangelogMenu(e);
openMenu();
}
});
document.body.addEventListener("keydown", (e) => {
if (isChangelogMenuOpen && e.key === "Escape") {
closeChangelogMenu(e);
openMenu();
}
});
const menuContainer = document.createElement("div");
menuContainer.title = ""; // prevent bg title from propagating downwards
menuContainer.classList.add("bytm-menu");
menuContainer.id = "bytm-changelog-menu";
const headerElem = document.createElement("div");
headerElem.classList.add("bytm-menu-header");
const titleCont = document.createElement("div");
titleCont.id = "bytm-menu-titlecont";
titleCont.role = "heading";
titleCont.ariaLevel = "1";
const titleElem = document.createElement("h2");
titleElem.id = "bytm-menu-title";
titleElem.innerText = `${constants_scriptInfo.name} - Changelog`;
const closeElem = document.createElement("img");
closeElem.classList.add("bytm-menu-close");
closeElem.src = yield getResourceUrl("close");
closeElem.title = "Click to close the menu";
closeElem.addEventListener("click", (e) => {
closeChangelogMenu(e);
openMenu();
});
titleCont.appendChild(titleElem);
headerElem.appendChild(titleCont);
headerElem.appendChild(closeElem);
const menuBodyElem = document.createElement("div");
menuBodyElem.id = "bytm-changelog-menu-body";
menuBodyElem.classList.add("bytm-menu-body");
const textElem = document.createElement("div");
textElem.id = "bytm-changelog-menu-text";
textElem.classList.add("bytm-markdown-container");
textElem.innerHTML = changelog;
menuBodyElem.appendChild(textElem);
menuContainer.appendChild(headerElem);
menuContainer.appendChild(menuBodyElem);
menuBgElem.appendChild(menuContainer);
document.body.appendChild(menuBgElem);
const anchors = document.querySelectorAll("#bytm-changelog-menu-text a");
for (const anchor of anchors)
anchor.target = "_blank";
});
}
/** Closes the changelog menu if it is open. If a bubbling event is passed, its propagation will be prevented. */
function closeChangelogMenu(evt) {
if (!isChangelogMenuOpen)
return;
isChangelogMenuOpen = false;
(evt === null || evt === void 0 ? void 0 : evt.bubbles) && evt.stopPropagation();
document.body.classList.remove("bytm-disable-scroll");
const menuBg = document.querySelector("#bytm-changelog-menu-bg");
if (!menuBg)
return warn("Couldn't find changelog menu background element");
menuBg.style.visibility = "hidden";
menuBg.style.display = "none";
}
/** Opens the changelog menu if it is closed */
function openChangelogMenu() {
if (isChangelogMenuOpen)
return;
isChangelogMenuOpen = true;
document.body.classList.add("bytm-disable-scroll");
const menuBg = document.querySelector("#bytm-changelog-menu-bg");
if (!menuBg)
return warn("Couldn't find changelog menu background element");
menuBg.style.visibility = "visible";
menuBg.style.display = "block";
}
;// CONCATENATED MODULE: ./src/features/input.ts
var input_awaiter = (undefined && undefined.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
function initArrowKeySkip() {
document.addEventListener("keydown", (evt) => {
var _a, _b, _c;
if (!["ArrowLeft", "ArrowRight"].includes(evt.code))
return;
// discard the event when a (text) input is currently active, like when editing a playlist
if (["INPUT", "TEXTAREA", "SELECT"].includes((_b = (_a = document.activeElement) === null || _a === void 0 ? void 0 : _a.tagName) !== null && _b !== void 0 ? _b : "_"))
return utils_info(`Captured valid key to skip forward or backward but the current active element is <${(_c = document.activeElement) === null || _c === void 0 ? void 0 : _c.tagName.toLowerCase()}>, so the keypress is ignored`);
onArrowKeyPress(evt);
});
log("Added arrow key press listener");
}
/** Called when the user presses any key, anywhere */
function onArrowKeyPress(evt) {
log(`Captured key '${evt.code}' in proxy listener`);
// ripped this stuff from the console, most of these are probably unnecessary but this was finnicky af and I am sick and tired of trial and error
const defaultProps = {
altKey: false,
ctrlKey: false,
metaKey: false,
shiftKey: false,
target: document.body,
currentTarget: document.body,
originalTarget: document.body,
explicitOriginalTarget: document.body,
srcElement: document.body,
type: "keydown",
bubbles: true,
cancelBubble: false,
cancelable: true,
isTrusted: true,
repeat: false,
// needed because otherwise YTM errors out - see https://github.com/Sv443/BetterYTM/issues/18#show_issue
view: getUnsafeWindow(),
};
let invalidKey = false;
let keyProps = {};
switch (evt.code) {
case "ArrowLeft":
keyProps = {
code: "KeyH",
key: "h",
keyCode: 72,
which: 72,
};
break;
case "ArrowRight":
keyProps = {
code: "KeyL",
key: "l",
keyCode: 76,
which: 76,
};
break;
default:
invalidKey = true;
break;
}
if (!invalidKey) {
const proxyProps = Object.assign(Object.assign({ code: "" }, defaultProps), keyProps);
document.body.dispatchEvent(new KeyboardEvent("keydown", proxyProps));
log(`Dispatched proxy keydown event: [${evt.code}] -> [${proxyProps.code}]`);
}
else
warn(`Captured key '${evt.code}' has no defined behavior`);
}
/** switch sites only if current video time is greater than this value */
const videoTimeThreshold = 3;
/** Initializes the site switch feature */
function initSiteSwitch(domain) {
document.addEventListener("keydown", (e) => {
if (e.key === "F9")
switchSite(domain === "yt" ? "ytm" : "yt");
});
log("Initialized site switch listener");
}
/** Switches to the other site (between YT and YTM) */
function switchSite(newDomain) {
return input_awaiter(this, void 0, void 0, function* () {
try {
if (newDomain === "ytm" && !location.href.includes("/watch"))
return warn("Not on a video page, so the site switch is ignored");
let subdomain;
if (newDomain === "ytm")
subdomain = "music";
else if (newDomain === "yt")
subdomain = "www";
if (!subdomain)
throw new Error(`Unrecognized domain '${newDomain}'`);
disableBeforeUnload();
const { pathname, search, hash } = new URL(location.href);
const vt = yield getVideoTime();
log(`Found video time of ${vt} seconds`);
const cleanSearch = search.split("&")
.filter((param) => !param.match(/^\??t=/))
.join("&");
const newSearch = typeof vt === "number" && vt > videoTimeThreshold ?
cleanSearch.includes("?")
? `${cleanSearch.startsWith("?")
? cleanSearch
: "?" + cleanSearch}&t=${vt - 1}`
: `?t=${vt - 1}`
: cleanSearch;
const newUrl = `https://${subdomain}.youtube.com${pathname}${newSearch}${hash}`;
utils_info(`Switching to domain '${newDomain}' at ${newUrl}`);
location.assign(newUrl);
}
catch (err) {
error("Error while switching site:", err);
}
});
}
let beforeUnloadEnabled = true;
/** Disables the popup before leaving the site */
function disableBeforeUnload() {
beforeUnloadEnabled = false;
utils_info("Disabled popup before leaving the site");
}
/** (Re-)enables the popup before leaving the site */
function enableBeforeUnload() {
beforeUnloadEnabled = true;
info("Enabled popup before leaving the site");
}
/**
* Adds a spy function into `window.__proto__.addEventListener` to selectively discard `beforeunload`
* event listeners before they can be called by the site.
*/
function initBeforeUnloadHook() {
Error.stackTraceLimit = 1000; // default is 25 on FF so this should hopefully be more than enough
(function (original) {
// @ts-ignore
window.__proto__.addEventListener = function (...args) {
const origListener = typeof args[1] === "function" ? args[1] : args[1].handleEvent;
args[1] = function (...a) {
if (!beforeUnloadEnabled && args[0] === "beforeunload")
return utils_info("Prevented beforeunload event listener from being called");
else
return origListener.apply(this, a);
};
original.apply(this, args);
};
// @ts-ignore
})(window.__proto__.addEventListener);
}
/** Adds the ability to skip to a certain time in the video by pressing a number key (0-9) */
function initNumKeysSkip() {
document.addEventListener("keydown", (e) => {
var _a, _b, _c, _d;
if (!e.key.trim().match(/^[0-9]$/))
return;
if (isMenuOpen)
return;
// discard the event when a (text) input is currently active, like when editing a playlist or when the search bar is focused
if (document.activeElement !== document.body
&& !["progress-bar"].includes((_b = (_a = document.activeElement) === null || _a === void 0 ? void 0 : _a.id) !== null && _b !== void 0 ? _b : "_")
&& !["BUTTON", "A"].includes((_d = (_c = document.activeElement) === null || _c === void 0 ? void 0 : _c.tagName) !== null && _d !== void 0 ? _d : "_"))
return utils_info("Captured valid key to skip video to but an unexpected element is focused, so the keypress is ignored");
skipToTimeKey(Number(e.key));
});
log("Added number key press listener");
}
/** Returns the x position as a fraction of timeKey in maxWidth */
function getX(timeKey, maxWidth) {
if (timeKey >= 10)
return maxWidth;
return Math.floor((maxWidth / 10) * timeKey);
}
/** Calculates DOM-relative offsets of the bounding client rect of the passed element - see https://stackoverflow.com/a/442474/11187044 */
function getOffsetRect(elem) {
let left = 0;
let top = 0;
const rect = elem.getBoundingClientRect();
while (elem && !isNaN(elem.offsetLeft) && !isNaN(elem.offsetTop)) {
left += elem.offsetLeft - elem.scrollLeft;
top += elem.offsetTop - elem.scrollTop;
elem = elem.offsetParent;
}
return {
top,
left,
width: rect.width,
height: rect.height,
};
}
/** Emulates a click on the video progress bar at the position calculated from the passed time key (0-9) */
function skipToTimeKey(key) {
// not technically a progress element but behaves pretty much the same
const progressElem = document.querySelector("tp-yt-paper-slider#progress-bar tp-yt-paper-progress#sliderBar");
if (!progressElem)
return;
const rect = getOffsetRect(progressElem);
const x = getX(key, rect.width);
const y = rect.top - rect.height / 2;
log(`Skipping to time key ${key} (x offset: ${x}px of ${rect.width}px)`);
const evt = new MouseEvent("mousedown", {
clientX: x,
clientY: Math.round(y),
// @ts-ignore
layerX: x,
layerY: Math.round(rect.height / 2),
target: progressElem,
bubbles: true,
shiftKey: false,
ctrlKey: false,
altKey: false,
metaKey: false,
button: 0,
buttons: 1,
which: 1,
isTrusted: true,
offsetX: 0,
offsetY: 0,
// needed because otherwise YTM errors out - see https://github.com/Sv443/BetterYTM/issues/18#show_issue
view: getUnsafeWindow(),
});
progressElem.dispatchEvent(evt);
}
;// CONCATENATED MODULE: ./src/features/lyrics.ts
var lyrics_awaiter = (undefined && undefined.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __asyncValues = (undefined && undefined.__asyncValues) || function (o) {
if (!Symbol.asyncIterator) throw new TypeError("Symbol.asyncIterator is not defined.");
var m = o[Symbol.asyncIterator], i;
return m ? m.call(o) : (o = typeof __values === "function" ? __values(o) : o[Symbol.iterator](), i = {}, verb("next"), verb("throw"), verb("return"), i[Symbol.asyncIterator] = function () { return this; }, i);
function verb(n) { i[n] = o[n] && function (v) { return new Promise(function (resolve, reject) { v = o[n](v), settle(resolve, reject, v.done, v.value); }); }; }
function settle(resolve, reject, d, v) { Promise.resolve(v).then(function(v) { resolve({ value: v, done: d }); }, reject); }
};
/** Base URL of geniURL */
const geniUrlBase = "https://api.sv443.net/geniurl";
/** GeniURL endpoint that gives song metadata when provided with a `?q` or `?artist` and `?song` parameter - [more info](https://api.sv443.net/geniurl) */
const geniURLSearchTopUrl = `${geniUrlBase}/search/top`;
/**
* The threshold to pass to geniURL's fuzzy filtering.
* From fuse.js docs: At what point does the match algorithm give up. A threshold of 0.0 requires a perfect match (of both letters and location), a threshold of 1.0 would match anything.
* Set to undefined to use the default.
*/
const threshold = 0.55;
/** Ratelimit budget timeframe in seconds - should reflect what's in geniURL's docs */
const geniUrlRatelimitTimeframe = 30;
const thresholdParam = threshold ? `&threshold=${clamp(threshold, 0, 1)}` : "";
void thresholdParam; // TODO: remove once geniURL 1.4 is released
/** Cache with key format `ARTIST - SONG` (sanitized) and lyrics URLs as values. Used to prevent extraneous requests to geniURL. */
const lyricsUrlCache = new Map();
/** How many cache entries can exist at a time - this is used to cap memory usage */
const maxLyricsCacheSize = 100;
/**
* Returns the lyrics URL from the passed un-/sanitized artist and song name, or undefined if the entry doesn't exist yet.
* **The passed parameters need to be sanitized first!**
*/
function getLyricsCacheEntry(artists, song) {
return lyricsUrlCache.get(`${artists} - ${song}`);
}
/** Adds the provided entry into the lyrics URL cache */
function addLyricsCacheEntry(artists, song, lyricsUrl) {
lyricsUrlCache.set(`${sanitizeArtists(artists)} - ${sanitizeSong(song)}`, lyricsUrl);
// delete oldest entry if cache gets too big
if (lyricsUrlCache.size > maxLyricsCacheSize)
lyricsUrlCache.delete([...lyricsUrlCache.keys()].at(-1));
}
let currentSongTitle = "";
/** Adds a lyrics button to the media controls bar */
function addMediaCtrlLyricsBtn() {
onSelector(".middle-controls-buttons ytmusic-like-button-renderer#like-button-renderer", { listener: addActualMediaCtrlLyricsBtn });
}
/** Actually adds the lyrics button after the like button renderer has been verified to exist */
function addActualMediaCtrlLyricsBtn(likeContainer) {
return lyrics_awaiter(this, void 0, void 0, function* () {
const songTitleElem = document.querySelector(".content-info-wrapper > yt-formatted-string");
if (!songTitleElem)
return warn("Couldn't find song title element");
// run parallel without awaiting so the MutationObserver below can observe the title element in time
(() => lyrics_awaiter(this, void 0, void 0, function* () {
const gUrl = yield getCurrentLyricsUrl();
const linkElem = yield createLyricsBtn(gUrl !== null && gUrl !== void 0 ? gUrl : undefined);
linkElem.id = "betterytm-lyrics-button";
log("Inserted lyrics button into media controls bar");
insertAfter(likeContainer, linkElem);
}))();
currentSongTitle = songTitleElem.title;
const spinnerIconUrl = yield getResourceUrl("spinner");
const lyricsIconUrl = yield getResourceUrl("lyrics");
const errorIconUrl = yield getResourceUrl("error");
const onMutation = (mutations) => { var _a, mutations_1, mutations_1_1; return lyrics_awaiter(this, void 0, void 0, function* () {
var _b, e_1, _c, _d;
try {
for (_a = true, mutations_1 = __asyncValues(mutations); mutations_1_1 = yield mutations_1.next(), _b = mutations_1_1.done, !_b; _a = true) {
_d = mutations_1_1.value;
_a = false;
const mut = _d;
const newTitle = mut.target.title;
if (newTitle !== currentSongTitle && newTitle.length > 0) {
const lyricsBtn = document.querySelector("#betterytm-lyrics-button");
if (!lyricsBtn)
continue;
utils_info(`Song title changed from '${currentSongTitle}' to '${newTitle}'`);
lyricsBtn.style.cursor = "wait";
lyricsBtn.style.pointerEvents = "none";
const imgElem = lyricsBtn.querySelector("img");
imgElem.src = spinnerIconUrl;
imgElem.classList.add("bytm-spinner");
currentSongTitle = newTitle;
const url = yield getCurrentLyricsUrl(); // can take a second or two
imgElem.src = lyricsIconUrl;
imgElem.classList.remove("bytm-spinner");
if (!url) {
let artist, song;
if ("mediaSession" in navigator && navigator.mediaSession.metadata) {
artist = navigator.mediaSession.metadata.artist;
song = navigator.mediaSession.metadata.title;
}
const query = artist && song ? "?q=" + encodeURIComponent(sanitizeArtists(artist) + " - " + sanitizeSong(song)) : "";
imgElem.src = errorIconUrl;
imgElem.title = "Couldn't find lyrics URL - click to open the manual lyrics search";
lyricsBtn.style.cursor = "pointer";
lyricsBtn.style.pointerEvents = "all";
lyricsBtn.style.display = "inline-flex";
lyricsBtn.style.visibility = "visible";
lyricsBtn.href = `https://genius.com/search${query}`;
continue;
}
lyricsBtn.href = url;
lyricsBtn.title = "Open the current song's lyrics in a new tab";
lyricsBtn.style.cursor = "pointer";
lyricsBtn.style.visibility = "visible";
lyricsBtn.style.display = "inline-flex";
lyricsBtn.style.pointerEvents = "initial";
}
}
}
catch (e_1_1) { e_1 = { error: e_1_1 }; }
finally {
try {
if (!_a && !_b && (_c = mutations_1.return)) yield _c.call(mutations_1);
}
finally { if (e_1) throw e_1.error; }
}
}); };
// since YT and YTM don't reload the page on video change, MutationObserver needs to be used to watch for changes in the video title
const obs = new MutationObserver(onMutation);
obs.observe(songTitleElem, { attributes: true, attributeFilter: ["title"] });
});
}
/** Removes everything in parentheses from the passed song name */
function sanitizeSong(songName) {
const parensRegex = /\(.+\)/gmi;
const squareParensRegex = /\[.+\]/gmi;
// trim right after the song name:
const sanitized = songName
.replace(parensRegex, "")
.replace(squareParensRegex, "");
return sanitized.trim();
}
/** Removes the secondary artist (if it exists) from the passed artists string */
function sanitizeArtists(artists) {
artists = artists.split(/\s*\u2022\s*/gmiu)[0]; // split at • [•] character
if (artists.match(/&/))
artists = artists.split(/\s*&\s*/gm)[0];
if (artists.match(/,/))
artists = artists.split(/,\s*/gm)[0];
return artists.trim();
}
/** Returns the lyrics URL from genius for the currently selected song */
function getCurrentLyricsUrl() {
var _a;
return lyrics_awaiter(this, void 0, void 0, function* () {
try {
// In videos the video title contains both artist and song title, in "regular" YTM songs, the video title only contains the song title
const isVideo = typeof ((_a = document.querySelector("ytmusic-player")) === null || _a === void 0 ? void 0 : _a.hasAttribute("video-mode"));
const songTitleElem = document.querySelector(".content-info-wrapper > yt-formatted-string");
const songMetaElem = document.querySelector("span.subtitle > yt-formatted-string:first-child");
if (!songTitleElem || !songMetaElem || !songTitleElem.title)
return undefined;
const songNameRaw = songTitleElem.title;
const songName = sanitizeSong(songNameRaw);
const artistName = sanitizeArtists(songMetaElem.title);
/** Use when the current song is not a "real YTM song" with a static background, but rather a music video */
const getGeniusUrlVideo = () => lyrics_awaiter(this, void 0, void 0, function* () {
if (!songName.includes("-")) // for some fucking reason some music videos have YTM-like song title and artist separation, some don't
return yield getGeniusUrl(artistName, songName);
const { artist, song } = splitVideoTitle(songName);
return yield getGeniusUrl(artist, song);
});
const url = isVideo ? yield getGeniusUrlVideo() : yield getGeniusUrl(artistName, songName);
return url;
}
catch (err) {
error("Couldn't resolve lyrics URL:", err);
return undefined;
}
});
}
/** Fetches the actual lyrics URL from geniURL - **the passed parameters need to be sanitized first!** */
function getGeniusUrl(artist, song) {
var _a, _b, _c;
return lyrics_awaiter(this, void 0, void 0, function* () {
try {
const cacheEntry = getLyricsCacheEntry(artist, song);
if (cacheEntry) {
utils_info(`Found lyrics URL in cache: ${cacheEntry}`);
return cacheEntry;
}
const startTs = Date.now();
const fetchUrl = `${geniURLSearchTopUrl}?disableFuzzy&artist=${encodeURIComponent(artist)}&song=${encodeURIComponent(song)}`;
log(`Requesting URL from geniURL at '${fetchUrl}'`);
const fetchRes = yield fetchAdvanced(fetchUrl);
if (fetchRes.status === 429) {
alert(`You are being rate limited.\nPlease wait ${(_a = fetchRes.headers.get("retry-after")) !== null && _a !== void 0 ? _a : geniUrlRatelimitTimeframe} seconds before requesting more lyrics.`);
return undefined;
}
else if (fetchRes.status < 200 || fetchRes.status >= 300) {
error(`Couldn't fetch lyrics URL from geniURL - status: ${fetchRes.status} - response: ${(_c = (_b = (yield fetchRes.json()).message) !== null && _b !== void 0 ? _b : yield fetchRes.text()) !== null && _c !== void 0 ? _c : "(none)"}`);
return undefined;
}
const result = yield fetchRes.json();
if (typeof result === "object" && result.error) {
error("Couldn't fetch lyrics URL:", result.message);
return undefined;
}
const url = result.url;
utils_info(`Found lyrics URL (after ${Date.now() - startTs}ms): ${url}`);
addLyricsCacheEntry(artist, song, url);
return url;
}
catch (err) {
error("Couldn't get lyrics URL due to error:", err);
return undefined;
}
});
}
/** Creates the base lyrics button element */
function createLyricsBtn(geniusUrl, hideIfLoading = true) {
return lyrics_awaiter(this, void 0, void 0, function* () {
const linkElem = document.createElement("a");
linkElem.className = "ytmusic-player-bar bytm-generic-btn";
linkElem.title = geniusUrl ? "Click to open this song's lyrics in a new tab" : "Loading lyrics URL...";
if (geniusUrl)
linkElem.href = geniusUrl;
linkElem.role = "button";
linkElem.target = "_blank";
linkElem.rel = "noopener noreferrer";
linkElem.style.visibility = hideIfLoading && geniusUrl ? "initial" : "hidden";
linkElem.style.display = hideIfLoading && geniusUrl ? "inline-flex" : "none";
const imgElem = document.createElement("img");
imgElem.className = "bytm-generic-btn-img";
imgElem.src = yield getResourceUrl("lyrics");
linkElem.appendChild(imgElem);
return linkElem;
});
}
/** Splits a video title that contains a hyphen into an artist and song */
function splitVideoTitle(title) {
const [artist, ...rest] = title.split("-").map((v, i) => i < 2 ? v.trim() : v);
return { artist, song: rest.join("-") };
}
;// CONCATENATED MODULE: ./src/features/layout.ts
var layout_awaiter = (undefined && undefined.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
let features;
function preInitLayout(feats) {
features = feats;
}
let menuOpenAmt = 0, logoExchanged = false;
/** Adds a watermark beneath the logo */
function addWatermark() {
const watermark = document.createElement("a");
watermark.role = "button";
watermark.id = "bytm-watermark";
watermark.className = "style-scope ytmusic-nav-bar bytm-no-select";
watermark.innerText = constants_scriptInfo.name;
watermark.title = "Open menu";
watermark.tabIndex = 1000;
improveLogo();
watermark.addEventListener("click", (e) => {
e.stopPropagation();
menuOpenAmt++;
if ((!e.shiftKey || logoExchanged) && menuOpenAmt !== 5)
openMenu();
if ((!logoExchanged && e.shiftKey) || menuOpenAmt === 5)
exchangeLogo();
});
// when using the tab key to navigate
watermark.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.stopPropagation();
menuOpenAmt++;
if ((!e.shiftKey || logoExchanged) && menuOpenAmt !== 5)
openMenu();
if ((!logoExchanged && e.shiftKey) || menuOpenAmt === 5)
exchangeLogo();
}
});
onSelector("ytmusic-nav-bar #left-content", {
listener: (logoElem) => insertAfter(logoElem, watermark),
});
log("Added watermark element");
}
/** Turns the regular `<img>`-based logo into inline SVG to be able to animate and modify parts of it */
function improveLogo() {
return layout_awaiter(this, void 0, void 0, function* () {
try {
const res = yield fetchAdvanced("https://music.youtube.com/img/on_platform_logo_dark.svg");
const svg = yield res.text();
onSelector("ytmusic-logo a", {
listener: (logoElem) => {
var _a;
logoElem.classList.add("bytm-mod-logo", "bytm-no-select");
logoElem.innerHTML = svg;
logoElem.querySelectorAll("ellipse").forEach((e) => {
e.classList.add("bytm-mod-logo-ellipse");
});
(_a = logoElem.querySelector("path")) === null || _a === void 0 ? void 0 : _a.classList.add("bytm-mod-logo-path");
log("Swapped logo to inline SVG");
},
});
}
catch (err) {
error("Couldn't improve logo due to an error:", err);
}
});
}
/** Exchanges the default YTM logo into BetterYTM's logo with a sick ass animation */
function exchangeLogo() {
onSelector(".bytm-mod-logo", {
listener: (logoElem) => layout_awaiter(this, void 0, void 0, function* () {
if (logoElem.classList.contains("bytm-logo-exchanged"))
return;
logoExchanged = true;
logoElem.classList.add("bytm-logo-exchanged");
const iconUrl = yield getResourceUrl("icon");
const newLogo = document.createElement("img");
newLogo.className = "bytm-mod-logo-img";
newLogo.src = iconUrl;
logoElem.insertBefore(newLogo, logoElem.querySelector("svg"));
document.head.querySelectorAll("link[rel=\"icon\"]").forEach((e) => {
e.href = iconUrl;
});
setTimeout(() => {
logoElem.querySelectorAll(".bytm-mod-logo-ellipse").forEach(e => e.remove());
}, 1000);
}),
});
}
/** Called whenever the avatar popover menu exists to add a BYTM-Configuration button to the user menu popover */
function addConfigMenuOption(container) {
return layout_awaiter(this, void 0, void 0, function* () {
const cfgOptElem = document.createElement("div");
cfgOptElem.role = "button";
cfgOptElem.className = "bytm-cfg-menu-option";
const cfgOptItemElem = document.createElement("div");
cfgOptItemElem.className = "bytm-cfg-menu-option-item";
cfgOptItemElem.ariaLabel = cfgOptItemElem.title = "Click to open BetterYTM's configuration menu";
cfgOptItemElem.addEventListener("click", (e) => layout_awaiter(this, void 0, void 0, function* () {
const settingsBtnElem = document.querySelector("ytmusic-nav-bar ytmusic-settings-button tp-yt-paper-icon-button");
settingsBtnElem === null || settingsBtnElem === void 0 ? void 0 : settingsBtnElem.click();
menuOpenAmt++;
yield pauseFor(100);
if ((!e.shiftKey || logoExchanged) && menuOpenAmt !== 5)
openMenu();
if ((!logoExchanged && e.shiftKey) || menuOpenAmt === 5)
exchangeLogo();
}));
const cfgOptIconElem = document.createElement("img");
cfgOptIconElem.className = "bytm-cfg-menu-option-icon";
cfgOptIconElem.src = yield getResourceUrl("icon");
const cfgOptTextElem = document.createElement("div");
cfgOptTextElem.className = "bytm-cfg-menu-option-text";
cfgOptTextElem.innerText = "BetterYTM Configuration";
cfgOptItemElem.appendChild(cfgOptIconElem);
cfgOptItemElem.appendChild(cfgOptTextElem);
cfgOptElem.appendChild(cfgOptItemElem);
container.appendChild(cfgOptElem);
log("Added BYTM-Configuration button to menu popover");
});
}
/** Removes the "Upgrade" / YT Music Premium tab from the sidebar */
function removeUpgradeTab() {
onSelector("ytmusic-app-layout tp-yt-app-drawer #contentContainer #guide-content #items ytmusic-guide-entry-renderer:nth-of-type(4)", {
listener: (tabElemLarge) => {
tabElemLarge.remove();
log("Removed large upgrade tab");
},
});
onSelector("ytmusic-app-layout #mini-guide ytmusic-guide-renderer #sections ytmusic-guide-section-renderer[is-primary] #items ytmusic-guide-entry-renderer:nth-of-type(4)", {
listener: (tabElemSmall) => {
tabElemSmall.remove();
log("Removed small upgrade tab");
},
});
}
function initVolumeFeatures() {
// not technically an input element but behaves pretty much the same
onSelector("tp-yt-paper-slider#volume-slider", {
listener: (sliderElem) => {
const volSliderCont = document.createElement("div");
volSliderCont.id = "bytm-vol-slider-cont";
addParent(sliderElem, volSliderCont);
if (typeof features.volumeSliderSize === "number")
setVolSliderSize();
if (features.volumeSliderLabel)
addVolumeSliderLabel(sliderElem, volSliderCont);
setVolSliderStep(sliderElem);
},
});
}
/** Adds a percentage label to the volume slider and tooltip */
function addVolumeSliderLabel(sliderElem, sliderCont) {
const labelElem = document.createElement("div");
labelElem.className = "bytm-vol-slider-label";
labelElem.innerText = `${sliderElem.value}%`;
// prevent video from minimizing
labelElem.addEventListener("click", (e) => e.stopPropagation());
const getLabelTexts = (slider) => {
const labelShort = `${slider.value}%`;
const sensText = features.volumeSliderStep !== featInfo.volumeSliderStep.default ? ` (Sensitivity: ${slider.step}%)` : "";
const labelFull = `Volume: ${labelShort}${sensText}`;
return { labelShort, labelFull };
};
const { labelFull } = getLabelTexts(sliderElem);
sliderCont.setAttribute("title", labelFull);
sliderElem.setAttribute("title", labelFull);
sliderElem.setAttribute("aria-valuetext", labelFull);
const updateLabel = () => {
const { labelShort, labelFull } = getLabelTexts(sliderElem);
sliderCont.setAttribute("title", labelFull);
sliderElem.setAttribute("title", labelFull);
sliderElem.setAttribute("aria-valuetext", labelFull);
const labelElem2 = document.querySelector(".bytm-vol-slider-label");
if (labelElem2)
labelElem2.innerText = labelShort;
};
sliderElem.addEventListener("change", () => updateLabel());
onSelector("#bytm-vol-slider-cont", {
listener: (volumeCont) => {
volumeCont.appendChild(labelElem);
},
});
let lastSliderVal = Number(sliderElem.value);
// show label if hovering over slider or slider is focused
const sliderHoverObserver = new MutationObserver(() => {
if (sliderElem.classList.contains("on-hover") || document.activeElement === sliderElem)
labelElem.classList.add("bytm-visible");
else if (labelElem.classList.contains("bytm-visible") || document.activeElement !== sliderElem)
labelElem.classList.remove("bytm-visible");
if (Number(sliderElem.value) !== lastSliderVal) {
lastSliderVal = Number(sliderElem.value);
updateLabel();
}
});
sliderHoverObserver.observe(sliderElem, {
attributes: true,
});
}
/** Sets the volume slider to a set size */
function setVolSliderSize() {
const { volumeSliderSize: size } = features;
if (typeof size !== "number" || isNaN(Number(size)))
return;
addGlobalStyle(`\
#bytm-vol-slider-cont tp-yt-paper-slider#volume-slider {
width: ${size}px !important;
}`);
}
/** Sets the `step` attribute of the volume slider */
function setVolSliderStep(sliderElem) {
sliderElem.setAttribute("step", String(features.volumeSliderStep));
}
function initQueueButtons() {
const addQueueBtns = (evt) => {
let amt = 0;
for (const queueItm of evt.childNodes) {
if (!queueItm.classList.contains("bytm-has-queue-btns")) {
addQueueButtons(queueItm);
amt++;
}
}
if (amt > 0)
log(`Added buttons to ${amt} new queue ${autoPlural("item", amt)}`);
};
siteEvents.on("queueChanged", addQueueBtns);
siteEvents.on("autoplayQueueChanged", addQueueBtns);
const queueItems = document.querySelectorAll("#contents.ytmusic-player-queue > ytmusic-player-queue-item");
if (queueItems.length === 0)
return;
queueItems.forEach(itm => addQueueButtons(itm));
log(`Added buttons to ${queueItems.length} existing queue ${autoPlural("item", queueItems)}`);
}
/**
* Adds the buttons to each item in the current song queue.
* Also observes for changes to add new buttons to new items in the queue.
* @param queueItem The element with tagname `ytmusic-player-queue-item` to add queue buttons to
*/
function addQueueButtons(queueItem) {
var _a;
return layout_awaiter(this, void 0, void 0, function* () {
const queueBtnsCont = document.createElement("div");
queueBtnsCont.className = "bytm-queue-btn-container";
const lyricsIconUrl = yield getResourceUrl("lyrics");
const deleteIconUrl = yield getResourceUrl("delete");
let lyricsBtnElem;
if (features.lyricsQueueButton) {
lyricsBtnElem = yield createLyricsBtn(undefined, false);
lyricsBtnElem.title = "Open this song's lyrics in a new tab";
lyricsBtnElem.style.display = "inline-flex";
lyricsBtnElem.style.visibility = "initial";
lyricsBtnElem.style.pointerEvents = "initial";
lyricsBtnElem.addEventListener("click", (e) => layout_awaiter(this, void 0, void 0, function* () {
e.stopPropagation();
const songInfo = queueItem.querySelector(".song-info");
if (!songInfo)
return;
const [songEl, artistEl] = songInfo.querySelectorAll("yt-formatted-string");
const song = songEl === null || songEl === void 0 ? void 0 : songEl.innerText;
const artist = artistEl === null || artistEl === void 0 ? void 0 : artistEl.innerText;
if (!song || !artist)
return;
let lyricsUrl;
const artistsSan = sanitizeArtists(artist);
const songSan = sanitizeSong(song);
const splitTitle = splitVideoTitle(songSan);
const cachedLyricsUrl = songSan.includes("-")
? getLyricsCacheEntry(splitTitle.artist, splitTitle.song)
: getLyricsCacheEntry(artistsSan, songSan);
if (cachedLyricsUrl)
lyricsUrl = cachedLyricsUrl;
else if (!songInfo.hasAttribute("data-bytm-loading")) {
const imgEl = lyricsBtnElem === null || lyricsBtnElem === void 0 ? void 0 : lyricsBtnElem.querySelector("img");
if (!imgEl)
return;
if (!cachedLyricsUrl) {
songInfo.setAttribute("data-bytm-loading", "");
imgEl.src = yield getResourceUrl("spinner");
imgEl.classList.add("bytm-spinner");
}
lyricsUrl = cachedLyricsUrl !== null && cachedLyricsUrl !== void 0 ? cachedLyricsUrl : yield getGeniusUrl(artistsSan, songSan);
const resetImgElem = () => {
imgEl.src = lyricsIconUrl;
imgEl.classList.remove("bytm-spinner");
};
if (!cachedLyricsUrl) {
songInfo.removeAttribute("data-bytm-loading");
// so the new image doesn't "blink"
setTimeout(resetImgElem, 100);
}
if (!lyricsUrl) {
resetImgElem();
if (confirm("Couldn't find a lyrics page for this song.\nDo you want to open genius.com to manually search for it?"))
openInNewTab(`https://genius.com/search?q=${encodeURIComponent(`${artistsSan} ${songSan}`)}`);
return;
}
}
lyricsUrl && openInNewTab(lyricsUrl);
}));
}
let deleteBtnElem;
if (features.deleteFromQueueButton) {
deleteBtnElem = document.createElement("a");
Object.assign(deleteBtnElem, {
title: "Remove this song from the queue",
className: "ytmusic-player-bar bytm-delete-from-queue bytm-generic-btn",
role: "button",
});
deleteBtnElem.style.visibility = "initial";
deleteBtnElem.addEventListener("click", (e) => layout_awaiter(this, void 0, void 0, function* () {
e.stopPropagation();
// container of the queue item popup menu - element gets reused for every queue item
let queuePopupCont = document.querySelector("ytmusic-app ytmusic-popup-container tp-yt-iron-dropdown");
try {
// three dots button to open the popup menu of a queue item
const dotsBtnElem = queueItem.querySelector("ytmusic-menu-renderer yt-button-shape button");
if (queuePopupCont)
queuePopupCont.setAttribute("data-bytm-hidden", "true");
dotsBtnElem === null || dotsBtnElem === void 0 ? void 0 : dotsBtnElem.click();
yield pauseFor(20);
queuePopupCont = document.querySelector("ytmusic-app ytmusic-popup-container tp-yt-iron-dropdown");
queuePopupCont === null || queuePopupCont === void 0 ? void 0 : queuePopupCont.setAttribute("data-bytm-hidden", "true");
// a little bit janky and unreliable but the only way afaik
const removeFromQueueBtn = queuePopupCont === null || queuePopupCont === void 0 ? void 0 : queuePopupCont.querySelector("tp-yt-paper-listbox ytmusic-menu-service-item-renderer:nth-of-type(3)");
yield pauseFor(10);
removeFromQueueBtn === null || removeFromQueueBtn === void 0 ? void 0 : removeFromQueueBtn.click();
}
catch (err) {
error("Couldn't remove song from queue due to error:", err);
}
finally {
queuePopupCont === null || queuePopupCont === void 0 ? void 0 : queuePopupCont.removeAttribute("data-bytm-hidden");
}
}));
const imgElem = document.createElement("img");
imgElem.className = "bytm-generic-btn-img";
imgElem.src = deleteIconUrl;
deleteBtnElem.appendChild(imgElem);
}
lyricsBtnElem && queueBtnsCont.appendChild(lyricsBtnElem);
deleteBtnElem && queueBtnsCont.appendChild(deleteBtnElem);
(_a = queueItem.querySelector(".song-info")) === null || _a === void 0 ? void 0 : _a.appendChild(queueBtnsCont);
queueItem.classList.add("bytm-has-queue-btns");
});
}
/** Adds anchors around elements and tweaks existing ones so songs are easier to open in a new tab */
function addAnchorImprovements() {
try {
const preventDefault = (e) => e.preventDefault();
/** Adds anchor improvements to <ytmusic-responsive-list-item-renderer> */
const addListItemAnchors = (items) => {
var _a;
for (const item of items) {
if (item.classList.contains("bytm-anchor-improved"))
continue;
item.classList.add("bytm-anchor-improved");
const thumbnailElem = item.querySelector(".left-items");
const titleElem = item.querySelector(".title-column .title a");
if (!thumbnailElem || !titleElem)
continue;
const anchorElem = document.createElement("a");
anchorElem.classList.add("bytm-anchor", "bytm-carousel-shelf-anchor");
anchorElem.href = (_a = titleElem === null || titleElem === void 0 ? void 0 : titleElem.href) !== null && _a !== void 0 ? _a : "#";
anchorElem.target = "_self";
anchorElem.role = "button";
anchorElem.addEventListener("click", preventDefault);
addParent(thumbnailElem, anchorElem);
}
};
// home page
onSelector("#contents.ytmusic-section-list-renderer ytmusic-carousel-shelf-renderer ytmusic-responsive-list-item-renderer", {
continuous: true,
all: true,
listener: addListItemAnchors,
});
// related tab in /watch
onSelector("ytmusic-tab-renderer[page-type=\"MUSIC_PAGE_TYPE_TRACK_RELATED\"] ytmusic-responsive-list-item-renderer", {
continuous: true,
all: true,
listener: addListItemAnchors,
});
// playlists
onSelector("#contents.ytmusic-section-list-renderer ytmusic-playlist-shelf-renderer ytmusic-responsive-list-item-renderer", {
continuous: true,
all: true,
listener: addListItemAnchors,
});
// generic shelves
onSelector("#contents.ytmusic-section-list-renderer ytmusic-shelf-renderer ytmusic-responsive-list-item-renderer", {
continuous: true,
all: true,
listener: addListItemAnchors,
});
}
catch (err) {
error("Couldn't improve carousel shelf anchors due to an error:", err);
}
try {
const addSidebarAnchors = (sidebarCont) => {
const items = sidebarCont.parentNode.querySelectorAll("ytmusic-guide-entry-renderer tp-yt-paper-item");
improveSidebarAnchors(items);
return items.length;
};
onSelector("ytmusic-app-layout tp-yt-app-drawer #contentContainer #guide-content #items ytmusic-guide-entry-renderer", {
listener: (sidebarCont) => {
const itemsAmt = addSidebarAnchors(sidebarCont);
log(`Added anchors around ${itemsAmt} sidebar ${autoPlural("item", itemsAmt)}`);
},
});
onSelector("ytmusic-app-layout #mini-guide ytmusic-guide-renderer ytmusic-guide-section-renderer #items ytmusic-guide-entry-renderer", {
listener: (miniSidebarCont) => {
const itemsAmt = addSidebarAnchors(miniSidebarCont);
log(`Added anchors around ${itemsAmt} mini sidebar ${autoPlural("item", itemsAmt)}`);
},
});
}
catch (err) {
error("Couldn't add anchors to sidebar items due to an error:", err);
}
}
const sidebarPaths = [
"/",
"/explore",
"/library",
];
/**
* Adds anchors to the sidebar items so they can be opened in a new tab
* @param sidebarItem
*/
function improveSidebarAnchors(sidebarItems) {
sidebarItems.forEach((item, i) => {
var _a;
const anchorElem = document.createElement("a");
anchorElem.classList.add("bytm-anchor", "bytm-no-select");
anchorElem.role = "button";
anchorElem.target = "_self";
anchorElem.href = (_a = sidebarPaths[i]) !== null && _a !== void 0 ? _a : "#";
anchorElem.title = "Middle click to open in a new tab";
anchorElem.addEventListener("click", (e) => {
e.preventDefault();
});
addParent(item, anchorElem);
});
}
/** Closes toasts after a set amount of time */
function initAutoCloseToasts() {
try {
const animTimeout = 300;
const closeTimeout = Math.max(features.closeToastsTimeout * 1000 + animTimeout, animTimeout);
onSelector("tp-yt-paper-toast#toast", {
all: true,
continuous: true,
listener: (toastElems) => layout_awaiter(this, void 0, void 0, function* () {
var _a;
for (const toastElem of toastElems) {
if (!toastElem.hasAttribute("allow-click-through"))
continue;
if (toastElem.classList.contains("bytm-closing"))
continue;
toastElem.classList.add("bytm-closing");
yield pauseFor(closeTimeout);
toastElem.classList.remove("paper-toast-open");
log(`Automatically closed toast '${(_a = toastElem.querySelector("#text-container yt-formatted-string")) === null || _a === void 0 ? void 0 : _a.innerText}' after ${features.closeToastsTimeout * 1000}ms`);
// wait for the transition to finish
yield pauseFor(animTimeout);
toastElem.style.display = "none";
}
}),
});
log("Initialized automatic toast closing");
}
catch (err) {
error("Error in automatic toast closing:", err);
}
}
/** Continuously removes the ?si tracking parameter from share URLs */
function removeShareTrackingParam() {
onSelector("yt-copy-link-renderer input#share-url", {
continuous: true,
listener: (inputElem) => {
try {
const url = new URL(inputElem.value);
if (!url.searchParams.has("si"))
return;
url.searchParams.delete("si");
inputElem.value = String(url);
log(`Removed tracking parameter from share link: ${url}`);
}
catch (err) {
warn("Couldn't remove tracking parameter from share link due to error:", err);
}
},
});
}
/** Applies global CSS to fix various spacings */
function fixSpacing() {
addGlobalStyle(`\
ytmusic-carousel-shelf-renderer ytmusic-carousel ytmusic-responsive-list-item-renderer {
margin-bottom: var(--ytmusic-carousel-item-margin-bottom, 16px) !important;
}
ytmusic-carousel-shelf-renderer ytmusic-carousel {
--ytmusic-carousel-item-height: 60px !important;
}`);
}
/** Adds a button to the queue to scroll to the active song */
function addScrollToActiveBtn() {
onSelector(".side-panel.modular #tabsContent tp-yt-paper-tab:nth-of-type(1)", {
listener: (tabElem) => layout_awaiter(this, void 0, void 0, function* () {
const containerElem = document.createElement("div");
containerElem.id = "bytm-scroll-to-active-btn-cont";
const linkElem = document.createElement("div");
linkElem.id = "bytm-scroll-to-active-btn";
linkElem.className = "ytmusic-player-bar bytm-generic-btn";
linkElem.title = "Click to scroll to the currently playing song";
linkElem.role = "button";
const imgElem = document.createElement("img");
imgElem.className = "bytm-generic-btn-img";
imgElem.src = yield getResourceUrl("skip_to");
linkElem.addEventListener("click", (e) => {
const activeItem = document.querySelector(".side-panel.modular .ytmusic-player-queue ytmusic-player-queue-item[play-button-state=\"loading\"], .side-panel.modular .ytmusic-player-queue ytmusic-player-queue-item[play-button-state=\"playing\"], .side-panel.modular .ytmusic-player-queue ytmusic-player-queue-item[play-button-state=\"paused\"]");
if (!activeItem)
return;
e.preventDefault();
e.stopImmediatePropagation();
activeItem.scrollIntoView({
behavior: "smooth",
block: "center",
inline: "center",
});
});
linkElem.appendChild(imgElem);
containerElem.appendChild(linkElem);
tabElem.appendChild(containerElem);
}),
});
}
;// CONCATENATED MODULE: ./src/menu/menu.ts
// REQUIREMENTS:
// - modal using the <dialog> element
// - sections with headers
// - support for "custom widgets"
// - debounce or save on button press to store new configuration
// - much better scaling including no vw and vh units
// - cleanup function per feature so a page reload is not always needed
/**
* The base selector values for the menu tabs
* Header selector format: `#${baseValue}-header`
* Content selector format: `#${baseValue}-content`
*/
const tabsSelectors = {
options: "bytm-menu-tab-options",
info: "bytm-menu-tab-info",
changelog: "bytm-menu-tab-changelog",
};
/** Called from init(), before DOMContentLoaded is fired */
function initMenu() {
document.addEventListener("DOMContentLoaded", () => {
// create menu container
const menuContainer = document.createElement("div");
menuContainer.id = "bytm-menu-container";
// add menu html
menuContainer.innerHTML = menuContent;
document.body.appendChild(menuContainer);
initMenuContents();
});
}
function initMenuContents() {
var _a;
// hook events
for (const tab in tabsSelectors) {
const selector = tabsSelectors[tab];
(_a = document.querySelector(`#${selector}-header`)) === null || _a === void 0 ? void 0 : _a.addEventListener("click", () => {
setActiveTab(tab);
});
}
// init tab contents
initOptionsContent();
initInfoContent();
initChangelogContent();
}
/** Opens the specified tab */
function setActiveTab(tab) {
const tabs = Object.assign({}, tabsSelectors);
delete tabs[tab];
// disable all but new active tab
for (const [, val] of Object.entries(tabs)) {
document.querySelector(`#${val}-header`).dataset.active = "false";
document.querySelector(`#${val}-content`).dataset.active = "false";
}
// enable new active tab
document.querySelector(`#${tabsSelectors[tab]}-header`).dataset.active = "true";
document.querySelector(`#${tabsSelectors[tab]}-content`).dataset.active = "true";
}
/** Opens the modal menu dialog */
function menu_openMenu() {
var _a;
(_a = document.querySelector("#bytm-menu-dialog")) === null || _a === void 0 ? void 0 : _a.showModal();
}
/** Closes the modal menu dialog */
function menu_closeMenu() {
var _a;
(_a = document.querySelector("#bytm-menu-dialog")) === null || _a === void 0 ? void 0 : _a.close();
}
function initOptionsContent() {
const tab = document.querySelector("#bytm-menu-tab-options-content");
void tab;
}
function initInfoContent() {
const tab = document.querySelector("#bytm-menu-tab-info-content");
void tab;
}
function initChangelogContent() {
const tab = document.querySelector("#bytm-menu-tab-changelog-content");
tab.innerHTML = changelogContent;
}
;// CONCATENATED MODULE: ./src/features/index.ts
/** Mapping of feature category identifiers to readable strings */
const categoryNames = {
input: "Input",
layout: "Layout",
lyrics: "Lyrics",
misc: "Other",
};
/** Contains all possible features with their default values and other configuration */
const featInfo = {
removeUpgradeTab: {
desc: "Remove the Upgrade / Premium tab",
type: "toggle",
category: "layout",
default: true,
},
volumeSliderLabel: {
desc: "Add a percentage label next to the volume slider",
type: "toggle",
category: "layout",
default: true,
},
volumeSliderSize: {
desc: "The width of the volume slider in pixels",
type: "number",
category: "layout",
min: 50,
max: 500,
step: 5,
default: 150,
unit: "px",
},
volumeSliderStep: {
desc: "Volume slider sensitivity (by how little percent the volume can be changed at a time)",
type: "slider",
category: "layout",
min: 1,
max: 25,
default: 2,
unit: "%",
},
watermarkEnabled: {
desc: `Show a ${constants_scriptInfo.name} watermark under the site logo that opens this config menu`,
type: "toggle",
category: "layout",
default: true,
},
deleteFromQueueButton: {
desc: "Add a button to each song in the queue to quickly remove it",
type: "toggle",
category: "layout",
default: true,
},
closeToastsTimeout: {
desc: "After how many seconds to close permanent notifications - 0 to only close them manually (default behavior)",
type: "number",
category: "layout",
min: 0,
max: 30,
step: 0.5,
default: 0,
unit: "s",
},
removeShareTrackingParam: {
desc: "Remove the tracking parameter (&si=...) from links in the share popup",
type: "toggle",
category: "layout",
default: true,
},
fixSpacing: {
desc: "Fix spacing issues in the layout",
type: "toggle",
category: "layout",
default: true,
},
scrollToActiveSongBtn: {
desc: "Add a button to the queue to scroll to the currently playing song",
type: "toggle",
category: "layout",
default: true,
},
arrowKeySupport: {
desc: "Use arrow keys to skip forwards and backwards by 10 seconds",
type: "toggle",
category: "input",
default: true,
},
switchBetweenSites: {
desc: "Add F9 as a hotkey to switch between the YT and YTM sites on a video / song",
type: "toggle",
category: "input",
default: true,
},
switchSitesHotkey: {
hidden: true,
desc: "TODO(v1.1): Which hotkey needs to be pressed to switch sites?",
type: "hotkey",
category: "input",
default: {
key: "F9",
shift: false,
ctrl: false,
meta: false,
},
},
disableBeforeUnloadPopup: {
desc: "Prevent the confirmation popup that appears when trying to leave the site while a song is playing",
type: "toggle",
category: "input",
default: false,
},
anchorImprovements: {
desc: "Add and improve links all over the page so things can be opened in a new tab easier",
type: "toggle",
category: "input",
default: true,
},
numKeysSkipToTime: {
desc: "Enable skipping to a specific time in the video by pressing a number key (0-9)",
type: "toggle",
category: "input",
default: true,
},
geniusLyrics: {
desc: "Add a button to the media controls of the currently playing song to open its lyrics on genius.com",
type: "toggle",
category: "lyrics",
default: true,
},
lyricsQueueButton: {
desc: "Add a button to each song in the queue to quickly open its lyrics page",
type: "toggle",
category: "lyrics",
default: true,
},
logLevel: {
desc: "How much information to log to the console",
type: "select",
category: "misc",
options: [
{ value: 0, label: "Debug (most)" },
{ value: 1, label: "Info (only important)" },
],
default: 1,
},
};
;// CONCATENATED MODULE: ./src/config.ts
var config_awaiter = (undefined && undefined.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
/** If this number is incremented, the features object data will be migrated to the new format */
const formatVersion = 3;
/** Config data format migration dictionary */
const migrations = {
// 1 -> 2
2: (oldData) => {
const queueBtnsEnabled = Boolean(oldData.queueButtons);
delete oldData.queueButtons;
return Object.assign(Object.assign({}, oldData), { deleteFromQueueButton: queueBtnsEnabled, lyricsQueueButton: queueBtnsEnabled });
},
// 2 -> 3
3: (oldData) => (Object.assign(Object.assign({}, oldData), { removeShareTrackingParam: true, numKeysSkipToTime: true, fixSpacing: true, scrollToActiveSongBtn: true, logLevel: 1 })),
};
const defaultConfig = Object.keys(featInfo)
.reduce((acc, key) => {
acc[key] = featInfo[key].default;
return acc;
}, {});
const cfgMgr = new ConfigManager({
id: "bytm-config",
formatVersion,
defaultConfig,
migrations,
});
/** Initializes the ConfigManager instance and loads persistent data into memory */
function initConfig() {
return config_awaiter(this, void 0, void 0, function* () {
const oldFmtVer = Number(yield GM.getValue(`_uucfgver-${cfgMgr.id}`, NaN));
const data = yield cfgMgr.loadData();
log(`Initialized ConfigManager (format version = ${cfgMgr.formatVersion})`);
if (isNaN(oldFmtVer))
utils_info("Config data initialized with default values");
else if (oldFmtVer !== cfgMgr.formatVersion)
utils_info(`Config data migrated from version ${oldFmtVer} to ${cfgMgr.formatVersion}`);
return data;
});
}
/** Returns the current feature config from the in-memory cache */
function getFeatures() {
return cfgMgr.getData();
}
/** Saves the feature config synchronously to the in-memory cache and asynchronously to the persistent storage */
function saveFeatures(featureConf) {
return config_awaiter(this, void 0, void 0, function* () {
yield cfgMgr.setData(featureConf);
siteEvents.emit("configChanged", cfgMgr.getData());
utils_info("Saved new feature config:", featureConf);
});
}
/** Saves the default feature config synchronously to the in-memory cache and asynchronously to persistent storage */
function setDefaultFeatures() {
return config_awaiter(this, void 0, void 0, function* () {
yield cfgMgr.saveDefaultData();
siteEvents.emit("configChanged", cfgMgr.getData());
utils_info("Reset feature config to its default values");
});
}
/** Clears the feature config from the persistent storage - since the cache will be out of whack, this should only be run before a site re-/unload */
function clearConfig() {
return config_awaiter(this, void 0, void 0, function* () {
yield cfgMgr.deleteConfig();
utils_info("Deleted config from persistent storage");
});
}
;// CONCATENATED MODULE: ./src/index.ts
var src_awaiter = (undefined && undefined.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
{
// console watermark with sexy gradient
const styleGradient = "background: rgba(165, 38, 38, 1); background: linear-gradient(90deg, rgb(154, 31, 103) 0%, rgb(135, 31, 31) 40%, rgb(184, 64, 41) 100%);";
const styleCommon = "color: #fff; font-size: 1.5em; padding-left: 6px; padding-right: 6px;";
console.log();
console.log(`%c${constants_scriptInfo.name}%cv${constants_scriptInfo.version}%c\n\nBuild #${constants_scriptInfo.buildNumber} ─ ${constants_scriptInfo.namespace}`, `font-weight: bold; ${styleCommon} ${styleGradient}`, `background-color: #333; ${styleCommon}`, "padding: initial;");
console.log([
"Powered by:",
"─ lots of ambition",
`─ my song metadata API: ${geniUrlBase}`,
"─ my userscript utility library: https://github.com/Sv443-Network/UserUtils",
"─ this tiny event listener library: https://github.com/ai/nanoevents",
].join("\n"));
console.log();
}
const domain = getDomain();
/** Stuff that needs to be called ASAP, before anything async happens */
function preInit() {
setLogLevel(defaultLogLevel);
if (domain === "ytm")
initBeforeUnloadHook();
init();
}
function init() {
return src_awaiter(this, void 0, void 0, function* () {
try {
registerMenuCommands();
}
catch (e) {
void e;
}
// init DOM-dependant stuff like features
try {
document.addEventListener("DOMContentLoaded", onDomLoad);
}
catch (err) {
error("General Error:", err);
}
// init config
try {
const ftConfig = yield initConfig();
setLogLevel(getFeatures().logLevel);
preInitLayout(ftConfig);
if (getFeatures().disableBeforeUnloadPopup)
disableBeforeUnload();
}
catch (err) {
error("Error while initializing ConfigManager:", err);
}
// init menu separately from features
try {
void "TODO(v1.1):";
// initMenu();
}
catch (err) {
error("Couldn't initialize menu:", err);
}
});
}
/** Called when the DOM has finished loading and can be queried and altered by the userscript */
function onDomLoad() {
return src_awaiter(this, void 0, void 0, function* () {
// post-build these double quotes are replaced by backticks (because if backticks are used here, webpack converts them to double quotes)
addGlobalStyle(`.bytm-menu-bg {
--bytm-menu-bg: #333333;
--bytm-menu-bg-highlight: #1e1e1e;
--bytm-menu-separator-color: #797979;
--bytm-menu-border-radius: 10px;
}
#bytm-cfg-menu-bg {
--bytm-menu-height-max: 750px;
--bytm-menu-width-max: 1000px;
}
#bytm-changelog-menu-bg {
--bytm-menu-height-max: 800px;
--bytm-menu-width-max: 800px;
}
#bytm-export-menu-bg, #bytm-import-menu-bg {
--bytm-menu-height-max: 500px;
--bytm-menu-width-max: 600px;
}
.bytm-menu-bg {
display: block;
position: fixed;
width: 100%;
height: 100%;
top: 0;
left: 0;
z-index: 15;
background-color: rgba(0, 0, 0, 0.6);
}
.bytm-menu {
position: fixed;
display: flex;
flex-direction: column;
width: calc(min(100% - 60px, var(--bytm-menu-width-max)));
border-radius: var(--bytm-menu-border-radius);
height: auto;
max-height: calc(min(100% - 40px, var(--bytm-menu-height-max)));
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 16;
color: #fff;
background-color: var(--bytm-menu-bg);
}
.bytm-menu-body {
padding: 20px;
}
#bytm-menu-opts {
display: flex;
flex-direction: column;
position: relative;
padding: 30px 0px;
overflow-y: auto;
}
.bytm-menu-header {
display: flex;
justify-content: space-between;
margin-bottom: 6px;
padding: 15px 20px 15px 20px;
background-color: var(--bytm-menu-bg);
border: 2px solid var(--bytm-menu-separator-color);
border-style: none none solid none;
border-radius: var(--bytm-menu-border-radius) var(--bytm-menu-border-radius) 0px 0px;
}
#bytm-menu-titlecont {
display: flex;
align-items: center;
}
#bytm-menu-title {
display: inline-block;
font-size: 22px;
}
#bytm-menu-linkscont {
display: flex;
align-items: center;
margin-left: 32px;
}
.bytm-menu-link {
display: inline-flex;
align-items: center;
cursor: pointer;
}
.bytm-menu-link:not(:last-of-type) {
margin-right: 10px;
}
.bytm-menu-close {
width: 32px;
height: 32px;
cursor: pointer;
}
.bytm-menu-footer {
font-size: 17px;
text-decoration: underline;
}
#bytm-menu-footer-cont {
display: flex;
flex-direction: row;
justify-content: space-between;
margin-top: 6px;
padding: 20px 20px 8px 20px;
background: var(--bytm-menu-bg);
background: linear-gradient(to bottom, rgba(0, 0, 0, 0) 0%, var(--bytm-menu-bg) 30%, var(--bytm-menu-bg) 100%);
border: 2px solid var(--bytm-menu-separator-color);
border-style: solid none none none;
}
#bytm-menu-footer-buttons-cont button:not(:last-of-type) {
margin-right: 15px;
}
.bytm-menu-footer-right {
display: flex;
flex-direction: row-reverse;
align-items: center;
margin-top: 15px;
}
#bytm-menu-version-cont {
display: flex;
justify-content: space-around;
font-size: 1.2em;
padding-bottom: 8px;
border-radius: var(--bytm-menu-border-radius) var(--bytm-menu-border-radius) 0px 0px;
}
#bytm-menu-scroll-indicator {
--bytm-scroll-indicator-padding: 5px;
position: sticky;
bottom: -15px;
left: 50%;
margin-top: calc(-32px - var(--bytm-scroll-indicator-padding) * 2);
padding: var(--bytm-scroll-indicator-padding);
transform: translateX(-50%);
width: 32px;
height: 32px;
z-index: 101;
background-color: var(--bytm-menu-bg-highlight);
border-radius: 50%;
cursor: pointer;
}
.bytm-hidden {
visibility: hidden !important;
}
.bytm-ftconf-category-header {
font-size: 18px;
margin-top: 32px;
margin-bottom: 8px;
padding: 0px 20px;
}
.bytm-ftconf-category-header:first-of-type {
margin-top: 0;
}
.bytm-ftitem {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
font-size: 1.4em;
padding: 8px 20px;
}
.bytm-ftconf-ctrl {
display: inline-flex;
align-items: center;
white-space: nowrap;
}
.bytm-ftconf-label {
user-select: none;
}
.bytm-slider-label {
margin-right: 10px;
}
.bytm-toggle-label {
padding-left: 10px;
padding-right: 5px;
}
.bytm-ftconf-input[type=number] {
width: 75px;
}
.bytm-ftconf-input[type=checkbox] {
margin-left: 5px;
}
#bytm-export-menu-text, #bytm-import-menu-text {
font-size: 1.6em;
margin-bottom: 15px;
}
.bytm-menu-footer-copied {
font-size: 1.6em;
margin-right: 15px;
}
#bytm-changelog-menu-body {
overflow-y: auto;
}
#bytm-export-menu-textarea, #bytm-import-menu-textarea {
width: 100%;
height: 150px;
resize: none;
}
.bytm-markdown-container {
display: flex;
flex-direction: column;
overflow-y: auto;
font-size: 1.5em;
line-height: 20px;
}
/* Markdown stuff */
.bytm-markdown-container a, #bytm-menu-version {
color: #369bff;
text-decoration: none;
cursor: pointer;
}
.bytm-markdown-container a:hover, #bytm-menu-version:hover {
text-decoration: underline;
}
.bytm-markdown-container kbd {
--easing: cubic-bezier(0.31, 0.58, 0.24, 1.15);
display: inline-block;
vertical-align: bottom;
padding: 4px;
padding-top: 2px;
font-size: 0.95em;
line-height: 11px;
background-color: #222;
border: 1px solid #777;
border-radius: 5px;
box-shadow: inset 0 -2px 0 #515559;
transition: padding 0.1s var(--easing), box-shadow 0.1s var(--easing);
}
.bytm-markdown-container kbd:active {
padding-bottom: 2px;
box-shadow: inset 0 0 0 #61666c;
}
.bytm-markdown-container kbd::selection {
background: rgba(0, 0, 0, 0);
}
.bytm-markdown-container code {
background-color: #222;
border-radius: 3px;
padding: 1px 5px;
}
.bytm-markdown-container h2 {
margin-bottom: 5px;
}
.bytm-markdown-container h2:not(:first-of-type) {
margin-top: 20px;
}
.bytm-markdown-container ul li::before {
content: "• ";
font-weight: bolder;
}
.bytm-markdown-container ul li > ul li::before {
white-space: pre;
content: " • ";
font-weight: bolder;
}
.bytm-disable-scroll {
overflow: hidden !important;
}
.bytm-generic-btn {
display: inline-flex;
align-items: center;
justify-content: center;
position: relative;
vertical-align: middle;
cursor: pointer;
margin-left: 8px;
width: 40px;
height: 40px;
border-radius: 100%;
background-color: transparent;
}
.bytm-generic-btn:hover {
background-color: var(--yt-spec-10-percent-layer, #1d1d1d);
}
.bytm-generic-btn-img {
display: inline-block;
z-index: 10;
width: 24px;
height: 24px;
padding: 5px;
}
.bytm-spinner {
animation: rotate 1.2s linear infinite;
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.bytm-anchor {
all: unset;
cursor: pointer;
}
/* ytmusic-logo a[bytm-animated="true"] .bytm-mod-logo-ellipse {
transform-origin: 12px 12px;
animation: rotate 1s ease-in-out infinite;
} */
ytmusic-logo a.bytm-logo-exchanged .bytm-mod-logo-path {
transform-origin: 12px 12px;
animation: rotate 1s ease-in-out;
}
ytmusic-logo a.bytm-logo-exchanged .bytm-mod-logo-img {
width: 24px;
height: 24px;
z-index: 1000;
position: absolute;
animation: rotate-fade-in 1s ease-in-out;
}
@keyframes rotate-fade-in {
0% {
opacity: 0;
transform: rotate(0deg);
}
30% {
opacity: 0;
}
90% {
opacity: 1;
}
100% {
transform: rotate(360deg);
}
}
.bytm-no-select {
user-select: none;
-ms-user-select: none;
-moz-user-select: none;
-webkit-user-select: none;
}
/* YTM does some weird styling that breaks everything, so this reverts all of BYTM's buttons to the browser default style */
button.bytm-btn {
padding: revert;
border: revert;
outline: revert;
font: revert;
text-transform: revert;
color: revert;
background: revert;
}
.bytm-cfg-menu-option {
display: block;
padding: 8px 0;
}
.bytm-cfg-menu-option-item {
display: flex;
flex-direction: row;
align-items: center;
font-size: 16px;
font-weight: 400;
line-height: 24px;
padding: var(--yt-compact-link-paper-item-padding, 0px 36px 0 16px);
min-height: var(--paper-item-min-height, 40px);
white-space: nowrap;
cursor: pointer;
}
.bytm-cfg-menu-option-item:hover {
background-color: var(--yt-spec-badge-chip-background, #3e3e3e);
}
.bytm-cfg-menu-option-icon {
width: 24px;
height: 24px;
margin-right: 16px;
display: flex;
align-items: center;
flex-direction: row;
flex: none;
}
.bytm-cfg-menu-option-text {
font-size: 1.4rem;
line-height: 2rem;
}
yt-multi-page-menu-section-renderer.ytd-multi-page-menu-renderer {
border-bottom: 1px solid var(--yt-spec-10-percent-layer, #3e3e3e);
}
#bytm-watermark {
font-size: 10px;
display: inline-block;
position: absolute;
left: 97px;
top: 46px;
z-index: 10;
color: white;
text-decoration: none;
cursor: pointer;
}
#bytm-watermark:hover {
text-decoration: underline;
}
.side-panel.modular ytmusic-player-queue-item .song-info.ytmusic-player-queue-item {
position: relative;
}
.side-panel.modular ytmusic-player-queue-item .bytm-queue-btn-container {
background: rgb(0, 0, 0);
background: linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 1) 15%);
display: none;
position: absolute;
right: 0;
padding-left: 25px;
height: 100%;
}
.side-panel.modular ytmusic-player-queue-item:hover .bytm-queue-btn-container {
display: inline-block;
}
.side-panel.modular ytmusic-player-queue-item[play-button-state="loading"] .bytm-queue-btn-container,
.side-panel.modular ytmusic-player-queue-item[play-button-state="playing"] .bytm-queue-btn-container,
.side-panel.modular ytmusic-player-queue-item[play-button-state="paused"] .bytm-queue-btn-container {
/* using a var() with predefined value from YTM is not viable since the nesting changes the actual value of the variable */
background: linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, rgba(29, 29, 29, 1) 15%);
}
ytmusic-app ytmusic-popup-container tp-yt-iron-dropdown[data-bytm-hidden=true] {
display: none !important;
}
ytmusic-responsive-list-item-renderer:not([unplayable_]) .left-items {
margin-right: 0 !important;
}
.bytm-carousel-shelf-anchor {
margin-right: var(--ytmusic-responsive-list-item-thumbnail-margin-right, 24px);
}
#bytm-vol-slider-cont {
position: relative;
}
.bytm-vol-slider-label {
opacity: 0.000001;
position: absolute;
font-size: 15px;
top: 50%;
left: 0;
transform: translate(calc(-50% - 10px), -50%);
text-align: right;
transition: opacity 0.2s ease;
}
.bytm-vol-slider-label.bytm-visible {
opacity: 1;
}
#bytm-scroll-to-active-btn-cont {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
position: absolute;
right: 5px;
top: 0;
height: 100%;
}
#bytm-scroll-to-active-btn {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
cursor: pointer;
}
#bytm-scroll-to-active-btn {
width: revert;
height: revert;
}
#bytm-scroll-to-active-btn .bytm-generic-btn-img {
padding: 4px;
}
display: none;
flex-direction: column;
justify-content: center;
align-items: center;
}
#bytm-menu-backdrop[data-menu-open="true"] {
display: flex;
} */
#bytm-menu-header-container {
display: flex;
justify-content: flex-start;
align-items: center;
border-color: #ffffff;
border-style: none solid none none;
}
.bytm-menu-header-option {
display: "flex";
justify-content: center;
align-items: center;
border-color: #ffffff;
border-style: solid none solid none;
}
#bytm-menu-header-option h3 {
margin: 0;
}
.bytm-menu-tab[data-active="true"] {
display: none;
}
.bytm-menu-tab[data-active="false"] {
display: none;
}
`);
initOnSelector();
const features = getFeatures();
log(`Initializing features for domain "${domain}"...`);
try {
if (domain === "ytm") {
try {
addMenu(); // TODO(v1.1): remove
}
catch (err) {
error("Couldn't add menu:", err);
}
initSiteEvents();
onSelector("tp-yt-iron-dropdown #contentWrapper ytd-multi-page-menu-renderer #container.menu-container", { listener: addConfigMenuOption });
if (features.arrowKeySupport)
initArrowKeySkip();
if (features.removeUpgradeTab)
removeUpgradeTab();
if (features.watermarkEnabled)
addWatermark();
if (features.geniusLyrics)
addMediaCtrlLyricsBtn();
if (features.deleteFromQueueButton || features.lyricsQueueButton)
initQueueButtons();
if (features.anchorImprovements)
addAnchorImprovements();
if (features.closeToastsTimeout > 0)
initAutoCloseToasts();
if (features.removeShareTrackingParam)
removeShareTrackingParam();
if (features.numKeysSkipToTime)
initNumKeysSkip();
if (features.fixSpacing)
fixSpacing();
if (features.scrollToActiveSongBtn)
addScrollToActiveBtn();
initVolumeFeatures();
}
if (["ytm", "yt"].includes(domain)) {
if (features.switchBetweenSites)
initSiteSwitch(domain);
}
}
catch (err) {
error("Feature error:", err);
}
});
}
function registerMenuCommands() {
if (mode === "development") {
GM.registerMenuCommand("Reset config", () => src_awaiter(this, void 0, void 0, function* () {
if (confirm("Are you sure you want to reset the configuration to its default values?\nThis will automatically reload the page.")) {
yield clearConfig();
location.reload();
}
}), "r");
GM.registerMenuCommand("List GM values", () => src_awaiter(this, void 0, void 0, function* () {
alert("See console.");
const keys = yield GM.listValues();
console.log("GM values:");
if (keys.length === 0)
console.log(" No values found.");
for (const key of keys)
console.log(` ${key} -> ${yield GM.getValue(key)}`);
}), "l");
GM.registerMenuCommand("Clear all GM values", () => src_awaiter(this, void 0, void 0, function* () {
if (confirm("Are you sure you want to clear all GM values?")) {
const keys = yield GM.listValues();
console.log("Clearing GM values:");
if (keys.length === 0)
console.log(" No values found.");
for (const key of keys) {
yield GM.deleteValue(key);
console.log(` Deleted ${key}`);
}
}
}), "c");
}
}
preInit();