siteEvents.ts 7.8 KB

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