input.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  1. import { DataStore, clamp, compress, decompress } from "@sv443-network/userutils";
  2. import { error, getVideoTime, info, log, warn, getVideoSelector, getDomain, compressionSupported, t, clearNode, resourceToHTMLString } from "../utils";
  3. import type { Domain } from "../types";
  4. import { isCfgMenuOpen } from "../menu/menu_old";
  5. import { disableBeforeUnload } from "./behavior";
  6. import { siteEvents } from "../siteEvents";
  7. import { featInfo } from "./index";
  8. import { getFeature } from "../config";
  9. import { compressionFormat } from "../constants";
  10. import { addSelectorListener } from "../observers";
  11. import { createLongBtn, showIconToast } from "../components";
  12. import { getAutoLikeChannelsDialog, initAutoLikeChannelsStore } from "../dialogs";
  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=/))
  79. .join("&");
  80. const newSearch = typeof vt === "number" && vt > videoTimeThreshold ?
  81. cleanSearch.includes("?")
  82. ? `${cleanSearch.startsWith("?")
  83. ? cleanSearch
  84. : "?" + cleanSearch
  85. }&t=${vt}`
  86. : `?t=${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 = ["progress-bar", "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. if(
  110. document.activeElement !== document.body // short-circuit if nothing is active
  111. || numKeysIgnoreIds.includes(document.activeElement?.id ?? "") // video element or player bar active
  112. || numKeysIgnoreTagNames.includes(document.activeElement?.tagName ?? "") // other element active
  113. )
  114. return info("Captured valid key to skip video to, but ignored it since an unexpected element is active:", document.activeElement);
  115. const vidElem = document.querySelector<HTMLVideoElement>(getVideoSelector());
  116. if(!vidElem)
  117. return warn("Could not find video element, so the keypress is ignored");
  118. const newVidTime = vidElem.duration / (10 / Number(e.key));
  119. if(!isNaN(newVidTime)) {
  120. log(`Captured number key [${e.key}], skipping to ${Math.floor(newVidTime / 60)}m ${(newVidTime % 60).toFixed(1)}s`);
  121. vidElem.currentTime = newVidTime;
  122. }
  123. });
  124. log("Added number key press listener");
  125. }
  126. //#region auto-like channels
  127. let canCompress = false;
  128. /** DataStore instance for all auto-liked channels */
  129. export const autoLikeStore = new DataStore<{
  130. channels: {
  131. /** 24-character channel ID or user ID without the @ */
  132. id: string;
  133. /** Channel name (for display purposes only) */
  134. name: string;
  135. /** Whether the channel should be auto-liked */
  136. enabled: boolean;
  137. }[];
  138. }>({
  139. id: "bytm-auto-like-channels",
  140. formatVersion: 1,
  141. defaultData: {
  142. channels: [],
  143. },
  144. encodeData: (data) => canCompress ? compress(data, compressionFormat, "string") : data,
  145. decodeData: (data) => canCompress ? decompress(data, compressionFormat, "string") : data,
  146. // migrations: {},
  147. });
  148. export async function initAutoLikeChannels() {
  149. try {
  150. canCompress = await compressionSupported();
  151. await initAutoLikeChannelsStore();
  152. if(getDomain() === "ytm") {
  153. let timeout: NodeJS.Timeout;
  154. siteEvents.on("songTitleChanged", () => {
  155. timeout && clearTimeout(timeout);
  156. timeout = setTimeout(() => {
  157. // TODO: support multiple artists
  158. const artistEls = document.querySelectorAll<HTMLAnchorElement>("ytmusic-player-bar .content-info-wrapper .subtitle a.yt-formatted-string[href]");
  159. const channelIds = [...artistEls].map(a => a.href.split("/").pop()).filter(a => typeof a === "string") as string[];
  160. const likeChan = autoLikeStore.getData().channels.find((ch) => channelIds.includes(ch.id));
  161. if(!likeChan || !likeChan.enabled)
  162. return;
  163. if(artistEls.length === 0)
  164. return error("Couldn't auto-like channel because the artist element couldn't be found");
  165. const likeRenderer = document.querySelector<HTMLElement>(".middle-controls-buttons ytmusic-like-button-renderer");
  166. const likeBtn = likeRenderer?.querySelector<HTMLButtonElement>("#button-shape-like button");
  167. if(!likeRenderer || !likeBtn)
  168. return error("Couldn't auto-like channel because the like button couldn't be found");
  169. if(likeRenderer.getAttribute("like-status") !== "LIKE") {
  170. likeBtn.click();
  171. log(`Auto-liked channel '${likeChan.name}' (ID: '${likeChan.id}')`);
  172. }
  173. }, (getFeature("autoLikeTimeout") ?? 5) * 1000);
  174. });
  175. siteEvents.on("pathChanged", (path) => {
  176. if(getFeature("autoLikeChannelToggleBtn") && path.match(/\/channel\/.+/)) {
  177. const chanId = path.split("/").pop();
  178. if(!chanId)
  179. return error("Couldn't extract channel ID from URL");
  180. document.querySelectorAll<HTMLElement>(".bytm-auto-like-toggle-btn").forEach((btn) => clearNode(btn));
  181. addSelectorListener("browseResponse", "ytmusic-browse-response #header.ytmusic-browse-response", {
  182. listener(headerCont) {
  183. const buttonsCont = headerCont.querySelector<HTMLElement>(".buttons");
  184. if(buttonsCont) {
  185. const lastBtn = buttonsCont.querySelector<HTMLElement>("ytmusic-subscribe-button-renderer");
  186. const chanName = document.querySelector<HTMLElement>("ytmusic-immersive-header-renderer .content-container yt-formatted-string[role=\"heading\"]")?.textContent ?? null;
  187. lastBtn && addAutoLikeToggleBtn(lastBtn, chanId, chanName);
  188. }
  189. else {
  190. // some channels don't have a subscribe button and instead only have a "share" button for some bullshit reason
  191. // (this is only the case on YTM, on YT the subscribe button exists and works perfectly fine)
  192. const shareBtnEl = headerCont.querySelector<HTMLElement>("ytmusic-menu-renderer #top-level-buttons yt-button-renderer:last-of-type");
  193. const chanName = headerCont.querySelector<HTMLElement>("ytmusic-visual-header-renderer .content-container h2 yt-formatted-string")?.textContent ?? null;
  194. shareBtnEl && chanName && addAutoLikeToggleBtn(shareBtnEl, chanId, chanName);
  195. }
  196. }
  197. });
  198. }
  199. });
  200. }
  201. else if(getDomain() === "yt") {
  202. let timeout: NodeJS.Timeout;
  203. siteEvents.on("watchIdChanged", () => {
  204. timeout && clearTimeout(timeout);
  205. timeout = setTimeout(() => {
  206. addSelectorListener<HTMLAnchorElement, "yt">("watchMetadata", "#owner ytd-channel-name yt-formatted-string a", {
  207. listener(chanElem) {
  208. let chanId = chanElem.href.split("/").pop();
  209. if(chanId?.startsWith("@"))
  210. chanId = chanId.slice(1);
  211. const likeChan = autoLikeStore.getData().channels.find((ch) => ch.id === chanId);
  212. if(!likeChan || !likeChan.enabled)
  213. return;
  214. const likeBtn = document.querySelector<HTMLButtonElement>("#actions ytd-menu-renderer like-button-view-model button");
  215. if(!likeBtn)
  216. return error("Couldn't auto-like channel because the like button couldn't be found");
  217. if(likeBtn.getAttribute("aria-pressed") !== "true") {
  218. likeBtn.click();
  219. showIconToast({
  220. message: t("auto_liked_video"),
  221. icon: "icon-auto_like",
  222. });
  223. log(`Auto-liked channel '${likeChan.name}' (ID: '${likeChan.id}')`);
  224. }
  225. }
  226. });
  227. }, (getFeature("autoLikeTimeout") ?? 5) * 1000);
  228. });
  229. siteEvents.on("pathChanged", (path) => {
  230. if(path.match(/(\/?@|\/channel\/).+/)) {
  231. const chanId = path.split("/").pop()?.replace(/@/g, "");
  232. if(!chanId)
  233. return error("Couldn't extract channel ID from URL");
  234. document.querySelectorAll<HTMLElement>(".bytm-auto-like-toggle-btn").forEach((btn) => clearNode(btn));
  235. addSelectorListener<0, "yt">("ytChannelHeader", "#channel-header-container", {
  236. listener(headerCont) {
  237. const titleCont = headerCont.querySelector<HTMLElement>("ytd-channel-name #container");
  238. if(!titleCont)
  239. return;
  240. const chanName = titleCont.querySelector<HTMLElement>("yt-formatted-string")?.textContent ?? null;
  241. const buttonsCont = headerCont.querySelector<HTMLElement>("#inner-header-container #buttons");
  242. if(buttonsCont) {
  243. addSelectorListener<0, "yt">("ytChannelHeader", "#channel-header-container #other-buttons", {
  244. listener(otherBtns) {
  245. addAutoLikeToggleBtn(otherBtns, chanId, chanName, ["left-margin"]);
  246. }
  247. });
  248. }
  249. else if(titleCont)
  250. addAutoLikeToggleBtn(titleCont, chanId, chanName);
  251. }
  252. });
  253. }
  254. });
  255. }
  256. log("Initialized auto-like channels feature");
  257. }
  258. catch(err) {
  259. error("Error while auto-liking channel:", err);
  260. }
  261. }
  262. async function addAutoLikeToggleBtn(siblingEl: HTMLElement, channelId: string, channelName: string | null, extraClasses?: string[]) {
  263. const chan = autoLikeStore.getData().channels.find((ch) => ch.id === channelId);
  264. const buttonEl = await createLongBtn({
  265. resourceName: `icon-auto_like${chan?.enabled ? "_enabled" : ""}`,
  266. text: t("auto_like"),
  267. title: t(`auto_like_button_tooltip${chan?.enabled ? "_enabled" : "_disabled"}`),
  268. toggle: true,
  269. toggleInitialState: chan?.enabled ?? false,
  270. async onToggle(toggled, evt) {
  271. if(evt.shiftKey) {
  272. buttonEl.classList.toggle("toggled");
  273. getAutoLikeChannelsDialog().then((dlg) => dlg.open());
  274. return;
  275. }
  276. buttonEl.title = buttonEl.ariaLabel = t(`auto_like_button_tooltip${toggled ? "_enabled" : "_disabled"}`);
  277. const chanId = buttonEl.dataset.channelId ?? channelId;
  278. const imgEl = buttonEl.querySelector<HTMLElement>(".bytm-generic-btn-img");
  279. const imgHtml = await resourceToHTMLString(`icon-auto_like${toggled ? "_enabled" : ""}`);
  280. if(imgEl && imgHtml)
  281. imgEl.innerHTML = imgHtml;
  282. showIconToast({
  283. message: toggled ? t("auto_like_enabled_toast") : t("auto_like_disabled_toast"),
  284. icon: `icon-auto_like${toggled ? "_enabled" : ""}`,
  285. });
  286. if(autoLikeStore.getData().channels.find((ch) => ch.id === chanId) === undefined) {
  287. await autoLikeStore.setData({
  288. channels: [
  289. ...autoLikeStore.getData().channels,
  290. { id: chanId, name: channelName ?? "", enabled: toggled },
  291. ],
  292. });
  293. }
  294. else {
  295. await autoLikeStore.setData({
  296. channels: autoLikeStore.getData().channels
  297. .map((ch) => ch.id === chanId ? { ...ch, enabled: toggled } : ch),
  298. });
  299. }
  300. }
  301. });
  302. buttonEl.classList.add(...["bytm-auto-like-toggle-btn", ...(extraClasses ?? [])]);
  303. buttonEl.dataset.channelId = channelId;
  304. siblingEl.insertAdjacentElement("afterend", buttonEl);
  305. }