siteEvents.ts 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. import { NanoEmitter, error, getDomain, info } from "./utils";
  2. import { FeatureConfig } from "./types";
  3. import { emitInterface } from "./interface";
  4. import { addSelectorListener } from "./observers";
  5. export interface SiteEventsMap {
  6. // misc:
  7. /** Emitted whenever the feature config is changed - initialization is not counted */
  8. configChanged: (config: FeatureConfig) => void;
  9. // TODO: implement
  10. /** Emitted whenever a config option is changed - contains the old and new value */
  11. configOptionChanged: <TKey extends keyof FeatureConfig>(key: TKey, oldValue: FeatureConfig[TKey], newValue: FeatureConfig[TKey]) => void;
  12. /** Emitted whenever the config menu should be rebuilt, like when a config was imported */
  13. rebuildCfgMenu: (config: 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. // 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. }
  46. /** Array of all site events */
  47. export const allSiteEvents = [
  48. "configChanged",
  49. "configOptionChanged",
  50. "rebuildCfgMenu",
  51. "recreateCfgMenu",
  52. "cfgMenuClosed",
  53. "welcomeMenuClosed",
  54. "hotkeyInputActive",
  55. "queueChanged",
  56. "autoplayQueueChanged",
  57. "songTitleChanged",
  58. "watchIdChanged",
  59. "pathChanged",
  60. "fullscreenToggled",
  61. ] as const;
  62. /** EventEmitter instance that is used to detect changes to the site */
  63. export const siteEvents = new NanoEmitter<SiteEventsMap>({ publicEmit: true });
  64. let observers: MutationObserver[] = [];
  65. /** Disconnects and deletes all observers. Run `initSiteEvents()` again to create new ones. */
  66. export function removeAllObservers() {
  67. observers.forEach((observer, i) => {
  68. observer.disconnect();
  69. delete observers[i];
  70. });
  71. observers = [];
  72. }
  73. let lastWatchId: string | null = null;
  74. let lastPathname: string | null = null;
  75. let lastFullscreen: boolean;
  76. /** Creates MutationObservers that check if parts of the site have changed, then emit an event on the `siteEvents` instance. */
  77. export async function initSiteEvents() {
  78. try {
  79. if(getDomain() === "ytm") {
  80. //#region queue
  81. // the queue container always exists so it doesn't need an extra init function
  82. const queueObs = new MutationObserver(([ { addedNodes, removedNodes, target } ]) => {
  83. if(addedNodes.length > 0 || removedNodes.length > 0) {
  84. info(`Detected queue change - added nodes: ${[...addedNodes.values()].length} - removed nodes: ${[...removedNodes.values()].length}`);
  85. emitSiteEvent("queueChanged", target as HTMLElement);
  86. }
  87. });
  88. // only observe added or removed elements
  89. addSelectorListener("sidePanel", "#contents.ytmusic-player-queue", {
  90. listener: (el) => {
  91. queueObs.observe(el, {
  92. childList: true,
  93. });
  94. },
  95. });
  96. const autoplayObs = new MutationObserver(([ { addedNodes, removedNodes, target } ]) => {
  97. if(addedNodes.length > 0 || removedNodes.length > 0) {
  98. info(`Detected autoplay queue change - added nodes: ${[...addedNodes.values()].length} - removed nodes: ${[...removedNodes.values()].length}`);
  99. emitSiteEvent("autoplayQueueChanged", target as HTMLElement);
  100. }
  101. });
  102. addSelectorListener("sidePanel", "ytmusic-player-queue #automix-contents", {
  103. listener: (el) => {
  104. autoplayObs.observe(el, {
  105. childList: true,
  106. });
  107. },
  108. });
  109. //#region player bar
  110. let lastTitle: string | null = null;
  111. addSelectorListener("playerBarInfo", "yt-formatted-string.title", {
  112. continuous: true,
  113. listener: (titleElem) => {
  114. const oldTitle = lastTitle;
  115. const newTitle = titleElem.textContent;
  116. if(newTitle === lastTitle || !newTitle)
  117. return;
  118. lastTitle = newTitle;
  119. info(`Detected song change - old title: "${oldTitle}" - new title: "${newTitle}"`);
  120. emitSiteEvent("songTitleChanged", newTitle, oldTitle);
  121. },
  122. });
  123. info("Successfully initialized SiteEvents observers");
  124. observers = observers.concat([
  125. queueObs,
  126. autoplayObs,
  127. ]);
  128. //#region player
  129. const playerFullscreenObs = new MutationObserver(([{ target }]) => {
  130. const isFullscreen = (target as HTMLElement).getAttribute("player-ui-state")?.toUpperCase() === "FULLSCREEN";
  131. if(lastFullscreen !== isFullscreen || typeof lastFullscreen === "undefined") {
  132. emitSiteEvent("fullscreenToggled", isFullscreen);
  133. lastFullscreen = isFullscreen;
  134. }
  135. });
  136. addSelectorListener("mainPanel", "ytmusic-player#player", {
  137. listener: (el) => {
  138. playerFullscreenObs.observe(el, {
  139. attributeFilter: ["player-ui-state"],
  140. });
  141. },
  142. });
  143. }
  144. //#region other
  145. const runIntervalChecks = () => {
  146. if(location.pathname.startsWith("/watch")) {
  147. const newWatchId = new URL(location.href).searchParams.get("v");
  148. if(newWatchId && newWatchId !== lastWatchId) {
  149. info(`Detected watch ID change - old ID: "${lastWatchId}" - new ID: "${newWatchId}"`);
  150. emitSiteEvent("watchIdChanged", newWatchId, lastWatchId);
  151. lastWatchId = newWatchId;
  152. }
  153. }
  154. if(location.pathname !== lastPathname) {
  155. emitSiteEvent("pathChanged", String(location.pathname), lastPathname);
  156. lastPathname = String(location.pathname);
  157. }
  158. };
  159. window.addEventListener("bytm:ready", () => {
  160. runIntervalChecks();
  161. setInterval(runIntervalChecks, 100);
  162. }, {
  163. once: true,
  164. });
  165. }
  166. catch(err) {
  167. error("Couldn't initialize SiteEvents observers due to an error:\n", err);
  168. }
  169. }
  170. let bytmReady = false;
  171. window.addEventListener("bytm:ready", () => bytmReady = true, { once: true });
  172. /** 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 */
  173. export function emitSiteEvent<TKey extends keyof SiteEventsMap>(key: TKey, ...args: Parameters<SiteEventsMap[TKey]>) {
  174. if(!bytmReady) {
  175. window.addEventListener("bytm:ready", () => {
  176. bytmReady = true;
  177. emitSiteEvent(key, ...args);
  178. }, { once: true });
  179. return;
  180. }
  181. siteEvents.emit(key, ...args);
  182. emitInterface(`bytm:siteEvent:${key}`, args as unknown as undefined);
  183. }