siteEvents.ts 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  1. import { NanoEmitter } from "@sv443-network/userutils";
  2. import { error, getDomain, info } from "./utils/index.js";
  3. import { FeatureConfig } from "./types.js";
  4. import { emitInterface } from "./interface.js";
  5. import { addSelectorListener, globserversReady } from "./observers.js";
  6. export interface SiteEventsMap {
  7. //#region misc:
  8. /** Emitted whenever the feature config is changed - initialization is not counted */
  9. configChanged: (newConfig: FeatureConfig) => void;
  10. /** Emitted whenever a config option is changed - contains the old and new value */
  11. configOptionChanged: <TFeatKey extends keyof FeatureConfig>(key: TFeatKey, oldValue: FeatureConfig[TFeatKey], newValue: FeatureConfig[TFeatKey]) => void;
  12. /** Emitted whenever the config menu should be rebuilt, like when a config was imported */
  13. rebuildCfgMenu: (newConfig: FeatureConfig) => void;
  14. /** Emitted whenever the config menu should be unmounted and recreated in the DOM */
  15. recreateCfgMenu: () => void;
  16. /** Emitted whenever the config menu is closed */
  17. cfgMenuClosed: () => void;
  18. /** Emitted when the welcome menu is closed */
  19. welcomeMenuClosed: () => void;
  20. /** Emitted whenever the user interacts with a hotkey input, used so other keyboard input event listeners don't get called while mid-input */
  21. hotkeyInputActive: (active: boolean) => void;
  22. //#region DOM:
  23. /** Emitted whenever child nodes are added to or removed from the song queue */
  24. queueChanged: (queueElement: HTMLElement) => void;
  25. /** Emitted whenever child nodes are added to or removed from the autoplay queue underneath the song queue */
  26. autoplayQueueChanged: (queueElement: HTMLElement) => void;
  27. /**
  28. * Emitted whenever the current song title changes.
  29. * Uses the DOM element `yt-formatted-string.title` to detect changes and emit instantaneously.
  30. * If `oldTitle` is `null`, this is the first song played in the session.
  31. */
  32. songTitleChanged: (newTitle: string, oldTitle: string | null) => void;
  33. /**
  34. * Emitted whenever the current song's watch ID changes.
  35. * If `oldId` is `null`, this is the first song played in the session.
  36. */
  37. watchIdChanged: (newId: string, oldId: string | null) => void;
  38. /**
  39. * Emitted whenever the URL path (`location.pathname`) changes.
  40. * If `oldPath` is `null`, this is the first path in the session.
  41. */
  42. pathChanged: (newPath: string, oldPath: string | null) => void;
  43. /** Emitted whenever the player enters or exits fullscreen mode */
  44. fullscreenToggled: (isFullscreen: boolean) => void;
  45. //#region features:
  46. /** Emitted whenever a channel was added, edited or removed from the auto-like list */
  47. autoLikeChannelsUpdated: () => void;
  48. }
  49. /** Array of all site events */
  50. export const allSiteEvents = [
  51. "configChanged",
  52. "configOptionChanged",
  53. "rebuildCfgMenu",
  54. "recreateCfgMenu",
  55. "cfgMenuClosed",
  56. "welcomeMenuClosed",
  57. "hotkeyInputActive",
  58. "queueChanged",
  59. "autoplayQueueChanged",
  60. "songTitleChanged",
  61. "watchIdChanged",
  62. "pathChanged",
  63. "fullscreenToggled",
  64. "autoLikeChannelsUpdated",
  65. ] as const;
  66. /** EventEmitter instance that is used to detect various changes to the site and userscript */
  67. export const siteEvents = new NanoEmitter<SiteEventsMap>({
  68. publicEmit: true,
  69. });
  70. let observers: MutationObserver[] = [];
  71. /** Disconnects and deletes all observers. Run `initSiteEvents()` again to create new ones. */
  72. export function removeAllObservers() {
  73. observers.forEach((ob) => ob.disconnect());
  74. observers = [];
  75. }
  76. let lastWatchId: string | null = null;
  77. let lastPathname: string | null = null;
  78. let lastFullscreen: boolean;
  79. /** Creates MutationObservers that check if parts of the site have changed, then emit an event on the `siteEvents` instance. */
  80. export async function initSiteEvents() {
  81. try {
  82. if(getDomain() === "ytm") {
  83. //#region queue
  84. // the queue container always exists so it doesn't need an extra init function
  85. const queueObs = new MutationObserver(([ { addedNodes, removedNodes, target } ]) => {
  86. if(addedNodes.length > 0 || removedNodes.length > 0) {
  87. info(`Detected queue change - added nodes: ${[...addedNodes.values()].length} - removed nodes: ${[...removedNodes.values()].length}`);
  88. emitSiteEvent("queueChanged", target as HTMLElement);
  89. }
  90. });
  91. // only observe added or removed elements
  92. addSelectorListener("sidePanel", "#contents.ytmusic-player-queue", {
  93. listener: (el) => {
  94. queueObs.observe(el, {
  95. childList: true,
  96. });
  97. },
  98. });
  99. const autoplayObs = new MutationObserver(([ { addedNodes, removedNodes, target } ]) => {
  100. if(addedNodes.length > 0 || removedNodes.length > 0) {
  101. info(`Detected autoplay queue change - added nodes: ${[...addedNodes.values()].length} - removed nodes: ${[...removedNodes.values()].length}`);
  102. emitSiteEvent("autoplayQueueChanged", target as HTMLElement);
  103. }
  104. });
  105. addSelectorListener("sidePanel", "ytmusic-player-queue #automix-contents", {
  106. listener: (el) => {
  107. autoplayObs.observe(el, {
  108. childList: true,
  109. });
  110. },
  111. });
  112. //#region player bar
  113. let lastTitle: string | null = null;
  114. addSelectorListener("playerBarInfo", "yt-formatted-string.title", {
  115. continuous: true,
  116. listener: (titleElem) => {
  117. const oldTitle = lastTitle;
  118. const newTitle = titleElem.textContent;
  119. if(newTitle === lastTitle || !newTitle)
  120. return;
  121. lastTitle = newTitle;
  122. info(`Detected song change - old title: "${oldTitle}" - new title: "${newTitle}"`);
  123. emitSiteEvent("songTitleChanged", newTitle, oldTitle);
  124. runIntervalChecks();
  125. },
  126. });
  127. info("Successfully initialized SiteEvents observers");
  128. observers = observers.concat([
  129. queueObs,
  130. autoplayObs,
  131. ]);
  132. //#region player
  133. const playerFullscreenObs = new MutationObserver(([{ target }]) => {
  134. const isFullscreen = (target as HTMLElement).getAttribute("player-ui-state")?.toUpperCase() === "FULLSCREEN";
  135. if(lastFullscreen !== isFullscreen || typeof lastFullscreen === "undefined") {
  136. emitSiteEvent("fullscreenToggled", isFullscreen);
  137. lastFullscreen = isFullscreen;
  138. }
  139. });
  140. if(getDomain() === "ytm") {
  141. const registerFullScreenObs = () => addSelectorListener("mainPanel", "ytmusic-player#player", {
  142. listener: (el) => {
  143. playerFullscreenObs.observe(el, {
  144. attributeFilter: ["player-ui-state"],
  145. });
  146. },
  147. });
  148. if(globserversReady)
  149. registerFullScreenObs();
  150. else
  151. window.addEventListener("bytm:observersReady", registerFullScreenObs, { once: true });
  152. }
  153. }
  154. window.addEventListener("bytm:ready", () => {
  155. runIntervalChecks();
  156. setInterval(runIntervalChecks, 100);
  157. if(getDomain() === "ytm") {
  158. addSelectorListener<HTMLAnchorElement>("mainPanel", "ytmusic-player #song-video #movie_player .ytp-title-text > a", {
  159. listener(el) {
  160. const urlRefObs = new MutationObserver(([ { target } ]) => {
  161. if(!target || !(target as HTMLAnchorElement)?.href?.includes("/watch"))
  162. return;
  163. const watchId = new URL((target as HTMLAnchorElement).href).searchParams.get("v");
  164. checkWatchIdChange(watchId);
  165. });
  166. urlRefObs.observe(el, {
  167. attributeFilter: ["href"],
  168. });
  169. }
  170. });
  171. }
  172. if(getDomain() === "ytm") {
  173. setInterval(checkWatchIdChange, 250);
  174. checkWatchIdChange();
  175. }
  176. }, {
  177. once: true,
  178. });
  179. }
  180. catch(err) {
  181. error("Couldn't initialize site event observers due to an error:\n", err);
  182. }
  183. }
  184. let bytmReady = false;
  185. window.addEventListener("bytm:ready", () => bytmReady = true, { once: true });
  186. /** Emits a site event with the given key and arguments - if `bytm:ready` has not been emitted yet, all events will be queued until it is */
  187. export function emitSiteEvent<TKey extends keyof SiteEventsMap>(key: TKey, ...args: Parameters<SiteEventsMap[TKey]>) {
  188. try {
  189. if(!bytmReady) {
  190. window.addEventListener("bytm:ready", () => {
  191. bytmReady = true;
  192. emitSiteEvent(key, ...args);
  193. }, { once: true });
  194. return;
  195. }
  196. siteEvents.emit(key, ...args);
  197. emitInterface(`bytm:siteEvent:${key}`, args as unknown as undefined);
  198. }
  199. catch(err) {
  200. error(`Couldn't emit site event "${key}" due to an error:\n`, err);
  201. }
  202. }
  203. //#region other
  204. /** Checks if the watch ID has changed and emits a `watchIdChanged` siteEvent if it has */
  205. function checkWatchIdChange(newId?: string | null) {
  206. const newWatchId = newId ?? new URL(location.href).searchParams.get("v");
  207. if(newWatchId && newWatchId !== lastWatchId) {
  208. info(`Detected watch ID change - old ID: "${lastWatchId}" - new ID: "${newWatchId}"`);
  209. emitSiteEvent("watchIdChanged", newWatchId, lastWatchId);
  210. lastWatchId = newWatchId;
  211. }
  212. }
  213. /** Periodically called to check for changes in the URL and emit associated siteEvents */
  214. export function runIntervalChecks() {
  215. if(!lastWatchId)
  216. checkWatchIdChange();
  217. if(location.pathname !== lastPathname) {
  218. emitSiteEvent("pathChanged", String(location.pathname), lastPathname);
  219. lastPathname = String(location.pathname);
  220. }
  221. };