")[1];
- const changelogHtml = await parseMarkdown(changelogMd);
- verNotifDialog = new BytmDialog({
- id: "version-notif",
- width: 600,
- height: 800,
- closeBtnEnabled: false,
- closeOnBgClick: false,
- closeOnEscPress: true,
- destroyOnClose: true,
- small: true,
- renderHeader: renderHeader$1,
- renderBody: () => renderBody$1({ latestTag, changelogHtml }),
- });
- }
- return verNotifDialog;
- }
- async function renderHeader$1() {
- const logoEl = document.createElement("img");
- logoEl.classList.add("bytm-dialog-header-img", "bytm-no-select");
- logoEl.src = await getResourceUrl(mode === "development" ? "img-logo_dev" : "img-logo");
- logoEl.alt = "BetterYTM logo";
- return logoEl;
- }
- let disableUpdateCheck = false;
- async function renderBody$1({ latestTag, changelogHtml, }) {
- disableUpdateCheck = false;
- const wrapperEl = document.createElement("div");
- const pEl = document.createElement("p");
- pEl.textContent = t("new_version_available", scriptInfo.name, scriptInfo.version, latestTag, platformNames[host]);
- wrapperEl.appendChild(pEl);
- const changelogDetailsEl = document.createElement("details");
- changelogDetailsEl.id = "bytm-version-notif-changelog-details";
- changelogDetailsEl.open = false;
- const changelogSummaryEl = document.createElement("summary");
- changelogSummaryEl.role = "button";
- changelogSummaryEl.tabIndex = 0;
- changelogSummaryEl.ariaLabel = changelogSummaryEl.title = changelogSummaryEl.textContent = t("expand_release_notes");
- changelogDetailsEl.appendChild(changelogSummaryEl);
- changelogDetailsEl.addEventListener("toggle", () => {
- changelogSummaryEl.ariaLabel = changelogSummaryEl.title = changelogSummaryEl.textContent = changelogDetailsEl.open ? t("collapse_release_notes") : t("expand_release_notes");
- });
- const changelogEl = document.createElement("p");
- changelogEl.id = "bytm-version-notif-changelog-cont";
- changelogEl.classList.add("bytm-markdown-container");
- setInnerHtml(changelogEl, changelogHtml);
- changelogEl.querySelectorAll("a").forEach((a) => {
- a.target = "_blank";
- a.rel = "noopener noreferrer";
- });
- changelogDetailsEl.appendChild(changelogEl);
- wrapperEl.appendChild(changelogDetailsEl);
- const disableUpdCheckEl = document.createElement("div");
- disableUpdCheckEl.id = "bytm-disable-update-check-wrapper";
- if (!getFeature("versionCheck"))
- disableUpdateCheck = true;
- const disableToggleEl = await createToggleInput({
- id: "disable-update-check",
- initialValue: disableUpdateCheck,
- labelPos: "off",
- onChange(checked) {
- disableUpdateCheck = checked;
- if (checked)
- btnClose.textContent = t("close_and_ignore_until_reenabled");
- else
- btnClose.textContent = t("close_and_ignore_for_24h");
- },
});
- const labelWrapperEl = document.createElement("div");
- labelWrapperEl.classList.add("bytm-disable-update-check-toggle-label-wrapper");
- const labelEl = document.createElement("label");
- labelEl.htmlFor = "bytm-toggle-disable-update-check";
- labelEl.textContent = t("disable_update_check");
- const secondaryLabelEl = document.createElement("span");
- secondaryLabelEl.classList.add("bytm-secondary-label");
- secondaryLabelEl.textContent = t("reenable_in_config_menu");
- labelWrapperEl.appendChild(labelEl);
- labelWrapperEl.appendChild(secondaryLabelEl);
- disableUpdCheckEl.appendChild(disableToggleEl);
- disableUpdCheckEl.appendChild(labelWrapperEl);
- wrapperEl.appendChild(disableUpdCheckEl);
- verNotifDialog === null || verNotifDialog === void 0 ? void 0 : verNotifDialog.on("close", async () => {
- const config = getFeatures();
- const recreateCfgMenu = config.versionCheck === disableUpdateCheck;
- if (config.versionCheck && disableUpdateCheck)
- config.versionCheck = false;
- else if (!config.versionCheck && !disableUpdateCheck)
- config.versionCheck = true;
- await setFeatures(config);
- recreateCfgMenu && emitSiteEvent("recreateCfgMenu");
- });
- const btnWrapper = document.createElement("div");
- btnWrapper.id = "bytm-version-notif-dialog-btns";
- const btnUpdate = document.createElement("button");
- btnUpdate.classList.add("bytm-btn");
- btnUpdate.tabIndex = 0;
- btnUpdate.textContent = t("open_update_page_install_manually", platformNames[host]);
- onInteraction(btnUpdate, () => {
- window.open(packageJson.updates[host]);
- verNotifDialog === null || verNotifDialog === void 0 ? void 0 : verNotifDialog.close();
- });
- const btnClose = document.createElement("button");
- btnClose.classList.add("bytm-btn");
- btnClose.tabIndex = 0;
- btnClose.textContent = t("close_and_ignore_for_24h");
- onInteraction(btnClose, () => verNotifDialog === null || verNotifDialog === void 0 ? void 0 : verNotifDialog.close());
- btnWrapper.appendChild(btnUpdate);
- btnWrapper.appendChild(btnClose);
- wrapperEl.appendChild(btnWrapper);
- return wrapperEl;
+ autoLikeDialog.on("close", () => emitSiteEvent("autoLikeChannelsUpdated"));
}
-
- //#region create menu
- let isCfgMenuMounted = false;
- let isCfgMenuOpen = false;
- /** Threshold in pixels from the top of the options container that dictates for how long the scroll indicator is shown */
- const scrollIndicatorOffsetThreshold = 50;
- let scrollIndicatorEnabled = true;
- /** Locale at the point of initializing the config menu */
- let initLocale;
- /** Stringified config at the point of initializing the config menu */
- let initConfig$1;
- /** Timeout id for the "copied" text in the hidden value copy button */
- let hiddenCopiedTxtTimeout;
- /**
- * Adds an element to open the BetterYTM menu
- * @deprecated to be replaced with new menu - see https://github.com/Sv443/BetterYTM/issues/23
- */
- async function mountCfgMenu() {
- var _a, _b, _c, _d, _e;
- if (isCfgMenuMounted)
- return;
- isCfgMenuMounted = true;
- BytmDialog.initDialogs();
- initLocale = getFeature("locale");
- initConfig$1 = getFeatures();
- const initLangReloadText = t("lang_changed_prompt_reload");
- //#region bg & container
- const backgroundElem = document.createElement("div");
- backgroundElem.id = "bytm-cfg-menu-bg";
- backgroundElem.classList.add("bytm-menu-bg");
- backgroundElem.ariaLabel = backgroundElem.title = t("close_menu_tooltip");
- backgroundElem.style.visibility = "hidden";
- backgroundElem.style.display = "none";
- backgroundElem.addEventListener("click", (e) => {
- var _a;
- if (isCfgMenuOpen && ((_a = e.target) === null || _a === void 0 ? void 0 : _a.id) === "bytm-cfg-menu-bg")
- closeCfgMenu(e);
- });
- document.body.addEventListener("keydown", (e) => {
- if (isCfgMenuOpen && e.key === "Escape" && BytmDialog.getCurrentDialogId() === "cfg-menu")
- closeCfgMenu(e);
- });
- const menuContainer = document.createElement("div");
- menuContainer.ariaLabel = menuContainer.title = ""; // prevent bg title from propagating downwards
- menuContainer.classList.add("bytm-menu");
- menuContainer.id = "bytm-cfg-menu";
- //#region title bar
- const headerElem = document.createElement("div");
- headerElem.classList.add("bytm-menu-header");
- const titleLogoHeaderCont = document.createElement("div");
- titleLogoHeaderCont.classList.add("bytm-menu-title-logo-header-cont");
- const titleCont = document.createElement("div");
- titleCont.classList.add("bytm-menu-titlecont");
- titleCont.role = "heading";
- titleCont.ariaLevel = "1";
- const titleLogoElem = document.createElement("img");
- const logoSrc = await getResourceUrl(`img-logo${mode === "development" ? "_dev" : ""}`);
- titleLogoElem.classList.add("bytm-cfg-menu-logo", "bytm-no-select");
- if (logoSrc)
- titleLogoElem.src = logoSrc;
- titleLogoHeaderCont.appendChild(titleLogoElem);
- const titleElem = document.createElement("h2");
- titleElem.classList.add("bytm-menu-title");
- const titleTextElem = document.createElement("div");
- titleTextElem.textContent = t("config_menu_title", scriptInfo.name);
- titleElem.appendChild(titleTextElem);
- const linksCont = document.createElement("div");
- linksCont.id = "bytm-menu-linkscont";
- linksCont.role = "navigation";
- const linkTitlesShort = {
- github: "GitHub",
- greasyfork: "GreasyFork",
- openuserjs: "OpenUserJS",
- discord: "Discord",
- };
- const addLink = (imgSrc, href, title, titleKey) => {
- const anchorElem = document.createElement("a");
- anchorElem.classList.add("bytm-menu-link", "bytm-no-select");
- anchorElem.rel = "noopener noreferrer";
- anchorElem.href = href;
- anchorElem.target = "_blank";
- anchorElem.tabIndex = 0;
- anchorElem.role = "button";
- anchorElem.ariaLabel = anchorElem.title = title;
- const extendedAnchorEl = document.createElement("a");
- extendedAnchorEl.classList.add("bytm-menu-link", "extended-link", "bytm-no-select");
- extendedAnchorEl.rel = "noopener noreferrer";
- extendedAnchorEl.href = href;
- extendedAnchorEl.target = "_blank";
- extendedAnchorEl.tabIndex = -1;
- extendedAnchorEl.textContent = linkTitlesShort[titleKey];
- extendedAnchorEl.ariaLabel = extendedAnchorEl.title = title;
- const imgElem = document.createElement("img");
- imgElem.classList.add("bytm-menu-img");
- imgElem.src = imgSrc;
- anchorElem.appendChild(imgElem);
- anchorElem.appendChild(extendedAnchorEl);
- linksCont.appendChild(anchorElem);
- };
- const links = [
- ["github", await getResourceUrl("img-github"), scriptInfo.namespace, t("open_github", scriptInfo.name), "github"],
- ["greasyfork", await getResourceUrl("img-greasyfork"), packageJson.hosts.greasyfork, t("open_greasyfork", scriptInfo.name), "greasyfork"],
- ["openuserjs", await getResourceUrl("img-openuserjs"), packageJson.hosts.openuserjs, t("open_openuserjs", scriptInfo.name), "openuserjs"],
- ];
- const hostLink = links.find(([name]) => name === host);
- const otherLinks = links.filter(([name]) => name !== host);
- const reorderedLinks = hostLink ? [hostLink, ...otherLinks] : links;
- for (const [, ...args] of reorderedLinks)
- addLink(...args);
- addLink(await getResourceUrl("img-discord"), "https://dc.sv443.net/", t("open_discord"), "discord");
- const closeElem = document.createElement("img");
- closeElem.classList.add("bytm-menu-close");
- closeElem.role = "button";
- closeElem.tabIndex = 0;
- closeElem.src = await getResourceUrl("img-close");
- closeElem.ariaLabel = closeElem.title = t("close_menu_tooltip");
- onInteraction(closeElem, closeCfgMenu);
- titleCont.appendChild(titleElem);
- titleCont.appendChild(linksCont);
- titleLogoHeaderCont.appendChild(titleCont);
- headerElem.appendChild(titleLogoHeaderCont);
- headerElem.appendChild(closeElem);
- //#region footer
- const footerCont = document.createElement("div");
- footerCont.classList.add("bytm-menu-footer-cont");
- const reloadFooterCont = document.createElement("div");
- const reloadFooterEl = document.createElement("div");
- reloadFooterEl.id = "bytm-menu-footer-reload-hint";
- reloadFooterEl.classList.add("bytm-menu-footer", "hidden");
- reloadFooterEl.setAttribute("aria-hidden", "true");
- reloadFooterEl.textContent = t("reload_hint");
- reloadFooterEl.role = "alert";
- reloadFooterEl.ariaLive = "polite";
- const reloadTxtEl = document.createElement("button");
- reloadTxtEl.classList.add("bytm-btn");
- reloadTxtEl.style.marginLeft = "10px";
- reloadTxtEl.textContent = t("reload_now");
- reloadTxtEl.ariaLabel = reloadTxtEl.title = t("reload_tooltip");
- reloadTxtEl.addEventListener("click", () => {
- closeCfgMenu();
- disableBeforeUnload();
- location.reload();
- });
- reloadFooterEl.appendChild(reloadTxtEl);
- reloadFooterCont.appendChild(reloadFooterEl);
- /** For copying plain when shift-clicking the copy button or when compression is not supported */
- const exportDataSpecial = () => JSON.stringify({ formatVersion, data: getFeatures() });
- const exImDlg = new ExImDialog({
- id: "bytm-config-export-import",
+ if (!autoLikeExImDialog) {
+ autoLikeExImDialog = new ExImDialog({
+ id: "auto-like-channels-export-import",
width: 800,
height: 600,
// try to compress the data if possible
exportData: async () => await compressionSupported()
- ? await UserUtils.compress(JSON.stringify({ formatVersion, data: getFeatures() }), compressionFormat, "string")
- : exportDataSpecial(),
- exportDataSpecial,
+ ? await UserUtils.compress(JSON.stringify(autoLikeStore.getData()), compressionFormat, "string")
+ : JSON.stringify(autoLikeStore.getData()),
+ // copy plain when shift-clicking the copy button
+ exportDataSpecial: () => JSON.stringify(autoLikeStore.getData()),
async onImport(data) {
try {
- const parsed = await tryToDecompressAndParse(data.trim());
- log("Trying to import configuration:", parsed);
+ const parsed = await tryToDecompressAndParse(data);
+ log("Trying to import auto-like data:", parsed);
if (!parsed || typeof parsed !== "object")
return await showPrompt({ type: "alert", message: t("import_error_invalid") });
- if (typeof parsed.formatVersion !== "number")
- return await showPrompt({ type: "alert", message: t("import_error_no_format_version") });
- if (typeof parsed.data !== "object" || parsed.data === null || Object.keys(parsed.data).length === 0)
+ if (!parsed.channels || typeof parsed.channels !== "object" || Object.keys(parsed.channels).length === 0)
return await showPrompt({ type: "alert", message: t("import_error_no_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 ? await migRes : migRes;
- curFmtVer = ver;
- }
- catch (err) {
- error(`Error while running migration function for format version ${fmtVer}:`, err);
- }
- }
- }
- parsed.formatVersion = curFmtVer;
- parsed.data = newData;
- }
- else if (parsed.formatVersion !== formatVersion)
- return await showPrompt({ type: "alert", message: t("import_error_wrong_format_version", formatVersion, parsed.formatVersion) });
- await setFeatures(Object.assign(Object.assign({}, getFeatures()), parsed.data));
- if (await showPrompt({ type: "confirm", message: t("import_success_confirm_reload") })) {
- disableBeforeUnload();
- return location.reload();
- }
- exImDlg.unmount();
- emitSiteEvent("rebuildCfgMenu", parsed.data);
+ await autoLikeStore.setData(parsed);
+ emitSiteEvent("autoLikeChannelsUpdated");
+ showToast({ message: t("import_success") });
+ autoLikeExImDialog === null || autoLikeExImDialog === void 0 ? void 0 : autoLikeExImDialog.unmount();
}
catch (err) {
- warn("Couldn't import configuration:", err);
- await showPrompt({ type: "alert", message: t("import_error_invalid") });
+ error("Couldn't import auto-like channels data:", err);
}
},
- title: () => t("bytm_config_export_import_title"),
- descImport: () => t("bytm_config_import_desc"),
- descExport: () => t("bytm_config_export_desc"),
+ title: () => t("auto_like_export_import_title"),
+ descImport: () => t("auto_like_import_desc"),
+ descExport: () => t("auto_like_export_desc"),
});
- const exportImportBtn = document.createElement("button");
- exportImportBtn.classList.add("bytm-btn");
- exportImportBtn.textContent = exportImportBtn.ariaLabel = exportImportBtn.title = t("export_import");
- onInteraction(exportImportBtn, async () => await exImDlg.open());
- const buttonsCont = document.createElement("div");
- buttonsCont.classList.add("bytm-menu-footer-buttons-cont");
- buttonsCont.appendChild(exportImportBtn);
- footerCont.appendChild(reloadFooterCont);
- footerCont.appendChild(buttonsCont);
- //#region feature list
- const featuresCont = document.createElement("div");
- featuresCont.id = "bytm-menu-opts";
- const onCfgChange = async (key, initialVal, newVal) => {
- var _a, _b, _c, _d;
- try {
- const fmt = (val) => typeof val === "object" ? JSON.stringify(val) : String(val);
- info(`Feature config changed at key '${key}', from value '${fmt(initialVal)}' to '${fmt(newVal)}'`);
- const featConf = JSON.parse(JSON.stringify(getFeatures()));
- featConf[key] = newVal;
- const changedKeys = initConfig$1 ? Object.keys(featConf).filter((k) => typeof featConf[k] !== "object"
- && featConf[k] !== initConfig$1[k]) : [];
- const requiresReload =
- // @ts-ignore
- changedKeys.some((k) => { var _a; return ((_a = featInfo[k]) === null || _a === void 0 ? void 0 : _a.reloadRequired) !== false; });
- await setFeatures(featConf);
- // @ts-ignore
- (_b = (_a = featInfo[key]) === null || _a === void 0 ? void 0 : _a.change) === null || _b === void 0 ? void 0 : _b.call(_a, key, initialVal, newVal);
- if (requiresReload) {
- reloadFooterEl.classList.remove("hidden");
- reloadFooterEl.setAttribute("aria-hidden", "false");
- }
- else {
- reloadFooterEl.classList.add("hidden");
- reloadFooterEl.setAttribute("aria-hidden", "true");
- }
- if (initLocale !== featConf.locale) {
- await initTranslations(featConf.locale);
- setLocale(featConf.locale);
- const newText = t("lang_changed_prompt_reload");
- const newLangEmoji = ((_c = langMapping[featConf.locale]) === null || _c === void 0 ? void 0 : _c.emoji) ? `${langMapping[featConf.locale].emoji}\n` : "";
- const initLangEmoji = ((_d = langMapping[initLocale]) === null || _d === void 0 ? void 0 : _d.emoji) ? `${langMapping[initLocale].emoji}\n` : "";
- const confirmText = newText !== initLangReloadText ? `${newLangEmoji}${newText}\n\n\n${initLangEmoji}${initLangReloadText}` : newText;
- if (await showPrompt({
- type: "confirm",
- message: confirmText,
- confirmBtnText: () => `${t("prompt_confirm")} / ${tl(initLocale, "prompt_confirm")}`,
- confirmBtnTooltip: () => `${t("click_to_confirm_tooltip")} / ${tl(initLocale, "click_to_confirm_tooltip")}`,
- denyBtnText: (type) => `${t(type === "alert" ? "prompt_close" : "prompt_cancel")} / ${tl(initLocale, type === "alert" ? "prompt_close" : "prompt_cancel")}`,
- denyBtnTooltip: (type) => `${t(type === "alert" ? "click_to_close_tooltip" : "click_to_cancel_tooltip")} / ${tl(initLocale, type === "alert" ? "click_to_close_tooltip" : "click_to_cancel_tooltip")}`,
- })) {
- closeCfgMenu();
- disableBeforeUnload();
- location.reload();
- }
- }
- else if (getLocale() !== featConf.locale)
- setLocale(featConf.locale);
- }
- catch (err) {
- error("Error while reacting to config change:", err);
- }
- finally {
- emitSiteEvent("configOptionChanged", key, initialVal, newVal);
- }
- };
- /** Call whenever the feature config is changed */
- const confChanged = UserUtils.debounce(onCfgChange, 333, "falling");
- const featureCfg = getFeatures();
- const featureCfgWithCategories = Object.entries(featInfo)
- .reduce((acc, [key, { category }]) => {
- if (!acc[category])
- acc[category] = {};
- acc[category][key] = featureCfg[key];
- return acc;
- }, {});
- /**
- * Formats the value `v` based on the provided `key` using the `featInfo` object.
- * If a custom `renderValue` function is defined for the `key`, it will be used to format the value.
- * If no custom `renderValue` function is defined, the value will be converted to a string and trimmed.
- * If the value is an object, it will be converted to a JSON string representation.
- * If an error occurs during formatting (like when passing objects with circular references), the original value will be returned as a string (trimmed).
- */
- const fmtVal = (v, key) => {
- var _a;
- try {
- // @ts-ignore
- const renderValue = typeof ((_a = featInfo === null || featInfo === void 0 ? void 0 : featInfo[key]) === null || _a === void 0 ? void 0 : _a.renderValue) === "function" ? featInfo[key].renderValue : undefined;
- const retVal = (typeof v === "object" ? JSON.stringify(v) : String(v)).trim();
- return renderValue ? renderValue(retVal) : retVal;
+ }
+ return autoLikeDialog;
+}
+//#region header
+async function renderHeader$5() {
+ const headerEl = document.createElement("h2");
+ headerEl.classList.add("bytm-dialog-title");
+ headerEl.role = "heading";
+ headerEl.ariaLevel = "1";
+ headerEl.tabIndex = 0;
+ headerEl.textContent = headerEl.ariaLabel = t("auto_like_channels_dialog_title");
+ return headerEl;
+}
+//#region body
+async function renderBody$5() {
+ const contElem = document.createElement("div");
+ const descriptionEl = document.createElement("p");
+ descriptionEl.classList.add("bytm-auto-like-channels-desc");
+ descriptionEl.textContent = t("auto_like_channels_dialog_desc");
+ descriptionEl.tabIndex = 0;
+ contElem.appendChild(descriptionEl);
+ const searchCont = document.createElement("div");
+ searchCont.classList.add("bytm-auto-like-channels-search-cont");
+ contElem.appendChild(searchCont);
+ const searchbarEl = document.createElement("input");
+ searchbarEl.classList.add("bytm-auto-like-channels-searchbar");
+ searchbarEl.placeholder = t("search_placeholder");
+ searchbarEl.type = searchbarEl.role = "search";
+ searchbarEl.tabIndex = 0;
+ searchbarEl.autofocus = true;
+ searchbarEl.autocomplete = searchbarEl.autocapitalize = "off";
+ searchbarEl.spellcheck = false;
+ searchbarEl.addEventListener("input", () => {
+ var _a, _b, _c, _d, _e, _f;
+ const searchVal = searchbarEl.value.trim().toLowerCase();
+ const rows = document.querySelectorAll(".bytm-auto-like-channel-row");
+ for (const row of rows) {
+ const name = (_c = (_b = (_a = row.querySelector(".bytm-auto-like-channel-name")) === null || _a === void 0 ? void 0 : _a.textContent) === null || _b === void 0 ? void 0 : _b.trim().toLowerCase().replace(/\s/g, "")) !== null && _c !== void 0 ? _c : "";
+ const id = (_f = (_e = (_d = row.querySelector(".bytm-auto-like-channel-id")) === null || _d === void 0 ? void 0 : _d.textContent) === null || _e === void 0 ? void 0 : _e.trim()) !== null && _f !== void 0 ? _f : "";
+ row.classList.toggle("hidden", !name.includes(searchVal) && !(id.startsWith("@") ? id : "").includes(searchVal));
+ }
+ });
+ searchCont.appendChild(searchbarEl);
+ const searchClearEl = document.createElement("button");
+ searchClearEl.classList.add("bytm-auto-like-channels-search-clear");
+ searchClearEl.title = searchClearEl.ariaLabel = t("search_clear");
+ searchClearEl.tabIndex = 0;
+ searchClearEl.innerText = "×";
+ onInteraction(searchClearEl, () => {
+ searchbarEl.value = "";
+ searchbarEl.dispatchEvent(new Event("input"));
+ });
+ searchCont.appendChild(searchClearEl);
+ const channelListCont = document.createElement("div");
+ channelListCont.id = "bytm-auto-like-channels-list";
+ const setChannelEnabled = UserUtils.debounce((id, enabled) => {
+ autoLikeStore.setData({
+ channels: autoLikeStore.getData().channels
+ .map((ch) => ch.id === id ? Object.assign(Object.assign({}, ch), { enabled }) : ch),
+ });
+ }, 250, "rising");
+ const sortedChannels = autoLikeStore
+ .getData().channels
+ .sort((a, b) => a.name.localeCompare(b.name));
+ for (const { name: chanName, id: chanId, enabled } of sortedChannels) {
+ const rowElem = document.createElement("div");
+ rowElem.classList.add("bytm-auto-like-channel-row");
+ const leftCont = document.createElement("div");
+ leftCont.classList.add("bytm-auto-like-channel-row-left-cont");
+ const nameLabelEl = document.createElement("label");
+ nameLabelEl.ariaLabel = nameLabelEl.title = chanName;
+ nameLabelEl.htmlFor = `bytm-auto-like-channel-list-toggle-${chanId}`;
+ nameLabelEl.classList.add("bytm-auto-like-channel-name-label");
+ const nameElem = document.createElement("a");
+ nameElem.classList.add("bytm-auto-like-channel-name", "bytm-link");
+ nameElem.ariaLabel = nameElem.textContent = chanName;
+ nameElem.href = (!chanId.startsWith("@") && getDomain() === "ytm")
+ ? `https://music.youtube.com/channel/${chanId}`
+ : `https://youtube.com/${chanId.startsWith("@") ? chanId : `channel/${chanId}`}`;
+ nameElem.target = "_blank";
+ nameElem.rel = "noopener noreferrer";
+ nameElem.tabIndex = 0;
+ const idElem = document.createElement("span");
+ idElem.classList.add("bytm-auto-like-channel-id");
+ idElem.textContent = idElem.title = chanId;
+ nameLabelEl.appendChild(nameElem);
+ nameLabelEl.appendChild(idElem);
+ const toggleElem = await createToggleInput({
+ id: `auto-like-channel-list-${chanId}`,
+ labelPos: "off",
+ initialValue: enabled,
+ onChange: (en) => setChannelEnabled(chanId, en),
+ });
+ toggleElem.classList.add("bytm-auto-like-channel-toggle");
+ toggleElem.title = toggleElem.ariaLabel = t("auto_like_channel_toggle_tooltip", chanName);
+ const btnCont = document.createElement("div");
+ btnCont.classList.add("bytm-auto-like-channel-row-btn-cont");
+ const editBtn = await createCircularBtn({
+ resourceName: "icon-edit",
+ title: t("edit_entry"),
+ async onClick() {
+ var _a, _b, _c;
+ const newNamePr = (_a = (await showPrompt({ type: "prompt", message: t("auto_like_channel_edit_name_prompt"), defaultValue: chanName }))) === null || _a === void 0 ? void 0 : _a.trim();
+ if (!newNamePr || newNamePr.length === 0)
+ return;
+ const newName = newNamePr.length > 0 ? newNamePr : chanName;
+ const newIdPr = (_b = (await showPrompt({ type: "prompt", message: t("auto_like_channel_edit_id_prompt"), defaultValue: chanId }))) === null || _b === void 0 ? void 0 : _b.trim();
+ if (!newIdPr || newIdPr.length === 0)
+ return;
+ const newId = newIdPr.length > 0 ? (_c = getChannelIdFromPrompt(newIdPr)) !== null && _c !== void 0 ? _c : chanId : chanId;
+ await autoLikeStore.setData({
+ channels: autoLikeStore.getData().channels
+ .map((ch) => ch.id === chanId ? Object.assign(Object.assign({}, ch), { name: newName, id: newId }) : ch),
+ });
+ emitSiteEvent("autoLikeChannelsUpdated");
+ },
+ });
+ btnCont.appendChild(editBtn);
+ const removeBtn = await createCircularBtn({
+ resourceName: "icon-delete",
+ title: t("remove_entry"),
+ async onClick() {
+ autoLikeStore.setData({
+ channels: autoLikeStore.getData().channels.filter((ch) => ch.id !== chanId),
+ });
+ rowElem.remove();
+ emitSiteEvent("autoLikeChannelsUpdated");
+ },
+ });
+ btnCont.appendChild(removeBtn);
+ leftCont.appendChild(toggleElem);
+ leftCont.appendChild(nameLabelEl);
+ rowElem.appendChild(leftCont);
+ rowElem.appendChild(btnCont);
+ channelListCont.appendChild(rowElem);
+ }
+ contElem.appendChild(channelListCont);
+ return contElem;
+}
+//#region footer
+function renderFooter$1() {
+ const wrapperEl = document.createElement("div");
+ wrapperEl.classList.add("bytm-auto-like-channels-footer-wrapper");
+ const addNewBtnElem = document.createElement("button");
+ addNewBtnElem.classList.add("bytm-btn");
+ addNewBtnElem.textContent = t("new_entry");
+ addNewBtnElem.ariaLabel = addNewBtnElem.title = t("new_entry_tooltip");
+ wrapperEl.appendChild(addNewBtnElem);
+ const importExportBtnElem = document.createElement("button");
+ importExportBtnElem.classList.add("bytm-btn");
+ importExportBtnElem.textContent = t("export_import");
+ importExportBtnElem.ariaLabel = importExportBtnElem.title = t("auto_like_export_or_import_tooltip");
+ wrapperEl.appendChild(importExportBtnElem);
+ onInteraction(addNewBtnElem, addAutoLikeEntryPrompts);
+ onInteraction(importExportBtnElem, openImportExportAutoLikeChannelsDialog);
+ return wrapperEl;
+}
+async function openImportExportAutoLikeChannelsDialog() {
+ await (autoLikeExImDialog === null || autoLikeExImDialog === void 0 ? void 0 : autoLikeExImDialog.open());
+}
+//#region add prompt
+async function addAutoLikeEntryPrompts() {
+ var _a, _b, _c;
+ await autoLikeStore.loadData();
+ const idPrompt = (_a = (await showPrompt({ type: "prompt", message: t("add_auto_like_channel_id_prompt") }))) === null || _a === void 0 ? void 0 : _a.trim();
+ if (!idPrompt)
+ return;
+ const id = (_b = parseChannelIdFromUrl(idPrompt)) !== null && _b !== void 0 ? _b : (isValidChannelId(idPrompt) ? idPrompt : null);
+ if (!id || id.length <= 0)
+ return await showPrompt({ type: "alert", message: t("add_auto_like_channel_invalid_id") });
+ let overwriteName = false;
+ const hasChannelEntry = autoLikeStore.getData().channels.find((ch) => ch.id === id);
+ if (hasChannelEntry) {
+ if (!await showPrompt({ type: "confirm", message: t("add_auto_like_channel_already_exists_prompt_new_name") }))
+ return;
+ overwriteName = true;
+ }
+ const name = (_c = (await showPrompt({ type: "prompt", message: t("add_auto_like_channel_name_prompt"), defaultValue: hasChannelEntry === null || hasChannelEntry === void 0 ? void 0 : hasChannelEntry.name }))) === null || _c === void 0 ? void 0 : _c.trim();
+ if (!name || name.length === 0)
+ return;
+ await autoLikeStore.setData(overwriteName
+ ? {
+ channels: autoLikeStore.getData().channels
+ .map((ch) => ch.id === id ? Object.assign(Object.assign({}, ch), { name }) : ch),
+ }
+ : {
+ channels: [
+ ...autoLikeStore.getData().channels,
+ { id, name, enabled: true },
+ ],
+ });
+ emitSiteEvent("autoLikeChannelsUpdated");
+ const unsub = autoLikeDialog === null || autoLikeDialog === void 0 ? void 0 : autoLikeDialog.on("clear", async () => {
+ unsub === null || unsub === void 0 ? void 0 : unsub();
+ await (autoLikeDialog === null || autoLikeDialog === void 0 ? void 0 : autoLikeDialog.open());
+ });
+ autoLikeDialog === null || autoLikeDialog === void 0 ? void 0 : autoLikeDialog.unmount();
+}
+function getChannelIdFromPrompt(promptStr) {
+ const isId = promptStr.match(/^@?.+$/);
+ const isUrl = promptStr.match(/^(?:https?:\/\/)?(?:www\.)?(?:music\.)?youtube\.com\/(?:channel\/|@)([a-zA-Z0-9_-]+)/);
+ const id = ((isId === null || isId === void 0 ? void 0 : isId[0]) || (isUrl === null || isUrl === void 0 ? void 0 : isUrl[1]) || "").trim();
+ return id.length > 0 ? id : null;
+}let changelogDialog = null;
+/** Creates and/or returns the changelog dialog */
+async function getChangelogDialog() {
+ if (!changelogDialog) {
+ changelogDialog = new BytmDialog({
+ id: "changelog",
+ width: 1000,
+ height: 800,
+ closeBtnEnabled: true,
+ closeOnBgClick: true,
+ closeOnEscPress: true,
+ small: true,
+ verticalAlign: "top",
+ renderHeader: renderHeader$4,
+ renderBody: renderBody$4,
+ });
+ changelogDialog.on("render", () => {
+ const mdContElem = document.querySelector("#bytm-changelog-dialog-text");
+ if (!mdContElem)
+ return;
+ const anchors = mdContElem.querySelectorAll("a");
+ for (const anchor of anchors) {
+ anchor.ariaLabel = anchor.title = anchor.href;
+ anchor.target = "_blank";
}
- catch (_b) {
- // absolute last resort fallback because stringify throws on circular refs
- return String(v).trim();
+ const firstDetails = mdContElem.querySelector("details");
+ if (firstDetails)
+ firstDetails.open = true;
+ const kbdElems = mdContElem.querySelectorAll("kbd");
+ for (const kbdElem of kbdElems)
+ kbdElem.addEventListener("selectstart", (e) => e.preventDefault());
+ });
+ }
+ return changelogDialog;
+}
+async function renderHeader$4() {
+ const headerEl = document.createElement("h2");
+ headerEl.classList.add("bytm-dialog-title");
+ headerEl.role = "heading";
+ headerEl.ariaLevel = "1";
+ headerEl.tabIndex = 0;
+ headerEl.textContent = headerEl.ariaLabel = t("changelog_menu_title", scriptInfo.name);
+ return headerEl;
+}
+async function renderBody$4() {
+ const contElem = document.createElement("div");
+ const mdContElem = document.createElement("div");
+ mdContElem.id = "bytm-changelog-dialog-text";
+ mdContElem.classList.add("bytm-markdown-container");
+ setInnerHtml(mdContElem, await getChangelogHtmlWithDetails());
+ contElem.appendChild(mdContElem);
+ return contElem;
+}let featHelpDialog = null;
+let curFeatKey = null;
+/** Creates or modifies the help dialog for a specific feature and returns it */
+async function getFeatHelpDialog({ featKey, }) {
+ curFeatKey = featKey;
+ if (!featHelpDialog) {
+ featHelpDialog = new BytmDialog({
+ id: "feat-help",
+ width: 600,
+ height: 400,
+ closeBtnEnabled: true,
+ closeOnBgClick: true,
+ closeOnEscPress: true,
+ small: true,
+ renderHeader: renderHeader$3,
+ renderBody: renderBody$3,
+ });
+ // make config menu inert while help dialog is open
+ featHelpDialog.on("open", () => { var _a; return (_a = document.querySelector("#bytm-cfg-menu")) === null || _a === void 0 ? void 0 : _a.setAttribute("inert", "true"); });
+ featHelpDialog.on("close", () => { var _a; return (_a = document.querySelector("#bytm-cfg-menu")) === null || _a === void 0 ? void 0 : _a.removeAttribute("inert"); });
+ }
+ return featHelpDialog;
+}
+async function renderHeader$3() {
+ const headerEl = document.createElement("div");
+ const helpIconSvg = await resourceAsString("icon-help");
+ if (helpIconSvg)
+ setInnerHtml(headerEl, helpIconSvg);
+ return headerEl;
+}
+async function renderBody$3() {
+ var _a, _b;
+ const contElem = document.createElement("div");
+ const featDescElem = document.createElement("h3");
+ featDescElem.role = "subheading";
+ featDescElem.tabIndex = 0;
+ featDescElem.textContent = t(`feature_desc_${curFeatKey}`);
+ featDescElem.id = "bytm-feat-help-dialog-desc";
+ const helpTextElem = document.createElement("div");
+ helpTextElem.id = "bytm-feat-help-dialog-text";
+ helpTextElem.tabIndex = 0;
+ // @ts-ignore
+ const helpText = (_b = (_a = featInfo[curFeatKey]) === null || _a === void 0 ? void 0 : _a.helpText) === null || _b === void 0 ? void 0 : _b.call(_a);
+ helpTextElem.textContent = helpText !== null && helpText !== void 0 ? helpText : t(`feature_helptext_${curFeatKey}`);
+ contElem.appendChild(featDescElem);
+ contElem.appendChild(helpTextElem);
+ return contElem;
+}var name = "betterytm";
+var userscriptName = "BetterYTM";
+var version = "2.2.0";
+var description = "Lots of configurable layout and user experience improvements for YouTube Music™ and YouTube™";
+var homepage = "https://github.com/Sv443/BetterYTM";
+var main = "./src/index.ts";
+var type = "module";
+var scripts = {
+ dev: "concurrently \"nodemon --exec pnpm run build-private-dev\" \"pnpm run serve\"",
+ serve: "pnpm run node-ts ./src/tools/serve.ts",
+ lint: "eslint . && tsc --noEmit",
+ build: "rollup -c",
+ "build-private-dev": "rollup -c --config-mode development --config-host github --config-branch develop --config-assetSource=local",
+ "build-dev": "rollup -c --config-mode development --config-host github --config-branch develop",
+ preview: "pnpm run build-prod-gh --config-assetSource=local && pnpm run serve --auto-exit-time=6",
+ "build-prod": "pnpm run build-prod-gh && pnpm run build-prod-gf && pnpm run build-prod-oujs",
+ "build-prod-base": "rollup -c --config-mode production --config-branch main",
+ "build-prod-gh": "pnpm run build-prod-base --config-host github",
+ "build-prod-gf": "pnpm run build-prod-base --config-host greasyfork --config-suffix _gf",
+ "build-prod-oujs": "pnpm run build-prod-base --config-host openuserjs --config-suffix _oujs",
+ "post-build": "pnpm run node-ts ./src/tools/post-build.ts",
+ "tr-changed": "pnpm run node-ts ./src/tools/tr-changed.ts",
+ "tr-progress": "pnpm run node-ts ./src/tools/tr-progress.ts",
+ "tr-format": "pnpm run node-ts ./src/tools/tr-format.ts",
+ "tr-prep": "pnpm run tr-format -p",
+ "gen-readme": "pnpm run node-ts ./src/tools/gen-readme.ts",
+ "node-ts": "node --no-warnings=ExperimentalWarning --enable-source-maps --loader ts-node/esm",
+ invisible: "node --enable-source-maps src/tools/run-invisible.mjs",
+ test: "pnpm run node-ts ./test.ts",
+ knip: "knip",
+ storybook: "storybook dev -p 6006",
+ "build-storybook": "storybook build",
+ "dep-cruise": "npx depcruise src",
+ "dep-graph": "npx depcruise src --include-only \"^src\" --output-type dot | dot -T svg > dependency-graph.svg && open-cli dependency-graph.svg -R"
+};
+var engines = {
+ node: ">=19",
+ pnpm: ">=6"
+};
+var repository = {
+ type: "git",
+ url: "git+https://github.com/Sv443/BetterYTM.git"
+};
+var author = {
+ name: "Sv443",
+ url: "https://github.com/Sv443"
+};
+var license = "AGPL-3.0-only";
+var bugs = {
+ url: "https://github.com/Sv443/BetterYTM/issues"
+};
+var funding = {
+ type: "github",
+ url: "https://github.com/sponsors/Sv443"
+};
+var hosts = {
+ github: "https://github.com/Sv443/BetterYTM",
+ greasyfork: "https://greasyfork.org/en/scripts/475682-betterytm",
+ openuserjs: "https://openuserjs.org/scripts/Sv443/BetterYTM"
+};
+var updates = {
+ github: "https://github.com/Sv443/BetterYTM/releases",
+ greasyfork: "https://greasyfork.org/en/scripts/475682-betterytm",
+ openuserjs: "https://openuserjs.org/scripts/Sv443/BetterYTM"
+};
+var dependencies = {
+ "@sv443-network/userutils": "^8.3.3",
+ "compare-versions": "^6.1.0",
+ dompurify: "^3.1.6",
+ marked: "^12.0.2",
+ tslib: "^2.6.3"
+};
+var devDependencies = {
+ "@chromatic-com/storybook": "^1.5.0",
+ "@eslint/eslintrc": "^3.1.0",
+ "@rollup/plugin-json": "^6.1.0",
+ "@rollup/plugin-node-resolve": "^15.2.3",
+ "@rollup/plugin-terser": "^0.4.4",
+ "@rollup/plugin-typescript": "^11.1.6",
+ "@storybook/addon-essentials": "^8.1.10",
+ "@storybook/addon-interactions": "^8.1.10",
+ "@storybook/addon-links": "^8.1.10",
+ "@storybook/blocks": "^8.1.10",
+ "@storybook/html": "^8.1.10",
+ "@storybook/html-vite": "^8.1.10",
+ "@storybook/test": "^8.1.10",
+ "@types/dompurify": "^3.0.5",
+ "@types/express": "^4.17.21",
+ "@types/greasemonkey": "^4.0.7",
+ "@types/node": "^20.14.8",
+ "@typescript-eslint/eslint-plugin": "^8.0.0",
+ "@typescript-eslint/parser": "^8.0.0",
+ "@typescript-eslint/utils": "^8.0.0",
+ concurrently: "^9.0.1",
+ "dependency-cruiser": "^16.3.10",
+ dotenv: "^16.4.5",
+ eslint: "^9.5.0",
+ "eslint-plugin-storybook": "^0.11.0",
+ express: "^4.19.2",
+ globals: "^15.6.0",
+ kleur: "^4.1.5",
+ knip: "^5.22.2",
+ nanoevents: "^9.0.0",
+ nodemon: "^3.1.4",
+ "open-cli": "^8.0.0",
+ pnpm: "^9.4.0",
+ rollup: "^4.18.0",
+ "rollup-plugin-execute": "^1.1.1",
+ "rollup-plugin-import-css": "^3.5.0",
+ storybook: "^8.1.10",
+ "storybook-dark-mode": "^4.0.2",
+ "ts-node": "^10.9.2",
+ tsx: "^4.19.2",
+ typescript: "^5.5.2"
+};
+var browserslist = [
+ "last 1 version",
+ "> 1%",
+ "not dead"
+];
+var nodemonConfig = {
+ watch: [
+ "src/**",
+ "assets/**",
+ "rollup.config.mjs",
+ ".env",
+ "changelog.md",
+ "package.json"
+ ],
+ ext: "ts,mts,js,jsx,mjs,json,html,css,svg,png",
+ ignore: [
+ "dist/*",
+ "dev/*",
+ "*/stories/*"
+ ]
+};
+var packageJson = {
+ name: name,
+ userscriptName: userscriptName,
+ version: version,
+ description: description,
+ homepage: homepage,
+ main: main,
+ type: type,
+ scripts: scripts,
+ engines: engines,
+ repository: repository,
+ author: author,
+ license: license,
+ bugs: bugs,
+ funding: funding,
+ hosts: hosts,
+ updates: updates,
+ dependencies: dependencies,
+ devDependencies: devDependencies,
+ browserslist: browserslist,
+ nodemonConfig: nodemonConfig
+};let pluginListDialog = null;
+/** Creates and/or returns the import dialog */
+async function getPluginListDialog() {
+ return pluginListDialog = pluginListDialog !== null && pluginListDialog !== void 0 ? pluginListDialog : new BytmDialog({
+ id: "plugin-list",
+ width: 800,
+ height: 600,
+ closeBtnEnabled: true,
+ closeOnBgClick: true,
+ closeOnEscPress: true,
+ destroyOnClose: true,
+ small: true,
+ renderHeader: renderHeader$2,
+ renderBody: renderBody$2,
+ });
+}
+async function renderHeader$2() {
+ const titleElem = document.createElement("h2");
+ titleElem.id = "bytm-plugin-list-title";
+ titleElem.classList.add("bytm-dialog-title");
+ titleElem.role = "heading";
+ titleElem.ariaLevel = "1";
+ titleElem.tabIndex = 0;
+ titleElem.textContent = t("plugin_list_title");
+ return titleElem;
+}
+async function renderBody$2() {
+ var _a;
+ const listContainerEl = document.createElement("div");
+ listContainerEl.id = "bytm-plugin-list-container";
+ const registeredPlugins = getRegisteredPlugins();
+ if (registeredPlugins.length === 0) {
+ const noPluginsEl = document.createElement("div");
+ noPluginsEl.classList.add("bytm-plugin-list-no-plugins");
+ noPluginsEl.tabIndex = 0;
+ setInnerHtml(noPluginsEl, t("plugin_list_no_plugins", `
"));
+ noPluginsEl.title = noPluginsEl.ariaLabel = t("plugin_list_no_plugins_tooltip");
+ listContainerEl.appendChild(noPluginsEl);
+ return listContainerEl;
+ }
+ for (const [, { def: { plugin, intents } }] of registeredPlugins) {
+ const rowEl = document.createElement("div");
+ rowEl.classList.add("bytm-plugin-list-row");
+ const leftEl = document.createElement("div");
+ leftEl.classList.add("bytm-plugin-list-row-left");
+ rowEl.appendChild(leftEl);
+ const headerWrapperEl = document.createElement("div");
+ headerWrapperEl.classList.add("bytm-plugin-list-row-header-wrapper");
+ leftEl.appendChild(headerWrapperEl);
+ if (plugin.iconUrl) {
+ const iconEl = document.createElement("img");
+ iconEl.classList.add("bytm-plugin-list-row-icon");
+ iconEl.src = plugin.iconUrl;
+ iconEl.alt = "";
+ headerWrapperEl.appendChild(iconEl);
+ }
+ const headerEl = document.createElement("div");
+ headerEl.classList.add("bytm-plugin-list-row-header");
+ headerWrapperEl.appendChild(headerEl);
+ const titleEl = document.createElement("div");
+ titleEl.classList.add("bytm-plugin-list-row-title");
+ titleEl.tabIndex = 0;
+ titleEl.textContent = titleEl.title = titleEl.ariaLabel = plugin.name;
+ headerEl.appendChild(titleEl);
+ const verEl = document.createElement("span");
+ verEl.classList.add("bytm-plugin-list-row-version");
+ verEl.textContent = verEl.title = verEl.ariaLabel = `v${plugin.version}`;
+ titleEl.appendChild(verEl);
+ const namespaceEl = document.createElement("div");
+ namespaceEl.classList.add("bytm-plugin-list-row-namespace");
+ namespaceEl.tabIndex = 0;
+ namespaceEl.textContent = namespaceEl.title = namespaceEl.ariaLabel = plugin.namespace;
+ headerEl.appendChild(namespaceEl);
+ const descEl = document.createElement("p");
+ descEl.classList.add("bytm-plugin-list-row-desc");
+ descEl.tabIndex = 0;
+ descEl.textContent = descEl.title = descEl.ariaLabel = (_a = plugin.description[getLocale()]) !== null && _a !== void 0 ? _a : plugin.description["en-US"];
+ leftEl.appendChild(descEl);
+ const linksList = document.createElement("div");
+ linksList.classList.add("bytm-plugin-list-row-links-list");
+ leftEl.appendChild(linksList);
+ let linkElCreated = false;
+ for (const key in plugin.homepage) {
+ const url = plugin.homepage[key];
+ if (!url)
+ continue;
+ if (linkElCreated) {
+ const bulletEl = document.createElement("span");
+ bulletEl.classList.add("bytm-plugin-list-row-links-list-bullet");
+ bulletEl.textContent = "•";
+ linksList.appendChild(bulletEl);
}
- };
- 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.tabIndex = 0;
- catHeaderElem.textContent = `${t(`feature_category_${category}`)}:`;
- featuresCont.appendChild(catHeaderElem);
- for (const featKey in featObj) {
- const ftInfo = featInfo[featKey];
- if (!ftInfo || ("hidden" in ftInfo && ftInfo.hidden === true))
- continue;
- if (ftInfo.advanced && !featureCfg.advancedMode)
- continue;
- const { type, default: ftDefault } = ftInfo;
- const step = "step" in ftInfo ? ftInfo.step : undefined;
- const val = featureCfg[featKey];
- const initialVal = (_a = val !== null && val !== void 0 ? val : ftDefault) !== null && _a !== void 0 ? _a : undefined;
- const ftConfElem = document.createElement("div");
- ftConfElem.classList.add("bytm-ftitem");
- {
- const featLeftSideElem = document.createElement("div");
- featLeftSideElem.classList.add("bytm-ftitem-leftside");
- if (getFeature("advancedMode")) {
- const defVal = fmtVal(ftDefault, featKey);
- const extraTxts = [
- `default: ${defVal.length === 0 ? "(undefined)" : defVal}`,
- ];
- "min" in ftInfo && extraTxts.push(`min: ${ftInfo.min}`);
- "max" in ftInfo && extraTxts.push(`max: ${ftInfo.max}`);
- "step" in ftInfo && extraTxts.push(`step: ${ftInfo.step}`);
- const rel = "reloadRequired" in ftInfo && ftInfo.reloadRequired !== false ? " (reload required)" : "";
- const adv = ftInfo.advanced ? " (advanced feature)" : "";
- ftConfElem.title = `${featKey}${rel}${adv}${extraTxts.length > 0 ? `\n${extraTxts.join(" - ")}` : ""}`;
+ linkElCreated = true;
+ const linkEl = document.createElement("a");
+ linkEl.classList.add("bytm-plugin-list-row-link", "bytm-link");
+ linkEl.href = url;
+ linkEl.tabIndex = 0;
+ linkEl.target = "_blank";
+ linkEl.rel = "noopener noreferrer";
+ linkEl.textContent = linkEl.title = linkEl.ariaLabel = t(`plugin_link_type_${key}`);
+ linksList.appendChild(linkEl);
+ }
+ const rightEl = document.createElement("div");
+ rightEl.classList.add("bytm-plugin-list-row-right");
+ rowEl.appendChild(rightEl);
+ const intentsAmount = Object.keys(PluginIntent).length / 2;
+ const intentsArr = typeof intents === "number" && intents > 0 ? (() => {
+ const arr = [];
+ for (let i = 0; i < intentsAmount; i++)
+ if (intents & (2 ** i))
+ arr.push(2 ** i);
+ return arr;
+ })() : [];
+ const permissionsHeaderEl = document.createElement("div");
+ permissionsHeaderEl.classList.add("bytm-plugin-list-row-permissions-header");
+ permissionsHeaderEl.tabIndex = 0;
+ permissionsHeaderEl.textContent = permissionsHeaderEl.title = permissionsHeaderEl.ariaLabel = t("plugin_list_permissions_header");
+ rightEl.appendChild(permissionsHeaderEl);
+ for (const intent of intentsArr) {
+ const intentEl = document.createElement("div");
+ intentEl.classList.add("bytm-plugin-list-row-intent-item");
+ intentEl.tabIndex = 0;
+ intentEl.textContent = PluginIntent[intent];
+ intentEl.title = intentEl.ariaLabel = t(`plugin_intent_description_${PluginIntent[intent]}`);
+ rightEl.appendChild(intentEl);
+ }
+ listContainerEl.appendChild(rowEl);
+ }
+ return listContainerEl;
+}let verNotifDialog = null;
+/** Creates and/or returns the dialog to be shown when a new version is available */
+async function getVersionNotifDialog({ latestTag, }) {
+ if (!verNotifDialog) {
+ const changelogMdFull = await getChangelogMd();
+ // I messed up because this should be 0 so the changelog will always need to have an extra div at the top for backwards compatibility
+ const changelogMd = changelogMdFull.split("
")[1];
+ const changelogHtml = await parseMarkdown(changelogMd);
+ verNotifDialog = new BytmDialog({
+ id: "version-notif",
+ width: 600,
+ height: 800,
+ closeBtnEnabled: false,
+ closeOnBgClick: false,
+ closeOnEscPress: true,
+ destroyOnClose: true,
+ small: true,
+ renderHeader: renderHeader$1,
+ renderBody: () => renderBody$1({ latestTag, changelogHtml }),
+ });
+ }
+ return verNotifDialog;
+}
+async function renderHeader$1() {
+ const logoEl = document.createElement("img");
+ logoEl.classList.add("bytm-dialog-header-img", "bytm-no-select");
+ logoEl.src = await getResourceUrl(mode === "development" ? "img-logo_dev" : "img-logo");
+ logoEl.alt = "BetterYTM logo";
+ return logoEl;
+}
+let disableUpdateCheck = false;
+async function renderBody$1({ latestTag, changelogHtml, }) {
+ disableUpdateCheck = false;
+ const wrapperEl = document.createElement("div");
+ const pEl = document.createElement("p");
+ pEl.textContent = t("new_version_available", scriptInfo.name, scriptInfo.version, latestTag, platformNames[host]);
+ wrapperEl.appendChild(pEl);
+ const changelogDetailsEl = document.createElement("details");
+ changelogDetailsEl.id = "bytm-version-notif-changelog-details";
+ changelogDetailsEl.open = false;
+ const changelogSummaryEl = document.createElement("summary");
+ changelogSummaryEl.role = "button";
+ changelogSummaryEl.tabIndex = 0;
+ changelogSummaryEl.ariaLabel = changelogSummaryEl.title = changelogSummaryEl.textContent = t("expand_release_notes");
+ changelogDetailsEl.appendChild(changelogSummaryEl);
+ changelogDetailsEl.addEventListener("toggle", () => {
+ changelogSummaryEl.ariaLabel = changelogSummaryEl.title = changelogSummaryEl.textContent = changelogDetailsEl.open ? t("collapse_release_notes") : t("expand_release_notes");
+ });
+ const changelogEl = document.createElement("p");
+ changelogEl.id = "bytm-version-notif-changelog-cont";
+ changelogEl.classList.add("bytm-markdown-container");
+ setInnerHtml(changelogEl, changelogHtml);
+ changelogEl.querySelectorAll("a").forEach((a) => {
+ a.target = "_blank";
+ a.rel = "noopener noreferrer";
+ });
+ changelogDetailsEl.appendChild(changelogEl);
+ wrapperEl.appendChild(changelogDetailsEl);
+ const disableUpdCheckEl = document.createElement("div");
+ disableUpdCheckEl.id = "bytm-disable-update-check-wrapper";
+ if (!getFeature("versionCheck"))
+ disableUpdateCheck = true;
+ const disableToggleEl = await createToggleInput({
+ id: "disable-update-check",
+ initialValue: disableUpdateCheck,
+ labelPos: "off",
+ onChange(checked) {
+ disableUpdateCheck = checked;
+ if (checked)
+ btnClose.textContent = t("close_and_ignore_until_reenabled");
+ else
+ btnClose.textContent = t("close_and_ignore_for_24h");
+ },
+ });
+ const labelWrapperEl = document.createElement("div");
+ labelWrapperEl.classList.add("bytm-disable-update-check-toggle-label-wrapper");
+ const labelEl = document.createElement("label");
+ labelEl.htmlFor = "bytm-toggle-disable-update-check";
+ labelEl.textContent = t("disable_update_check");
+ const secondaryLabelEl = document.createElement("span");
+ secondaryLabelEl.classList.add("bytm-secondary-label");
+ secondaryLabelEl.textContent = t("reenable_in_config_menu");
+ labelWrapperEl.appendChild(labelEl);
+ labelWrapperEl.appendChild(secondaryLabelEl);
+ disableUpdCheckEl.appendChild(disableToggleEl);
+ disableUpdCheckEl.appendChild(labelWrapperEl);
+ wrapperEl.appendChild(disableUpdCheckEl);
+ verNotifDialog === null || verNotifDialog === void 0 ? void 0 : verNotifDialog.on("close", async () => {
+ const config = getFeatures();
+ const recreateCfgMenu = config.versionCheck === disableUpdateCheck;
+ if (config.versionCheck && disableUpdateCheck)
+ config.versionCheck = false;
+ else if (!config.versionCheck && !disableUpdateCheck)
+ config.versionCheck = true;
+ await setFeatures(config);
+ recreateCfgMenu && emitSiteEvent("recreateCfgMenu");
+ });
+ const btnWrapper = document.createElement("div");
+ btnWrapper.id = "bytm-version-notif-dialog-btns";
+ const btnUpdate = document.createElement("button");
+ btnUpdate.classList.add("bytm-btn");
+ btnUpdate.tabIndex = 0;
+ btnUpdate.textContent = t("open_update_page_install_manually", platformNames[host]);
+ onInteraction(btnUpdate, () => {
+ window.open(packageJson.updates[host]);
+ verNotifDialog === null || verNotifDialog === void 0 ? void 0 : verNotifDialog.close();
+ });
+ const btnClose = document.createElement("button");
+ btnClose.classList.add("bytm-btn");
+ btnClose.tabIndex = 0;
+ btnClose.textContent = t("close_and_ignore_for_24h");
+ onInteraction(btnClose, () => verNotifDialog === null || verNotifDialog === void 0 ? void 0 : verNotifDialog.close());
+ btnWrapper.appendChild(btnUpdate);
+ btnWrapper.appendChild(btnClose);
+ wrapperEl.appendChild(btnWrapper);
+ return wrapperEl;
+}//#region create menu
+let isCfgMenuMounted = false;
+let isCfgMenuOpen = false;
+/** Threshold in pixels from the top of the options container that dictates for how long the scroll indicator is shown */
+const scrollIndicatorOffsetThreshold = 50;
+let scrollIndicatorEnabled = true;
+/** Locale at the point of initializing the config menu */
+let initLocale;
+/** Stringified config at the point of initializing the config menu */
+let initConfig$1;
+/** Timeout id for the "copied" text in the hidden value copy button */
+let hiddenCopiedTxtTimeout;
+/**
+ * Adds an element to open the BetterYTM menu
+ * @deprecated to be replaced with new menu - see https://github.com/Sv443/BetterYTM/issues/23
+ */
+async function mountCfgMenu() {
+ var _a, _b, _c, _d, _e;
+ if (isCfgMenuMounted)
+ return;
+ isCfgMenuMounted = true;
+ BytmDialog.initDialogs();
+ initLocale = getFeature("locale");
+ initConfig$1 = getFeatures();
+ const initLangReloadText = t("lang_changed_prompt_reload");
+ //#region bg & container
+ const backgroundElem = document.createElement("div");
+ backgroundElem.id = "bytm-cfg-menu-bg";
+ backgroundElem.classList.add("bytm-menu-bg");
+ backgroundElem.ariaLabel = backgroundElem.title = t("close_menu_tooltip");
+ backgroundElem.style.visibility = "hidden";
+ backgroundElem.style.display = "none";
+ backgroundElem.addEventListener("click", (e) => {
+ var _a;
+ if (isCfgMenuOpen && ((_a = e.target) === null || _a === void 0 ? void 0 : _a.id) === "bytm-cfg-menu-bg")
+ closeCfgMenu(e);
+ });
+ document.body.addEventListener("keydown", (e) => {
+ if (isCfgMenuOpen && e.key === "Escape" && BytmDialog.getCurrentDialogId() === "cfg-menu")
+ closeCfgMenu(e);
+ });
+ const menuContainer = document.createElement("div");
+ menuContainer.ariaLabel = menuContainer.title = ""; // prevent bg title from propagating downwards
+ menuContainer.classList.add("bytm-menu");
+ menuContainer.id = "bytm-cfg-menu";
+ //#region title bar
+ const headerElem = document.createElement("div");
+ headerElem.classList.add("bytm-menu-header");
+ const titleLogoHeaderCont = document.createElement("div");
+ titleLogoHeaderCont.classList.add("bytm-menu-title-logo-header-cont");
+ const titleCont = document.createElement("div");
+ titleCont.classList.add("bytm-menu-titlecont");
+ titleCont.role = "heading";
+ titleCont.ariaLevel = "1";
+ const titleLogoElem = document.createElement("img");
+ const logoSrc = await getResourceUrl(`img-logo${mode === "development" ? "_dev" : ""}`);
+ titleLogoElem.classList.add("bytm-cfg-menu-logo", "bytm-no-select");
+ if (logoSrc)
+ titleLogoElem.src = logoSrc;
+ titleLogoHeaderCont.appendChild(titleLogoElem);
+ const titleElem = document.createElement("h2");
+ titleElem.classList.add("bytm-menu-title");
+ const titleTextElem = document.createElement("div");
+ titleTextElem.textContent = t("config_menu_title", scriptInfo.name);
+ titleElem.appendChild(titleTextElem);
+ const linksCont = document.createElement("div");
+ linksCont.id = "bytm-menu-linkscont";
+ linksCont.role = "navigation";
+ const linkTitlesShort = {
+ github: "GitHub",
+ greasyfork: "GreasyFork",
+ openuserjs: "OpenUserJS",
+ discord: "Discord",
+ };
+ const addLink = (imgSrc, href, title, titleKey) => {
+ const anchorElem = document.createElement("a");
+ anchorElem.classList.add("bytm-menu-link", "bytm-no-select");
+ anchorElem.rel = "noopener noreferrer";
+ anchorElem.href = href;
+ anchorElem.target = "_blank";
+ anchorElem.tabIndex = 0;
+ anchorElem.role = "button";
+ anchorElem.ariaLabel = anchorElem.title = title;
+ const extendedAnchorEl = document.createElement("a");
+ extendedAnchorEl.classList.add("bytm-menu-link", "extended-link", "bytm-no-select");
+ extendedAnchorEl.rel = "noopener noreferrer";
+ extendedAnchorEl.href = href;
+ extendedAnchorEl.target = "_blank";
+ extendedAnchorEl.tabIndex = -1;
+ extendedAnchorEl.textContent = linkTitlesShort[titleKey];
+ extendedAnchorEl.ariaLabel = extendedAnchorEl.title = title;
+ const imgElem = document.createElement("img");
+ imgElem.classList.add("bytm-menu-img");
+ imgElem.src = imgSrc;
+ anchorElem.appendChild(imgElem);
+ anchorElem.appendChild(extendedAnchorEl);
+ linksCont.appendChild(anchorElem);
+ };
+ const links = [
+ ["github", await getResourceUrl("img-github"), scriptInfo.namespace, t("open_github", scriptInfo.name), "github"],
+ ["greasyfork", await getResourceUrl("img-greasyfork"), packageJson.hosts.greasyfork, t("open_greasyfork", scriptInfo.name), "greasyfork"],
+ ["openuserjs", await getResourceUrl("img-openuserjs"), packageJson.hosts.openuserjs, t("open_openuserjs", scriptInfo.name), "openuserjs"],
+ ];
+ const hostLink = links.find(([name]) => name === host);
+ const otherLinks = links.filter(([name]) => name !== host);
+ const reorderedLinks = hostLink ? [hostLink, ...otherLinks] : links;
+ for (const [, ...args] of reorderedLinks)
+ addLink(...args);
+ addLink(await getResourceUrl("img-discord"), "https://dc.sv443.net/", t("open_discord"), "discord");
+ const closeElem = document.createElement("img");
+ closeElem.classList.add("bytm-menu-close");
+ closeElem.role = "button";
+ closeElem.tabIndex = 0;
+ closeElem.src = await getResourceUrl("img-close");
+ closeElem.ariaLabel = closeElem.title = t("close_menu_tooltip");
+ onInteraction(closeElem, closeCfgMenu);
+ titleCont.appendChild(titleElem);
+ titleCont.appendChild(linksCont);
+ titleLogoHeaderCont.appendChild(titleCont);
+ headerElem.appendChild(titleLogoHeaderCont);
+ headerElem.appendChild(closeElem);
+ //#region footer
+ const footerCont = document.createElement("div");
+ footerCont.classList.add("bytm-menu-footer-cont");
+ const reloadFooterCont = document.createElement("div");
+ const reloadFooterEl = document.createElement("div");
+ reloadFooterEl.id = "bytm-menu-footer-reload-hint";
+ reloadFooterEl.classList.add("bytm-menu-footer", "hidden");
+ reloadFooterEl.setAttribute("aria-hidden", "true");
+ reloadFooterEl.textContent = t("reload_hint");
+ reloadFooterEl.role = "alert";
+ reloadFooterEl.ariaLive = "polite";
+ const reloadTxtEl = document.createElement("button");
+ reloadTxtEl.classList.add("bytm-btn");
+ reloadTxtEl.style.marginLeft = "10px";
+ reloadTxtEl.textContent = t("reload_now");
+ reloadTxtEl.ariaLabel = reloadTxtEl.title = t("reload_tooltip");
+ reloadTxtEl.addEventListener("click", () => {
+ closeCfgMenu();
+ disableBeforeUnload();
+ location.reload();
+ });
+ reloadFooterEl.appendChild(reloadTxtEl);
+ reloadFooterCont.appendChild(reloadFooterEl);
+ /** For copying plain when shift-clicking the copy button or when compression is not supported */
+ const exportDataSpecial = () => JSON.stringify({ formatVersion, data: getFeatures() });
+ const exImDlg = new ExImDialog({
+ id: "bytm-config-export-import",
+ width: 800,
+ height: 600,
+ // try to compress the data if possible
+ exportData: async () => await compressionSupported()
+ ? await UserUtils.compress(JSON.stringify({ formatVersion, data: getFeatures() }), compressionFormat, "string")
+ : exportDataSpecial(),
+ exportDataSpecial,
+ async onImport(data) {
+ try {
+ const parsed = await tryToDecompressAndParse(data.trim());
+ log("Trying to import configuration:", parsed);
+ if (!parsed || typeof parsed !== "object")
+ return await showPrompt({ type: "alert", message: t("import_error_invalid") });
+ if (typeof parsed.formatVersion !== "number")
+ return await showPrompt({ type: "alert", message: t("import_error_no_format_version") });
+ if (typeof parsed.data !== "object" || parsed.data === null || Object.keys(parsed.data).length === 0)
+ return await showPrompt({ type: "alert", message: t("import_error_no_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 ? await migRes : migRes;
+ curFmtVer = ver;
+ }
+ catch (err) {
+ error(`Error while running migration function for format version ${fmtVer}:`, err);
+ }
+ }
+ }
+ parsed.formatVersion = curFmtVer;
+ parsed.data = newData;
+ }
+ else if (parsed.formatVersion !== formatVersion)
+ return await showPrompt({ type: "alert", message: t("import_error_wrong_format_version", formatVersion, parsed.formatVersion) });
+ await setFeatures(Object.assign(Object.assign({}, getFeatures()), parsed.data));
+ if (await showPrompt({ type: "confirm", message: t("import_success_confirm_reload") })) {
+ disableBeforeUnload();
+ return location.reload();
+ }
+ exImDlg.unmount();
+ emitSiteEvent("rebuildCfgMenu", parsed.data);
+ }
+ catch (err) {
+ warn("Couldn't import configuration:", err);
+ await showPrompt({ type: "alert", message: t("import_error_invalid") });
+ }
+ },
+ title: () => t("bytm_config_export_import_title"),
+ descImport: () => t("bytm_config_import_desc"),
+ descExport: () => t("bytm_config_export_desc"),
+ });
+ const exportImportBtn = document.createElement("button");
+ exportImportBtn.classList.add("bytm-btn");
+ exportImportBtn.textContent = exportImportBtn.ariaLabel = exportImportBtn.title = t("export_import");
+ onInteraction(exportImportBtn, async () => await exImDlg.open());
+ const buttonsCont = document.createElement("div");
+ buttonsCont.classList.add("bytm-menu-footer-buttons-cont");
+ buttonsCont.appendChild(exportImportBtn);
+ footerCont.appendChild(reloadFooterCont);
+ footerCont.appendChild(buttonsCont);
+ //#region feature list
+ const featuresCont = document.createElement("div");
+ featuresCont.id = "bytm-menu-opts";
+ const onCfgChange = async (key, initialVal, newVal) => {
+ var _a, _b, _c, _d;
+ try {
+ const fmt = (val) => typeof val === "object" ? JSON.stringify(val) : String(val);
+ info(`Feature config changed at key '${key}', from value '${fmt(initialVal)}' to '${fmt(newVal)}'`);
+ const featConf = JSON.parse(JSON.stringify(getFeatures()));
+ featConf[key] = newVal;
+ const changedKeys = initConfig$1 ? Object.keys(featConf).filter((k) => typeof featConf[k] !== "object"
+ && featConf[k] !== initConfig$1[k]) : [];
+ const requiresReload =
+ // @ts-ignore
+ changedKeys.some((k) => { var _a; return ((_a = featInfo[k]) === null || _a === void 0 ? void 0 : _a.reloadRequired) !== false; });
+ await setFeatures(featConf);
+ // @ts-ignore
+ (_b = (_a = featInfo[key]) === null || _a === void 0 ? void 0 : _a.change) === null || _b === void 0 ? void 0 : _b.call(_a, key, initialVal, newVal);
+ if (requiresReload) {
+ reloadFooterEl.classList.remove("hidden");
+ reloadFooterEl.setAttribute("aria-hidden", "false");
+ }
+ else {
+ reloadFooterEl.classList.add("hidden");
+ reloadFooterEl.setAttribute("aria-hidden", "true");
+ }
+ if (initLocale !== featConf.locale) {
+ await initTranslations(featConf.locale);
+ setLocale(featConf.locale);
+ const newText = t("lang_changed_prompt_reload");
+ const newLangEmoji = ((_c = langMapping[featConf.locale]) === null || _c === void 0 ? void 0 : _c.emoji) ? `${langMapping[featConf.locale].emoji}\n` : "";
+ const initLangEmoji = ((_d = langMapping[initLocale]) === null || _d === void 0 ? void 0 : _d.emoji) ? `${langMapping[initLocale].emoji}\n` : "";
+ const confirmText = newText !== initLangReloadText ? `${newLangEmoji}${newText}\n\n\n${initLangEmoji}${initLangReloadText}` : newText;
+ if (await showPrompt({
+ type: "confirm",
+ message: confirmText,
+ confirmBtnText: () => `${t("prompt_confirm")} / ${tl(initLocale, "prompt_confirm")}`,
+ confirmBtnTooltip: () => `${t("click_to_confirm_tooltip")} / ${tl(initLocale, "click_to_confirm_tooltip")}`,
+ denyBtnText: (type) => `${t(type === "alert" ? "prompt_close" : "prompt_cancel")} / ${tl(initLocale, type === "alert" ? "prompt_close" : "prompt_cancel")}`,
+ denyBtnTooltip: (type) => `${t(type === "alert" ? "click_to_close_tooltip" : "click_to_cancel_tooltip")} / ${tl(initLocale, type === "alert" ? "click_to_close_tooltip" : "click_to_cancel_tooltip")}`,
+ })) {
+ closeCfgMenu();
+ disableBeforeUnload();
+ location.reload();
+ }
+ }
+ else if (getLocale() !== featConf.locale)
+ setLocale(featConf.locale);
+ }
+ catch (err) {
+ error("Error while reacting to config change:", err);
+ }
+ finally {
+ emitSiteEvent("configOptionChanged", key, initialVal, newVal);
+ }
+ };
+ /** Call whenever the feature config is changed */
+ const confChanged = UserUtils.debounce(onCfgChange, 333, "falling");
+ const featureCfg = getFeatures();
+ const featureCfgWithCategories = Object.entries(featInfo)
+ .reduce((acc, [key, { category }]) => {
+ if (!acc[category])
+ acc[category] = {};
+ acc[category][key] = featureCfg[key];
+ return acc;
+ }, {});
+ /**
+ * Formats the value `v` based on the provided `key` using the `featInfo` object.
+ * If a custom `renderValue` function is defined for the `key`, it will be used to format the value.
+ * If no custom `renderValue` function is defined, the value will be converted to a string and trimmed.
+ * If the value is an object, it will be converted to a JSON string representation.
+ * If an error occurs during formatting (like when passing objects with circular references), the original value will be returned as a string (trimmed).
+ */
+ const fmtVal = (v, key) => {
+ var _a;
+ try {
+ // @ts-ignore
+ const renderValue = typeof ((_a = featInfo === null || featInfo === void 0 ? void 0 : featInfo[key]) === null || _a === void 0 ? void 0 : _a.renderValue) === "function" ? featInfo[key].renderValue : undefined;
+ const retVal = (typeof v === "object" ? JSON.stringify(v) : String(v)).trim();
+ return renderValue ? renderValue(retVal) : retVal;
+ }
+ catch (_b) {
+ // absolute last resort fallback because stringify throws on circular refs
+ return String(v).trim();
+ }
+ };
+ 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.tabIndex = 0;
+ catHeaderElem.textContent = `${t(`feature_category_${category}`)}:`;
+ featuresCont.appendChild(catHeaderElem);
+ for (const featKey in featObj) {
+ const ftInfo = featInfo[featKey];
+ if (!ftInfo || ("hidden" in ftInfo && ftInfo.hidden === true))
+ continue;
+ if (ftInfo.advanced && !featureCfg.advancedMode)
+ continue;
+ const { type, default: ftDefault } = ftInfo;
+ const step = "step" in ftInfo ? ftInfo.step : undefined;
+ const val = featureCfg[featKey];
+ const initialVal = (_a = val !== null && val !== void 0 ? val : ftDefault) !== null && _a !== void 0 ? _a : undefined;
+ const ftConfElem = document.createElement("div");
+ ftConfElem.classList.add("bytm-ftitem");
+ {
+ const featLeftSideElem = document.createElement("div");
+ featLeftSideElem.classList.add("bytm-ftitem-leftside");
+ if (getFeature("advancedMode")) {
+ const defVal = fmtVal(ftDefault, featKey);
+ const extraTxts = [
+ `default: ${defVal.length === 0 ? "(undefined)" : defVal}`,
+ ];
+ "min" in ftInfo && extraTxts.push(`min: ${ftInfo.min}`);
+ "max" in ftInfo && extraTxts.push(`max: ${ftInfo.max}`);
+ "step" in ftInfo && extraTxts.push(`step: ${ftInfo.step}`);
+ const rel = "reloadRequired" in ftInfo && ftInfo.reloadRequired !== false ? " (reload required)" : "";
+ const adv = ftInfo.advanced ? " (advanced feature)" : "";
+ ftConfElem.title = `${featKey}${rel}${adv}${extraTxts.length > 0 ? `\n${extraTxts.join(" - ")}` : ""}`;
+ }
+ const textElem = document.createElement("span");
+ textElem.classList.add("bytm-ftitem-text", "bytm-ellipsis-wrap");
+ textElem.textContent = textElem.title = textElem.ariaLabel = t(`feature_desc_${featKey}`);
+ let adornmentElem;
+ const adornContentAsync = (_b = ftInfo.textAdornment) === null || _b === void 0 ? void 0 : _b.call(ftInfo);
+ const adornContent = adornContentAsync instanceof Promise ? await adornContentAsync : adornContentAsync;
+ if ((typeof adornContentAsync === "string" || adornContentAsync instanceof Promise) && typeof adornContent !== "undefined") {
+ adornmentElem = document.createElement("span");
+ adornmentElem.id = `bytm-ftitem-${featKey}-adornment`;
+ adornmentElem.classList.add("bytm-ftitem-adornment");
+ setInnerHtml(adornmentElem, adornContent);
+ }
+ let helpElem;
+ // @ts-ignore
+ const hasHelpTextFunc = typeof ((_c = featInfo[featKey]) === null || _c === void 0 ? void 0 : _c.helpText) === "function";
+ // @ts-ignore
+ const helpTextVal = hasHelpTextFunc && featInfo[featKey].helpText();
+ if (hasKey(`feature_helptext_${featKey}`) || (helpTextVal && hasKey(helpTextVal))) {
+ const helpElemImgHtml = await resourceAsString("icon-help");
+ if (helpElemImgHtml) {
+ helpElem = document.createElement("div");
+ helpElem.classList.add("bytm-ftitem-help-btn", "bytm-generic-btn");
+ helpElem.ariaLabel = helpElem.title = t("feature_help_button_tooltip", t(`feature_desc_${featKey}`));
+ helpElem.role = "button";
+ helpElem.tabIndex = 0;
+ setInnerHtml(helpElem, helpElemImgHtml);
+ onInteraction(helpElem, async (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ await (await getFeatHelpDialog({ featKey: featKey })).open();
+ });
}
- const textElem = document.createElement("span");
- textElem.classList.add("bytm-ftitem-text", "bytm-ellipsis-wrap");
- textElem.textContent = textElem.title = textElem.ariaLabel = t(`feature_desc_${featKey}`);
- let adornmentElem;
- const adornContentAsync = (_b = ftInfo.textAdornment) === null || _b === void 0 ? void 0 : _b.call(ftInfo);
- const adornContent = adornContentAsync instanceof Promise ? await adornContentAsync : adornContentAsync;
- if ((typeof adornContentAsync === "string" || adornContentAsync instanceof Promise) && typeof adornContent !== "undefined") {
- adornmentElem = document.createElement("span");
- adornmentElem.id = `bytm-ftitem-${featKey}-adornment`;
- adornmentElem.classList.add("bytm-ftitem-adornment");
- setInnerHtml(adornmentElem, adornContent);
+ else {
+ error(`Couldn't create help button SVG element for feature '${featKey}'`);
}
- let helpElem;
- // @ts-ignore
- const hasHelpTextFunc = typeof ((_c = featInfo[featKey]) === null || _c === void 0 ? void 0 : _c.helpText) === "function";
- // @ts-ignore
- const helpTextVal = hasHelpTextFunc && featInfo[featKey].helpText();
- if (hasKey(`feature_helptext_${featKey}`) || (helpTextVal && hasKey(helpTextVal))) {
- const helpElemImgHtml = await resourceAsString("icon-help");
- if (helpElemImgHtml) {
- helpElem = document.createElement("div");
- helpElem.classList.add("bytm-ftitem-help-btn", "bytm-generic-btn");
- helpElem.ariaLabel = helpElem.title = t("feature_help_button_tooltip", t(`feature_desc_${featKey}`));
- helpElem.role = "button";
- helpElem.tabIndex = 0;
- setInnerHtml(helpElem, helpElemImgHtml);
- onInteraction(helpElem, async (e) => {
- e.preventDefault();
- e.stopPropagation();
- await (await getFeatHelpDialog({ featKey: featKey })).open();
- });
+ }
+ adornmentElem && featLeftSideElem.appendChild(adornmentElem);
+ featLeftSideElem.appendChild(textElem);
+ helpElem && featLeftSideElem.appendChild(helpElem);
+ ftConfElem.appendChild(featLeftSideElem);
+ }
+ {
+ let inputType = "text";
+ let inputTag = "input";
+ switch (type) {
+ case "toggle":
+ inputTag = undefined;
+ inputType = undefined;
+ break;
+ case "slider":
+ inputType = "range";
+ break;
+ case "number":
+ inputType = "number";
+ break;
+ case "text":
+ inputType = "text";
+ break;
+ case "select":
+ inputTag = "select";
+ inputType = undefined;
+ break;
+ case "hotkey":
+ inputTag = undefined;
+ inputType = undefined;
+ break;
+ case "button":
+ inputTag = undefined;
+ inputType = undefined;
+ break;
+ }
+ const inputElemId = `bytm-ftconf-${featKey}-input`;
+ const ctrlElem = document.createElement("span");
+ ctrlElem.classList.add("bytm-ftconf-ctrl");
+ // to prevent dev mode title from propagating:
+ ctrlElem.title = "";
+ let advCopyHiddenCont;
+ if ((getFeature("advancedMode") || mode === "development") && ftInfo.valueHidden) {
+ const advCopyHintElem = document.createElement("span");
+ advCopyHintElem.classList.add("bytm-ftconf-adv-copy-hint");
+ advCopyHintElem.textContent = t("copied");
+ advCopyHintElem.role = "status";
+ advCopyHintElem.style.display = "none";
+ const advCopyHiddenBtn = document.createElement("button");
+ advCopyHiddenBtn.classList.add("bytm-ftconf-adv-copy-btn", "bytm-btn");
+ advCopyHiddenBtn.tabIndex = 0;
+ advCopyHiddenBtn.textContent = t("copy_hidden");
+ advCopyHiddenBtn.ariaLabel = advCopyHiddenBtn.title = t("copy_hidden_tooltip");
+ const copyHiddenInteraction = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ copyToClipboard(getFeatures()[featKey]);
+ advCopyHintElem.style.display = "inline";
+ if (typeof hiddenCopiedTxtTimeout === "undefined") {
+ hiddenCopiedTxtTimeout = setTimeout(() => {
+ advCopyHintElem.style.display = "none";
+ hiddenCopiedTxtTimeout = undefined;
+ }, 3000);
}
- else {
- error(`Couldn't create help button SVG element for feature '${featKey}'`);
+ };
+ onInteraction(advCopyHiddenBtn, copyHiddenInteraction);
+ advCopyHiddenCont = document.createElement("span");
+ advCopyHiddenCont.appendChild(advCopyHintElem);
+ advCopyHiddenCont.appendChild(advCopyHiddenBtn);
+ }
+ advCopyHiddenCont && ctrlElem.appendChild(advCopyHiddenCont);
+ if (inputTag) {
+ // standard input element:
+ const inputElem = document.createElement(inputTag);
+ inputElem.classList.add("bytm-ftconf-input");
+ inputElem.id = inputElemId;
+ inputElem.ariaLabel = t(`feature_desc_${featKey}`);
+ if (inputType)
+ inputElem.type = inputType;
+ if ("min" in ftInfo && typeof ftInfo.min !== "undefined")
+ inputElem.min = String(ftInfo.min);
+ if ("max" in ftInfo && typeof ftInfo.max !== "undefined")
+ inputElem.max = String(ftInfo.max);
+ if (typeof initialVal !== "undefined")
+ inputElem.value = String(initialVal);
+ if (type === "text" && ftInfo.valueHidden) {
+ inputElem.type = "password";
+ inputElem.autocomplete = "off";
+ }
+ if (type === "number" || type === "slider" && step)
+ inputElem.step = String(step);
+ if (type === "toggle" && typeof initialVal !== "undefined")
+ inputElem.checked = Boolean(initialVal);
+ const unitTxt = ("unit" in ftInfo && typeof ftInfo.unit === "string"
+ ? ftInfo.unit
+ : ("unit" in ftInfo && typeof ftInfo.unit === "function"
+ ? ftInfo.unit(Number(inputElem.value))
+ : ""));
+ let labelElem;
+ let lastDisplayedVal;
+ if (type === "slider") {
+ labelElem = document.createElement("label");
+ labelElem.classList.add("bytm-ftconf-label", "bytm-slider-label");
+ labelElem.textContent = `${fmtVal(initialVal, featKey)}${unitTxt}`;
+ inputElem.addEventListener("input", () => {
+ if (labelElem && lastDisplayedVal !== inputElem.value) {
+ labelElem.textContent = `${fmtVal(inputElem.value, featKey)}${unitTxt}`;
+ lastDisplayedVal = inputElem.value;
+ }
+ });
+ }
+ else if (type === "select") {
+ const ftOpts = typeof ftInfo.options === "function"
+ ? ftInfo.options()
+ : ftInfo.options;
+ for (const { value, label } of ftOpts) {
+ const optionElem = document.createElement("option");
+ optionElem.value = String(value);
+ optionElem.textContent = label;
+ if (value === initialVal)
+ optionElem.selected = true;
+ inputElem.appendChild(optionElem);
}
}
- adornmentElem && featLeftSideElem.appendChild(adornmentElem);
- featLeftSideElem.appendChild(textElem);
- helpElem && featLeftSideElem.appendChild(helpElem);
- ftConfElem.appendChild(featLeftSideElem);
+ if (type === "text") {
+ let lastValue = inputElem.value && inputElem.value.length > 0 ? inputElem.value : ftInfo.default;
+ const textInputUpdate = () => {
+ let v = String(inputElem.value).trim();
+ if (type === "text" && ftInfo.normalize)
+ v = inputElem.value = ftInfo.normalize(String(v));
+ if (v === lastValue)
+ return;
+ lastValue = v;
+ if (v === "")
+ v = ftInfo.default;
+ if (typeof initialVal !== "undefined")
+ confChanged(featKey, initialVal, v);
+ };
+ const unsub = siteEvents.on("cfgMenuClosed", () => {
+ unsub();
+ textInputUpdate();
+ });
+ inputElem.addEventListener("blur", () => textInputUpdate());
+ inputElem.addEventListener("keydown", (e) => e.key === "Tab" && textInputUpdate());
+ }
+ else {
+ inputElem.addEventListener("input", () => {
+ let v = String(inputElem.value).trim();
+ if (["number", "slider"].includes(type) || v.match(/^-?\d+$/))
+ v = Number(v);
+ if (typeof initialVal !== "undefined")
+ confChanged(featKey, initialVal, (type !== "toggle" ? v : inputElem.checked));
+ });
+ }
+ if (labelElem) {
+ labelElem.id = `bytm-ftconf-${featKey}-label`;
+ labelElem.htmlFor = inputElemId;
+ ctrlElem.appendChild(labelElem);
+ }
+ ctrlElem.appendChild(inputElem);
}
- {
- let inputType = "text";
- let inputTag = "input";
+ else {
+ // custom input element:
+ let customInputEl;
switch (type) {
- case "toggle":
- inputTag = undefined;
- inputType = undefined;
- break;
- case "slider":
- inputType = "range";
- break;
- case "number":
- inputType = "number";
- break;
- case "text":
- inputType = "text";
- break;
- case "select":
- inputTag = "select";
- inputType = undefined;
- break;
case "hotkey":
- inputTag = undefined;
- inputType = undefined;
- break;
- case "button":
- inputTag = undefined;
- inputType = undefined;
+ customInputEl = createHotkeyInput({
+ initialValue: typeof initialVal === "object" ? initialVal : undefined,
+ onChange: (hotkey) => confChanged(featKey, initialVal, hotkey),
+ createTitle: (value) => t("hotkey_input_click_to_change_tooltip", t(`feature_desc_${featKey}`), value),
+ });
break;
- }
- const inputElemId = `bytm-ftconf-${featKey}-input`;
- const ctrlElem = document.createElement("span");
- ctrlElem.classList.add("bytm-ftconf-ctrl");
- // to prevent dev mode title from propagating:
- ctrlElem.title = "";
- let advCopyHiddenCont;
- if ((getFeature("advancedMode") || mode === "development") && ftInfo.valueHidden) {
- const advCopyHintElem = document.createElement("span");
- advCopyHintElem.classList.add("bytm-ftconf-adv-copy-hint");
- advCopyHintElem.textContent = t("copied");
- advCopyHintElem.role = "status";
- advCopyHintElem.style.display = "none";
- const advCopyHiddenBtn = document.createElement("button");
- advCopyHiddenBtn.classList.add("bytm-ftconf-adv-copy-btn", "bytm-btn");
- advCopyHiddenBtn.tabIndex = 0;
- advCopyHiddenBtn.textContent = t("copy_hidden");
- advCopyHiddenBtn.ariaLabel = advCopyHiddenBtn.title = t("copy_hidden_tooltip");
- const copyHiddenInteraction = (e) => {
- e.preventDefault();
- e.stopPropagation();
- copyToClipboard(getFeatures()[featKey]);
- advCopyHintElem.style.display = "inline";
- if (typeof hiddenCopiedTxtTimeout === "undefined") {
- hiddenCopiedTxtTimeout = setTimeout(() => {
- advCopyHintElem.style.display = "none";
- hiddenCopiedTxtTimeout = undefined;
- }, 3000);
- }
- };
- onInteraction(advCopyHiddenBtn, copyHiddenInteraction);
- advCopyHiddenCont = document.createElement("span");
- advCopyHiddenCont.appendChild(advCopyHintElem);
- advCopyHiddenCont.appendChild(advCopyHiddenBtn);
- }
- advCopyHiddenCont && ctrlElem.appendChild(advCopyHiddenCont);
- if (inputTag) {
- // standard input element:
- const inputElem = document.createElement(inputTag);
- inputElem.classList.add("bytm-ftconf-input");
- inputElem.id = inputElemId;
- inputElem.ariaLabel = t(`feature_desc_${featKey}`);
- if (inputType)
- inputElem.type = inputType;
- if ("min" in ftInfo && typeof ftInfo.min !== "undefined")
- inputElem.min = String(ftInfo.min);
- if ("max" in ftInfo && typeof ftInfo.max !== "undefined")
- inputElem.max = String(ftInfo.max);
- if (typeof initialVal !== "undefined")
- inputElem.value = String(initialVal);
- if (type === "text" && ftInfo.valueHidden) {
- inputElem.type = "password";
- inputElem.autocomplete = "off";
- }
- if (type === "number" || type === "slider" && step)
- inputElem.step = String(step);
- if (type === "toggle" && typeof initialVal !== "undefined")
- inputElem.checked = Boolean(initialVal);
- const unitTxt = ("unit" in ftInfo && typeof ftInfo.unit === "string"
- ? ftInfo.unit
- : ("unit" in ftInfo && typeof ftInfo.unit === "function"
- ? ftInfo.unit(Number(inputElem.value))
- : ""));
- let labelElem;
- let lastDisplayedVal;
- if (type === "slider") {
- labelElem = document.createElement("label");
- labelElem.classList.add("bytm-ftconf-label", "bytm-slider-label");
- labelElem.textContent = `${fmtVal(initialVal, featKey)}${unitTxt}`;
- inputElem.addEventListener("input", () => {
- if (labelElem && lastDisplayedVal !== inputElem.value) {
- labelElem.textContent = `${fmtVal(inputElem.value, featKey)}${unitTxt}`;
- lastDisplayedVal = inputElem.value;
- }
+ case "toggle":
+ customInputEl = await createToggleInput({
+ initialValue: Boolean(initialVal),
+ onChange: (checked) => confChanged(featKey, initialVal, checked),
+ id: `ftconf-${featKey}`,
+ labelPos: "left",
});
- }
- else if (type === "select") {
- const ftOpts = typeof ftInfo.options === "function"
- ? ftInfo.options()
- : ftInfo.options;
- for (const { value, label } of ftOpts) {
- const optionElem = document.createElement("option");
- optionElem.value = String(value);
- optionElem.textContent = label;
- if (value === initialVal)
- optionElem.selected = true;
- inputElem.appendChild(optionElem);
- }
- }
- if (type === "text") {
- let lastValue = inputElem.value && inputElem.value.length > 0 ? inputElem.value : ftInfo.default;
- const textInputUpdate = () => {
- let v = String(inputElem.value).trim();
- if (type === "text" && ftInfo.normalize)
- v = inputElem.value = ftInfo.normalize(String(v));
- if (v === lastValue)
+ break;
+ case "button":
+ customInputEl = document.createElement("button");
+ customInputEl.classList.add("bytm-btn");
+ customInputEl.tabIndex = 0;
+ customInputEl.textContent = hasKey(`feature_btn_${featKey}`) ? t(`feature_btn_${featKey}`) : t("trigger_btn_action");
+ customInputEl.ariaLabel = customInputEl.title = t(`feature_desc_${featKey}`);
+ onInteraction(customInputEl, async () => {
+ if (customInputEl.disabled)
return;
- lastValue = v;
- if (v === "")
- v = ftInfo.default;
- if (typeof initialVal !== "undefined")
- confChanged(featKey, initialVal, v);
- };
- const unsub = siteEvents.on("cfgMenuClosed", () => {
- unsub();
- textInputUpdate();
+ const startTs = Date.now();
+ const res = ftInfo.click();
+ customInputEl.disabled = true;
+ customInputEl.classList.add("bytm-busy");
+ customInputEl.textContent = hasKey(`feature_btn_${featKey}_running`) ? t(`feature_btn_${featKey}_running`) : t("trigger_btn_action_running");
+ if (res instanceof Promise)
+ await res;
+ const finalize = () => {
+ customInputEl.disabled = false;
+ customInputEl.classList.remove("bytm-busy");
+ customInputEl.textContent = hasKey(`feature_btn_${featKey}`) ? t(`feature_btn_${featKey}`) : t("trigger_btn_action");
+ };
+ // artificial timeout ftw
+ if (Date.now() - startTs < 350)
+ setTimeout(finalize, 350 - (Date.now() - startTs));
+ else
+ finalize();
});
- inputElem.addEventListener("blur", () => textInputUpdate());
- inputElem.addEventListener("keydown", (e) => e.key === "Tab" && textInputUpdate());
- }
- else {
- inputElem.addEventListener("input", () => {
- let v = String(inputElem.value).trim();
- if (["number", "slider"].includes(type) || v.match(/^-?\d+$/))
- v = Number(v);
- if (typeof initialVal !== "undefined")
- confChanged(featKey, initialVal, (type !== "toggle" ? v : inputElem.checked));
- });
- }
- if (labelElem) {
- labelElem.id = `bytm-ftconf-${featKey}-label`;
- labelElem.htmlFor = inputElemId;
- ctrlElem.appendChild(labelElem);
- }
- ctrlElem.appendChild(inputElem);
- }
- else {
- // custom input element:
- let customInputEl;
- switch (type) {
- case "hotkey":
- customInputEl = createHotkeyInput({
- initialValue: typeof initialVal === "object" ? initialVal : undefined,
- onChange: (hotkey) => confChanged(featKey, initialVal, hotkey),
- createTitle: (value) => t("hotkey_input_click_to_change_tooltip", t(`feature_desc_${featKey}`), value),
- });
- break;
- case "toggle":
- customInputEl = await createToggleInput({
- initialValue: Boolean(initialVal),
- onChange: (checked) => confChanged(featKey, initialVal, checked),
- id: `ftconf-${featKey}`,
- labelPos: "left",
- });
- break;
- case "button":
- customInputEl = document.createElement("button");
- customInputEl.classList.add("bytm-btn");
- customInputEl.tabIndex = 0;
- customInputEl.textContent = hasKey(`feature_btn_${featKey}`) ? t(`feature_btn_${featKey}`) : t("trigger_btn_action");
- customInputEl.ariaLabel = customInputEl.title = t(`feature_desc_${featKey}`);
- onInteraction(customInputEl, async () => {
- if (customInputEl.disabled)
- return;
- const startTs = Date.now();
- const res = ftInfo.click();
- customInputEl.disabled = true;
- customInputEl.classList.add("bytm-busy");
- customInputEl.textContent = hasKey(`feature_btn_${featKey}_running`) ? t(`feature_btn_${featKey}_running`) : t("trigger_btn_action_running");
- if (res instanceof Promise)
- await res;
- const finalize = () => {
- customInputEl.disabled = false;
- customInputEl.classList.remove("bytm-busy");
- customInputEl.textContent = hasKey(`feature_btn_${featKey}`) ? t(`feature_btn_${featKey}`) : t("trigger_btn_action");
- };
- // artificial timeout ftw
- if (Date.now() - startTs < 350)
- setTimeout(finalize, 350 - (Date.now() - startTs));
- else
- finalize();
- });
- break;
- }
- if (customInputEl && !customInputEl.hasAttribute("aria-label"))
- customInputEl.ariaLabel = t(`feature_desc_${featKey}`);
- ctrlElem.appendChild(customInputEl);
+ break;
}
- ftConfElem.appendChild(ctrlElem);
+ if (customInputEl && !customInputEl.hasAttribute("aria-label"))
+ customInputEl.ariaLabel = t(`feature_desc_${featKey}`);
+ ctrlElem.appendChild(customInputEl);
}
- featuresCont.appendChild(ftConfElem);
+ ftConfElem.appendChild(ctrlElem);
}
+ featuresCont.appendChild(ftConfElem);
}
- //#region reset inputs on external change
- 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;
- const unitTxt = ("unit" in ftInfo && typeof ftInfo.unit === "string"
- ? ftInfo.unit
- : ("unit" in ftInfo && typeof ftInfo.unit === "function"
- ? ftInfo.unit(Number(ftElem.value))
- : ""));
- if (ftInfo.type === "slider")
- labelElem.textContent = `${fmtVal(Number(value), ftKey)}${unitTxt}`;
- }
- info("Rebuilt config menu");
- });
- //#region scroll indicator
- const scrollIndicator = document.createElement("img");
- scrollIndicator.id = "bytm-menu-scroll-indicator";
- scrollIndicator.src = await getResourceUrl("icon-arrow_down");
- scrollIndicator.role = "button";
- scrollIndicator.ariaLabel = scrollIndicator.title = t("scroll_to_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);
- //#region finalize
- menuContainer.appendChild(headerElem);
- menuContainer.appendChild(featuresCont);
- const subtitleElemCont = document.createElement("div");
- subtitleElemCont.id = "bytm-menu-subtitle-cont";
- subtitleElemCont.classList.add("bytm-ellipsis");
- const versionEl = document.createElement("a");
- versionEl.id = "bytm-menu-version-anchor";
- versionEl.classList.add("bytm-link", "bytm-ellipsis");
- versionEl.role = "button";
- versionEl.tabIndex = 0;
- versionEl.ariaLabel = versionEl.title = t("version_tooltip", scriptInfo.version, buildNumber);
- versionEl.textContent = `v${scriptInfo.version} (#${buildNumber})`;
- onInteraction(versionEl, async (e) => {
- e.preventDefault();
- e.stopPropagation();
- const dlg = await getChangelogDialog();
- dlg.on("close", openCfgMenu);
- await dlg.mount();
- closeCfgMenu(undefined, false);
- await dlg.open();
+ }
+ //#region reset inputs on external change
+ 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;
+ const unitTxt = ("unit" in ftInfo && typeof ftInfo.unit === "string"
+ ? ftInfo.unit
+ : ("unit" in ftInfo && typeof ftInfo.unit === "function"
+ ? ftInfo.unit(Number(ftElem.value))
+ : ""));
+ if (ftInfo.type === "slider")
+ labelElem.textContent = `${fmtVal(Number(value), ftKey)}${unitTxt}`;
+ }
+ info("Rebuilt config menu");
+ });
+ //#region scroll indicator
+ const scrollIndicator = document.createElement("img");
+ scrollIndicator.id = "bytm-menu-scroll-indicator";
+ scrollIndicator.src = await getResourceUrl("icon-arrow_down");
+ scrollIndicator.role = "button";
+ scrollIndicator.ariaLabel = scrollIndicator.title = t("scroll_to_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",
});
- subtitleElemCont.appendChild(versionEl);
- titleElem.appendChild(subtitleElemCont);
- const modeItems = [];
- mode === "development" && modeItems.push("dev_mode");
- getFeature("advancedMode") && modeItems.push("advanced_mode");
- if (modeItems.length > 0) {
- const modeDisplayEl = document.createElement("span");
- modeDisplayEl.id = "bytm-menu-mode-display";
- modeDisplayEl.classList.add("bytm-ellipsis");
- modeDisplayEl.textContent = `[${t("active_mode_display", arrayWithSeparators(modeItems.map(v => t(`${v}_short`)), ", ", " & "))}]`;
- modeDisplayEl.ariaLabel = modeDisplayEl.title = tp("active_mode_tooltip", modeItems, arrayWithSeparators(modeItems.map(t), ", ", " & "));
- subtitleElemCont.appendChild(modeDisplayEl);
+ });
+ 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");
}
- menuContainer.appendChild(footerCont);
- backgroundElem.appendChild(menuContainer);
- ((_d = document.querySelector("#bytm-dialog-container")) !== null && _d !== void 0 ? _d : document.body).appendChild(backgroundElem);
- window.addEventListener("resize", UserUtils.debounce(checkToggleScrollIndicator, 250, "rising"));
- log("Added menu element");
- // ensure stuff is reset if menu was opened before being added
- isCfgMenuOpen = false;
+ });
+ const bottomAnchor = document.createElement("div");
+ bottomAnchor.id = "bytm-menu-bottom-anchor";
+ featuresCont.appendChild(bottomAnchor);
+ //#region finalize
+ menuContainer.appendChild(headerElem);
+ menuContainer.appendChild(featuresCont);
+ const subtitleElemCont = document.createElement("div");
+ subtitleElemCont.id = "bytm-menu-subtitle-cont";
+ subtitleElemCont.classList.add("bytm-ellipsis");
+ const versionEl = document.createElement("a");
+ versionEl.id = "bytm-menu-version-anchor";
+ versionEl.classList.add("bytm-link", "bytm-ellipsis");
+ versionEl.role = "button";
+ versionEl.tabIndex = 0;
+ versionEl.ariaLabel = versionEl.title = t("version_tooltip", scriptInfo.version, buildNumber);
+ versionEl.textContent = `v${scriptInfo.version} (#${buildNumber})`;
+ onInteraction(versionEl, async (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ const dlg = await getChangelogDialog();
+ dlg.on("close", openCfgMenu);
+ await dlg.mount();
+ closeCfgMenu(undefined, false);
+ await dlg.open();
+ });
+ subtitleElemCont.appendChild(versionEl);
+ titleElem.appendChild(subtitleElemCont);
+ const modeItems = [];
+ mode === "development" && modeItems.push("dev_mode");
+ getFeature("advancedMode") && modeItems.push("advanced_mode");
+ if (modeItems.length > 0) {
+ const modeDisplayEl = document.createElement("span");
+ modeDisplayEl.id = "bytm-menu-mode-display";
+ modeDisplayEl.classList.add("bytm-ellipsis");
+ modeDisplayEl.textContent = `[${t("active_mode_display", arrayWithSeparators(modeItems.map(v => t(`${v}_short`)), ", ", " & "))}]`;
+ modeDisplayEl.ariaLabel = modeDisplayEl.title = tp("active_mode_tooltip", modeItems, arrayWithSeparators(modeItems.map(t), ", ", " & "));
+ subtitleElemCont.appendChild(modeDisplayEl);
+ }
+ menuContainer.appendChild(footerCont);
+ backgroundElem.appendChild(menuContainer);
+ ((_d = document.querySelector("#bytm-dialog-container")) !== null && _d !== void 0 ? _d : document.body).appendChild(backgroundElem);
+ window.addEventListener("resize", UserUtils.debounce(checkToggleScrollIndicator, 250, "rising"));
+ log("Added menu element");
+ // ensure stuff is reset if menu was opened before being added
+ isCfgMenuOpen = false;
+ document.body.classList.remove("bytm-disable-scroll");
+ (_e = document.querySelector(getDomain() === "ytm" ? "ytmusic-app" : "ytd-app")) === null || _e === void 0 ? void 0 : _e.removeAttribute("inert");
+ backgroundElem.style.visibility = "hidden";
+ backgroundElem.style.display = "none";
+ siteEvents.on("recreateCfgMenu", async () => {
+ const bgElem = document.querySelector("#bytm-cfg-menu-bg");
+ if (!bgElem)
+ return;
+ closeCfgMenu();
+ bgElem.remove();
+ isCfgMenuMounted = false;
+ await mountCfgMenu();
+ await openCfgMenu();
+ });
+}
+//#region open & close
+/** Closes the config menu if it is open. If a bubbling event is passed, its propagation will be prevented. */
+function closeCfgMenu(evt, enableScroll = true) {
+ var _a, _b, _c;
+ if (!isCfgMenuOpen)
+ return;
+ isCfgMenuOpen = false;
+ (evt === null || evt === void 0 ? void 0 : evt.bubbles) && evt.stopPropagation();
+ if (enableScroll) {
document.body.classList.remove("bytm-disable-scroll");
- (_e = document.querySelector(getDomain() === "ytm" ? "ytmusic-app" : "ytd-app")) === null || _e === void 0 ? void 0 : _e.removeAttribute("inert");
- backgroundElem.style.visibility = "hidden";
- backgroundElem.style.display = "none";
- siteEvents.on("recreateCfgMenu", async () => {
- const bgElem = document.querySelector("#bytm-cfg-menu-bg");
- if (!bgElem)
- return;
- closeCfgMenu();
- bgElem.remove();
- isCfgMenuMounted = false;
- await mountCfgMenu();
- await openCfgMenu();
+ (_a = document.querySelector(getDomain() === "ytm" ? "ytmusic-app" : "ytd-app")) === null || _a === void 0 ? void 0 : _a.removeAttribute("inert");
+ }
+ const menuBg = document.querySelector("#bytm-cfg-menu-bg");
+ clearTimeout(hiddenCopiedTxtTimeout);
+ openDialogs.splice(openDialogs.indexOf("cfg-menu"), 1);
+ setCurrentDialogId((_b = openDialogs === null || openDialogs === void 0 ? void 0 : openDialogs[0]) !== null && _b !== void 0 ? _b : null);
+ // since this menu doesn't have a BytmDialog instance, it's undefined here
+ emitInterface("bytm:dialogClosed", undefined);
+ emitInterface("bytm:dialogClosed:cfg-menu", undefined);
+ if (!menuBg)
+ return warn("Couldn't close config menu because background element couldn't be found. The config menu is considered closed but might still be open. In this case please reload the page. If the issue persists, please create an issue on GitHub.");
+ (_c = menuBg.querySelectorAll(".bytm-ftconf-adv-copy-hint")) === null || _c === void 0 ? void 0 : _c.forEach((el) => el.style.display = "none");
+ menuBg.style.visibility = "hidden";
+ menuBg.style.display = "none";
+}
+/** Opens the config menu if it is closed */
+async function openCfgMenu() {
+ var _a;
+ if (!isCfgMenuMounted)
+ await mountCfgMenu();
+ if (isCfgMenuOpen)
+ return;
+ isCfgMenuOpen = true;
+ document.body.classList.add("bytm-disable-scroll");
+ (_a = document.querySelector(getDomain() === "ytm" ? "ytmusic-app" : "ytd-app")) === null || _a === void 0 ? void 0 : _a.setAttribute("inert", "true");
+ const menuBg = document.querySelector("#bytm-cfg-menu-bg");
+ setCurrentDialogId("cfg-menu");
+ openDialogs.unshift("cfg-menu");
+ // since this menu doesn't have a BytmDialog instance, it's undefined here
+ emitInterface("bytm:dialogOpened", undefined);
+ emitInterface("bytm:dialogOpened:cfg-menu", undefined);
+ checkToggleScrollIndicator();
+ if (!menuBg)
+ return warn("Couldn't open config menu because background element couldn't be found. The config menu is considered open but might still be closed. In this case please reload the page. If the issue persists, please create an issue on GitHub.");
+ menuBg.style.visibility = "visible";
+ menuBg.style.display = "block";
+}
+//#region chk scroll indicator
+/** 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 = UserUtils.isScrollable(featuresCont).vertical;
+ /** If true, the indicator's threshold is under the available scrollable space and so it should be disabled */
+ const underThreshold = featuresCont.scrollHeight - featuresCont.clientHeight <= scrollIndicatorOffsetThreshold;
+ if (!underThreshold && verticalScroll && !scrollIndicatorEnabled) {
+ scrollIndicatorEnabled = true;
+ scrollIndicator.classList.remove("bytm-hidden");
+ }
+ if ((!verticalScroll && scrollIndicatorEnabled) || underThreshold) {
+ scrollIndicatorEnabled = false;
+ scrollIndicator.classList.add("bytm-hidden");
+ }
+ }
+}let welcomeDialog = null;
+/** Creates and/or returns the import dialog */
+async function getWelcomeDialog() {
+ if (!welcomeDialog) {
+ welcomeDialog = new BytmDialog({
+ id: "welcome",
+ width: 700,
+ height: 500,
+ closeBtnEnabled: true,
+ closeOnBgClick: true,
+ closeOnEscPress: true,
+ destroyOnClose: true,
+ renderHeader,
+ renderBody,
+ renderFooter,
});
+ welcomeDialog.on("render", retranslateWelcomeMenu);
}
- //#region open & close
- /** Closes the config menu if it is open. If a bubbling event is passed, its propagation will be prevented. */
- function closeCfgMenu(evt, enableScroll = true) {
- var _a, _b, _c;
- if (!isCfgMenuOpen)
- return;
- isCfgMenuOpen = false;
- (evt === null || evt === void 0 ? void 0 : evt.bubbles) && evt.stopPropagation();
- if (enableScroll) {
- document.body.classList.remove("bytm-disable-scroll");
- (_a = document.querySelector(getDomain() === "ytm" ? "ytmusic-app" : "ytd-app")) === null || _a === void 0 ? void 0 : _a.removeAttribute("inert");
+ return welcomeDialog;
+}
+async function renderHeader() {
+ const titleWrapperElem = document.createElement("div");
+ titleWrapperElem.id = "bytm-welcome-menu-title-wrapper";
+ const titleLogoElem = document.createElement("img");
+ titleLogoElem.id = "bytm-welcome-menu-title-logo";
+ titleLogoElem.classList.add("bytm-no-select");
+ titleLogoElem.src = await getResourceUrl(mode === "development" ? "img-logo_dev" : "img-logo");
+ const titleElem = document.createElement("h2");
+ titleElem.id = "bytm-welcome-menu-title";
+ titleElem.classList.add("bytm-dialog-title");
+ titleElem.role = "heading";
+ titleElem.ariaLevel = "1";
+ titleElem.tabIndex = 0;
+ titleWrapperElem.appendChild(titleLogoElem);
+ titleWrapperElem.appendChild(titleElem);
+ return titleWrapperElem;
+}
+async function renderBody() {
+ const contentWrapper = document.createElement("div");
+ contentWrapper.id = "bytm-welcome-menu-content-wrapper";
+ // locale switcher
+ const localeCont = document.createElement("div");
+ localeCont.id = "bytm-welcome-menu-locale-cont";
+ const localeImg = document.createElement("img");
+ localeImg.id = "bytm-welcome-menu-locale-img";
+ localeImg.classList.add("bytm-no-select");
+ localeImg.src = await getResourceUrl("icon-globe");
+ const localeSelectElem = document.createElement("select");
+ localeSelectElem.id = "bytm-welcome-menu-locale-select";
+ for (const [locale, { name }] of Object.entries(langMapping)) {
+ const localeOptionElem = document.createElement("option");
+ localeOptionElem.value = locale;
+ localeOptionElem.textContent = name;
+ localeSelectElem.appendChild(localeOptionElem);
+ }
+ localeSelectElem.value = getFeature("locale");
+ localeSelectElem.addEventListener("change", async () => {
+ const selectedLocale = localeSelectElem.value;
+ const feats = Object.assign({}, getFeatures());
+ feats.locale = selectedLocale;
+ setFeatures(feats);
+ await initTranslations(selectedLocale);
+ setLocale(selectedLocale);
+ retranslateWelcomeMenu();
+ });
+ localeCont.appendChild(localeImg);
+ localeCont.appendChild(localeSelectElem);
+ contentWrapper.appendChild(localeCont);
+ // text
+ const textCont = document.createElement("div");
+ textCont.id = "bytm-welcome-menu-text-cont";
+ const textElem = document.createElement("p");
+ textElem.id = "bytm-welcome-menu-text";
+ const textElems = [];
+ const line1Elem = document.createElement("span");
+ line1Elem.id = "bytm-welcome-text-line1";
+ line1Elem.tabIndex = 0;
+ textElems.push(line1Elem);
+ const br1Elem = document.createElement("br");
+ textElems.push(br1Elem);
+ const line2Elem = document.createElement("span");
+ line2Elem.id = "bytm-welcome-text-line2";
+ line2Elem.tabIndex = 0;
+ textElems.push(line2Elem);
+ const br2Elem = document.createElement("br");
+ textElems.push(br2Elem);
+ const br3Elem = document.createElement("br");
+ textElems.push(br3Elem);
+ const line3Elem = document.createElement("span");
+ line3Elem.id = "bytm-welcome-text-line3";
+ line3Elem.tabIndex = 0;
+ textElems.push(line3Elem);
+ const br4Elem = document.createElement("br");
+ textElems.push(br4Elem);
+ const line4Elem = document.createElement("span");
+ line4Elem.id = "bytm-welcome-text-line4";
+ line4Elem.tabIndex = 0;
+ textElems.push(line4Elem);
+ const br5Elem = document.createElement("br");
+ textElems.push(br5Elem);
+ const br6Elem = document.createElement("br");
+ textElems.push(br6Elem);
+ const line5Elem = document.createElement("span");
+ line5Elem.id = "bytm-welcome-text-line5";
+ line5Elem.tabIndex = 0;
+ textElems.push(line5Elem);
+ textElems.forEach((elem) => textElem.appendChild(elem));
+ textCont.appendChild(textElem);
+ contentWrapper.appendChild(textCont);
+ return contentWrapper;
+}
+/** Retranslates all elements inside the welcome menu */
+function retranslateWelcomeMenu() {
+ const getLink = (href) => {
+ return [`
`, ""];
+ };
+ const changes = {
+ "#bytm-welcome-menu-title": (e) => e.textContent = e.ariaLabel = t("welcome_menu_title", scriptInfo.name),
+ "#bytm-welcome-menu-title-close": (e) => e.ariaLabel = e.title = t("close_menu_tooltip"),
+ "#bytm-welcome-menu-open-cfg": (e) => {
+ e.textContent = e.ariaLabel = t("config_menu");
+ e.ariaLabel = e.title = t("open_config_menu_tooltip");
+ },
+ "#bytm-welcome-menu-open-changelog": (e) => {
+ e.textContent = e.ariaLabel = t("open_changelog");
+ e.ariaLabel = e.title = t("open_changelog_tooltip");
+ },
+ "#bytm-welcome-menu-footer-close": (e) => {
+ e.textContent = e.ariaLabel = t("close");
+ e.ariaLabel = e.title = t("close_menu_tooltip");
+ },
+ "#bytm-welcome-text-line1": (e) => setInnerHtml(e, e.ariaLabel = t("welcome_text_line_1")),
+ "#bytm-welcome-text-line2": (e) => setInnerHtml(e, e.ariaLabel = t("welcome_text_line_2", scriptInfo.name)),
+ "#bytm-welcome-text-line3": (e) => setInnerHtml(e, e.ariaLabel = t("welcome_text_line_3", scriptInfo.name, ...getLink(`${packageJson.hosts.greasyfork}/feedback`), ...getLink(packageJson.hosts.openuserjs))),
+ "#bytm-welcome-text-line4": (e) => setInnerHtml(e, e.ariaLabel = t("welcome_text_line_4", ...getLink(packageJson.funding.url))),
+ "#bytm-welcome-text-line5": (e) => setInnerHtml(e, e.ariaLabel = t("welcome_text_line_5", ...getLink(packageJson.bugs.url))),
+ };
+ for (const [selector, fn] of Object.entries(changes)) {
+ const el = document.querySelector(selector);
+ if (!el) {
+ warn(`Couldn't find element in welcome menu with selector '${selector}'`);
+ continue;
}
- const menuBg = document.querySelector("#bytm-cfg-menu-bg");
- clearTimeout(hiddenCopiedTxtTimeout);
- openDialogs.splice(openDialogs.indexOf("cfg-menu"), 1);
- setCurrentDialogId((_b = openDialogs === null || openDialogs === void 0 ? void 0 : openDialogs[0]) !== null && _b !== void 0 ? _b : null);
- // since this menu doesn't have a BytmDialog instance, it's undefined here
- emitInterface("bytm:dialogClosed", undefined);
- emitInterface("bytm:dialogClosed:cfg-menu", undefined);
- if (!menuBg)
- return warn("Couldn't close config menu because background element couldn't be found. The config menu is considered closed but might still be open. In this case please reload the page. If the issue persists, please create an issue on GitHub.");
- (_c = menuBg.querySelectorAll(".bytm-ftconf-adv-copy-hint")) === null || _c === void 0 ? void 0 : _c.forEach((el) => el.style.display = "none");
- menuBg.style.visibility = "hidden";
- menuBg.style.display = "none";
- }
- /** Opens the config menu if it is closed */
- async function openCfgMenu() {
- var _a;
- if (!isCfgMenuMounted)
- await mountCfgMenu();
- if (isCfgMenuOpen)
+ fn(el);
+ }
+}
+async function renderFooter() {
+ const footerCont = document.createElement("div");
+ footerCont.id = "bytm-welcome-menu-footer-cont";
+ const openCfgElem = document.createElement("button");
+ openCfgElem.id = "bytm-welcome-menu-open-cfg";
+ openCfgElem.classList.add("bytm-btn");
+ openCfgElem.addEventListener("click", () => {
+ welcomeDialog === null || welcomeDialog === void 0 ? void 0 : welcomeDialog.close();
+ openCfgMenu();
+ });
+ const openChangelogElem = document.createElement("button");
+ openChangelogElem.id = "bytm-welcome-menu-open-changelog";
+ openChangelogElem.classList.add("bytm-btn");
+ openChangelogElem.addEventListener("click", async () => {
+ const dlg = await getChangelogDialog();
+ await dlg.mount();
+ welcomeDialog === null || welcomeDialog === void 0 ? void 0 : welcomeDialog.close();
+ await dlg.open();
+ });
+ const closeBtnElem = document.createElement("button");
+ closeBtnElem.id = "bytm-welcome-menu-footer-close";
+ closeBtnElem.classList.add("bytm-btn");
+ closeBtnElem.addEventListener("click", async () => {
+ welcomeDialog === null || welcomeDialog === void 0 ? void 0 : welcomeDialog.close();
+ });
+ const leftButtonsCont = document.createElement("div");
+ leftButtonsCont.id = "bytm-menu-footer-left-buttons-cont";
+ leftButtonsCont.appendChild(openCfgElem);
+ leftButtonsCont.appendChild(openChangelogElem);
+ footerCont.appendChild(leftButtonsCont);
+ footerCont.appendChild(closeBtnElem);
+ return footerCont;
+}const releaseURL = "https://github.com/Sv443/BetterYTM/releases/latest";
+/** Initializes the version check feature */
+async function initVersionCheck() {
+ try {
+ if (getFeature("versionCheck") === false)
+ return info("Version check is disabled");
+ const lastCheck = await GM.getValue("bytm-version-check", 0);
+ if (Date.now() - lastCheck < 1000 * 60 * 60 * 24)
return;
- isCfgMenuOpen = true;
- document.body.classList.add("bytm-disable-scroll");
- (_a = document.querySelector(getDomain() === "ytm" ? "ytmusic-app" : "ytd-app")) === null || _a === void 0 ? void 0 : _a.setAttribute("inert", "true");
- const menuBg = document.querySelector("#bytm-cfg-menu-bg");
- setCurrentDialogId("cfg-menu");
- openDialogs.unshift("cfg-menu");
- // since this menu doesn't have a BytmDialog instance, it's undefined here
- emitInterface("bytm:dialogOpened", undefined);
- emitInterface("bytm:dialogOpened:cfg-menu", undefined);
- checkToggleScrollIndicator();
- if (!menuBg)
- return warn("Couldn't open config menu because background element couldn't be found. The config menu is considered open but might still be closed. In this case please reload the page. If the issue persists, please create an issue on GitHub.");
- menuBg.style.visibility = "visible";
- menuBg.style.display = "block";
- }
- //#region chk scroll indicator
- /** 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 = UserUtils.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");
- }
- }
+ await doVersionCheck(false);
}
-
- let welcomeDialog = null;
- /** Creates and/or returns the import dialog */
- async function getWelcomeDialog() {
- if (!welcomeDialog) {
- welcomeDialog = new BytmDialog({
- id: "welcome",
- width: 700,
- height: 500,
- closeBtnEnabled: true,
- closeOnBgClick: true,
- closeOnEscPress: true,
- destroyOnClose: true,
- renderHeader,
- renderBody,
- renderFooter,
- });
- welcomeDialog.on("render", retranslateWelcomeMenu);
- }
- return welcomeDialog;
- }
- async function renderHeader() {
- const titleWrapperElem = document.createElement("div");
- titleWrapperElem.id = "bytm-welcome-menu-title-wrapper";
- const titleLogoElem = document.createElement("img");
- titleLogoElem.id = "bytm-welcome-menu-title-logo";
- titleLogoElem.classList.add("bytm-no-select");
- titleLogoElem.src = await getResourceUrl(mode === "development" ? "img-logo_dev" : "img-logo");
- const titleElem = document.createElement("h2");
- titleElem.id = "bytm-welcome-menu-title";
- titleElem.classList.add("bytm-dialog-title");
- titleElem.role = "heading";
- titleElem.ariaLevel = "1";
- titleElem.tabIndex = 0;
- titleWrapperElem.appendChild(titleLogoElem);
- titleWrapperElem.appendChild(titleElem);
- return titleWrapperElem;
- }
- async function renderBody() {
- const contentWrapper = document.createElement("div");
- contentWrapper.id = "bytm-welcome-menu-content-wrapper";
- // locale switcher
- const localeCont = document.createElement("div");
- localeCont.id = "bytm-welcome-menu-locale-cont";
- const localeImg = document.createElement("img");
- localeImg.id = "bytm-welcome-menu-locale-img";
- localeImg.classList.add("bytm-no-select");
- localeImg.src = await getResourceUrl("icon-globe");
- const localeSelectElem = document.createElement("select");
- localeSelectElem.id = "bytm-welcome-menu-locale-select";
- for (const [locale, { name }] of Object.entries(langMapping)) {
- const localeOptionElem = document.createElement("option");
- localeOptionElem.value = locale;
- localeOptionElem.textContent = name;
- localeSelectElem.appendChild(localeOptionElem);
- }
- localeSelectElem.value = getFeature("locale");
- localeSelectElem.addEventListener("change", async () => {
- const selectedLocale = localeSelectElem.value;
- const feats = Object.assign({}, getFeatures());
- feats.locale = selectedLocale;
- setFeatures(feats);
- await initTranslations(selectedLocale);
- setLocale(selectedLocale);
- retranslateWelcomeMenu();
- });
- localeCont.appendChild(localeImg);
- localeCont.appendChild(localeSelectElem);
- contentWrapper.appendChild(localeCont);
- // text
- const textCont = document.createElement("div");
- textCont.id = "bytm-welcome-menu-text-cont";
- const textElem = document.createElement("p");
- textElem.id = "bytm-welcome-menu-text";
- const textElems = [];
- const line1Elem = document.createElement("span");
- line1Elem.id = "bytm-welcome-text-line1";
- line1Elem.tabIndex = 0;
- textElems.push(line1Elem);
- const br1Elem = document.createElement("br");
- textElems.push(br1Elem);
- const line2Elem = document.createElement("span");
- line2Elem.id = "bytm-welcome-text-line2";
- line2Elem.tabIndex = 0;
- textElems.push(line2Elem);
- const br2Elem = document.createElement("br");
- textElems.push(br2Elem);
- const br3Elem = document.createElement("br");
- textElems.push(br3Elem);
- const line3Elem = document.createElement("span");
- line3Elem.id = "bytm-welcome-text-line3";
- line3Elem.tabIndex = 0;
- textElems.push(line3Elem);
- const br4Elem = document.createElement("br");
- textElems.push(br4Elem);
- const line4Elem = document.createElement("span");
- line4Elem.id = "bytm-welcome-text-line4";
- line4Elem.tabIndex = 0;
- textElems.push(line4Elem);
- const br5Elem = document.createElement("br");
- textElems.push(br5Elem);
- const br6Elem = document.createElement("br");
- textElems.push(br6Elem);
- const line5Elem = document.createElement("span");
- line5Elem.id = "bytm-welcome-text-line5";
- line5Elem.tabIndex = 0;
- textElems.push(line5Elem);
- textElems.forEach((elem) => textElem.appendChild(elem));
- textCont.appendChild(textElem);
- contentWrapper.appendChild(textCont);
- return contentWrapper;
- }
- /** Retranslates all elements inside the welcome menu */
- function retranslateWelcomeMenu() {
- const getLink = (href) => {
- return [`
`, ""];
- };
- const changes = {
- "#bytm-welcome-menu-title": (e) => e.textContent = e.ariaLabel = t("welcome_menu_title", scriptInfo.name),
- "#bytm-welcome-menu-title-close": (e) => e.ariaLabel = e.title = t("close_menu_tooltip"),
- "#bytm-welcome-menu-open-cfg": (e) => {
- e.textContent = e.ariaLabel = t("config_menu");
- e.ariaLabel = e.title = t("open_config_menu_tooltip");
- },
- "#bytm-welcome-menu-open-changelog": (e) => {
- e.textContent = e.ariaLabel = t("open_changelog");
- e.ariaLabel = e.title = t("open_changelog_tooltip");
- },
- "#bytm-welcome-menu-footer-close": (e) => {
- e.textContent = e.ariaLabel = t("close");
- e.ariaLabel = e.title = t("close_menu_tooltip");
- },
- "#bytm-welcome-text-line1": (e) => setInnerHtml(e, e.ariaLabel = t("welcome_text_line_1")),
- "#bytm-welcome-text-line2": (e) => setInnerHtml(e, e.ariaLabel = t("welcome_text_line_2", scriptInfo.name)),
- "#bytm-welcome-text-line3": (e) => setInnerHtml(e, e.ariaLabel = t("welcome_text_line_3", scriptInfo.name, ...getLink(`${packageJson.hosts.greasyfork}/feedback`), ...getLink(packageJson.hosts.openuserjs))),
- "#bytm-welcome-text-line4": (e) => setInnerHtml(e, e.ariaLabel = t("welcome_text_line_4", ...getLink(packageJson.funding.url))),
- "#bytm-welcome-text-line5": (e) => setInnerHtml(e, e.ariaLabel = t("welcome_text_line_5", ...getLink(packageJson.bugs.url))),
- };
- for (const [selector, fn] of Object.entries(changes)) {
- const el = document.querySelector(selector);
- if (!el) {
- warn(`Couldn't find element in welcome menu with selector '${selector}'`);
- continue;
+ catch (err) {
+ error("Version check failed:", err);
+ }
+}
+/**
+ * Checks for a new version of the script and shows a dialog.
+ * If {@linkcode notifyNoUpdatesFound} is set to true, a dialog is also shown if no updates were found.
+ */
+async function doVersionCheck(notifyNoUpdatesFound = false) {
+ var _a;
+ await GM.setValue("bytm-version-check", Date.now());
+ const res = await sendRequest({
+ method: "GET",
+ url: releaseURL,
+ });
+ // TODO: small dialog for "no update found" message?
+ const noUpdateFound = () => notifyNoUpdatesFound ? showPrompt({ type: "alert", message: t("no_updates_found") }) : undefined;
+ const latestTag = (_a = res.finalUrl.split("/").pop()) === null || _a === void 0 ? void 0 : _a.replace(/[a-zA-Z]/g, "");
+ if (!latestTag)
+ return await noUpdateFound();
+ info("Version check - current version:", scriptInfo.version, "- latest version:", latestTag, LogLevel.Info);
+ if (compareVersions.compare(scriptInfo.version, latestTag, "<")) {
+ const dialog = await getVersionNotifDialog({ latestTag });
+ await dialog.open();
+ return;
+ }
+ return await noUpdateFound();
+}//#region beforeunload popup
+let discardBeforeUnload = false;
+/** Disables the popup before leaving the site */
+function disableBeforeUnload() {
+ discardBeforeUnload = true;
+ info("Disabled 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 */
+async function initBeforeUnloadHook() {
+ try {
+ UserUtils.interceptWindowEvent("beforeunload", () => discardBeforeUnload);
+ }
+ catch (err) {
+ error("Error in beforeunload hook:", err);
+ }
+}
+//#region auto close toasts
+/** Closes toasts after a set amount of time */
+async function initAutoCloseToasts() {
+ const animTimeout = 300;
+ addSelectorListener("popupContainer", "ytmusic-notification-action-renderer", {
+ all: true,
+ continuous: true,
+ listener: async (toastContElems) => {
+ try {
+ for (const toastContElem of toastContElems) {
+ const toastElem = toastContElem.querySelector("tp-yt-paper-toast#toast");
+ if (!toastElem || !toastElem.hasAttribute("allow-click-through"))
+ continue;
+ if (toastElem.classList.contains("bytm-closing"))
+ continue;
+ toastElem.classList.add("bytm-closing");
+ const closeTimeout = Math.max(getFeature("closeToastsTimeout") * 1000 + animTimeout, animTimeout);
+ await UserUtils.pauseFor(closeTimeout);
+ toastElem.classList.remove("paper-toast-open");
+ toastElem.addEventListener("transitionend", () => {
+ toastElem.classList.remove("bytm-closing");
+ toastElem.style.display = "none";
+ clearNode(toastElem);
+ log(`Automatically closed toast after ${getFeature("closeToastsTimeout") * 1000}ms`);
+ }, { once: true });
+ }
}
- fn(el);
- }
+ catch (err) {
+ error("Error in automatic toast closing:", err);
+ }
+ },
+ });
+ log("Initialized automatic toast closing");
+}
+let remVidsCache = [];
+/**
+ * Remembers the time of the last played video and resumes playback from that time.
+ * **Needs to be called *before* DOM is ready!**
+ */
+async function initRememberSongTime() {
+ if (getFeature("rememberSongTimeSites") !== "all" && getFeature("rememberSongTimeSites") !== getDomain())
+ return;
+ const storedDataRaw = await GM.getValue("bytm-rem-songs");
+ if (!storedDataRaw)
+ await GM.setValue("bytm-rem-songs", "[]");
+ try {
+ remVidsCache = JSON.parse(String(storedDataRaw !== null && storedDataRaw !== void 0 ? storedDataRaw : "[]"));
+ }
+ catch (err) {
+ error("Error parsing stored video time data, defaulting to empty cache:", err);
+ await GM.setValue("bytm-rem-songs", "[]");
+ remVidsCache = [];
+ }
+ log(`Initialized video time restoring with ${remVidsCache.length} initial entr${remVidsCache.length === 1 ? "y" : "ies"}`);
+ await remTimeRestoreTime();
+ try {
+ if (!domLoaded)
+ document.addEventListener("DOMContentLoaded", remTimeStartUpdateLoop);
+ else
+ remTimeStartUpdateLoop();
}
- async function renderFooter() {
- const footerCont = document.createElement("div");
- footerCont.id = "bytm-welcome-menu-footer-cont";
- const openCfgElem = document.createElement("button");
- openCfgElem.id = "bytm-welcome-menu-open-cfg";
- openCfgElem.classList.add("bytm-btn");
- openCfgElem.addEventListener("click", () => {
- welcomeDialog === null || welcomeDialog === void 0 ? void 0 : welcomeDialog.close();
- openCfgMenu();
- });
- const openChangelogElem = document.createElement("button");
- openChangelogElem.id = "bytm-welcome-menu-open-changelog";
- openChangelogElem.classList.add("bytm-btn");
- openChangelogElem.addEventListener("click", async () => {
- const dlg = await getChangelogDialog();
- await dlg.mount();
- welcomeDialog === null || welcomeDialog === void 0 ? void 0 : welcomeDialog.close();
- await dlg.open();
- });
- const closeBtnElem = document.createElement("button");
- closeBtnElem.id = "bytm-welcome-menu-footer-close";
- closeBtnElem.classList.add("bytm-btn");
- closeBtnElem.addEventListener("click", async () => {
- welcomeDialog === null || welcomeDialog === void 0 ? void 0 : welcomeDialog.close();
- });
- const leftButtonsCont = document.createElement("div");
- leftButtonsCont.id = "bytm-menu-footer-left-buttons-cont";
- leftButtonsCont.appendChild(openCfgElem);
- leftButtonsCont.appendChild(openChangelogElem);
- footerCont.appendChild(leftButtonsCont);
- footerCont.appendChild(closeBtnElem);
- return footerCont;
+ catch (err) {
+ error("Error in video time remembering update loop:", err);
}
-
- const releaseURL = "https://github.com/Sv443/BetterYTM/releases/latest";
- /** Initializes the version check feature */
- async function initVersionCheck() {
- try {
- if (getFeature("versionCheck") === false)
- return info("Version check is disabled");
- const lastCheck = await GM.getValue("bytm-version-check", 0);
- if (Date.now() - lastCheck < 1000 * 60 * 60 * 24)
+}
+/** Tries to restore the time of the currently playing video */
+async function remTimeRestoreTime() {
+ if (location.pathname.startsWith("/watch")) {
+ const watchID = new URL(location.href).searchParams.get("v");
+ if (!watchID)
+ return;
+ if (initialParams.has("t"))
+ return info("Not restoring song time because the URL has the '&t' parameter", LogLevel.Info);
+ const entry = remVidsCache.find(entry => entry.watchID === watchID);
+ if (entry) {
+ if (Date.now() - entry.updateTimestamp > getFeature("rememberSongTimeDuration") * 1000) {
+ await remTimeDeleteEntry(entry.watchID);
return;
- await doVersionCheck(false);
- }
- catch (err) {
- error("Version check failed:", err);
+ }
+ else if (isNaN(Number(entry.songTime)))
+ return;
+ else {
+ let vidElem;
+ const doRestoreTime = async () => {
+ var _a;
+ if (!vidElem)
+ vidElem = await waitVideoElementReady();
+ const vidRestoreTime = entry.songTime - ((_a = getFeature("rememberSongTimeReduction")) !== null && _a !== void 0 ? _a : 0);
+ vidElem.currentTime = UserUtils.clamp(Math.max(vidRestoreTime, 0), 0, vidElem.duration);
+ await remTimeDeleteEntry(entry.watchID);
+ info(`Restored ${getDomain() === "ytm" ? getCurrentMediaType() : "video"} time to ${Math.floor(vidRestoreTime / 60)}m, ${(vidRestoreTime % 60).toFixed(1)}s`, LogLevel.Info);
+ };
+ if (!domLoaded)
+ document.addEventListener("DOMContentLoaded", doRestoreTime);
+ else
+ doRestoreTime();
+ }
}
}
- /**
- * Checks for a new version of the script and shows a dialog.
- * If {@linkcode notifyNoUpdatesFound} is set to true, a dialog is also shown if no updates were found.
- */
- async function doVersionCheck(notifyNoUpdatesFound = false) {
- var _a;
- await GM.setValue("bytm-version-check", Date.now());
- const res = await sendRequest({
- method: "GET",
- url: releaseURL,
- });
- // TODO: small dialog for "no update found" message?
- const noUpdateFound = () => notifyNoUpdatesFound ? showPrompt({ type: "alert", message: t("no_updates_found") }) : undefined;
- const latestTag = (_a = res.finalUrl.split("/").pop()) === null || _a === void 0 ? void 0 : _a.replace(/[a-zA-Z]/g, "");
- if (!latestTag)
- return await noUpdateFound();
- info("Version check - current version:", scriptInfo.version, "- latest version:", latestTag, LogLevel.Info);
- if (compareVersions.compare(scriptInfo.version, latestTag, "<")) {
- const dialog = await getVersionNotifDialog({ latestTag });
- await dialog.open();
- return;
+}
+let lastSongTime = -1;
+let remVidCheckTimeout;
+/** Only call once as this calls itself after a timeout! - Updates the currently playing video's entry in GM storage */
+async function remTimeStartUpdateLoop() {
+ var _a, _b, _c;
+ if (location.pathname.startsWith("/watch")) {
+ const watchID = getWatchId();
+ const songTime = (_a = await getVideoTime()) !== null && _a !== void 0 ? _a : 0;
+ if (watchID && songTime !== lastSongTime) {
+ lastSongTime = songTime;
+ const paused = (_c = (_b = getVideoElement()) === null || _b === void 0 ? void 0 : _b.paused) !== null && _c !== void 0 ? _c : false;
+ // don't immediately update to reduce race conditions and only update if the video is playing
+ // also it just sounds better if the song starts at the beginning if only a couple seconds have passed
+ if (songTime > getFeature("rememberSongTimeMinPlayTime") && !paused) {
+ const entry = {
+ watchID,
+ songTime,
+ updateTimestamp: Date.now(),
+ };
+ await remTimeUpsertEntry(entry);
+ }
+ // if the song is rewound to the beginning, update the entry accordingly
+ else if (!paused) {
+ const entry = remVidsCache.find(entry => entry.watchID === watchID);
+ if (entry && songTime <= entry.songTime)
+ await remTimeUpsertEntry(Object.assign(Object.assign({}, entry), { songTime, updateTimestamp: Date.now() }));
+ }
}
- return await noUpdateFound();
- }
-
- //#region beforeunload popup
- let beforeUnloadEnabled = true;
- /** Disables the popup before leaving the site */
- function disableBeforeUnload() {
- beforeUnloadEnabled = false;
- info("Disabled 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 */
- async function initBeforeUnloadHook() {
- var _a;
- if (((_a = GM === null || GM === void 0 ? void 0 : GM.info) === null || _a === void 0 ? void 0 : _a.scriptHandler) && GM.info.scriptHandler !== "FireMonkey")
- UserUtils.interceptWindowEvent("beforeunload", () => !beforeUnloadEnabled);
- else
- warn(`Event intercepting is not available in ${GM.info.scriptHandler}, please use a different userscript extension`);
}
- //#region auto close toasts
- /** Closes toasts after a set amount of time */
- async function initAutoCloseToasts() {
- const animTimeout = 300;
- addSelectorListener("popupContainer", "ytmusic-notification-action-renderer", {
- all: true,
- continuous: true,
- listener: async (toastContElems) => {
- try {
- for (const toastContElem of toastContElems) {
- const toastElem = toastContElem.querySelector("tp-yt-paper-toast#toast");
- if (!toastElem || !toastElem.hasAttribute("allow-click-through"))
- continue;
- if (toastElem.classList.contains("bytm-closing"))
- continue;
- toastElem.classList.add("bytm-closing");
- const closeTimeout = Math.max(getFeature("closeToastsTimeout") * 1000 + animTimeout, animTimeout);
- await UserUtils.pauseFor(closeTimeout);
- toastElem.classList.remove("paper-toast-open");
- toastElem.addEventListener("transitionend", () => {
- toastElem.classList.remove("bytm-closing");
- toastElem.style.display = "none";
- clearNode(toastElem);
- log(`Automatically closed toast after ${getFeature("closeToastsTimeout") * 1000}ms`);
- }, { once: true });
- }
- }
- catch (err) {
- error("Error in automatic toast closing:", err);
- }
- },
- });
- log("Initialized automatic toast closing");
+ const expiredEntries = remVidsCache.filter(entry => Date.now() - entry.updateTimestamp > getFeature("rememberSongTimeDuration") * 1000);
+ for (const entry of expiredEntries)
+ await remTimeDeleteEntry(entry.watchID);
+ // for no overlapping calls and better error handling:
+ if (remVidCheckTimeout)
+ clearTimeout(remVidCheckTimeout);
+ remVidCheckTimeout = setTimeout(remTimeStartUpdateLoop, 1000);
+}
+/** Updates an existing or inserts a new entry to be remembered */
+async function remTimeUpsertEntry(data) {
+ const foundIdx = remVidsCache.findIndex(entry => entry.watchID === data.watchID);
+ if (foundIdx >= 0)
+ remVidsCache[foundIdx] = data;
+ else
+ remVidsCache.push(data);
+ await GM.setValue("bytm-rem-songs", JSON.stringify(remVidsCache));
+}
+/** Deletes an entry in the "remember cache" */
+async function remTimeDeleteEntry(watchID) {
+ remVidsCache = [...remVidsCache.filter(entry => entry.watchID !== watchID)];
+ await GM.setValue("bytm-rem-songs", JSON.stringify(remVidsCache));
+}const inputIgnoreTagNames = ["INPUT", "TEXTAREA", "SELECT", "BUTTON", "A"];
+//#region arrow key skip
+async function initArrowKeySkip() {
+ document.addEventListener("keydown", (evt) => {
+ var _a, _b, _c, _d, _e, _f;
+ if (!getFeature("arrowKeySupport"))
+ return;
+ if (!["ArrowLeft", "ArrowRight"].includes(evt.code))
+ return;
+ const allowedClasses = ["bytm-generic-btn", "yt-spec-button-shape-next"];
+ // discard the event when a (text) input is currently active, like when editing a playlist
+ if ((inputIgnoreTagNames.includes((_b = (_a = document.activeElement) === null || _a === void 0 ? void 0 : _a.tagName) !== null && _b !== void 0 ? _b : "") || ["volume-slider"].includes((_d = (_c = document.activeElement) === null || _c === void 0 ? void 0 : _c.id) !== null && _d !== void 0 ? _d : ""))
+ && !allowedClasses.some((cls) => { var _a; return (_a = document.activeElement) === null || _a === void 0 ? void 0 : _a.classList.contains(cls); }))
+ return info(`Captured valid key to skip forward or backward but the current active element is <${(_e = document.activeElement) === null || _e === void 0 ? void 0 : _e.tagName.toLowerCase()}>, so the keypress is ignored`);
+ evt.preventDefault();
+ evt.stopImmediatePropagation();
+ let skipBy = (_f = getFeature("arrowKeySkipBy")) !== null && _f !== void 0 ? _f : featInfo.arrowKeySkipBy.default;
+ if (evt.code === "ArrowLeft")
+ skipBy *= -1;
+ log(`Captured arrow key '${evt.code}' - skipping by ${skipBy} seconds`);
+ const vidElem = getVideoElement();
+ if (vidElem)
+ vidElem.currentTime = UserUtils.clamp(vidElem.currentTime + skipBy, 0, vidElem.duration);
+ });
+ log("Added arrow key press listener");
+}
+//#region site switch
+/** switch sites only if current video time is greater than this value */
+const videoTimeThreshold = 3;
+let siteSwitchEnabled = true;
+/** Initializes the site switch feature */
+async function initSiteSwitch(domain) {
+ document.addEventListener("keydown", (e) => {
+ var _a, _b;
+ if (!getFeature("switchBetweenSites"))
+ return;
+ if (inputIgnoreTagNames.includes((_b = (_a = document.activeElement) === null || _a === void 0 ? void 0 : _a.tagName) !== null && _b !== void 0 ? _b : ""))
+ return;
+ const hk = getFeature("switchSitesHotkey");
+ if (siteSwitchEnabled && e.code === hk.code && e.shiftKey === hk.shift && e.ctrlKey === hk.ctrl && e.altKey === hk.alt)
+ switchSite(domain === "yt" ? "ytm" : "yt");
+ });
+ siteEvents.on("hotkeyInputActive", (state) => {
+ if (!getFeature("switchBetweenSites"))
+ return;
+ siteSwitchEnabled = !state;
+ });
+ log("Initialized site switch listener");
+}
+/** Switches to the other site (between YT and YTM) */
+async function switchSite(newDomain) {
+ try {
+ if (!(["/watch", "/playlist"].some(v => location.pathname.startsWith(v))))
+ return warn("Not on a supported 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 = await getVideoTime(0);
+ log(`Found video time of ${vt} seconds`);
+ const cleanSearch = search.split("&")
+ .filter((param) => !param.match(/^\??(t|time_continue)=/))
+ .join("&");
+ const newSearch = typeof vt === "number" && vt > videoTimeThreshold ?
+ cleanSearch.includes("?")
+ ? `${cleanSearch.startsWith("?")
+ ? cleanSearch
+ : "?" + cleanSearch}&time_continue=${vt}`
+ : `?time_continue=${vt}`
+ : cleanSearch;
+ const newUrl = `https://${subdomain}.youtube.com${pathname}${newSearch}${hash}`;
+ info(`Switching to domain '${newDomain}' at ${newUrl}`);
+ location.assign(newUrl);
}
- let remVidsCache = [];
- /**
- * Remembers the time of the last played video and resumes playback from that time.
- * **Needs to be called *before* DOM is ready!**
- */
- async function initRememberSongTime() {
- if (getFeature("rememberSongTimeSites") !== "all" && getFeature("rememberSongTimeSites") !== getDomain())
+ catch (err) {
+ error("Error while switching site:", err);
+ }
+}
+//#region num keys skip
+const numKeysIgnoreTagNames = [...inputIgnoreTagNames];
+/** Adds the ability to skip to a certain time in the video by pressing a number key (0-9) */
+async function initNumKeysSkip() {
+ document.addEventListener("keydown", (e) => {
+ var _a, _b;
+ if (!getFeature("numKeysSkipToTime"))
return;
- const storedDataRaw = await GM.getValue("bytm-rem-songs");
- if (!storedDataRaw)
- await GM.setValue("bytm-rem-songs", "[]");
- try {
- remVidsCache = JSON.parse(String(storedDataRaw !== null && storedDataRaw !== void 0 ? storedDataRaw : "[]"));
- }
- catch (err) {
- error("Error parsing stored video time data, defaulting to empty cache:", err);
- await GM.setValue("bytm-rem-songs", "[]");
- remVidsCache = [];
- }
- log(`Initialized video time restoring with ${remVidsCache.length} initial entr${remVidsCache.length === 1 ? "y" : "ies"}`);
- await remTimeRestoreTime();
- try {
- if (!domLoaded)
- document.addEventListener("DOMContentLoaded", remTimeStartUpdateLoop);
- else
- remTimeStartUpdateLoop();
- }
- catch (err) {
- error("Error in video time remembering update loop:", err);
+ if (!e.key.trim().match(/^[0-9]$/))
+ return;
+ // discard the event when an unexpected element is currently active or in focus, like when editing a playlist or when the search bar is focused
+ const ignoreElement = numKeysIgnoreTagNames.includes((_b = (_a = document.activeElement) === null || _a === void 0 ? void 0 : _a.tagName) !== null && _b !== void 0 ? _b : "");
+ if ((document.activeElement !== document.body && ignoreElement) || ignoreElement)
+ return info("Captured valid key to skip video to, but ignored it since this element is currently active:", document.activeElement);
+ const vidElem = getVideoElement();
+ if (!vidElem)
+ return warn("Could not find video element, so the keypress is ignored");
+ const newVidTime = vidElem.duration / (10 / Number(e.key));
+ if (!isNaN(newVidTime)) {
+ log(`Captured number key [${e.key}], skipping to ${Math.floor(newVidTime / 60)}m ${(newVidTime % 60).toFixed(1)}s`);
+ vidElem.currentTime = newVidTime;
}
- }
- /** Tries to restore the time of the currently playing video */
- async function remTimeRestoreTime() {
- if (location.pathname.startsWith("/watch")) {
- const watchID = new URL(location.href).searchParams.get("v");
- if (!watchID)
- return;
- if (initialParams.has("t"))
- return info("Not restoring song time because the URL has the '&t' parameter", LogLevel.Info);
- const entry = remVidsCache.find(entry => entry.watchID === watchID);
- if (entry) {
- if (Date.now() - entry.updateTimestamp > getFeature("rememberSongTimeDuration") * 1000) {
- await remTimeDeleteEntry(entry.watchID);
+ });
+ log("Added number key press listener");
+}
+//#region auto-like vids
+let canCompress$1 = false;
+/** DataStore instance for all auto-liked channels */
+const autoLikeStore = new UserUtils.DataStore({
+ id: "bytm-auto-like-channels",
+ formatVersion: 2,
+ defaultData: {
+ channels: [],
+ },
+ encodeData: (data) => canCompress$1 ? UserUtils.compress(data, compressionFormat, "string") : data,
+ decodeData: (data) => canCompress$1 ? UserUtils.decompress(data, compressionFormat, "string") : data,
+ migrations: {
+ // 1 -> 2 (v2.1-pre) - add @ prefix to channel IDs if missing
+ 2: (oldData) => ({
+ channels: oldData.channels.map((ch) => (Object.assign(Object.assign({}, ch), { id: isValidChannelId(ch.id.trim())
+ ? ch.id.trim()
+ : `@${ch.id.trim()}` }))),
+ }),
+ },
+});
+let autoLikeStoreLoaded = false;
+/** Inits the auto-like DataStore instance */
+async function initAutoLikeStore() {
+ if (autoLikeStoreLoaded)
+ return;
+ autoLikeStoreLoaded = true;
+ return autoLikeStore.loadData();
+}
+/** Initializes the auto-like feature */
+async function initAutoLike() {
+ try {
+ canCompress$1 = await compressionSupported();
+ await initAutoLikeStore();
+ //#SECTION ytm
+ if (getDomain() === "ytm") {
+ let timeout;
+ siteEvents.on("songTitleChanged", () => {
+ var _a;
+ const autoLikeTimeoutMs = ((_a = getFeature("autoLikeTimeout")) !== null && _a !== void 0 ? _a : 5) * 1000;
+ timeout && clearTimeout(timeout);
+ const ytmTryAutoLike = () => {
+ const artistEls = document.querySelectorAll("ytmusic-player-bar .content-info-wrapper .subtitle a.yt-formatted-string[href]");
+ const channelIds = [...artistEls].map(a => a.href.split("/").pop()).filter(a => typeof a === "string");
+ const likeChan = autoLikeStore.getData().channels.find((ch) => channelIds.includes(ch.id));
+ if (!likeChan || !likeChan.enabled)
+ return;
+ if (artistEls.length === 0)
+ return error("Couldn't auto-like channel because the artist element couldn't be found");
+ const likeRendererEl = document.querySelector(".middle-controls-buttons ytmusic-like-button-renderer");
+ const likeBtnEl = likeRendererEl === null || likeRendererEl === void 0 ? void 0 : likeRendererEl.querySelector("#button-shape-like button");
+ if (!likeRendererEl || !likeBtnEl)
+ return error("Couldn't auto-like channel because the like button couldn't be found");
+ if (likeRendererEl.getAttribute("like-status") !== "LIKE") {
+ likeBtnEl.click();
+ getFeature("autoLikeShowToast") && showIconToast({
+ message: t(`auto_liked_a_channels_${getCurrentMediaType()}`, likeChan.name),
+ subtitle: t("auto_like_click_to_configure"),
+ icon: "icon-auto_like",
+ onClick: () => getAutoLikeDialog().then((dlg) => dlg.open()),
+ }).catch(e => error("Error while showing auto-like toast:", e));
+ log(`Auto-liked ${getCurrentMediaType()} from channel '${likeChan.name}' (${likeChan.id})`);
+ }
+ };
+ timeout = setTimeout(ytmTryAutoLike, autoLikeTimeoutMs);
+ siteEvents.on("autoLikeChannelsUpdated", () => setTimeout(ytmTryAutoLike, autoLikeTimeoutMs));
+ });
+ const recreateBtn = (headerCont, chanId) => {
+ var _a, _b, _c, _d, _e, _f;
+ const titleCont = headerCont.querySelector("ytd-channel-name #container, yt-dynamic-text-view-model.page-header-view-model-wiz__page-header-title, ytmusic-immersive-header-renderer .ytmusic-immersive-header-renderer yt-formatted-string.title");
+ if (!titleCont)
return;
+ const checkBtn = () => setTimeout(() => {
+ if (!document.querySelector(".bytm-auto-like-toggle-btn"))
+ recreateBtn(headerCont, chanId);
+ }, 250);
+ const chanName = (_b = (_a = titleCont.querySelector("yt-formatted-string, span.yt-core-attributed-string")) === null || _a === void 0 ? void 0 : _a.textContent) !== null && _b !== void 0 ? _b : null;
+ log("Re-rendering auto-like toggle button for channel", chanName, "with ID", chanId);
+ const buttonsCont = headerCont.querySelector(".buttons");
+ if (buttonsCont) {
+ const lastBtn = buttonsCont.querySelector("ytmusic-subscribe-button-renderer");
+ const chanName = (_d = (_c = document.querySelector("ytmusic-immersive-header-renderer .content-container yt-formatted-string[role=\"heading\"]")) === null || _c === void 0 ? void 0 : _c.textContent) !== null && _d !== void 0 ? _d : null;
+ lastBtn && addAutoLikeToggleBtn(lastBtn, chanId, chanName).then(checkBtn);
}
- else if (isNaN(Number(entry.songTime)))
- return;
else {
- let vidElem;
- const doRestoreTime = async () => {
- var _a;
- if (!vidElem)
- vidElem = await waitVideoElementReady();
- const vidRestoreTime = entry.songTime - ((_a = getFeature("rememberSongTimeReduction")) !== null && _a !== void 0 ? _a : 0);
- vidElem.currentTime = UserUtils.clamp(Math.max(vidRestoreTime, 0), 0, vidElem.duration);
- await remTimeDeleteEntry(entry.watchID);
- info(`Restored ${getDomain() === "ytm" ? getCurrentMediaType() : "video"} time to ${Math.floor(vidRestoreTime / 60)}m, ${(vidRestoreTime % 60).toFixed(1)}s`, LogLevel.Info);
+ // some channels don't have a subscribe button and instead only have a "share" button for some bullshit reason
+ const shareBtnEl = headerCont.querySelector("ytmusic-menu-renderer #top-level-buttons yt-button-renderer:last-of-type");
+ const chanName = (_f = (_e = headerCont.querySelector("ytmusic-visual-header-renderer .content-container h2 yt-formatted-string")) === null || _e === void 0 ? void 0 : _e.textContent) !== null && _f !== void 0 ? _f : null;
+ shareBtnEl && chanName && addAutoLikeToggleBtn(shareBtnEl, chanId, chanName).then(checkBtn);
+ }
+ };
+ siteEvents.on("pathChanged", (path) => {
+ if (getFeature("autoLikeChannelToggleBtn") && path.match(/\/channel\/.+/)) {
+ const chanId = getCurrentChannelId();
+ if (!chanId)
+ return error("Couldn't extract channel ID from URL");
+ document.querySelectorAll(".bytm-auto-like-toggle-btn").forEach((btn) => clearNode(btn));
+ addSelectorListener("browseResponse", "ytmusic-browse-response #header.ytmusic-browse-response", {
+ listener: (el) => recreateBtn(el, chanId),
+ });
+ }
+ });
+ }
+ //#SECTION yt
+ else if (getDomain() === "yt") {
+ addStyleFromResource("css-auto_like");
+ let timeout;
+ siteEvents.on("watchIdChanged", () => {
+ var _a;
+ const autoLikeTimeoutMs = ((_a = getFeature("autoLikeTimeout")) !== null && _a !== void 0 ? _a : 5) * 1000;
+ timeout && clearTimeout(timeout);
+ if (!location.pathname.startsWith("/watch"))
+ return;
+ const ytTryAutoLike = () => {
+ addSelectorListener("ytWatchMetadata", "#owner ytd-channel-name yt-formatted-string a", {
+ listener(chanElem) {
+ var _a, _b;
+ const chanElemId = (_b = (_a = chanElem.href.split("/").pop()) === null || _a === void 0 ? void 0 : _a.split("/")[0]) !== null && _b !== void 0 ? _b : null;
+ const likeChan = autoLikeStore.getData().channels.find((ch) => ch.id === chanElemId);
+ if (!likeChan || !likeChan.enabled)
+ return;
+ addSelectorListener("ytWatchMetadata", "#actions ytd-menu-renderer like-button-view-model button", {
+ listener(likeBtn) {
+ if (likeBtn.getAttribute("aria-pressed") !== "true") {
+ likeBtn.click();
+ getFeature("autoLikeShowToast") && showIconToast({
+ message: t("auto_liked_a_channels_video", likeChan.name),
+ subtitle: t("auto_like_click_to_configure"),
+ icon: "icon-auto_like",
+ onClick: () => getAutoLikeDialog().then((dlg) => dlg.open()),
+ }).catch(e => error("Error while showing auto-like toast:", e));
+ log(`Auto-liked video from channel '${likeChan.name}' (${likeChan.id})`);
+ }
+ }
+ });
+ }
+ });
+ };
+ siteEvents.on("autoLikeChannelsUpdated", () => setTimeout(ytTryAutoLike, autoLikeTimeoutMs));
+ timeout = setTimeout(ytTryAutoLike, autoLikeTimeoutMs);
+ });
+ siteEvents.on("pathChanged", (path) => {
+ if (path.match(/(\/?@|\/?channel\/)\S+/)) {
+ const chanId = getCurrentChannelId();
+ if (!chanId)
+ return error("Couldn't extract channel ID from URL");
+ document.querySelectorAll(".bytm-auto-like-toggle-btn").forEach((btn) => clearNode(btn));
+ const recreateBtn = (headerCont) => {
+ var _a, _b;
+ const titleCont = headerCont.querySelector("ytd-channel-name #container, yt-dynamic-text-view-model.page-header-view-model-wiz__page-header-title");
+ if (!titleCont)
+ return;
+ const checkBtn = () => setTimeout(() => {
+ if (!document.querySelector(".bytm-auto-like-toggle-btn"))
+ recreateBtn(headerCont);
+ }, 350);
+ const chanName = (_b = (_a = titleCont.querySelector("yt-formatted-string, span.yt-core-attributed-string")) === null || _a === void 0 ? void 0 : _a.textContent) !== null && _b !== void 0 ? _b : null;
+ log("Re-rendering auto-like toggle button for channel", chanName, "with ID", chanId);
+ const buttonsCont = headerCont.querySelector("#inner-header-container #buttons, yt-flexible-actions-view-model");
+ if (buttonsCont) {
+ addSelectorListener("ytAppHeader", "#channel-header-container #other-buttons, yt-flexible-actions-view-model .yt-flexible-actions-view-model-wiz__action", {
+ listener: (otherBtns) => addAutoLikeToggleBtn(otherBtns, chanId, chanName, ["left-margin", "right-margin"]).then(checkBtn),
+ });
+ }
+ else if (titleCont)
+ addAutoLikeToggleBtn(titleCont, chanId, chanName).then(checkBtn);
};
- if (!domLoaded)
- document.addEventListener("DOMContentLoaded", doRestoreTime);
- else
- doRestoreTime();
+ addSelectorListener("ytAppHeader", "#channel-header-container, #page-header", {
+ listener: recreateBtn,
+ });
}
- }
+ });
}
+ log("Initialized auto-like channels feature");
}
- let lastSongTime = -1;
- let remVidCheckTimeout;
- /** Only call once as this calls itself after a timeout! - Updates the currently playing video's entry in GM storage */
- async function remTimeStartUpdateLoop() {
- var _a, _b, _c;
- if (location.pathname.startsWith("/watch")) {
- const watchID = getWatchId();
- const songTime = (_a = await getVideoTime()) !== null && _a !== void 0 ? _a : 0;
- if (watchID && songTime !== lastSongTime) {
- lastSongTime = songTime;
- const paused = (_c = (_b = getVideoElement()) === null || _b === void 0 ? void 0 : _b.paused) !== null && _c !== void 0 ? _c : false;
- // don't immediately update to reduce race conditions and only update if the video is playing
- // also it just sounds better if the song starts at the beginning if only a couple seconds have passed
- if (songTime > getFeature("rememberSongTimeMinPlayTime") && !paused) {
- const entry = {
- watchID,
- songTime,
- updateTimestamp: Date.now(),
- };
- await remTimeUpsertEntry(entry);
+ catch (err) {
+ error("Error while auto-liking channel:", err);
+ }
+}
+//#SECTION toggle btn
+/** Adds a toggle button to enable or disable auto-liking videos from a channel */
+async function addAutoLikeToggleBtn(siblingEl, channelId, channelName, extraClasses) {
+ var _a;
+ const chan = autoLikeStore.getData().channels.find((ch) => ch.id === channelId);
+ log(`Adding auto-like toggle button for channel with ID '${channelId}' - current state:`, chan);
+ siteEvents.on("autoLikeChannelsUpdated", () => {
+ var _a, _b;
+ const buttonEl = document.querySelector(`.bytm-auto-like-toggle-btn[data-channel-id="${channelId}"]`);
+ if (!buttonEl)
+ return warn("Couldn't find auto-like toggle button for channel ID:", channelId);
+ const enabled = (_b = (_a = autoLikeStore.getData().channels.find((ch) => ch.id === channelId)) === null || _a === void 0 ? void 0 : _a.enabled) !== null && _b !== void 0 ? _b : false;
+ if (enabled)
+ buttonEl.classList.add("toggled");
+ else
+ buttonEl.classList.remove("toggled");
+ });
+ const buttonEl = await createLongBtn({
+ resourceName: `icon-auto_like${(chan === null || chan === void 0 ? void 0 : chan.enabled) ? "_enabled" : ""}`,
+ text: t("auto_like"),
+ title: t(`auto_like_button_tooltip${(chan === null || chan === void 0 ? void 0 : chan.enabled) ? "_enabled" : "_disabled"}`),
+ toggle: true,
+ toggleInitialState: (_a = chan === null || chan === void 0 ? void 0 : chan.enabled) !== null && _a !== void 0 ? _a : false,
+ togglePredicate(e) {
+ e.shiftKey && getAutoLikeDialog().then((dlg) => dlg.open());
+ return !e.shiftKey;
+ },
+ async onToggle(toggled) {
+ var _a;
+ try {
+ await autoLikeStore.loadData();
+ buttonEl.title = buttonEl.ariaLabel = t(`auto_like_button_tooltip${toggled ? "_enabled" : "_disabled"}`);
+ const chanId = sanitizeChannelId((_a = buttonEl.dataset.channelId) !== null && _a !== void 0 ? _a : channelId);
+ const imgEl = buttonEl.querySelector(".bytm-generic-btn-img");
+ const imgHtml = await resourceAsString(`icon-auto_like${toggled ? "_enabled" : ""}`);
+ if (imgEl && imgHtml)
+ setInnerHtml(imgEl, imgHtml);
+ if (autoLikeStore.getData().channels.find((ch) => ch.id === chanId) === undefined) {
+ await autoLikeStore.setData({
+ channels: [
+ ...autoLikeStore.getData().channels,
+ { id: chanId, name: channelName !== null && channelName !== void 0 ? channelName : "", enabled: toggled },
+ ],
+ });
}
- // if the song is rewound to the beginning, update the entry accordingly
- else if (!paused) {
- const entry = remVidsCache.find(entry => entry.watchID === watchID);
- if (entry && songTime <= entry.songTime)
- await remTimeUpsertEntry(Object.assign(Object.assign({}, entry), { songTime, updateTimestamp: Date.now() }));
+ else {
+ await autoLikeStore.setData({
+ channels: autoLikeStore.getData().channels
+ .map((ch) => ch.id === chanId ? Object.assign(Object.assign({}, ch), { enabled: toggled }) : ch),
+ });
}
+ emitSiteEvent("autoLikeChannelsUpdated");
+ showIconToast({
+ message: toggled ? t("auto_like_enabled_toast") : t("auto_like_disabled_toast"),
+ subtitle: t("auto_like_click_to_configure"),
+ icon: `icon-auto_like${toggled ? "_enabled" : ""}`,
+ onClick: () => getAutoLikeDialog().then((dlg) => dlg.open()),
+ }).catch(e => error("Error while showing auto-like toast:", e));
+ log(`Toggled auto-like for channel '${channelName}' (ID: '${chanId}') to ${toggled ? "enabled" : "disabled"}`);
+ }
+ catch (err) {
+ error("Error while toggling auto-like channel:", err);
}
}
- const expiredEntries = remVidsCache.filter(entry => Date.now() - entry.updateTimestamp > getFeature("rememberSongTimeDuration") * 1000);
- for (const entry of expiredEntries)
- await remTimeDeleteEntry(entry.watchID);
- // for no overlapping calls and better error handling:
- if (remVidCheckTimeout)
- clearTimeout(remVidCheckTimeout);
- remVidCheckTimeout = setTimeout(remTimeStartUpdateLoop, 1000);
- }
- /** Updates an existing or inserts a new entry to be remembered */
- async function remTimeUpsertEntry(data) {
- const foundIdx = remVidsCache.findIndex(entry => entry.watchID === data.watchID);
- if (foundIdx >= 0)
- remVidsCache[foundIdx] = data;
+ });
+ buttonEl.classList.add(...["bytm-auto-like-toggle-btn", ...(extraClasses !== null && extraClasses !== void 0 ? extraClasses : [])]);
+ buttonEl.dataset.channelId = channelId;
+ siblingEl.insertAdjacentElement("afterend", createRipple(buttonEl));
+ siteEvents.on("autoLikeChannelsUpdated", async () => {
+ var _a, _b;
+ const buttonEl = document.querySelector(`.bytm-auto-like-toggle-btn[data-channel-id="${channelId}"]`);
+ if (!buttonEl)
+ return;
+ const enabled = (_b = (_a = autoLikeStore.getData().channels.find((ch) => ch.id === channelId)) === null || _a === void 0 ? void 0 : _a.enabled) !== null && _b !== void 0 ? _b : false;
+ if (enabled)
+ buttonEl.classList.add("toggled");
else
- remVidsCache.push(data);
- await GM.setValue("bytm-rem-songs", JSON.stringify(remVidsCache));
+ buttonEl.classList.remove("toggled");
+ const imgEl = buttonEl.querySelector(".bytm-generic-btn-img");
+ const imgHtml = await resourceAsString(`icon-auto_like${enabled ? "_enabled" : ""}`);
+ if (imgEl && imgHtml)
+ setInnerHtml(imgEl, imgHtml);
+ });
+}//#region logging fns
+let curLogLevel = LogLevel.Info;
+/** Common prefix to be able to tell logged messages apart and filter them in devtools */
+const consPrefix = `[${scriptInfo.name}]`;
+const consPrefixDbg = `[${scriptInfo.name}/#DEBUG]`;
+/** Sets the current log level. 0 = Debug, 1 = Info */
+function setLogLevel(level) {
+ curLogLevel = level;
+ setGlobalProp("logLevel", level);
+ if (curLogLevel !== level)
+ log("Set the log level to", LogLevel[level]);
+}
+/** Extracts the log level from the last item from spread arguments - returns 0 if the last item is not a number or too low or high */
+function getLogLevel(args) {
+ const minLogLvl = 0, maxLogLvl = 1;
+ if (typeof args.at(-1) === "number")
+ return UserUtils.clamp(args.splice(args.length - 1)[0], minLogLvl, maxLogLvl);
+ return LogLevel.Debug;
+}
+/**
+ * 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 it shouldn't be.
+ */
+function log(...args) {
+ if (curLogLevel <= getLogLevel(args))
+ console.log(consPrefix, ...args);
+}
+/**
+ * Logs all passed values to the console as info, as long as the log level is sufficient.
+ * @param args Last parameter is log level (0 = Debug, 1/undefined = Info) - any number as the last parameter will be stripped out! Convert to string if it shouldn't be.
+ */
+function info(...args) {
+ if (curLogLevel <= getLogLevel(args))
+ console.info(consPrefix, ...args);
+}
+/** Logs all passed values to the console as a warning, no matter the log level. */
+function warn(...args) {
+ console.warn(consPrefix, ...args);
+}
+/** Logs all passed values to the console as an error, no matter the log level. */
+function error(...args) {
+ var _a, _b;
+ console.error(consPrefix, ...args);
+ if (getFeature("showToastOnGenericError")) {
+ const errName = (_b = (_a = args.find(a => a instanceof Error)) === null || _a === void 0 ? void 0 : _a.name) !== null && _b !== void 0 ? _b : t("error");
+ UserUtils.debounce(() => showIconToast({
+ message: t("generic_error_toast_encountered_error_type", errName),
+ subtitle: t("generic_error_toast_click_for_details"),
+ icon: "icon-error",
+ iconFill: "var(--bytm-error-col)",
+ onClick: () => getErrorDialog(errName, Array.isArray(args) ? args : []).open(),
+ }))();
}
- /** Deletes an entry in the "remember cache" */
- async function remTimeDeleteEntry(watchID) {
- remVidsCache = [...remVidsCache.filter(entry => entry.watchID !== watchID)];
- await GM.setValue("bytm-rem-songs", JSON.stringify(remVidsCache));
+}
+/** Logs all passed values to the console with a debug-specific prefix */
+function dbg(...args) {
+ console.log(consPrefixDbg, ...args);
+}
+//#region error dialog
+function getErrorDialog(errName, args) {
+ return new MarkdownDialog({
+ id: "generic-error",
+ height: 400,
+ width: 500,
+ small: true,
+ destroyOnClose: true,
+ renderHeader() {
+ const header = document.createElement("h2");
+ header.classList.add("bytm-dialog-title");
+ header.role = "heading";
+ header.ariaLevel = "1";
+ header.tabIndex = 0;
+ header.textContent = header.ariaLabel = errName;
+ return header;
+ },
+ body: `\
+${args.length > 0 ? args.join(" ") : t("generic_error_dialog_message")}
+${t("generic_error_dialog_open_console_note", consPrefix, packageJson.bugs.url)}`,
+ });
+}
+//#region rrror classes
+class LyricsError extends Error {
+ constructor(message) {
+ super(message);
+ this.name = "LyricsError";
}
-
- const inputIgnoreTagNames = ["INPUT", "TEXTAREA", "SELECT", "BUTTON", "A"];
- //#region arrow key skip
- async function initArrowKeySkip() {
- document.addEventListener("keydown", (evt) => {
- var _a, _b, _c, _d, _e, _f;
- if (!getFeature("arrowKeySupport"))
- return;
- if (!["ArrowLeft", "ArrowRight"].includes(evt.code))
- return;
- const allowedClasses = ["bytm-generic-btn", "yt-spec-button-shape-next"];
- // discard the event when a (text) input is currently active, like when editing a playlist
- if ((inputIgnoreTagNames.includes((_b = (_a = document.activeElement) === null || _a === void 0 ? void 0 : _a.tagName) !== null && _b !== void 0 ? _b : "") || ["volume-slider"].includes((_d = (_c = document.activeElement) === null || _c === void 0 ? void 0 : _c.id) !== null && _d !== void 0 ? _d : ""))
- && !allowedClasses.some((cls) => { var _a; return (_a = document.activeElement) === null || _a === void 0 ? void 0 : _a.classList.contains(cls); }))
- return info(`Captured valid key to skip forward or backward but the current active element is <${(_e = document.activeElement) === null || _e === void 0 ? void 0 : _e.tagName.toLowerCase()}>, so the keypress is ignored`);
- evt.preventDefault();
- evt.stopImmediatePropagation();
- let skipBy = (_f = getFeature("arrowKeySkipBy")) !== null && _f !== void 0 ? _f : featInfo.arrowKeySkipBy.default;
- if (evt.code === "ArrowLeft")
- skipBy *= -1;
- log(`Captured arrow key '${evt.code}' - skipping by ${skipBy} seconds`);
- const vidElem = getVideoElement();
- if (vidElem)
- vidElem.currentTime = UserUtils.clamp(vidElem.currentTime + skipBy, 0, vidElem.duration);
- });
- log("Added arrow key press listener");
- }
- //#region site switch
- /** switch sites only if current video time is greater than this value */
- const videoTimeThreshold = 3;
- let siteSwitchEnabled = true;
- /** Initializes the site switch feature */
- async function initSiteSwitch(domain) {
- document.addEventListener("keydown", (e) => {
- var _a, _b;
- if (!getFeature("switchBetweenSites"))
- return;
- if (inputIgnoreTagNames.includes((_b = (_a = document.activeElement) === null || _a === void 0 ? void 0 : _a.tagName) !== null && _b !== void 0 ? _b : ""))
- return;
- const hk = getFeature("switchSitesHotkey");
- if (siteSwitchEnabled && e.code === hk.code && e.shiftKey === hk.shift && e.ctrlKey === hk.ctrl && e.altKey === hk.alt)
- switchSite(domain === "yt" ? "ytm" : "yt");
+}
+class PluginError extends Error {
+ constructor(message) {
+ super(message);
+ this.name = "PluginError";
+ }
+}/** Central serializer for all data stores */
+let serializer;
+/** Returns the serializer for all data stores */
+function getStoreSerializer() {
+ if (!serializer) {
+ serializer = new UserUtils.DataStoreSerializer([
+ configStore,
+ autoLikeStore,
+ ], {
+ addChecksum: true,
+ ensureIntegrity: true,
});
- siteEvents.on("hotkeyInputActive", (state) => {
- if (!getFeature("switchBetweenSites"))
- return;
- siteSwitchEnabled = !state;
+ }
+ return serializer;
+}
+/** Downloads the current data stores as a single file */
+async function downloadData() {
+ const serializer = getStoreSerializer();
+ const pad = (num, len = 2) => String(num).padStart(len, "0");
+ const d = new Date();
+ const dateStr = `${pad(d.getFullYear(), 4)}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}_${pad(d.getHours())}-${pad(d.getMinutes())}`;
+ const fileName = `BetterYTM ${packageJson.version} data export ${dateStr}.json`;
+ const data = JSON.stringify(JSON.parse(await serializer.serialize()), undefined, 2);
+ downloadFile(fileName, data, "application/json");
+}//#region cfg menu btns
+let logoExchanged = false, improveLogoCalled = false;
+/** Adds a watermark beneath the logo */
+async function addWatermark() {
+ const watermark = document.createElement("a");
+ watermark.role = "button";
+ watermark.id = "bytm-watermark";
+ watermark.classList.add("style-scope", "ytmusic-nav-bar", "bytm-no-select");
+ watermark.textContent = scriptInfo.name;
+ watermark.ariaLabel = watermark.title = t("open_menu_tooltip", scriptInfo.name);
+ watermark.tabIndex = 0;
+ improveLogo();
+ const watermarkOpenMenu = (e) => {
+ e.stopPropagation();
+ if ((!e.shiftKey && !e.ctrlKey) || logoExchanged)
+ openCfgMenu();
+ if (!logoExchanged && (e.shiftKey || e.ctrlKey))
+ exchangeLogo();
+ };
+ onInteraction(watermark, watermarkOpenMenu);
+ addSelectorListener("navBar", "ytmusic-nav-bar #left-content", {
+ listener: (logoElem) => logoElem.insertAdjacentElement("afterend", watermark),
+ });
+ log("Added watermark element");
+}
+/** Turns the regular `
`-based logo into inline SVG to be able to animate and modify parts of it */
+async function improveLogo() {
+ try {
+ if (improveLogoCalled)
+ return;
+ improveLogoCalled = true;
+ const res = await UserUtils.fetchAdvanced("https://music.youtube.com/img/on_platform_logo_dark.svg");
+ const svg = await res.text();
+ addSelectorListener("navBar", "ytmusic-logo a", {
+ listener: (logoElem) => {
+ var _a;
+ logoElem.classList.add("bytm-mod-logo", "bytm-no-select");
+ setInnerHtml(logoElem, 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");
+ },
});
- log("Initialized site switch listener");
}
- /** Switches to the other site (between YT and YTM) */
- async function switchSite(newDomain) {
- try {
- if (!(["/watch", "/playlist"].some(v => location.pathname.startsWith(v))))
- return warn("Not on a supported 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 = await getVideoTime(0);
- log(`Found video time of ${vt} seconds`);
- const cleanSearch = search.split("&")
- .filter((param) => !param.match(/^\??(t|time_continue)=/))
- .join("&");
- const newSearch = typeof vt === "number" && vt > videoTimeThreshold ?
- cleanSearch.includes("?")
- ? `${cleanSearch.startsWith("?")
- ? cleanSearch
- : "?" + cleanSearch}&time_continue=${vt}`
- : `?time_continue=${vt}`
- : cleanSearch;
- const newUrl = `https://${subdomain}.youtube.com${pathname}${newSearch}${hash}`;
- info(`Switching to domain '${newDomain}' at ${newUrl}`);
- location.assign(newUrl);
- }
- catch (err) {
- error("Error while switching site:", err);
- }
+ catch (err) {
+ error("Couldn't improve logo due to an error:", err);
}
- //#region num keys skip
- const numKeysIgnoreTagNames = [...inputIgnoreTagNames];
- /** Adds the ability to skip to a certain time in the video by pressing a number key (0-9) */
- async function initNumKeysSkip() {
- document.addEventListener("keydown", (e) => {
- var _a, _b;
- if (!getFeature("numKeysSkipToTime"))
- return;
- if (!e.key.trim().match(/^[0-9]$/))
+}
+/** Exchanges the default YTM logo into BetterYTM's logo with a sick ass animation */
+function exchangeLogo() {
+ addSelectorListener("navBar", ".bytm-mod-logo", {
+ listener: async (logoElem) => {
+ if (logoElem.classList.contains("bytm-logo-exchanged"))
return;
- // discard the event when an unexpected element is currently active or in focus, like when editing a playlist or when the search bar is focused
- const ignoreElement = numKeysIgnoreTagNames.includes((_b = (_a = document.activeElement) === null || _a === void 0 ? void 0 : _a.tagName) !== null && _b !== void 0 ? _b : "");
- if ((document.activeElement !== document.body && ignoreElement) || ignoreElement)
- return info("Captured valid key to skip video to, but ignored it since this element is currently active:", document.activeElement);
- const vidElem = getVideoElement();
- if (!vidElem)
- return warn("Could not find video element, so the keypress is ignored");
- const newVidTime = vidElem.duration / (10 / Number(e.key));
- if (!isNaN(newVidTime)) {
- log(`Captured number key [${e.key}], skipping to ${Math.floor(newVidTime / 60)}m ${(newVidTime % 60).toFixed(1)}s`);
- vidElem.currentTime = newVidTime;
- }
- });
- log("Added number key press listener");
- }
- //#region auto-like vids
- let canCompress$1 = false;
- /** DataStore instance for all auto-liked channels */
- const autoLikeStore = new UserUtils.DataStore({
- id: "bytm-auto-like-channels",
- formatVersion: 2,
- defaultData: {
- channels: [],
- },
- encodeData: (data) => canCompress$1 ? UserUtils.compress(data, compressionFormat, "string") : data,
- decodeData: (data) => canCompress$1 ? UserUtils.decompress(data, compressionFormat, "string") : data,
- migrations: {
- // 1 -> 2 (v2.1-pre) - add @ prefix to channel IDs if missing
- 2: (oldData) => ({
- channels: oldData.channels.map((ch) => (Object.assign(Object.assign({}, ch), { id: isValidChannelId(ch.id.trim())
- ? ch.id.trim()
- : `@${ch.id.trim()}` }))),
- }),
+ logoExchanged = true;
+ logoElem.classList.add("bytm-logo-exchanged");
+ const iconUrl = await getResourceUrl(mode === "development" ? "img-logo_dev" : "img-logo");
+ const newLogo = document.createElement("img");
+ newLogo.classList.add("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);
},
});
- let autoLikeStoreLoaded = false;
- /** Inits the auto-like DataStore instance */
- async function initAutoLikeStore() {
- if (autoLikeStoreLoaded)
- return;
- autoLikeStoreLoaded = true;
- return autoLikeStore.loadData();
+}
+/** Called whenever the avatar popover menu exists on YTM to add a BYTM config menu button to the user menu popover */
+async function addConfigMenuOptionYTM(container) {
+ const cfgOptElem = document.createElement("div");
+ cfgOptElem.classList.add("bytm-cfg-menu-option");
+ const cfgOptItemElem = document.createElement("div");
+ cfgOptItemElem.classList.add("bytm-cfg-menu-option-item");
+ cfgOptItemElem.role = "button";
+ cfgOptItemElem.tabIndex = 0;
+ cfgOptItemElem.ariaLabel = cfgOptItemElem.title = t("open_menu_tooltip", scriptInfo.name);
+ onInteraction(cfgOptItemElem, async (e) => {
+ const settingsBtnElem = document.querySelector("ytmusic-nav-bar ytmusic-settings-button tp-yt-paper-icon-button");
+ settingsBtnElem === null || settingsBtnElem === void 0 ? void 0 : settingsBtnElem.click();
+ await UserUtils.pauseFor(20);
+ if ((!e.shiftKey && !e.ctrlKey) || logoExchanged)
+ openCfgMenu();
+ if (!logoExchanged && (e.shiftKey || e.ctrlKey))
+ exchangeLogo();
+ });
+ const cfgOptIconElem = document.createElement("img");
+ cfgOptIconElem.classList.add("bytm-cfg-menu-option-icon");
+ cfgOptIconElem.src = await getResourceUrl(mode === "development" ? "img-logo_dev" : "img-logo");
+ const cfgOptTextElem = document.createElement("div");
+ cfgOptTextElem.classList.add("bytm-cfg-menu-option-text");
+ cfgOptTextElem.textContent = t("config_menu_option", scriptInfo.name);
+ cfgOptItemElem.appendChild(cfgOptIconElem);
+ cfgOptItemElem.appendChild(cfgOptTextElem);
+ cfgOptElem.appendChild(cfgOptItemElem);
+ container.appendChild(cfgOptElem);
+ improveLogo();
+ log("Added BYTM-Configuration button to menu popover");
+}
+/** Called whenever the titlebar (masthead) exists on YT to add a BYTM config menu button */
+async function addConfigMenuOptionYT(container) {
+ const cfgOptWrapperElem = document.createElement("div");
+ cfgOptWrapperElem.classList.add("bytm-yt-cfg-menu-option", "darkreader-ignore");
+ cfgOptWrapperElem.role = "button";
+ cfgOptWrapperElem.tabIndex = 0;
+ cfgOptWrapperElem.ariaLabel = cfgOptWrapperElem.title = t("open_menu_tooltip", scriptInfo.name);
+ const cfgOptElem = document.createElement("div");
+ cfgOptElem.classList.add("bytm-yt-cfg-menu-option-inner");
+ const cfgOptImgElem = document.createElement("img");
+ cfgOptImgElem.classList.add("bytm-yt-cfg-menu-option-icon");
+ cfgOptImgElem.src = await getResourceUrl(mode === "development" ? "img-logo_dev" : "img-logo");
+ const cfgOptItemElem = document.createElement("div");
+ cfgOptItemElem.classList.add("bytm-yt-cfg-menu-option-item");
+ cfgOptItemElem.textContent = scriptInfo.name;
+ cfgOptElem.appendChild(cfgOptImgElem);
+ cfgOptElem.appendChild(cfgOptItemElem);
+ cfgOptWrapperElem.appendChild(cfgOptElem);
+ onInteraction(cfgOptWrapperElem, openCfgMenu);
+ const firstChild = container === null || container === void 0 ? void 0 : container.firstElementChild;
+ if (firstChild)
+ container.insertBefore(cfgOptWrapperElem, firstChild);
+ else
+ return error("Couldn't add config menu option to YT titlebar - couldn't find container element");
+}
+//#region anchor impr.
+/** Adds anchors around elements and tweaks existing ones so songs are easier to open in a new tab */
+async function addAnchorImprovements() {
+ try {
+ await addStyleFromResource("css-anchor_improvements");
+ }
+ catch (err) {
+ error("Couldn't add anchor improvements CSS due to an error:", err);
+ }
+ //#region 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"))
+ 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);
+ UserUtils.addParent(thumbnailElem, anchorElem);
+ }
+ };
+ // home page
+ addSelectorListener("body", "#contents.ytmusic-section-list-renderer ytmusic-carousel-shelf-renderer ytmusic-responsive-list-item-renderer", {
+ continuous: true,
+ all: true,
+ listener: addListItemAnchors,
+ });
+ // related tab in /watch
+ addSelectorListener("body", "ytmusic-tab-renderer[page-type=\"MUSIC_PAGE_TYPE_TRACK_RELATED\"] ytmusic-responsive-list-item-renderer", {
+ continuous: true,
+ all: true,
+ listener: addListItemAnchors,
+ });
+ // playlists
+ addSelectorListener("body", "#contents.ytmusic-section-list-renderer ytmusic-playlist-shelf-renderer ytmusic-responsive-list-item-renderer", {
+ continuous: true,
+ all: true,
+ listener: addListItemAnchors,
+ });
+ // generic shelves
+ addSelectorListener("body", "#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);
+ }
+ //#region sidebar
+ try {
+ const addSidebarAnchors = (sidebarCont) => {
+ const items = sidebarCont.parentNode.querySelectorAll("ytmusic-guide-entry-renderer tp-yt-paper-item");
+ improveSidebarAnchors(items);
+ return items.length;
+ };
+ addSelectorListener("sideBar", "#contentContainer #guide-content #items ytmusic-guide-entry-renderer", {
+ listener: (sidebarCont) => {
+ const itemsAmt = addSidebarAnchors(sidebarCont);
+ log(`Added anchors around ${itemsAmt} sidebar ${UserUtils.autoPlural("item", itemsAmt)}`);
+ },
+ });
+ addSelectorListener("sideBarMini", "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 ${UserUtils.autoPlural("item", itemsAmt)}`);
+ },
+ });
+ }
+ catch (err) {
+ error("Couldn't add anchors to sidebar items due to an error:", err);
}
- /** Initializes the auto-like feature */
- async function initAutoLike() {
+}
+const sidebarPaths = [
+ "/",
+ "/explore",
+ "/library",
+];
+/**
+ * Adds anchors to the sidebar items so they can be opened in a new tab
+ * @param sidebarItem
+ */
+function improveSidebarAnchors(sidebarItems) {
+ sidebarItems.forEach((item, i) => {
+ var _a;
+ const anchorElem = document.createElement("a");
+ anchorElem.classList.add("bytm-anchor", "bytm-no-select");
+ anchorElem.role = "button";
+ anchorElem.target = "_self";
+ anchorElem.href = (_a = sidebarPaths[i]) !== null && _a !== void 0 ? _a : "#";
+ anchorElem.ariaLabel = anchorElem.title = t("middle_click_open_tab");
+ anchorElem.addEventListener("click", (e) => {
+ e.preventDefault();
+ });
+ UserUtils.addParent(item, anchorElem);
+ });
+}
+//#region share track par.
+/** Removes the ?si tracking parameter from share URLs */
+async function initRemShareTrackParam() {
+ const removeSiParam = (inputElem) => {
try {
- canCompress$1 = await compressionSupported();
- await initAutoLikeStore();
- //#SECTION ytm
- if (getDomain() === "ytm") {
- let timeout;
- siteEvents.on("songTitleChanged", () => {
- var _a;
- const autoLikeTimeoutMs = ((_a = getFeature("autoLikeTimeout")) !== null && _a !== void 0 ? _a : 5) * 1000;
- timeout && clearTimeout(timeout);
- const ytmTryAutoLike = () => {
- const artistEls = document.querySelectorAll("ytmusic-player-bar .content-info-wrapper .subtitle a.yt-formatted-string[href]");
- const channelIds = [...artistEls].map(a => a.href.split("/").pop()).filter(a => typeof a === "string");
- const likeChan = autoLikeStore.getData().channels.find((ch) => channelIds.includes(ch.id));
- if (!likeChan || !likeChan.enabled)
- return;
- if (artistEls.length === 0)
- return error("Couldn't auto-like channel because the artist element couldn't be found");
- const likeRendererEl = document.querySelector(".middle-controls-buttons ytmusic-like-button-renderer");
- const likeBtnEl = likeRendererEl === null || likeRendererEl === void 0 ? void 0 : likeRendererEl.querySelector("#button-shape-like button");
- if (!likeRendererEl || !likeBtnEl)
- return error("Couldn't auto-like channel because the like button couldn't be found");
- if (likeRendererEl.getAttribute("like-status") !== "LIKE") {
- likeBtnEl.click();
- getFeature("autoLikeShowToast") && showIconToast({
- message: t(`auto_liked_a_channels_${getCurrentMediaType()}`, likeChan.name),
- subtitle: t("auto_like_click_to_configure"),
- icon: "icon-auto_like",
- onClick: () => getAutoLikeDialog().then((dlg) => dlg.open()),
- }).catch(e => error("Error while showing auto-like toast:", e));
- log(`Auto-liked ${getCurrentMediaType()} from channel '${likeChan.name}' (${likeChan.id})`);
- }
- };
- timeout = setTimeout(ytmTryAutoLike, autoLikeTimeoutMs);
- siteEvents.on("autoLikeChannelsUpdated", () => setTimeout(ytmTryAutoLike, autoLikeTimeoutMs));
- });
- const recreateBtn = (headerCont, chanId) => {
- var _a, _b, _c, _d, _e, _f;
- const titleCont = headerCont.querySelector("ytd-channel-name #container, yt-dynamic-text-view-model.page-header-view-model-wiz__page-header-title, ytmusic-immersive-header-renderer .ytmusic-immersive-header-renderer yt-formatted-string.title");
- if (!titleCont)
- return;
- const checkBtn = () => setTimeout(() => {
- if (!document.querySelector(".bytm-auto-like-toggle-btn"))
- recreateBtn(headerCont, chanId);
- }, 250);
- const chanName = (_b = (_a = titleCont.querySelector("yt-formatted-string, span.yt-core-attributed-string")) === null || _a === void 0 ? void 0 : _a.textContent) !== null && _b !== void 0 ? _b : null;
- log("Re-rendering auto-like toggle button for channel", chanName, "with ID", chanId);
- const buttonsCont = headerCont.querySelector(".buttons");
- if (buttonsCont) {
- const lastBtn = buttonsCont.querySelector("ytmusic-subscribe-button-renderer");
- const chanName = (_d = (_c = document.querySelector("ytmusic-immersive-header-renderer .content-container yt-formatted-string[role=\"heading\"]")) === null || _c === void 0 ? void 0 : _c.textContent) !== null && _d !== void 0 ? _d : null;
- lastBtn && addAutoLikeToggleBtn(lastBtn, chanId, chanName).then(checkBtn);
- }
- else {
- // some channels don't have a subscribe button and instead only have a "share" button for some bullshit reason
- const shareBtnEl = headerCont.querySelector("ytmusic-menu-renderer #top-level-buttons yt-button-renderer:last-of-type");
- const chanName = (_f = (_e = headerCont.querySelector("ytmusic-visual-header-renderer .content-container h2 yt-formatted-string")) === null || _e === void 0 ? void 0 : _e.textContent) !== null && _f !== void 0 ? _f : null;
- shareBtnEl && chanName && addAutoLikeToggleBtn(shareBtnEl, chanId, chanName).then(checkBtn);
- }
- };
- siteEvents.on("pathChanged", (path) => {
- if (getFeature("autoLikeChannelToggleBtn") && path.match(/\/channel\/.+/)) {
- const chanId = getCurrentChannelId();
- if (!chanId)
- return error("Couldn't extract channel ID from URL");
- document.querySelectorAll(".bytm-auto-like-toggle-btn").forEach((btn) => clearNode(btn));
- addSelectorListener("browseResponse", "ytmusic-browse-response #header.ytmusic-browse-response", {
- listener: (el) => recreateBtn(el, chanId),
- });
- }
- });
- }
- //#SECTION yt
- else if (getDomain() === "yt") {
- addStyleFromResource("css-auto_like");
- let timeout;
- siteEvents.on("watchIdChanged", () => {
- var _a;
- const autoLikeTimeoutMs = ((_a = getFeature("autoLikeTimeout")) !== null && _a !== void 0 ? _a : 5) * 1000;
- timeout && clearTimeout(timeout);
- if (!location.pathname.startsWith("/watch"))
- return;
- const ytTryAutoLike = () => {
- addSelectorListener("ytWatchMetadata", "#owner ytd-channel-name yt-formatted-string a", {
- listener(chanElem) {
- var _a, _b;
- const chanElemId = (_b = (_a = chanElem.href.split("/").pop()) === null || _a === void 0 ? void 0 : _a.split("/")[0]) !== null && _b !== void 0 ? _b : null;
- const likeChan = autoLikeStore.getData().channels.find((ch) => ch.id === chanElemId);
- if (!likeChan || !likeChan.enabled)
- return;
- addSelectorListener("ytWatchMetadata", "#actions ytd-menu-renderer like-button-view-model button", {
- listener(likeBtn) {
- if (likeBtn.getAttribute("aria-pressed") !== "true") {
- likeBtn.click();
- getFeature("autoLikeShowToast") && showIconToast({
- message: t("auto_liked_a_channels_video", likeChan.name),
- subtitle: t("auto_like_click_to_configure"),
- icon: "icon-auto_like",
- onClick: () => getAutoLikeDialog().then((dlg) => dlg.open()),
- });
- log(`Auto-liked video from channel '${likeChan.name}' (${likeChan.id})`);
- }
- }
- });
- }
- });
- };
- siteEvents.on("autoLikeChannelsUpdated", () => setTimeout(ytTryAutoLike, autoLikeTimeoutMs));
- timeout = setTimeout(ytTryAutoLike, autoLikeTimeoutMs);
- });
- siteEvents.on("pathChanged", (path) => {
- if (path.match(/(\/?@|\/?channel\/)\S+/)) {
- const chanId = getCurrentChannelId();
- if (!chanId)
- return error("Couldn't extract channel ID from URL");
- document.querySelectorAll(".bytm-auto-like-toggle-btn").forEach((btn) => clearNode(btn));
- const recreateBtn = (headerCont) => {
- var _a, _b;
- const titleCont = headerCont.querySelector("ytd-channel-name #container, yt-dynamic-text-view-model.page-header-view-model-wiz__page-header-title");
- if (!titleCont)
- return;
- const checkBtn = () => setTimeout(() => {
- if (!document.querySelector(".bytm-auto-like-toggle-btn"))
- recreateBtn(headerCont);
- }, 350);
- const chanName = (_b = (_a = titleCont.querySelector("yt-formatted-string, span.yt-core-attributed-string")) === null || _a === void 0 ? void 0 : _a.textContent) !== null && _b !== void 0 ? _b : null;
- log("Re-rendering auto-like toggle button for channel", chanName, "with ID", chanId);
- const buttonsCont = headerCont.querySelector("#inner-header-container #buttons, yt-flexible-actions-view-model");
- if (buttonsCont) {
- addSelectorListener("ytAppHeader", "#channel-header-container #other-buttons, yt-flexible-actions-view-model .yt-flexible-actions-view-model-wiz__action", {
- listener: (otherBtns) => addAutoLikeToggleBtn(otherBtns, chanId, chanName, ["left-margin", "right-margin"]).then(checkBtn),
- });
- }
- else if (titleCont)
- addAutoLikeToggleBtn(titleCont, chanId, chanName).then(checkBtn);
- };
- addSelectorListener("ytAppHeader", "#channel-header-container, #page-header", {
- listener: recreateBtn,
- });
- }
- });
- }
- log("Initialized auto-like channels feature");
+ if (!inputElem.value.match(/(&|\?)si=/i))
+ return;
+ const url = new URL(inputElem.value);
+ url.searchParams.delete("si");
+ inputElem.value = String(url);
+ log(`Removed tracking parameter from share link -> ${url}`);
}
catch (err) {
- error("Error while auto-liking channel:", err);
+ warn("Couldn't remove tracking parameter from share link due to error:", err);
}
- }
- //#SECTION toggle btn
- /** Adds a toggle button to enable or disable auto-liking videos from a channel */
- async function addAutoLikeToggleBtn(siblingEl, channelId, channelName, extraClasses) {
- var _a;
- const chan = autoLikeStore.getData().channels.find((ch) => ch.id === channelId);
- log(`Adding auto-like toggle button for channel with ID '${channelId}' - current state:`, chan);
- siteEvents.on("autoLikeChannelsUpdated", () => {
- var _a, _b;
- const buttonEl = document.querySelector(`.bytm-auto-like-toggle-btn[data-channel-id="${channelId}"]`);
- if (!buttonEl)
- return warn("Couldn't find auto-like toggle button for channel ID:", channelId);
- const enabled = (_b = (_a = autoLikeStore.getData().channels.find((ch) => ch.id === channelId)) === null || _a === void 0 ? void 0 : _a.enabled) !== null && _b !== void 0 ? _b : false;
- if (enabled)
- buttonEl.classList.add("toggled");
- else
- buttonEl.classList.remove("toggled");
- });
- const buttonEl = await createLongBtn({
- resourceName: `icon-auto_like${(chan === null || chan === void 0 ? void 0 : chan.enabled) ? "_enabled" : ""}`,
- text: t("auto_like"),
- title: t(`auto_like_button_tooltip${(chan === null || chan === void 0 ? void 0 : chan.enabled) ? "_enabled" : "_disabled"}`),
- toggle: true,
- toggleInitialState: (_a = chan === null || chan === void 0 ? void 0 : chan.enabled) !== null && _a !== void 0 ? _a : false,
- togglePredicate(e) {
- e.shiftKey && getAutoLikeDialog().then((dlg) => dlg.open());
- return !e.shiftKey;
+ };
+ const [sharePanelSel, inputSel] = (() => {
+ switch (getDomain()) {
+ case "ytm": return ["tp-yt-paper-dialog ytmusic-unified-share-panel-renderer", "input#share-url"];
+ case "yt": return ["ytd-unified-share-panel-renderer", "input#share-url"];
+ }
+ })();
+ addSelectorListener("body", sharePanelSel, {
+ listener: (sharePanelEl) => {
+ const obs = new MutationObserver(() => {
+ const inputElem = sharePanelEl.querySelector(inputSel);
+ inputElem && removeSiParam(inputElem);
+ });
+ obs.observe(sharePanelEl, {
+ childList: true,
+ subtree: true,
+ characterData: true,
+ attributeFilter: ["aria-hidden", "aria-checked", "checked"],
+ });
+ },
+ });
+}
+//#region fix spacing
+/** Applies global CSS to fix various spacings */
+async function fixSpacing() {
+ if (!await addStyleFromResource("css-fix_spacing"))
+ error("Couldn't fix spacing");
+}
+//#region ab.queue btns
+async function initAboveQueueBtns() {
+ const { scrollToActiveSongBtn, clearQueueBtn } = getFeatures();
+ if (!await addStyleFromResource("css-above_queue_btns"))
+ error("Couldn't add CSS for above queue buttons");
+ else if (getFeature("aboveQueueBtnsSticky"))
+ addStyleFromResource("css-above_queue_btns_sticky");
+ const contBtns = [
+ {
+ condition: scrollToActiveSongBtn,
+ id: "scroll-to-active",
+ resourceName: "icon-skip_to",
+ titleKey: "scroll_to_playing",
+ async interaction(evt) {
+ const activeItem = document.querySelector("#side-panel .ytmusic-player-queue ytmusic-player-queue-item[play-button-state=\"loading\"], #side-panel .ytmusic-player-queue ytmusic-player-queue-item[play-button-state=\"playing\"], #side-panel .ytmusic-player-queue ytmusic-player-queue-item[play-button-state=\"paused\"]");
+ if (!activeItem)
+ return;
+ activeItem.scrollIntoView({
+ behavior: evt.shiftKey ? "instant" : "smooth",
+ block: evt.ctrlKey || evt.altKey ? "start" : "center",
+ inline: "center",
+ });
},
- async onToggle(toggled) {
- var _a;
+ },
+ {
+ condition: clearQueueBtn,
+ id: "clear-queue",
+ resourceName: "icon-clear_list",
+ titleKey: "clear_list",
+ async interaction(evt) {
try {
- await autoLikeStore.loadData();
- buttonEl.title = buttonEl.ariaLabel = t(`auto_like_button_tooltip${toggled ? "_enabled" : "_disabled"}`);
- const chanId = sanitizeChannelId((_a = buttonEl.dataset.channelId) !== null && _a !== void 0 ? _a : channelId);
- const imgEl = buttonEl.querySelector(".bytm-generic-btn-img");
- const imgHtml = await resourceAsString(`icon-auto_like${toggled ? "_enabled" : ""}`);
- if (imgEl && imgHtml)
- setInnerHtml(imgEl, imgHtml);
- if (autoLikeStore.getData().channels.find((ch) => ch.id === chanId) === undefined) {
- await autoLikeStore.setData({
- channels: [
- ...autoLikeStore.getData().channels,
- { id: chanId, name: channelName !== null && channelName !== void 0 ? channelName : "", enabled: toggled },
- ],
- });
- }
- else {
- await autoLikeStore.setData({
- channels: autoLikeStore.getData().channels
- .map((ch) => ch.id === chanId ? Object.assign(Object.assign({}, ch), { enabled: toggled }) : ch),
- });
+ if (evt.shiftKey || await showPrompt({ type: "confirm", message: t("clear_list_confirm") })) {
+ const url = new URL(location.href);
+ url.searchParams.delete("list");
+ url.searchParams.set("time_continue", String(await getVideoTime(0)));
+ location.assign(url);
}
- emitSiteEvent("autoLikeChannelsUpdated");
- showIconToast({
- message: toggled ? t("auto_like_enabled_toast") : t("auto_like_disabled_toast"),
- icon: `icon-auto_like${toggled ? "_enabled" : ""}`,
- });
- log(`Toggled auto-like for channel '${channelName}' (ID: '${chanId}') to ${toggled ? "enabled" : "disabled"}`);
}
catch (err) {
- error("Error while toggling auto-like channel:", err);
+ error("Couldn't clear queue due to an error:", err);
+ }
+ },
+ },
+ ];
+ if (!contBtns.some(b => Boolean(b.condition)))
+ return;
+ addSelectorListener("sidePanel", "ytmusic-tab-renderer ytmusic-queue-header-renderer #buttons", {
+ async listener(rightBtnsEl) {
+ try {
+ const aboveQueueBtnCont = document.createElement("div");
+ aboveQueueBtnCont.id = "bytm-above-queue-btn-cont";
+ UserUtils.addParent(rightBtnsEl, aboveQueueBtnCont);
+ const headerEl = rightBtnsEl.closest("ytmusic-queue-header-renderer");
+ if (!headerEl)
+ return error("Couldn't find queue header element while adding above queue buttons");
+ siteEvents.on("fullscreenToggled", (isFullscreen) => {
+ headerEl.classList[isFullscreen ? "add" : "remove"]("hidden");
+ });
+ const wrapperElem = document.createElement("div");
+ wrapperElem.id = "bytm-above-queue-btn-wrapper";
+ for (const item of contBtns) {
+ if (Boolean(item.condition) === false)
+ continue;
+ const btnElem = await createCircularBtn({
+ resourceName: item.resourceName,
+ onClick: item.interaction,
+ title: t(item.titleKey),
+ });
+ btnElem.id = `bytm-${item.id}-btn`;
+ btnElem.classList.add("ytmusic-player-bar", "bytm-generic-btn", "bytm-above-queue-btn");
+ wrapperElem.appendChild(btnElem);
}
+ rightBtnsEl.insertAdjacentElement("beforebegin", wrapperElem);
}
+ catch (err) {
+ error("Couldn't add above queue buttons due to an error:", err);
+ }
+ },
+ });
+}
+//#region thumb.overlay
+/** To be changed when the toggle button is pressed - used to invert the state of "showOverlay" */
+let invertOverlay = false;
+async function initThumbnailOverlay() {
+ const toggleBtnShown = getFeature("thumbnailOverlayToggleBtnShown");
+ if (getFeature("thumbnailOverlayBehavior") === "never" && !toggleBtnShown)
+ return;
+ // so the script init doesn't keep waiting until a /watch page is loaded
+ waitVideoElementReady().then(() => {
+ const playerSelector = "ytmusic-player#player";
+ const playerEl = document.querySelector(playerSelector);
+ if (!playerEl)
+ return error("Couldn't find video player element while adding thumbnail overlay");
+ /** Checks and updates the overlay and toggle button states based on the current song type (yt video or ytm song) */
+ const updateOverlayVisibility = async () => {
+ if (!domLoaded)
+ return;
+ const behavior = getFeature("thumbnailOverlayBehavior");
+ let showOverlay = behavior === "always";
+ const isVideo = getCurrentMediaType() === "video";
+ if (behavior === "videosOnly" && isVideo)
+ showOverlay = true;
+ else if (behavior === "songsOnly" && !isVideo)
+ showOverlay = true;
+ showOverlay = invertOverlay ? !showOverlay : showOverlay;
+ const overlayElem = document.querySelector("#bytm-thumbnail-overlay");
+ const thumbElem = document.querySelector("#bytm-thumbnail-overlay-img");
+ const indicatorElem = document.querySelector("#bytm-thumbnail-overlay-indicator");
+ if (overlayElem)
+ overlayElem.style.display = showOverlay ? "block" : "none";
+ if (thumbElem)
+ thumbElem.ariaHidden = String(!showOverlay);
+ if (indicatorElem) {
+ indicatorElem.style.display = showOverlay ? "block" : "none";
+ indicatorElem.ariaHidden = String(!showOverlay);
+ }
+ if (getFeature("thumbnailOverlayToggleBtnShown")) {
+ addSelectorListener("playerBarMiddleButtons", "#bytm-thumbnail-overlay-toggle", {
+ async listener(toggleBtnElem) {
+ const toggleBtnImgElem = toggleBtnElem.querySelector("img");
+ if (toggleBtnImgElem)
+ toggleBtnImgElem.src = await getResourceUrl(`icon-image${showOverlay ? "_filled" : ""}`);
+ if (toggleBtnElem)
+ toggleBtnElem.ariaLabel = toggleBtnElem.title = t(`thumbnail_overlay_toggle_btn_tooltip${showOverlay ? "_hide" : "_show"}`);
+ },
+ });
+ }
+ };
+ const applyThumbUrl = async (watchId) => {
+ try {
+ const thumbUrl = await getBestThumbnailUrl(watchId);
+ if (thumbUrl) {
+ const toggleBtnElem = document.querySelector("#bytm-thumbnail-overlay-toggle");
+ const thumbImgElem = document.querySelector("#bytm-thumbnail-overlay-img");
+ if ((toggleBtnElem === null || toggleBtnElem === void 0 ? void 0 : toggleBtnElem.href) === thumbUrl && (thumbImgElem === null || thumbImgElem === void 0 ? void 0 : thumbImgElem.src) === thumbUrl)
+ return;
+ if (toggleBtnElem)
+ toggleBtnElem.href = thumbUrl;
+ if (thumbImgElem)
+ thumbImgElem.src = thumbUrl;
+ log("Applied thumbnail URL to overlay:", thumbUrl);
+ }
+ else
+ error("Couldn't get thumbnail URL for watch ID", watchId);
+ }
+ catch (err) {
+ error("Couldn't apply thumbnail URL to overlay due to an error:", err);
+ }
+ };
+ const unsubWatchIdChanged = siteEvents.on("watchIdChanged", (watchId) => {
+ unsubWatchIdChanged();
+ addSelectorListener("body", "#bytm-thumbnail-overlay", {
+ listener: () => {
+ applyThumbUrl(watchId);
+ updateOverlayVisibility();
+ },
+ });
});
- buttonEl.classList.add(...["bytm-auto-like-toggle-btn", ...(extraClasses !== null && extraClasses !== void 0 ? extraClasses : [])]);
- buttonEl.dataset.channelId = channelId;
- siblingEl.insertAdjacentElement("afterend", createRipple(buttonEl));
- siteEvents.on("autoLikeChannelsUpdated", async () => {
- var _a, _b;
- const buttonEl = document.querySelector(`.bytm-auto-like-toggle-btn[data-channel-id="${channelId}"]`);
- if (!buttonEl)
- return;
- const enabled = (_b = (_a = autoLikeStore.getData().channels.find((ch) => ch.id === channelId)) === null || _a === void 0 ? void 0 : _a.enabled) !== null && _b !== void 0 ? _b : false;
- if (enabled)
- buttonEl.classList.add("toggled");
- else
- buttonEl.classList.remove("toggled");
- const imgEl = buttonEl.querySelector(".bytm-generic-btn-img");
- const imgHtml = await resourceAsString(`icon-auto_like${enabled ? "_enabled" : ""}`);
- if (imgEl && imgHtml)
- setInnerHtml(imgEl, imgHtml);
- });
- }
-
- //#region logging fns
- let curLogLevel = LogLevel.Info;
- /** Common prefix to be able to tell logged messages apart and filter them in devtools */
- const consPrefix = `[${scriptInfo.name}]`;
- const consPrefixDbg = `[${scriptInfo.name}/#DEBUG]`;
- /** Sets the current log level. 0 = Debug, 1 = Info */
- function setLogLevel(level) {
- curLogLevel = level;
- setGlobalProp("logLevel", level);
- if (curLogLevel !== level)
- log("Set the log level to", LogLevel[level]);
- }
- /** Extracts the log level from the last item from spread arguments - returns 0 if the last item is not a number or too low or high */
- function getLogLevel(args) {
- const minLogLvl = 0, maxLogLvl = 1;
- if (typeof args.at(-1) === "number")
- return UserUtils.clamp(args.splice(args.length - 1)[0], minLogLvl, maxLogLvl);
- return LogLevel.Debug;
+ const createElements = async () => {
+ try {
+ // overlay
+ const overlayElem = document.createElement("div");
+ overlayElem.id = "bytm-thumbnail-overlay";
+ overlayElem.title = ""; // prevent child titles from propagating
+ overlayElem.classList.add("bytm-no-select");
+ overlayElem.style.display = "none";
+ let indicatorElem;
+ if (getFeature("thumbnailOverlayShowIndicator")) {
+ indicatorElem = document.createElement("img");
+ indicatorElem.id = "bytm-thumbnail-overlay-indicator";
+ indicatorElem.src = await getResourceUrl("icon-image");
+ indicatorElem.role = "presentation";
+ indicatorElem.title = indicatorElem.ariaLabel = t("thumbnail_overlay_indicator_tooltip");
+ indicatorElem.ariaHidden = "true";
+ indicatorElem.style.display = "none";
+ indicatorElem.style.opacity = String(getFeature("thumbnailOverlayIndicatorOpacity") / 100);
+ }
+ const thumbImgElem = document.createElement("img");
+ thumbImgElem.id = "bytm-thumbnail-overlay-img";
+ thumbImgElem.role = "presentation";
+ thumbImgElem.ariaHidden = "true";
+ thumbImgElem.style.objectFit = getFeature("thumbnailOverlayImageFit");
+ overlayElem.appendChild(thumbImgElem);
+ playerEl.appendChild(overlayElem);
+ indicatorElem && playerEl.appendChild(indicatorElem);
+ siteEvents.on("watchIdChanged", async (watchId) => {
+ invertOverlay = false;
+ applyThumbUrl(watchId);
+ updateOverlayVisibility();
+ });
+ const params = new URL(location.href).searchParams;
+ if (params.has("v")) {
+ applyThumbUrl(params.get("v"));
+ updateOverlayVisibility();
+ }
+ // toggle button
+ if (toggleBtnShown) {
+ const toggleBtnElem = createRipple(document.createElement("a"));
+ toggleBtnElem.id = "bytm-thumbnail-overlay-toggle";
+ toggleBtnElem.role = "button";
+ toggleBtnElem.tabIndex = 0;
+ toggleBtnElem.classList.add("ytmusic-player-bar", "bytm-generic-btn", "bytm-no-select");
+ onInteraction(toggleBtnElem, (e) => {
+ if (e.shiftKey)
+ return openInTab(toggleBtnElem.href, false);
+ invertOverlay = !invertOverlay;
+ updateOverlayVisibility();
+ });
+ const imgElem = document.createElement("img");
+ imgElem.classList.add("bytm-generic-btn-img");
+ toggleBtnElem.appendChild(imgElem);
+ addSelectorListener("playerBarMiddleButtons", "ytmusic-like-button-renderer#like-button-renderer", {
+ listener: (likeContainer) => likeContainer.insertAdjacentElement("afterend", toggleBtnElem),
+ });
+ }
+ log("Added thumbnail overlay");
+ }
+ catch (err) {
+ error("Couldn't create thumbnail overlay elements due to an error:", err);
+ }
+ };
+ addSelectorListener("mainPanel", playerSelector, {
+ listener(playerEl) {
+ if (playerEl.getAttribute("player-ui-state") === "INACTIVE") {
+ const obs = new MutationObserver(() => {
+ if (playerEl.getAttribute("player-ui-state") === "INACTIVE")
+ return;
+ createElements();
+ obs.disconnect();
+ });
+ obs.observe(playerEl, {
+ attributes: true,
+ attributeFilter: ["player-ui-state"],
+ });
+ }
+ else
+ createElements();
+ },
+ });
+ });
+}
+//#region idle hide cursor
+async function initHideCursorOnIdle() {
+ addSelectorListener("mainPanel", "ytmusic-player#player", {
+ listener(vidContainer) {
+ const overlaySelector = "ytmusic-player #song-media-window";
+ const overlayElem = document.querySelector(overlaySelector);
+ if (!overlayElem)
+ return warn("Couldn't find overlay element while initializing cursor hiding");
+ /** Timer after which the cursor is hidden */
+ let cursorHideTimer;
+ /** Timer for the opacity transition while switching to the hidden state */
+ let hideTransTimer;
+ const hide = () => {
+ if (!getFeature("hideCursorOnIdle"))
+ return;
+ if (vidContainer.classList.contains("bytm-cursor-hidden"))
+ return;
+ overlayElem.style.opacity = ".000001 !important";
+ hideTransTimer = setTimeout(() => {
+ overlayElem.style.display = "none";
+ vidContainer.style.cursor = "none";
+ vidContainer.classList.add("bytm-cursor-hidden");
+ hideTransTimer = undefined;
+ }, 200);
+ };
+ const show = () => {
+ hideTransTimer && clearTimeout(hideTransTimer);
+ if (!vidContainer.classList.contains("bytm-cursor-hidden"))
+ return;
+ vidContainer.classList.remove("bytm-cursor-hidden");
+ vidContainer.style.cursor = "initial";
+ overlayElem.style.display = "initial";
+ overlayElem.style.opacity = "1 !important";
+ };
+ const cursorHideTimerCb = () => cursorHideTimer = setTimeout(hide, getFeature("hideCursorOnIdleDelay") * 1000);
+ const onMove = () => {
+ cursorHideTimer && clearTimeout(cursorHideTimer);
+ show();
+ cursorHideTimerCb();
+ };
+ vidContainer.addEventListener("mouseenter", onMove);
+ vidContainer.addEventListener("mousemove", UserUtils.debounce(onMove, 200, "rising"));
+ vidContainer.addEventListener("mouseleave", () => {
+ cursorHideTimer && clearTimeout(cursorHideTimer);
+ hideTransTimer && clearTimeout(hideTransTimer);
+ hide();
+ });
+ vidContainer.addEventListener("click", () => {
+ show();
+ cursorHideTimerCb();
+ setTimeout(hide, 3000);
+ });
+ log("Initialized cursor hiding on idle");
+ },
+ });
+}
+//#region fix HDR
+/** Prevents visual issues when using HDR */
+async function fixHdrIssues() {
+ if (!await addStyleFromResource("css-fix_hdr"))
+ error("Couldn't load stylesheet to fix HDR issues");
+ else
+ log("Fixed HDR issues");
+}
+//#region show vote nums
+/** Shows the amount of likes and dislikes on the current song */
+async function initShowVotes() {
+ addSelectorListener("playerBar", ".middle-controls-buttons ytmusic-like-button-renderer", {
+ async listener(voteCont) {
+ try {
+ const watchId = getWatchId();
+ if (!watchId) {
+ await siteEvents.once("watchIdChanged");
+ return initShowVotes();
+ }
+ const voteObj = await fetchVideoVotes(watchId);
+ if (!voteObj || !("likes" in voteObj) || !("dislikes" in voteObj) || !("rating" in voteObj))
+ return error("Couldn't fetch votes from the Return YouTube Dislike API");
+ if (getFeature("showVotes")) {
+ addVoteNumbers(voteCont, voteObj);
+ siteEvents.on("watchIdChanged", async (watchId) => {
+ var _a, _b;
+ const labelLikes = document.querySelector("ytmusic-like-button-renderer .bytm-vote-label.likes");
+ const labelDislikes = document.querySelector("ytmusic-like-button-renderer .bytm-vote-label.dislikes");
+ if (!labelLikes || !labelDislikes)
+ return error("Couldn't find vote label elements while updating like and dislike counts");
+ if (labelLikes.dataset.watchId === watchId && labelDislikes.dataset.watchId === watchId)
+ return log("Vote labels already updated for this video");
+ const voteObj = await fetchVideoVotes(watchId);
+ if (!voteObj || !("likes" in voteObj) || !("dislikes" in voteObj) || !("rating" in voteObj))
+ return error("Couldn't fetch votes from the Return YouTube Dislike API");
+ const likesLabelText = tp("vote_label_likes", voteObj.likes, formatNumber(voteObj.likes, "long"));
+ const dislikesLabelText = tp("vote_label_dislikes", voteObj.dislikes, formatNumber(voteObj.dislikes, "long"));
+ labelLikes.dataset.watchId = (_a = getWatchId()) !== null && _a !== void 0 ? _a : "";
+ labelLikes.textContent = formatNumber(voteObj.likes);
+ labelLikes.title = labelLikes.ariaLabel = likesLabelText;
+ labelDislikes.textContent = formatNumber(voteObj.dislikes);
+ labelDislikes.title = labelDislikes.ariaLabel = dislikesLabelText;
+ labelDislikes.dataset.watchId = (_b = getWatchId()) !== null && _b !== void 0 ? _b : "";
+ addSelectorListener("playerBar", "ytmusic-like-button-renderer#like-button-renderer", {
+ listener: (bar) => upsertVoteBtnLabels(bar, likesLabelText, dislikesLabelText),
+ });
+ });
+ }
+ }
+ catch (err) {
+ error("Couldn't initialize show votes feature due to an error:", err);
+ }
+ }
+ });
+}
+function addVoteNumbers(voteCont, voteObj) {
+ const likeBtn = voteCont.querySelector("#button-shape-like");
+ const dislikeBtn = voteCont.querySelector("#button-shape-dislike");
+ if (!likeBtn || !dislikeBtn)
+ return error("Couldn't find like or dislike button while adding vote numbers");
+ const createLabel = (amount, type) => {
+ var _a;
+ const label = document.createElement("span");
+ label.classList.add("bytm-vote-label", "bytm-no-select", type);
+ label.textContent = String(formatNumber(amount));
+ label.title = label.ariaLabel = tp(`vote_label_${type}`, amount, formatNumber(amount, "long"));
+ label.dataset.watchId = (_a = getWatchId()) !== null && _a !== void 0 ? _a : "";
+ label.addEventListener("click", (e) => {
+ var _a;
+ e.preventDefault();
+ e.stopPropagation();
+ (_a = (type === "likes" ? likeBtn : dislikeBtn).querySelector("button")) === null || _a === void 0 ? void 0 : _a.click();
+ });
+ return label;
+ };
+ addStyleFromResource("css-show_votes")
+ .catch((e) => error("Couldn't add CSS for show votes feature due to an error:", e));
+ const likeLblEl = createLabel(voteObj.likes, "likes");
+ likeBtn.insertAdjacentElement("afterend", likeLblEl);
+ const dislikeLblEl = createLabel(voteObj.dislikes, "dislikes");
+ dislikeBtn.insertAdjacentElement("afterend", dislikeLblEl);
+ upsertVoteBtnLabels(voteCont, likeLblEl.title, dislikeLblEl.title);
+ log("Added vote number labels to like and dislike buttons");
+}
+/** Updates or inserts the labels on the native like and dislike buttons */
+function upsertVoteBtnLabels(parentEl, likesLabelText, dislikesLabelText) {
+ const likeBtn = parentEl.querySelector("#button-shape-like button");
+ const dislikeBtn = parentEl.querySelector("#button-shape-dislike button");
+ if (likeBtn)
+ likeBtn.title = likeBtn.ariaLabel = likesLabelText;
+ if (dislikeBtn)
+ dislikeBtn.title = dislikeBtn.ariaLabel = dislikesLabelText;
+}//#region Dark Reader
+/** Disables Dark Reader if it is present */
+async function disableDarkReader() {
+ if (getFeature("disableDarkReaderSites") !== getDomain() && getFeature("disableDarkReaderSites") !== "all")
+ return;
+ const metaElem = document.createElement("meta");
+ metaElem.name = "darkreader-lock";
+ metaElem.id = "bytm-disable-dark-reader";
+ document.head.appendChild(metaElem);
+ info("Disabled Dark Reader");
+}
+//#region SponsorBlock
+/** Fixes the z-index of the SponsorBlock panel */
+async function fixSponsorBlock() {
+ try {
+ return addStyleFromResource("css-fix_sponsorblock");
}
- /**
- * 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 it shouldn't be.
- */
- function log(...args) {
- if (curLogLevel <= getLogLevel(args))
- console.log(consPrefix, ...args);
+ catch (err) {
+ error("Failed to fix SponsorBlock styling:", 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 it shouldn't be.
- */
- function info(...args) {
- if (curLogLevel <= getLogLevel(args))
- console.info(consPrefix, ...args);
+}
+//#region ThemeSong
+/** Adjust the BetterYTM styles if ThemeSong is ***not*** used */
+async function fixPlayerPageTheming() {
+ try {
+ return addStyleFromResource("css-fix_playerpage_theming");
}
- /** Logs all passed values to the console as a warning, no matter the log level. */
- function warn(...args) {
- console.warn(consPrefix, ...args);
+ catch (err) {
+ error("Failed to fix BetterYTM player page theming:", err);
}
- /** Logs all passed values to the console as an error, no matter the log level. */
- function error(...args) {
- var _a, _b;
- console.error(consPrefix, ...args);
- if (getFeature("showToastOnGenericError")) {
- const errName = (_b = (_a = args.find(a => a instanceof Error)) === null || _a === void 0 ? void 0 : _a.name) !== null && _b !== void 0 ? _b : t("error");
- UserUtils.debounce(() => showIconToast({
- message: t("generic_error_toast_encountered_error_type", errName),
- subtitle: t("generic_error_toast_click_for_details"),
- icon: "icon-error",
- iconFill: "var(--bytm-error-col)",
- onClick: () => getErrorDialog(errName, Array.isArray(args) ? args : []).open(),
- }))();
+}
+/** Sets the lightness of the theme color used by BYTM according to the configured lightness value */
+async function fixThemeSong() {
+ try {
+ const cssVarName = (() => {
+ switch (getFeature("themeSongLightness")) {
+ default:
+ case "darker":
+ return "--ts-palette-darkmuted-hex";
+ case "normal":
+ return "--ts-palette-muted-hex";
+ case "lighter":
+ return "--ts-palette-lightmuted-hex";
+ }
+ ;
+ })();
+ document.documentElement.style.setProperty("--bytm-themesong-bg-accent-col", `var(${cssVarName})`);
+ }
+ catch (err) {
+ error("Failed to set ThemeSong integration color lightness:", err);
+ }
+}/** Ratelimit budget timeframe in seconds - should reflect what's in geniURL's docs */
+const geniUrlRatelimitTimeframe = 30;
+//#region media control bar
+let currentSongTitle = "";
+/** Adds a lyrics button to the player bar */
+async function addPlayerBarLyricsBtn() {
+ addSelectorListener("playerBarMiddleButtons", "ytmusic-like-button-renderer#like-button-renderer", { listener: addActualLyricsBtn });
+}
+/** Actually adds the lyrics button after the like button renderer has been verified to exist */
+async function addActualLyricsBtn(likeContainer) {
+ const songTitleElem = document.querySelector(".content-info-wrapper > yt-formatted-string");
+ if (!songTitleElem)
+ return warn("Couldn't find song title element");
+ currentSongTitle = songTitleElem.title;
+ const spinnerIconUrl = await getResourceUrl("icon-spinner");
+ const lyricsIconUrl = await getResourceUrl("icon-lyrics");
+ const errorIconUrl = await getResourceUrl("icon-error");
+ const onMutation = async (mutations) => {
+ var _a, e_1, _b, _c;
+ try {
+ for (var _d = true, mutations_1 = __asyncValues(mutations), mutations_1_1; mutations_1_1 = await mutations_1.next(), _a = mutations_1_1.done, !_a; _d = true) {
+ _c = mutations_1_1.value;
+ _d = false;
+ const mut = _c;
+ const newTitle = mut.target.title;
+ if (newTitle !== currentSongTitle && newTitle.length > 0) {
+ const lyricsBtn = document.querySelector("#bytm-player-bar-lyrics-btn");
+ if (!lyricsBtn)
+ continue;
+ 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 = await 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;
+ lyricsBtn.ariaLabel = lyricsBtn.title = t("lyrics_not_found_click_open_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.ariaLabel = lyricsBtn.title = t("open_current_lyrics");
+ lyricsBtn.style.cursor = "pointer";
+ lyricsBtn.style.visibility = "visible";
+ lyricsBtn.style.display = "inline-flex";
+ lyricsBtn.style.pointerEvents = "initial";
+ }
+ }
}
- }
- /** Logs all passed values to the console with a debug-specific prefix */
- function dbg(...args) {
- console.log(consPrefixDbg, ...args);
- }
- //#region error dialog
- function getErrorDialog(errName, args) {
- return new MarkdownDialog({
- id: "generic-error",
- height: 400,
- width: 500,
- small: true,
- destroyOnClose: true,
- renderHeader() {
- const header = document.createElement("h2");
- header.classList.add("bytm-dialog-title");
- header.role = "heading";
- header.ariaLevel = "1";
- header.tabIndex = 0;
- header.textContent = header.ariaLabel = errName;
- return header;
- },
- body: `\
-${args.length > 0 ? args.join(" ") : t("generic_error_dialog_message")}
-${t("generic_error_dialog_open_console_note", consPrefix, packageJson.bugs.url)}`,
- });
- }
- //#region rrror classes
- class LyricsError extends Error {
- constructor(message) {
- super(message);
- this.name = "LyricsError";
+ catch (e_1_1) { e_1 = { error: e_1_1 }; }
+ finally {
+ try {
+ if (!_d && !_a && (_b = mutations_1.return)) await _b.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"] });
+ const lyricsBtnElem = await createLyricsBtn(undefined);
+ lyricsBtnElem.id = "bytm-player-bar-lyrics-btn";
+ // run parallel so the element is inserted as soon as possible
+ getCurrentLyricsUrl().then(url => {
+ url && addGeniusUrlToLyricsBtn(lyricsBtnElem, url);
+ });
+ log("Inserted lyrics button into media controls bar");
+ const thumbToggleElem = document.querySelector("#bytm-thumbnail-overlay-toggle");
+ if (thumbToggleElem)
+ thumbToggleElem.insertAdjacentElement("afterend", lyricsBtnElem);
+ else
+ likeContainer.insertAdjacentElement("afterend", lyricsBtnElem);
+}
+//#region lyrics utils
+/** Removes everything in parentheses from the passed song name */
+function sanitizeSong(songName) {
+ if (typeof songName !== "string")
+ return 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];
+ if (artists.match(/(f(ea)?t\.?|Remix|Edit|Flip|Cover|Night\s?Core|Bass\s?Boost|pro?d\.?)/i)) {
+ const parensRegex = /\(.+\)/gmi;
+ const squareParensRegex = /\[.+\]/gmi;
+ artists = artists
+ .replace(parensRegex, "")
+ .replace(squareParensRegex, "");
}
- class PluginError extends Error {
- constructor(message) {
- super(message);
- this.name = "PluginError";
+ return artists.trim();
+}
+/** Returns the lyrics URL from genius for the currently selected song */
+async function getCurrentLyricsUrl() {
+ 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 = getCurrentMediaType() === "video";
+ const songTitleElem = document.querySelector(".content-info-wrapper > yt-formatted-string");
+ const songMetaElem = document.querySelector("span.subtitle > yt-formatted-string :first-child");
+ if (!songTitleElem || !songMetaElem)
+ return undefined;
+ const songNameRaw = songTitleElem.title;
+ let songName = songNameRaw;
+ let artistName = songMetaElem.textContent;
+ if (isVideo) {
+ // for some fucking reason some music videos have YTM-like song title and artist separation, some don't
+ if (songName.includes("-")) {
+ const split = splitVideoTitle(songName);
+ songName = split.song;
+ artistName = split.artist;
+ }
}
- }
-
- /** Central serializer for all data stores */
- let serializer;
- /** Returns the serializer for all data stores */
- function getStoreSerializer() {
- if (!serializer) {
- serializer = new UserUtils.DataStoreSerializer([
- configStore,
- autoLikeStore,
- ], {
- addChecksum: true,
- ensureIntegrity: true,
+ if (!artistName)
+ return undefined;
+ const url = await fetchLyricsUrlTop(sanitizeArtists(artistName), sanitizeSong(songName));
+ if (url) {
+ emitInterface("bytm:lyricsLoaded", {
+ type: "current",
+ artists: artistName,
+ title: songName,
+ url,
});
}
- return serializer;
- }
- /** Downloads the current data stores as a single file */
- async function downloadData() {
- const serializer = getStoreSerializer();
- const pad = (num, len = 2) => String(num).padStart(len, "0");
- const d = new Date();
- const dateStr = `${pad(d.getFullYear(), 4)}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}_${pad(d.getHours())}-${pad(d.getMinutes())}`;
- const fileName = `BetterYTM ${packageJson.version} data export ${dateStr}.json`;
- const data = JSON.stringify(JSON.parse(await serializer.serialize()), undefined, 2);
- downloadFile(fileName, data, "application/json");
+ return url;
}
-
- //#region cfg menu btns
- let logoExchanged = false, improveLogoCalled = false;
- /** Adds a watermark beneath the logo */
- async function addWatermark() {
- const watermark = document.createElement("a");
- watermark.role = "button";
- watermark.id = "bytm-watermark";
- watermark.classList.add("style-scope", "ytmusic-nav-bar", "bytm-no-select");
- watermark.textContent = scriptInfo.name;
- watermark.ariaLabel = watermark.title = t("open_menu_tooltip", scriptInfo.name);
- watermark.tabIndex = 0;
- improveLogo();
- const watermarkOpenMenu = (e) => {
- e.stopPropagation();
- if ((!e.shiftKey && !e.ctrlKey) || logoExchanged)
- openCfgMenu();
- if (!logoExchanged && (e.shiftKey || e.ctrlKey))
- exchangeLogo();
- };
- onInteraction(watermark, watermarkOpenMenu);
- addSelectorListener("navBar", "ytmusic-nav-bar #left-content", {
- listener: (logoElem) => logoElem.insertAdjacentElement("afterend", watermark),
- });
- log("Added watermark element");
+ catch (err) {
+ getFeature("errorOnLyricsNotFound") && error("Couldn't resolve lyrics URL:", err);
+ return undefined;
}
- /** Turns the regular `
`-based logo into inline SVG to be able to animate and modify parts of it */
- async function improveLogo() {
- try {
- if (improveLogoCalled)
- return;
- improveLogoCalled = true;
- const res = await UserUtils.fetchAdvanced("https://music.youtube.com/img/on_platform_logo_dark.svg");
- const svg = await res.text();
- addSelectorListener("navBar", "ytmusic-logo a", {
- listener: (logoElem) => {
- var _a;
- logoElem.classList.add("bytm-mod-logo", "bytm-no-select");
- setInnerHtml(logoElem, 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);
- }
+}
+/** Fetches the top lyrics URL result from geniURL - **the passed parameters need to be sanitized first!** */
+async function fetchLyricsUrlTop(artist, song) {
+ var _a, _b;
+ try {
+ return (_b = (_a = (await fetchLyricsUrls(artist, song))) === null || _a === void 0 ? void 0 : _a[0]) === null || _b === void 0 ? void 0 : _b.url;
}
- /** Exchanges the default YTM logo into BetterYTM's logo with a sick ass animation */
- function exchangeLogo() {
- addSelectorListener("navBar", ".bytm-mod-logo", {
- listener: async (logoElem) => {
- if (logoElem.classList.contains("bytm-logo-exchanged"))
- return;
- logoExchanged = true;
- logoElem.classList.add("bytm-logo-exchanged");
- const iconUrl = await getResourceUrl(mode === "development" ? "img-logo_dev" : "img-logo");
- const newLogo = document.createElement("img");
- newLogo.classList.add("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);
- },
- });
+ catch (err) {
+ getFeature("errorOnLyricsNotFound") && error("Couldn't get lyrics URL due to error:", err);
+ return undefined;
}
- /** Called whenever the avatar popover menu exists on YTM to add a BYTM config menu button to the user menu popover */
- async function addConfigMenuOptionYTM(container) {
- const cfgOptElem = document.createElement("div");
- cfgOptElem.classList.add("bytm-cfg-menu-option");
- const cfgOptItemElem = document.createElement("div");
- cfgOptItemElem.classList.add("bytm-cfg-menu-option-item");
- cfgOptItemElem.role = "button";
- cfgOptItemElem.tabIndex = 0;
- cfgOptItemElem.ariaLabel = cfgOptItemElem.title = t("open_menu_tooltip", scriptInfo.name);
- onInteraction(cfgOptItemElem, async (e) => {
- const settingsBtnElem = document.querySelector("ytmusic-nav-bar ytmusic-settings-button tp-yt-paper-icon-button");
- settingsBtnElem === null || settingsBtnElem === void 0 ? void 0 : settingsBtnElem.click();
- await UserUtils.pauseFor(20);
- if ((!e.shiftKey && !e.ctrlKey) || logoExchanged)
- openCfgMenu();
- if (!logoExchanged && (e.shiftKey || e.ctrlKey))
- exchangeLogo();
+}
+/**
+ * Fetches the 5 best matching lyrics URLs from geniURL using a combo exact-ish and fuzzy search
+ * **the passed parameters need to be sanitized first!**
+ */
+async function fetchLyricsUrls(artist, song) {
+ var _a, _b, _c;
+ try {
+ const cacheEntry = getLyricsCacheEntry(artist, song);
+ if (cacheEntry) {
+ info(`Found lyrics URL in cache: ${cacheEntry.url}`);
+ return [cacheEntry];
+ }
+ const fetchUrl = constructUrl(`${getFeature("geniUrlBase")}/search`, {
+ disableFuzzy: null,
+ utm_source: `${scriptInfo.name} v${scriptInfo.version}${mode === "development" ? "-pre" : ""}`,
+ q: `${artist} ${song}`,
});
- const cfgOptIconElem = document.createElement("img");
- cfgOptIconElem.classList.add("bytm-cfg-menu-option-icon");
- cfgOptIconElem.src = await getResourceUrl(mode === "development" ? "img-logo_dev" : "img-logo");
- const cfgOptTextElem = document.createElement("div");
- cfgOptTextElem.classList.add("bytm-cfg-menu-option-text");
- cfgOptTextElem.textContent = t("config_menu_option", scriptInfo.name);
- cfgOptItemElem.appendChild(cfgOptIconElem);
- cfgOptItemElem.appendChild(cfgOptTextElem);
- cfgOptElem.appendChild(cfgOptItemElem);
- container.appendChild(cfgOptElem);
- improveLogo();
- log("Added BYTM-Configuration button to menu popover");
- }
- /** Called whenever the titlebar (masthead) exists on YT to add a BYTM config menu button */
- async function addConfigMenuOptionYT(container) {
- const cfgOptWrapperElem = document.createElement("div");
- cfgOptWrapperElem.classList.add("bytm-yt-cfg-menu-option", "darkreader-ignore");
- cfgOptWrapperElem.role = "button";
- cfgOptWrapperElem.tabIndex = 0;
- cfgOptWrapperElem.ariaLabel = cfgOptWrapperElem.title = t("open_menu_tooltip", scriptInfo.name);
- const cfgOptElem = document.createElement("div");
- cfgOptElem.classList.add("bytm-yt-cfg-menu-option-inner");
- const cfgOptImgElem = document.createElement("img");
- cfgOptImgElem.classList.add("bytm-yt-cfg-menu-option-icon");
- cfgOptImgElem.src = await getResourceUrl(mode === "development" ? "img-logo_dev" : "img-logo");
- const cfgOptItemElem = document.createElement("div");
- cfgOptItemElem.classList.add("bytm-yt-cfg-menu-option-item");
- cfgOptItemElem.textContent = scriptInfo.name;
- cfgOptElem.appendChild(cfgOptImgElem);
- cfgOptElem.appendChild(cfgOptItemElem);
- cfgOptWrapperElem.appendChild(cfgOptElem);
- onInteraction(cfgOptWrapperElem, openCfgMenu);
- const firstChild = container === null || container === void 0 ? void 0 : container.firstElementChild;
- if (firstChild)
- container.insertBefore(cfgOptWrapperElem, firstChild);
- else
- return error("Couldn't add config menu option to YT titlebar - couldn't find container element");
- }
- //#region anchor impr.
- /** Adds anchors around elements and tweaks existing ones so songs are easier to open in a new tab */
- async function addAnchorImprovements() {
- try {
- await addStyleFromResource("css-anchor_improvements");
+ log("Requesting lyrics from geniURL:", fetchUrl);
+ const token = getFeature("geniUrlToken");
+ const fetchRes = await UserUtils.fetchAdvanced(fetchUrl, Object.assign({}, (token ? {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ } : {})));
+ if (fetchRes.status === 429) {
+ const waitSeconds = Number((_a = fetchRes.headers.get("retry-after")) !== null && _a !== void 0 ? _a : geniUrlRatelimitTimeframe);
+ await showPrompt({ type: "alert", message: tp("lyrics_rate_limited", waitSeconds, waitSeconds) });
+ return undefined;
}
- catch (err) {
- error("Couldn't add anchor improvements CSS due to an error:", err);
+ else if (fetchRes.status < 200 || fetchRes.status >= 300) {
+ getFeature("errorOnLyricsNotFound") && error(new LyricsError(`Couldn't fetch lyrics URLs from geniURL - status: ${fetchRes.status} - response: ${(_c = (_b = (await fetchRes.json()).message) !== null && _b !== void 0 ? _b : await fetchRes.text()) !== null && _c !== void 0 ? _c : "(none)"}`));
+ return undefined;
}
- //#region 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"))
- 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);
- UserUtils.addParent(thumbnailElem, anchorElem);
- }
- };
- // home page
- addSelectorListener("body", "#contents.ytmusic-section-list-renderer ytmusic-carousel-shelf-renderer ytmusic-responsive-list-item-renderer", {
- continuous: true,
- all: true,
- listener: addListItemAnchors,
- });
- // related tab in /watch
- addSelectorListener("body", "ytmusic-tab-renderer[page-type=\"MUSIC_PAGE_TYPE_TRACK_RELATED\"] ytmusic-responsive-list-item-renderer", {
- continuous: true,
- all: true,
- listener: addListItemAnchors,
- });
- // playlists
- addSelectorListener("body", "#contents.ytmusic-section-list-renderer ytmusic-playlist-shelf-renderer ytmusic-responsive-list-item-renderer", {
- continuous: true,
- all: true,
- listener: addListItemAnchors,
- });
- // generic shelves
- addSelectorListener("body", "#contents.ytmusic-section-list-renderer ytmusic-shelf-renderer ytmusic-responsive-list-item-renderer", {
- continuous: true,
- all: true,
- listener: addListItemAnchors,
- });
+ const result = await fetchRes.json();
+ if (typeof result === "object" && result.error || !result || !result.all) {
+ getFeature("errorOnLyricsNotFound") && error(new LyricsError(`Couldn't fetch lyrics URLs from geniURL: ${result.message}`));
+ return undefined;
}
- catch (err) {
- error("Couldn't improve carousel shelf anchors due to an error:", err);
+ const allResults = result.all;
+ if (allResults.length === 0) {
+ warn("No lyrics URL found for the provided song");
+ return undefined;
}
- //#region sidebar
- try {
- const addSidebarAnchors = (sidebarCont) => {
- const items = sidebarCont.parentNode.querySelectorAll("ytmusic-guide-entry-renderer tp-yt-paper-item");
- improveSidebarAnchors(items);
- return items.length;
- };
- addSelectorListener("sideBar", "#contentContainer #guide-content #items ytmusic-guide-entry-renderer", {
- listener: (sidebarCont) => {
- const itemsAmt = addSidebarAnchors(sidebarCont);
- log(`Added anchors around ${itemsAmt} sidebar ${UserUtils.autoPlural("item", itemsAmt)}`);
- },
- });
- addSelectorListener("sideBarMini", "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 ${UserUtils.autoPlural("item", itemsAmt)}`);
- },
+ const allResultsSan = allResults
+ .filter(({ meta, url }) => (meta.title || meta.fullTitle) && meta.artists && url)
+ .map(({ meta, url }) => {
+ var _a;
+ return ({
+ meta: Object.assign(Object.assign({}, meta), { title: sanitizeSong(String((_a = meta.title) !== null && _a !== void 0 ? _a : meta.fullTitle)), artists: sanitizeArtists(String(meta.artists)) }),
+ url,
});
+ });
+ const topRes = allResultsSan[0];
+ topRes && addLyricsCacheEntryBest(topRes.meta.artists, topRes.meta.title, topRes.url);
+ return allResultsSan.map(r => ({
+ artist: r.meta.primaryArtist.name,
+ song: r.meta.title,
+ url: r.url,
+ }));
+ }
+ catch (err) {
+ getFeature("errorOnLyricsNotFound") && error("Couldn't get lyrics URL due to error:", err);
+ return undefined;
+ }
+}
+/** Adds the genius URL to the passed lyrics button element if it was previously instantiated with an undefined URL */
+async function addGeniusUrlToLyricsBtn(btnElem, geniusUrl) {
+ btnElem.href = geniusUrl;
+ btnElem.ariaLabel = btnElem.title = t("open_lyrics");
+ btnElem.style.visibility = "visible";
+ btnElem.style.display = "inline-flex";
+}
+/** Creates the base lyrics button element */
+async function createLyricsBtn(geniusUrl, hideIfLoading = true) {
+ const linkElem = document.createElement("a");
+ linkElem.classList.add("ytmusic-player-bar", "bytm-generic-btn");
+ linkElem.ariaLabel = linkElem.title = t("lyrics_loading");
+ 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.classList.add("bytm-generic-btn-img");
+ imgElem.src = await getResourceUrl("icon-lyrics");
+ onInteraction(linkElem, (e) => {
+ var _a;
+ const url = (_a = linkElem.href) !== null && _a !== void 0 ? _a : geniusUrl;
+ if (!url || e instanceof MouseEvent)
+ return;
+ if (!e.ctrlKey && !e.altKey)
+ openInTab(url);
+ }, {
+ preventDefault: false,
+ stopPropagation: false,
+ });
+ linkElem.appendChild(imgElem);
+ onInteraction(linkElem, async (e) => {
+ if (e.ctrlKey || e.altKey) {
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ const search = await showPrompt({ type: "prompt", message: t("open_lyrics_search_prompt") });
+ if (search && search.length > 0)
+ openInTab(`https://genius.com/search?q=${encodeURIComponent(search)}`);
}
- catch (err) {
- error("Couldn't add anchors to sidebar items due to an error:", err);
+ }, {
+ preventDefault: false,
+ stopPropagation: false,
+ });
+ 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("-") };
+}//#region init queue btns
+/** Initializes the queue buttons */
+async function initQueueButtons() {
+ const addCurrentQueueBtns = (evt) => {
+ let amt = 0;
+ for (const queueItm of evt.childNodes) {
+ if (!queueItm.classList.contains("bytm-has-queue-btns")) {
+ addQueueButtons(queueItm, undefined, "currentQueue");
+ amt++;
+ }
}
+ if (amt > 0)
+ log(`Added buttons to ${amt} new queue ${UserUtils.autoPlural("item", amt)}`);
+ };
+ // current queue
+ siteEvents.on("queueChanged", addCurrentQueueBtns);
+ siteEvents.on("autoplayQueueChanged", addCurrentQueueBtns);
+ const queueItems = document.querySelectorAll("#contents.ytmusic-player-queue > ytmusic-player-queue-item");
+ if (queueItems.length > 0) {
+ queueItems.forEach(itm => addQueueButtons(itm, undefined, "currentQueue"));
+ log(`Added buttons to ${queueItems.length} existing "current song queue" ${UserUtils.autoPlural("item", queueItems)}`);
+ }
+ // generic lists
+ const addGenericListQueueBtns = (listElem) => {
+ const queueItems = listElem.querySelectorAll("ytmusic-responsive-list-item-renderer");
+ if (queueItems.length === 0)
+ return;
+ let addedBtnsCount = 0;
+ queueItems.forEach(itm => {
+ if (itm.classList.contains("bytm-has-btns"))
+ return;
+ itm.classList.add("bytm-has-btns");
+ addQueueButtons(itm, ".flex-columns", "genericList", ["bytm-generic-list-queue-btn-container"], "afterParent");
+ addedBtnsCount++;
+ });
+ addedBtnsCount > 0 &&
+ log(`Added buttons to ${addedBtnsCount} new "generic song list" ${UserUtils.autoPlural("item", addedBtnsCount)} in list`, listElem);
+ };
+ const listSelector = `\
+ytmusic-playlist-shelf-renderer #contents,
+ytmusic-section-list-renderer[main-page-type="MUSIC_PAGE_TYPE_ALBUM"] ytmusic-shelf-renderer #contents,
+ytmusic-section-list-renderer[main-page-type="MUSIC_PAGE_TYPE_ARTIST"] ytmusic-shelf-renderer #contents,
+ytmusic-section-list-renderer[main-page-type="MUSIC_PAGE_TYPE_PLAYLIST"] ytmusic-shelf-renderer #contents\
+`;
+ if (getFeature("listButtonsPlacement") === "everywhere") {
+ const checkAddGenericBtns = (songLists) => {
+ for (const list of songLists)
+ addGenericListQueueBtns(list);
+ };
+ addSelectorListener("body", listSelector, {
+ all: true,
+ continuous: true,
+ debounce: 150,
+ // TODO: switch to longer debounce time and edge type "risingIdle" after UserUtils update
+ debounceEdge: "falling",
+ listener: checkAddGenericBtns,
+ });
+ siteEvents.on("pathChanged", () => {
+ const songLists = document.querySelectorAll(listSelector);
+ if (songLists.length > 0)
+ checkAddGenericBtns(songLists);
+ });
}
- const sidebarPaths = [
- "/",
- "/explore",
- "/library",
- ];
- /**
- * Adds anchors to the sidebar items so they can be opened in a new tab
- * @param sidebarItem
- */
- function improveSidebarAnchors(sidebarItems) {
- sidebarItems.forEach((item, i) => {
+}
+//#region add queue btns
+/**
+ * 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` or `ytmusic-responsive-list-item-renderer` to add queue buttons to
+ * @param listType The type of list the queue item is in
+ * @param classes Extra CSS classes to apply to the container
+ * @param insertPosition Where to insert the button container in relation to the parent element
+ */
+async function addQueueButtons(queueItem, containerParentSelector = ".song-info", listType = "currentQueue", classes = [], insertPosition = "child") {
+ const queueBtnsCont = document.createElement("div");
+ queueBtnsCont.classList.add(...["bytm-queue-btn-container", ...classes]);
+ const lyricsIconUrl = await getResourceUrl("icon-lyrics");
+ const deleteIconUrl = await getResourceUrl("icon-delete");
+ //#region lyrics btn
+ let lyricsBtnElem;
+ if (getFeature("lyricsQueueButton")) {
+ lyricsBtnElem = await createLyricsBtn(undefined, false);
+ lyricsBtnElem.ariaLabel = lyricsBtnElem.title = t("open_lyrics");
+ lyricsBtnElem.style.display = "inline-flex";
+ lyricsBtnElem.style.visibility = "initial";
+ lyricsBtnElem.style.pointerEvents = "initial";
+ lyricsBtnElem.role = "link";
+ lyricsBtnElem.tabIndex = 0;
+ onInteraction(lyricsBtnElem, async (e) => {
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.ariaLabel = anchorElem.title = t("middle_click_open_tab");
- anchorElem.addEventListener("click", (e) => {
- e.preventDefault();
- });
- UserUtils.addParent(item, anchorElem);
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ let song, artist;
+ if (listType === "currentQueue") {
+ const songInfo = queueItem.querySelector(".song-info");
+ if (!songInfo)
+ return;
+ const [songEl, artistEl] = songInfo.querySelectorAll("yt-formatted-string");
+ song = songEl === null || songEl === void 0 ? void 0 : songEl.textContent;
+ artist = artistEl === null || artistEl === void 0 ? void 0 : artistEl.textContent;
+ }
+ else if (listType === "genericList") {
+ const songEl = queueItem.querySelector(".title-column yt-formatted-string a");
+ let artistEl = null;
+ if (location.pathname.startsWith("/playlist"))
+ artistEl = document.querySelector("ytmusic-detail-header-renderer .metadata .subtitle-container yt-formatted-string a");
+ if (!artistEl || !artistEl.textContent)
+ artistEl = queueItem.querySelector(".secondary-flex-columns yt-formatted-string:first-child a");
+ song = songEl === null || songEl === void 0 ? void 0 : songEl.textContent;
+ artist = artistEl === null || artistEl === void 0 ? void 0 : artistEl.textContent;
+ if (!artist) {
+ // new playlist design
+ artistEl = document.querySelector("ytmusic-responsive-header-renderer .strapline a.yt-formatted-string[href]");
+ artist = artistEl === null || artistEl === void 0 ? void 0 : artistEl.textContent;
+ }
+ }
+ else
+ return;
+ if (!song || !artist)
+ return error("Couldn't get song or artist name from queue item - song:", song, "- artist:", artist);
+ let lyricsUrl;
+ const artistsSan = sanitizeArtists(artist);
+ const songSan = sanitizeSong(song);
+ const splitTitle = splitVideoTitle(songSan);
+ const cachedLyricsEntry = songSan.includes("-")
+ ? getLyricsCacheEntry(splitTitle.artist, splitTitle.song)
+ : getLyricsCacheEntry(artistsSan, songSan);
+ if (cachedLyricsEntry)
+ lyricsUrl = cachedLyricsEntry.url;
+ else if (!queueItem.hasAttribute("data-bytm-loading")) {
+ const imgEl = lyricsBtnElem === null || lyricsBtnElem === void 0 ? void 0 : lyricsBtnElem.querySelector("img");
+ if (!imgEl)
+ return;
+ if (!cachedLyricsEntry) {
+ queueItem.setAttribute("data-bytm-loading", "");
+ imgEl.src = await getResourceUrl("icon-spinner");
+ imgEl.classList.add("bytm-spinner");
+ }
+ lyricsUrl = (_a = cachedLyricsEntry === null || cachedLyricsEntry === void 0 ? void 0 : cachedLyricsEntry.url) !== null && _a !== void 0 ? _a : await fetchLyricsUrlTop(artistsSan, songSan);
+ if (lyricsUrl) {
+ emitInterface("bytm:lyricsLoaded", {
+ type: "queue",
+ artists: artist,
+ title: song,
+ url: lyricsUrl,
+ });
+ }
+ const resetImgElem = () => {
+ imgEl.src = lyricsIconUrl;
+ imgEl.classList.remove("bytm-spinner");
+ };
+ if (!cachedLyricsEntry) {
+ queueItem.removeAttribute("data-bytm-loading");
+ // so the new image doesn't "blink"
+ setTimeout(resetImgElem, 100);
+ }
+ if (!lyricsUrl) {
+ resetImgElem();
+ if (await showPrompt({ type: "confirm", message: t("lyrics_not_found_confirm_open_search") }))
+ openInTab(`https://genius.com/search?q=${encodeURIComponent(`${artistsSan} - ${songSan}`)}`);
+ return;
+ }
+ }
+ lyricsUrl && openInTab(lyricsUrl);
});
}
- //#region share track par.
- /** Removes the ?si tracking parameter from share URLs */
- async function initRemShareTrackParam() {
- const removeSiParam = (inputElem) => {
+ //#region delete btn
+ let deleteBtnElem;
+ if (getFeature("deleteFromQueueButton")) {
+ deleteBtnElem = document.createElement("a");
+ deleteBtnElem.ariaLabel = deleteBtnElem.title = (listType === "currentQueue" ? t("remove_from_queue") : t("delete_from_list"));
+ deleteBtnElem.classList.add("ytmusic-player-bar", "bytm-delete-from-queue", "bytm-generic-btn");
+ deleteBtnElem.role = "button";
+ deleteBtnElem.tabIndex = 0;
+ deleteBtnElem.style.visibility = "initial";
+ const imgElem = document.createElement("img");
+ imgElem.classList.add("bytm-generic-btn-img");
+ imgElem.src = deleteIconUrl;
+ onInteraction(deleteBtnElem, async (e) => {
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ // 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 {
- if (!inputElem.value.match(/(&|\?)si=/i))
- return;
- const url = new URL(inputElem.value);
- url.searchParams.delete("si");
- inputElem.value = String(url);
- log(`Removed tracking parameter from share link -> ${url}`);
+ // three dots button to open the popup menu of a queue item
+ const dotsBtnElem = queueItem.querySelector("ytmusic-menu-renderer yt-button-shape[id=\"button-shape\"] button");
+ if (dotsBtnElem) {
+ if (queuePopupCont)
+ queuePopupCont.setAttribute("data-bytm-hidden", "true");
+ dotsBtnElem.click();
+ }
+ else {
+ info("Couldn't find three dots button in queue item, trying to open the context menu manually");
+ queueItem.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: false }));
+ }
+ 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");
+ await UserUtils.pauseFor(15);
+ const removeFromQueueBtn = queuePopupCont === null || queuePopupCont === void 0 ? void 0 : queuePopupCont.querySelector("tp-yt-paper-listbox ytmusic-menu-service-item-renderer:nth-of-type(3)");
+ removeFromQueueBtn === null || removeFromQueueBtn === void 0 ? void 0 : removeFromQueueBtn.click();
+ // queue items aren't removed automatically outside of the current queue
+ if (removeFromQueueBtn && listType === "genericList") {
+ await UserUtils.pauseFor(200);
+ clearInner(queueItem);
+ queueItem.remove();
+ }
+ if (!removeFromQueueBtn) {
+ error("Couldn't find 'remove from queue' button in queue item three dots menu.\nPlease make sure all autoplay restrictions on your browser's side are disabled for this page.");
+ dotsBtnElem === null || dotsBtnElem === void 0 ? void 0 : dotsBtnElem.click();
+ imgElem.src = await getResourceUrl("icon-error");
+ if (deleteBtnElem)
+ deleteBtnElem.ariaLabel = deleteBtnElem.title = (listType === "currentQueue" ? t("couldnt_remove_from_queue") : t("couldnt_delete_from_list"));
+ }
}
catch (err) {
- warn("Couldn't remove tracking parameter from share link due to error:", err);
+ error("Couldn't remove song from queue due to error:", err);
}
- };
- const [sharePanelSel, inputSel] = (() => {
- switch (getDomain()) {
- case "ytm": return ["tp-yt-paper-dialog ytmusic-unified-share-panel-renderer", "input#share-url"];
- case "yt": return ["ytd-unified-share-panel-renderer", "input#share-url"];
+ finally {
+ queuePopupCont === null || queuePopupCont === void 0 ? void 0 : queuePopupCont.removeAttribute("data-bytm-hidden");
}
- })();
- addSelectorListener("body", sharePanelSel, {
- listener: (sharePanelEl) => {
- const obs = new MutationObserver(() => {
- const inputElem = sharePanelEl.querySelector(inputSel);
- inputElem && removeSiParam(inputElem);
- });
- obs.observe(sharePanelEl, {
- childList: true,
- subtree: true,
- characterData: true,
- attributeFilter: ["aria-hidden", "aria-checked", "checked"],
- });
- },
});
- }
- //#region fix spacing
- /** Applies global CSS to fix various spacings */
- async function fixSpacing() {
- if (!await addStyleFromResource("css-fix_spacing"))
- error("Couldn't fix spacing");
- }
- //#region ab.queue btns
- async function initAboveQueueBtns() {
- const { scrollToActiveSongBtn, clearQueueBtn } = getFeatures();
- if (!await addStyleFromResource("css-above_queue_btns"))
- error("Couldn't add CSS for above queue buttons");
- const contBtns = [
- {
- condition: scrollToActiveSongBtn,
- id: "scroll-to-active",
- resourceName: "icon-skip_to",
- titleKey: "scroll_to_playing",
- async interaction(evt) {
- const activeItem = document.querySelector("#side-panel .ytmusic-player-queue ytmusic-player-queue-item[play-button-state=\"loading\"], #side-panel .ytmusic-player-queue ytmusic-player-queue-item[play-button-state=\"playing\"], #side-panel .ytmusic-player-queue ytmusic-player-queue-item[play-button-state=\"paused\"]");
- if (!activeItem)
- return;
- activeItem.scrollIntoView({
- behavior: evt.shiftKey ? "instant" : "smooth",
- block: evt.ctrlKey || evt.altKey ? "start" : "center",
- inline: "center",
- });
- },
- },
- {
- condition: clearQueueBtn,
- id: "clear-queue",
- resourceName: "icon-clear_list",
- titleKey: "clear_list",
- async interaction(evt) {
- try {
- if (evt.shiftKey || await showPrompt({ type: "confirm", message: t("clear_list_confirm") })) {
- const url = new URL(location.href);
- url.searchParams.delete("list");
- url.searchParams.set("time_continue", String(await getVideoTime(0)));
- location.assign(url);
- }
- }
- catch (err) {
- error("Couldn't clear queue due to an error:", err);
- }
- },
- },
- ];
- if (!contBtns.some(b => Boolean(b.condition)))
- return;
- addSelectorListener("sidePanel", "ytmusic-tab-renderer ytmusic-queue-header-renderer #buttons", {
- async listener(rightBtnsEl) {
- try {
- const aboveQueueBtnCont = document.createElement("div");
- aboveQueueBtnCont.id = "bytm-above-queue-btn-cont";
- UserUtils.addParent(rightBtnsEl, aboveQueueBtnCont);
- const headerEl = rightBtnsEl.closest("ytmusic-queue-header-renderer");
- if (!headerEl)
- return error("Couldn't find queue header element while adding above queue buttons");
- siteEvents.on("fullscreenToggled", (isFullscreen) => {
- headerEl.classList[isFullscreen ? "add" : "remove"]("hidden");
- });
- const wrapperElem = document.createElement("div");
- wrapperElem.id = "bytm-above-queue-btn-wrapper";
- for (const item of contBtns) {
- if (Boolean(item.condition) === false)
- continue;
- const btnElem = await createCircularBtn({
- resourceName: item.resourceName,
- onClick: item.interaction,
- title: t(item.titleKey),
- });
- btnElem.id = `bytm-${item.id}-btn`;
- btnElem.classList.add("ytmusic-player-bar", "bytm-generic-btn", "bytm-above-queue-btn");
- wrapperElem.appendChild(btnElem);
- }
- rightBtnsEl.insertAdjacentElement("beforebegin", wrapperElem);
- }
- catch (err) {
- error("Couldn't add above queue buttons due to an error:", err);
- }
- },
- });
- }
- //#region thumb.overlay
- /** To be changed when the toggle button is pressed - used to invert the state of "showOverlay" */
- let invertOverlay = false;
- async function initThumbnailOverlay() {
- const toggleBtnShown = getFeature("thumbnailOverlayToggleBtnShown");
- if (getFeature("thumbnailOverlayBehavior") === "never" && !toggleBtnShown)
- return;
- // so the script init doesn't keep waiting until a /watch page is loaded
- waitVideoElementReady().then(() => {
- const playerSelector = "ytmusic-player#player";
- const playerEl = document.querySelector(playerSelector);
- if (!playerEl)
- return error("Couldn't find video player element while adding thumbnail overlay");
- /** Checks and updates the overlay and toggle button states based on the current song type (yt video or ytm song) */
- const updateOverlayVisibility = async () => {
- if (!domLoaded)
- return;
- const behavior = getFeature("thumbnailOverlayBehavior");
- let showOverlay = behavior === "always";
- const isVideo = getCurrentMediaType() === "video";
- if (behavior === "videosOnly" && isVideo)
- showOverlay = true;
- else if (behavior === "songsOnly" && !isVideo)
- showOverlay = true;
- showOverlay = invertOverlay ? !showOverlay : showOverlay;
- const overlayElem = document.querySelector("#bytm-thumbnail-overlay");
- const thumbElem = document.querySelector("#bytm-thumbnail-overlay-img");
- const indicatorElem = document.querySelector("#bytm-thumbnail-overlay-indicator");
- if (overlayElem)
- overlayElem.style.display = showOverlay ? "block" : "none";
- if (thumbElem)
- thumbElem.ariaHidden = String(!showOverlay);
- if (indicatorElem) {
- indicatorElem.style.display = showOverlay ? "block" : "none";
- indicatorElem.ariaHidden = String(!showOverlay);
- }
- if (getFeature("thumbnailOverlayToggleBtnShown")) {
- addSelectorListener("playerBarMiddleButtons", "#bytm-thumbnail-overlay-toggle", {
- async listener(toggleBtnElem) {
- const toggleBtnImgElem = toggleBtnElem.querySelector("img");
- if (toggleBtnImgElem)
- toggleBtnImgElem.src = await getResourceUrl(`icon-image${showOverlay ? "_filled" : ""}`);
- if (toggleBtnElem)
- toggleBtnElem.ariaLabel = toggleBtnElem.title = t(`thumbnail_overlay_toggle_btn_tooltip${showOverlay ? "_hide" : "_show"}`);
- },
- });
- }
- };
- const applyThumbUrl = async (watchId) => {
- try {
- const thumbUrl = await getBestThumbnailUrl(watchId);
- if (thumbUrl) {
- const toggleBtnElem = document.querySelector("#bytm-thumbnail-overlay-toggle");
- const thumbImgElem = document.querySelector("#bytm-thumbnail-overlay-img");
- if ((toggleBtnElem === null || toggleBtnElem === void 0 ? void 0 : toggleBtnElem.href) === thumbUrl && (thumbImgElem === null || thumbImgElem === void 0 ? void 0 : thumbImgElem.src) === thumbUrl)
- return;
- if (toggleBtnElem)
- toggleBtnElem.href = thumbUrl;
- if (thumbImgElem)
- thumbImgElem.src = thumbUrl;
- log("Applied thumbnail URL to overlay:", thumbUrl);
- }
- else
- error("Couldn't get thumbnail URL for watch ID", watchId);
- }
- catch (err) {
- error("Couldn't apply thumbnail URL to overlay due to an error:", err);
- }
- };
- const unsubWatchIdChanged = siteEvents.on("watchIdChanged", (watchId) => {
- unsubWatchIdChanged();
- addSelectorListener("body", "#bytm-thumbnail-overlay", {
- listener: () => {
- applyThumbUrl(watchId);
- updateOverlayVisibility();
- },
- });
- });
- const createElements = async () => {
- try {
- // overlay
- const overlayElem = document.createElement("div");
- overlayElem.id = "bytm-thumbnail-overlay";
- overlayElem.title = ""; // prevent child titles from propagating
- overlayElem.classList.add("bytm-no-select");
- overlayElem.style.display = "none";
- let indicatorElem;
- if (getFeature("thumbnailOverlayShowIndicator")) {
- indicatorElem = document.createElement("img");
- indicatorElem.id = "bytm-thumbnail-overlay-indicator";
- indicatorElem.src = await getResourceUrl("icon-image");
- indicatorElem.role = "presentation";
- indicatorElem.title = indicatorElem.ariaLabel = t("thumbnail_overlay_indicator_tooltip");
- indicatorElem.ariaHidden = "true";
- indicatorElem.style.display = "none";
- indicatorElem.style.opacity = String(getFeature("thumbnailOverlayIndicatorOpacity") / 100);
- }
- const thumbImgElem = document.createElement("img");
- thumbImgElem.id = "bytm-thumbnail-overlay-img";
- thumbImgElem.role = "presentation";
- thumbImgElem.ariaHidden = "true";
- thumbImgElem.style.objectFit = getFeature("thumbnailOverlayImageFit");
- overlayElem.appendChild(thumbImgElem);
- playerEl.appendChild(overlayElem);
- indicatorElem && playerEl.appendChild(indicatorElem);
- siteEvents.on("watchIdChanged", async (watchId) => {
- invertOverlay = false;
- applyThumbUrl(watchId);
- updateOverlayVisibility();
- });
- const params = new URL(location.href).searchParams;
- if (params.has("v")) {
- applyThumbUrl(params.get("v"));
- updateOverlayVisibility();
- }
- // toggle button
- if (toggleBtnShown) {
- const toggleBtnElem = createRipple(document.createElement("a"));
- toggleBtnElem.id = "bytm-thumbnail-overlay-toggle";
- toggleBtnElem.role = "button";
- toggleBtnElem.tabIndex = 0;
- toggleBtnElem.classList.add("ytmusic-player-bar", "bytm-generic-btn", "bytm-no-select");
- onInteraction(toggleBtnElem, (e) => {
- if (e.shiftKey)
- return openInTab(toggleBtnElem.href, false);
- invertOverlay = !invertOverlay;
- updateOverlayVisibility();
- });
- const imgElem = document.createElement("img");
- imgElem.classList.add("bytm-generic-btn-img");
- toggleBtnElem.appendChild(imgElem);
- addSelectorListener("playerBarMiddleButtons", "ytmusic-like-button-renderer#like-button-renderer", {
- listener: (likeContainer) => likeContainer.insertAdjacentElement("afterend", toggleBtnElem),
- });
- }
- log("Added thumbnail overlay");
- }
- catch (err) {
- error("Couldn't create thumbnail overlay elements due to an error:", err);
- }
- };
- addSelectorListener("mainPanel", playerSelector, {
- listener(playerEl) {
- if (playerEl.getAttribute("player-ui-state") === "INACTIVE") {
- const obs = new MutationObserver(() => {
- if (playerEl.getAttribute("player-ui-state") === "INACTIVE")
- return;
- createElements();
- obs.disconnect();
- });
- obs.observe(playerEl, {
- attributes: true,
- attributeFilter: ["player-ui-state"],
- });
- }
- else
- createElements();
- },
- });
- });
- }
- //#region idle hide cursor
- async function initHideCursorOnIdle() {
- addSelectorListener("mainPanel", "ytmusic-player#player", {
- listener(vidContainer) {
- const overlaySelector = "ytmusic-player #song-media-window";
- const overlayElem = document.querySelector(overlaySelector);
- if (!overlayElem)
- return warn("Couldn't find overlay element while initializing cursor hiding");
- /** Timer after which the cursor is hidden */
- let cursorHideTimer;
- /** Timer for the opacity transition while switching to the hidden state */
- let hideTransTimer;
- const hide = () => {
- if (!getFeature("hideCursorOnIdle"))
- return;
- if (vidContainer.classList.contains("bytm-cursor-hidden"))
- return;
- overlayElem.style.opacity = ".000001 !important";
- hideTransTimer = setTimeout(() => {
- overlayElem.style.display = "none";
- vidContainer.style.cursor = "none";
- vidContainer.classList.add("bytm-cursor-hidden");
- hideTransTimer = undefined;
- }, 200);
- };
- const show = () => {
- hideTransTimer && clearTimeout(hideTransTimer);
- if (!vidContainer.classList.contains("bytm-cursor-hidden"))
- return;
- vidContainer.classList.remove("bytm-cursor-hidden");
- vidContainer.style.cursor = "initial";
- overlayElem.style.display = "initial";
- overlayElem.style.opacity = "1 !important";
- };
- const cursorHideTimerCb = () => cursorHideTimer = setTimeout(hide, getFeature("hideCursorOnIdleDelay") * 1000);
- const onMove = () => {
- cursorHideTimer && clearTimeout(cursorHideTimer);
- show();
- cursorHideTimerCb();
- };
- vidContainer.addEventListener("mouseenter", onMove);
- vidContainer.addEventListener("mousemove", UserUtils.debounce(onMove, 200, "rising"));
- vidContainer.addEventListener("mouseleave", () => {
- cursorHideTimer && clearTimeout(cursorHideTimer);
- hideTransTimer && clearTimeout(hideTransTimer);
- hide();
- });
- vidContainer.addEventListener("click", () => {
- show();
- cursorHideTimerCb();
- setTimeout(hide, 3000);
- });
- log("Initialized cursor hiding on idle");
- },
- });
- }
- //#region fix HDR
- /** Prevents visual issues when using HDR */
- async function fixHdrIssues() {
- if (!await addStyleFromResource("css-fix_hdr"))
- error("Couldn't load stylesheet to fix HDR issues");
- else
- log("Fixed HDR issues");
- }
- //#region show vote nums
- /** Shows the amount of likes and dislikes on the current song */
- async function initShowVotes() {
- addSelectorListener("playerBar", ".middle-controls-buttons ytmusic-like-button-renderer", {
- async listener(voteCont) {
- try {
- const watchId = getWatchId();
- if (!watchId) {
- await siteEvents.once("watchIdChanged");
- return initShowVotes();
- }
- const voteObj = await fetchVideoVotes(watchId);
- if (!voteObj || !("likes" in voteObj) || !("dislikes" in voteObj) || !("rating" in voteObj))
- return error("Couldn't fetch votes from the Return YouTube Dislike API");
- if (getFeature("showVotes")) {
- addVoteNumbers(voteCont, voteObj);
- siteEvents.on("watchIdChanged", async (watchId) => {
- var _a, _b;
- const labelLikes = document.querySelector("ytmusic-like-button-renderer .bytm-vote-label.likes");
- const labelDislikes = document.querySelector("ytmusic-like-button-renderer .bytm-vote-label.dislikes");
- if (!labelLikes || !labelDislikes)
- return error("Couldn't find vote label elements while updating like and dislike counts");
- if (labelLikes.dataset.watchId === watchId && labelDislikes.dataset.watchId === watchId)
- return log("Vote labels already updated for this video");
- const voteObj = await fetchVideoVotes(watchId);
- if (!voteObj || !("likes" in voteObj) || !("dislikes" in voteObj) || !("rating" in voteObj))
- return error("Couldn't fetch votes from the Return YouTube Dislike API");
- const likesLabelText = tp("vote_label_likes", voteObj.likes, formatNumber(voteObj.likes, "long"));
- const dislikesLabelText = tp("vote_label_dislikes", voteObj.dislikes, formatNumber(voteObj.dislikes, "long"));
- labelLikes.dataset.watchId = (_a = getWatchId()) !== null && _a !== void 0 ? _a : "";
- labelLikes.textContent = formatNumber(voteObj.likes);
- labelLikes.title = labelLikes.ariaLabel = likesLabelText;
- labelDislikes.textContent = formatNumber(voteObj.dislikes);
- labelDislikes.title = labelDislikes.ariaLabel = dislikesLabelText;
- labelDislikes.dataset.watchId = (_b = getWatchId()) !== null && _b !== void 0 ? _b : "";
- addSelectorListener("playerBar", "ytmusic-like-button-renderer#like-button-renderer", {
- listener: (bar) => upsertVoteBtnLabels(bar, likesLabelText, dislikesLabelText),
- });
- });
- }
- }
- catch (err) {
- error("Couldn't initialize show votes feature due to an error:", err);
- }
- }
- });
- }
- function addVoteNumbers(voteCont, voteObj) {
- const likeBtn = voteCont.querySelector("#button-shape-like");
- const dislikeBtn = voteCont.querySelector("#button-shape-dislike");
- if (!likeBtn || !dislikeBtn)
- return error("Couldn't find like or dislike button while adding vote numbers");
- const createLabel = (amount, type) => {
- var _a;
- const label = document.createElement("span");
- label.classList.add("bytm-vote-label", "bytm-no-select", type);
- label.textContent = String(formatNumber(amount));
- label.title = label.ariaLabel = tp(`vote_label_${type}`, amount, formatNumber(amount, "long"));
- label.dataset.watchId = (_a = getWatchId()) !== null && _a !== void 0 ? _a : "";
- label.addEventListener("click", (e) => {
- var _a;
- e.preventDefault();
- e.stopPropagation();
- (_a = (type === "likes" ? likeBtn : dislikeBtn).querySelector("button")) === null || _a === void 0 ? void 0 : _a.click();
- });
- return label;
- };
- addStyleFromResource("css-show_votes")
- .catch((e) => error("Couldn't add CSS for show votes feature due to an error:", e));
- const likeLblEl = createLabel(voteObj.likes, "likes");
- likeBtn.insertAdjacentElement("afterend", likeLblEl);
- const dislikeLblEl = createLabel(voteObj.dislikes, "dislikes");
- dislikeBtn.insertAdjacentElement("afterend", dislikeLblEl);
- upsertVoteBtnLabels(voteCont, likeLblEl.title, dislikeLblEl.title);
- log("Added vote number labels to like and dislike buttons");
- }
- /** Updates or inserts the labels on the native like and dislike buttons */
- function upsertVoteBtnLabels(parentEl, likesLabelText, dislikesLabelText) {
- const likeBtn = parentEl.querySelector("#button-shape-like button");
- const dislikeBtn = parentEl.querySelector("#button-shape-dislike button");
- if (likeBtn)
- likeBtn.title = likeBtn.ariaLabel = likesLabelText;
- if (dislikeBtn)
- dislikeBtn.title = dislikeBtn.ariaLabel = dislikesLabelText;
- }
-
- //#region Dark Reader
- /** Disables Dark Reader if it is present */
- async function disableDarkReader() {
- if (getFeature("disableDarkReaderSites") !== getDomain() && getFeature("disableDarkReaderSites") !== "all")
- return;
- const metaElem = document.createElement("meta");
- metaElem.name = "darkreader-lock";
- metaElem.id = "bytm-disable-dark-reader";
- document.head.appendChild(metaElem);
- info("Disabled Dark Reader");
- }
- //#region SponsorBlock
- /** Fixes the z-index of the SponsorBlock panel */
- async function fixSponsorBlock() {
- try {
- return addStyleFromResource("css-fix_sponsorblock");
- }
- catch (err) {
- error("Failed to fix SponsorBlock styling:", err);
- }
- }
- //#region ThemeSong
- /** Adjust the BetterYTM styles if ThemeSong is ***not*** used */
- async function fixPlayerPageTheming() {
- try {
- return addStyleFromResource("css-fix_playerpage_theming");
- }
- catch (err) {
- error("Failed to fix BetterYTM player page theming:", err);
- }
- }
- /** Sets the lightness of the theme color used by BYTM according to the configured lightness value */
- async function fixThemeSong() {
- try {
- const cssVarName = (() => {
- switch (getFeature("themeSongLightness")) {
- default:
- case "darker":
- return "--ts-palette-darkmuted-hex";
- case "normal":
- return "--ts-palette-muted-hex";
- case "lighter":
- return "--ts-palette-lightmuted-hex";
- }
- ;
- })();
- document.documentElement.style.setProperty("--bytm-themesong-bg-accent-col", `var(${cssVarName})`);
- }
- catch (err) {
- error("Failed to set ThemeSong integration color lightness:", err);
- }
- }
-
- /** Ratelimit budget timeframe in seconds - should reflect what's in geniURL's docs */
- const geniUrlRatelimitTimeframe = 30;
- //#region media control bar
- let currentSongTitle = "";
- /** Adds a lyrics button to the player bar */
- async function addPlayerBarLyricsBtn() {
- addSelectorListener("playerBarMiddleButtons", "ytmusic-like-button-renderer#like-button-renderer", { listener: addActualLyricsBtn });
- }
- /** Actually adds the lyrics button after the like button renderer has been verified to exist */
- async function addActualLyricsBtn(likeContainer) {
- const songTitleElem = document.querySelector(".content-info-wrapper > yt-formatted-string");
- if (!songTitleElem)
- return warn("Couldn't find song title element");
- currentSongTitle = songTitleElem.title;
- const spinnerIconUrl = await getResourceUrl("icon-spinner");
- const lyricsIconUrl = await getResourceUrl("icon-lyrics");
- const errorIconUrl = await getResourceUrl("icon-error");
- const onMutation = async (mutations) => {
- var _a, e_1, _b, _c;
- try {
- for (var _d = true, mutations_1 = __asyncValues(mutations), mutations_1_1; mutations_1_1 = await mutations_1.next(), _a = mutations_1_1.done, !_a; _d = true) {
- _c = mutations_1_1.value;
- _d = false;
- const mut = _c;
- const newTitle = mut.target.title;
- if (newTitle !== currentSongTitle && newTitle.length > 0) {
- const lyricsBtn = document.querySelector("#bytm-player-bar-lyrics-btn");
- if (!lyricsBtn)
- continue;
- 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 = await 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;
- lyricsBtn.ariaLabel = lyricsBtn.title = t("lyrics_not_found_click_open_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.ariaLabel = lyricsBtn.title = t("open_current_lyrics");
- lyricsBtn.style.cursor = "pointer";
- lyricsBtn.style.visibility = "visible";
- lyricsBtn.style.display = "inline-flex";
- lyricsBtn.style.pointerEvents = "initial";
- }
- }
- }
- catch (e_1_1) { e_1 = { error: e_1_1 }; }
- finally {
- try {
- if (!_d && !_a && (_b = mutations_1.return)) await _b.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"] });
- const lyricsBtnElem = await createLyricsBtn(undefined);
- lyricsBtnElem.id = "bytm-player-bar-lyrics-btn";
- // run parallel so the element is inserted as soon as possible
- getCurrentLyricsUrl().then(url => {
- url && addGeniusUrlToLyricsBtn(lyricsBtnElem, url);
- });
- log("Inserted lyrics button into media controls bar");
- const thumbToggleElem = document.querySelector("#bytm-thumbnail-overlay-toggle");
- if (thumbToggleElem)
- thumbToggleElem.insertAdjacentElement("afterend", lyricsBtnElem);
- else
- likeContainer.insertAdjacentElement("afterend", lyricsBtnElem);
- }
- //#region lyrics utils
- /** Removes everything in parentheses from the passed song name */
- function sanitizeSong(songName) {
- if (typeof songName !== "string")
- return 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];
- if (artists.match(/(f(ea)?t\.?|Remix|Edit|Flip|Cover|Night\s?Core|Bass\s?Boost|pro?d\.?)/i)) {
- const parensRegex = /\(.+\)/gmi;
- const squareParensRegex = /\[.+\]/gmi;
- artists = artists
- .replace(parensRegex, "")
- .replace(squareParensRegex, "");
- }
- return artists.trim();
- }
- /** Returns the lyrics URL from genius for the currently selected song */
- async function getCurrentLyricsUrl() {
- 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 = getCurrentMediaType() === "video";
- const songTitleElem = document.querySelector(".content-info-wrapper > yt-formatted-string");
- const songMetaElem = document.querySelector("span.subtitle > yt-formatted-string :first-child");
- if (!songTitleElem || !songMetaElem)
- return undefined;
- const songNameRaw = songTitleElem.title;
- let songName = songNameRaw;
- let artistName = songMetaElem.textContent;
- if (isVideo) {
- // for some fucking reason some music videos have YTM-like song title and artist separation, some don't
- if (songName.includes("-")) {
- const split = splitVideoTitle(songName);
- songName = split.song;
- artistName = split.artist;
- }
- }
- if (!artistName)
- return undefined;
- const url = await fetchLyricsUrlTop(sanitizeArtists(artistName), sanitizeSong(songName));
- if (url) {
- emitInterface("bytm:lyricsLoaded", {
- type: "current",
- artists: artistName,
- title: songName,
- url,
- });
- }
- return url;
- }
- catch (err) {
- getFeature("errorOnLyricsNotFound") && error("Couldn't resolve lyrics URL:", err);
- return undefined;
- }
- }
- /** Fetches the top lyrics URL result from geniURL - **the passed parameters need to be sanitized first!** */
- async function fetchLyricsUrlTop(artist, song) {
- var _a, _b;
- try {
- return (_b = (_a = (await fetchLyricsUrls(artist, song))) === null || _a === void 0 ? void 0 : _a[0]) === null || _b === void 0 ? void 0 : _b.url;
- }
- catch (err) {
- getFeature("errorOnLyricsNotFound") && error("Couldn't get lyrics URL due to error:", err);
- return undefined;
- }
- }
- /**
- * Fetches the 5 best matching lyrics URLs from geniURL using a combo exact-ish and fuzzy search
- * **the passed parameters need to be sanitized first!**
- */
- async function fetchLyricsUrls(artist, song) {
- var _a, _b, _c;
- try {
- const cacheEntry = getLyricsCacheEntry(artist, song);
- if (cacheEntry) {
- info(`Found lyrics URL in cache: ${cacheEntry.url}`);
- return [cacheEntry];
- }
- const fetchUrl = constructUrl(`${getFeature("geniUrlBase")}/search`, {
- disableFuzzy: null,
- utm_source: `${scriptInfo.name} v${scriptInfo.version}${mode === "development" ? "-pre" : ""}`,
- q: `${artist} ${song}`,
- });
- log("Requesting lyrics from geniURL:", fetchUrl);
- const token = getFeature("geniUrlToken");
- const fetchRes = await UserUtils.fetchAdvanced(fetchUrl, Object.assign({}, (token ? {
- headers: {
- Authorization: `Bearer ${token}`,
- },
- } : {})));
- if (fetchRes.status === 429) {
- const waitSeconds = Number((_a = fetchRes.headers.get("retry-after")) !== null && _a !== void 0 ? _a : geniUrlRatelimitTimeframe);
- await showPrompt({ type: "alert", message: tp("lyrics_rate_limited", waitSeconds, waitSeconds) });
- return undefined;
- }
- else if (fetchRes.status < 200 || fetchRes.status >= 300) {
- getFeature("errorOnLyricsNotFound") && error(new LyricsError(`Couldn't fetch lyrics URLs from geniURL - status: ${fetchRes.status} - response: ${(_c = (_b = (await fetchRes.json()).message) !== null && _b !== void 0 ? _b : await fetchRes.text()) !== null && _c !== void 0 ? _c : "(none)"}`));
- return undefined;
- }
- const result = await fetchRes.json();
- if (typeof result === "object" && result.error || !result || !result.all) {
- getFeature("errorOnLyricsNotFound") && error(new LyricsError(`Couldn't fetch lyrics URLs from geniURL: ${result.message}`));
- return undefined;
- }
- const allResults = result.all;
- if (allResults.length === 0) {
- warn("No lyrics URL found for the provided song");
- return undefined;
- }
- const allResultsSan = allResults
- .filter(({ meta, url }) => (meta.title || meta.fullTitle) && meta.artists && url)
- .map(({ meta, url }) => {
- var _a;
- return ({
- meta: Object.assign(Object.assign({}, meta), { title: sanitizeSong(String((_a = meta.title) !== null && _a !== void 0 ? _a : meta.fullTitle)), artists: sanitizeArtists(String(meta.artists)) }),
- url,
- });
- });
- const topRes = allResultsSan[0];
- topRes && addLyricsCacheEntryBest(topRes.meta.artists, topRes.meta.title, topRes.url);
- return allResultsSan.map(r => ({
- artist: r.meta.primaryArtist.name,
- song: r.meta.title,
- url: r.url,
- }));
- }
- catch (err) {
- getFeature("errorOnLyricsNotFound") && error("Couldn't get lyrics URL due to error:", err);
- return undefined;
- }
- }
- /** Adds the genius URL to the passed lyrics button element if it was previously instantiated with an undefined URL */
- async function addGeniusUrlToLyricsBtn(btnElem, geniusUrl) {
- btnElem.href = geniusUrl;
- btnElem.ariaLabel = btnElem.title = t("open_lyrics");
- btnElem.style.visibility = "visible";
- btnElem.style.display = "inline-flex";
- }
- /** Creates the base lyrics button element */
- async function createLyricsBtn(geniusUrl, hideIfLoading = true) {
- const linkElem = document.createElement("a");
- linkElem.classList.add("ytmusic-player-bar", "bytm-generic-btn");
- linkElem.ariaLabel = linkElem.title = t("lyrics_loading");
- 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.classList.add("bytm-generic-btn-img");
- imgElem.src = await getResourceUrl("icon-lyrics");
- onInteraction(linkElem, (e) => {
- var _a;
- const url = (_a = linkElem.href) !== null && _a !== void 0 ? _a : geniusUrl;
- if (!url || e instanceof MouseEvent)
- return;
- if (!e.ctrlKey && !e.altKey)
- openInTab(url);
- }, {
- preventDefault: false,
- stopPropagation: false,
- });
- linkElem.appendChild(imgElem);
- onInteraction(linkElem, async (e) => {
- if (e.ctrlKey || e.altKey) {
- e.preventDefault();
- e.stopImmediatePropagation();
- const search = await showPrompt({ type: "prompt", message: t("open_lyrics_search_prompt") });
- if (search && search.length > 0)
- openInTab(`https://genius.com/search?q=${encodeURIComponent(search)}`);
- }
- }, {
- preventDefault: false,
- stopPropagation: false,
- });
- 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("-") };
- }
-
- //#region init queue btns
- /** Initializes the queue buttons */
- async function initQueueButtons() {
- const addCurrentQueueBtns = (evt) => {
- let amt = 0;
- for (const queueItm of evt.childNodes) {
- if (!queueItm.classList.contains("bytm-has-queue-btns")) {
- addQueueButtons(queueItm, undefined, "currentQueue");
- amt++;
- }
- }
- if (amt > 0)
- log(`Added buttons to ${amt} new queue ${UserUtils.autoPlural("item", amt)}`);
- };
- // current queue
- siteEvents.on("queueChanged", addCurrentQueueBtns);
- siteEvents.on("autoplayQueueChanged", addCurrentQueueBtns);
- const queueItems = document.querySelectorAll("#contents.ytmusic-player-queue > ytmusic-player-queue-item");
- if (queueItems.length > 0) {
- queueItems.forEach(itm => addQueueButtons(itm, undefined, "currentQueue"));
- log(`Added buttons to ${queueItems.length} existing "current song queue" ${UserUtils.autoPlural("item", queueItems)}`);
- }
- // generic lists
- const addGenericListQueueBtns = (listElem) => {
- const queueItems = listElem.querySelectorAll("ytmusic-responsive-list-item-renderer");
- if (queueItems.length === 0)
- return;
- let addedBtnsCount = 0;
- queueItems.forEach(itm => {
- if (itm.classList.contains("bytm-has-btns"))
- return;
- itm.classList.add("bytm-has-btns");
- addQueueButtons(itm, ".flex-columns", "genericList", ["bytm-generic-list-queue-btn-container"], "afterParent");
- addedBtnsCount++;
- });
- addedBtnsCount > 0 &&
- log(`Added buttons to ${addedBtnsCount} new "generic song list" ${UserUtils.autoPlural("item", addedBtnsCount)} in list`, listElem);
- };
- const listSelector = `\
-ytmusic-playlist-shelf-renderer #contents,
-ytmusic-section-list-renderer[main-page-type="MUSIC_PAGE_TYPE_ALBUM"] ytmusic-shelf-renderer #contents,
-ytmusic-section-list-renderer[main-page-type="MUSIC_PAGE_TYPE_ARTIST"] ytmusic-shelf-renderer #contents,
-ytmusic-section-list-renderer[main-page-type="MUSIC_PAGE_TYPE_PLAYLIST"] ytmusic-shelf-renderer #contents\
-`;
- if (getFeature("listButtonsPlacement") === "everywhere") {
- const checkAddGenericBtns = (songLists) => {
- for (const list of songLists)
- addGenericListQueueBtns(list);
- };
- addSelectorListener("body", listSelector, {
- all: true,
- continuous: true,
- debounce: 150,
- // TODO: switch to longer debounce time and edge type "risingIdle" after UserUtils update
- debounceEdge: "falling",
- listener: checkAddGenericBtns,
- });
- siteEvents.on("pathChanged", () => {
- const songLists = document.querySelectorAll(listSelector);
- if (songLists.length > 0)
- checkAddGenericBtns(songLists);
- });
- }
- }
- //#region add queue btns
- /**
- * 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` or `ytmusic-responsive-list-item-renderer` to add queue buttons to
- * @param listType The type of list the queue item is in
- * @param classes Extra CSS classes to apply to the container
- * @param insertPosition Where to insert the button container in relation to the parent element
- */
- async function addQueueButtons(queueItem, containerParentSelector = ".song-info", listType = "currentQueue", classes = [], insertPosition = "child") {
- const queueBtnsCont = document.createElement("div");
- queueBtnsCont.classList.add(...["bytm-queue-btn-container", ...classes]);
- const lyricsIconUrl = await getResourceUrl("icon-lyrics");
- const deleteIconUrl = await getResourceUrl("icon-delete");
- //#region lyrics btn
- let lyricsBtnElem;
- if (getFeature("lyricsQueueButton")) {
- lyricsBtnElem = await createLyricsBtn(undefined, false);
- lyricsBtnElem.ariaLabel = lyricsBtnElem.title = t("open_lyrics");
- lyricsBtnElem.style.display = "inline-flex";
- lyricsBtnElem.style.visibility = "initial";
- lyricsBtnElem.style.pointerEvents = "initial";
- lyricsBtnElem.role = "link";
- lyricsBtnElem.tabIndex = 0;
- onInteraction(lyricsBtnElem, async (e) => {
- var _a;
- e.preventDefault();
- e.stopImmediatePropagation();
- let song, artist;
- if (listType === "currentQueue") {
- const songInfo = queueItem.querySelector(".song-info");
- if (!songInfo)
- return;
- const [songEl, artistEl] = songInfo.querySelectorAll("yt-formatted-string");
- song = songEl === null || songEl === void 0 ? void 0 : songEl.textContent;
- artist = artistEl === null || artistEl === void 0 ? void 0 : artistEl.textContent;
- }
- else if (listType === "genericList") {
- const songEl = queueItem.querySelector(".title-column yt-formatted-string a");
- let artistEl = null;
- if (location.pathname.startsWith("/playlist"))
- artistEl = document.querySelector("ytmusic-detail-header-renderer .metadata .subtitle-container yt-formatted-string a");
- if (!artistEl || !artistEl.textContent)
- artistEl = queueItem.querySelector(".secondary-flex-columns yt-formatted-string:first-child a");
- song = songEl === null || songEl === void 0 ? void 0 : songEl.textContent;
- artist = artistEl === null || artistEl === void 0 ? void 0 : artistEl.textContent;
- if (!artist) {
- // new playlist design
- artistEl = document.querySelector("ytmusic-responsive-header-renderer .strapline a.yt-formatted-string[href]");
- artist = artistEl === null || artistEl === void 0 ? void 0 : artistEl.textContent;
- }
- }
- else
- return;
- if (!song || !artist)
- return error("Couldn't get song or artist name from queue item - song:", song, "- artist:", artist);
- let lyricsUrl;
- const artistsSan = sanitizeArtists(artist);
- const songSan = sanitizeSong(song);
- const splitTitle = splitVideoTitle(songSan);
- const cachedLyricsEntry = songSan.includes("-")
- ? getLyricsCacheEntry(splitTitle.artist, splitTitle.song)
- : getLyricsCacheEntry(artistsSan, songSan);
- if (cachedLyricsEntry)
- lyricsUrl = cachedLyricsEntry.url;
- else if (!queueItem.hasAttribute("data-bytm-loading")) {
- const imgEl = lyricsBtnElem === null || lyricsBtnElem === void 0 ? void 0 : lyricsBtnElem.querySelector("img");
- if (!imgEl)
- return;
- if (!cachedLyricsEntry) {
- queueItem.setAttribute("data-bytm-loading", "");
- imgEl.src = await getResourceUrl("icon-spinner");
- imgEl.classList.add("bytm-spinner");
- }
- lyricsUrl = (_a = cachedLyricsEntry === null || cachedLyricsEntry === void 0 ? void 0 : cachedLyricsEntry.url) !== null && _a !== void 0 ? _a : await fetchLyricsUrlTop(artistsSan, songSan);
- if (lyricsUrl) {
- emitInterface("bytm:lyricsLoaded", {
- type: "queue",
- artists: artist,
- title: song,
- url: lyricsUrl,
- });
- }
- const resetImgElem = () => {
- imgEl.src = lyricsIconUrl;
- imgEl.classList.remove("bytm-spinner");
- };
- if (!cachedLyricsEntry) {
- queueItem.removeAttribute("data-bytm-loading");
- // so the new image doesn't "blink"
- setTimeout(resetImgElem, 100);
- }
- if (!lyricsUrl) {
- resetImgElem();
- if (await showPrompt({ type: "confirm", message: t("lyrics_not_found_confirm_open_search") }))
- openInTab(`https://genius.com/search?q=${encodeURIComponent(`${artistsSan} - ${songSan}`)}`);
- return;
- }
- }
- lyricsUrl && openInTab(lyricsUrl);
- });
- }
- //#region delete btn
- let deleteBtnElem;
- if (getFeature("deleteFromQueueButton")) {
- deleteBtnElem = document.createElement("a");
- deleteBtnElem.ariaLabel = deleteBtnElem.title = (listType === "currentQueue" ? t("remove_from_queue") : t("delete_from_list"));
- deleteBtnElem.classList.add("ytmusic-player-bar", "bytm-delete-from-queue", "bytm-generic-btn");
- deleteBtnElem.role = "button";
- deleteBtnElem.tabIndex = 0;
- deleteBtnElem.style.visibility = "initial";
- const imgElem = document.createElement("img");
- imgElem.classList.add("bytm-generic-btn-img");
- imgElem.src = deleteIconUrl;
- onInteraction(deleteBtnElem, async (e) => {
- e.preventDefault();
- e.stopImmediatePropagation();
- // 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[id=\"button-shape\"] button");
- if (dotsBtnElem) {
- if (queuePopupCont)
- queuePopupCont.setAttribute("data-bytm-hidden", "true");
- dotsBtnElem.click();
- }
- else {
- info("Couldn't find three dots button in queue item, trying to open the context menu manually");
- queueItem.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: false }));
- }
- 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");
- await UserUtils.pauseFor(15);
- const removeFromQueueBtn = queuePopupCont === null || queuePopupCont === void 0 ? void 0 : queuePopupCont.querySelector("tp-yt-paper-listbox ytmusic-menu-service-item-renderer:nth-of-type(3)");
- removeFromQueueBtn === null || removeFromQueueBtn === void 0 ? void 0 : removeFromQueueBtn.click();
- // queue items aren't removed automatically outside of the current queue
- if (removeFromQueueBtn && listType === "genericList") {
- await UserUtils.pauseFor(200);
- clearInner(queueItem);
- queueItem.remove();
- }
- if (!removeFromQueueBtn) {
- error("Couldn't find 'remove from queue' button in queue item three dots menu.\nPlease make sure all autoplay restrictions on your browser's side are disabled for this page.");
- dotsBtnElem === null || dotsBtnElem === void 0 ? void 0 : dotsBtnElem.click();
- imgElem.src = await getResourceUrl("icon-error");
- if (deleteBtnElem)
- deleteBtnElem.ariaLabel = deleteBtnElem.title = (listType === "currentQueue" ? t("couldnt_remove_from_queue") : t("couldnt_delete_from_list"));
- }
- }
- 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");
- }
- });
- deleteBtnElem.appendChild(imgElem);
- }
- lyricsBtnElem && queueBtnsCont.appendChild(createRipple(lyricsBtnElem));
- deleteBtnElem && queueBtnsCont.appendChild(createRipple(deleteBtnElem));
- const parentEl = queueItem.querySelector(containerParentSelector);
- if (insertPosition === "child")
- parentEl === null || parentEl === void 0 ? void 0 : parentEl.appendChild(queueBtnsCont);
- else if (insertPosition === "beforeParent")
- parentEl === null || parentEl === void 0 ? void 0 : parentEl.before(queueBtnsCont);
- else if (insertPosition === "afterParent")
- parentEl === null || parentEl === void 0 ? void 0 : parentEl.after(queueBtnsCont);
- queueItem.classList.add("bytm-has-queue-btns");
- }
-
- //#region init vol features
- /** Initializes all volume-related features */
- async function initVolumeFeatures() {
- let listenerOnce = false;
- // sliderElem is not technically an input element but behaves pretty much the same
- const listener = async (type, sliderElem) => {
- const volSliderCont = document.createElement("div");
- volSliderCont.classList.add("bytm-vol-slider-cont");
- if (getFeature("volumeSliderScrollStep") !== featInfo.volumeSliderScrollStep.default)
- initScrollStep(volSliderCont, sliderElem);
- UserUtils.addParent(sliderElem, volSliderCont);
- if (getFeature("volumeSliderLabel"))
- await addVolumeSliderLabel(type, sliderElem, volSliderCont);
- setVolSliderStep(sliderElem);
- if (getFeature("volumeSharedBetweenTabs"))
- sliderElem.addEventListener("change", () => sharedVolumeChanged(Number(sliderElem.value)));
- if (listenerOnce)
- return;
- listenerOnce = true;
- // the following are only run once:
- if (getFeature("setInitialTabVolume"))
- setInitialTabVolume(sliderElem);
- if (typeof getFeature("volumeSliderSize") === "number")
- setVolSliderSize();
- if (getFeature("volumeSharedBetweenTabs"))
- checkSharedVolume();
- };
- addSelectorListener("playerBarRightControls", "tp-yt-paper-slider#volume-slider", {
- listener: (el) => listener("normal", el),
- });
- let sizeSmOnce = false;
- const onResize = () => {
- if (sizeSmOnce || window.innerWidth >= 1150)
- return;
- sizeSmOnce = true;
- addSelectorListener("playerBarRightControls", "ytmusic-player-expanding-menu tp-yt-paper-slider#expand-volume-slider", {
- listener: (el) => listener("expand", el),
- debounceEdge: "falling",
- });
- };
- window.addEventListener("resize", UserUtils.debounce(onResize, 150, "falling"));
- waitVideoElementReady().then(onResize);
- onResize();
- }
- //#region scroll step
- /** Initializes the volume slider scroll step feature */
- function initScrollStep(volSliderCont, sliderElem) {
- for (const evtName of ["wheel", "scroll", "mousewheel", "DOMMouseScroll"]) {
- volSliderCont.addEventListener(evtName, (e) => {
- var _a, _b;
- e.preventDefault();
- // cancels all the other events that would be fired
- e.stopImmediatePropagation();
- const delta = Number((_b = (_a = e.deltaY) !== null && _a !== void 0 ? _a : e.detail) !== null && _b !== void 0 ? _b : 1);
- if (isNaN(delta))
- return warn("Invalid scroll delta:", delta);
- const volumeDir = -Math.sign(delta);
- const newVolume = String(Number(sliderElem.value) + (getFeature("volumeSliderScrollStep") * volumeDir));
- sliderElem.value = newVolume;
- sliderElem.setAttribute("aria-valuenow", newVolume);
- // make the site actually change the volume
- sliderElem.dispatchEvent(new Event("change", { bubbles: true }));
- }, {
- // takes precedence over the slider's own event listener
- capture: true,
- });
- }
- }
- //#region volume slider label
- /** Adds a percentage label to the volume slider and tooltip */
- async function addVolumeSliderLabel(type, sliderElem, sliderContainer) {
- const labelContElem = document.createElement("div");
- labelContElem.classList.add("bytm-vol-slider-label");
- const volShared = getFeature("volumeSharedBetweenTabs");
- if (volShared) {
- const linkIconHtml = await resourceAsString("icon-link");
- if (linkIconHtml) {
- const linkIconElem = document.createElement("div");
- linkIconElem.classList.add("bytm-vol-slider-shared");
- setInnerHtml(linkIconElem, linkIconHtml);
- linkIconElem.role = "alert";
- linkIconElem.ariaLive = "polite";
- linkIconElem.title = linkIconElem.ariaLabel = t("volume_shared_tooltip");
- labelContElem.classList.add("has-icon");
- labelContElem.appendChild(linkIconElem);
- }
- }
- const getLabel = (value) => `${value}%`;
- const labelElem = document.createElement("div");
- labelElem.classList.add("label");
- labelElem.textContent = getLabel(sliderElem.value);
- labelContElem.appendChild(labelElem);
- // prevent video from minimizing
- labelContElem.addEventListener("click", (e) => e.stopPropagation());
- labelContElem.addEventListener("keydown", (e) => ["Enter", "Space", " "].includes(e.key) && e.stopPropagation());
- const getLabelText = (slider) => { var _a; return t("volume_tooltip", slider.value, (_a = getFeature("volumeSliderStep")) !== null && _a !== void 0 ? _a : slider.step); };
- const labelFull = getLabelText(sliderElem);
- sliderContainer.setAttribute("title", labelFull);
- sliderElem.setAttribute("title", labelFull);
- sliderElem.setAttribute("aria-valuetext", labelFull);
- const updateLabel = () => {
- const labelFull = getLabelText(sliderElem);
- sliderContainer.setAttribute("title", labelFull);
- sliderElem.setAttribute("title", labelFull);
- sliderElem.setAttribute("aria-valuetext", labelFull);
- const labelElem2 = document.querySelectorAll(".bytm-vol-slider-label div.label");
- for (const el of labelElem2)
- el.textContent = getLabel(sliderElem.value);
- };
- sliderElem.addEventListener("change", updateLabel);
- siteEvents.on("configChanged", updateLabel);
- addSelectorListener("playerBarRightControls", type === "normal" ? ".bytm-vol-slider-cont" : "ytmusic-player-expanding-menu .bytm-vol-slider-cont", {
- listener: (volumeCont) => volumeCont.appendChild(labelContElem),
- });
- 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)
- labelContElem.classList.add("bytm-visible");
- else if (labelContElem.classList.contains("bytm-visible") || document.activeElement !== sliderElem)
- labelContElem.classList.remove("bytm-visible");
- if (Number(sliderElem.value) !== lastSliderVal) {
- lastSliderVal = Number(sliderElem.value);
- updateLabel();
- }
- });
- sliderHoverObserver.observe(sliderElem, {
- attributes: true,
- });
- }
- //#region volume slider size
- /** Sets the volume slider to a set size */
- function setVolSliderSize() {
- const size = getFeature("volumeSliderSize");
- if (typeof size !== "number" || isNaN(Number(size)))
- return error("Invalid volume slider size:", size);
- setGlobalCssVar("vol-slider-size", `${size}px`);
- addStyleFromResource("css-vol_slider_size");
- }
- //#region volume slider step
- /** Sets the `step` attribute of the volume slider */
- function setVolSliderStep(sliderElem) {
- sliderElem.setAttribute("step", String(getFeature("volumeSliderStep")));
- }
- //#region shared volume
- /** Saves the shared volume level to persistent storage */
- async function sharedVolumeChanged(vol) {
- try {
- await GM.setValue("bytm-shared-volume", String(lastCheckedSharedVolume = ignoreVal = vol));
- }
- catch (err) {
- error("Couldn't save shared volume level due to an error:", err);
- }
- }
- let ignoreVal = -1;
- let lastCheckedSharedVolume = -1;
- /** Only call once as this calls itself after a timeout! - Checks if the shared volume has changed and updates the volume slider accordingly */
- async function checkSharedVolume() {
- try {
- const vol = await GM.getValue("bytm-shared-volume");
- if (vol && lastCheckedSharedVolume !== Number(vol)) {
- if (ignoreVal === Number(vol))
- return;
- lastCheckedSharedVolume = Number(vol);
- const sliderElem = document.querySelector("tp-yt-paper-slider#volume-slider");
- if (sliderElem) {
- sliderElem.value = String(vol);
- sliderElem.dispatchEvent(new Event("change", { bubbles: true }));
- }
- }
- setTimeout(checkSharedVolume, 333);
- }
- catch (err) {
- error("Couldn't check for shared volume level due to an error:", err);
- }
- }
- //#region initial volume
- /** Sets the volume slider to a set volume level when the session starts */
- async function setInitialTabVolume(sliderElem) {
- await waitVideoElementReady();
- const initialVol = getFeature("initialTabVolumeLevel");
- if (getFeature("volumeSharedBetweenTabs")) {
- lastCheckedSharedVolume = ignoreVal = initialVol;
- if (getFeature("volumeSharedBetweenTabs"))
- GM.setValue("bytm-shared-volume", String(initialVol));
- }
- sliderElem.value = String(initialVol);
- sliderElem.dispatchEvent(new Event("change", { bubbles: true }));
- log(`Set initial tab volume to ${initialVol}%`);
- }
-
- //#region misc
- function noop() {
- }
- /** Creates an HTML string for the given adornment properties */
- const getAdornHtml = async (className, title, resource, extraAttributes) => { var _a; return `
${(_a = await resourceAsString(resource)) !== null && _a !== void 0 ? _a : ""}`; };
- /** Combines multiple async functions or promises that resolve with an adornment HTML string into a single string */
- const combineAdornments = (adornments) => new Promise(async (resolve) => {
- const sortedAdornments = adornments.sort((a, b) => {
- const aIndex = adornmentOrder.get(a) ? adornmentOrder.get(a) : -1;
- const bIndex = adornmentOrder.has(b) ? adornmentOrder.get(b) : -1;
- return aIndex - bIndex;
- });
- const html = [];
- for (const adornment of sortedAdornments) {
- const val = typeof adornment === "function"
- ? await adornment()
- : await adornment;
- val && html.push(val);
- }
- resolve(html.join(""));
- });
- /** Decoration elements that can be added next to the label */
- const adornments = {
- advanced: async () => getAdornHtml("bytm-advanced-mode-icon", t("advanced_mode"), "icon-advanced_mode"),
- experimental: async () => getAdornHtml("bytm-experimental-icon", t("experimental_feature"), "icon-experimental"),
- globe: async () => getAdornHtml("bytm-locale-icon", undefined, "icon-globe_small"),
- alert: async (title) => getAdornHtml("bytm-warning-icon", title, "icon-error", "role=\"alert\""),
- reload: async () => getFeature("advancedMode") ? getAdornHtml("bytm-reload-icon", t("feature_requires_reload"), "icon-reload") : undefined,
- };
- /** Order of adornment elements in the {@linkcode combineAdornments()} function */
- const adornmentOrder = new Map();
- adornmentOrder.set(adornments.alert, 0);
- adornmentOrder.set(adornments.experimental, 1);
- adornmentOrder.set(adornments.globe, 2);
- adornmentOrder.set(adornments.reload, 3);
- adornmentOrder.set(adornments.advanced, 4);
- /** Common options for config items of type "select" */
- const options = {
- siteSelection: () => [
- { value: "all", label: t("site_selection_both_sites") },
- { value: "yt", label: t("site_selection_only_yt") },
- { value: "ytm", label: t("site_selection_only_ytm") },
- ],
- siteSelectionOrNone: () => [
- { value: "all", label: t("site_selection_both_sites") },
- { value: "yt", label: t("site_selection_only_yt") },
- { value: "ytm", label: t("site_selection_only_ytm") },
- { value: "none", label: t("site_selection_none") },
- ],
- locale: () => Object.entries(langMapping)
- .reduce((a, [locale, { name }]) => {
- return [...a, {
- value: locale,
- label: name,
- }];
- }, [])
- .sort((a, b) => a.label.localeCompare(b.label)),
- colorLightness: () => [
- { value: "darker", label: t("color_lightness_darker") },
- { value: "normal", label: t("color_lightness_normal") },
- { value: "lighter", label: t("color_lightness_lighter") },
- ],
- };
- //#region rendering
- /** Renders a long number with a thousands separator */
- function renderLongNumberValue(val, maximumFractionDigits = 0) {
- return Number(val).toLocaleString(getLocale().replace(/_/g, "-"), {
- style: "decimal",
- maximumFractionDigits,
- });
- }
- //#region features
- /**
- * Contains all possible features with their default values and other configuration.
- *
- * **Required props:**
- *
- * | Property | Description |
- * | :----------------------------- | :------------------------------------------------------------------------------------------------------------------------------- |
- * | `type: string` | Type of the feature configuration element - use autocomplete or check `FeatureTypeProps` in `src/types.ts` |
- * | `category: string` | Category of the feature - use autocomplete or check `FeatureCategory` in `src/types.ts` |
- * | `default: unknown` | Default value of the feature - type of the value depends on the given `type` |
- * | `enable(value: unknown): void` | (required if reloadRequired = false) - function that will be called when the feature is enabled / initialized for the first time |
- *
- *
- *
- * **Optional props:**
- *
- * | Property | Description |
- * | :----------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------|
- * | `disable(newValue: unknown): void` | For type `toggle` only - function that will be called when the feature is disabled - can be a synchronous or asynchronous function |
- * | `change(key: string, prevValue: unknown, newValue: unknown): void` | For types `number`, `select`, `slider` and `hotkey` only - function that will be called when the value is changed |
- * | `click(): void` | For type `button` only - function that will be called when the button is clicked |
- * | `helpText: string \| () => string` | Function that returns an HTML string or the literal string itself that will be the help text for this feature - writing as function is useful for pluralizing or inserting values into the translation at runtime - if not set, translation with key `feature_helptext_featureKey` will be used instead, if available |
- * | `textAdornment(): string \| Promise
` | Function that returns an HTML string that will be appended to the text in the config menu as an adornment element |
- * | `unit: string \| (val: number) => string` | For types `number` or `slider` only - The unit text that is displayed next to the input element, i.e. " px" - a leading space need to be added too! |
- * | `min: number` | For types `number` or `slider` only - Overwrites the default of the `min` property of the HTML input element |
- * | `max: number` | For types `number` or `slider` only - Overwrites the default of the `max` property of the HTML input element |
- * | `step: number` | For types `number` or `slider` only - Overwrites the default of the `step` property of the HTML input element |
- * | `options: SelectOption[] \| () => SelectOption[]` | For type `select` only - function that returns an array of objects with `value` and `label` properties |
- * | `reloadRequired: boolean` | If true (default), the page needs to be reloaded for the changes to take effect - if false, `enable()` needs to be provided |
- * | `advanced: boolean` | If true, the feature will only be shown if the advanced mode feature has been turned on |
- * | `hidden: boolean` | If true, the feature will not be shown in the settings - default is undefined (false) |
- * | `valueHidden: boolean` | If true, the value of the feature will be hidden in the settings and via the plugin interface - default is undefined (false) |
- * | `normalize(val: unknown): unknown` | Function that will be called to normalize the value before it is saved - useful for trimming strings or other simple operations |
- * | `renderValue(val: string): string` | If provided, is used to render the value's label in the config menu |
- *
- *
- * TODO: go through all features and set as many as possible to reloadRequired = false
- */
- const featInfo = {
- //#region cat:layout
- watermarkEnabled: {
- type: "toggle",
- category: "layout",
- default: true,
- textAdornment: adornments.reload,
- },
- removeShareTrackingParam: {
- type: "toggle",
- category: "layout",
- default: true,
- textAdornment: adornments.reload,
- },
- removeShareTrackingParamSites: {
- type: "select",
- category: "layout",
- options: options.siteSelection,
- default: "all",
- advanced: true,
- textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]),
- },
- fixSpacing: {
- type: "toggle",
- category: "layout",
- default: true,
- advanced: true,
- textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]),
- },
- thumbnailOverlayBehavior: {
- type: "select",
- category: "layout",
- options: () => [
- { value: "songsOnly", label: t("thumbnail_overlay_behavior_songs_only") },
- { value: "videosOnly", label: t("thumbnail_overlay_behavior_videos_only") },
- { value: "always", label: t("thumbnail_overlay_behavior_always") },
- { value: "never", label: t("thumbnail_overlay_behavior_never") },
- ],
- default: "songsOnly",
- reloadRequired: false,
- enable: noop,
- },
- thumbnailOverlayToggleBtnShown: {
- type: "toggle",
- category: "layout",
- default: true,
- textAdornment: adornments.reload,
- },
- thumbnailOverlayShowIndicator: {
- type: "toggle",
- category: "layout",
- default: true,
- textAdornment: adornments.reload,
- },
- thumbnailOverlayIndicatorOpacity: {
- type: "slider",
- category: "layout",
- min: 5,
- max: 100,
- step: 5,
- default: 40,
- unit: "%",
- advanced: true,
- textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]),
- },
- thumbnailOverlayImageFit: {
- type: "select",
- category: "layout",
- options: () => [
- { value: "cover", label: t("thumbnail_overlay_image_fit_crop") },
- { value: "contain", label: t("thumbnail_overlay_image_fit_full") },
- { value: "fill", label: t("thumbnail_overlay_image_fit_stretch") },
- ],
- default: "cover",
- advanced: true,
- textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]),
- },
- hideCursorOnIdle: {
- type: "toggle",
- category: "layout",
- default: true,
- reloadRequired: false,
- enable: noop,
- },
- hideCursorOnIdleDelay: {
- type: "slider",
- category: "layout",
- min: 0.5,
- max: 10,
- step: 0.25,
- default: 2,
- unit: "s",
- advanced: true,
- textAdornment: adornments.advanced,
- reloadRequired: false,
- enable: noop,
- },
- fixHdrIssues: {
- type: "toggle",
- category: "layout",
- default: true,
- advanced: true,
- textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]),
- },
- showVotes: {
- type: "toggle",
- category: "layout",
- default: true,
- textAdornment: adornments.reload,
- },
- // archived idea for future version
- // (shows a bar under the like/dislike buttons that shows the ratio of likes to dislikes)
- // showVoteRatio: {
- // type: "select",
- // category: "layout",
- // options: () => [
- // { value: "disabled", label: t("vote_ratio_disabled") },
- // { value: "greenRed", label: t("vote_ratio_green_red") },
- // { value: "blueGray", label: t("vote_ratio_blue_gray") },
- // ],
- // default: "disabled",
- // textAdornment: adornments.reload,
- // },
- //#region cat:volume
- volumeSliderLabel: {
- type: "toggle",
- category: "volume",
- default: true,
- textAdornment: adornments.reload,
- },
- volumeSliderSize: {
- type: "number",
- category: "volume",
- min: 50,
- max: 500,
- step: 5,
- default: 150,
- unit: "px",
- textAdornment: adornments.reload,
- },
- volumeSliderStep: {
- type: "slider",
- category: "volume",
- min: 1,
- max: 25,
- default: 2,
- unit: "%",
- textAdornment: adornments.reload,
- },
- volumeSliderScrollStep: {
- type: "slider",
- category: "volume",
- min: 1,
- max: 25,
- default: 4,
- unit: "%",
- textAdornment: adornments.reload,
- },
- volumeSharedBetweenTabs: {
- type: "toggle",
- category: "volume",
- default: false,
- textAdornment: adornments.reload,
- },
- setInitialTabVolume: {
- type: "toggle",
- category: "volume",
- default: false,
- textAdornment: () => getFeature("volumeSharedBetweenTabs")
- ? combineAdornments([adornments.alert(t("feature_warning_setInitialTabVolume_volumeSharedBetweenTabs_incompatible").replace(/"/g, "'")), adornments.reload])
- : adornments.reload(),
- },
- initialTabVolumeLevel: {
- type: "slider",
- category: "volume",
- min: 0,
- max: 100,
- step: 1,
- default: 100,
- unit: "%",
- textAdornment: () => getFeature("volumeSharedBetweenTabs")
- ? combineAdornments([adornments.alert(t("feature_warning_setInitialTabVolume_volumeSharedBetweenTabs_incompatible").replace(/"/g, "'")), adornments.reload])
- : adornments.reload(),
- reloadRequired: false,
- enable: noop,
- },
- //#region cat:song lists
- lyricsQueueButton: {
- type: "toggle",
- category: "songLists",
- default: true,
- textAdornment: adornments.reload,
- },
- deleteFromQueueButton: {
- type: "toggle",
- category: "songLists",
- default: true,
- textAdornment: adornments.reload,
- },
- listButtonsPlacement: {
- type: "select",
- category: "songLists",
- options: () => [
- { value: "queueOnly", label: t("list_button_placement_queue_only") },
- { value: "everywhere", label: t("list_button_placement_everywhere") },
- ],
- default: "everywhere",
- advanced: true,
- textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]),
- },
- scrollToActiveSongBtn: {
- type: "toggle",
- category: "songLists",
- default: true,
- textAdornment: adornments.reload,
- },
- clearQueueBtn: {
- type: "toggle",
- category: "songLists",
- default: true,
- textAdornment: adornments.reload,
- },
- //#region cat:behavior
- disableBeforeUnloadPopup: {
- type: "toggle",
- category: "behavior",
- default: false,
- textAdornment: adornments.reload,
- },
- closeToastsTimeout: {
- type: "number",
- category: "behavior",
- min: 0,
- max: 30,
- step: 0.5,
- default: 3,
- unit: "s",
- reloadRequired: false,
- enable: noop,
- },
- rememberSongTime: {
- type: "toggle",
- category: "behavior",
- default: true,
- helpText: () => tp("feature_helptext_rememberSongTime", getFeature("rememberSongTimeMinPlayTime"), getFeature("rememberSongTimeMinPlayTime")),
- textAdornment: adornments.reload,
- },
- rememberSongTimeSites: {
- type: "select",
- category: "behavior",
- options: options.siteSelection,
- default: "all",
- textAdornment: adornments.reload,
- },
- rememberSongTimeDuration: {
- type: "number",
- category: "behavior",
- min: 1,
- max: 60 * 60 * 24 * 7,
- step: 1,
- default: 60,
- unit: "s",
- advanced: true,
- textAdornment: adornments.advanced,
- reloadRequired: false,
- enable: noop,
- },
- rememberSongTimeReduction: {
- type: "number",
- category: "behavior",
- min: 0,
- max: 30,
- step: 0.05,
- default: 0.2,
- unit: "s",
- advanced: true,
- textAdornment: adornments.advanced,
- reloadRequired: false,
- enable: noop,
- },
- rememberSongTimeMinPlayTime: {
- type: "slider",
- category: "behavior",
- min: 3,
- max: 30,
- step: 0.5,
- default: 10,
- unit: "s",
- advanced: true,
- textAdornment: adornments.advanced,
- reloadRequired: false,
- enable: noop,
- },
- //#region cat:input
- arrowKeySupport: {
- type: "toggle",
- category: "input",
- default: true,
- reloadRequired: false,
- enable: noop,
- },
- arrowKeySkipBy: {
- type: "slider",
- category: "input",
- min: 0.5,
- max: 30,
- step: 0.5,
- default: 5,
- unit: "s",
- reloadRequired: false,
- enable: noop,
- },
- switchBetweenSites: {
- type: "toggle",
- category: "input",
- default: true,
- reloadRequired: false,
- enable: noop,
- },
- switchSitesHotkey: {
- type: "hotkey",
- category: "input",
- default: {
- code: "F9",
- shift: false,
- ctrl: false,
- alt: false,
- },
- reloadRequired: false,
- enable: noop,
- },
- anchorImprovements: {
- type: "toggle",
- category: "input",
- default: true,
- textAdornment: adornments.reload,
- },
- numKeysSkipToTime: {
- type: "toggle",
- category: "input",
- default: true,
- reloadRequired: false,
- enable: noop,
- },
- autoLikeChannels: {
- type: "toggle",
- category: "input",
- default: true,
- textAdornment: adornments.reload,
- },
- autoLikeChannelToggleBtn: {
- type: "toggle",
- category: "input",
- default: true,
- reloadRequired: false,
- enable: noop,
- advanced: true,
- textAdornment: adornments.advanced,
- },
- // TODO(v2.2):
- // autoLikePlayerBarToggleBtn: {
- // type: "toggle",
- // category: "input",
- // default: false,
- // textAdornment: adornments.reload,
- // },
- autoLikeTimeout: {
- type: "slider",
- category: "input",
- min: 3,
- max: 30,
- step: 0.5,
- default: 5,
- unit: "s",
- advanced: true,
- reloadRequired: false,
- enable: noop,
- textAdornment: adornments.advanced,
- },
- autoLikeShowToast: {
- type: "toggle",
- category: "input",
- default: true,
- reloadRequired: false,
- advanced: true,
- enable: noop,
- textAdornment: adornments.advanced,
- },
- autoLikeOpenMgmtDialog: {
- type: "button",
- category: "input",
- click: () => getAutoLikeDialog().then(d => d.open()),
- },
- //#region cat:lyrics
- geniusLyrics: {
- type: "toggle",
- category: "lyrics",
- default: true,
- textAdornment: adornments.reload,
- },
- errorOnLyricsNotFound: {
- type: "toggle",
- category: "lyrics",
- default: false,
- reloadRequired: false,
- enable: noop,
- },
- geniUrlBase: {
- type: "text",
- category: "lyrics",
- default: "https://api.sv443.net/geniurl",
- normalize: (val) => val.trim().replace(/\/+$/, ""),
- advanced: true,
- textAdornment: adornments.advanced,
- reloadRequired: false,
- enable: noop,
- },
- geniUrlToken: {
- type: "text",
- valueHidden: true,
- category: "lyrics",
- default: "",
- normalize: (val) => val.trim(),
- advanced: true,
- textAdornment: adornments.advanced,
- reloadRequired: false,
- enable: noop,
- },
- lyricsCacheMaxSize: {
- type: "slider",
- category: "lyrics",
- default: 2000,
- min: 100,
- max: 10000,
- step: 100,
- unit: (val) => ` ${tp("unit_entries", val)}`,
- renderValue: renderLongNumberValue,
- advanced: true,
- textAdornment: adornments.advanced,
- reloadRequired: false,
- enable: noop,
- },
- lyricsCacheTTL: {
- type: "slider",
- category: "lyrics",
- default: 21,
- min: 1,
- max: 100,
- step: 1,
- unit: (val) => " " + tp("unit_days", val),
- advanced: true,
- textAdornment: adornments.advanced,
- reloadRequired: false,
- enable: noop,
- },
- clearLyricsCache: {
- type: "button",
- category: "lyrics",
- async click() {
- const entries = getLyricsCache().length;
- const formattedEntries = entries.toLocaleString(getLocale(), { style: "decimal", maximumFractionDigits: 0 });
- if (await showPrompt({ type: "confirm", message: tp("lyrics_clear_cache_confirm_prompt", entries, formattedEntries) })) {
- await clearLyricsCache();
- await showPrompt({ type: "alert", message: t("lyrics_clear_cache_success") });
- }
- },
- advanced: true,
- textAdornment: adornments.advanced,
- },
- // advancedLyricsFilter: {
- // type: "toggle",
- // category: "lyrics",
- // default: false,
- // change: () => setTimeout(async () => await showPrompt({ type: "confirm", message: t("lyrics_cache_changed_clear_confirm") }) && clearLyricsCache(), 200),
- // advanced: true,
- // textAdornment: adornments.experimental,
- // reloadRequired: false,
- // enable: noop,
- // },
- //#region cat:integrations
- disableDarkReaderSites: {
- type: "select",
- category: "integrations",
- options: options.siteSelectionOrNone,
- default: "all",
- advanced: true,
- textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]),
- },
- sponsorBlockIntegration: {
- type: "toggle",
- category: "integrations",
- default: true,
- advanced: true,
- textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]),
- },
- themeSongIntegration: {
- type: "toggle",
- category: "integrations",
- default: false,
- textAdornment: adornments.reload,
- },
- themeSongLightness: {
- type: "select",
- category: "integrations",
- options: options.colorLightness,
- default: "darker",
- textAdornment: adornments.reload,
- },
- //#region cat:plugins
- openPluginList: {
- type: "button",
- category: "plugins",
- default: undefined,
- click: () => getPluginListDialog().then(d => d.open()),
- },
- initTimeout: {
- type: "number",
- category: "plugins",
- min: 3,
- max: 30,
- default: 8,
- step: 0.1,
- unit: "s",
- advanced: true,
- textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]),
- },
- //#region cat:general
- locale: {
- type: "select",
- category: "general",
- options: options.locale,
- default: getPreferredLocale(),
- textAdornment: () => combineAdornments([adornments.globe, adornments.reload]),
- },
- localeFallback: {
- type: "toggle",
- category: "general",
- default: true,
- advanced: true,
- textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]),
- },
- versionCheck: {
- type: "toggle",
- category: "general",
- default: true,
- textAdornment: adornments.reload,
- },
- checkVersionNow: {
- type: "button",
- category: "general",
- click: () => doVersionCheck(true),
- },
- numbersFormat: {
- type: "select",
- category: "general",
- options: () => [
- { value: "long", label: `${formatNumber(12345678, "long")} (${t("votes_format_long")})` },
- { value: "short", label: `${formatNumber(12345678, "short")} (${t("votes_format_short")})` },
- ],
- default: "short",
- reloadRequired: false,
- enable: noop,
- },
- toastDuration: {
- type: "slider",
- category: "general",
- min: 0,
- max: 15,
- default: 4,
- step: 0.5,
- unit: "s",
- reloadRequired: false,
- advanced: true,
- textAdornment: adornments.advanced,
- enable: noop,
- change: () => showIconToast({
- message: t("example_toast"),
- iconSrc: getResourceUrl(`img-logo${mode === "development" ? "_dev" : ""}`),
- }),
- },
- showToastOnGenericError: {
- type: "toggle",
- category: "general",
- default: true,
- advanced: true,
- textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]),
- },
- resetConfig: {
- type: "button",
- category: "general",
- click: promptResetConfig,
- textAdornment: adornments.reload,
- },
- resetEverything: {
- type: "button",
- category: "general",
- click: async () => {
- if (await showPrompt({
- type: "confirm",
- message: t("reset_everything_confirm"),
- })) {
- await getStoreSerializer().resetStoresData();
- location.reload();
- }
- },
- advanced: true,
- textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]),
- },
- logLevel: {
- type: "select",
- category: "general",
- options: () => [
- { value: 0, label: t("log_level_debug") },
- { value: 1, label: t("log_level_info") },
- ],
- default: 1,
- textAdornment: adornments.reload,
- },
- advancedMode: {
- type: "toggle",
- category: "general",
- default: false,
- textAdornment: () => getFeature("advancedMode") ? adornments.advanced() : undefined,
- change: (_key, prevValue, newValue) => prevValue !== newValue &&
- emitSiteEvent("recreateCfgMenu"),
- },
- };
-
- /** If this number is incremented, the features object data will be migrated to the new format */
- const formatVersion = 9;
- const defaultData = Object.keys(featInfo)
- // @ts-ignore
- .filter((ftKey) => { var _a; return ((_a = featInfo === null || featInfo === void 0 ? void 0 : featInfo[ftKey]) === null || _a === void 0 ? void 0 : _a.default) !== undefined; })
- .reduce((acc, key) => {
- var _a;
- // @ts-ignore
- acc[key] = (_a = featInfo === null || featInfo === void 0 ? void 0 : featInfo[key]) === null || _a === void 0 ? void 0 : _a.default;
- return acc;
- }, {});
- /** Config data format migration dictionary */
- const migrations = {
- // 1 -> 2 (<=v1.0)
- 2: (oldData) => {
- const queueBtnsEnabled = Boolean(oldData.queueButtons);
- delete oldData.queueButtons;
- return Object.assign(Object.assign({}, oldData), { deleteFromQueueButton: queueBtnsEnabled, lyricsQueueButton: queueBtnsEnabled });
- },
- // 2 -> 3 (v1.0)
- 3: (oldData) => useDefaultConfig(oldData, [
- "removeShareTrackingParam", "numKeysSkipToTime",
- "fixSpacing", "scrollToActiveSongBtn", "logLevel",
- ]),
- // 3 -> 4 (v1.1)
- 4: (oldData) => {
- var _a, _b, _c, _d;
- const oldSwitchSitesHotkey = oldData.switchSitesHotkey;
- return Object.assign(Object.assign({}, useDefaultConfig(oldData, [
- "rememberSongTime", "rememberSongTimeSites",
- "volumeSliderScrollStep", "locale", "versionCheck",
- ])), { arrowKeySkipBy: 10, switchSitesHotkey: {
- code: (_a = oldSwitchSitesHotkey.key) !== null && _a !== void 0 ? _a : "F9",
- shift: Boolean((_b = oldSwitchSitesHotkey.shift) !== null && _b !== void 0 ? _b : false),
- ctrl: Boolean((_c = oldSwitchSitesHotkey.ctrl) !== null && _c !== void 0 ? _c : false),
- alt: Boolean((_d = oldSwitchSitesHotkey.meta) !== null && _d !== void 0 ? _d : false),
- }, listButtonsPlacement: "queueOnly" });
- },
- // 4 -> 5 (v2.0)
- 5: (oldData) => useDefaultConfig(oldData, [
- "localeFallback", "geniUrlBase", "geniUrlToken",
- "lyricsCacheMaxSize", "lyricsCacheTTL",
- "clearLyricsCache", "advancedMode",
- "checkVersionNow", "advancedLyricsFilter",
- "rememberSongTimeDuration", "rememberSongTimeReduction",
- "rememberSongTimeMinPlayTime", "volumeSharedBetweenTabs",
- "setInitialTabVolume", "initialTabVolumeLevel",
- "thumbnailOverlayBehavior", "thumbnailOverlayToggleBtnShown",
- "thumbnailOverlayShowIndicator", "thumbnailOverlayIndicatorOpacity",
- "thumbnailOverlayImageFit", "removeShareTrackingParamSites",
- "fixHdrIssues", "clearQueueBtn",
- "closeToastsTimeout", "disableDarkReaderSites",
- ]),
- // 5 -> 6 (v2.1)
- 6: (oldData) => {
- const newData = useNewDefaultIfUnchanged(useDefaultConfig(oldData, [
- "autoLikeChannels", "autoLikeChannelToggleBtn",
- "autoLikeTimeout", "autoLikeShowToast",
- "autoLikeOpenMgmtDialog", "showVotes",
- "numbersFormat", "toastDuration",
- "initTimeout",
- // forgot to add this to the migration when adding the feature way before so now will have to do:
- "volumeSliderLabel",
- ]), [
- { key: "rememberSongTimeSites", oldDefault: "ytm" },
- { key: "volumeSliderScrollStep", oldDefault: 10 },
- ]);
- "removeUpgradeTab" in newData && delete newData.removeUpgradeTab;
- "advancedLyricsFilter" in newData && delete newData.advancedLyricsFilter;
- return newData;
- },
- // TODO(v2.2): use default for "autoLikePlayerBarToggleBtn"
- // TODO(v2.2): set autoLikeChannels to true on migration once feature is fully implemented
- // 6 -> 7 (v2.1-dev)
- 7: (oldData) => {
- const newData = useNewDefaultIfUnchanged(useDefaultConfig(oldData, [
- "showToastOnGenericError", "sponsorBlockIntegration",
- "themeSongIntegration", "themeSongLightness",
- "errorOnLyricsNotFound", "openPluginList",
- ]), [
- { key: "toastDuration", oldDefault: 3 },
- ]);
- newData.arrowKeySkipBy = UserUtils.clamp(newData.arrowKeySkipBy, 0.5, 30);
- return newData;
- },
- // 7 -> 8 (v2.1)
- 8: (oldData) => {
- if ("showVotesFormat" in oldData) {
- oldData.numbersFormat = oldData.showVotesFormat;
- delete oldData.showVotesFormat;
- }
- return useDefaultConfig(oldData, [
- "autoLikeChannels"
- ]);
- },
- // 8 -> 9 (v2.2)
- 9: (oldData) => {
- oldData.locale = oldData.locale.replace("_", "-");
- if (oldData.locale === "ja-JA")
- oldData.locale = "ja-JP";
- if (oldData.locale === "en-GB")
- oldData.locale = "en-GB";
- return useDefaultConfig(oldData, [
- "resetEverything",
- // TODO(V2.2):
- // "autoLikePlayerBarToggleBtn",
- ]);
- },
+ deleteBtnElem.appendChild(imgElem);
+ }
+ lyricsBtnElem && queueBtnsCont.appendChild(createRipple(lyricsBtnElem));
+ deleteBtnElem && queueBtnsCont.appendChild(createRipple(deleteBtnElem));
+ const parentEl = queueItem.querySelector(containerParentSelector);
+ if (insertPosition === "child")
+ parentEl === null || parentEl === void 0 ? void 0 : parentEl.appendChild(queueBtnsCont);
+ else if (insertPosition === "beforeParent")
+ parentEl === null || parentEl === void 0 ? void 0 : parentEl.before(queueBtnsCont);
+ else if (insertPosition === "afterParent")
+ parentEl === null || parentEl === void 0 ? void 0 : parentEl.after(queueBtnsCont);
+ queueItem.classList.add("bytm-has-queue-btns");
+}//#region init vol features
+/** Initializes all volume-related features */
+async function initVolumeFeatures() {
+ let listenerOnce = false;
+ // sliderElem is not technically an input element but behaves pretty much the same
+ const listener = async (type, sliderElem) => {
+ const volSliderCont = document.createElement("div");
+ volSliderCont.classList.add("bytm-vol-slider-cont");
+ if (getFeature("volumeSliderScrollStep") !== featInfo.volumeSliderScrollStep.default)
+ initScrollStep(volSliderCont, sliderElem);
+ UserUtils.addParent(sliderElem, volSliderCont);
+ if (getFeature("volumeSliderLabel"))
+ await addVolumeSliderLabel(type, sliderElem, volSliderCont);
+ setVolSliderStep(sliderElem);
+ if (getFeature("volumeSharedBetweenTabs"))
+ sliderElem.addEventListener("change", () => sharedVolumeChanged(Number(sliderElem.value)));
+ if (listenerOnce)
+ return;
+ listenerOnce = true;
+ // the following are only run once:
+ if (getFeature("setInitialTabVolume"))
+ setInitialTabVolume(sliderElem);
+ if (typeof getFeature("volumeSliderSize") === "number")
+ setVolSliderSize();
+ if (getFeature("volumeSharedBetweenTabs"))
+ checkSharedVolume();
};
- /** Uses the default config as the base, then overwrites all values with the passed {@linkcode baseData}, then sets all passed {@linkcode resetKeys} to their default values */
- function useDefaultConfig(baseData, resetKeys) {
- var _a;
- const newData = Object.assign(Object.assign({}, defaultData), (baseData !== null && baseData !== void 0 ? baseData : {}));
- for (const key of resetKeys) // @ts-ignore
- newData[key] = (_a = featInfo === null || featInfo === void 0 ? void 0 : featInfo[key]) === null || _a === void 0 ? void 0 : _a.default; // typescript funny moments
- return newData;
+ addSelectorListener("playerBarRightControls", "tp-yt-paper-slider#volume-slider", {
+ listener: (el) => listener("normal", el),
+ });
+ let sizeSmOnce = false;
+ const onResize = () => {
+ if (sizeSmOnce || window.innerWidth >= 1150)
+ return;
+ sizeSmOnce = true;
+ addSelectorListener("playerBarRightControls", "ytmusic-player-expanding-menu tp-yt-paper-slider#expand-volume-slider", {
+ listener: (el) => listener("expand", el),
+ debounceEdge: "falling",
+ });
+ };
+ window.addEventListener("resize", UserUtils.debounce(onResize, 150, "falling"));
+ waitVideoElementReady().then(onResize);
+ onResize();
+}
+//#region scroll step
+/** Initializes the volume slider scroll step feature */
+function initScrollStep(volSliderCont, sliderElem) {
+ for (const evtName of ["wheel", "scroll", "mousewheel", "DOMMouseScroll"]) {
+ volSliderCont.addEventListener(evtName, (e) => {
+ var _a, _b;
+ e.preventDefault();
+ // cancels all the other events that would be fired
+ e.stopImmediatePropagation();
+ const delta = Number((_b = (_a = e.deltaY) !== null && _a !== void 0 ? _a : e.detail) !== null && _b !== void 0 ? _b : 1);
+ if (isNaN(delta))
+ return warn("Invalid scroll delta:", delta);
+ const volumeDir = -Math.sign(delta);
+ const newVolume = String(Number(sliderElem.value) + (getFeature("volumeSliderScrollStep") * volumeDir));
+ sliderElem.value = newVolume;
+ sliderElem.setAttribute("aria-valuenow", newVolume);
+ // make the site actually change the volume
+ sliderElem.dispatchEvent(new Event("change", { bubbles: true }));
+ }, {
+ // takes precedence over the slider's own event listener
+ capture: true,
+ });
}
- /**
- * Uses {@linkcode oldData} as the base, then sets all keys provided in {@linkcode defaults} to their old default values, as long as their current value is equal to the provided old default.
- * This essentially means if someone has changed a feature's value from its old default value, that decision will be respected. Only if it has been left on its old default value, it will be reset to the new default value.
- * Returns a copy of the object.
- */
- function useNewDefaultIfUnchanged(oldData, defaults) {
- var _a;
- const newData = Object.assign({}, oldData);
- for (const { key, oldDefault } of defaults) {
- // @ts-ignore
- const defaultVal = (_a = featInfo === null || featInfo === void 0 ? void 0 : featInfo[key]) === null || _a === void 0 ? void 0 : _a.default;
- if (newData[key] === oldDefault)
- newData[key] = defaultVal; // we love TS
+}
+//#region volume slider label
+/** Adds a percentage label to the volume slider and tooltip */
+async function addVolumeSliderLabel(type, sliderElem, sliderContainer) {
+ const labelContElem = document.createElement("div");
+ labelContElem.classList.add("bytm-vol-slider-label");
+ const volShared = getFeature("volumeSharedBetweenTabs");
+ if (volShared) {
+ const linkIconHtml = await resourceAsString("icon-link");
+ if (linkIconHtml) {
+ const linkIconElem = document.createElement("div");
+ linkIconElem.classList.add("bytm-vol-slider-shared");
+ setInnerHtml(linkIconElem, linkIconHtml);
+ linkIconElem.role = "alert";
+ linkIconElem.ariaLive = "polite";
+ linkIconElem.title = linkIconElem.ariaLabel = t("volume_shared_tooltip");
+ labelContElem.classList.add("has-icon");
+ labelContElem.appendChild(linkIconElem);
+ }
+ }
+ const getLabel = (value) => `${value}%`;
+ const labelElem = document.createElement("div");
+ labelElem.classList.add("label");
+ labelElem.textContent = getLabel(sliderElem.value);
+ labelContElem.appendChild(labelElem);
+ // prevent video from minimizing
+ labelContElem.addEventListener("click", (e) => e.stopPropagation());
+ labelContElem.addEventListener("keydown", (e) => ["Enter", "Space", " "].includes(e.key) && e.stopPropagation());
+ const getLabelText = (slider) => { var _a; return t("volume_tooltip", slider.value, (_a = getFeature("volumeSliderStep")) !== null && _a !== void 0 ? _a : slider.step); };
+ const labelFull = getLabelText(sliderElem);
+ sliderContainer.setAttribute("title", labelFull);
+ sliderElem.setAttribute("title", labelFull);
+ sliderElem.setAttribute("aria-valuetext", labelFull);
+ const updateLabel = () => {
+ const labelFull = getLabelText(sliderElem);
+ sliderContainer.setAttribute("title", labelFull);
+ sliderElem.setAttribute("title", labelFull);
+ sliderElem.setAttribute("aria-valuetext", labelFull);
+ const labelElem2 = document.querySelectorAll(".bytm-vol-slider-label div.label");
+ for (const el of labelElem2)
+ el.textContent = getLabel(sliderElem.value);
+ };
+ sliderElem.addEventListener("change", updateLabel);
+ siteEvents.on("configChanged", updateLabel);
+ addSelectorListener("playerBarRightControls", type === "normal" ? ".bytm-vol-slider-cont" : "ytmusic-player-expanding-menu .bytm-vol-slider-cont", {
+ listener: (volumeCont) => volumeCont.appendChild(labelContElem),
+ });
+ 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)
+ labelContElem.classList.add("bytm-visible");
+ else if (labelContElem.classList.contains("bytm-visible") || document.activeElement !== sliderElem)
+ labelContElem.classList.remove("bytm-visible");
+ if (Number(sliderElem.value) !== lastSliderVal) {
+ lastSliderVal = Number(sliderElem.value);
+ updateLabel();
}
- return newData;
- }
- let canCompress = true;
- const configStore = new UserUtils.DataStore({
- id: "bytm-config",
- formatVersion,
- defaultData,
- migrations,
- encodeData: (data) => canCompress ? UserUtils.compress(data, compressionFormat, "string") : data,
- decodeData: (data) => canCompress ? UserUtils.decompress(data, compressionFormat, "string") : data,
});
- /** Initializes the DataStore instance and loads persistent data into memory. Returns a copy of the config object. */
- async function initConfig() {
- canCompress = await compressionSupported();
- const oldFmtVer = Number(await GM.getValue(`_uucfgver-${configStore.id}`, NaN));
- // remove extraneous keys
- let data = fixCfgKeys(await configStore.loadData());
- await configStore.setData(data);
- log(`Initialized feature config DataStore with version ${configStore.formatVersion}`);
- if (isNaN(oldFmtVer))
- info(" !- Config data was initialized with default values");
- else if (oldFmtVer !== configStore.formatVersion) {
- try {
- await configStore.setData(data = fixCfgKeys(data));
- info(` !- Config data was migrated from version ${oldFmtVer} to ${configStore.formatVersion}`);
- }
- catch (err) {
- error(" !- Config data migration failed, falling back to default data:", err);
- await configStore.setData(data = configStore.defaultData);
+ sliderHoverObserver.observe(sliderElem, {
+ attributes: true,
+ });
+}
+//#region volume slider size
+/** Sets the volume slider to a set size */
+function setVolSliderSize() {
+ const size = getFeature("volumeSliderSize");
+ if (typeof size !== "number" || isNaN(Number(size)))
+ return error("Invalid volume slider size:", size);
+ setGlobalCssVar("vol-slider-size", `${size}px`);
+ addStyleFromResource("css-vol_slider_size");
+}
+//#region volume slider step
+/** Sets the `step` attribute of the volume slider */
+function setVolSliderStep(sliderElem) {
+ sliderElem.setAttribute("step", String(getFeature("volumeSliderStep")));
+}
+//#region shared volume
+/** Saves the shared volume level to persistent storage */
+async function sharedVolumeChanged(vol) {
+ try {
+ await GM.setValue("bytm-shared-volume", String(lastCheckedSharedVolume = ignoreVal = vol));
+ }
+ catch (err) {
+ error("Couldn't save shared volume level due to an error:", err);
+ }
+}
+let ignoreVal = -1;
+let lastCheckedSharedVolume = -1;
+/** Only call once as this calls itself after a timeout! - Checks if the shared volume has changed and updates the volume slider accordingly */
+async function checkSharedVolume() {
+ try {
+ const vol = await GM.getValue("bytm-shared-volume");
+ if (vol && lastCheckedSharedVolume !== Number(vol)) {
+ if (ignoreVal === Number(vol))
+ return;
+ lastCheckedSharedVolume = Number(vol);
+ const sliderElem = document.querySelector("tp-yt-paper-slider#volume-slider");
+ if (sliderElem) {
+ sliderElem.value = String(vol);
+ sliderElem.dispatchEvent(new Event("change", { bubbles: true }));
}
}
- emitInterface("bytm:configReady");
- return Object.assign({}, data);
+ setTimeout(checkSharedVolume, 333);
}
- /**
- * Fixes missing keys in the passed config object with their default values or removes extraneous keys and returns a copy of the fixed object.
- * Returns a copy of the originally passed object if nothing needs to be fixed.
- */
- function fixCfgKeys(cfg) {
- const newCfg = Object.assign({}, cfg);
- const passedKeys = Object.keys(cfg);
- const defaultKeys = Object.keys(defaultData);
- const missingKeys = defaultKeys.filter(k => !passedKeys.includes(k));
- if (missingKeys.length > 0) {
- for (const key of missingKeys)
- newCfg[key] = defaultData[key];
- }
- const extraKeys = passedKeys.filter(k => !defaultKeys.includes(k));
- if (extraKeys.length > 0) {
- for (const key of extraKeys)
- delete newCfg[key];
- }
- return newCfg;
- }
- /** Returns the current feature config from the in-memory cache as a copy */
- function getFeatures() {
- return configStore.getData();
- }
- /** Returns the value of the feature with the given key from the in-memory cache, as a copy */
- function getFeature(key) {
- return configStore.getData()[key];
- }
- /** Saves the feature config synchronously to the in-memory cache and asynchronously to the persistent storage */
- function setFeatures(featureConf) {
- const res = configStore.setData(featureConf);
- emitSiteEvent("configChanged", configStore.getData());
- info("Saved new feature config:", featureConf);
- return res;
- }
- /** Saves the default feature config synchronously to the in-memory cache and asynchronously to persistent storage */
- function setDefaultFeatures() {
- const res = configStore.saveDefaultData();
- emitSiteEvent("configChanged", configStore.getData());
- info("Reset feature config to its default values");
- return res;
- }
- async function promptResetConfig() {
- if (await showPrompt({ type: "confirm", message: t("reset_config_confirm") })) {
- closeCfgMenu();
- disableBeforeUnload();
- await setDefaultFeatures();
- if (location.pathname.startsWith("/watch")) {
- const videoTime = await getVideoTime(0);
- const url = new URL(location.href);
- url.searchParams.delete("t");
- if (videoTime)
- url.searchParams.set("time_continue", String(videoTime));
- location.replace(url.href);
+ catch (err) {
+ error("Couldn't check for shared volume level due to an error:", err);
+ }
+}
+//#region initial volume
+/** Sets the volume slider to a set volume level when the session starts */
+async function setInitialTabVolume(sliderElem) {
+ await waitVideoElementReady();
+ const initialVol = getFeature("initialTabVolumeLevel");
+ if (getFeature("volumeSharedBetweenTabs")) {
+ lastCheckedSharedVolume = ignoreVal = initialVol;
+ if (getFeature("volumeSharedBetweenTabs"))
+ GM.setValue("bytm-shared-volume", String(initialVol));
+ }
+ sliderElem.value = String(initialVol);
+ sliderElem.dispatchEvent(new Event("change", { bubbles: true }));
+ log(`Set initial tab volume to ${initialVol}%`);
+}//#region misc
+function noop() {
+}
+/** Creates an HTML string for the given adornment properties */
+const getAdornHtml = async (className, title, resource, extraAttributes) => { var _a; return `${(_a = await resourceAsString(resource)) !== null && _a !== void 0 ? _a : ""}`; };
+/** Combines multiple async functions or promises that resolve with an adornment HTML string into a single string */
+const combineAdornments = (adornments) => new Promise(async (resolve) => {
+ const sortedAdornments = adornments.sort((a, b) => {
+ const aIndex = adornmentOrder.get(a) ? adornmentOrder.get(a) : -1;
+ const bIndex = adornmentOrder.has(b) ? adornmentOrder.get(b) : -1;
+ return aIndex - bIndex;
+ });
+ const html = [];
+ for (const adornment of sortedAdornments) {
+ const val = typeof adornment === "function"
+ ? await adornment()
+ : await adornment;
+ val && html.push(val);
+ }
+ resolve(html.join(""));
+});
+/** Decoration elements that can be added next to the label */
+const adornments = {
+ advanced: async () => getAdornHtml("bytm-advanced-mode-icon", t("advanced_mode"), "icon-advanced_mode"),
+ experimental: async () => getAdornHtml("bytm-experimental-icon", t("experimental_feature"), "icon-experimental"),
+ globe: async () => getAdornHtml("bytm-locale-icon", undefined, "icon-globe_small"),
+ alert: async (title) => getAdornHtml("bytm-warning-icon", title, "icon-error", "role=\"alert\""),
+ reload: async () => getFeature("advancedMode") ? getAdornHtml("bytm-reload-icon", t("feature_requires_reload"), "icon-reload") : undefined,
+};
+/** Order of adornment elements in the {@linkcode combineAdornments()} function */
+const adornmentOrder = new Map();
+adornmentOrder.set(adornments.alert, 0);
+adornmentOrder.set(adornments.experimental, 1);
+adornmentOrder.set(adornments.globe, 2);
+adornmentOrder.set(adornments.reload, 3);
+adornmentOrder.set(adornments.advanced, 4);
+/** Common options for config items of type "select" */
+const options = {
+ siteSelection: () => [
+ { value: "all", label: t("site_selection_both_sites") },
+ { value: "yt", label: t("site_selection_only_yt") },
+ { value: "ytm", label: t("site_selection_only_ytm") },
+ ],
+ siteSelectionOrNone: () => [
+ { value: "all", label: t("site_selection_both_sites") },
+ { value: "yt", label: t("site_selection_only_yt") },
+ { value: "ytm", label: t("site_selection_only_ytm") },
+ { value: "none", label: t("site_selection_none") },
+ ],
+ locale: () => Object.entries(langMapping)
+ .reduce((a, [locale, { name }]) => {
+ return [...a, {
+ value: locale,
+ label: name,
+ }];
+ }, [])
+ .sort((a, b) => a.label.localeCompare(b.label)),
+ colorLightness: () => [
+ { value: "darker", label: t("color_lightness_darker") },
+ { value: "normal", label: t("color_lightness_normal") },
+ { value: "lighter", label: t("color_lightness_lighter") },
+ ],
+};
+//#region rendering
+/** Renders a long number with a thousands separator */
+function renderLongNumberValue(val, maximumFractionDigits = 0) {
+ return Number(val).toLocaleString(getLocale().replace(/_/g, "-"), {
+ style: "decimal",
+ maximumFractionDigits,
+ });
+}
+//#region features
+/**
+ * Contains all possible features with their default values and other configuration.
+ *
+ * **Required props:**
+ *
+ * | Property | Description |
+ * | :----------------------------- | :------------------------------------------------------------------------------------------------------------------------------- |
+ * | `type: string` | Type of the feature configuration element - use autocomplete or check `FeatureTypeProps` in `src/types.ts` |
+ * | `category: string` | Category of the feature - use autocomplete or check `FeatureCategory` in `src/types.ts` |
+ * | `default: unknown` | Default value of the feature - type of the value depends on the given `type` |
+ * | `enable(value: unknown): void` | (required if reloadRequired = false) - function that will be called when the feature is enabled / initialized for the first time |
+ *
+ *
+ *
+ * **Optional props:**
+ *
+ * | Property | Description |
+ * | :----------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------|
+ * | `disable(newValue: unknown): void` | For type `toggle` only - function that will be called when the feature is disabled - can be a synchronous or asynchronous function |
+ * | `change(key: string, prevValue: unknown, newValue: unknown): void` | For types `number`, `select`, `slider` and `hotkey` only - function that will be called when the value is changed |
+ * | `click(): void` | For type `button` only - function that will be called when the button is clicked |
+ * | `helpText: string \| () => string` | Function that returns an HTML string or the literal string itself that will be the help text for this feature - writing as function is useful for pluralizing or inserting values into the translation at runtime - if not set, translation with key `feature_helptext_featureKey` will be used instead, if available |
+ * | `textAdornment(): string \| Promise` | Function that returns an HTML string that will be appended to the text in the config menu as an adornment element |
+ * | `unit: string \| (val: number) => string` | For types `number` or `slider` only - The unit text that is displayed next to the input element, i.e. " px" - a leading space need to be added too! |
+ * | `min: number` | For types `number` or `slider` only - Overwrites the default of the `min` property of the HTML input element |
+ * | `max: number` | For types `number` or `slider` only - Overwrites the default of the `max` property of the HTML input element |
+ * | `step: number` | For types `number` or `slider` only - Overwrites the default of the `step` property of the HTML input element |
+ * | `options: SelectOption[] \| () => SelectOption[]` | For type `select` only - function that returns an array of objects with `value` and `label` properties |
+ * | `reloadRequired: boolean` | If true (default), the page needs to be reloaded for the changes to take effect - if false, `enable()` needs to be provided |
+ * | `advanced: boolean` | If true, the feature will only be shown if the advanced mode feature has been turned on |
+ * | `hidden: boolean` | If true, the feature will not be shown in the settings - default is undefined (false) |
+ * | `valueHidden: boolean` | If true, the value of the feature will be hidden in the settings and via the plugin interface - default is undefined (false) |
+ * | `normalize(val: unknown): unknown` | Function that will be called to normalize the value before it is saved - useful for trimming strings or other simple operations |
+ * | `renderValue(val: string): string` | If provided, is used to render the value's label in the config menu |
+ *
+ *
+ * TODO: go through all features and set as many as possible to reloadRequired = false
+ */
+const featInfo = {
+ //#region cat:layout
+ watermarkEnabled: {
+ type: "toggle",
+ category: "layout",
+ default: true,
+ textAdornment: adornments.reload,
+ },
+ removeShareTrackingParam: {
+ type: "toggle",
+ category: "layout",
+ default: true,
+ textAdornment: adornments.reload,
+ },
+ removeShareTrackingParamSites: {
+ type: "select",
+ category: "layout",
+ options: options.siteSelection,
+ default: "all",
+ advanced: true,
+ textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]),
+ },
+ fixSpacing: {
+ type: "toggle",
+ category: "layout",
+ default: true,
+ advanced: true,
+ textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]),
+ },
+ thumbnailOverlayBehavior: {
+ type: "select",
+ category: "layout",
+ options: () => [
+ { value: "songsOnly", label: t("thumbnail_overlay_behavior_songs_only") },
+ { value: "videosOnly", label: t("thumbnail_overlay_behavior_videos_only") },
+ { value: "always", label: t("thumbnail_overlay_behavior_always") },
+ { value: "never", label: t("thumbnail_overlay_behavior_never") },
+ ],
+ default: "songsOnly",
+ reloadRequired: false,
+ enable: noop,
+ },
+ thumbnailOverlayToggleBtnShown: {
+ type: "toggle",
+ category: "layout",
+ default: true,
+ textAdornment: adornments.reload,
+ },
+ thumbnailOverlayShowIndicator: {
+ type: "toggle",
+ category: "layout",
+ default: true,
+ textAdornment: adornments.reload,
+ },
+ thumbnailOverlayIndicatorOpacity: {
+ type: "slider",
+ category: "layout",
+ min: 5,
+ max: 100,
+ step: 5,
+ default: 40,
+ unit: "%",
+ advanced: true,
+ textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]),
+ },
+ thumbnailOverlayImageFit: {
+ type: "select",
+ category: "layout",
+ options: () => [
+ { value: "cover", label: t("thumbnail_overlay_image_fit_crop") },
+ { value: "contain", label: t("thumbnail_overlay_image_fit_full") },
+ { value: "fill", label: t("thumbnail_overlay_image_fit_stretch") },
+ ],
+ default: "cover",
+ advanced: true,
+ textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]),
+ },
+ hideCursorOnIdle: {
+ type: "toggle",
+ category: "layout",
+ default: true,
+ reloadRequired: false,
+ enable: noop,
+ },
+ hideCursorOnIdleDelay: {
+ type: "slider",
+ category: "layout",
+ min: 0.5,
+ max: 10,
+ step: 0.25,
+ default: 2,
+ unit: "s",
+ advanced: true,
+ textAdornment: adornments.advanced,
+ reloadRequired: false,
+ enable: noop,
+ },
+ fixHdrIssues: {
+ type: "toggle",
+ category: "layout",
+ default: true,
+ advanced: true,
+ textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]),
+ },
+ showVotes: {
+ type: "toggle",
+ category: "layout",
+ default: true,
+ textAdornment: adornments.reload,
+ },
+ // archived idea for future version
+ // (shows a bar under the like/dislike buttons that shows the ratio of likes to dislikes)
+ // showVoteRatio: {
+ // type: "select",
+ // category: "layout",
+ // options: () => [
+ // { value: "disabled", label: t("vote_ratio_disabled") },
+ // { value: "greenRed", label: t("vote_ratio_green_red") },
+ // { value: "blueGray", label: t("vote_ratio_blue_gray") },
+ // ],
+ // default: "disabled",
+ // textAdornment: adornments.reload,
+ // },
+ //#region cat:volume
+ volumeSliderLabel: {
+ type: "toggle",
+ category: "volume",
+ default: true,
+ textAdornment: adornments.reload,
+ },
+ volumeSliderSize: {
+ type: "number",
+ category: "volume",
+ min: 50,
+ max: 500,
+ step: 5,
+ default: 150,
+ unit: "px",
+ textAdornment: adornments.reload,
+ },
+ volumeSliderStep: {
+ type: "slider",
+ category: "volume",
+ min: 1,
+ max: 25,
+ default: 2,
+ unit: "%",
+ textAdornment: adornments.reload,
+ },
+ volumeSliderScrollStep: {
+ type: "slider",
+ category: "volume",
+ min: 1,
+ max: 25,
+ default: 4,
+ unit: "%",
+ textAdornment: adornments.reload,
+ },
+ volumeSharedBetweenTabs: {
+ type: "toggle",
+ category: "volume",
+ default: false,
+ textAdornment: adornments.reload,
+ },
+ setInitialTabVolume: {
+ type: "toggle",
+ category: "volume",
+ default: false,
+ textAdornment: () => getFeature("volumeSharedBetweenTabs")
+ ? combineAdornments([adornments.alert(t("feature_warning_setInitialTabVolume_volumeSharedBetweenTabs_incompatible").replace(/"/g, "'")), adornments.reload])
+ : adornments.reload(),
+ },
+ initialTabVolumeLevel: {
+ type: "slider",
+ category: "volume",
+ min: 0,
+ max: 100,
+ step: 1,
+ default: 100,
+ unit: "%",
+ textAdornment: () => getFeature("volumeSharedBetweenTabs")
+ ? combineAdornments([adornments.alert(t("feature_warning_setInitialTabVolume_volumeSharedBetweenTabs_incompatible").replace(/"/g, "'")), adornments.reload])
+ : adornments.reload(),
+ reloadRequired: false,
+ enable: noop,
+ },
+ //#region cat:song lists
+ lyricsQueueButton: {
+ type: "toggle",
+ category: "songLists",
+ default: true,
+ textAdornment: adornments.reload,
+ },
+ deleteFromQueueButton: {
+ type: "toggle",
+ category: "songLists",
+ default: true,
+ textAdornment: adornments.reload,
+ },
+ listButtonsPlacement: {
+ type: "select",
+ category: "songLists",
+ options: () => [
+ { value: "queueOnly", label: t("list_button_placement_queue_only") },
+ { value: "everywhere", label: t("list_button_placement_everywhere") },
+ ],
+ default: "everywhere",
+ advanced: true,
+ textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]),
+ },
+ scrollToActiveSongBtn: {
+ type: "toggle",
+ category: "songLists",
+ default: true,
+ textAdornment: adornments.reload,
+ },
+ clearQueueBtn: {
+ type: "toggle",
+ category: "songLists",
+ default: true,
+ textAdornment: adornments.reload,
+ },
+ aboveQueueBtnsSticky: {
+ type: "toggle",
+ category: "songLists",
+ default: true,
+ advanced: true,
+ textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]),
+ },
+ //#region cat:behavior
+ disableBeforeUnloadPopup: {
+ type: "toggle",
+ category: "behavior",
+ default: false,
+ textAdornment: adornments.reload,
+ },
+ closeToastsTimeout: {
+ type: "number",
+ category: "behavior",
+ min: 0,
+ max: 30,
+ step: 0.5,
+ default: 3,
+ unit: "s",
+ reloadRequired: false,
+ enable: noop,
+ },
+ rememberSongTime: {
+ type: "toggle",
+ category: "behavior",
+ default: true,
+ helpText: () => tp("feature_helptext_rememberSongTime", getFeature("rememberSongTimeMinPlayTime"), getFeature("rememberSongTimeMinPlayTime")),
+ textAdornment: adornments.reload,
+ },
+ rememberSongTimeSites: {
+ type: "select",
+ category: "behavior",
+ options: options.siteSelection,
+ default: "all",
+ textAdornment: adornments.reload,
+ },
+ rememberSongTimeDuration: {
+ type: "number",
+ category: "behavior",
+ min: 1,
+ max: 60 * 60 * 24 * 7,
+ step: 1,
+ default: 60,
+ unit: "s",
+ advanced: true,
+ textAdornment: adornments.advanced,
+ reloadRequired: false,
+ enable: noop,
+ },
+ rememberSongTimeReduction: {
+ type: "number",
+ category: "behavior",
+ min: 0,
+ max: 30,
+ step: 0.05,
+ default: 0.2,
+ unit: "s",
+ advanced: true,
+ textAdornment: adornments.advanced,
+ reloadRequired: false,
+ enable: noop,
+ },
+ rememberSongTimeMinPlayTime: {
+ type: "slider",
+ category: "behavior",
+ min: 3,
+ max: 30,
+ step: 0.5,
+ default: 10,
+ unit: "s",
+ advanced: true,
+ textAdornment: adornments.advanced,
+ reloadRequired: false,
+ enable: noop,
+ },
+ //#region cat:input
+ arrowKeySupport: {
+ type: "toggle",
+ category: "input",
+ default: true,
+ reloadRequired: false,
+ enable: noop,
+ },
+ arrowKeySkipBy: {
+ type: "slider",
+ category: "input",
+ min: 0.5,
+ max: 30,
+ step: 0.5,
+ default: 5,
+ unit: "s",
+ reloadRequired: false,
+ enable: noop,
+ },
+ switchBetweenSites: {
+ type: "toggle",
+ category: "input",
+ default: true,
+ reloadRequired: false,
+ enable: noop,
+ },
+ switchSitesHotkey: {
+ type: "hotkey",
+ category: "input",
+ default: {
+ code: "F9",
+ shift: false,
+ ctrl: false,
+ alt: false,
+ },
+ reloadRequired: false,
+ enable: noop,
+ },
+ anchorImprovements: {
+ type: "toggle",
+ category: "input",
+ default: true,
+ textAdornment: adornments.reload,
+ },
+ numKeysSkipToTime: {
+ type: "toggle",
+ category: "input",
+ default: true,
+ reloadRequired: false,
+ enable: noop,
+ },
+ autoLikeChannels: {
+ type: "toggle",
+ category: "input",
+ default: true,
+ textAdornment: adornments.reload,
+ },
+ autoLikeChannelToggleBtn: {
+ type: "toggle",
+ category: "input",
+ default: true,
+ reloadRequired: false,
+ enable: noop,
+ advanced: true,
+ textAdornment: adornments.advanced,
+ },
+ // TODO(v2.2):
+ // autoLikePlayerBarToggleBtn: {
+ // type: "toggle",
+ // category: "input",
+ // default: false,
+ // textAdornment: adornments.reload,
+ // },
+ autoLikeTimeout: {
+ type: "slider",
+ category: "input",
+ min: 3,
+ max: 30,
+ step: 0.5,
+ default: 5,
+ unit: "s",
+ advanced: true,
+ reloadRequired: false,
+ enable: noop,
+ textAdornment: adornments.advanced,
+ },
+ autoLikeShowToast: {
+ type: "toggle",
+ category: "input",
+ default: true,
+ reloadRequired: false,
+ advanced: true,
+ enable: noop,
+ textAdornment: adornments.advanced,
+ },
+ autoLikeOpenMgmtDialog: {
+ type: "button",
+ category: "input",
+ click: () => getAutoLikeDialog().then(d => d.open()),
+ },
+ //#region cat:lyrics
+ geniusLyrics: {
+ type: "toggle",
+ category: "lyrics",
+ default: true,
+ textAdornment: adornments.reload,
+ },
+ errorOnLyricsNotFound: {
+ type: "toggle",
+ category: "lyrics",
+ default: false,
+ reloadRequired: false,
+ enable: noop,
+ },
+ geniUrlBase: {
+ type: "text",
+ category: "lyrics",
+ default: "https://api.sv443.net/geniurl",
+ normalize: (val) => val.trim().replace(/\/+$/, ""),
+ advanced: true,
+ textAdornment: adornments.advanced,
+ reloadRequired: false,
+ enable: noop,
+ },
+ geniUrlToken: {
+ type: "text",
+ valueHidden: true,
+ category: "lyrics",
+ default: "",
+ normalize: (val) => val.trim(),
+ advanced: true,
+ textAdornment: adornments.advanced,
+ reloadRequired: false,
+ enable: noop,
+ },
+ lyricsCacheMaxSize: {
+ type: "slider",
+ category: "lyrics",
+ default: 2000,
+ min: 100,
+ max: 10000,
+ step: 100,
+ unit: (val) => ` ${tp("unit_entries", val)}`,
+ renderValue: renderLongNumberValue,
+ advanced: true,
+ textAdornment: adornments.advanced,
+ reloadRequired: false,
+ enable: noop,
+ },
+ lyricsCacheTTL: {
+ type: "slider",
+ category: "lyrics",
+ default: 21,
+ min: 1,
+ max: 100,
+ step: 1,
+ unit: (val) => " " + tp("unit_days", val),
+ advanced: true,
+ textAdornment: adornments.advanced,
+ reloadRequired: false,
+ enable: noop,
+ },
+ clearLyricsCache: {
+ type: "button",
+ category: "lyrics",
+ async click() {
+ const entries = getLyricsCache().length;
+ const formattedEntries = entries.toLocaleString(getLocale(), { style: "decimal", maximumFractionDigits: 0 });
+ if (await showPrompt({ type: "confirm", message: tp("lyrics_clear_cache_confirm_prompt", entries, formattedEntries) })) {
+ await clearLyricsCache();
+ await showPrompt({ type: "alert", message: t("lyrics_clear_cache_success") });
}
- else
+ },
+ advanced: true,
+ textAdornment: adornments.advanced,
+ },
+ // advancedLyricsFilter: {
+ // type: "toggle",
+ // category: "lyrics",
+ // default: false,
+ // change: () => setTimeout(async () => await showPrompt({ type: "confirm", message: t("lyrics_cache_changed_clear_confirm") }) && clearLyricsCache(), 200),
+ // advanced: true,
+ // textAdornment: adornments.experimental,
+ // reloadRequired: false,
+ // enable: noop,
+ // },
+ //#region cat:integrations
+ disableDarkReaderSites: {
+ type: "select",
+ category: "integrations",
+ options: options.siteSelectionOrNone,
+ default: "all",
+ advanced: true,
+ textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]),
+ },
+ sponsorBlockIntegration: {
+ type: "toggle",
+ category: "integrations",
+ default: true,
+ advanced: true,
+ textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]),
+ },
+ themeSongIntegration: {
+ type: "toggle",
+ category: "integrations",
+ default: false,
+ textAdornment: adornments.reload,
+ },
+ themeSongLightness: {
+ type: "select",
+ category: "integrations",
+ options: options.colorLightness,
+ default: "darker",
+ textAdornment: adornments.reload,
+ },
+ //#region cat:plugins
+ openPluginList: {
+ type: "button",
+ category: "plugins",
+ default: undefined,
+ click: () => getPluginListDialog().then(d => d.open()),
+ },
+ initTimeout: {
+ type: "number",
+ category: "plugins",
+ min: 3,
+ max: 30,
+ default: 8,
+ step: 0.1,
+ unit: "s",
+ advanced: true,
+ textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]),
+ },
+ //#region cat:general
+ locale: {
+ type: "select",
+ category: "general",
+ options: options.locale,
+ default: getPreferredLocale(),
+ textAdornment: () => combineAdornments([adornments.globe, adornments.reload]),
+ },
+ localeFallback: {
+ type: "toggle",
+ category: "general",
+ default: true,
+ advanced: true,
+ textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]),
+ },
+ versionCheck: {
+ type: "toggle",
+ category: "general",
+ default: true,
+ textAdornment: adornments.reload,
+ },
+ checkVersionNow: {
+ type: "button",
+ category: "general",
+ click: () => doVersionCheck(true),
+ },
+ numbersFormat: {
+ type: "select",
+ category: "general",
+ options: () => [
+ { value: "long", label: `${formatNumber(12345678, "long")} (${t("votes_format_long")})` },
+ { value: "short", label: `${formatNumber(12345678, "short")} (${t("votes_format_short")})` },
+ ],
+ default: "short",
+ reloadRequired: false,
+ enable: noop,
+ },
+ toastDuration: {
+ type: "slider",
+ category: "general",
+ min: 0,
+ max: 15,
+ default: 4,
+ step: 0.5,
+ unit: "s",
+ reloadRequired: false,
+ advanced: true,
+ textAdornment: adornments.advanced,
+ enable: noop,
+ change: () => showIconToast({
+ message: t("example_toast"),
+ iconSrc: getResourceUrl(`img-logo${mode === "development" ? "_dev" : ""}`),
+ }),
+ },
+ showToastOnGenericError: {
+ type: "toggle",
+ category: "general",
+ default: true,
+ advanced: true,
+ textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]),
+ },
+ resetConfig: {
+ type: "button",
+ category: "general",
+ click: promptResetConfig,
+ textAdornment: adornments.reload,
+ },
+ resetEverything: {
+ type: "button",
+ category: "general",
+ click: async () => {
+ if (await showPrompt({
+ type: "confirm",
+ message: t("reset_everything_confirm"),
+ })) {
+ await getStoreSerializer().resetStoresData();
location.reload();
- }
- }
- /** 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 */
- async function clearConfig() {
- await configStore.deleteData();
- info("Deleted config from persistent storage");
+ }
+ },
+ advanced: true,
+ textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]),
+ },
+ logLevel: {
+ type: "select",
+ category: "general",
+ options: () => [
+ { value: 0, label: t("log_level_debug") },
+ { value: 1, label: t("log_level_info") },
+ ],
+ default: 1,
+ textAdornment: adornments.reload,
+ },
+ advancedMode: {
+ type: "toggle",
+ category: "general",
+ default: false,
+ textAdornment: () => getFeature("advancedMode") ? adornments.advanced() : undefined,
+ change: (_key, prevValue, newValue) => prevValue !== newValue &&
+ emitSiteEvent("recreateCfgMenu"),
+ },
+};/** If this number is incremented, the features object data will be migrated to the new format */
+const formatVersion = 9;
+const defaultData = Object.keys(featInfo)
+ // @ts-ignore
+ .filter((ftKey) => { var _a; return ((_a = featInfo === null || featInfo === void 0 ? void 0 : featInfo[ftKey]) === null || _a === void 0 ? void 0 : _a.default) !== undefined; })
+ .reduce((acc, key) => {
+ var _a;
+ // @ts-ignore
+ acc[key] = (_a = featInfo === null || featInfo === void 0 ? void 0 : featInfo[key]) === null || _a === void 0 ? void 0 : _a.default;
+ return acc;
+}, {});
+/** Config data format migration dictionary */
+const migrations = {
+ // 1 -> 2 (<=v1.0)
+ 2: (oldData) => {
+ const queueBtnsEnabled = Boolean(oldData.queueButtons);
+ delete oldData.queueButtons;
+ return Object.assign(Object.assign({}, oldData), { deleteFromQueueButton: queueBtnsEnabled, lyricsQueueButton: queueBtnsEnabled });
+ },
+ // 2 -> 3 (v1.0)
+ 3: (oldData) => useDefaultConfig(oldData, [
+ "removeShareTrackingParam", "numKeysSkipToTime",
+ "fixSpacing", "scrollToActiveSongBtn", "logLevel",
+ ]),
+ // 3 -> 4 (v1.1)
+ 4: (oldData) => {
+ var _a, _b, _c, _d;
+ const oldSwitchSitesHotkey = oldData.switchSitesHotkey;
+ return Object.assign(Object.assign({}, useDefaultConfig(oldData, [
+ "rememberSongTime", "rememberSongTimeSites",
+ "volumeSliderScrollStep", "locale", "versionCheck",
+ ])), { arrowKeySkipBy: 10, switchSitesHotkey: {
+ code: (_a = oldSwitchSitesHotkey.key) !== null && _a !== void 0 ? _a : "F9",
+ shift: Boolean((_b = oldSwitchSitesHotkey.shift) !== null && _b !== void 0 ? _b : false),
+ ctrl: Boolean((_c = oldSwitchSitesHotkey.ctrl) !== null && _c !== void 0 ? _c : false),
+ alt: Boolean((_d = oldSwitchSitesHotkey.meta) !== null && _d !== void 0 ? _d : false),
+ }, listButtonsPlacement: "queueOnly" });
+ },
+ // 4 -> 5 (v2.0)
+ 5: (oldData) => useDefaultConfig(oldData, [
+ "localeFallback", "geniUrlBase", "geniUrlToken",
+ "lyricsCacheMaxSize", "lyricsCacheTTL",
+ "clearLyricsCache", "advancedMode",
+ "checkVersionNow", "advancedLyricsFilter",
+ "rememberSongTimeDuration", "rememberSongTimeReduction",
+ "rememberSongTimeMinPlayTime", "volumeSharedBetweenTabs",
+ "setInitialTabVolume", "initialTabVolumeLevel",
+ "thumbnailOverlayBehavior", "thumbnailOverlayToggleBtnShown",
+ "thumbnailOverlayShowIndicator", "thumbnailOverlayIndicatorOpacity",
+ "thumbnailOverlayImageFit", "removeShareTrackingParamSites",
+ "fixHdrIssues", "clearQueueBtn",
+ "closeToastsTimeout", "disableDarkReaderSites",
+ ]),
+ // 5 -> 6 (v2.1)
+ 6: (oldData) => {
+ const newData = useNewDefaultIfUnchanged(useDefaultConfig(oldData, [
+ "autoLikeChannels", "autoLikeChannelToggleBtn",
+ "autoLikeTimeout", "autoLikeShowToast",
+ "autoLikeOpenMgmtDialog", "showVotes",
+ "numbersFormat", "toastDuration",
+ "initTimeout",
+ // forgot to add this to the migration when adding the feature way before so now will have to do:
+ "volumeSliderLabel",
+ ]), [
+ { key: "rememberSongTimeSites", oldDefault: "ytm" },
+ { key: "volumeSliderScrollStep", oldDefault: 10 },
+ ]);
+ "removeUpgradeTab" in newData && delete newData.removeUpgradeTab;
+ "advancedLyricsFilter" in newData && delete newData.advancedLyricsFilter;
+ return newData;
+ },
+ // TODO(v2.2): use default for "autoLikePlayerBarToggleBtn"
+ // TODO(v2.2): set autoLikeChannels to true on migration once feature is fully implemented
+ // 6 -> 7 (v2.1-dev)
+ 7: (oldData) => {
+ const newData = useNewDefaultIfUnchanged(useDefaultConfig(oldData, [
+ "showToastOnGenericError", "sponsorBlockIntegration",
+ "themeSongIntegration", "themeSongLightness",
+ "errorOnLyricsNotFound", "openPluginList",
+ ]), [
+ { key: "toastDuration", oldDefault: 3 },
+ ]);
+ newData.arrowKeySkipBy = UserUtils.clamp(newData.arrowKeySkipBy, 0.5, 30);
+ return newData;
+ },
+ // 7 -> 8 (v2.1)
+ 8: (oldData) => {
+ if ("showVotesFormat" in oldData) {
+ oldData.numbersFormat = oldData.showVotesFormat;
+ delete oldData.showVotesFormat;
+ }
+ return useDefaultConfig(oldData, [
+ "autoLikeChannels"
+ ]);
+ },
+ // 8 -> 9 (v2.2)
+ 9: (oldData) => {
+ oldData.locale = oldData.locale.replace("_", "-");
+ if (oldData.locale === "ja-JA")
+ oldData.locale = "ja-JP";
+ if (oldData.locale === "en-GB")
+ oldData.locale = "en-GB";
+ return useDefaultConfig(oldData, [
+ "resetEverything",
+ // TODO(V2.2):
+ // "autoLikePlayerBarToggleBtn",
+ ]);
+ },
+ // 9 -> 10 (v2.2.1)
+ 10: (oldData) => useDefaultConfig(oldData, [
+ "aboveQueueBtnsSticky",
+ ]),
+};
+/** Uses the default config as the base, then overwrites all values with the passed {@linkcode baseData}, then sets all passed {@linkcode resetKeys} to their default values */
+function useDefaultConfig(baseData, resetKeys) {
+ var _a;
+ const newData = Object.assign(Object.assign({}, defaultData), (baseData !== null && baseData !== void 0 ? baseData : {}));
+ for (const key of resetKeys) // @ts-ignore
+ newData[key] = (_a = featInfo === null || featInfo === void 0 ? void 0 : featInfo[key]) === null || _a === void 0 ? void 0 : _a.default; // typescript funny moments
+ return newData;
+}
+/**
+ * Uses {@linkcode oldData} as the base, then sets all keys provided in {@linkcode defaults} to their old default values, as long as their current value is equal to the provided old default.
+ * This essentially means if someone has changed a feature's value from its old default value, that decision will be respected. Only if it has been left on its old default value, it will be reset to the new default value.
+ * Returns a copy of the object.
+ */
+function useNewDefaultIfUnchanged(oldData, defaults) {
+ var _a;
+ const newData = Object.assign({}, oldData);
+ for (const { key, oldDefault } of defaults) {
+ // @ts-ignore
+ const defaultVal = (_a = featInfo === null || featInfo === void 0 ? void 0 : featInfo[key]) === null || _a === void 0 ? void 0 : _a.default;
+ if (newData[key] === oldDefault)
+ newData[key] = defaultVal; // we love TS
}
-
- const { autoPlural, getUnsafeWindow, randomId, NanoEmitter } = UserUtils__namespace;
- /**
- * All functions that can be called on the BYTM interface using `unsafeWindow.BYTM.functionName();` (or `const { functionName } = unsafeWindow.BYTM;`)
- * If prefixed with /\*🔒\*\/, the function is authenticated and requires a token to be passed as the first argument.
- */
- const globalFuncs = {
- // meta:
- /*🔒*/ getPluginInfo,
- // bytm-specific:
- getDomain,
- getResourceUrl,
- getSessionId,
- // dom:
- setInnerHtml,
- addSelectorListener,
- onInteraction,
- getVideoTime,
- getThumbnailUrl,
- getBestThumbnailUrl,
- waitVideoElementReady,
- getCurrentMediaType,
- // translations:
- /*🔒*/ setLocale: setLocaleInterface,
- getLocale,
- hasKey,
- hasKeyFor,
- t,
- tp,
- tl,
- tlp,
- // feature config:
- /*🔒*/ getFeatures: getFeaturesInterface,
- /*🔒*/ saveFeatures: saveFeaturesInterface,
- // lyrics:
- fetchLyricsUrlTop,
- getLyricsCacheEntry,
- sanitizeArtists,
- sanitizeSong,
- // auto-like:
- /*🔒*/ getAutoLikeData: getAutoLikeDataInterface,
- /*🔒*/ saveAutoLikeData: saveAutoLikeDataInterface,
- fetchVideoVotes,
- // components:
- createHotkeyInput,
- createToggleInput,
- createCircularBtn,
- createRipple,
- showToast,
- showIconToast,
- showPrompt,
- // other:
- formatNumber,
- };
- /** Initializes the BYTM interface */
- function initInterface() {
- const props = Object.assign(Object.assign(Object.assign({
- // meta / constants
- mode,
- branch,
- host,
- buildNumber,
- compressionFormat }, scriptInfo), globalFuncs), {
- // classes
- NanoEmitter,
- BytmDialog,
- ExImDialog,
- MarkdownDialog,
- // libraries
- UserUtils: UserUtils__namespace,
- compareVersions: compareVersions__namespace });
- for (const [key, value] of Object.entries(props))
- setGlobalProp(key, value);
- log("Initialized BYTM interface");
- }
- /** Sets a global property on the unsafeWindow.BYTM object - ⚠️ use with caution as these props can be accessed by any script on the page! */
- function setGlobalProp(key, value) {
- // use unsafeWindow so the properties are available to plugins outside of the userscript's scope
- const win = getUnsafeWindow();
- if (typeof win.BYTM !== "object")
- win.BYTM = {};
- win.BYTM[key] = value;
- }
- /** Emits an event on the BYTM interface */
- function emitInterface(type, ...detail) {
- var _a;
+ return newData;
+}
+let canCompress = true;
+const configStore = new UserUtils.DataStore({
+ id: "bytm-config",
+ formatVersion,
+ defaultData,
+ migrations,
+ encodeData: (data) => canCompress ? UserUtils.compress(data, compressionFormat, "string") : data,
+ decodeData: (data) => canCompress ? UserUtils.decompress(data, compressionFormat, "string") : data,
+});
+/** Initializes the DataStore instance and loads persistent data into memory. Returns a copy of the config object. */
+async function initConfig() {
+ canCompress = await compressionSupported();
+ const oldFmtVer = Number(await GM.getValue(`_uucfgver-${configStore.id}`, NaN));
+ // remove extraneous keys
+ let data = fixCfgKeys(await configStore.loadData());
+ await configStore.setData(data);
+ log(`Initialized feature config DataStore with version ${configStore.formatVersion}`);
+ if (isNaN(oldFmtVer))
+ info(" !- Config data was initialized with default values");
+ else if (oldFmtVer !== configStore.formatVersion) {
try {
- getUnsafeWindow().dispatchEvent(new CustomEvent(type, { detail: (_a = detail === null || detail === void 0 ? void 0 : detail[0]) !== null && _a !== void 0 ? _a : undefined }));
- //@ts-ignore
- emitOnPlugins(type, undefined, ...detail);
- log(`Emitted interface event '${type}'${detail.length > 0 && (detail === null || detail === void 0 ? void 0 : detail[0]) ? " with data:" : ""}`, ...detail);
+ await configStore.setData(data = fixCfgKeys(data));
+ info(` !- Config data was migrated from version ${oldFmtVer} to ${configStore.formatVersion}`);
}
catch (err) {
- error(`Couldn't emit interface event '${type}' due to an error:\n`, err);
+ error(" !- Config data migration failed, falling back to default data:", err);
+ await configStore.setData(data = configStore.defaultData);
}
}
- //#region register plugins
- /** Map of plugin ID and all registered plugins */
- const registeredPlugins = new Map();
- /** Map of plugin ID to auth token for plugins that have been registered */
- const registeredPluginTokens = new Map();
- /** Initializes plugins that have been registered already. Needs to be run after `bytm:ready`! */
- function initPlugins() {
- // TODO: check perms and ask user for initial activation
- const registerPlugin = (def) => {
- var _a, _b;
- try {
- const plKey = getPluginKey(def);
- if (registeredPlugins.has(plKey))
- throw new PluginError(`Failed to register plugin '${plKey}': Plugin with the same name and namespace is already registered`);
- const validationErrors = validatePluginDef(def);
- if (validationErrors)
- throw new PluginError(`Failed to register plugin${((_a = def === null || def === void 0 ? void 0 : def.plugin) === null || _a === void 0 ? void 0 : _a.name) ? ` '${(_b = def === null || def === void 0 ? void 0 : def.plugin) === null || _b === void 0 ? void 0 : _b.name}'` : ""} with invalid definition:\n- ${validationErrors.join("\n- ")}`);
- const events = new NanoEmitter({ publicEmit: true });
- const token = randomId(32, 36, true);
- registeredPlugins.set(plKey, {
- def: def,
- events,
- });
- registeredPluginTokens.set(plKey, token);
- info(`Successfully registered plugin '${plKey}'`);
- setTimeout(() => emitOnPlugins("pluginRegistered", (d) => sameDef(d, def), pluginDefToInfo(def)), 1);
- return {
- info: getPluginInfo(token, def),
- events,
- token,
- };
- }
- catch (err) {
- error(`Failed to register plugin '${getPluginKey(def)}':`, err instanceof PluginError ? err : new PluginError(String(err)));
- throw err;
- }
- };
- emitInterface("bytm:registerPlugin", (def) => registerPlugin(def));
- if (registeredPlugins.size > 0)
- log(`Registered ${registeredPlugins.size} ${autoPlural("plugin", registeredPlugins.size)}`);
- }
- /** Returns the registered plugins as an array of tuples with the items `[id: string, item: PluginItem]` */
- function getRegisteredPlugins() {
- return [...registeredPlugins.entries()];
- }
- /** Returns the key for a given plugin definition */
- function getPluginKey(plugin) {
- return `${plugin.plugin.namespace}/${plugin.plugin.name}`;
- }
- /** Converts a PluginDef object (full definition) into a PluginInfo object (restricted definition) or undefined, if undefined is passed */
- function pluginDefToInfo(plugin) {
- return plugin
- ? {
- name: plugin.plugin.name,
- namespace: plugin.plugin.namespace,
- version: plugin.plugin.version,
- }
- : undefined;
- }
- /** Checks whether two plugins are the same, given their resolvable definition objects */
- function sameDef(def1, def2) {
- return getPluginKey(def1) === getPluginKey(def2);
- }
- /** Emits an event on all plugins that match the predicate (all plugins by default) */
- function emitOnPlugins(event, predicate = true, ...data) {
- for (const { def, events } of registeredPlugins.values())
- if (typeof predicate === "boolean" ? predicate : predicate(def))
- events.emit(event, ...data);
- }
- /**
- * Returns info about a registered plugin on the BYTM interface, or undefined if the plugin isn't registered.
- * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration.
- * @public Intended for general use in plugins.
- */
- function getPluginInfo(...args) {
- var _a;
- if (resolveToken(args[0]) === undefined)
- return undefined;
- return pluginDefToInfo((_a = registeredPlugins.get(typeof args[1] === "string" && typeof args[2] === "undefined"
- ? args[1]
- : args.length === 2
- ? `${args[2]}/${args[1]}`
- : getPluginKey(args[1]))) === null || _a === void 0 ? void 0 : _a.def);
- }
- /** Validates the passed PluginDef object and returns an array of errors - returns undefined if there were no errors - never returns an empty array */
- function validatePluginDef(pluginDef) {
- const errors = [];
- const addNoPropErr = (jsonPath, type) => errors.push(t("plugin_validation_error_no_property", jsonPath, type));
- const addInvalidPropErr = (jsonPath, value, examples) => errors.push(tp("plugin_validation_error_invalid_property", examples, jsonPath, value, `'${examples.join("', '")}'`));
- // def.plugin and its properties:
- typeof pluginDef.plugin !== "object" && addNoPropErr("plugin", "object");
- const { plugin } = pluginDef;
- !(plugin === null || plugin === void 0 ? void 0 : plugin.name) && addNoPropErr("plugin.name", "string");
- !(plugin === null || plugin === void 0 ? void 0 : plugin.namespace) && addNoPropErr("plugin.namespace", "string");
- if (typeof (plugin === null || plugin === void 0 ? void 0 : plugin.version) !== "string")
- addNoPropErr("plugin.version", "MAJOR.MINOR.PATCH");
- else if (!compareVersions__namespace.validateStrict(plugin.version))
- addInvalidPropErr("plugin.version", plugin.version, ["0.0.1", "2.5.21-rc.1"]);
- return errors.length > 0 ? errors : undefined;
- }
- /** Checks whether the passed token is a valid auth token for any registered plugin and returns the plugin ID, else returns undefined */
- function resolveToken(token) {
- var _a, _b;
- return typeof token === "string" && token.length > 0
- ? (_b = (_a = [...registeredPluginTokens.entries()]
- .find(([k, t]) => registeredPlugins.has(k) && token === t)) === null || _a === void 0 ? void 0 : _a[0]) !== null && _b !== void 0 ? _b : undefined
- : undefined;
- }
- //#region proxy funcs
- /**
- * Sets the new locale on the BYTM interface
- * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration.
- */
- function setLocaleInterface(token, locale) {
- const pluginId = resolveToken(token);
- if (pluginId === undefined)
- return;
- setLocale(locale);
- emitInterface("bytm:setLocale", { pluginId, locale });
- }
- /**
- * Returns the current feature config, with sensitive values replaced by `undefined`
- * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration.
- */
- function getFeaturesInterface(token) {
- if (resolveToken(token) === undefined)
- return undefined;
- const features = getFeatures();
- for (const ftKey of Object.keys(features)) {
- const info = featInfo[ftKey];
- if (info && info.valueHidden) // @ts-ignore
- features[ftKey] = undefined;
+ emitInterface("bytm:configReady");
+ return Object.assign({}, data);
+}
+/**
+ * Fixes missing keys in the passed config object with their default values or removes extraneous keys and returns a copy of the fixed object.
+ * Returns a copy of the originally passed object if nothing needs to be fixed.
+ */
+function fixCfgKeys(cfg) {
+ const newCfg = Object.assign({}, cfg);
+ const passedKeys = Object.keys(cfg);
+ const defaultKeys = Object.keys(defaultData);
+ const missingKeys = defaultKeys.filter(k => !passedKeys.includes(k));
+ if (missingKeys.length > 0) {
+ for (const key of missingKeys)
+ newCfg[key] = defaultData[key];
+ }
+ const extraKeys = passedKeys.filter(k => !defaultKeys.includes(k));
+ if (extraKeys.length > 0) {
+ for (const key of extraKeys)
+ delete newCfg[key];
+ }
+ return newCfg;
+}
+/** Returns the current feature config from the in-memory cache as a copy */
+function getFeatures() {
+ return configStore.getData();
+}
+/** Returns the value of the feature with the given key from the in-memory cache, as a copy */
+function getFeature(key) {
+ return configStore.getData()[key];
+}
+/** Saves the feature config synchronously to the in-memory cache and asynchronously to the persistent storage */
+function setFeatures(featureConf) {
+ const res = configStore.setData(featureConf);
+ emitSiteEvent("configChanged", configStore.getData());
+ info("Saved new feature config:", featureConf);
+ return res;
+}
+/** Saves the default feature config synchronously to the in-memory cache and asynchronously to persistent storage */
+function setDefaultFeatures() {
+ const res = configStore.saveDefaultData();
+ emitSiteEvent("configChanged", configStore.getData());
+ info("Reset feature config to its default values");
+ return res;
+}
+async function promptResetConfig() {
+ if (await showPrompt({ type: "confirm", message: t("reset_config_confirm") })) {
+ closeCfgMenu();
+ disableBeforeUnload();
+ await setDefaultFeatures();
+ if (location.pathname.startsWith("/watch")) {
+ const videoTime = await getVideoTime(0);
+ const url = new URL(location.href);
+ url.searchParams.delete("t");
+ if (videoTime)
+ url.searchParams.set("time_continue", String(videoTime));
+ location.replace(url.href);
}
- return features;
- }
- /**
- * Saves the passed feature config synchronously to the in-memory cache and asynchronously to the persistent storage.
- * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration.
- */
- function saveFeaturesInterface(token, features) {
- if (resolveToken(token) === undefined)
- return;
- setFeatures(features);
- }
- /**
- * Returns the auto-like data.
- * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration.
- */
- function getAutoLikeDataInterface(token) {
- if (resolveToken(token) === undefined)
- return;
- return autoLikeStore.getData();
- }
- /**
- * Saves new auto-like data, synchronously to the in-memory cache and asynchronously to the persistent storage.
- * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration.
- */
- function saveAutoLikeDataInterface(token, data) {
- if (resolveToken(token) === undefined)
- return;
- return autoLikeStore.setData(data);
+ else
+ location.reload();
}
-
- //#region globals
- /** Options that are applied to every SelectorObserver instance */
- const defaultObserverOptions = {
- disableOnNoListeners: false,
- enableOnAddListener: false,
- defaultDebounce: 150,
- defaultDebounceEdge: "rising",
- };
- /** Global SelectorObserver instances usable throughout the script for improved performance */
- const globservers = {};
- /** Whether all observers have been initialized */
- let globserversReady = false;
- //#region add listener func
- /**
- * Interface function for adding listeners to the {@linkcode globservers}
- * If the observers haven't been initialized yet, the function will queue calls until the `bytm:observersReady` event is emitted
- * @param selector Relative to the observer's root element, so the selector can only start at of the root element's children at the earliest!
- * @param options Options for the listener
- * @template TElem The type of the element that the listener will be attached to. If set to `0`, the default type `HTMLElement` will be used.
- * @template TDomain This restricts which observers are available with the current domain
- */
- function addSelectorListener(observerName, selector, options) {
- try {
- if (!globserversReady) {
- window.addEventListener("bytm:observersReady", () => addSelectorListener(observerName, selector, options), { once: true });
- return;
- }
- globservers[observerName].addListener(selector, options);
- }
- catch (err) {
- error(`Couldn't add listener to globserver '${observerName}':`, err);
- }
+}
+/** 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 */
+async function clearConfig() {
+ await configStore.deleteData();
+ info("Deleted config from persistent storage");
+}const { autoPlural, getUnsafeWindow, randomId, NanoEmitter } = UserUtils__namespace;
+/**
+ * All functions that can be called on the BYTM interface using `unsafeWindow.BYTM.functionName();` (or `const { functionName } = unsafeWindow.BYTM;`)
+ * If prefixed with /\*🔒\*\/, the function is authenticated and requires a token to be passed as the first argument.
+ */
+const globalFuncs = {
+ // meta:
+ /*🔒*/ getPluginInfo,
+ // bytm-specific:
+ getDomain,
+ getResourceUrl,
+ getSessionId,
+ // dom:
+ setInnerHtml,
+ addSelectorListener,
+ onInteraction,
+ getVideoTime,
+ getThumbnailUrl,
+ getBestThumbnailUrl,
+ waitVideoElementReady,
+ getCurrentMediaType,
+ // translations:
+ /*🔒*/ setLocale: setLocaleInterface,
+ getLocale,
+ hasKey,
+ hasKeyFor,
+ t,
+ tp,
+ tl,
+ tlp,
+ // feature config:
+ /*🔒*/ getFeatures: getFeaturesInterface,
+ /*🔒*/ saveFeatures: saveFeaturesInterface,
+ // lyrics:
+ fetchLyricsUrlTop,
+ getLyricsCacheEntry,
+ sanitizeArtists,
+ sanitizeSong,
+ // auto-like:
+ /*🔒*/ getAutoLikeData: getAutoLikeDataInterface,
+ /*🔒*/ saveAutoLikeData: saveAutoLikeDataInterface,
+ fetchVideoVotes,
+ // components:
+ createHotkeyInput,
+ createToggleInput,
+ createCircularBtn,
+ createRipple,
+ showToast,
+ showIconToast,
+ showPrompt,
+ // other:
+ formatNumber,
+};
+/** Initializes the BYTM interface */
+function initInterface() {
+ const props = Object.assign(Object.assign(Object.assign({
+ // meta / constants
+ mode,
+ branch,
+ host,
+ buildNumber,
+ compressionFormat }, scriptInfo), globalFuncs), {
+ // classes
+ NanoEmitter,
+ BytmDialog,
+ ExImDialog,
+ MarkdownDialog,
+ // libraries
+ UserUtils: UserUtils__namespace,
+ compareVersions: compareVersions__namespace });
+ for (const [key, value] of Object.entries(props))
+ setGlobalProp(key, value);
+ log("Initialized BYTM interface");
+}
+/** Sets a global property on the unsafeWindow.BYTM object - ⚠️ use with caution as these props can be accessed by any script on the page! */
+function setGlobalProp(key, value) {
+ // use unsafeWindow so the properties are available to plugins outside of the userscript's scope
+ const win = getUnsafeWindow();
+ if (typeof win.BYTM !== "object")
+ win.BYTM = {};
+ win.BYTM[key] = value;
+}
+/** Emits an event on the BYTM interface */
+function emitInterface(type, ...detail) {
+ var _a;
+ try {
+ getUnsafeWindow().dispatchEvent(new CustomEvent(type, { detail: (_a = detail === null || detail === void 0 ? void 0 : detail[0]) !== null && _a !== void 0 ? _a : undefined }));
+ //@ts-ignore
+ emitOnPlugins(type, undefined, ...detail);
+ log(`Emitted interface event '${type}'${detail.length > 0 && (detail === null || detail === void 0 ? void 0 : detail[0]) ? " with data:" : ""}`, ...detail);
+ }
+ catch (err) {
+ error(`Couldn't emit interface event '${type}' due to an error:\n`, err);
}
- //#region init
- /** Call after DOM load to initialize all SelectorObserver instances */
- function initObservers() {
+}
+//#region register plugins
+/** Map of plugin ID and all registered plugins */
+const registeredPlugins = new Map();
+/** Map of plugin ID to auth token for plugins that have been registered */
+const registeredPluginTokens = new Map();
+/** Initializes plugins that have been registered already. Needs to be run after `bytm:ready`! */
+function initPlugins() {
+ // TODO: check perms and ask user for initial activation
+ const registerPlugin = (def) => {
+ var _a, _b;
try {
- //#region both sites
- //#region body
- // -> the entire element - use sparingly due to performance impacts!
- // enabled immediately
- globservers.body = new UserUtils.SelectorObserver(document.body, Object.assign(Object.assign({}, defaultObserverOptions), { defaultDebounceEdge: "falling", defaultDebounce: 150, subtree: false }));
- globservers.body.enable();
- //#region bytmDialogContainer
- // -> the container for all BytmDialog instances
- // enabled immediately
- const bytmDialogContainerSelector = "#bytm-dialog-container";
- globservers.bytmDialogContainer = new UserUtils.SelectorObserver(bytmDialogContainerSelector, Object.assign(Object.assign({}, defaultObserverOptions), { defaultDebounceEdge: "falling", defaultDebounce: 100, subtree: true }));
- globservers.bytmDialogContainer.enable();
- switch (getDomain()) {
- case "ytm": {
- //#region YTM
- //#region browseResponse
- // -> for example the /channel/UC... page#
- // enabled by "body"
- const browseResponseSelector = "ytmusic-browse-response";
- globservers.browseResponse = new UserUtils.SelectorObserver(browseResponseSelector, Object.assign(Object.assign({}, defaultObserverOptions), { defaultDebounce: 75, subtree: true }));
- globservers.body.addListener(browseResponseSelector, {
- listener: () => globservers.browseResponse.enable(),
- });
- //#region navBar
- // -> the navigation / title bar at the top of the page
- // enabled by "body"
- const navBarSelector = "ytmusic-nav-bar";
- globservers.navBar = new UserUtils.SelectorObserver(navBarSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: false }));
- globservers.body.addListener(navBarSelector, {
- listener: () => globservers.navBar.enable(),
- });
- //#region mainPanel
- // -> the main content panel - includes things like the video element
- // enabled by "body"
- const mainPanelSelector = "ytmusic-player-page #main-panel";
- globservers.mainPanel = new UserUtils.SelectorObserver(mainPanelSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
- globservers.body.addListener(mainPanelSelector, {
- listener: () => globservers.mainPanel.enable(),
- });
- //#region sideBar
- // -> the sidebar on the left side of the page
- // enabled by "body"
- const sidebarSelector = "ytmusic-app-layout tp-yt-app-drawer";
- globservers.sideBar = new UserUtils.SelectorObserver(sidebarSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
- globservers.body.addListener(sidebarSelector, {
- listener: () => globservers.sideBar.enable(),
- });
- //#region sideBarMini
- // -> the minimized sidebar on the left side of the page
- // enabled by "body"
- const sideBarMiniSelector = "ytmusic-app-layout #mini-guide";
- globservers.sideBarMini = new UserUtils.SelectorObserver(sideBarMiniSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
- globservers.body.addListener(sideBarMiniSelector, {
- listener: () => globservers.sideBarMini.enable(),
- });
- //#region sidePanel
- // -> the side panel on the right side of the /watch page
- // enabled by "body"
- const sidePanelSelector = "#side-panel";
- globservers.sidePanel = new UserUtils.SelectorObserver(sidePanelSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
- globservers.body.addListener(sidePanelSelector, {
- listener: () => globservers.sidePanel.enable(),
- });
- //#region playerBar
- // -> media controls bar at the bottom of the page
- // enabled by "body"
- const playerBarSelector = "ytmusic-app-layout ytmusic-player-bar.ytmusic-app";
- globservers.playerBar = new UserUtils.SelectorObserver(playerBarSelector, Object.assign(Object.assign({}, defaultObserverOptions), { defaultDebounce: 200 }));
- globservers.body.addListener(playerBarSelector, {
- listener: () => {
- globservers.playerBar.enable();
- },
- });
- //#region playerBarInfo
- // -> song title, artist, album, etc. inside the player bar
- // enabled by "playerBar"
- const playerBarInfoSelector = `${playerBarSelector} .middle-controls .content-info-wrapper`;
- globservers.playerBarInfo = new UserUtils.SelectorObserver(playerBarInfoSelector, Object.assign(Object.assign({}, defaultObserverOptions), { attributes: true, attributeFilter: ["title"] }));
- globservers.playerBar.addListener(playerBarInfoSelector, {
- listener: () => globservers.playerBarInfo.enable(),
- });
- //#region playerBarMiddleButtons
- // -> the buttons inside the player bar (like, dislike, lyrics, etc.)
- // enabled by "playerBar"
- const playerBarMiddleButtonsSelector = ".middle-controls .middle-controls-buttons";
- globservers.playerBarMiddleButtons = new UserUtils.SelectorObserver(playerBarMiddleButtonsSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
- globservers.playerBar.addListener(playerBarMiddleButtonsSelector, {
- listener: () => globservers.playerBarMiddleButtons.enable(),
- });
- //#region playerBarRightControls
- // -> the controls on the right side of the player bar (volume, repeat, shuffle, etc.)
- // enabled by "playerBar"
- const playerBarRightControls = "#right-controls";
- globservers.playerBarRightControls = new UserUtils.SelectorObserver(playerBarRightControls, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
- globservers.playerBar.addListener(playerBarRightControls, {
- listener: () => globservers.playerBarRightControls.enable(),
- });
- //#region popupContainer
- // -> the container for popups (e.g. the queue popup)
- // enabled by "body"
- const popupContainerSelector = "ytmusic-app ytmusic-popup-container";
- globservers.popupContainer = new UserUtils.SelectorObserver(popupContainerSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
- globservers.body.addListener(popupContainerSelector, {
- listener: () => globservers.popupContainer.enable(),
- });
- break;
- }
- case "yt": {
- //#region YT
- //#region ytGuide
- // -> the left sidebar menu
- // enabled by "body"
- const ytGuideSelector = "#content tp-yt-app-drawer#guide #guide-inner-content";
- globservers.ytGuide = new UserUtils.SelectorObserver(ytGuideSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
- globservers.body.addListener(ytGuideSelector, {
- listener: () => globservers.ytGuide.enable(),
- });
- //#region ytdBrowse
- // -> channel pages for example
- // enabled by "body"
- const ytdBrowseSelector = "ytd-app ytd-page-manager ytd-browse";
- globservers.ytdBrowse = new UserUtils.SelectorObserver(ytdBrowseSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
- globservers.body.addListener(ytdBrowseSelector, {
- listener: () => globservers.ytdBrowse.enable(),
- });
- //#region ytAppHeader
- // -> header of the page
- // enabled by "ytdBrowse"
- const ytAppHeaderSelector = "#header tp-yt-app-header";
- globservers.ytAppHeader = new UserUtils.SelectorObserver(ytAppHeaderSelector, Object.assign(Object.assign({}, defaultObserverOptions), { defaultDebounce: 75, subtree: true }));
- globservers.ytdBrowse.addListener(ytAppHeaderSelector, {
- listener: () => globservers.ytAppHeader.enable(),
- });
- //#region ytWatchFlexy
- // -> the main content of the /watch page
- // enabled by "body"
- const ytWatchFlexySelector = "ytd-app ytd-watch-flexy";
- globservers.ytWatchFlexy = new UserUtils.SelectorObserver(ytWatchFlexySelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
- globservers.body.addListener(ytWatchFlexySelector, {
- listener: () => globservers.ytWatchFlexy.enable(),
- });
- //#region ytWatchMetadata
- // -> the metadata section of the /watch page (title, channel, views, description, buttons, etc. but not comments)
- // enabled by "ytWatchFlexy"
- const ytWatchMetadataSelector = "#columns #primary-inner ytd-watch-metadata";
- globservers.ytWatchMetadata = new UserUtils.SelectorObserver(ytWatchMetadataSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
- globservers.ytWatchFlexy.addListener(ytWatchMetadataSelector, {
- listener: () => globservers.ytWatchMetadata.enable(),
- });
- //#region ytMasthead
- // -> the masthead (title bar) at the top of the page
- // enabled by "body"
- const mastheadSelector = "#content ytd-masthead#masthead";
- globservers.ytMasthead = new UserUtils.SelectorObserver(mastheadSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
- globservers.body.addListener(mastheadSelector, {
- listener: () => globservers.ytMasthead.enable(),
- });
- }
- }
- //#region finalize
- globserversReady = true;
- emitInterface("bytm:observersReady");
+ const plKey = getPluginKey(def);
+ if (registeredPlugins.has(plKey))
+ throw new PluginError(`Failed to register plugin '${plKey}': Plugin with the same name and namespace is already registered`);
+ const validationErrors = validatePluginDef(def);
+ if (validationErrors)
+ throw new PluginError(`Failed to register plugin${((_a = def === null || def === void 0 ? void 0 : def.plugin) === null || _a === void 0 ? void 0 : _a.name) ? ` '${(_b = def === null || def === void 0 ? void 0 : def.plugin) === null || _b === void 0 ? void 0 : _b.name}'` : ""} with invalid definition:\n- ${validationErrors.join("\n- ")}`);
+ const events = new NanoEmitter({ publicEmit: true });
+ const token = randomId(32, 36, true);
+ registeredPlugins.set(plKey, {
+ def: def,
+ events,
+ });
+ registeredPluginTokens.set(plKey, token);
+ info(`Successfully registered plugin '${plKey}'`);
+ setTimeout(() => emitOnPlugins("pluginRegistered", (d) => sameDef(d, def), pluginDefToInfo(def)), 1);
+ return {
+ info: getPluginInfo(token, def),
+ events,
+ token,
+ };
}
catch (err) {
- error("Failed to initialize observers:", err);
+ error(`Failed to register plugin '${getPluginKey(def)}':`, err instanceof PluginError ? err : new PluginError(String(err)));
+ throw err;
}
+ };
+ emitInterface("bytm:registerPlugin", (def) => registerPlugin(def));
+ if (registeredPlugins.size > 0)
+ log(`Registered ${registeredPlugins.size} ${autoPlural("plugin", registeredPlugins.size)}`);
+}
+/** Returns the registered plugins as an array of tuples with the items `[id: string, item: PluginItem]` */
+function getRegisteredPlugins() {
+ return [...registeredPlugins.entries()];
+}
+/** Returns the key for a given plugin definition */
+function getPluginKey(plugin) {
+ return `${plugin.plugin.namespace}/${plugin.plugin.name}`;
+}
+/** Converts a PluginDef object (full definition) into a PluginInfo object (restricted definition) or undefined, if undefined is passed */
+function pluginDefToInfo(plugin) {
+ return plugin
+ ? {
+ name: plugin.plugin.name,
+ namespace: plugin.plugin.namespace,
+ version: plugin.plugin.version,
+ }
+ : undefined;
+}
+/** Checks whether two plugins are the same, given their resolvable definition objects */
+function sameDef(def1, def2) {
+ return getPluginKey(def1) === getPluginKey(def2);
+}
+/** Emits an event on all plugins that match the predicate (all plugins by default) */
+function emitOnPlugins(event, predicate = true, ...data) {
+ for (const { def, events } of registeredPlugins.values())
+ if (typeof predicate === "boolean" ? predicate : predicate(def))
+ events.emit(event, ...data);
+}
+/**
+ * Returns info about a registered plugin on the BYTM interface, or undefined if the plugin isn't registered.
+ * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration.
+ * @public Intended for general use in plugins.
+ */
+function getPluginInfo(...args) {
+ var _a;
+ if (resolveToken(args[0]) === undefined)
+ return undefined;
+ return pluginDefToInfo((_a = registeredPlugins.get(typeof args[1] === "string" && typeof args[2] === "undefined"
+ ? args[1]
+ : args.length === 2
+ ? `${args[2]}/${args[1]}`
+ : getPluginKey(args[1]))) === null || _a === void 0 ? void 0 : _a.def);
+}
+/** Validates the passed PluginDef object and returns an array of errors - returns undefined if there were no errors - never returns an empty array */
+function validatePluginDef(pluginDef) {
+ const errors = [];
+ const addNoPropErr = (jsonPath, type) => errors.push(t("plugin_validation_error_no_property", jsonPath, type));
+ const addInvalidPropErr = (jsonPath, value, examples) => errors.push(tp("plugin_validation_error_invalid_property", examples, jsonPath, value, `'${examples.join("', '")}'`));
+ // def.plugin and its properties:
+ typeof pluginDef.plugin !== "object" && addNoPropErr("plugin", "object");
+ const { plugin } = pluginDef;
+ !(plugin === null || plugin === void 0 ? void 0 : plugin.name) && addNoPropErr("plugin.name", "string");
+ !(plugin === null || plugin === void 0 ? void 0 : plugin.namespace) && addNoPropErr("plugin.namespace", "string");
+ if (typeof (plugin === null || plugin === void 0 ? void 0 : plugin.version) !== "string")
+ addNoPropErr("plugin.version", "MAJOR.MINOR.PATCH");
+ else if (!compareVersions__namespace.validateStrict(plugin.version))
+ addInvalidPropErr("plugin.version", plugin.version, ["0.0.1", "2.5.21-rc.1"]);
+ return errors.length > 0 ? errors : undefined;
+}
+/** Checks whether the passed token is a valid auth token for any registered plugin and returns the plugin ID, else returns undefined */
+function resolveToken(token) {
+ var _a, _b;
+ return typeof token === "string" && token.length > 0
+ ? (_b = (_a = [...registeredPluginTokens.entries()]
+ .find(([k, t]) => registeredPlugins.has(k) && token === t)) === null || _a === void 0 ? void 0 : _a[0]) !== null && _b !== void 0 ? _b : undefined
+ : undefined;
+}
+//#region proxy funcs
+/**
+ * Sets the new locale on the BYTM interface
+ * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration.
+ */
+function setLocaleInterface(token, locale) {
+ const pluginId = resolveToken(token);
+ if (pluginId === undefined)
+ return;
+ setLocale(locale);
+ emitInterface("bytm:setLocale", { pluginId, locale });
+}
+/**
+ * Returns the current feature config, with sensitive values replaced by `undefined`
+ * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration.
+ */
+function getFeaturesInterface(token) {
+ if (resolveToken(token) === undefined)
+ return undefined;
+ const features = getFeatures();
+ for (const ftKey of Object.keys(features)) {
+ const info = featInfo[ftKey];
+ if (info && info.valueHidden) // @ts-ignore
+ features[ftKey] = undefined;
+ }
+ return features;
+}
+/**
+ * Saves the passed feature config synchronously to the in-memory cache and asynchronously to the persistent storage.
+ * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration.
+ */
+function saveFeaturesInterface(token, features) {
+ if (resolveToken(token) === undefined)
+ return;
+ setFeatures(features);
+}
+/**
+ * Returns the auto-like data.
+ * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration.
+ */
+function getAutoLikeDataInterface(token) {
+ if (resolveToken(token) === undefined)
+ return;
+ return autoLikeStore.getData();
+}
+/**
+ * Saves new auto-like data, synchronously to the in-memory cache and asynchronously to the persistent storage.
+ * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration.
+ */
+function saveAutoLikeDataInterface(token, data) {
+ if (resolveToken(token) === undefined)
+ return;
+ return autoLikeStore.setData(data);
+}//#region globals
+/** Options that are applied to every SelectorObserver instance */
+const defaultObserverOptions = {
+ disableOnNoListeners: false,
+ enableOnAddListener: false,
+ defaultDebounce: 150,
+ defaultDebounceEdge: "rising",
+};
+/** Global SelectorObserver instances usable throughout the script for improved performance */
+const globservers = {};
+/** Whether all observers have been initialized */
+let globserversReady = false;
+//#region add listener func
+/**
+ * Interface function for adding listeners to the {@linkcode globservers}
+ * If the observers haven't been initialized yet, the function will queue calls until the `bytm:observersReady` event is emitted
+ * @param selector Relative to the observer's root element, so the selector can only start at of the root element's children at the earliest!
+ * @param options Options for the listener
+ * @template TElem The type of the element that the listener will be attached to. If set to `0`, the default type `HTMLElement` will be used.
+ * @template TDomain This restricts which observers are available with the current domain
+ */
+function addSelectorListener(observerName, selector, options) {
+ try {
+ if (!globserversReady) {
+ window.addEventListener("bytm:observersReady", () => addSelectorListener(observerName, selector, options), { once: true });
+ return;
+ }
+ globservers[observerName].addListener(selector, options);
}
-
- /** Whether the DOM has finished loading and elements can be added or modified */
- let domLoaded = false;
- document.addEventListener("DOMContentLoaded", () => domLoaded = true);
- //#region vid time & vol.
- /** Returns the video element selector string based on the current domain */
- const getVideoSelector = () => getDomain() === "ytm" ? "ytmusic-player video" : "#player-container ytd-player video";
- /** Returns the video element based on the current domain */
- function getVideoElement() {
- return document.querySelector(getVideoSelector());
- }
- let vidElemReady = false;
- /**
- * Returns the current video time in seconds, with the given {@linkcode precision} (2 decimal digits by default).
- * Rounds down if the precision is set to 0. The maximum average available precision on YTM is 6.
- * Dispatches mouse movement events in case the video time can't be read from the video or progress bar elements (needs a prior user interaction to work)
- * @returns Returns null if the video time is unavailable or no user interaction has happened prior to calling in case of the fallback behavior being used
- */
- function getVideoTime(precision = 2) {
- return new Promise(async (res) => {
- if (!vidElemReady) {
- await waitVideoElementReady();
- vidElemReady = true;
- }
- const resolveWithVal = (time) => res(time && !isNaN(time)
- ? Number(precision <= 0 ? Math.floor(time) : time.toFixed(precision))
- : null);
- try {
- if (getDomain() === "ytm") {
- const vidElem = getVideoElement();
- if (vidElem)
- return resolveWithVal(vidElem.currentTime);
- addSelectorListener("playerBar", "tp-yt-paper-slider#progress-bar tp-yt-paper-progress#sliderBar", {
- listener: (pbEl) => resolveWithVal(!isNaN(Number(pbEl.value)) ? Math.floor(Number(pbEl.value)) : null)
- });
- }
- else if (getDomain() === "yt") {
- const vidElem = getVideoElement();
- if (vidElem)
- return resolveWithVal(vidElem.currentTime);
- // 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)) {
- resolveWithVal(Math.floor(videoTime));
- mut.disconnect();
- }
- else
- setTimeout(() => {
- resolveWithVal(videoTime >= 0 && !isNaN(videoTime) ? Math.floor(videoTime) : null);
- mut.disconnect();
- }, 500);
- };
- addSelectorListener("body", pbSelector, { listener: observe });
- }
- }
- catch (err) {
- error("Couldn't get video time due to error:", err);
- res(null);
- }
- });
- }
- /**
- * Sends events that force the video controls to become visible for about 3 seconds.
- * This only works once (for some reason), 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: UserUtils.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;
+ catch (err) {
+ error(`Couldn't add listener to globserver '${observerName}':`, err);
}
- /**
- * Waits for the video element to be in its readyState 4 / canplay state and returns it.
- * Could take a very long time to resolve if the `/watch` page isn't open.
- * Resolves immediately if the video element is already ready.
- */
- function waitVideoElementReady() {
- return new Promise(async (res, rej) => {
- try {
- const vidEl = getVideoElement();
- if ((vidEl === null || vidEl === void 0 ? void 0 : vidEl.readyState) === 4)
- return res(vidEl);
- if (!location.pathname.startsWith("/watch"))
- await siteEvents.once("watchIdChanged");
- addSelectorListener("body", getVideoSelector(), {
- listener(vidElem) {
- // this is just after YT has finished doing their own shenanigans with the video time and volume
- if (vidElem.readyState === 4)
- res(vidElem);
- else
- vidElem.addEventListener("canplay", () => res(vidElem), { once: true });
+}
+//#region init
+/** Call after DOM load to initialize all SelectorObserver instances */
+function initObservers() {
+ try {
+ //#region both sites
+ //#region body
+ // -> the entire element - use sparingly due to performance impacts!
+ // enabled immediately
+ globservers.body = new UserUtils.SelectorObserver(document.body, Object.assign(Object.assign({}, defaultObserverOptions), { defaultDebounceEdge: "falling", defaultDebounce: 150, subtree: false }));
+ globservers.body.enable();
+ //#region bytmDialogContainer
+ // -> the container for all BytmDialog instances
+ // enabled immediately
+ const bytmDialogContainerSelector = "#bytm-dialog-container";
+ globservers.bytmDialogContainer = new UserUtils.SelectorObserver(bytmDialogContainerSelector, Object.assign(Object.assign({}, defaultObserverOptions), { defaultDebounceEdge: "falling", defaultDebounce: 100, subtree: true }));
+ globservers.bytmDialogContainer.enable();
+ switch (getDomain()) {
+ case "ytm": {
+ //#region YTM
+ //#region browseResponse
+ // -> for example the /channel/UC... page#
+ // enabled by "body"
+ const browseResponseSelector = "ytmusic-browse-response";
+ globservers.browseResponse = new UserUtils.SelectorObserver(browseResponseSelector, Object.assign(Object.assign({}, defaultObserverOptions), { defaultDebounce: 75, subtree: true }));
+ globservers.body.addListener(browseResponseSelector, {
+ listener: () => globservers.browseResponse.enable(),
+ });
+ //#region navBar
+ // -> the navigation / title bar at the top of the page
+ // enabled by "body"
+ const navBarSelector = "ytmusic-nav-bar";
+ globservers.navBar = new UserUtils.SelectorObserver(navBarSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: false }));
+ globservers.body.addListener(navBarSelector, {
+ listener: () => globservers.navBar.enable(),
+ });
+ //#region mainPanel
+ // -> the main content panel - includes things like the video element
+ // enabled by "body"
+ const mainPanelSelector = "ytmusic-player-page #main-panel";
+ globservers.mainPanel = new UserUtils.SelectorObserver(mainPanelSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
+ globservers.body.addListener(mainPanelSelector, {
+ listener: () => globservers.mainPanel.enable(),
+ });
+ //#region sideBar
+ // -> the sidebar on the left side of the page
+ // enabled by "body"
+ const sidebarSelector = "ytmusic-app-layout tp-yt-app-drawer";
+ globservers.sideBar = new UserUtils.SelectorObserver(sidebarSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
+ globservers.body.addListener(sidebarSelector, {
+ listener: () => globservers.sideBar.enable(),
+ });
+ //#region sideBarMini
+ // -> the minimized sidebar on the left side of the page
+ // enabled by "body"
+ const sideBarMiniSelector = "ytmusic-app-layout #mini-guide";
+ globservers.sideBarMini = new UserUtils.SelectorObserver(sideBarMiniSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
+ globservers.body.addListener(sideBarMiniSelector, {
+ listener: () => globservers.sideBarMini.enable(),
+ });
+ //#region sidePanel
+ // -> the side panel on the right side of the /watch page
+ // enabled by "body"
+ const sidePanelSelector = "#side-panel";
+ globservers.sidePanel = new UserUtils.SelectorObserver(sidePanelSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
+ globservers.body.addListener(sidePanelSelector, {
+ listener: () => globservers.sidePanel.enable(),
+ });
+ //#region playerBar
+ // -> media controls bar at the bottom of the page
+ // enabled by "body"
+ const playerBarSelector = "ytmusic-app-layout ytmusic-player-bar.ytmusic-app";
+ globservers.playerBar = new UserUtils.SelectorObserver(playerBarSelector, Object.assign(Object.assign({}, defaultObserverOptions), { defaultDebounce: 200 }));
+ globservers.body.addListener(playerBarSelector, {
+ listener: () => {
+ globservers.playerBar.enable();
},
});
+ //#region playerBarInfo
+ // -> song title, artist, album, etc. inside the player bar
+ // enabled by "playerBar"
+ const playerBarInfoSelector = `${playerBarSelector} .middle-controls .content-info-wrapper`;
+ globservers.playerBarInfo = new UserUtils.SelectorObserver(playerBarInfoSelector, Object.assign(Object.assign({}, defaultObserverOptions), { attributes: true, attributeFilter: ["title"] }));
+ globservers.playerBar.addListener(playerBarInfoSelector, {
+ listener: () => globservers.playerBarInfo.enable(),
+ });
+ //#region playerBarMiddleButtons
+ // -> the buttons inside the player bar (like, dislike, lyrics, etc.)
+ // enabled by "playerBar"
+ const playerBarMiddleButtonsSelector = ".middle-controls .middle-controls-buttons";
+ globservers.playerBarMiddleButtons = new UserUtils.SelectorObserver(playerBarMiddleButtonsSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
+ globservers.playerBar.addListener(playerBarMiddleButtonsSelector, {
+ listener: () => globservers.playerBarMiddleButtons.enable(),
+ });
+ //#region playerBarRightControls
+ // -> the controls on the right side of the player bar (volume, repeat, shuffle, etc.)
+ // enabled by "playerBar"
+ const playerBarRightControls = "#right-controls";
+ globservers.playerBarRightControls = new UserUtils.SelectorObserver(playerBarRightControls, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
+ globservers.playerBar.addListener(playerBarRightControls, {
+ listener: () => globservers.playerBarRightControls.enable(),
+ });
+ //#region popupContainer
+ // -> the container for popups (e.g. the queue popup)
+ // enabled by "body"
+ const popupContainerSelector = "ytmusic-app ytmusic-popup-container";
+ globservers.popupContainer = new UserUtils.SelectorObserver(popupContainerSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
+ globservers.body.addListener(popupContainerSelector, {
+ listener: () => globservers.popupContainer.enable(),
+ });
+ break;
}
- catch (err) {
- rej(err);
+ case "yt": {
+ //#region YT
+ //#region ytGuide
+ // -> the left sidebar menu
+ // enabled by "body"
+ const ytGuideSelector = "#content tp-yt-app-drawer#guide #guide-inner-content";
+ globservers.ytGuide = new UserUtils.SelectorObserver(ytGuideSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
+ globservers.body.addListener(ytGuideSelector, {
+ listener: () => globservers.ytGuide.enable(),
+ });
+ //#region ytdBrowse
+ // -> channel pages for example
+ // enabled by "body"
+ const ytdBrowseSelector = "ytd-app ytd-page-manager ytd-browse";
+ globservers.ytdBrowse = new UserUtils.SelectorObserver(ytdBrowseSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
+ globservers.body.addListener(ytdBrowseSelector, {
+ listener: () => globservers.ytdBrowse.enable(),
+ });
+ //#region ytAppHeader
+ // -> header of the page
+ // enabled by "ytdBrowse"
+ const ytAppHeaderSelector = "#header tp-yt-app-header";
+ globservers.ytAppHeader = new UserUtils.SelectorObserver(ytAppHeaderSelector, Object.assign(Object.assign({}, defaultObserverOptions), { defaultDebounce: 75, subtree: true }));
+ globservers.ytdBrowse.addListener(ytAppHeaderSelector, {
+ listener: () => globservers.ytAppHeader.enable(),
+ });
+ //#region ytWatchFlexy
+ // -> the main content of the /watch page
+ // enabled by "body"
+ const ytWatchFlexySelector = "ytd-app ytd-watch-flexy";
+ globservers.ytWatchFlexy = new UserUtils.SelectorObserver(ytWatchFlexySelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
+ globservers.body.addListener(ytWatchFlexySelector, {
+ listener: () => globservers.ytWatchFlexy.enable(),
+ });
+ //#region ytWatchMetadata
+ // -> the metadata section of the /watch page (title, channel, views, description, buttons, etc. but not comments)
+ // enabled by "ytWatchFlexy"
+ const ytWatchMetadataSelector = "#columns #primary-inner ytd-watch-metadata";
+ globservers.ytWatchMetadata = new UserUtils.SelectorObserver(ytWatchMetadataSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
+ globservers.ytWatchFlexy.addListener(ytWatchMetadataSelector, {
+ listener: () => globservers.ytWatchMetadata.enable(),
+ });
+ //#region ytMasthead
+ // -> the masthead (title bar) at the top of the page
+ // enabled by "body"
+ const mastheadSelector = "#content ytd-masthead#masthead";
+ globservers.ytMasthead = new UserUtils.SelectorObserver(mastheadSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true }));
+ globservers.body.addListener(mastheadSelector, {
+ listener: () => globservers.ytMasthead.enable(),
+ });
}
- });
- }
- //#region css utils
- /**
- * Adds a style element to the DOM at runtime.
- * @param css The CSS stylesheet to add
- * @param ref A reference string to identify the style element - defaults to a random 5-character string
- * @param transform A function to transform the CSS before adding it to the DOM
- */
- async function addStyle(css, ref, transform = (c) => c) {
- if (!domLoaded)
- throw new Error("DOM has not finished loading yet");
- const elem = UserUtils.addGlobalStyle(await transform(css));
- elem.id = `bytm-style-${ref !== null && ref !== void 0 ? ref : UserUtils.randomId(6, 36)}`;
- return elem;
- }
- /**
- * Adds a global style element with the contents fetched from the specified resource starting with `css-`
- * The CSS can be transformed using the provided function before being added to the DOM.
- */
- async function addStyleFromResource(key, transform = (c) => c) {
- const css = await fetchCss(key);
- if (css) {
- await addStyle(transform(css), key.slice(4));
- return true;
}
- return false;
- }
- /** Sets a global CSS variable on the <document> element with the name `--bytm-global-${name}` */
- function setGlobalCssVar(name, value) {
- document.documentElement.style.setProperty(`--bytm-global-${name.toLowerCase().trim()}`, String(value));
- }
- /** Sets multiple global CSS variables on the <document> element with the name `--bytm-global-${name}` */
- function setGlobalCssVars(vars) {
- for (const [name, value] of Object.entries(vars))
- setGlobalCssVar(name, value);
- }
- //#region other
- /** Removes all child nodes of an element without invoking the slow-ish HTML parser */
- function clearInner(element) {
- while (element.hasChildNodes())
- clearNode(element.firstChild);
- }
- /** Removes all child nodes of an element recursively and also removes the element itself */
- function clearNode(element) {
- while (element.hasChildNodes())
- clearNode(element.firstChild);
- element.parentNode.removeChild(element);
- }
- /**
- * Returns an identifier for the currently playing media type on YTM (song or video).
- * Only works on YTM and will throw on YT or if {@linkcode waitVideoElementReady} hasn't been awaited yet.
- */
- function getCurrentMediaType() {
- if (getDomain() === "yt")
- throw new Error("currentMediaType() is only available on YTM!");
- const songImgElem = document.querySelector("ytmusic-player #song-image");
- if (!songImgElem)
- throw new Error("Couldn't find the song image element. Use this function only after awaiting `waitVideoElementReady()`!");
- return window.getComputedStyle(songImgElem).display !== "none" ? "song" : "video";
- }
- /** Copies the provided text to the clipboard and shows an error message for manual copying if the grant `GM.setClipboard` is not given. */
- function copyToClipboard(text) {
+ //#region finalize
+ globserversReady = true;
+ emitInterface("bytm:observersReady");
+ }
+ catch (err) {
+ error("Failed to initialize observers:", err);
+ }
+}/** Whether the DOM has finished loading and elements can be added or modified */
+let domLoaded = false;
+document.addEventListener("DOMContentLoaded", () => domLoaded = true);
+//#region vid time & vol.
+/** Returns the video element selector string based on the current domain */
+const getVideoSelector = () => getDomain() === "ytm" ? "ytmusic-player video" : "#player-container ytd-player video";
+/** Returns the video element based on the current domain */
+function getVideoElement() {
+ return document.querySelector(getVideoSelector());
+}
+let vidElemReady = false;
+/**
+ * Returns the current video time in seconds, with the given {@linkcode precision} (2 decimal digits by default).
+ * Rounds down if the precision is set to 0. The maximum average available precision on YTM is 6.
+ * Dispatches mouse movement events in case the video time can't be read from the video or progress bar elements (needs a prior user interaction to work)
+ * @returns Returns null if the video time is unavailable or no user interaction has happened prior to calling in case of the fallback behavior being used
+ */
+function getVideoTime(precision = 2) {
+ return new Promise(async (res) => {
+ if (!vidElemReady) {
+ await waitVideoElementReady();
+ vidElemReady = true;
+ }
+ const resolveWithVal = (time) => res(time && !isNaN(time)
+ ? Number(precision <= 0 ? Math.floor(time) : time.toFixed(precision))
+ : null);
try {
- GM.setClipboard(String(text));
- }
- catch (_a) {
- showPrompt({ type: "alert", message: t("copy_to_clipboard_error", String(text)) });
- }
- }
- let ttPolicy;
- // workaround for supporting `target="_blank"` links without compromising security:
- const tempTargetAttrName = `data-tmp-target-${UserUtils.randomId(6, 36)}`;
- DOMPurify.addHook("beforeSanitizeAttributes", (node) => {
- if (node.tagName === "A") {
- if (!node.hasAttribute("target"))
- node.setAttribute("target", "_self");
- if (node.hasAttribute("target"))
- node.setAttribute(tempTargetAttrName, node.getAttribute("target"));
+ if (getDomain() === "ytm") {
+ const vidElem = getVideoElement();
+ if (vidElem)
+ return resolveWithVal(vidElem.currentTime);
+ addSelectorListener("playerBar", "tp-yt-paper-slider#progress-bar tp-yt-paper-progress#sliderBar", {
+ listener: (pbEl) => resolveWithVal(!isNaN(Number(pbEl.value)) ? Math.floor(Number(pbEl.value)) : null)
+ });
+ }
+ else if (getDomain() === "yt") {
+ const vidElem = getVideoElement();
+ if (vidElem)
+ return resolveWithVal(vidElem.currentTime);
+ // 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)) {
+ resolveWithVal(Math.floor(videoTime));
+ mut.disconnect();
+ }
+ else
+ setTimeout(() => {
+ resolveWithVal(videoTime >= 0 && !isNaN(videoTime) ? Math.floor(videoTime) : null);
+ mut.disconnect();
+ }, 500);
+ };
+ addSelectorListener("body", pbSelector, { listener: observe });
+ }
}
- });
- DOMPurify.addHook("afterSanitizeAttributes", (node) => {
- if (node.tagName === "A" && node.hasAttribute(tempTargetAttrName)) {
- node.setAttribute("target", node.getAttribute(tempTargetAttrName));
- node.removeAttribute(tempTargetAttrName);
- if (node.getAttribute("target") === "_blank")
- node.setAttribute("rel", "noopener noreferrer");
+ catch (err) {
+ error("Couldn't get video time due to error:", err);
+ res(null);
}
});
- /** Sets innerHTML directly on Firefox and Safari, while on Chromium a [Trusted Types policy](https://developer.mozilla.org/en-US/docs/Web/API/Trusted_Types_API) is used to set the HTML */
- function setInnerHtml(element, html) {
- var _a, _b;
- if (!ttPolicy && ((_a = window === null || window === void 0 ? void 0 : window.trustedTypes) === null || _a === void 0 ? void 0 : _a.createPolicy)) {
- ttPolicy = window.trustedTypes.createPolicy("bytm-sanitize-html", {
- createHTML: (dirty) => DOMPurify.sanitize(dirty, {
- RETURN_TRUSTED_TYPE: true,
- }),
+}
+/**
+ * Sends events that force the video controls to become visible for about 3 seconds.
+ * This only works once (for some reason), 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: UserUtils.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;
+}
+/**
+ * Waits for the video element to be in its readyState 4 / canplay state and returns it.
+ * Could take a very long time to resolve if the `/watch` page isn't open.
+ * Resolves immediately if the video element is already ready.
+ */
+function waitVideoElementReady() {
+ return new Promise(async (res, rej) => {
+ try {
+ const vidEl = getVideoElement();
+ if ((vidEl === null || vidEl === void 0 ? void 0 : vidEl.readyState) === 4)
+ return res(vidEl);
+ if (!location.pathname.startsWith("/watch"))
+ await siteEvents.once("watchIdChanged");
+ addSelectorListener("body", getVideoSelector(), {
+ listener(vidElem) {
+ // this is just after YT has finished doing their own shenanigans with the video time and volume
+ if (vidElem.readyState === 4)
+ res(vidElem);
+ else
+ vidElem.addEventListener("canplay", () => res(vidElem), { once: true });
+ },
});
}
- element.innerHTML = (_b = ttPolicy === null || ttPolicy === void 0 ? void 0 : ttPolicy.createHTML(html)) !== null && _b !== void 0 ? _b : DOMPurify.sanitize(html, { RETURN_TRUSTED_TYPE: false });
- }
- /** Creates an invisible link element and clicks it to download the provided string or Blob data as a file */
- function downloadFile(fileName, data, mimeType = "text/plain") {
- const blob = data instanceof Blob ? data : new Blob([data], { type: mimeType });
- const a = document.createElement("a");
- a.classList.add("bytm-hidden");
- a.href = URL.createObjectURL(blob);
- a.download = fileName;
- document.body.appendChild(a);
- a.click();
- setTimeout(() => a.remove(), 50);
+ catch (err) {
+ rej(err);
+ }
+ });
+}
+//#region css utils
+/**
+ * Adds a style element to the DOM at runtime.
+ * @param css The CSS stylesheet to add
+ * @param ref A reference string to identify the style element - defaults to a random 5-character string
+ * @param transform A function to transform the CSS before adding it to the DOM
+ */
+async function addStyle(css, ref, transform = (c) => c) {
+ if (!domLoaded)
+ throw new Error("DOM has not finished loading yet");
+ const elem = UserUtils.addGlobalStyle(await transform(css));
+ elem.id = `bytm-style-${ref !== null && ref !== void 0 ? ref : UserUtils.randomId(6, 36)}`;
+ return elem;
+}
+/**
+ * Adds a global style element with the contents fetched from the specified resource starting with `css-`
+ * The CSS can be transformed using the provided function before being added to the DOM.
+ */
+async function addStyleFromResource(key, transform = (c) => c) {
+ const css = await fetchCss(key);
+ if (css) {
+ await addStyle(transform(css), key.slice(4));
+ return true;
}
-
- /**
- * Constructs a URL from a base URL and a record of query parameters.
- * If a value is null, the parameter will be valueless. If a value is undefined, the parameter will be omitted.
- * All values will be stringified using their `toString()` method and then URI-encoded.
- * @returns Returns a string instead of a URL object
- */
- function constructUrlString(baseUrl, params) {
- return `${baseUrl}?${Object.entries(params)
- .filter(([, v]) => v !== undefined)
- .map(([key, val]) => `${key}${val === null ? "" : `=${encodeURIComponent(String(val))}`}`)
- .join("&")}`;
+ return false;
+}
+/** Sets a global CSS variable on the <document> element with the name `--bytm-global-${name}` */
+function setGlobalCssVar(name, value) {
+ document.documentElement.style.setProperty(`--bytm-global-${name.toLowerCase().trim()}`, String(value));
+}
+/** Sets multiple global CSS variables on the <document> element with the name `--bytm-global-${name}` */
+function setGlobalCssVars(vars) {
+ for (const [name, value] of Object.entries(vars))
+ setGlobalCssVar(name, value);
+}
+//#region other
+/** Removes all child nodes of an element without invoking the slow-ish HTML parser */
+function clearInner(element) {
+ while (element.hasChildNodes())
+ clearNode(element.firstChild);
+}
+/** Removes all child nodes of an element recursively and also removes the element itself */
+function clearNode(element) {
+ while (element.hasChildNodes())
+ clearNode(element.firstChild);
+ element.parentNode.removeChild(element);
+}
+/**
+ * Returns an identifier for the currently playing media type on YTM (song or video).
+ * Only works on YTM and will throw on YT or if {@linkcode waitVideoElementReady} hasn't been awaited yet.
+ */
+function getCurrentMediaType() {
+ if (getDomain() === "yt")
+ throw new Error("currentMediaType() is only available on YTM!");
+ const songImgElem = document.querySelector("ytmusic-player #song-image");
+ if (!songImgElem)
+ throw new Error("Couldn't find the song image element. Use this function only after awaiting `waitVideoElementReady()`!");
+ return window.getComputedStyle(songImgElem).display !== "none" ? "song" : "video";
+}
+/** Copies the provided text to the clipboard and shows an error message for manual copying if the grant `GM.setClipboard` is not given. */
+function copyToClipboard(text) {
+ try {
+ GM.setClipboard(String(text));
}
- /**
- * Constructs a URL object from a base URL and a record of query parameters.
- * If a value is null, the parameter will be valueless. If a value is undefined, the parameter will be omitted.
- * All values will be URI-encoded.
- * @returns Returns a URL object instead of a string
- */
- function constructUrl(base, params) {
- return new URL(constructUrlString(base, params));
+ catch (_a) {
+ showPrompt({ type: "alert", message: t("copy_to_clipboard_error", String(text)) });
}
- /**
- * Sends a request with the specified parameters and returns the response as a Promise.
- * Ignores [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS), contrary to fetch and fetchAdvanced.
- */
- function sendRequest(details) {
- return new Promise((resolve, reject) => {
- GM.xmlHttpRequest(Object.assign(Object.assign({ timeout: 10000 }, details), { onload: resolve, onerror: reject, ontimeout: reject, onabort: reject }));
+}
+let ttPolicy;
+// workaround for supporting `target="_blank"` links without compromising security:
+const tempTargetAttrName = `data-tmp-target-${UserUtils.randomId(6, 36)}`;
+DOMPurify.addHook("beforeSanitizeAttributes", (node) => {
+ if (node.tagName === "A") {
+ if (!node.hasAttribute("target"))
+ node.setAttribute("target", "_self");
+ if (node.hasAttribute("target"))
+ node.setAttribute(tempTargetAttrName, node.getAttribute("target"));
+ }
+});
+DOMPurify.addHook("afterSanitizeAttributes", (node) => {
+ if (node.tagName === "A" && node.hasAttribute(tempTargetAttrName)) {
+ node.setAttribute("target", node.getAttribute(tempTargetAttrName));
+ node.removeAttribute(tempTargetAttrName);
+ if (node.getAttribute("target") === "_blank")
+ node.setAttribute("rel", "noopener noreferrer");
+ }
+});
+/** Sets innerHTML directly on Firefox and Safari, while on Chromium a [Trusted Types policy](https://developer.mozilla.org/en-US/docs/Web/API/Trusted_Types_API) is used to set the HTML */
+function setInnerHtml(element, html) {
+ var _a, _b;
+ if (!ttPolicy && ((_a = window === null || window === void 0 ? void 0 : window.trustedTypes) === null || _a === void 0 ? void 0 : _a.createPolicy)) {
+ ttPolicy = window.trustedTypes.createPolicy("bytm-sanitize-html", {
+ createHTML: (dirty) => DOMPurify.sanitize(dirty, {
+ RETURN_TRUSTED_TYPE: true,
+ }),
});
}
- /** Fetches a CSS file from the specified resource with a key starting with `css-` */
- async function fetchCss(key) {
- try {
- const css = await (await UserUtils.fetchAdvanced(await getResourceUrl(key))).text();
- return css !== null && css !== void 0 ? css : undefined;
- }
- catch (err) {
- error("Couldn't fetch CSS due to an error:", err);
- return undefined;
- }
+ element.innerHTML = (_b = ttPolicy === null || ttPolicy === void 0 ? void 0 : ttPolicy.createHTML(html)) !== null && _b !== void 0 ? _b : DOMPurify.sanitize(html, { RETURN_TRUSTED_TYPE: false });
+}
+/** Creates an invisible link element and clicks it to download the provided string or Blob data as a file */
+function downloadFile(fileName, data, mimeType = "text/plain") {
+ const blob = data instanceof Blob ? data : new Blob([data], { type: mimeType });
+ const a = document.createElement("a");
+ a.classList.add("bytm-hidden");
+ a.href = URL.createObjectURL(blob);
+ a.download = fileName;
+ document.body.appendChild(a);
+ a.click();
+ setTimeout(() => a.remove(), 50);
+}/**
+ * Constructs a URL from a base URL and a record of query parameters.
+ * If a value is null, the parameter will be valueless. If a value is undefined, the parameter will be omitted.
+ * All values will be stringified using their `toString()` method and then URI-encoded.
+ * @returns Returns a string instead of a URL object
+ */
+function constructUrlString(baseUrl, params) {
+ return `${baseUrl}?${Object.entries(params)
+ .filter(([, v]) => v !== undefined)
+ .map(([key, val]) => `${key}${val === null ? "" : `=${encodeURIComponent(String(val))}`}`)
+ .join("&")}`;
+}
+/**
+ * Constructs a URL object from a base URL and a record of query parameters.
+ * If a value is null, the parameter will be valueless. If a value is undefined, the parameter will be omitted.
+ * All values will be URI-encoded.
+ * @returns Returns a URL object instead of a string
+ */
+function constructUrl(base, params) {
+ return new URL(constructUrlString(base, params));
+}
+/**
+ * Sends a request with the specified parameters and returns the response as a Promise.
+ * Ignores [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS), contrary to fetch and fetchAdvanced.
+ */
+function sendRequest(details) {
+ return new Promise((resolve, reject) => {
+ GM.xmlHttpRequest(Object.assign(Object.assign({ timeout: 10000 }, details), { onload: resolve, onerror: reject, ontimeout: reject, onabort: reject }));
+ });
+}
+/** Fetches a CSS file from the specified resource with a key starting with `css-` */
+async function fetchCss(key) {
+ try {
+ const css = await (await UserUtils.fetchAdvanced(await getResourceUrl(key))).text();
+ return css !== null && css !== void 0 ? css : undefined;
}
- /** Cache for the vote data of YouTube videos to prevent some unnecessary requests */
- const voteCache = new Map();
- /** Time-to-live for the vote cache in milliseconds */
- const voteCacheTTL = 1000 * 60 * 10;
- /**
- * Fetches the votes object for a YouTube video from the [Return YouTube Dislike API.](https://returnyoutubedislike.com/docs)
- * @param watchId The watch ID of the video
- */
- async function fetchVideoVotes(watchId) {
- try {
- if (voteCache.has(watchId)) {
- const cached = voteCache.get(watchId);
- if (Date.now() - cached.timestamp < voteCacheTTL) {
- info(`Returning cached video votes for watch ID '${watchId}':`, cached);
- return cached;
- }
- else
- voteCache.delete(watchId);
- }
- const votesRaw = JSON.parse((await sendRequest({
- method: "GET",
- url: `https://returnyoutubedislikeapi.com/votes?videoId=${watchId}`,
- })).response);
- if (!("id" in votesRaw) || !("likes" in votesRaw) || !("dislikes" in votesRaw) || !("rating" in votesRaw)) {
- error("Couldn't parse video votes due to an error:", votesRaw);
- return undefined;
+ catch (err) {
+ error("Couldn't fetch CSS due to an error:", err);
+ return undefined;
+ }
+}
+/** Cache for the vote data of YouTube videos to prevent some unnecessary requests */
+const voteCache = new Map();
+/** Time-to-live for the vote cache in milliseconds */
+const voteCacheTTL = 1000 * 60 * 10;
+/**
+ * Fetches the votes object for a YouTube video from the [Return YouTube Dislike API.](https://returnyoutubedislike.com/docs)
+ * @param watchId The watch ID of the video
+ */
+async function fetchVideoVotes(watchId) {
+ try {
+ if (voteCache.has(watchId)) {
+ const cached = voteCache.get(watchId);
+ if (Date.now() - cached.timestamp < voteCacheTTL) {
+ info(`Returning cached video votes for watch ID '${watchId}':`, cached);
+ return cached;
}
- const votesObj = {
- id: votesRaw.id,
- likes: votesRaw.likes,
- dislikes: votesRaw.dislikes,
- rating: votesRaw.rating,
- timestamp: Date.now(),
- };
- voteCache.set(votesObj.id, votesObj);
- info(`Fetched video votes for watch ID '${watchId}':`, votesObj);
- return votesObj;
+ else
+ voteCache.delete(watchId);
}
- catch (err) {
- error("Couldn't fetch video votes due to an error:", err);
+ const votesRaw = JSON.parse((await sendRequest({
+ method: "GET",
+ url: `https://returnyoutubedislikeapi.com/votes?videoId=${watchId}`,
+ })).response);
+ if (!("id" in votesRaw) || !("likes" in votesRaw) || !("dislikes" in votesRaw) || !("rating" in votesRaw)) {
+ error("Couldn't parse video votes due to an error:", votesRaw);
return undefined;
}
- }
-
- //#region cns. watermark
- {
- // console watermark with sexy gradient
- const [styleGradient, gradientContBg] = (() => {
- switch (mode) {
- case "production": return ["background: rgb(165, 57, 36); background: linear-gradient(90deg, rgb(154, 31, 103) 0%, rgb(135, 31, 31) 40%, rgb(165, 57, 36) 100%);", "rgb(165, 57, 36)"];
- case "development": return ["background: rgb(72, 66, 178); background: linear-gradient(90deg, rgb(38, 160, 172) 0%, rgb(33, 48, 158) 40%, rgb(72, 66, 178) 100%);", "rgb(72, 66, 178)"];
- }
- })();
- const styleCommon = "color: #fff; font-size: 1.3rem;";
- const poweredBy = `Powered by:
+ const votesObj = {
+ id: votesRaw.id,
+ likes: votesRaw.likes,
+ dislikes: votesRaw.dislikes,
+ rating: votesRaw.rating,
+ timestamp: Date.now(),
+ };
+ voteCache.set(votesObj.id, votesObj);
+ info(`Fetched video votes for watch ID '${watchId}':`, votesObj);
+ return votesObj;
+ }
+ catch (err) {
+ error("Couldn't fetch video votes due to an error:", err);
+ return undefined;
+ }
+}//#region cns. watermark
+{
+ // console watermark with sexy gradient
+ const [styleGradient, gradientContBg] = (() => {
+ switch (mode) {
+ case "production": return ["background: rgb(165, 57, 36); background: linear-gradient(90deg, rgb(154, 31, 103) 0%, rgb(135, 31, 31) 40%, rgb(165, 57, 36) 100%);", "rgb(165, 57, 36)"];
+ case "development": return ["background: rgb(72, 66, 178); background: linear-gradient(90deg, rgb(38, 160, 172) 0%, rgb(33, 48, 158) 40%, rgb(72, 66, 178) 100%);", "rgb(72, 66, 178)"];
+ }
+ })();
+ const styleCommon = "color: #fff; font-size: 1.3rem;";
+ const poweredBy = `Powered by:
─ Lots of ambition and dedication
─ My song metadata API: https://api.sv443.net/geniurl
─ My userscript utility library: https://github.com/Sv443-Network/UserUtils
@@ -7750,224 +7661,224 @@ ytmusic-section-list-renderer[main-page-type="MUSIC_PAGE_TYPE_PLAYLIST"] ytmusic
─ This tiny event listener library: https://github.com/ai/nanoevents
─ TypeScript and the tslib runtime: https://github.com/microsoft/TypeScript
─ The Cousine font: https://fonts.google.com/specimen/Cousine`;
- console.log(`\
+ console.log(`\
%c${scriptInfo.name}%cv${scriptInfo.version}%c • ${scriptInfo.namespace}%c
Build #${buildNumber}${mode === "development" ? " (dev mode)" : ""}
%c${poweredBy}`, `${styleCommon} ${styleGradient} font-weight: bold; padding-left: 6px; padding-right: 6px;`, `${styleCommon} background-color: ${gradientContBg}; padding-left: 8px; padding-right: 8px;`, "color: #fff; font-size: 1.2rem;", "padding: initial; font-size: 0.9rem;", "padding: initial; font-size: 1rem;");
+}
+//#region preInit
+/** Stuff that needs to be called ASAP, before anything async happens */
+function preInit() {
+ var _a, _b;
+ try {
+ const unsupportedHandlers = [
+ "FireMonkey",
+ ];
+ if (unsupportedHandlers.includes((_b = (_a = GM === null || GM === void 0 ? void 0 : GM.info) === null || _a === void 0 ? void 0 : _a.scriptHandler) !== null && _b !== void 0 ? _b : "_"))
+ return alert(`BetterYTM does not work when using ${GM.info.scriptHandler} as the userscript manager extension and will be disabled.\nI recommend using either ViolentMonkey, TamperMonkey or GreaseMonkey.`);
+ log("Session ID:", getSessionId());
+ initInterface();
+ setLogLevel(defaultLogLevel);
+ if (getDomain() === "ytm")
+ initBeforeUnloadHook();
+ init();
+ }
+ catch (err) {
+ return error("Fatal pre-init error:", err);
}
- //#region preInit
- /** Stuff that needs to be called ASAP, before anything async happens */
- function preInit() {
- var _a, _b;
+}
+//#region init
+async function init() {
+ var _a, _b;
+ try {
+ const domain = getDomain();
+ const features = await initConfig();
+ setLogLevel(features.logLevel);
+ await initLyricsCache();
+ await initTranslations((_a = features.locale) !== null && _a !== void 0 ? _a : "en-US");
+ setLocale((_b = features.locale) !== null && _b !== void 0 ? _b : "en-US");
try {
- const unsupportedHandlers = [
- "FireMonkey",
- ];
- if (unsupportedHandlers.includes((_b = (_a = GM === null || GM === void 0 ? void 0 : GM.info) === null || _a === void 0 ? void 0 : _a.scriptHandler) !== null && _b !== void 0 ? _b : "_"))
- return alert(`BetterYTM does not work when using ${GM.info.scriptHandler} as the userscript manager extension and will be disabled.\nI recommend using either ViolentMonkey, TamperMonkey or GreaseMonkey.`);
- log("Session ID:", getSessionId());
- initInterface();
- setLogLevel(defaultLogLevel);
- if (getDomain() === "ytm")
- initBeforeUnloadHook();
- init();
+ initPlugins();
}
catch (err) {
- return error("Fatal pre-init error:", err);
+ error("Plugin loading error:", err);
+ emitInterface("bytm:fatalError", "Error while loading plugins");
}
+ if (features.disableBeforeUnloadPopup && domain === "ytm")
+ disableBeforeUnload();
+ if (features.rememberSongTime)
+ initRememberSongTime();
+ if (!domLoaded)
+ document.addEventListener("DOMContentLoaded", onDomLoad, { once: true });
+ else
+ onDomLoad();
}
- //#region init
- async function init() {
- var _a, _b;
- try {
- const domain = getDomain();
- const features = await initConfig();
- setLogLevel(features.logLevel);
- await initLyricsCache();
- await initTranslations((_a = features.locale) !== null && _a !== void 0 ? _a : "en-US");
- setLocale((_b = features.locale) !== null && _b !== void 0 ? _b : "en-US");
- try {
- initPlugins();
- }
- catch (err) {
- error("Plugin loading error:", err);
- emitInterface("bytm:fatalError", "Error while loading plugins");
- }
- if (features.disableBeforeUnloadPopup && domain === "ytm")
- disableBeforeUnload();
- if (features.rememberSongTime)
- initRememberSongTime();
- if (!domLoaded)
- document.addEventListener("DOMContentLoaded", onDomLoad, { once: true });
- else
- onDomLoad();
- }
- catch (err) {
- error("Fatal error:", err);
- }
+ catch (err) {
+ error("Fatal error:", err);
}
- //#region onDomLoad
- /** Called when the DOM has finished loading and can be queried and altered by the userscript */
- async function onDomLoad() {
- const domain = getDomain();
- const feats = getFeatures();
- const ftInit = [];
- // for being able to apply domain-specific styles (prefix any CSS selector with "body.bytm-dom-yt" or "body.bytm-dom-ytm")
- document.body.classList.add(`bytm-dom-${domain}`);
- try {
- initGlobalCssVars();
- initObservers();
- await Promise.allSettled([
- injectCssBundle(),
- initVersionCheck(),
- ]);
+}
+//#region onDomLoad
+/** Called when the DOM has finished loading and can be queried and altered by the userscript */
+async function onDomLoad() {
+ const domain = getDomain();
+ const feats = getFeatures();
+ const ftInit = [];
+ // for being able to apply domain-specific styles (prefix any CSS selector with "body.bytm-dom-yt" or "body.bytm-dom-ytm")
+ document.body.classList.add(`bytm-dom-${domain}`);
+ try {
+ initGlobalCssVars();
+ initObservers();
+ await Promise.allSettled([
+ injectCssBundle(),
+ initVersionCheck(),
+ ]);
+ }
+ catch (err) {
+ error("Fatal error in feature pre-init:", err);
+ return;
+ }
+ log(`DOM loaded and feature pre-init finished, now initializing all features for domain "${domain}"...`);
+ try {
+ //#region welcome dlg
+ if (typeof await GM.getValue("bytm-installed") !== "string") {
+ // open welcome menu with language selector
+ const dlg = await getWelcomeDialog();
+ dlg.on("close", () => GM.setValue("bytm-installed", JSON.stringify({ timestamp: Date.now(), version: scriptInfo.version })));
+ info("Showing welcome menu");
+ await dlg.open();
}
- catch (err) {
- error("Fatal error in feature pre-init:", err);
- return;
+ if (domain === "ytm") {
+ //#region (ytm) layout
+ if (feats.watermarkEnabled)
+ ftInit.push(["addWatermark", addWatermark()]);
+ if (feats.fixSpacing)
+ ftInit.push(["fixSpacing", fixSpacing()]);
+ ftInit.push(["thumbnailOverlay", initThumbnailOverlay()]);
+ if (feats.hideCursorOnIdle)
+ ftInit.push(["hideCursorOnIdle", initHideCursorOnIdle()]);
+ if (feats.fixHdrIssues)
+ ftInit.push(["fixHdrIssues", fixHdrIssues()]);
+ if (feats.showVotes)
+ ftInit.push(["showVotes", initShowVotes()]);
+ //#region (ytm) volume
+ ftInit.push(["volumeFeatures", initVolumeFeatures()]);
+ //#region (ytm) song lists
+ if (feats.lyricsQueueButton || feats.deleteFromQueueButton)
+ ftInit.push(["queueButtons", initQueueButtons()]);
+ ftInit.push(["aboveQueueBtns", initAboveQueueBtns()]);
+ //#region (ytm) behavior
+ if (feats.closeToastsTimeout > 0)
+ ftInit.push(["autoCloseToasts", initAutoCloseToasts()]);
+ //#region (ytm) input
+ ftInit.push(["arrowKeySkip", initArrowKeySkip()]);
+ if (feats.anchorImprovements)
+ ftInit.push(["anchorImprovements", addAnchorImprovements()]);
+ ftInit.push(["numKeysSkip", initNumKeysSkip()]);
+ //#region (ytm) lyrics
+ if (feats.geniusLyrics)
+ ftInit.push(["playerBarLyricsBtn", addPlayerBarLyricsBtn()]);
+ // #region (ytm) integrations
+ if (feats.sponsorBlockIntegration)
+ ftInit.push(["sponsorBlockIntegration", fixSponsorBlock()]);
+ const hideThemeSongLogo = addStyleFromResource("css-hide_themesong_logo");
+ if (feats.themeSongIntegration)
+ ftInit.push(["themeSongIntegration", Promise.allSettled([fixThemeSong(), hideThemeSongLogo])]);
+ else
+ ftInit.push(["themeSongIntegration", Promise.allSettled([fixPlayerPageTheming(), hideThemeSongLogo])]);
}
- log(`DOM loaded and feature pre-init finished, now initializing all features for domain "${domain}"...`);
+ //#region (ytm+yt) cfg menu
try {
- //#region welcome dlg
- if (typeof await GM.getValue("bytm-installed") !== "string") {
- // open welcome menu with language selector
- const dlg = await getWelcomeDialog();
- dlg.on("close", () => GM.setValue("bytm-installed", JSON.stringify({ timestamp: Date.now(), version: scriptInfo.version })));
- info("Showing welcome menu");
- await dlg.open();
- }
if (domain === "ytm") {
- //#region (ytm) layout
- if (feats.watermarkEnabled)
- ftInit.push(["addWatermark", addWatermark()]);
- if (feats.fixSpacing)
- ftInit.push(["fixSpacing", fixSpacing()]);
- ftInit.push(["thumbnailOverlay", initThumbnailOverlay()]);
- if (feats.hideCursorOnIdle)
- ftInit.push(["hideCursorOnIdle", initHideCursorOnIdle()]);
- if (feats.fixHdrIssues)
- ftInit.push(["fixHdrIssues", fixHdrIssues()]);
- if (feats.showVotes)
- ftInit.push(["showVotes", initShowVotes()]);
- //#region (ytm) volume
- ftInit.push(["volumeFeatures", initVolumeFeatures()]);
- //#region (ytm) song lists
- if (feats.lyricsQueueButton || feats.deleteFromQueueButton)
- ftInit.push(["queueButtons", initQueueButtons()]);
- ftInit.push(["aboveQueueBtns", initAboveQueueBtns()]);
- //#region (ytm) behavior
- if (feats.closeToastsTimeout > 0)
- ftInit.push(["autoCloseToasts", initAutoCloseToasts()]);
- //#region (ytm) input
- ftInit.push(["arrowKeySkip", initArrowKeySkip()]);
- if (feats.anchorImprovements)
- ftInit.push(["anchorImprovements", addAnchorImprovements()]);
- ftInit.push(["numKeysSkip", initNumKeysSkip()]);
- //#region (ytm) lyrics
- if (feats.geniusLyrics)
- ftInit.push(["playerBarLyricsBtn", addPlayerBarLyricsBtn()]);
- // #region (ytm) integrations
- if (feats.sponsorBlockIntegration)
- ftInit.push(["sponsorBlockIntegration", fixSponsorBlock()]);
- const hideThemeSongLogo = addStyleFromResource("css-hide_themesong_logo");
- if (feats.themeSongIntegration)
- ftInit.push(["themeSongIntegration", Promise.allSettled([fixThemeSong(), hideThemeSongLogo])]);
- else
- ftInit.push(["themeSongIntegration", Promise.allSettled([fixPlayerPageTheming(), hideThemeSongLogo])]);
- }
- //#region (ytm+yt) cfg menu
- try {
- if (domain === "ytm") {
- addSelectorListener("body", "tp-yt-iron-dropdown #contentWrapper ytd-multi-page-menu-renderer #container.menu-container", {
- listener: addConfigMenuOptionYTM,
- });
- }
- else if (domain === "yt") {
- addSelectorListener("ytGuide", "#sections ytd-guide-section-renderer:nth-child(5) #items ytd-guide-entry-renderer:nth-child(1)", {
- listener: (el) => el.parentElement && addConfigMenuOptionYT(el.parentElement),
- });
- }
- }
- catch (err) {
- error("Couldn't add config menu option:", err);
- }
- if (["ytm", "yt"].includes(domain)) {
- //#region general
- ftInit.push(["initSiteEvents", initSiteEvents()]);
- //#region (ytm+yt) layout
- if (feats.removeShareTrackingParamSites && (feats.removeShareTrackingParamSites === domain || feats.removeShareTrackingParamSites === "all"))
- ftInit.push(["initRemShareTrackParam", initRemShareTrackParam()]);
- //#region (ytm+yt) input
- ftInit.push(["siteSwitch", initSiteSwitch(domain)]);
- if (feats.autoLikeChannels)
- ftInit.push(["autoLikeChannels", initAutoLike()]);
- //#region (ytm+yt) integrations
- if (feats.disableDarkReaderSites !== "none")
- ftInit.push(["disableDarkReaderSites", disableDarkReader()]);
- }
- emitInterface("bytm:featureInitStarted");
- const initStartTs = Date.now();
- // wait for feature init or timeout (in case an init function is hung up on a promise)
- await Promise.race([
- UserUtils.pauseFor(feats.initTimeout > 0 ? feats.initTimeout * 1000 : 8000),
- Promise.allSettled(ftInit.map(([name, prom]) => new Promise(async (res) => {
- const v = await prom;
- emitInterface("bytm:featureInitialized", name);
- res(v);
- }))),
- ]);
- emitInterface("bytm:ready");
- info(`Done initializing ${ftInit.length} features after ${Math.floor(Date.now() - initStartTs)}ms`);
- try {
- registerDevCommands();
+ addSelectorListener("body", "tp-yt-iron-dropdown #contentWrapper ytd-multi-page-menu-renderer #container.menu-container", {
+ listener: addConfigMenuOptionYTM,
+ });
}
- catch (e) {
- warn("Couldn't register dev menu commands:", e);
+ else if (domain === "yt") {
+ addSelectorListener("ytGuide", "#sections ytd-guide-section-renderer:nth-child(5) #items ytd-guide-entry-renderer:nth-child(1)", {
+ listener: (el) => el.parentElement && addConfigMenuOptionYT(el.parentElement),
+ });
}
}
catch (err) {
- error("Feature error:", err);
- emitInterface("bytm:fatalError", "Error while initializing features");
- }
- }
- //#region css
- /** Inserts the bundled CSS files imported throughout the script into a