input.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408
  1. import { DataStore, clamp, compress, decompress } from "@sv443-network/userutils";
  2. import { error, getVideoTime, info, log, warn, getVideoSelector, getDomain, compressionSupported, t, clearNode, resourceToHTMLString, getCurrentChannelId } from "../utils/index.js";
  3. import type { Domain } from "../types.js";
  4. import { isCfgMenuOpen } from "../menu/menu_old.js";
  5. import { disableBeforeUnload } from "./behavior.js";
  6. import { siteEvents } from "../siteEvents.js";
  7. import { featInfo } from "./index.js";
  8. import { getFeature } from "../config.js";
  9. import { compressionFormat } from "../constants.js";
  10. import { addSelectorListener } from "../observers.js";
  11. import { createLongBtn, showIconToast } from "../components/index.js";
  12. import { getAutoLikeDialog } from "../dialogs/index.js";
  13. import "./input.css";
  14. export const inputIgnoreTagNames = ["INPUT", "TEXTAREA", "SELECT", "BUTTON", "A"];
  15. //#region arrow key skip
  16. export async function initArrowKeySkip() {
  17. document.addEventListener("keydown", (evt) => {
  18. if(!getFeature("arrowKeySupport"))
  19. return;
  20. if(!["ArrowLeft", "ArrowRight"].includes(evt.code))
  21. return;
  22. const allowedClasses = ["bytm-generic-btn", "yt-spec-button-shape-next"];
  23. // discard the event when a (text) input is currently active, like when editing a playlist
  24. if(
  25. (inputIgnoreTagNames.includes(document.activeElement?.tagName ?? "") || ["volume-slider"].includes(document.activeElement?.id ?? ""))
  26. && !allowedClasses.some((cls) => document.activeElement?.classList.contains(cls))
  27. )
  28. 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`);
  29. evt.preventDefault();
  30. evt.stopImmediatePropagation();
  31. let skipBy = getFeature("arrowKeySkipBy") ?? featInfo.arrowKeySkipBy.default;
  32. if(evt.code === "ArrowLeft")
  33. skipBy *= -1;
  34. log(`Captured arrow key '${evt.code}' - skipping by ${skipBy} seconds`);
  35. const vidElem = document.querySelector<HTMLVideoElement>(getVideoSelector());
  36. if(vidElem)
  37. vidElem.currentTime = clamp(vidElem.currentTime + skipBy, 0, vidElem.duration);
  38. });
  39. log("Added arrow key press listener");
  40. }
  41. //#region site switch
  42. /** switch sites only if current video time is greater than this value */
  43. const videoTimeThreshold = 3;
  44. let siteSwitchEnabled = true;
  45. /** Initializes the site switch feature */
  46. export async function initSiteSwitch(domain: Domain) {
  47. document.addEventListener("keydown", (e) => {
  48. if(!getFeature("switchBetweenSites"))
  49. return;
  50. const hk = getFeature("switchSitesHotkey");
  51. if(siteSwitchEnabled && e.code === hk.code && e.shiftKey === hk.shift && e.ctrlKey === hk.ctrl && e.altKey === hk.alt)
  52. switchSite(domain === "yt" ? "ytm" : "yt");
  53. });
  54. siteEvents.on("hotkeyInputActive", (state) => {
  55. if(!getFeature("switchBetweenSites"))
  56. return;
  57. siteSwitchEnabled = !state;
  58. });
  59. log("Initialized site switch listener");
  60. }
  61. /** Switches to the other site (between YT and YTM) */
  62. async function switchSite(newDomain: Domain) {
  63. try {
  64. if(!(["/watch", "/playlist"].some(v => location.pathname.startsWith(v))))
  65. return warn("Not on a supported page, so the site switch is ignored");
  66. let subdomain;
  67. if(newDomain === "ytm")
  68. subdomain = "music";
  69. else if(newDomain === "yt")
  70. subdomain = "www";
  71. if(!subdomain)
  72. throw new Error(`Unrecognized domain '${newDomain}'`);
  73. disableBeforeUnload();
  74. const { pathname, search, hash } = new URL(location.href);
  75. const vt = await getVideoTime(0);
  76. log(`Found video time of ${vt} seconds`);
  77. const cleanSearch = search.split("&")
  78. .filter((param) => !param.match(/^\??(t|time_continue)=/))
  79. .join("&");
  80. const newSearch = typeof vt === "number" && vt > videoTimeThreshold ?
  81. cleanSearch.includes("?")
  82. ? `${cleanSearch.startsWith("?")
  83. ? cleanSearch
  84. : "?" + cleanSearch
  85. }&time_continue=${vt}`
  86. : `?time_continue=${vt}`
  87. : cleanSearch;
  88. const newUrl = `https://${subdomain}.youtube.com${pathname}${newSearch}${hash}`;
  89. info(`Switching to domain '${newDomain}' at ${newUrl}`);
  90. location.assign(newUrl);
  91. }
  92. catch(err) {
  93. error("Error while switching site:", err);
  94. }
  95. }
  96. //#region num keys skip
  97. const numKeysIgnoreTagNames = [...inputIgnoreTagNames, "TP-YT-PAPER-TAB"];
  98. const numKeysIgnoreIds = ["song-media-window"];
  99. /** Adds the ability to skip to a certain time in the video by pressing a number key (0-9) */
  100. export async function initNumKeysSkip() {
  101. document.addEventListener("keydown", (e) => {
  102. if(!getFeature("numKeysSkipToTime"))
  103. return;
  104. if(!e.key.trim().match(/^[0-9]$/))
  105. return;
  106. if(isCfgMenuOpen)
  107. return;
  108. // 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
  109. const ignoreElement = numKeysIgnoreIds.includes(document.activeElement?.id ?? "") // video element or player bar active
  110. || numKeysIgnoreTagNames.includes(document.activeElement?.tagName ?? ""); // other element active
  111. if((document.activeElement !== document.body && ignoreElement) || ignoreElement)
  112. return info("Captured valid key to skip video to, but ignored it since an unexpected element is active:", document.activeElement);
  113. const vidElem = document.querySelector<HTMLVideoElement>(getVideoSelector());
  114. if(!vidElem)
  115. return warn("Could not find video element, so the keypress is ignored");
  116. const newVidTime = vidElem.duration / (10 / Number(e.key));
  117. if(!isNaN(newVidTime)) {
  118. log(`Captured number key [${e.key}], skipping to ${Math.floor(newVidTime / 60)}m ${(newVidTime % 60).toFixed(1)}s`);
  119. vidElem.currentTime = newVidTime;
  120. }
  121. });
  122. log("Added number key press listener");
  123. }
  124. //#region auto-like channels
  125. let canCompress = false;
  126. type AutoLikeData = {
  127. channels: {
  128. /** 24-character channel ID or user ID including the @ prefix */
  129. id: string;
  130. /** Channel name (for display purposes only) */
  131. name: string;
  132. /** Whether the channel should be auto-liked */
  133. enabled: boolean;
  134. }[];
  135. };
  136. /** DataStore instance for all auto-liked channels */
  137. export const autoLikeStore = new DataStore<AutoLikeData>({
  138. id: "bytm-auto-like-channels",
  139. formatVersion: 2,
  140. defaultData: {
  141. channels: [],
  142. },
  143. encodeData: (data) => canCompress ? compress(data, compressionFormat, "string") : data,
  144. decodeData: (data) => canCompress ? decompress(data, compressionFormat, "string") : data,
  145. migrations: {
  146. // 1 -> 2 (v2.1-pre) - add @ prefix to channel IDs if missing
  147. 2: (oldData: AutoLikeData) => ({
  148. channels: oldData.channels.map((ch) => ({
  149. ...ch,
  150. id: ch.id.trim().match(/^(UC|@).+$/)
  151. ? ch.id.trim()
  152. : `@${ch.id.trim()}`,
  153. })),
  154. }),
  155. },
  156. });
  157. let autoLikeStoreLoaded = false;
  158. /** Inits the auto-like DataStore instance */
  159. export function initAutoLikeStore() {
  160. if(autoLikeStoreLoaded)
  161. return;
  162. autoLikeStoreLoaded = true;
  163. return autoLikeStore.loadData();
  164. }
  165. /** Initializes the auto-like feature */
  166. export async function initAutoLike() {
  167. try {
  168. canCompress = await compressionSupported();
  169. await initAutoLikeStore();
  170. if(getDomain() === "ytm") {
  171. let timeout: NodeJS.Timeout;
  172. siteEvents.on("songTitleChanged", () => {
  173. timeout && clearTimeout(timeout);
  174. timeout = setTimeout(() => {
  175. const artistEls = document.querySelectorAll<HTMLAnchorElement>("ytmusic-player-bar .content-info-wrapper .subtitle a.yt-formatted-string[href]");
  176. const channelIds = [...artistEls].map(a => a.href.split("/").pop()).filter(a => typeof a === "string") as string[];
  177. const likeChan = autoLikeStore.getData().channels.find((ch) => channelIds.includes(ch.id));
  178. if(!likeChan || !likeChan.enabled)
  179. return;
  180. if(artistEls.length === 0)
  181. return error("Couldn't auto-like channel because the artist element couldn't be found");
  182. const likeRenderer = document.querySelector<HTMLElement>(".middle-controls-buttons ytmusic-like-button-renderer");
  183. const likeBtn = likeRenderer?.querySelector<HTMLButtonElement>("#button-shape-like button");
  184. if(!likeRenderer || !likeBtn)
  185. return error("Couldn't auto-like channel because the like button couldn't be found");
  186. if(likeRenderer.getAttribute("like-status") !== "LIKE") {
  187. likeBtn.click();
  188. getFeature("autoLikeShowToast") && showIconToast({
  189. message: t("auto_liked_channel", likeChan.name),
  190. icon: "icon-auto_like",
  191. });
  192. log(`Auto-liked channel '${likeChan.name}' (ID: '${likeChan.id}')`);
  193. }
  194. }, (getFeature("autoLikeTimeout") ?? 5) * 1000);
  195. });
  196. siteEvents.on("pathChanged", (path) => {
  197. if(getFeature("autoLikeChannelToggleBtn") && path.match(/\/channel\/.+/)) {
  198. const chanId = getCurrentChannelId();
  199. if(!chanId)
  200. return error("Couldn't extract channel ID from URL");
  201. document.querySelectorAll<HTMLElement>(".bytm-auto-like-toggle-btn").forEach((btn) => clearNode(btn));
  202. addSelectorListener("browseResponse", "ytmusic-browse-response #header.ytmusic-browse-response", {
  203. listener(headerCont) {
  204. const buttonsCont = headerCont.querySelector<HTMLElement>(".buttons");
  205. if(buttonsCont) {
  206. const lastBtn = buttonsCont.querySelector<HTMLElement>("ytmusic-subscribe-button-renderer");
  207. const chanName = document.querySelector<HTMLElement>("ytmusic-immersive-header-renderer .content-container yt-formatted-string[role=\"heading\"]")?.textContent ?? null;
  208. lastBtn && addAutoLikeToggleBtn(lastBtn, chanId, chanName);
  209. }
  210. else {
  211. // some channels don't have a subscribe button and instead only have a "share" button for some bullshit reason
  212. const shareBtnEl = headerCont.querySelector<HTMLElement>("ytmusic-menu-renderer #top-level-buttons yt-button-renderer:last-of-type");
  213. const chanName = headerCont.querySelector<HTMLElement>("ytmusic-visual-header-renderer .content-container h2 yt-formatted-string")?.textContent ?? null;
  214. shareBtnEl && chanName && addAutoLikeToggleBtn(shareBtnEl, chanId, chanName);
  215. }
  216. }
  217. });
  218. }
  219. });
  220. }
  221. else if(getDomain() === "yt") {
  222. let timeout: NodeJS.Timeout;
  223. siteEvents.on("watchIdChanged", () => {
  224. timeout && clearTimeout(timeout);
  225. if(!location.pathname.startsWith("/watch"))
  226. return;
  227. timeout = setTimeout(() => {
  228. addSelectorListener<HTMLAnchorElement, "yt">("ytWatchMetadata", "#owner ytd-channel-name yt-formatted-string a", {
  229. listener(chanElem) {
  230. const chanElemId = chanElem.href.split("/").pop()?.split("/")[0] ?? null;
  231. const likeChan = autoLikeStore.getData().channels.find((ch) => ch.id === chanElemId);
  232. if(!likeChan || !likeChan.enabled)
  233. return;
  234. addSelectorListener<0, "yt">("ytWatchMetadata", "#actions ytd-menu-renderer like-button-view-model button", {
  235. listener(likeBtn) {
  236. if(likeBtn.getAttribute("aria-pressed") !== "true") {
  237. likeBtn.click();
  238. getFeature("autoLikeShowToast") && showIconToast({
  239. message: t("auto_liked_channel", likeChan.name),
  240. icon: "icon-auto_like",
  241. });
  242. log(`Auto-liked channel '${likeChan.name}' (ID: '${likeChan.id}')`);
  243. }
  244. }
  245. });
  246. }
  247. });
  248. }, (getFeature("autoLikeTimeout") ?? 5) * 1000);
  249. });
  250. siteEvents.on("pathChanged", (path) => {
  251. if(path.match(/(\/?@|\/channel\/).+/)) {
  252. const chanId = getCurrentChannelId();
  253. if(!chanId)
  254. return error("Couldn't extract channel ID from URL");
  255. document.querySelectorAll<HTMLElement>(".bytm-auto-like-toggle-btn").forEach((btn) => clearNode(btn));
  256. addSelectorListener<0, "yt">("ytChannelHeader", "#channel-header-container", {
  257. listener(headerCont) {
  258. const titleCont = headerCont.querySelector<HTMLElement>("ytd-channel-name #container");
  259. if(!titleCont)
  260. return;
  261. const chanName = titleCont.querySelector<HTMLElement>("yt-formatted-string")?.textContent ?? null;
  262. const buttonsCont = headerCont.querySelector<HTMLElement>("#inner-header-container #buttons");
  263. if(buttonsCont) {
  264. addSelectorListener<0, "yt">("ytChannelHeader", "#channel-header-container #other-buttons", {
  265. listener(otherBtns) {
  266. addAutoLikeToggleBtn(otherBtns, chanId, chanName, ["left-margin"]);
  267. }
  268. });
  269. }
  270. else if(titleCont)
  271. addAutoLikeToggleBtn(titleCont, chanId, chanName);
  272. }
  273. });
  274. }
  275. });
  276. }
  277. log("Initialized auto-like channels feature");
  278. }
  279. catch(err) {
  280. error("Error while auto-liking channel:", err);
  281. }
  282. }
  283. async function addAutoLikeToggleBtn(siblingEl: HTMLElement, channelId: string, channelName: string | null, extraClasses?: string[]) {
  284. const chan = autoLikeStore.getData().channels.find((ch) => ch.id === channelId);
  285. siteEvents.on("autoLikeChannelsUpdated", () => {
  286. const buttonEl = document.querySelector<HTMLElement>(`.bytm-auto-like-toggle-btn[data-channel-id="${channelId}"]`);
  287. if(!buttonEl)
  288. return warn("Couldn't find auto-like toggle button for channel ID:", channelId);
  289. const enabled = autoLikeStore.getData().channels.find((ch) => ch.id === channelId)?.enabled ?? false;
  290. if(enabled)
  291. buttonEl.classList.add("toggled");
  292. else
  293. buttonEl.classList.remove("toggled");
  294. });
  295. const buttonEl = await createLongBtn({
  296. resourceName: `icon-auto_like${chan?.enabled ? "_enabled" : ""}`,
  297. text: t("auto_like"),
  298. title: t(`auto_like_button_tooltip${chan?.enabled ? "_enabled" : "_disabled"}`),
  299. toggle: true,
  300. toggleInitialState: chan?.enabled ?? false,
  301. async onToggle(toggled, evt) {
  302. try {
  303. await autoLikeStore.loadData();
  304. if(evt.shiftKey) {
  305. buttonEl.classList.toggle("toggled");
  306. getAutoLikeDialog().then((dlg) => dlg.open());
  307. return;
  308. }
  309. buttonEl.title = buttonEl.ariaLabel = t(`auto_like_button_tooltip${toggled ? "_enabled" : "_disabled"}`);
  310. const chanId = buttonEl.dataset.channelId ?? channelId;
  311. const imgEl = buttonEl.querySelector<HTMLElement>(".bytm-generic-btn-img");
  312. const imgHtml = await resourceToHTMLString(`icon-auto_like${toggled ? "_enabled" : ""}`);
  313. if(imgEl && imgHtml)
  314. imgEl.innerHTML = imgHtml;
  315. if(autoLikeStore.getData().channels.find((ch) => ch.id === chanId) === undefined) {
  316. await autoLikeStore.setData({
  317. channels: [
  318. ...autoLikeStore.getData().channels,
  319. { id: chanId, name: channelName ?? "", enabled: toggled },
  320. ],
  321. });
  322. }
  323. else {
  324. await autoLikeStore.setData({
  325. channels: autoLikeStore.getData().channels
  326. .map((ch) => ch.id === chanId ? { ...ch, enabled: toggled } : ch),
  327. });
  328. }
  329. siteEvents.emit("autoLikeChannelsUpdated");
  330. showIconToast({
  331. message: toggled ? t("auto_like_enabled_toast") : t("auto_like_disabled_toast"),
  332. icon: `icon-auto_like${toggled ? "_enabled" : ""}`,
  333. });
  334. log(`Toggled auto-like for channel '${channelName}' (ID: '${chanId}') to ${toggled ? "enabled" : "disabled"}`);
  335. }
  336. catch(err) {
  337. error("Error while toggling auto-like channel:", err);
  338. }
  339. }
  340. });
  341. buttonEl.classList.add(...["bytm-auto-like-toggle-btn", ...(extraClasses ?? [])]);
  342. buttonEl.dataset.channelId = channelId;
  343. siblingEl.insertAdjacentElement("afterend", buttonEl);
  344. }