input.ts 16 KB

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