Browse Source

chore: build dev

Sv443 2 days ago
parent
commit
40febd3d15
1 changed files with 382 additions and 285 deletions
  1. 382 285
      dist/BetterYTM.user.js

+ 382 - 285
dist/BetterYTM.user.js

@@ -8,7 +8,7 @@
 // @license           AGPL-3.0-only
 // @author            Sv443
 // @copyright         Sv443 (https://github.com/Sv443)
-// @icon              https://cdn.jsdelivr.net/gh/Sv443/BetterYTM@18b3af38/assets/images/logo/logo_dev_48.png
+// @icon              https://cdn.jsdelivr.net/gh/Sv443/BetterYTM@6abc4749/assets/images/logo/logo_dev_48.png
 // @match             https://music.youtube.com/*
 // @match             https://www.youtube.com/*
 // @run-at            document-start
@@ -333,7 +333,7 @@ const rawConsts = {
     mode: "development",
     branch: "develop",
     host: "github",
-    buildNumber: "18b3af38",
+    buildNumber: "6abc4749",
     assetSource: "jsdelivr",
     devServerPort: "8710",
 };
@@ -1720,206 +1720,6 @@ async function closeToast() {
         toastEl.remove();
         await UserUtils.pauseFor(100);
     }));
-}//#region beforeunload popup
-let discardBeforeUnload = false;
-/** Disables the popup before leaving the site */
-function enableDiscardBeforeUnload() {
-    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 });
-                }
-            }
-            catch (err) {
-                error("Error in automatic toast closing:", err);
-            }
-        },
-    });
-    log("Initialized automatic toast closing");
-}
-//#region auto scroll to active
-let initialAutoScrollToActiveSong = true;
-let prevVidMaxTime = Infinity;
-let prevTime = -1;
-/** Initializes the autoScrollToActiveSong feature */
-async function initAutoScrollToActiveSong() {
-    setInterval(() => {
-        var _a, _b, _c, _d;
-        prevTime = (_b = (_a = getVideoElement()) === null || _a === void 0 ? void 0 : _a.currentTime) !== null && _b !== void 0 ? _b : -1;
-        prevVidMaxTime = (_d = (_c = getVideoElement()) === null || _c === void 0 ? void 0 : _c.duration) !== null && _d !== void 0 ? _d : Infinity;
-    }, 50);
-    siteEvents.on("watchIdChanged", (_, oldId) => {
-        if (!oldId)
-            return;
-        const isManualChange = prevTime < prevVidMaxTime - 1;
-        if (["videoChangeManual", "videoChangeAll"].includes(getFeature("autoScrollToActiveSongMode")) && isManualChange)
-            scrollToCurrentSongInQueue();
-        else if (["videoChangeAuto", "videoChangeAll"].includes(getFeature("autoScrollToActiveSongMode")) && !isManualChange)
-            scrollToCurrentSongInQueue();
-    });
-    if (getFeature("autoScrollToActiveSongMode") !== "never" && initialAutoScrollToActiveSong) {
-        initialAutoScrollToActiveSong = false;
-        scrollToCurrentSongInQueue();
-    }
-}
-/**
- * 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", "[]");
-    let remVids;
-    try {
-        remVids = 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", "[]");
-        remVids = [];
-    }
-    if (remVids.some(e => "watchID" in e)) {
-        remVids = remVids.filter(e => "id" in e);
-        await GM.setValue("bytm-rem-songs", JSON.stringify(remVids));
-        log(`Removed ${remVids.length} ${UserUtils.autoPlural("entry", remVids)} with an outdated format from the video time cache`);
-    }
-    log(`Initialized video time restoring with ${remVids.length} initial ${UserUtils.autoPlural("entry", remVids)}:`, remVids);
-    await remTimeRestoreTime();
-    try {
-        if (!UserUtils.isDomLoaded())
-            document.addEventListener("DOMContentLoaded", remTimeStartUpdateLoop);
-        else
-            remTimeStartUpdateLoop();
-    }
-    catch (err) {
-        error("Error in video time remembering update loop:", err);
-    }
-}
-/** Tries to restore the time of the currently playing video */
-async function remTimeRestoreTime() {
-    const remVids = JSON.parse(await GM.getValue("bytm-rem-songs", "[]"));
-    if (location.pathname.startsWith("/watch")) {
-        const videoID = new URL(location.href).searchParams.get("v");
-        if (!videoID)
-            return;
-        if (initialParams.has("t"))
-            return info("Not restoring song time because the URL has the '&t' parameter", LogLevel.Info);
-        const entry = remVids.find(entry => entry.id === videoID);
-        if (entry) {
-            if (Date.now() - entry.updated > getFeature("rememberSongTimeDuration") * 1000) {
-                await remTimeDeleteEntry(entry.id);
-                return;
-            }
-            else if (isNaN(Number(entry.time)) || entry.time < 0)
-                return warn("Invalid time in remembered song time entry:", entry);
-            else {
-                let vidElem;
-                const doRestoreTime = async () => {
-                    var _a;
-                    if (!vidElem)
-                        vidElem = await waitVideoElementReady();
-                    const vidRestoreTime = entry.time - ((_a = getFeature("rememberSongTimeReduction")) !== null && _a !== void 0 ? _a : 0);
-                    vidElem.currentTime = UserUtils.clamp(Math.max(vidRestoreTime, 0), 0, vidElem.duration);
-                    await remTimeDeleteEntry(entry.id);
-                    info(`Restored ${getDomain() === "ytm" ? getCurrentMediaType() : "video"} time to ${Math.floor(vidRestoreTime / 60)}m, ${(vidRestoreTime % 60).toFixed(1)}s`, LogLevel.Info);
-                };
-                if (!UserUtils.isDomLoaded())
-                    document.addEventListener("DOMContentLoaded", doRestoreTime);
-                else
-                    doRestoreTime();
-            }
-        }
-    }
-}
-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;
-    const remVids = JSON.parse(await GM.getValue("bytm-rem-songs", "[]"));
-    if (location.pathname.startsWith("/watch")) {
-        const id = getWatchId();
-        const songTime = (_a = await getVideoTime()) !== null && _a !== void 0 ? _a : 0;
-        if (id && 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 = {
-                    id,
-                    time: songTime,
-                    updated: Date.now(),
-                };
-                await remTimeUpsertEntry(entry);
-            }
-            // if the song is rewound to the beginning, update the entry accordingly
-            else if (!paused) {
-                const entry = remVids.find(entry => entry.id === id);
-                if (entry && songTime <= entry.time)
-                    await remTimeUpsertEntry(Object.assign(Object.assign({}, entry), { time: songTime, updated: Date.now() }));
-            }
-        }
-    }
-    const expiredEntries = remVids.filter(entry => Date.now() - entry.updated > getFeature("rememberSongTimeDuration") * 1000);
-    for (const entry of expiredEntries)
-        await remTimeDeleteEntry(entry.id);
-    // 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 remVids = JSON.parse(await GM.getValue("bytm-rem-songs", "[]"));
-    const foundIdx = remVids.findIndex(entry => entry.id === data.id);
-    if (foundIdx >= 0)
-        remVids[foundIdx] = data;
-    else
-        remVids.push(data);
-    await GM.setValue("bytm-rem-songs", JSON.stringify(remVids));
-}
-/** Deletes an entry in the "remember cache" */
-async function remTimeDeleteEntry(videoID) {
-    const remVids = JSON.parse(await GM.getValue("bytm-rem-songs", "[]"))
-        .filter(entry => entry.id !== videoID);
-    await GM.setValue("bytm-rem-songs", JSON.stringify(remVids));
 }const interactionKeys = ["Enter", " ", "Space"];
 /**
  * Adds generic, accessible interaction listeners to the passed element.
@@ -2413,11 +2213,17 @@ function getChannelIdFromPrompt(promptStr) {
     return id.length > 0 ? id : null;
 }const inputIgnoreTagNames = ["INPUT", "TEXTAREA", "SELECT", "BUTTON", "A"];
 //#region arrow key skip
+let sliderEl;
 async function initArrowKeySkip() {
+    addSelectorListener("playerBarRightControls", "tp-yt-paper-slider#volume-slider", {
+        listener: (el) => sliderEl = el,
+    });
     document.addEventListener("keydown", (evt) => {
         var _a, _b, _c, _d, _e, _f;
         if (!getFeature("arrowKeySupport"))
             return;
+        if (["ArrowUp", "ArrowDown"].includes(evt.code))
+            return handleVolumeKeyPress(evt);
         if (!["ArrowLeft", "ArrowRight"].includes(evt.code))
             return;
         const allowedClasses = ["bytm-generic-btn", "yt-spec-button-shape-next"];
@@ -2437,6 +2243,22 @@ async function initArrowKeySkip() {
     });
     log("Added arrow key press listener");
 }
+function handleVolumeKeyPress(evt) {
+    var _a;
+    evt.preventDefault();
+    evt.stopImmediatePropagation();
+    if (!sliderEl || !getVideoElement())
+        return warn("Couldn't find video or volume slider element, so the keypress is ignored");
+    const step = Number(sliderEl.step);
+    const newVol = UserUtils.clamp(Number(sliderEl.value)
+        + (evt.code === "ArrowUp" ? 1 : -1)
+            * UserUtils.clamp(((_a = getFeature("arrowKeyVolumeStep")) !== null && _a !== void 0 ? _a : featInfo.arrowKeyVolumeStep.default), isNaN(step) ? 5 : step, 100), 0, 100);
+    if (newVol !== Number(sliderEl.value)) {
+        sliderEl.value = String(newVol);
+        sliderEl.dispatchEvent(new Event("change", { bubbles: true }));
+        log(`Captured key '${evt.code}' - changed volume to ${newVol}%`);
+    }
+}
 //#region frame skip
 /** Initializes the feature that lets users skip by a frame with the period and comma keys while the video is paused */
 async function initFrameSkip() {
@@ -2458,63 +2280,6 @@ async function initFrameSkip() {
     });
     log("Added frame skip 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}'`);
-        enableDiscardBeforeUnload();
-        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);
-    }
-}
 //#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) */
@@ -2921,15 +2686,215 @@ class CustomError extends Error {
         this.time = Date.now();
     }
 }
-class LyricsError extends CustomError {
-    constructor(message, opts) {
-        super("LyricsError", message, opts);
+class LyricsError extends CustomError {
+    constructor(message, opts) {
+        super("LyricsError", message, opts);
+    }
+}
+class PluginError extends CustomError {
+    constructor(message, opts) {
+        super("PluginError", message, opts);
+    }
+}//#region beforeunload popup
+let discardBeforeUnload = false;
+/** Disables the popup before leaving the site */
+function enableDiscardBeforeUnload() {
+    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 });
+                }
+            }
+            catch (err) {
+                error("Error in automatic toast closing:", err);
+            }
+        },
+    });
+    log("Initialized automatic toast closing");
+}
+//#region auto scroll to active
+let initialAutoScrollToActiveSong = true;
+let prevVidMaxTime = Infinity;
+let prevTime = -1;
+/** Initializes the autoScrollToActiveSong feature */
+async function initAutoScrollToActiveSong() {
+    setInterval(() => {
+        var _a, _b, _c, _d;
+        prevTime = (_b = (_a = getVideoElement()) === null || _a === void 0 ? void 0 : _a.currentTime) !== null && _b !== void 0 ? _b : -1;
+        prevVidMaxTime = (_d = (_c = getVideoElement()) === null || _c === void 0 ? void 0 : _c.duration) !== null && _d !== void 0 ? _d : Infinity;
+    }, 50);
+    siteEvents.on("watchIdChanged", (_, oldId) => {
+        if (!oldId)
+            return;
+        const isManualChange = prevTime < prevVidMaxTime - 1;
+        if (["videoChangeManual", "videoChangeAll"].includes(getFeature("autoScrollToActiveSongMode")) && isManualChange)
+            scrollToCurrentSongInQueue();
+        else if (["videoChangeAuto", "videoChangeAll"].includes(getFeature("autoScrollToActiveSongMode")) && !isManualChange)
+            scrollToCurrentSongInQueue();
+    });
+    if (getFeature("autoScrollToActiveSongMode") !== "never" && initialAutoScrollToActiveSong) {
+        initialAutoScrollToActiveSong = false;
+        scrollToCurrentSongInQueue();
+    }
+}
+/**
+ * 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", "[]");
+    let remVids;
+    try {
+        remVids = 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", "[]");
+        remVids = [];
+    }
+    if (remVids.some(e => "watchID" in e)) {
+        remVids = remVids.filter(e => "id" in e);
+        await GM.setValue("bytm-rem-songs", JSON.stringify(remVids));
+        log(`Removed ${remVids.length} ${UserUtils.autoPlural("entry", remVids)} with an outdated format from the video time cache`);
+    }
+    log(`Initialized video time restoring with ${remVids.length} initial ${UserUtils.autoPlural("entry", remVids)}:`, remVids);
+    await remTimeRestoreTime();
+    try {
+        if (!UserUtils.isDomLoaded())
+            document.addEventListener("DOMContentLoaded", remTimeStartUpdateLoop);
+        else
+            remTimeStartUpdateLoop();
+    }
+    catch (err) {
+        error("Error in video time remembering update loop:", err);
+    }
+}
+/** Tries to restore the time of the currently playing video */
+async function remTimeRestoreTime() {
+    const remVids = JSON.parse(await GM.getValue("bytm-rem-songs", "[]"));
+    if (location.pathname.startsWith("/watch")) {
+        const videoID = new URL(location.href).searchParams.get("v");
+        if (!videoID)
+            return;
+        if (initialParams.has("t"))
+            return info("Not restoring song time because the URL has the '&t' parameter", LogLevel.Info);
+        const entry = remVids.find(entry => entry.id === videoID);
+        if (entry) {
+            if (Date.now() - entry.updated > getFeature("rememberSongTimeDuration") * 1000) {
+                await remTimeDeleteEntry(entry.id);
+                return;
+            }
+            else if (isNaN(Number(entry.time)) || entry.time < 0)
+                return warn("Invalid time in remembered song time entry:", entry);
+            else {
+                let vidElem;
+                const doRestoreTime = async () => {
+                    var _a;
+                    if (!vidElem)
+                        vidElem = await waitVideoElementReady();
+                    const vidRestoreTime = entry.time - ((_a = getFeature("rememberSongTimeReduction")) !== null && _a !== void 0 ? _a : 0);
+                    vidElem.currentTime = UserUtils.clamp(Math.max(vidRestoreTime, 0), 0, vidElem.duration);
+                    await remTimeDeleteEntry(entry.id);
+                    info(`Restored ${getDomain() === "ytm" ? getCurrentMediaType() : "video"} time to ${Math.floor(vidRestoreTime / 60)}m, ${(vidRestoreTime % 60).toFixed(1)}s`, LogLevel.Info);
+                };
+                if (!UserUtils.isDomLoaded())
+                    document.addEventListener("DOMContentLoaded", doRestoreTime);
+                else
+                    doRestoreTime();
+            }
+        }
     }
 }
-class PluginError extends CustomError {
-    constructor(message, opts) {
-        super("PluginError", message, opts);
+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;
+    const remVids = JSON.parse(await GM.getValue("bytm-rem-songs", "[]"));
+    if (location.pathname.startsWith("/watch")) {
+        const id = getWatchId();
+        const songTime = (_a = await getVideoTime()) !== null && _a !== void 0 ? _a : 0;
+        if (id && 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 = {
+                    id,
+                    time: songTime,
+                    updated: Date.now(),
+                };
+                await remTimeUpsertEntry(entry);
+            }
+            // if the song is rewound to the beginning, update the entry accordingly
+            else if (!paused) {
+                const entry = remVids.find(entry => entry.id === id);
+                if (entry && songTime <= entry.time)
+                    await remTimeUpsertEntry(Object.assign(Object.assign({}, entry), { time: songTime, updated: Date.now() }));
+            }
+        }
     }
+    const expiredEntries = remVids.filter(entry => Date.now() - entry.updated > getFeature("rememberSongTimeDuration") * 1000);
+    for (const entry of expiredEntries)
+        await remTimeDeleteEntry(entry.id);
+    // 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 remVids = JSON.parse(await GM.getValue("bytm-rem-songs", "[]"));
+    const foundIdx = remVids.findIndex(entry => entry.id === data.id);
+    if (foundIdx >= 0)
+        remVids[foundIdx] = data;
+    else
+        remVids.push(data);
+    await GM.setValue("bytm-rem-songs", JSON.stringify(remVids));
+}
+/** Deletes an entry in the "remember cache" */
+async function remTimeDeleteEntry(videoID) {
+    const remVids = JSON.parse(await GM.getValue("bytm-rem-songs", "[]"))
+        .filter(entry => entry.id !== videoID);
+    await GM.setValue("bytm-rem-songs", JSON.stringify(remVids));
 }//#region misc
 let domain;
 /**
@@ -5131,6 +5096,93 @@ async function initWatchPageFullSize() {
         error("Couldn't load stylesheet to make watch page full size");
     else
         log("Made watch page full size");
+}async function initHotkeys() {
+    const promises = [];
+    if (getFeature("likeDislikeHotkeys"))
+        promises.push(initLikeDislikeHotkeys());
+    if (getFeature("switchBetweenSites"))
+        promises.push(initSiteSwitch());
+    return await Promise.allSettled(promises);
+}
+function keyPressed(e, hk) {
+    return e.code === hk.code && e.shiftKey === hk.shift && e.ctrlKey === hk.ctrl && e.altKey === hk.alt;
+}
+//#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() {
+    const domain = getDomain();
+    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;
+        if (siteSwitchEnabled && keyPressed(e, getFeature("switchSitesHotkey")))
+            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}'`);
+        enableDiscardBeforeUnload();
+        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);
+    }
+}
+//#region like/dislike
+async function initLikeDislikeHotkeys() {
+    document.addEventListener("keydown", (e) => {
+        var _a, _b;
+        if (!getFeature("likeDislikeHotkeys"))
+            return;
+        if (inputIgnoreTagNames.includes((_b = (_a = document.activeElement) === null || _a === void 0 ? void 0 : _a.tagName) !== null && _b !== void 0 ? _b : ""))
+            return;
+        if (keyPressed(e, getFeature("likeHotkey"))) {
+            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");
+            likeBtnEl === null || likeBtnEl === void 0 ? void 0 : likeBtnEl.click();
+        }
+        else if (keyPressed(e, getFeature("dislikeHotkey"))) {
+            const dislikeRendererEl = document.querySelector(".middle-controls-buttons ytmusic-like-button-renderer");
+            const dislikeBtnEl = dislikeRendererEl === null || dislikeRendererEl === void 0 ? void 0 : dislikeRendererEl.querySelector("#button-shape-dislike button");
+            dislikeBtnEl === null || dislikeBtnEl === void 0 ? void 0 : dislikeBtnEl.click();
+        }
+    });
 }//#region Dark Reader
 /** Disables Dark Reader if it is present */
 async function disableDarkReader() {
@@ -6358,6 +6410,17 @@ const featInfo = {
         reloadRequired: false,
         enable: noop,
     },
+    arrowKeyVolumeStep: {
+        type: "slider",
+        category: "input",
+        min: 1,
+        max: 25,
+        step: 1,
+        default: 2,
+        unit: "%",
+        reloadRequired: false,
+        enable: noop,
+    },
     frameSkip: {
         type: "toggle",
         category: "input",
@@ -6386,25 +6449,6 @@ const featInfo = {
         advanced: true,
         textAdornment: adornments.advanced,
     },
-    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",
@@ -6467,6 +6511,57 @@ const featInfo = {
         category: "input",
         click: () => getAutoLikeDialog().then(d => d.open()),
     },
+    //#region cat:hotkeys
+    switchBetweenSites: {
+        type: "toggle",
+        category: "hotkeys",
+        default: true,
+        reloadRequired: false,
+        enable: noop,
+    },
+    switchSitesHotkey: {
+        type: "hotkey",
+        category: "hotkeys",
+        default: {
+            code: "F9",
+            shift: false,
+            ctrl: false,
+            alt: false,
+        },
+        reloadRequired: false,
+        enable: noop,
+    },
+    likeDislikeHotkeys: {
+        type: "toggle",
+        category: "hotkeys",
+        default: true,
+        reloadRequired: false,
+        enable: noop,
+    },
+    likeHotkey: {
+        type: "hotkey",
+        category: "hotkeys",
+        default: {
+            code: "KeyL",
+            shift: false,
+            ctrl: false,
+            alt: true,
+        },
+        reloadRequired: false,
+        enable: noop,
+    },
+    dislikeHotkey: {
+        type: "hotkey",
+        category: "hotkeys",
+        default: {
+            code: "KeyL",
+            shift: false,
+            ctrl: true,
+            alt: true,
+        },
+        reloadRequired: false,
+        enable: noop,
+    },
     //#region cat:lyrics
     geniusLyrics: {
         type: "toggle",
@@ -6816,6 +6911,8 @@ const migrations = {
         "aboveQueueBtnsSticky", "autoScrollToActiveSongMode",
         "frameSkip", "frameSkipWhilePlaying",
         "frameSkipAmount", "watchPageFullSize",
+        "arrowKeyVolumeStep", "likeDislikeHotkeys",
+        "likeHotkey", "dislikeHotkey",
     ]), [
         { key: "lyricsCacheMaxSize", oldDefault: 2000 },
     ]),
@@ -8084,7 +8181,7 @@ async function onDomLoad() {
             if (feats.removeShareTrackingParamSites && (feats.removeShareTrackingParamSites === domain || feats.removeShareTrackingParamSites === "all"))
                 ftInit.push(["initRemShareTrackParam", initRemShareTrackParam()]);
             //#region (ytm+yt) input
-            ftInit.push(["siteSwitch", initSiteSwitch(domain)]);
+            ftInit.push(["hotkeys", initHotkeys()]);
             if (feats.autoLikeChannels)
                 ftInit.push(["autoLikeChannels", initAutoLike()]);
             //#region (ytm+yt) integrations