123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403 |
- import { DataStore, clamp, compress, decompress } from "@sv443-network/userutils";
- import { error, getVideoTime, info, log, warn, getVideoSelector, getDomain, compressionSupported, t, clearNode, resourceToHTMLString, getCurrentChannelId, currentMediaType } from "../utils/index.js";
- import type { Domain } from "../types.js";
- import { disableBeforeUnload } from "./behavior.js";
- import { siteEvents } from "../siteEvents.js";
- import { featInfo } from "./index.js";
- import { getFeature } from "../config.js";
- import { compressionFormat } from "../constants.js";
- import { addSelectorListener } from "../observers.js";
- import { createLongBtn, showIconToast } from "../components/index.js";
- import { getAutoLikeDialog } from "../dialogs/index.js";
- import "./input.css";
- export const inputIgnoreTagNames = ["INPUT", "TEXTAREA", "SELECT", "BUTTON", "A", "TP-YT-PAPER-SLIDER"];
- //#region arrow key skip
- export async function initArrowKeySkip() {
- document.addEventListener("keydown", (evt) => {
- 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(document.activeElement?.tagName ?? "") || ["volume-slider"].includes(document.activeElement?.id ?? ""))
- && !allowedClasses.some((cls) => document.activeElement?.classList.contains(cls))
- )
- return info(`Captured valid key to skip forward or backward but the current active element is <${document.activeElement?.tagName.toLowerCase()}>, so the keypress is ignored`);
- evt.preventDefault();
- evt.stopImmediatePropagation();
- let skipBy = getFeature("arrowKeySkipBy") ?? featInfo.arrowKeySkipBy.default;
- if(evt.code === "ArrowLeft")
- skipBy *= -1;
- log(`Captured arrow key '${evt.code}' - skipping by ${skipBy} seconds`);
-
- const vidElem = document.querySelector<HTMLVideoElement>(getVideoSelector());
-
- if(vidElem)
- vidElem.currentTime = 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 */
- export async function initSiteSwitch(domain: Domain) {
- document.addEventListener("keydown", (e) => {
- if(!getFeature("switchBetweenSites"))
- 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: Domain) {
- 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);
- }
- }
- //#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) */
- export async function initNumKeysSkip() {
- document.addEventListener("keydown", (e) => {
- if(!getFeature("numKeysSkipToTime"))
- return;
- 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(document.activeElement?.tagName ?? "");
- 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 = document.querySelector<HTMLVideoElement>(getVideoSelector());
- 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 = false;
- export type AutoLikeData = {
- channels: {
- /** 24-character channel ID or user ID including the @ prefix */
- id: string;
- /** Channel name (for display purposes only) */
- name: string;
- /** Whether the channel should be auto-liked */
- enabled: boolean;
- }[];
- };
- /** DataStore instance for all auto-liked channels */
- export const autoLikeStore = new DataStore<AutoLikeData>({
- id: "bytm-auto-like-channels",
- formatVersion: 2,
- defaultData: {
- channels: [],
- },
- encodeData: (data) => canCompress ? compress(data, compressionFormat, "string") : data,
- decodeData: (data) => canCompress ? decompress(data, compressionFormat, "string") : data,
- migrations: {
- // 1 -> 2 (v2.1-pre) - add @ prefix to channel IDs if missing
- 2: (oldData: AutoLikeData) => ({
- channels: oldData.channels.map((ch) => ({
- ...ch,
- id: ch.id.trim().match(/^(UC|@).+$/)
- ? ch.id.trim()
- : `@${ch.id.trim()}`,
- })),
- }),
- },
- });
- let autoLikeStoreLoaded = false;
- /** Inits the auto-like DataStore instance */
- export function initAutoLikeStore() {
- if(autoLikeStoreLoaded)
- return;
- autoLikeStoreLoaded = true;
- return autoLikeStore.loadData();
- }
- /** Initializes the auto-like feature */
- export async function initAutoLike() {
- try {
- canCompress = await compressionSupported();
- await initAutoLikeStore();
- if(getDomain() === "ytm") {
- let timeout: NodeJS.Timeout;
- siteEvents.on("songTitleChanged", () => {
- timeout && clearTimeout(timeout);
- timeout = setTimeout(() => {
- const artistEls = document.querySelectorAll<HTMLAnchorElement>("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") as 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<HTMLElement>(".middle-controls-buttons ytmusic-like-button-renderer");
- const likeBtnEl = likeRendererEl?.querySelector<HTMLButtonElement>("#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_${currentMediaType()}`, likeChan.name),
- icon: "icon-auto_like",
- });
- log(`Auto-liked ${currentMediaType()} from channel '${likeChan.name}' (${likeChan.id})`);
- }
- }, (getFeature("autoLikeTimeout") ?? 5) * 1000);
- });
- 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<HTMLElement>(".bytm-auto-like-toggle-btn").forEach((btn) => clearNode(btn));
- addSelectorListener("browseResponse", "ytmusic-browse-response #header.ytmusic-browse-response", {
- listener(headerCont) {
- const buttonsCont = headerCont.querySelector<HTMLElement>(".buttons");
- if(buttonsCont) {
- const lastBtn = buttonsCont.querySelector<HTMLElement>("ytmusic-subscribe-button-renderer");
- const chanName = document.querySelector<HTMLElement>("ytmusic-immersive-header-renderer .content-container yt-formatted-string[role=\"heading\"]")?.textContent ?? null;
- lastBtn && addAutoLikeToggleBtn(lastBtn, chanId, chanName);
- }
- else {
- // some channels don't have a subscribe button and instead only have a "share" button for some bullshit reason
- const shareBtnEl = headerCont.querySelector<HTMLElement>("ytmusic-menu-renderer #top-level-buttons yt-button-renderer:last-of-type");
- const chanName = headerCont.querySelector<HTMLElement>("ytmusic-visual-header-renderer .content-container h2 yt-formatted-string")?.textContent ?? null;
- shareBtnEl && chanName && addAutoLikeToggleBtn(shareBtnEl, chanId, chanName);
- }
- }
- });
- }
- });
- }
- else if(getDomain() === "yt") {
- let timeout: NodeJS.Timeout;
- siteEvents.on("watchIdChanged", () => {
- timeout && clearTimeout(timeout);
- if(!location.pathname.startsWith("/watch"))
- return;
- timeout = setTimeout(() => {
- addSelectorListener<HTMLAnchorElement, "yt">("ytWatchMetadata", "#owner ytd-channel-name yt-formatted-string a", {
- listener(chanElem) {
- const chanElemId = chanElem.href.split("/").pop()?.split("/")[0] ?? null;
- const likeChan = autoLikeStore.getData().channels.find((ch) => ch.id === chanElemId);
- if(!likeChan || !likeChan.enabled)
- return;
- addSelectorListener<0, "yt">("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_${currentMediaType()}`, likeChan.name),
- icon: "icon-auto_like",
- });
- log(`Auto-liked ${currentMediaType()} from channel '${likeChan.name}' (${likeChan.id})`);
- }
- }
- });
- }
- });
- }, (getFeature("autoLikeTimeout") ?? 5) * 1000);
- });
- siteEvents.on("pathChanged", (path) => {
- if(path.match(/(\/?@|\/channel\/).+/)) {
- const chanId = getCurrentChannelId();
- if(!chanId)
- return error("Couldn't extract channel ID from URL");
- document.querySelectorAll<HTMLElement>(".bytm-auto-like-toggle-btn").forEach((btn) => clearNode(btn));
- addSelectorListener<0, "yt">("ytChannelHeader", "#channel-header-container", {
- listener(headerCont) {
- const titleCont = headerCont.querySelector<HTMLElement>("ytd-channel-name #container");
- if(!titleCont)
- return;
- const chanName = titleCont.querySelector<HTMLElement>("yt-formatted-string")?.textContent ?? null;
- const buttonsCont = headerCont.querySelector<HTMLElement>("#inner-header-container #buttons");
- if(buttonsCont) {
- addSelectorListener<0, "yt">("ytChannelHeader", "#channel-header-container #other-buttons", {
- listener(otherBtns) {
- addAutoLikeToggleBtn(otherBtns, chanId, chanName, ["left-margin"]);
- }
- });
- }
- else if(titleCont)
- addAutoLikeToggleBtn(titleCont, chanId, chanName);
- }
- });
- }
- });
- }
- log("Initialized auto-like channels feature");
- }
- catch(err) {
- error("Error while auto-liking channel:", err);
- }
- }
- async function addAutoLikeToggleBtn(siblingEl: HTMLElement, channelId: string, channelName: string | null, extraClasses?: string[]) {
- const chan = autoLikeStore.getData().channels.find((ch) => ch.id === channelId);
- siteEvents.on("autoLikeChannelsUpdated", () => {
- const buttonEl = document.querySelector<HTMLElement>(`.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 = autoLikeStore.getData().channels.find((ch) => ch.id === channelId)?.enabled ?? false;
- if(enabled)
- buttonEl.classList.add("toggled");
- else
- buttonEl.classList.remove("toggled");
- });
- const buttonEl = await createLongBtn({
- resourceName: `icon-auto_like${chan?.enabled ? "_enabled" : ""}`,
- text: t("auto_like"),
- title: t(`auto_like_button_tooltip${chan?.enabled ? "_enabled" : "_disabled"}`),
- toggle: true,
- toggleInitialState: chan?.enabled ?? false,
- async onToggle(toggled, evt) {
- try {
- await autoLikeStore.loadData();
- if(evt.shiftKey) {
- buttonEl.classList.toggle("toggled");
- getAutoLikeDialog().then((dlg) => dlg.open());
- return;
- }
- buttonEl.title = buttonEl.ariaLabel = t(`auto_like_button_tooltip${toggled ? "_enabled" : "_disabled"}`);
- const chanId = buttonEl.dataset.channelId ?? channelId;
- const imgEl = buttonEl.querySelector<HTMLElement>(".bytm-generic-btn-img");
- const imgHtml = await resourceToHTMLString(`icon-auto_like${toggled ? "_enabled" : ""}`);
- if(imgEl && imgHtml)
- imgEl.innerHTML = imgHtml;
- if(autoLikeStore.getData().channels.find((ch) => ch.id === chanId) === undefined) {
- await autoLikeStore.setData({
- channels: [
- ...autoLikeStore.getData().channels,
- { id: chanId, name: channelName ?? "", enabled: toggled },
- ],
- });
- }
- else {
- await autoLikeStore.setData({
- channels: autoLikeStore.getData().channels
- .map((ch) => ch.id === chanId ? { ...ch, enabled: toggled } : ch),
- });
- }
- siteEvents.emit("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);
- }
- }
- });
- buttonEl.classList.add(...["bytm-auto-like-toggle-btn", ...(extraClasses ?? [])]);
- buttonEl.dataset.channelId = channelId;
- siblingEl.insertAdjacentElement("afterend", buttonEl);
- }
|