From e0d58c6bb7c63df59d864ed34a95e6a5eea6d655 Mon Sep 17 00:00:00 2001 From: Sv443 Date: Mon, 18 Sep 2023 19:39:26 +0200 Subject: [PATCH] chore: build prod --- dist/BetterYTM.user.js | 5626 +++++++++++++++++++--------------------- 1 file changed, 2618 insertions(+), 3008 deletions(-) diff --git a/dist/BetterYTM.user.js b/dist/BetterYTM.user.js index bd62100b07..ee926bcc51 100644 --- a/dist/BetterYTM.user.js +++ b/dist/BetterYTM.user.js @@ -8,12 +8,12 @@ // @license MIT // @author Sv443 // @copyright Sv443 (https://github.com/Sv443) -// @icon https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/icon/icon_48.png +// @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 -// @downloadURL https://raw.githubusercontent.com/Sv443/BetterYTM/develop/dist/BetterYTM.user.js -// @updateURL https://raw.githubusercontent.com/Sv443/BetterYTM/develop/dist/BetterYTM.user.js +// @downloadURL https://raw.githubusercontent.com/Sv443/BetterYTM/main/dist/BetterYTM.user.js +// @updateURL https://raw.githubusercontent.com/Sv443/BetterYTM/main/dist/BetterYTM.user.js // @connect api.sv443.net // @grant GM.getValue // @grant GM.setValue @@ -21,18 +21,16 @@ // @grant GM.setClipboard // @grant unsafeWindow // @noframes -// @resource icon https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/icon/icon_48.png -// @resource close https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/close.png -// @resource delete https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/delete.svg -// @resource error https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/error.svg -// @resource lyrics https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/lyrics.svg -// @resource spinner https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/spinner.svg -// @resource arrow_down https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/arrow_down.svg -// @resource github https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/external/github.png -// @resource greasyfork https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/external/greasyfork.png -// @grant GM.deleteValue -// @grant GM.registerMenuCommand -// @grant GM.listValues +// @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== /* ▄▄▄ ▄ ▄▄▄▄▄▄ ▄ @@ -45,1307 +43,661 @@ I welcome every contribution on GitHub! https://github.com/Sv443/BetterYTM */ -/* Disclaimer: I am not affiliated with YouTube, Google, Alphabet, Genius or anyone else */ +/* Disclaimer: I am not affiliated with or endorsed by YouTube, Google, Alphabet, Genius or anyone else */ /* C&D this 🖕 */ -/******/ var __webpack_modules__ = ({ - -/***/ "./changelog.md": -/*!**********************!*\ - !*** ./changelog.md ***! - \**********************/ -/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) { - -__webpack_require__.r(__webpack_exports__); -// Module -var code = "

1.0.0

\n\n
\n\n

0.2.0

\n\n
\n\n

0.1.0

\n\n"; -// Exports -/* harmony default export */ __webpack_exports__["default"] = (code); - -/***/ }), - -/***/ "./src/menu/menu.html": -/*!****************************!*\ - !*** ./src/menu/menu.html ***! - \****************************/ -/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) { - -__webpack_require__.r(__webpack_exports__); -// Module -var code = "\n
\n
\n

Options

\n
\n
\n

Info

\n
\n
\n

Changelog

\n
\n
\n
\n
\n
\n ayo info\n
\n
\n
\n
\n"; -// Exports -/* harmony default export */ __webpack_exports__["default"] = (code); - -/***/ }), - -/***/ "./src/features/layout.css": -/*!*********************************!*\ - !*** ./src/features/layout.css ***! - \*********************************/ -/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) { - -__webpack_require__.r(__webpack_exports__); -// extracted by mini-css-extract-plugin - - -/***/ }), - -/***/ "./src/menu/menu.css": -/*!***************************!*\ - !*** ./src/menu/menu.css ***! - \***************************/ -/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) { - -__webpack_require__.r(__webpack_exports__); -// extracted by mini-css-extract-plugin - - -/***/ }), - -/***/ "./src/menu/menu_old.css": -/*!*******************************!*\ - !*** ./src/menu/menu_old.css ***! - \*******************************/ -/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) { - -__webpack_require__.r(__webpack_exports__); -// extracted by mini-css-extract-plugin - - -/***/ }), - -/***/ "./src/config.ts": -/*!***********************!*\ - !*** ./src/config.ts ***! - \***********************/ -/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) { +var __webpack_exports__ = {}; -__webpack_require__.r(__webpack_exports__); -/* harmony export */ __webpack_require__.d(__webpack_exports__, { -/* harmony export */ clearConfig: function() { return /* binding */ clearConfig; }, -/* harmony export */ defaultConfig: function() { return /* binding */ defaultConfig; }, -/* harmony export */ formatVersion: function() { return /* binding */ formatVersion; }, -/* harmony export */ getFeatures: function() { return /* binding */ getFeatures; }, -/* harmony export */ initConfig: function() { return /* binding */ initConfig; }, -/* harmony export */ migrations: function() { return /* binding */ migrations; }, -/* harmony export */ saveFeatures: function() { return /* binding */ saveFeatures; }, -/* harmony export */ setDefaultFeatures: function() { return /* binding */ setDefaultFeatures; } -/* harmony export */ }); -/* harmony import */ var _sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! @sv443-network/userutils */ "./node_modules/@sv443-network/userutils/dist/index.mjs"); -/* harmony import */ var _features_index__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./features/index */ "./src/features/index.ts"); -/* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./utils */ "./src/utils.ts"); -/* harmony import */ var _events__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./events */ "./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()); - }); +;// 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; }; - - - - -/** 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, logLevel: 1 })), +var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b)); +var __publicField = (obj, key, value) => { + __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); + return value; }; -const defaultConfig = Object.keys(_features_index__WEBPACK_IMPORTED_MODULE_1__.featInfo) - .reduce((acc, key) => { - acc[key] = _features_index__WEBPACK_IMPORTED_MODULE_1__.featInfo[key].default; - return acc; -}, {}); -const cfgMgr = new _sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.ConfigManager({ - id: "bytm-config", - formatVersion, - defaultConfig, - migrations, -}); -/** Initializes the ConfigManager instance and loads persistent data into memory */ -function initConfig() { - return __awaiter(this, void 0, void 0, function* () { - const oldFmtVer = Number(yield GM.getValue(`_uucfgver-${cfgMgr.id}`, NaN)); - const data = yield cfgMgr.loadData(); - (0,_utils__WEBPACK_IMPORTED_MODULE_2__.log)(`Initialized ConfigManager (format version = ${cfgMgr.formatVersion})`); - if (isNaN(oldFmtVer)) - (0,_utils__WEBPACK_IMPORTED_MODULE_2__.info)("Config data initialized with default values"); - else if (oldFmtVer !== cfgMgr.formatVersion) - (0,_utils__WEBPACK_IMPORTED_MODULE_2__.info)(`Config data migrated from version ${oldFmtVer} to ${cfgMgr.formatVersion}`); - return data; - }); +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); } -/** Returns the current feature config from the in-memory cache */ -function getFeatures() { - return cfgMgr.getData(); +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; } -/** Saves the feature config synchronously to the in-memory cache and asynchronously to the persistent storage */ -function saveFeatures(featureConf) { - return __awaiter(this, void 0, void 0, function* () { - yield cfgMgr.setData(featureConf); - _events__WEBPACK_IMPORTED_MODULE_3__.siteEvents.emit("configChanged", cfgMgr.getData()); - (0,_utils__WEBPACK_IMPORTED_MODULE_2__.info)("Saved new feature config:", featureConf); - }); +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; } -/** Saves the default feature config synchronously to the in-memory cache and asynchronously to persistent storage */ -function setDefaultFeatures() { - return __awaiter(this, void 0, void 0, function* () { - yield cfgMgr.saveDefaultData(); - _events__WEBPACK_IMPORTED_MODULE_3__.siteEvents.emit("configChanged", cfgMgr.getData()); - (0,_utils__WEBPACK_IMPORTED_MODULE_2__.info)("Reset feature config to its default values"); - }); + +// lib/array.ts +function randomItem(array) { + return randomItemIndex(array)[0]; } -/** 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 __awaiter(this, void 0, void 0, function* () { - yield cfgMgr.deleteConfig(); - (0,_utils__WEBPACK_IMPORTED_MODULE_2__.info)("Deleted config from persistent storage"); - }); +function randomItemIndex(array) { + if (array.length === 0) + return [void 0, void 0]; + const idx = randRange(array.length - 1); + return [array[idx], idx]; } - - -/***/ }), - -/***/ "./src/constants.ts": -/*!**************************!*\ - !*** ./src/constants.ts ***! - \**************************/ -/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) { - -__webpack_require__.r(__webpack_exports__); -/* harmony export */ __webpack_require__.d(__webpack_exports__, { -/* harmony export */ branch: function() { return /* binding */ branch; }, -/* harmony export */ defaultLogLevel: function() { return /* binding */ defaultLogLevel; }, -/* harmony export */ mode: function() { return /* binding */ mode; }, -/* harmony export */ scriptInfo: function() { return /* binding */ scriptInfo; } -/* harmony export */ }); -const modeRaw = "development"; -const branchRaw = "develop"; -/** 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 scriptInfo = { - name: GM.info.script.name, - version: GM.info.script.version, - namespace: GM.info.script.namespace, - lastCommit: "9aca99d", // assert as generic string instead of literal -}; - - -/***/ }), - -/***/ "./src/events.ts": -/*!***********************!*\ - !*** ./src/events.ts ***! - \***********************/ -/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) { - -__webpack_require__.r(__webpack_exports__); -/* harmony export */ __webpack_require__.d(__webpack_exports__, { -/* harmony export */ initSiteEvents: function() { return /* binding */ initSiteEvents; }, -/* harmony export */ removeAllObservers: function() { return /* binding */ removeAllObservers; }, -/* harmony export */ siteEvents: function() { return /* binding */ siteEvents; } -/* harmony export */ }); -/* harmony import */ var nanoevents__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! nanoevents */ "./node_modules/nanoevents/index.js"); -/* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./utils */ "./src/utils.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 = (0,nanoevents__WEBPACK_IMPORTED_MODULE_1__.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 = []; +function takeRandomItem(arr) { + const [itm, idx] = randomItemIndex(arr); + if (idx === void 0) + return void 0; + arr.splice(idx, 1); + return itm; } -/** 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 { - //#SECTION queue - // 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) { - (0,_utils__WEBPACK_IMPORTED_MODULE_0__.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) { - (0,_utils__WEBPACK_IMPORTED_MODULE_0__.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, - }); - //#SECTION home page observers - (0,_utils__WEBPACK_IMPORTED_MODULE_0__.info)("Successfully initialized SiteEvents observers"); - observers = observers.concat([ - queueObs, - autoplayObs, - ]); - } - catch (err) { - (0,_utils__WEBPACK_IMPORTED_MODULE_0__.error)("Couldn't initialize SiteEvents observers due to an error:\n", err); - } - }); +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; } - -/***/ }), - -/***/ "./src/features/index.ts": -/*!*******************************!*\ - !*** ./src/features/index.ts ***! - \*******************************/ -/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) { - -__webpack_require__.r(__webpack_exports__); -/* harmony export */ __webpack_require__.d(__webpack_exports__, { -/* harmony export */ addAnchorImprovements: function() { return /* reexport safe */ _layout__WEBPACK_IMPORTED_MODULE_2__.addAnchorImprovements; }, -/* harmony export */ addConfigMenuOption: function() { return /* reexport safe */ _layout__WEBPACK_IMPORTED_MODULE_2__.addConfigMenuOption; }, -/* harmony export */ addLyricsCacheEntry: function() { return /* reexport safe */ _lyrics__WEBPACK_IMPORTED_MODULE_3__.addLyricsCacheEntry; }, -/* harmony export */ addMediaCtrlLyricsBtn: function() { return /* reexport safe */ _lyrics__WEBPACK_IMPORTED_MODULE_3__.addMediaCtrlLyricsBtn; }, -/* harmony export */ addMenu: function() { return /* reexport safe */ _menu_menu_old__WEBPACK_IMPORTED_MODULE_5__.addMenu; }, -/* harmony export */ addWatermark: function() { return /* reexport safe */ _layout__WEBPACK_IMPORTED_MODULE_2__.addWatermark; }, -/* harmony export */ categoryNames: function() { return /* binding */ categoryNames; }, -/* harmony export */ closeMenu: function() { return /* reexport safe */ _menu_menu_old__WEBPACK_IMPORTED_MODULE_5__.closeMenu; }, -/* harmony export */ createLyricsBtn: function() { return /* reexport safe */ _lyrics__WEBPACK_IMPORTED_MODULE_3__.createLyricsBtn; }, -/* harmony export */ disableBeforeUnload: function() { return /* reexport safe */ _input__WEBPACK_IMPORTED_MODULE_1__.disableBeforeUnload; }, -/* harmony export */ enableBeforeUnload: function() { return /* reexport safe */ _input__WEBPACK_IMPORTED_MODULE_1__.enableBeforeUnload; }, -/* harmony export */ featInfo: function() { return /* binding */ featInfo; }, -/* harmony export */ geniUrlBase: function() { return /* reexport safe */ _lyrics__WEBPACK_IMPORTED_MODULE_3__.geniUrlBase; }, -/* harmony export */ getCurrentLyricsUrl: function() { return /* reexport safe */ _lyrics__WEBPACK_IMPORTED_MODULE_3__.getCurrentLyricsUrl; }, -/* harmony export */ getGeniusUrl: function() { return /* reexport safe */ _lyrics__WEBPACK_IMPORTED_MODULE_3__.getGeniusUrl; }, -/* harmony export */ getLyricsCacheEntry: function() { return /* reexport safe */ _lyrics__WEBPACK_IMPORTED_MODULE_3__.getLyricsCacheEntry; }, -/* harmony export */ initArrowKeySkip: function() { return /* reexport safe */ _input__WEBPACK_IMPORTED_MODULE_1__.initArrowKeySkip; }, -/* harmony export */ initAutoCloseToasts: function() { return /* reexport safe */ _layout__WEBPACK_IMPORTED_MODULE_2__.initAutoCloseToasts; }, -/* harmony export */ initBeforeUnloadHook: function() { return /* reexport safe */ _input__WEBPACK_IMPORTED_MODULE_1__.initBeforeUnloadHook; }, -/* harmony export */ initMenu: function() { return /* reexport safe */ _menu_menu__WEBPACK_IMPORTED_MODULE_4__.initMenu; }, -/* harmony export */ initNumKeysSkip: function() { return /* reexport safe */ _input__WEBPACK_IMPORTED_MODULE_1__.initNumKeysSkip; }, -/* harmony export */ initQueueButtons: function() { return /* reexport safe */ _layout__WEBPACK_IMPORTED_MODULE_2__.initQueueButtons; }, -/* harmony export */ initSiteSwitch: function() { return /* reexport safe */ _input__WEBPACK_IMPORTED_MODULE_1__.initSiteSwitch; }, -/* harmony export */ initVolumeFeatures: function() { return /* reexport safe */ _layout__WEBPACK_IMPORTED_MODULE_2__.initVolumeFeatures; }, -/* harmony export */ isMenuOpen: function() { return /* reexport safe */ _menu_menu_old__WEBPACK_IMPORTED_MODULE_5__.isMenuOpen; }, -/* harmony export */ openMenu: function() { return /* reexport safe */ _menu_menu_old__WEBPACK_IMPORTED_MODULE_5__.openMenu; }, -/* harmony export */ preInitLayout: function() { return /* reexport safe */ _layout__WEBPACK_IMPORTED_MODULE_2__.preInitLayout; }, -/* harmony export */ removeShareTrackingParam: function() { return /* reexport safe */ _layout__WEBPACK_IMPORTED_MODULE_2__.removeShareTrackingParam; }, -/* harmony export */ removeUpgradeTab: function() { return /* reexport safe */ _layout__WEBPACK_IMPORTED_MODULE_2__.removeUpgradeTab; }, -/* harmony export */ sanitizeArtists: function() { return /* reexport safe */ _lyrics__WEBPACK_IMPORTED_MODULE_3__.sanitizeArtists; }, -/* harmony export */ sanitizeSong: function() { return /* reexport safe */ _lyrics__WEBPACK_IMPORTED_MODULE_3__.sanitizeSong; }, -/* harmony export */ splitVideoTitle: function() { return /* reexport safe */ _lyrics__WEBPACK_IMPORTED_MODULE_3__.splitVideoTitle; } -/* harmony export */ }); -/* harmony import */ var _constants__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ../constants */ "./src/constants.ts"); -/* harmony import */ var _input__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./input */ "./src/features/input.ts"); -/* harmony import */ var _layout__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./layout */ "./src/features/layout.ts"); -/* harmony import */ var _lyrics__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./lyrics */ "./src/features/lyrics.ts"); -/* harmony import */ var _menu_menu__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ../menu/menu */ "./src/menu/menu.ts"); -/* harmony import */ var _menu_menu_old__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! ../menu/menu_old */ "./src/menu/menu_old.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 = { - //#SECTION layout - 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__WEBPACK_IMPORTED_MODULE_0__.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, - }, - //#SECTION input - 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, - }, - //#SECTION lyrics - 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, - }, - //#SECTION misc - 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, - }, -}; - - -/***/ }), - -/***/ "./src/features/input.ts": -/*!*******************************!*\ - !*** ./src/features/input.ts ***! - \*******************************/ -/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) { - -__webpack_require__.r(__webpack_exports__); -/* harmony export */ __webpack_require__.d(__webpack_exports__, { -/* harmony export */ disableBeforeUnload: function() { return /* binding */ disableBeforeUnload; }, -/* harmony export */ enableBeforeUnload: function() { return /* binding */ enableBeforeUnload; }, -/* harmony export */ initArrowKeySkip: function() { return /* binding */ initArrowKeySkip; }, -/* harmony export */ initBeforeUnloadHook: function() { return /* binding */ initBeforeUnloadHook; }, -/* harmony export */ initNumKeysSkip: function() { return /* binding */ initNumKeysSkip; }, -/* harmony export */ initSiteSwitch: function() { return /* binding */ initSiteSwitch; } -/* harmony export */ }); -/* harmony import */ var _sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! @sv443-network/userutils */ "./node_modules/@sv443-network/userutils/dist/index.mjs"); -/* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ../utils */ "./src/utils.ts"); -/* harmony import */ var _menu_menu_old__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ../menu/menu_old */ "./src/menu/menu_old.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()); +// 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)); + } }; - - -//#MARKER arrow key skip -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 (0,_utils__WEBPACK_IMPORTED_MODULE_1__.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); - }); - (0,_utils__WEBPACK_IMPORTED_MODULE_1__.log)("Added arrow key press listener"); +// lib/dom.ts +function getUnsafeWindow() { + try { + return unsafeWindow; + } catch (e) { + return window; + } } -/** Called when the user presses any key, anywhere */ -function onArrowKeyPress(evt) { - (0,_utils__WEBPACK_IMPORTED_MODULE_1__.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: (0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.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)); - (0,_utils__WEBPACK_IMPORTED_MODULE_1__.log)(`Dispatched proxy keydown event: [${evt.code}] -> [${proxyProps.code}]`); - } - else - (0,_utils__WEBPACK_IMPORTED_MODULE_1__.warn)(`Captured key '${evt.code}' has no defined behavior`); +function insertAfter(beforeElement, afterElement) { + var _a; + (_a = beforeElement.parentNode) == null ? void 0 : _a.insertBefore(afterElement, beforeElement.nextSibling); + return afterElement; } -//#MARKER site switch -/** 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"); - }); - (0,_utils__WEBPACK_IMPORTED_MODULE_1__.log)("Initialized site switch listener"); +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; } -/** Switches to the other site (between YT and YTM) */ -function switchSite(newDomain) { - return __awaiter(this, void 0, void 0, function* () { - try { - if (newDomain === "ytm" && !location.href.includes("/watch")) - return (0,_utils__WEBPACK_IMPORTED_MODULE_1__.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 (0,_utils__WEBPACK_IMPORTED_MODULE_1__.getVideoTime)(); - (0,_utils__WEBPACK_IMPORTED_MODULE_1__.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}`; - (0,_utils__WEBPACK_IMPORTED_MODULE_1__.info)(`Switching to domain '${newDomain}' at ${newUrl}`); - location.assign(newUrl); - } - catch (err) { - (0,_utils__WEBPACK_IMPORTED_MODULE_1__.error)("Error while switching site:", err); - } - }); +function addGlobalStyle(style) { + const styleElem = document.createElement("style"); + styleElem.innerHTML = style; + document.head.appendChild(styleElem); } -//#MARKER beforeunload popup -let beforeUnloadEnabled = true; -/** Disables the popup before leaving the site */ -function disableBeforeUnload() { - beforeUnloadEnabled = false; - (0,_utils__WEBPACK_IMPORTED_MODULE_1__.info)("Disabled popup before leaving the site"); +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); } -/** (Re-)enables the popup before leaving the site */ -function enableBeforeUnload() { - beforeUnloadEnabled = true; - (0,_utils__WEBPACK_IMPORTED_MODULE_1__.info)("Enabled popup before leaving the site"); +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; } -/** - * 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 (0,_utils__WEBPACK_IMPORTED_MODULE_1__.info)("Prevented beforeunload event listener from being called"); - else - return origListener.apply(this, a); - }; - original.apply(this, args); - }; - // @ts-ignore - })(window.__proto__.addEventListener); +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 + }; } -//#MARKER number keys skip to time -/** 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 (_menu_menu_old__WEBPACK_IMPORTED_MODULE_2__.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 (0,_utils__WEBPACK_IMPORTED_MODULE_1__.info)("Captured valid key to skip video to but an unexpected element is focused, so the keypress is ignored"); - skipToTimeKey(Number(e.key)); - }); - (0,_utils__WEBPACK_IMPORTED_MODULE_1__.log)("Added number key press listener"); + +// lib/misc.ts +function autoPlural(word, num) { + if (Array.isArray(num) || num instanceof NodeList) + num = num.length; + return `${word}${num === 1 ? "" : "s"}`; } -/** 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); +function pauseFor(time) { + return new Promise((res) => { + setTimeout(() => res(), time); + }); } -/** 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, - }; +function debounce(func, timeout = 300) { + let timer; + return function(...args) { + clearTimeout(timer); + timer = setTimeout(() => func.apply(this, args), timeout); + }; } -/** 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; - (0,_utils__WEBPACK_IMPORTED_MODULE_1__.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: (0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.getUnsafeWindow)(), - }); - progressElem.dispatchEvent(evt); +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; + }); } - -/***/ }), - -/***/ "./src/features/layout.ts": -/*!********************************!*\ - !*** ./src/features/layout.ts ***! - \********************************/ -/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) { - -__webpack_require__.r(__webpack_exports__); -/* harmony export */ __webpack_require__.d(__webpack_exports__, { -/* harmony export */ addAnchorImprovements: function() { return /* binding */ addAnchorImprovements; }, -/* harmony export */ addConfigMenuOption: function() { return /* binding */ addConfigMenuOption; }, -/* harmony export */ addWatermark: function() { return /* binding */ addWatermark; }, -/* harmony export */ initAutoCloseToasts: function() { return /* binding */ initAutoCloseToasts; }, -/* harmony export */ initQueueButtons: function() { return /* binding */ initQueueButtons; }, -/* harmony export */ initVolumeFeatures: function() { return /* binding */ initVolumeFeatures; }, -/* harmony export */ preInitLayout: function() { return /* binding */ preInitLayout; }, -/* harmony export */ removeShareTrackingParam: function() { return /* binding */ removeShareTrackingParam; }, -/* harmony export */ removeUpgradeTab: function() { return /* binding */ removeUpgradeTab; } -/* harmony export */ }); -/* harmony import */ var _sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! @sv443-network/userutils */ "./node_modules/@sv443-network/userutils/dist/index.mjs"); -/* harmony import */ var _constants__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ../constants */ "./src/constants.ts"); -/* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ../utils */ "./src/utils.ts"); -/* harmony import */ var _events__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ../events */ "./src/events.ts"); -/* harmony import */ var _menu_menu_old__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ../menu/menu_old */ "./src/menu/menu_old.ts"); -/* harmony import */ var _lyrics__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! ./lyrics */ "./src/features/lyrics.ts"); -/* harmony import */ var _index__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(/*! ./index */ "./src/features/index.ts"); -/* harmony import */ var _layout_css__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(/*! ./layout.css */ "./src/features/layout.css"); -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()); - }); -}; - - - - - - - - -let features; -function preInitLayout(feats) { - features = feats; +// 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); } -//#MARKER BYTM-Config buttons -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__WEBPACK_IMPORTED_MODULE_1__.scriptInfo.name; - watermark.title = "Open menu"; - watermark.tabIndex = 1000; - improveLogo(); - watermark.addEventListener("click", (e) => { - e.stopPropagation(); - menuOpenAmt++; - if ((!e.shiftKey || logoExchanged) && menuOpenAmt !== 5) - (0,_menu_menu_old__WEBPACK_IMPORTED_MODULE_4__.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) - (0,_menu_menu_old__WEBPACK_IMPORTED_MODULE_4__.openMenu)(); - if ((!logoExchanged && e.shiftKey) || menuOpenAmt === 5) - exchangeLogo(); - } - }); - const logoElem = document.querySelector("#left-content"); - (0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.insertAfter)(logoElem, watermark); - (0,_utils__WEBPACK_IMPORTED_MODULE_2__.log)("Added watermark element"); +function removeOnSelector(selector) { + return selectorMap.delete(selector); } -/** Turns the regular ``-based logo into inline SVG to be able to animate and modify parts of it */ -function improveLogo() { - return __awaiter(this, void 0, void 0, function* () { - try { - const res = yield (0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.fetchAdvanced)("https://music.youtube.com/img/on_platform_logo_dark.svg"); - const svg = yield res.text(); - (0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.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"); - (0,_utils__WEBPACK_IMPORTED_MODULE_2__.log)("Swapped logo to inline SVG"); - }, - }); - } - catch (err) { - (0,_utils__WEBPACK_IMPORTED_MODULE_2__.error)("Couldn't improve logo due to an error:", err); - } - }); +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); + } + } } -/** Exchanges the default YTM logo into BetterYTM's logo with a sick ass animation */ -function exchangeLogo() { - (0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.onSelector)(".bytm-mod-logo", { - listener: (logoElem) => __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 (0,_utils__WEBPACK_IMPORTED_MODULE_2__.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); - }), - }); +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)); } -/** Called whenever the menu exists to add a BYTM-Configuration button to the user menu popover */ -function addConfigMenuOption(container) { - return __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) => __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 (0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.pauseFor)(100); - if ((!e.shiftKey || logoExchanged) && menuOpenAmt !== 5) - (0,_menu_menu_old__WEBPACK_IMPORTED_MODULE_4__.openMenu)(); - if ((!logoExchanged && e.shiftKey) || menuOpenAmt === 5) - exchangeLogo(); - })); - const cfgOptIconElem = document.createElement("img"); - cfgOptIconElem.className = "bytm-cfg-menu-option-icon"; - cfgOptIconElem.src = yield (0,_utils__WEBPACK_IMPORTED_MODULE_2__.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); - (0,_utils__WEBPACK_IMPORTED_MODULE_2__.log)("Added BYTM-Configuration button to menu popover"); - }); +function getSelectorMap() { + return selectorMap; } -//#MARKER remove upgrade tab -/** Removes the "Upgrade" / YT Music Premium tab from the sidebar */ -function removeUpgradeTab() { - (0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.onSelector)("ytmusic-app-layout tp-yt-app-drawer #contentContainer #guide-content #items ytmusic-guide-entry-renderer:nth-of-type(4)", { - listener: (tabElemLarge) => { - tabElemLarge.remove(); - (0,_utils__WEBPACK_IMPORTED_MODULE_2__.log)("Removed large upgrade tab"); - }, - }); - (0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.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(); - (0,_utils__WEBPACK_IMPORTED_MODULE_2__.log)("Removed small upgrade tab"); - }, - }); + + + +;// 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: "79adee8", // 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; } -//#MARKER volume slider -function initVolumeFeatures() { - // not technically an input element but behaves pretty much the same - (0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.onSelector)("tp-yt-paper-slider#volume-slider", { - listener: (sliderElem) => { - const volSliderCont = document.createElement("div"); - volSliderCont.id = "bytm-vol-slider-cont"; - (0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.addParent)(sliderElem, volSliderCont); - if (typeof features.volumeSliderSize === "number") - setVolSliderSize(); - if (features.volumeSliderLabel) - addVolumeSliderLabel(sliderElem, volSliderCont); - setVolSliderStep(sliderElem); - }, - }); +/** 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; } -/** 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 !== _index__WEBPACK_IMPORTED_MODULE_6__.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()); - (0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.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, - }); +/** + * 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); } -/** Sets the volume slider to a set size */ -function setVolSliderSize() { - const { volumeSliderSize: size } = features; - if (typeof size !== "number" || isNaN(Number(size))) - return; - (0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.addGlobalStyle)(`\ -#bytm-vol-slider-cont tp-yt-paper-slider#volume-slider { - width: ${size}px !important; -}`); +/** + * 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); } -/** Sets the `step` attribute of the volume slider */ -function setVolSliderStep(sliderElem) { - sliderElem.setAttribute("step", String(features.volumeSliderStep)); +/** Logs all passed values to the console as a warning, no matter the log level. */ +function warn(...args) { + console.warn(consPrefix, ...args); } -//#MARKER queue buttons -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) - (0,_utils__WEBPACK_IMPORTED_MODULE_2__.log)(`Added buttons to ${amt} new queue ${(0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.autoPlural)("item", amt)}`); - }; - _events__WEBPACK_IMPORTED_MODULE_3__.siteEvents.on("queueChanged", addQueueBtns); - _events__WEBPACK_IMPORTED_MODULE_3__.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)); - (0,_utils__WEBPACK_IMPORTED_MODULE_2__.log)(`Added buttons to ${queueItems.length} existing queue ${(0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.autoPlural)("item", queueItems)}`); +/** 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); } /** - * 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 + * 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 addQueueButtons(queueItem) { - var _a; - return __awaiter(this, void 0, void 0, function* () { - //#SECTION general queue item stuff - const queueBtnsCont = document.createElement("div"); - queueBtnsCont.className = "bytm-queue-btn-container"; - const lyricsIconUrl = yield (0,_utils__WEBPACK_IMPORTED_MODULE_2__.getResourceUrl)("lyrics"); - const deleteIconUrl = yield (0,_utils__WEBPACK_IMPORTED_MODULE_2__.getResourceUrl)("delete"); - //#SECTION lyrics btn - let lyricsBtnElem; - if (features.lyricsQueueButton) { - lyricsBtnElem = yield (0,_lyrics__WEBPACK_IMPORTED_MODULE_5__.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) => __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 = (0,_lyrics__WEBPACK_IMPORTED_MODULE_5__.sanitizeArtists)(artist); - const songSan = (0,_lyrics__WEBPACK_IMPORTED_MODULE_5__.sanitizeSong)(song); - const splitTitle = (0,_lyrics__WEBPACK_IMPORTED_MODULE_5__.splitVideoTitle)(songSan); - const cachedLyricsUrl = songSan.includes("-") - ? (0,_lyrics__WEBPACK_IMPORTED_MODULE_5__.getLyricsCacheEntry)(splitTitle.artist, splitTitle.song) - : (0,_lyrics__WEBPACK_IMPORTED_MODULE_5__.getLyricsCacheEntry)(artistsSan, songSan); - if (cachedLyricsUrl) - lyricsUrl = cachedLyricsUrl; - else if (!songInfo.hasAttribute("data-bytm-loading")) { - const imgEl = lyricsBtnElem.querySelector("img"); - if (!cachedLyricsUrl) { - songInfo.setAttribute("data-bytm-loading", ""); - imgEl.src = yield (0,_utils__WEBPACK_IMPORTED_MODULE_2__.getResourceUrl)("spinner"); - imgEl.classList.add("bytm-spinner"); - } - lyricsUrl = cachedLyricsUrl !== null && cachedLyricsUrl !== void 0 ? cachedLyricsUrl : yield (0,_lyrics__WEBPACK_IMPORTED_MODULE_5__.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?")) - (0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.openInNewTab)(`https://genius.com/search?q=${encodeURIComponent(`${artistsSan} ${songSan}`)}`); - return; +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(); } - } - lyricsUrl && (0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.openInNewTab)(lyricsUrl); - })); + else + setTimeout(() => { + res(videoTime >= 0 && !isNaN(videoTime) ? videoTime : null); + mut.disconnect(); + }, 500); + }; + onSelector(pbSelector, { listener: observe }); + } } - //#SECTION delete from queue btn - 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) => __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 (0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.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 (0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.pauseFor)(10); - removeFromQueueBtn === null || removeFromQueueBtn === void 0 ? void 0 : removeFromQueueBtn.click(); - } - catch (err) { - (0,_utils__WEBPACK_IMPORTED_MODULE_2__.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); + catch (err) { + error("Couldn't get video time due to error:", err); + res(null); } - //#SECTION append elements to DOM - 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"); }); } -//#MARKER anchor improvements -/** Adds anchors around elements and tweaks existing ones so songs are easier to open in a new tab */ -function addAnchorImprovements() { - //#SECTION carousel shelves - 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")) - return; - item.classList.add("bytm-anchor-improved"); - const thumbnailElem = item.querySelector(".left-items"); - const titleElem = item.querySelector(".title-column .title a"); - if (!thumbnailElem || !titleElem) - return; - 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); - (0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.addParent)(thumbnailElem, anchorElem); - } - }; - // home page - (0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.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 - (0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.onSelector)("ytmusic-tab-renderer[page-type=\"MUSIC_PAGE_TYPE_TRACK_RELATED\"] ytmusic-responsive-list-item-renderer", { - continuous: true, - all: true, - listener: addListItemAnchors, - }); - // playlists - (0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.onSelector)("#contents.ytmusic-section-list-renderer ytmusic-playlist-shelf-renderer ytmusic-responsive-list-item-renderer", { - continuous: true, - all: true, - listener: addListItemAnchors, - }); - // generic shelves - (0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.onSelector)("#contents.ytmusic-section-list-renderer ytmusic-shelf-renderer ytmusic-responsive-list-item-renderer", { - continuous: true, - all: true, - listener: addListItemAnchors, - }); - } - catch (err) { - (0,_utils__WEBPACK_IMPORTED_MODULE_2__.error)("Couldn't improve carousel shelf anchors due to an error:", err); - } - //#SECTION sidebar - try { - const addSidebarAnchors = (sidebarCont) => { - const items = sidebarCont.parentNode.querySelectorAll("ytmusic-guide-entry-renderer tp-yt-paper-item"); - improveSidebarAnchors(items); - return items.length; - }; - (0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.onSelector)("ytmusic-app-layout tp-yt-app-drawer #contentContainer #guide-content #items ytmusic-guide-entry-renderer", { - listener: (sidebarCont) => { - const itemsAmt = addSidebarAnchors(sidebarCont); - (0,_utils__WEBPACK_IMPORTED_MODULE_2__.log)(`Added anchors around ${itemsAmt} sidebar ${(0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.autoPlural)("item", itemsAmt)}`); - }, - }); - (0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.onSelector)("ytmusic-app-layout #mini-guide ytmusic-guide-renderer ytmusic-guide-section-renderer #items ytmusic-guide-entry-renderer", { - listener: (miniSidebarCont) => { - const itemsAmt = addSidebarAnchors(miniSidebarCont); - (0,_utils__WEBPACK_IMPORTED_MODULE_2__.log)(`Added anchors around ${itemsAmt} mini sidebar ${(0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.autoPlural)("item", itemsAmt)}`); - }, - }); - } - catch (err) { - (0,_utils__WEBPACK_IMPORTED_MODULE_2__.error)("Couldn't add anchors to sidebar items due to an error:", err); - } +/** + * 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; } -const sidebarPaths = [ - "/", - "/explore", - "/library", -]; +// /** Parses a video time string in the format `[hh:m]m:ss` to the equivalent number of seconds - returns 0 if input couldn't be parsed */ +// function parseVideoTime(videoTime: string) { +// const matches = /^((\d{1,2}):)?(\d{1,2}):(\d{2})$/.exec(videoTime); +// if(!matches) +// return 0; +// const [, , hrs, min, sec] = matches as unknown as [string, string | undefined, string | undefined, string, string]; +// let finalTime = 0; +// if(hrs) +// finalTime += Number(hrs) * 60 * 60; +// finalTime += Number(min) * 60 + Number(sec); +// return isNaN(finalTime) ? 0 : finalTime; +// } /** - * Adds anchors to the sidebar items so they can be opened in a new tab - * @param sidebarItem + * Returns the current domain as a constant string representation + * @throws Throws if script runs on an unexpected website */ -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(); - }); - (0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.addParent)(item, anchorElem); - }); +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."); } -//#MARKER auto close toasts -/** Closes toasts after a set amount of time */ -function initAutoCloseToasts() { - try { - const animTimeout = 300; - const closeTimeout = Math.max(features.closeToastsTimeout * 1000 + animTimeout, animTimeout); - (0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.onSelector)("tp-yt-paper-toast#toast", { - all: true, - continuous: true, - listener: (toastElems) => __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 (0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.pauseFor)(closeTimeout); - toastElem.classList.remove("paper-toast-open"); - (0,_utils__WEBPACK_IMPORTED_MODULE_2__.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 (0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.pauseFor)(animTimeout); - toastElem.style.display = "none"; - } - }), - }); - (0,_utils__WEBPACK_IMPORTED_MODULE_2__.log)("Initialized automatic toast closing"); +/** 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) } - catch (err) { - (0,_utils__WEBPACK_IMPORTED_MODULE_2__.error)("Error in automatic toast closing:", err); + }, + 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 = []; } -//#MARKER remove share tracking param -/** Continuously removes the ?si tracking parameter from share URLs */ -function removeShareTrackingParam() { - (0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.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); - (0,_utils__WEBPACK_IMPORTED_MODULE_2__.log)(`Removed tracking parameter from share link: ${url}`); - } - catch (err) { - (0,_utils__WEBPACK_IMPORTED_MODULE_2__.warn)("Couldn't remove tracking parameter from share link due to error:", err); - } - }, +/** 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); + } }); } - -/***/ }), - -/***/ "./src/features/lyrics.ts": -/*!********************************!*\ - !*** ./src/features/lyrics.ts ***! - \********************************/ -/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) { - -__webpack_require__.r(__webpack_exports__); -/* harmony export */ __webpack_require__.d(__webpack_exports__, { -/* harmony export */ addLyricsCacheEntry: function() { return /* binding */ addLyricsCacheEntry; }, -/* harmony export */ addMediaCtrlLyricsBtn: function() { return /* binding */ addMediaCtrlLyricsBtn; }, -/* harmony export */ createLyricsBtn: function() { return /* binding */ createLyricsBtn; }, -/* harmony export */ geniUrlBase: function() { return /* binding */ geniUrlBase; }, -/* harmony export */ getCurrentLyricsUrl: function() { return /* binding */ getCurrentLyricsUrl; }, -/* harmony export */ getGeniusUrl: function() { return /* binding */ getGeniusUrl; }, -/* harmony export */ getLyricsCacheEntry: function() { return /* binding */ getLyricsCacheEntry; }, -/* harmony export */ sanitizeArtists: function() { return /* binding */ sanitizeArtists; }, -/* harmony export */ sanitizeSong: function() { return /* binding */ sanitizeSong; }, -/* harmony export */ splitVideoTitle: function() { return /* binding */ splitVideoTitle; } -/* harmony export */ }); -/* harmony import */ var _sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! @sv443-network/userutils */ "./node_modules/@sv443-network/userutils/dist/index.mjs"); -/* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ../utils */ "./src/utils.ts"); -var __awaiter = (undefined && undefined.__awaiter) || function (thisArg, _arguments, P, generator) { +;// CONCATENATED MODULE: ./changelog.md +// Module +var code = "

1.0.0


0.2.0


0.1.0

"; +// 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); } } @@ -1354,411 +706,525 @@ var __awaiter = (undefined && undefined.__awaiter) || function (thisArg, _argume 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=${(0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.clamp)(threshold, 0, 1)}` : ""; -//#MARKER cache -/** 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; +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; /** - * 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!** + * Adds an element to open the BetterYTM menu + * @deprecated to be replaced with new menu - see https://github.com/Sv443/BetterYTM/issues/23 */ -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)); -} -//#MARKER media control bar -let currentSongTitle = ""; -/** Adds a lyrics button to the media controls bar */ -function addMediaCtrlLyricsBtn() { - (0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.onSelector)(".middle-controls-buttons ytmusic-like-button-renderer#like-button-renderer", { listener: addActualMediaCtrlLyricsBtn }); -} -// TODO: add error.svg if the request fails -/** Actually adds the lyrics button after the like button renderer has been verified to exist */ -function addActualMediaCtrlLyricsBtn(likeContainer) { - return __awaiter(this, void 0, void 0, function* () { - const songTitleElem = document.querySelector(".content-info-wrapper > yt-formatted-string"); - // run parallel without awaiting so the MutationObserver below can observe the title element in time - (() => __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"; - (0,_utils__WEBPACK_IMPORTED_MODULE_1__.log)("Inserted lyrics button into media controls bar"); - (0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.insertAfter)(likeContainer, linkElem); - }))(); - currentSongTitle = songTitleElem.title; - const spinnerIconUrl = yield (0,_utils__WEBPACK_IMPORTED_MODULE_1__.getResourceUrl)("spinner"); - const lyricsIconUrl = yield (0,_utils__WEBPACK_IMPORTED_MODULE_1__.getResourceUrl)("lyrics"); - const errorIconUrl = yield (0,_utils__WEBPACK_IMPORTED_MODULE_1__.getResourceUrl)("error"); - const onMutation = (mutations) => { var _a, mutations_1, mutations_1_1; return __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) - return; - (0,_utils__WEBPACK_IMPORTED_MODULE_1__.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) { - imgElem.src = errorIconUrl; - imgElem.title = "Couldn't find lyrics URL"; - continue; +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`); + // TODO: + // addLink(await getResourceUrl("greasyfork"), "https://greasyfork.org/en/users/184165-sv443", `Open ${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); } - lyricsBtn.href = url; - lyricsBtn.title = "Open the current song's lyrics in a new tab"; - lyricsBtn.style.cursor = "pointer"; - lyricsBtn.style.visibility = "initial"; - lyricsBtn.style.display = "inline-flex"; - lyricsBtn.style.pointerEvents = "initial"; } + 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); } - 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"] }); - }); -} -//#MARKER utils -/** 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 __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 = () => __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) { - (0,_utils__WEBPACK_IMPORTED_MODULE_1__.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 __awaiter(this, void 0, void 0, function* () { - try { - const cacheEntry = getLyricsCacheEntry(artist, song); - if (cacheEntry) { - (0,_utils__WEBPACK_IMPORTED_MODULE_1__.info)(`Found lyrics URL in cache: ${cacheEntry}`); - return cacheEntry; + 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 startTs = Date.now(); - const fetchUrl = `${geniURLSearchTopUrl}?artist=${encodeURIComponent(artist)}&song=${encodeURIComponent(song)}${thresholdParam}`; - (0,_utils__WEBPACK_IMPORTED_MODULE_1__.log)(`Requesting URL from geniURL at '${fetchUrl}'`); - const fetchRes = yield (0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.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; + }); + 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 (fetchRes.status < 200 || fetchRes.status >= 300) { - (0,_utils__WEBPACK_IMPORTED_MODULE_1__.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; + else if (scrollIndicatorEnabled && scrollPos <= scrollIndicatorOffsetThreshold && scrollIndicator.classList.contains("bytm-hidden")) { + scrollIndicator.classList.remove("bytm-hidden"); } - const result = yield fetchRes.json(); - if (typeof result === "object" && result.error) { - (0,_utils__WEBPACK_IMPORTED_MODULE_1__.error)("Couldn't fetch lyrics URL:", result.message); - return undefined; + }); + 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 url = result.url; - (0,_utils__WEBPACK_IMPORTED_MODULE_1__.info)(`Found lyrics URL (after ${Date.now() - startTs}ms): ${url}`); - addLyricsCacheEntry(artist, song, url); - return url; - } - catch (err) { - (0,_utils__WEBPACK_IMPORTED_MODULE_1__.error)("Couldn't get lyrics URL due to error:", err); - return undefined; - } - }); -} -/** Creates the base lyrics button element */ -function createLyricsBtn(geniusUrl, hideIfLoading = true) { - return __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 (0,_utils__WEBPACK_IMPORTED_MODULE_1__.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("-") }; -} - - -/***/ }), - -/***/ "./src/menu/menu.ts": -/*!**************************!*\ - !*** ./src/menu/menu.ts ***! - \**************************/ -/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) { - -__webpack_require__.r(__webpack_exports__); -/* harmony export */ __webpack_require__.d(__webpack_exports__, { -/* harmony export */ closeMenu: function() { return /* binding */ closeMenu; }, -/* harmony export */ initMenu: function() { return /* binding */ initMenu; }, -/* harmony export */ openMenu: function() { return /* binding */ openMenu; }, -/* harmony export */ setActiveTab: function() { return /* binding */ setActiveTab; } -/* harmony export */ }); -/* harmony import */ var _changelog_md__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ../../changelog.md */ "./changelog.md"); -/* harmony import */ var _menu_html__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./menu.html */ "./src/menu/menu.html"); -/* harmony import */ var _menu_css__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./menu.css */ "./src/menu/menu.css"); - - - -// REQUIREMENTS: -// - modal using the 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 -//#MARKER menu -/** - * 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 = _menu_html__WEBPACK_IMPORTED_MODULE_1__["default"]; - 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); + })); + 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(); }); - } - // init tab contents - initOptionsContent(); - initInfoContent(); - initChangelogContent(); + 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"); + }); } -/** 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"; +/** 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 modal menu dialog */ +/** Opens the menu if it is closed */ function openMenu() { - var _a; - (_a = document.querySelector("#bytm-menu-dialog")) === null || _a === void 0 ? void 0 : _a.showModal(); + 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(); } -/** Closes the modal menu dialog */ -function closeMenu() { - var _a; - (_a = document.querySelector("#bytm-menu-dialog")) === null || _a === void 0 ? void 0 : _a.close(); +/** 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"); + } + } } -//#MARKER menu tab contents -function initOptionsContent() { - const tab = document.querySelector("#bytm-menu-tab-options-content"); - void tab; +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); + }); } -function initInfoContent() { - const tab = document.querySelector("#bytm-menu-tab-info-content"); - void tab; +/** 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"; } -function initChangelogContent() { - const tab = document.querySelector("#bytm-menu-tab-changelog-content"); - tab.innerHTML = _changelog_md__WEBPACK_IMPORTED_MODULE_0__["default"]; -} - - -/***/ }), - -/***/ "./src/menu/menu_old.ts": -/*!******************************!*\ - !*** ./src/menu/menu_old.ts ***! - \******************************/ -/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) { - -__webpack_require__.r(__webpack_exports__); -/* harmony export */ __webpack_require__.d(__webpack_exports__, { -/* harmony export */ addMenu: function() { return /* binding */ addMenu; }, -/* harmony export */ closeMenu: function() { return /* binding */ closeMenu; }, -/* harmony export */ isMenuOpen: function() { return /* binding */ isMenuOpen; }, -/* harmony export */ openMenu: function() { return /* binding */ openMenu; } -/* harmony export */ }); -/* harmony import */ var _sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! @sv443-network/userutils */ "./node_modules/@sv443-network/userutils/dist/index.mjs"); -/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ../config */ "./src/config.ts"); -/* harmony import */ var _constants__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ../constants */ "./src/constants.ts"); -/* harmony import */ var _features_index__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ../features/index */ "./src/features/index.ts"); -/* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ../utils */ "./src/utils.ts"); -/* harmony import */ var _events__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! ../events */ "./src/events.ts"); -/* harmony import */ var _changelog_md__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(/*! ../../changelog.md */ "./changelog.md"); -/* harmony import */ var _menu_old_css__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(/*! ./menu_old.css */ "./src/menu/menu_old.css"); -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()); - }); -}; - - - - - - - - - -//#MARKER create menu elements -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 __awaiter(this, void 0, void 0, function* () { - //#SECTION backdrop & menu container - 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) => { +/** 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 (isMenuOpen && ((_a = e.target) === null || _a === void 0 ? void 0 : _a.id) === "bytm-cfg-menu-bg") - closeMenu(e); + 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 (isMenuOpen && e.key === "Escape") - closeMenu(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-cfg-menu"; - //#SECTION title bar + menuContainer.id = "bytm-import-menu"; const headerElem = document.createElement("div"); headerElem.classList.add("bytm-menu-header"); const titleCont = document.createElement("div"); @@ -1767,387 +1233,144 @@ function addMenu() { titleCont.ariaLevel = "1"; const titleElem = document.createElement("h2"); titleElem.id = "bytm-menu-title"; - titleElem.innerText = `${_constants__WEBPACK_IMPORTED_MODULE_2__.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 (0,_utils__WEBPACK_IMPORTED_MODULE_4__.getResourceUrl)("github"), _constants__WEBPACK_IMPORTED_MODULE_2__.scriptInfo.namespace, `Open ${_constants__WEBPACK_IMPORTED_MODULE_2__.scriptInfo.name} on GitHub`); - // TODO: - // addLink(await getResourceUrl("greasyfork"), "https://greasyfork.org/en/users/184165-sv443", `Open ${scriptInfo.name} on GreasyFork`); + titleElem.innerText = `${constants_scriptInfo.name} - Import Configuration`; const closeElem = document.createElement("img"); closeElem.classList.add("bytm-menu-close"); - closeElem.src = yield (0,_utils__WEBPACK_IMPORTED_MODULE_4__.getResourceUrl)("close"); + closeElem.src = yield getResourceUrl("close"); closeElem.title = "Click to close the menu"; - closeElem.addEventListener("click", closeMenu); + closeElem.addEventListener("click", (e) => { + closeImportMenu(e); + openMenu(); + }); titleCont.appendChild(titleElem); - titleCont.appendChild(linksCont); headerElem.appendChild(titleCont); headerElem.appendChild(closeElem); - //#SECTION feature list - const featuresCont = document.createElement("div"); - featuresCont.id = "bytm-menu-opts"; - featuresCont.style.display = "flex"; - featuresCont.style.flexDirection = "column"; - featuresCont.style.overflowY = "auto"; - /** Gets called whenever the feature config is changed */ - const confChanged = (0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.debounce)((key, initialVal, newVal) => __awaiter(this, void 0, void 0, function* () { - const fmt = (val) => typeof val === "object" ? JSON.stringify(val) : String(val); - (0,_utils__WEBPACK_IMPORTED_MODULE_4__.info)(`Feature config changed at key '${key}', from value '${fmt(initialVal)}' to '${fmt(newVal)}'`); - const featConf = Object.assign({}, (0,_config__WEBPACK_IMPORTED_MODULE_1__.getFeatures)()); - featConf[key] = newVal; - yield (0,_config__WEBPACK_IMPORTED_MODULE_1__.saveFeatures)(featConf); - })); - const featureCfg = (0,_config__WEBPACK_IMPORTED_MODULE_1__.getFeatures)(); - const featureCfgWithCategories = Object.entries(_features_index__WEBPACK_IMPORTED_MODULE_3__.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 = `${_features_index__WEBPACK_IMPORTED_MODULE_3__.categoryNames[category]}:`; - featuresCont.appendChild(catHeaderElem); - for (const featKey in featObj) { - const ftInfo = _features_index__WEBPACK_IMPORTED_MODULE_3__.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("span"); - textElem.style.display = "inline-block"; - textElem.style.fontSize = "15px"; - 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.style.display = "inline-flex"; - ctrlElem.style.alignItems = "center"; - ctrlElem.style.whiteSpace = "nowrap"; - const inputElem = document.createElement(inputTag); - inputElem.classList.add("bytm-ftconf-input"); - inputElem.id = inputElemId; - if (inputType) - inputElem.type = inputType; - if (type === "toggle") - inputElem.style.marginLeft = "5px"; - 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"); - labelElem.style.marginRight = "10px"; - labelElem.style.fontSize = "16px"; - 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"); - labelElem.style.paddingLeft = "10px"; - labelElem.style.paddingRight = "5px"; - labelElem.style.fontSize = "16px"; - 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); + 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); + } } } - 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); - } - } - //#SECTION set values of inputs on external change - _events__WEBPACK_IMPORTED_MODULE_5__.siteEvents.on("rebuildCfgMenu", (newConfig) => { - for (const ftKey in _features_index__WEBPACK_IMPORTED_MODULE_3__.featInfo) { - const ftElem = document.querySelector(`#bytm-ftconf-${ftKey}-input`); - const labelElem = document.querySelector(`#bytm-ftconf-${ftKey}-label`); - if (!ftElem) - continue; - const ftInfo = _features_index__WEBPACK_IMPORTED_MODULE_3__.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; - } - }); - //#SECTION scroll indicator - const scrollIndicator = document.createElement("img"); - scrollIndicator.id = "bytm-menu-scroll-indicator"; - scrollIndicator.src = yield (0,_utils__WEBPACK_IMPORTED_MODULE_4__.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); - //#SECTION footer - const footerCont = document.createElement("div"); - footerCont.id = "bytm-menu-footer-cont"; - const footerElem = document.createElement("div"); - footerElem.id = "bytm-menu-footer"; - footerElem.style.fontSize = "17px"; - footerElem.style.textDecoration = "underline"; - 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", () => __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 (0,_config__WEBPACK_IMPORTED_MODULE_1__.setDefaultFeatures)(); - closeMenu(); - location.reload(); + 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"); } })); - const exportElem = document.createElement("button"); - exportElem.classList.add("bytm-btn"); - exportElem.title = "Click to export your current configuration"; - exportElem.innerText = "Export"; - exportElem.addEventListener("click", () => __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", () => __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); - //#SECTION finalize + footerElem.appendChild(importBtnElem); + menuBodyElem.appendChild(textElem); + menuBodyElem.appendChild(textAreaElem); + menuBodyElem.appendChild(footerElem); 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__WEBPACK_IMPORTED_MODULE_2__.scriptInfo.version} (build ${_constants__WEBPACK_IMPORTED_MODULE_2__.scriptInfo.lastCommit}) - click to open the changelog`; - versionElem.innerText = `v${_constants__WEBPACK_IMPORTED_MODULE_2__.scriptInfo.version} (${_constants__WEBPACK_IMPORTED_MODULE_2__.scriptInfo.lastCommit})`; - 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", (0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.debounce)(checkToggleScrollIndicator, 150)); - yield addChangelogMenu(); - yield addExportMenu(); - yield addImportMenu(); - (0,_utils__WEBPACK_IMPORTED_MODULE_4__.log)("Added menu element"); + menuContainer.appendChild(menuBodyElem); + menuBgElem.appendChild(menuContainer); + document.body.appendChild(menuBgElem); }); } -/** Closes the menu if it is open. If a bubbling event is passed, its propagation will be prevented. */ -function closeMenu(evt) { - if (!isMenuOpen) +/** 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; - isMenuOpen = false; + 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-cfg-menu-bg"); + 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 menu if it is closed */ -function openMenu() { - if (isMenuOpen) +/** Opens the import menu if it is closed */ +function openImportMenu() { + if (isImportMenuOpen) return; - isMenuOpen = true; + isImportMenuOpen = true; document.body.classList.add("bytm-disable-scroll"); - const menuBg = document.querySelector("#bytm-cfg-menu-bg"); + 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"; - 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 = (0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.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"); - } - } } -//#MARKER export menu -let isExportMenuOpen = false; -/** Adds a menu to copy the current configuration as JSON (hidden by default) */ -function addExportMenu() { - return __awaiter(this, void 0, void 0, function* () { +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-export-menu-bg"; + 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 (isExportMenuOpen && ((_a = e.target) === null || _a === void 0 ? void 0 : _a.id) === "bytm-export-menu-bg") { - closeExportMenu(e); + 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 (isExportMenuOpen && e.key === "Escape") { - closeExportMenu(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-export-menu"; - //#SECTION title bar + menuContainer.id = "bytm-changelog-menu"; const headerElem = document.createElement("div"); headerElem.classList.add("bytm-menu-header"); const titleCont = document.createElement("div"); @@ -2156,1050 +1379,1405 @@ function addExportMenu() { titleCont.ariaLevel = "1"; const titleElem = document.createElement("h2"); titleElem.id = "bytm-menu-title"; - titleElem.innerText = `${_constants__WEBPACK_IMPORTED_MODULE_2__.scriptInfo.name} - Export Configuration`; + titleElem.innerText = `${constants_scriptInfo.name} - Changelog`; const closeElem = document.createElement("img"); closeElem.classList.add("bytm-menu-close"); - closeElem.src = yield (0,_utils__WEBPACK_IMPORTED_MODULE_4__.getResourceUrl)("close"); + closeElem.src = yield getResourceUrl("close"); closeElem.title = "Click to close the menu"; closeElem.addEventListener("click", (e) => { - closeExportMenu(e); + closeChangelogMenu(e); openMenu(); }); titleCont.appendChild(titleElem); headerElem.appendChild(titleCont); headerElem.appendChild(closeElem); - //#SECTION body 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-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: _config__WEBPACK_IMPORTED_MODULE_1__.formatVersion, data: (0,_config__WEBPACK_IMPORTED_MODULE_1__.getFeatures)() }); - _events__WEBPACK_IMPORTED_MODULE_5__.siteEvents.on("configChanged", (data) => { - const textAreaElem = document.querySelector("#bytm-export-menu-textarea"); - if (textAreaElem) - textAreaElem.value = JSON.stringify({ formatVersion: _config__WEBPACK_IMPORTED_MODULE_1__.formatVersion, data }); - }); - //#SECTION footer - 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) => __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); - //#SECTION finalize + textElem.id = "bytm-changelog-menu-text"; + textElem.classList.add("bytm-markdown-container"); + textElem.innerHTML = changelog; menuBodyElem.appendChild(textElem); - menuBodyElem.appendChild(textAreaElem); - menuBodyElem.appendChild(footerElem); 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"); } -/** 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 (0,_utils__WEBPACK_IMPORTED_MODULE_4__.warn)("Couldn't find export menu background element"); - menuBg.style.visibility = "hidden"; - menuBg.style.display = "none"; +/** 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); + } + }); } -/** Opens the export menu if it is closed */ -function openExportMenu() { - if (isExportMenuOpen) +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; - isExportMenuOpen = true; - document.body.classList.add("bytm-disable-scroll"); - const menuBg = document.querySelector("#bytm-export-menu-bg"); - if (!menuBg) - return (0,_utils__WEBPACK_IMPORTED_MODULE_4__.warn)("Couldn't find export menu background element"); - menuBg.style.visibility = "visible"; - menuBg.style.display = "block"; + 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); } -//#MARKER import menu -let isImportMenuOpen = false; -/** Adds a menu to import a configuration from JSON (hidden by default) */ -function addImportMenu() { - return __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"; - //#SECTION title bar - 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__WEBPACK_IMPORTED_MODULE_2__.scriptInfo.name} - Import Configuration`; - const closeElem = document.createElement("img"); - closeElem.classList.add("bytm-menu-close"); - closeElem.src = yield (0,_utils__WEBPACK_IMPORTED_MODULE_4__.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); - //#SECTION body - 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"; - //#SECTION footer - 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) => __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 (0,_utils__WEBPACK_IMPORTED_MODULE_4__.warn)("Couldn't find import menu textarea element"); + +;// 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)}` : ""; +/** 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 }); +} +// TODO: add error.svg if the request fails +/** 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 { - 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 < _config__WEBPACK_IMPORTED_MODULE_1__.formatVersion) { - let newData = JSON.parse(JSON.stringify(parsed.data)); - const sortedMigrations = Object.entries(_config__WEBPACK_IMPORTED_MODULE_1__.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 < _config__WEBPACK_IMPORTED_MODULE_1__.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); + 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"; } - parsed.formatVersion = curFmtVer; - parsed.data = newData; } - else if (parsed.formatVersion !== _config__WEBPACK_IMPORTED_MODULE_1__.formatVersion) - return alert(`The imported data is in an unsupported format version (expected ${_config__WEBPACK_IMPORTED_MODULE_1__.formatVersion} or lower, got ${parsed.formatVersion})`); - yield (0,_config__WEBPACK_IMPORTED_MODULE_1__.saveFeatures)(parsed.data); - if (confirm("Successfully imported the configuration.\nDo you want to reload the page now to apply changes?")) - return location.reload(); - _events__WEBPACK_IMPORTED_MODULE_5__.siteEvents.emit("rebuildCfgMenu", parsed.data); - closeImportMenu(); - openMenu(); } - catch (err) { - (0,_utils__WEBPACK_IMPORTED_MODULE_4__.warn)("Couldn't import configuration:", err); - alert("The imported data is not a valid configuration"); + 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; } } - })); - footerElem.appendChild(importBtnElem); - //#SECTION finalize - menuBodyElem.appendChild(textElem); - menuBodyElem.appendChild(textAreaElem); - menuBodyElem.appendChild(footerElem); - menuContainer.appendChild(headerElem); - menuContainer.appendChild(menuBodyElem); - menuBgElem.appendChild(menuContainer); - document.body.appendChild(menuBgElem); + }); }; + // 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"] }); }); } -/** 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 (0,_utils__WEBPACK_IMPORTED_MODULE_4__.warn)("Couldn't find import menu background element"); - menuBg.style.visibility = "hidden"; - menuBg.style.display = "none"; +/** 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(); } -/** 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 (0,_utils__WEBPACK_IMPORTED_MODULE_4__.warn)("Couldn't find import menu background element"); - menuBg.style.visibility = "visible"; - menuBg.style.display = "block"; +/** 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(); } -//#MARKER changelog menu -let isChangelogMenuOpen = false; -/** Adds a changelog menu (hidden by default) */ -function addChangelogMenu() { - return __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(); +/** 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; } - }); - document.body.addEventListener("keydown", (e) => { - if (isChangelogMenuOpen && e.key === "Escape") { - closeChangelogMenu(e); - openMenu(); + const startTs = Date.now(); + const fetchUrl = `${geniURLSearchTopUrl}?artist=${encodeURIComponent(artist)}&song=${encodeURIComponent(song)}${thresholdParam}`; + 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; } - }); - const menuContainer = document.createElement("div"); - menuContainer.title = ""; // prevent bg title from propagating downwards - menuContainer.classList.add("bytm-menu"); - menuContainer.id = "bytm-changelog-menu"; - //#SECTION title bar - 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__WEBPACK_IMPORTED_MODULE_2__.scriptInfo.name} - Changelog`; - const closeElem = document.createElement("img"); - closeElem.classList.add("bytm-menu-close"); - closeElem.src = yield (0,_utils__WEBPACK_IMPORTED_MODULE_4__.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); - //#SECTION body - 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_md__WEBPACK_IMPORTED_MODULE_6__["default"]; - //#SECTION finalize - 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"; + 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; }); } -/** 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 (0,_utils__WEBPACK_IMPORTED_MODULE_4__.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 (0,_utils__WEBPACK_IMPORTED_MODULE_4__.warn)("Couldn't find changelog menu background element"); - menuBg.style.visibility = "visible"; - menuBg.style.display = "block"; +/** 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()); + }); +}; + -/***/ }), -/***/ "./src/utils.ts": -/*!**********************!*\ - !*** ./src/utils.ts ***! - \**********************/ -/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) { -__webpack_require__.r(__webpack_exports__); -/* harmony export */ __webpack_require__.d(__webpack_exports__, { -/* harmony export */ dbg: function() { return /* binding */ dbg; }, -/* harmony export */ error: function() { return /* binding */ error; }, -/* harmony export */ getDomain: function() { return /* binding */ getDomain; }, -/* harmony export */ getResourceUrl: function() { return /* binding */ getResourceUrl; }, -/* harmony export */ getVideoTime: function() { return /* binding */ getVideoTime; }, -/* harmony export */ info: function() { return /* binding */ info; }, -/* harmony export */ log: function() { return /* binding */ log; }, -/* harmony export */ setLogLevel: function() { return /* binding */ setLogLevel; }, -/* harmony export */ warn: function() { return /* binding */ warn; } -/* harmony export */ }); -/* harmony import */ var _sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! @sv443-network/userutils */ "./node_modules/@sv443-network/userutils/dist/index.mjs"); -/* harmony import */ var _constants__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./constants */ "./src/constants.ts"); -//#SECTION logging -let curLogLevel = 1; -/** Common prefix to be able to tell logged messages apart and filter them in devtools */ -const consPrefix = `[${_constants__WEBPACK_IMPORTED_MODULE_1__.scriptInfo.name}]`; -const consPrefixDbg = `[${_constants__WEBPACK_IMPORTED_MODULE_1__.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; + + +let features; +function preInitLayout(feats) { + features = feats; } -/** 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 (0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.clamp)(args.splice(args.length - 1)[0], minLogLvl, maxLogLvl); - return 0; +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"); } -/** - * 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); +/** Turns the regular ``-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); + } + }); } -/** - * 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 info(...args) { - if (curLogLevel <= getLogLevel(args)) - console.info(consPrefix, ...args); +/** 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); + }), + }); } -/** Logs all passed values to the console as a warning, no matter the log level. */ -function warn(...args) { - console.warn(consPrefix, ...args); +/** 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"); + }); } -/** Logs all passed values to the console as an error, no matter the log level. */ -function error(...args) { - console.error(consPrefix, ...args); +/** 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"); + }, + }); } -/** Logs all passed values to the console with a debug-specific prefix */ -function dbg(...args) { - console.log(consPrefixDbg, ...args); +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)}`); } -//#SECTION video time /** - * 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 + * 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 getVideoTime() { - return new Promise((res) => { - const domain = getDomain(); - try { - if (domain === "ytm") { - (0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.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(); +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"); } - else - setTimeout(() => { - res(videoTime >= 0 && !isNaN(videoTime) ? videoTime : null); - mut.disconnect(); - }, 500); - }; - (0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.onSelector)(pbSelector, { listener: observe }); - } + 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); + })); } - catch (err) { - error("Couldn't get video time due to error:", err); - res(null); + 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"); }); } -/** - * 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: (0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.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; +/** 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); + } } -// /** Parses a video time string in the format `[hh:m]m:ss` to the equivalent number of seconds - returns 0 if input couldn't be parsed */ -// function parseVideoTime(videoTime: string) { -// const matches = /^((\d{1,2}):)?(\d{1,2}):(\d{2})$/.exec(videoTime); -// if(!matches) -// return 0; -// const [, , hrs, min, sec] = matches as unknown as [string, string | undefined, string | undefined, string, string]; -// let finalTime = 0; -// if(hrs) -// finalTime += Number(hrs) * 60 * 60; -// finalTime += Number(min) * 60 + Number(sec); -// return isNaN(finalTime) ? 0 : finalTime; -// } +const sidebarPaths = [ + "/", + "/explore", + "/library", +]; /** - * Returns the current domain as a constant string representation - * @throws Throws if script runs on an unexpected website + * Adds anchors to the sidebar items so they can be opened in a new tab + * @param sidebarItem */ -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); +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); + }); } - - -/***/ }), - -/***/ "./node_modules/@sv443-network/userutils/dist/index.mjs": -/*!**************************************************************!*\ - !*** ./node_modules/@sv443-network/userutils/dist/index.mjs ***! - \**************************************************************/ -/***/ (function(__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) { - -__webpack_require__.r(__webpack_exports__); -/* harmony export */ __webpack_require__.d(__webpack_exports__, { -/* harmony export */ ConfigManager: function() { return /* binding */ ConfigManager; }, -/* harmony export */ addGlobalStyle: function() { return /* binding */ addGlobalStyle; }, -/* harmony export */ addParent: function() { return /* binding */ addParent; }, -/* harmony export */ amplifyMedia: function() { return /* binding */ amplifyMedia; }, -/* harmony export */ autoPlural: function() { return /* binding */ autoPlural; }, -/* harmony export */ clamp: function() { return /* binding */ clamp; }, -/* harmony export */ debounce: function() { return /* binding */ debounce; }, -/* harmony export */ fetchAdvanced: function() { return /* binding */ fetchAdvanced; }, -/* harmony export */ getSelectorMap: function() { return /* binding */ getSelectorMap; }, -/* harmony export */ getUnsafeWindow: function() { return /* binding */ getUnsafeWindow; }, -/* harmony export */ initOnSelector: function() { return /* binding */ initOnSelector; }, -/* harmony export */ insertAfter: function() { return /* binding */ insertAfter; }, -/* harmony export */ interceptEvent: function() { return /* binding */ interceptEvent; }, -/* harmony export */ interceptWindowEvent: function() { return /* binding */ interceptWindowEvent; }, -/* harmony export */ isScrollable: function() { return /* binding */ isScrollable; }, -/* harmony export */ mapRange: function() { return /* binding */ mapRange; }, -/* harmony export */ onSelector: function() { return /* binding */ onSelector; }, -/* harmony export */ openInNewTab: function() { return /* binding */ openInNewTab; }, -/* harmony export */ pauseFor: function() { return /* binding */ pauseFor; }, -/* harmony export */ preloadImages: function() { return /* binding */ preloadImages; }, -/* harmony export */ randRange: function() { return /* binding */ randRange; }, -/* harmony export */ randomItem: function() { return /* binding */ randomItem; }, -/* harmony export */ randomItemIndex: function() { return /* binding */ randomItemIndex; }, -/* harmony export */ randomizeArray: function() { return /* binding */ randomizeArray; }, -/* harmony export */ removeOnSelector: function() { return /* binding */ removeOnSelector; }, -/* harmony export */ takeRandomItem: function() { return /* binding */ takeRandomItem; } -/* harmony export */ }); -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]); +/** 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); } - 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; +/** 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); + } + }, + }); } -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; +/** 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; } -// 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; +ytmusic-carousel-shelf-renderer ytmusic-carousel { + --ytmusic-carousel-item-height: 60px !important; +}`); } - -// 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; +/** 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); + }), }); - } - /** 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; - } +;// CONCATENATED MODULE: ./src/menu/menu.ts + + + +// REQUIREMENTS: +// - modal using the 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 insertAfter(beforeElement, afterElement) { - var _a; - (_a = beforeElement.parentNode) == null ? void 0 : _a.insertBefore(afterElement, beforeElement.nextSibling); - return afterElement; +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(); } -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; +/** 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"; } -function addGlobalStyle(style) { - const styleElem = document.createElement("style"); - styleElem.innerHTML = style; - document.head.appendChild(styleElem); +/** Opens the modal menu dialog */ +function menu_openMenu() { + var _a; + (_a = document.querySelector("#bytm-menu-dialog")) === null || _a === void 0 ? void 0 : _a.showModal(); } -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); +/** 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 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 initOptionsContent() { + const tab = document.querySelector("#bytm-menu-tab-options-content"); + void tab; } -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 initInfoContent() { + const tab = document.querySelector("#bytm-menu-tab-info-content"); + void tab; } -function interceptWindowEvent(eventName, predicate) { - return interceptEvent(getUnsafeWindow(), eventName, predicate); +function initChangelogContent() { + const tab = document.querySelector("#bytm-menu-tab-changelog-content"); + tab.innerHTML = changelogContent; } -function amplifyMedia(mediaElement, multiplier = 1) { - const context = new (window.AudioContext || window.webkitAudioContext)(); - const result = { - mediaElement, - amplify: (multiplier2) => { - result.gain.gain.value = multiplier2; + +;// 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, }, - 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 - }; -} + 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, + }, +}; -// 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; - }); -} +;// 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()); + }); +}; -// 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); + + + +/** 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; + }); } -function removeOnSelector(selector) { - return selectorMap.delete(selector); +/** Returns the current feature config from the in-memory cache */ +function getFeatures() { + return cfgMgr.getData(); } -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); - } - } +/** 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); + }); } -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)); +/** 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"); + }); } -function getSelectorMap() { - return selectorMap; +/** 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"); + }); } - - - -/***/ }), - -/***/ "./node_modules/nanoevents/index.js": -/*!******************************************!*\ - !*** ./node_modules/nanoevents/index.js ***! - \******************************************/ -/***/ (function(__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) { - -__webpack_require__.r(__webpack_exports__); -/* harmony export */ __webpack_require__.d(__webpack_exports__, { -/* harmony export */ createNanoEvents: function() { return /* binding */ createNanoEvents; } -/* harmony export */ }); -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) - } - } -}) - - -/***/ }) - -/******/ }); -/************************************************************************/ -/******/ // The module cache -/******/ var __webpack_module_cache__ = {}; -/******/ -/******/ // The require function -/******/ function __webpack_require__(moduleId) { -/******/ // Check if module is in cache -/******/ var cachedModule = __webpack_module_cache__[moduleId]; -/******/ if (cachedModule !== undefined) { -/******/ return cachedModule.exports; -/******/ } -/******/ // Create a new module (and put it into the cache) -/******/ var module = __webpack_module_cache__[moduleId] = { -/******/ // no module.id needed -/******/ // no module.loaded needed -/******/ exports: {} -/******/ }; -/******/ -/******/ // Execute the module function -/******/ __webpack_modules__[moduleId](module, module.exports, __webpack_require__); -/******/ -/******/ // Return the exports of the module -/******/ return module.exports; -/******/ } -/******/ -/************************************************************************/ -/******/ /* webpack/runtime/define property getters */ -/******/ !function() { -/******/ // define getter functions for harmony exports -/******/ __webpack_require__.d = function(exports, definition) { -/******/ for(var key in definition) { -/******/ if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) { -/******/ Object.defineProperty(exports, key, { enumerable: true, get: definition[key] }); -/******/ } -/******/ } -/******/ }; -/******/ }(); -/******/ -/******/ /* webpack/runtime/hasOwnProperty shorthand */ -/******/ !function() { -/******/ __webpack_require__.o = function(obj, prop) { return Object.prototype.hasOwnProperty.call(obj, prop); } -/******/ }(); -/******/ -/******/ /* webpack/runtime/make namespace object */ -/******/ !function() { -/******/ // define __esModule on exports -/******/ __webpack_require__.r = function(exports) { -/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) { -/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); -/******/ } -/******/ Object.defineProperty(exports, '__esModule', { value: true }); -/******/ }; -/******/ }(); -/******/ -/************************************************************************/ -var __webpack_exports__ = {}; -// This entry need to be wrapped in an IIFE because it need to be isolated against other modules in the chunk. -!function() { -/*!**********************!*\ - !*** ./src/index.ts ***! - \**********************/ -__webpack_require__.r(__webpack_exports__); -/* harmony import */ var _sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! @sv443-network/userutils */ "./node_modules/@sv443-network/userutils/dist/index.mjs"); -/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./config */ "./src/config.ts"); -/* harmony import */ var _constants__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./constants */ "./src/constants.ts"); -/* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./utils */ "./src/utils.ts"); -/* harmony import */ var _events__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ./events */ "./src/events.ts"); -/* harmony import */ var _features_index__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! ./features/index */ "./src/features/index.ts"); -var __awaiter = (undefined && undefined.__awaiter) || function (thisArg, _arguments, P, generator) { +;// 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); } } @@ -3219,26 +2797,26 @@ var __awaiter = (undefined && undefined.__awaiter) || function (thisArg, _argume 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__WEBPACK_IMPORTED_MODULE_2__.scriptInfo.name}%cv${_constants__WEBPACK_IMPORTED_MODULE_2__.scriptInfo.version}%c\n\nBuild #${_constants__WEBPACK_IMPORTED_MODULE_2__.scriptInfo.lastCommit} ─ ${_constants__WEBPACK_IMPORTED_MODULE_2__.scriptInfo.namespace}`, `font-weight: bold; ${styleCommon} ${styleGradient}`, `background-color: #333; ${styleCommon}`, "padding: initial;"); + 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: ${_features_index__WEBPACK_IMPORTED_MODULE_5__.geniUrlBase}`, + `─ 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 = (0,_utils__WEBPACK_IMPORTED_MODULE_3__.getDomain)(); +const domain = getDomain(); /** Stuff that needs to be called ASAP, before anything async happens */ function preInit() { - (0,_utils__WEBPACK_IMPORTED_MODULE_3__.setLogLevel)(_constants__WEBPACK_IMPORTED_MODULE_2__.defaultLogLevel); + setLogLevel(defaultLogLevel); if (domain === "ytm") - (0,_features_index__WEBPACK_IMPORTED_MODULE_5__.initBeforeUnloadHook)(); + initBeforeUnloadHook(); init(); } function init() { - return __awaiter(this, void 0, void 0, function* () { + return src_awaiter(this, void 0, void 0, function* () { try { registerMenuCommands(); } @@ -3250,41 +2828,38 @@ function init() { document.addEventListener("DOMContentLoaded", onDomLoad); } catch (err) { - (0,_utils__WEBPACK_IMPORTED_MODULE_3__.error)("General Error:", err); + error("General Error:", err); } // init config try { - const ftConfig = yield (0,_config__WEBPACK_IMPORTED_MODULE_1__.initConfig)(); - (0,_utils__WEBPACK_IMPORTED_MODULE_3__.setLogLevel)((0,_config__WEBPACK_IMPORTED_MODULE_1__.getFeatures)().logLevel); - (0,_features_index__WEBPACK_IMPORTED_MODULE_5__.preInitLayout)(ftConfig); - if ((0,_config__WEBPACK_IMPORTED_MODULE_1__.getFeatures)().disableBeforeUnloadPopup) - (0,_features_index__WEBPACK_IMPORTED_MODULE_5__.disableBeforeUnload)(); + const ftConfig = yield initConfig(); + setLogLevel(getFeatures().logLevel); + preInitLayout(ftConfig); + if (getFeatures().disableBeforeUnloadPopup) + disableBeforeUnload(); } catch (err) { - (0,_utils__WEBPACK_IMPORTED_MODULE_3__.error)("Error while initializing ConfigManager:", err); + error("Error while initializing ConfigManager:", err); } // init menu separately from features try { - void ["TODO(v1.1):", _features_index__WEBPACK_IMPORTED_MODULE_5__.initMenu]; + void "TODO(v1.1):"; // initMenu(); } catch (err) { - (0,_utils__WEBPACK_IMPORTED_MODULE_3__.error)("Couldn't initialize menu:", 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 __awaiter(this, void 0, void 0, function* () { + 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) - (0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.addGlobalStyle)(`/*!*************************************************************************!*\ - !*** css ./node_modules/css-loader/dist/cjs.js!./src/menu/menu_old.css ***! - \*************************************************************************/ -.bytm-menu-bg { + addGlobalStyle(`.bytm-menu-bg { --bytm-menu-bg: #333333; --bytm-menu-bg-highlight: #1e1e1e; --bytm-menu-separator-color: #797979; - --bytm-menu-border-radius: 15px; + --bytm-menu-border-radius: 10px; } #bytm-cfg-menu-bg { @@ -3334,9 +2909,11 @@ function onDomLoad() { } #bytm-menu-opts { + display: flex; + flex-direction: column; position: relative; - overflow: auto; padding: 30px 0px; + overflow-y: auto; } .bytm-menu-header { @@ -3382,6 +2959,11 @@ function onDomLoad() { cursor: pointer; } +.bytm-menu-footer { + font-size: 17px; + text-decoration: underline; +} + #bytm-menu-footer-cont { display: flex; flex-direction: row; @@ -3449,17 +3031,37 @@ function onDomLoad() { 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; @@ -3549,11 +3151,6 @@ function onDomLoad() { font-weight: bolder; } -/*!***************************************************************************!*\ - !*** css ./node_modules/css-loader/dist/cjs.js!./src/features/layout.css ***! - \***************************************************************************/ -/* #MARKER misc */ - .bytm-disable-scroll { overflow: hidden !important; } @@ -3655,8 +3252,6 @@ button.bytm-btn { background: revert; } -/* #MARKER menu */ - .bytm-cfg-menu-option { display: block; padding: 8px 0; @@ -3698,8 +3293,6 @@ yt-multi-page-menu-section-renderer.ytd-multi-page-menu-renderer { border-bottom: 1px solid var(--yt-spec-10-percent-layer, #3e3e3e); } -/* #MARKER watermark */ - #bytm-watermark { font-size: 10px; display: inline-block; @@ -3716,8 +3309,6 @@ yt-multi-page-menu-section-renderer.ytd-multi-page-menu-renderer { text-decoration: underline; } -/* #MARKER queue buttons */ - .side-panel.modular ytmusic-player-queue-item .song-info.ytmusic-player-queue-item { position: relative; } @@ -3747,18 +3338,14 @@ ytmusic-app ytmusic-popup-container tp-yt-iron-dropdown[data-bytm-hidden=true] { display: none !important; } -/* #MARKER anchor improvements */ - -ytmusic-responsive-list-item-renderer .left-items { +ytmusic-responsive-list-item-renderer:not([unplayable_]) .left-items { margin-right: 0 !important; } .bytm-carousel-shelf-anchor { - margin-right: 16px; + margin-right: var(--ytmusic-responsive-list-item-thumbnail-margin-right, 24px); } -/* #MARKER volume slider */ - #bytm-vol-slider-cont { position: relative; } @@ -3778,10 +3365,33 @@ ytmusic-responsive-list-item-renderer .left-items { opacity: 1; } -/*!*********************************************************************!*\ - !*** css ./node_modules/css-loader/dist/cjs.js!./src/menu/menu.css ***! - \*********************************************************************/ -/* #bytm-menu-backdrop { +#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; @@ -3820,60 +3430,63 @@ ytmusic-responsive-list-item-renderer .left-items { display: none; } - -/*# sourceMappingURL=http://localhost:8710/global.css.map*/`); - (0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.initOnSelector)(); - const features = (0,_config__WEBPACK_IMPORTED_MODULE_1__.getFeatures)(); - (0,_utils__WEBPACK_IMPORTED_MODULE_3__.log)(`Initializing features for domain "${domain}"...`); +`); + initOnSelector(); + const features = getFeatures(); + log(`Initializing features for domain "${domain}"...`); try { if (domain === "ytm") { try { - (0,_features_index__WEBPACK_IMPORTED_MODULE_5__.addMenu)(); // TODO(v1.1): remove + addMenu(); // TODO(v1.1): remove } catch (err) { - (0,_utils__WEBPACK_IMPORTED_MODULE_3__.error)("Couldn't add menu:", err); + error("Couldn't add menu:", err); } - (0,_events__WEBPACK_IMPORTED_MODULE_4__.initSiteEvents)(); - (0,_sv443_network_userutils__WEBPACK_IMPORTED_MODULE_0__.onSelector)("tp-yt-iron-dropdown #contentWrapper ytd-multi-page-menu-renderer #container.menu-container", { listener: _features_index__WEBPACK_IMPORTED_MODULE_5__.addConfigMenuOption }); + initSiteEvents(); + onSelector("tp-yt-iron-dropdown #contentWrapper ytd-multi-page-menu-renderer #container.menu-container", { listener: addConfigMenuOption }); if (features.arrowKeySupport) - (0,_features_index__WEBPACK_IMPORTED_MODULE_5__.initArrowKeySkip)(); + initArrowKeySkip(); if (features.removeUpgradeTab) - (0,_features_index__WEBPACK_IMPORTED_MODULE_5__.removeUpgradeTab)(); + removeUpgradeTab(); if (features.watermarkEnabled) - (0,_features_index__WEBPACK_IMPORTED_MODULE_5__.addWatermark)(); + addWatermark(); if (features.geniusLyrics) - (0,_features_index__WEBPACK_IMPORTED_MODULE_5__.addMediaCtrlLyricsBtn)(); + addMediaCtrlLyricsBtn(); if (features.deleteFromQueueButton || features.lyricsQueueButton) - (0,_features_index__WEBPACK_IMPORTED_MODULE_5__.initQueueButtons)(); + initQueueButtons(); if (features.anchorImprovements) - (0,_features_index__WEBPACK_IMPORTED_MODULE_5__.addAnchorImprovements)(); + addAnchorImprovements(); if (features.closeToastsTimeout > 0) - (0,_features_index__WEBPACK_IMPORTED_MODULE_5__.initAutoCloseToasts)(); + initAutoCloseToasts(); if (features.removeShareTrackingParam) - (0,_features_index__WEBPACK_IMPORTED_MODULE_5__.removeShareTrackingParam)(); + removeShareTrackingParam(); if (features.numKeysSkipToTime) - (0,_features_index__WEBPACK_IMPORTED_MODULE_5__.initNumKeysSkip)(); - (0,_features_index__WEBPACK_IMPORTED_MODULE_5__.initVolumeFeatures)(); + initNumKeysSkip(); + if (features.fixSpacing) + fixSpacing(); + if (features.scrollToActiveSongBtn) + addScrollToActiveBtn(); + initVolumeFeatures(); } if (["ytm", "yt"].includes(domain)) { if (features.switchBetweenSites) - (0,_features_index__WEBPACK_IMPORTED_MODULE_5__.initSiteSwitch)(domain); + initSiteSwitch(domain); } } catch (err) { - (0,_utils__WEBPACK_IMPORTED_MODULE_3__.error)("Feature error:", err); + error("Feature error:", err); } }); } function registerMenuCommands() { - if (_constants__WEBPACK_IMPORTED_MODULE_2__.mode === "development") { - GM.registerMenuCommand("Reset config", () => __awaiter(this, void 0, void 0, function* () { + 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 (0,_config__WEBPACK_IMPORTED_MODULE_1__.clearConfig)(); + yield clearConfig(); location.reload(); } }), "r"); - GM.registerMenuCommand("List GM values", () => __awaiter(this, void 0, void 0, function* () { + 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:"); @@ -3882,7 +3495,7 @@ function registerMenuCommands() { for (const key of keys) console.log(` ${key} -> ${yield GM.getValue(key)}`); }), "l"); - GM.registerMenuCommand("Clear all GM values", () => __awaiter(this, void 0, void 0, function* () { + 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:"); @@ -3898,6 +3511,3 @@ function registerMenuCommands() { } preInit(); -}(); - -//# sourceMappingURL=http://localhost:8710/BetterYTM.user.js.map