siteEvents.ts 6.6 KB

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