input.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422
  1. import { DataStore, clamp, compress, decompress } from "@sv443-network/userutils";
  2. import { error, getVideoTime, info, log, warn, getDomain, compressionSupported, t, clearNode, resourceAsString, getCurrentChannelId, currentMediaType, sanitizeChannelId, addStyleFromResource, isValidChannelId, getVideoElement } from "../utils/index.js";
  3. import type { AutoLikeData, Domain } from "../types.js";
  4. import { disableBeforeUnload } from "./behavior.js";
  5. import { emitSiteEvent, 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, createRipple, 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 = getVideoElement();
  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. if(inputIgnoreTagNames.includes(document.activeElement?.tagName ?? ""))
  50. return;
  51. const hk = getFeature("switchSitesHotkey");
  52. if(siteSwitchEnabled && e.code === hk.code && e.shiftKey === hk.shift && e.ctrlKey === hk.ctrl && e.altKey === hk.alt)
  53. switchSite(domain === "yt" ? "ytm" : "yt");
  54. });
  55. siteEvents.on("hotkeyInputActive", (state) => {
  56. if(!getFeature("switchBetweenSites"))
  57. return;
  58. siteSwitchEnabled = !state;
  59. });
  60. log("Initialized site switch listener");
  61. }
  62. /** Switches to the other site (between YT and YTM) */
  63. async function switchSite(newDomain: Domain) {
  64. try {
  65. if(!(["/watch", "/playlist"].some(v => location.pathname.startsWith(v))))
  66. return warn("Not on a supported page, so the site switch is ignored");
  67. let subdomain;
  68. if(newDomain === "ytm")
  69. subdomain = "music";
  70. else if(newDomain === "yt")
  71. subdomain = "www";
  72. if(!subdomain)
  73. throw new Error(`Unrecognized domain '${newDomain}'`);
  74. disableBeforeUnload();
  75. const { pathname, search, hash } = new URL(location.href);
  76. const vt = await getVideoTime(0);
  77. log(`Found video time of ${vt} seconds`);
  78. const cleanSearch = search.split("&")
  79. .filter((param) => !param.match(/^\??(t|time_continue)=/))
  80. .join("&");
  81. const newSearch = typeof vt === "number" && vt > videoTimeThreshold ?
  82. cleanSearch.includes("?")
  83. ? `${cleanSearch.startsWith("?")
  84. ? cleanSearch
  85. : "?" + cleanSearch
  86. }&time_continue=${vt}`
  87. : `?time_continue=${vt}`
  88. : cleanSearch;
  89. const newUrl = `https://${subdomain}.youtube.com${pathname}${newSearch}${hash}`;
  90. info(`Switching to domain '${newDomain}' at ${newUrl}`);
  91. location.assign(newUrl);
  92. }
  93. catch(err) {
  94. error("Error while switching site:", err);
  95. }
  96. }
  97. //#region num keys skip
  98. const numKeysIgnoreTagNames = [...inputIgnoreTagNames];
  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. // 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
  107. const ignoreElement = numKeysIgnoreTagNames.includes(document.activeElement?.tagName ?? "");
  108. if((document.activeElement !== document.body && ignoreElement) || ignoreElement)
  109. return info("Captured valid key to skip video to, but ignored it since this element is currently active:", document.activeElement);
  110. const vidElem = getVideoElement();
  111. if(!vidElem)
  112. return warn("Could not find video element, so the keypress is ignored");
  113. const newVidTime = vidElem.duration / (10 / Number(e.key));
  114. if(!isNaN(newVidTime)) {
  115. log(`Captured number key [${e.key}], skipping to ${Math.floor(newVidTime / 60)}m ${(newVidTime % 60).toFixed(1)}s`);
  116. vidElem.currentTime = newVidTime;
  117. }
  118. });
  119. log("Added number key press listener");
  120. }
  121. //#region auto-like vids
  122. let canCompress = false;
  123. /** DataStore instance for all auto-liked channels */
  124. export const autoLikeStore = new DataStore<AutoLikeData>({
  125. id: "bytm-auto-like-channels",
  126. formatVersion: 2,
  127. defaultData: {
  128. channels: [],
  129. },
  130. encodeData: (data) => canCompress ? compress(data, compressionFormat, "string") : data,
  131. decodeData: (data) => canCompress ? decompress(data, compressionFormat, "string") : data,
  132. migrations: {
  133. // 1 -> 2 (v2.1-pre) - add @ prefix to channel IDs if missing
  134. 2: (oldData: AutoLikeData) => ({
  135. channels: oldData.channels.map((ch) => ({
  136. ...ch,
  137. id: isValidChannelId(ch.id.trim())
  138. ? ch.id.trim()
  139. : `@${ch.id.trim()}`,
  140. })),
  141. }),
  142. },
  143. });
  144. let autoLikeStoreLoaded = false;
  145. /** Inits the auto-like DataStore instance */
  146. export function initAutoLikeStore() {
  147. if(autoLikeStoreLoaded)
  148. return;
  149. autoLikeStoreLoaded = true;
  150. return autoLikeStore.loadData();
  151. }
  152. /** Initializes the auto-like feature */
  153. export async function initAutoLike() {
  154. try {
  155. canCompress = await compressionSupported();
  156. await initAutoLikeStore();
  157. //#SECTION ytm
  158. if(getDomain() === "ytm") {
  159. let timeout: ReturnType<typeof setTimeout>;
  160. siteEvents.on("songTitleChanged", () => {
  161. const autoLikeTimeoutMs = (getFeature("autoLikeTimeout") ?? 5) * 1000;
  162. timeout && clearTimeout(timeout);
  163. const ytmTryAutoLike = () => {
  164. const artistEls = document.querySelectorAll<HTMLAnchorElement>("ytmusic-player-bar .content-info-wrapper .subtitle a.yt-formatted-string[href]");
  165. const channelIds = [...artistEls].map(a => a.href.split("/").pop()).filter(a => typeof a === "string") as string[];
  166. const likeChan = autoLikeStore.getData().channels.find((ch) => channelIds.includes(ch.id));
  167. if(!likeChan || !likeChan.enabled)
  168. return;
  169. if(artistEls.length === 0)
  170. return error("Couldn't auto-like channel because the artist element couldn't be found");
  171. const likeRendererEl = document.querySelector<HTMLElement>(".middle-controls-buttons ytmusic-like-button-renderer");
  172. const likeBtnEl = likeRendererEl?.querySelector<HTMLButtonElement>("#button-shape-like button");
  173. if(!likeRendererEl || !likeBtnEl)
  174. return error("Couldn't auto-like channel because the like button couldn't be found");
  175. if(likeRendererEl.getAttribute("like-status") !== "LIKE") {
  176. likeBtnEl.click();
  177. getFeature("autoLikeShowToast") && showIconToast({
  178. message: t(`auto_liked_a_channels_${currentMediaType()}`, likeChan.name),
  179. icon: "icon-auto_like",
  180. });
  181. log(`Auto-liked ${currentMediaType()} from channel '${likeChan.name}' (${likeChan.id})`);
  182. }
  183. };
  184. timeout = setTimeout(ytmTryAutoLike, autoLikeTimeoutMs);
  185. siteEvents.on("autoLikeChannelsUpdated", () => setTimeout(ytmTryAutoLike, autoLikeTimeoutMs));
  186. });
  187. siteEvents.on("pathChanged", (path) => {
  188. if(getFeature("autoLikeChannelToggleBtn") && path.match(/\/channel\/.+/)) {
  189. const chanId = getCurrentChannelId();
  190. if(!chanId)
  191. return error("Couldn't extract channel ID from URL");
  192. document.querySelectorAll<HTMLElement>(".bytm-auto-like-toggle-btn").forEach((btn) => clearNode(btn));
  193. addSelectorListener("browseResponse", "ytmusic-browse-response #header.ytmusic-browse-response", {
  194. listener(headerCont) {
  195. const buttonsCont = headerCont.querySelector<HTMLElement>(".buttons");
  196. if(buttonsCont) {
  197. const lastBtn = buttonsCont.querySelector<HTMLElement>("ytmusic-subscribe-button-renderer");
  198. const chanName = document.querySelector<HTMLElement>("ytmusic-immersive-header-renderer .content-container yt-formatted-string[role=\"heading\"]")?.textContent ?? null;
  199. lastBtn && addAutoLikeToggleBtn(lastBtn, chanId, chanName);
  200. }
  201. else {
  202. // some channels don't have a subscribe button and instead only have a "share" button for some bullshit reason
  203. const shareBtnEl = headerCont.querySelector<HTMLElement>("ytmusic-menu-renderer #top-level-buttons yt-button-renderer:last-of-type");
  204. const chanName = headerCont.querySelector<HTMLElement>("ytmusic-visual-header-renderer .content-container h2 yt-formatted-string")?.textContent ?? null;
  205. shareBtnEl && chanName && addAutoLikeToggleBtn(shareBtnEl, chanId, chanName);
  206. }
  207. }
  208. });
  209. }
  210. });
  211. }
  212. //#SECTION yt
  213. else if(getDomain() === "yt") {
  214. addStyleFromResource("css-auto_like");
  215. let timeout: ReturnType<typeof setTimeout>;
  216. siteEvents.on("watchIdChanged", () => {
  217. const autoLikeTimeoutMs = (getFeature("autoLikeTimeout") ?? 5) * 1000;
  218. timeout && clearTimeout(timeout);
  219. if(!location.pathname.startsWith("/watch"))
  220. return;
  221. const ytTryAutoLike = () => {
  222. addSelectorListener<HTMLAnchorElement, "yt">("ytWatchMetadata", "#owner ytd-channel-name yt-formatted-string a", {
  223. listener(chanElem) {
  224. const chanElemId = chanElem.href.split("/").pop()?.split("/")[0] ?? null;
  225. const likeChan = autoLikeStore.getData().channels.find((ch) => ch.id === chanElemId);
  226. if(!likeChan || !likeChan.enabled)
  227. return;
  228. addSelectorListener<0, "yt">("ytWatchMetadata", "#actions ytd-menu-renderer like-button-view-model button", {
  229. listener(likeBtn) {
  230. if(likeBtn.getAttribute("aria-pressed") !== "true") {
  231. likeBtn.click();
  232. getFeature("autoLikeShowToast") && showIconToast({
  233. message: t("auto_liked_a_channels_video", likeChan.name),
  234. icon: "icon-auto_like",
  235. });
  236. log(`Auto-liked video from channel '${likeChan.name}' (${likeChan.id})`);
  237. }
  238. }
  239. });
  240. }
  241. });
  242. };
  243. siteEvents.on("autoLikeChannelsUpdated", () => setTimeout(ytTryAutoLike, autoLikeTimeoutMs));
  244. timeout = setTimeout(ytTryAutoLike, autoLikeTimeoutMs);
  245. });
  246. siteEvents.on("pathChanged", (path) => {
  247. if(path.match(/(\/?@|\/?channel\/)\S+/)) {
  248. const chanId = getCurrentChannelId();
  249. if(!chanId)
  250. return error("Couldn't extract channel ID from URL");
  251. document.querySelectorAll<HTMLElement>(".bytm-auto-like-toggle-btn").forEach((btn) => clearNode(btn));
  252. addSelectorListener<0, "yt">("ytAppHeader", "#channel-header-container, #page-header", {
  253. listener(headerCont) {
  254. const titleCont = headerCont.querySelector<HTMLElement>("ytd-channel-name #container, yt-dynamic-text-view-model.page-header-view-model-wiz__page-header-title");
  255. if(!titleCont)
  256. return;
  257. const chanName = titleCont.querySelector<HTMLElement>("yt-formatted-string, span.yt-core-attributed-string")?.textContent ?? null;
  258. const buttonsCont = headerCont.querySelector<HTMLElement>("#inner-header-container #buttons, yt-flexible-actions-view-model");
  259. if(buttonsCont) {
  260. addSelectorListener<0, "yt">("ytAppHeader", "#channel-header-container #other-buttons, yt-flexible-actions-view-model .yt-flexible-actions-view-model-wiz__action", {
  261. listener: (otherBtns) =>
  262. addAutoLikeToggleBtn(otherBtns, chanId, chanName, ["left-margin", "right-margin"]),
  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. //#SECTION toggle btn
  279. /** Adds a toggle button to enable or disable auto-liking videos from a channel */
  280. async function addAutoLikeToggleBtn(siblingEl: HTMLElement, channelId: string, channelName: string | null, extraClasses?: string[]) {
  281. const chan = autoLikeStore.getData().channels.find((ch) => ch.id === channelId);
  282. siteEvents.on("autoLikeChannelsUpdated", () => {
  283. const buttonEl = document.querySelector<HTMLElement>(`.bytm-auto-like-toggle-btn[data-channel-id="${channelId}"]`);
  284. if(!buttonEl)
  285. return warn("Couldn't find auto-like toggle button for channel ID:", channelId);
  286. const enabled = autoLikeStore.getData().channels.find((ch) => ch.id === channelId)?.enabled ?? false;
  287. if(enabled)
  288. buttonEl.classList.add("toggled");
  289. else
  290. buttonEl.classList.remove("toggled");
  291. });
  292. const buttonEl = await createLongBtn({
  293. resourceName: `icon-auto_like${chan?.enabled ? "_enabled" : ""}`,
  294. text: t("auto_like"),
  295. title: t(`auto_like_button_tooltip${chan?.enabled ? "_enabled" : "_disabled"}`),
  296. toggle: true,
  297. toggleInitialState: chan?.enabled ?? false,
  298. togglePredicate(e) {
  299. e.shiftKey && getAutoLikeDialog().then((dlg) => dlg.open());
  300. return !e.shiftKey;
  301. },
  302. async onToggle(toggled) {
  303. try {
  304. await autoLikeStore.loadData();
  305. buttonEl.title = buttonEl.ariaLabel = t(`auto_like_button_tooltip${toggled ? "_enabled" : "_disabled"}`);
  306. const chanId = sanitizeChannelId(buttonEl.dataset.channelId ?? channelId);
  307. const imgEl = buttonEl.querySelector<HTMLElement>(".bytm-generic-btn-img");
  308. const imgHtml = await resourceAsString(`icon-auto_like${toggled ? "_enabled" : ""}`);
  309. if(imgEl && imgHtml)
  310. imgEl.innerHTML = imgHtml;
  311. if(autoLikeStore.getData().channels.find((ch) => ch.id === chanId) === undefined) {
  312. await autoLikeStore.setData({
  313. channels: [
  314. ...autoLikeStore.getData().channels,
  315. { id: chanId, name: channelName ?? "", enabled: toggled },
  316. ],
  317. });
  318. }
  319. else {
  320. await autoLikeStore.setData({
  321. channels: autoLikeStore.getData().channels
  322. .map((ch) => ch.id === chanId ? { ...ch, enabled: toggled } : ch),
  323. });
  324. }
  325. emitSiteEvent("autoLikeChannelsUpdated");
  326. showIconToast({
  327. message: toggled ? t("auto_like_enabled_toast") : t("auto_like_disabled_toast"),
  328. icon: `icon-auto_like${toggled ? "_enabled" : ""}`,
  329. });
  330. log(`Toggled auto-like for channel '${channelName}' (ID: '${chanId}') to ${toggled ? "enabled" : "disabled"}`);
  331. }
  332. catch(err) {
  333. error("Error while toggling auto-like channel:", err);
  334. }
  335. }
  336. });
  337. buttonEl.classList.add(...["bytm-auto-like-toggle-btn", ...(extraClasses ?? [])]);
  338. buttonEl.dataset.channelId = channelId;
  339. siblingEl.insertAdjacentElement("afterend", createRipple(buttonEl));
  340. siteEvents.on("autoLikeChannelsUpdated", async () => {
  341. const buttonEl = document.querySelector<HTMLElement>(`.bytm-auto-like-toggle-btn[data-channel-id="${channelId}"]`);
  342. if(!buttonEl)
  343. return;
  344. const enabled = autoLikeStore.getData().channels.find((ch) => ch.id === channelId)?.enabled ?? false;
  345. if(enabled)
  346. buttonEl.classList.add("toggled");
  347. else
  348. buttonEl.classList.remove("toggled");
  349. const imgEl = buttonEl.querySelector<HTMLElement>(".bytm-generic-btn-img");
  350. const imgHtml = await resourceAsString(`icon-auto_like${enabled ? "_enabled" : ""}`);
  351. if(imgEl && imgHtml)
  352. imgEl.innerHTML = imgHtml;
  353. });
  354. }