siteEvents.ts 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
  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. export interface SiteEventsMap {
  7. // misc:
  8. /** Emitted whenever the feature config is changed - initialization is not counted */
  9. configChanged: (config: FeatureConfig) => void;
  10. // TODO: implement
  11. /** Emitted whenever a config option is changed - contains the old and the new values */
  12. configOptionChanged: <TKey extends keyof FeatureConfig>(key: TKey, oldValue: FeatureConfig[TKey], newValue: FeatureConfig[TKey]) => void;
  13. /** Emitted whenever the config menu should be rebuilt, like when a config was imported */
  14. rebuildCfgMenu: (config: FeatureConfig) => 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. // 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. * @param newTitle The new song title
  29. * @param oldTitle The old song title, or `null` if no previous title was found
  30. * @param initialPlay Whether this is the first played song
  31. */
  32. songTitleChanged: (newTitle: string, oldTitle: string | null, initialPlay: boolean) => void;
  33. /** Emitted whenever the current song's watch ID changes - `oldId` is `null` if this is the first song played in the session */
  34. watchIdChanged: (newId: string, oldId: string | null) => void;
  35. }
  36. /** Array of all site events */
  37. export const allSiteEvents = [
  38. "configChanged",
  39. "configOptionChanged",
  40. "rebuildCfgMenu",
  41. "cfgMenuClosed",
  42. "welcomeMenuClosed",
  43. "hotkeyInputActive",
  44. "queueChanged",
  45. "autoplayQueueChanged",
  46. "songTitleChanged",
  47. "watchIdChanged",
  48. ] as const;
  49. /** EventEmitter instance that is used to detect changes to the site */
  50. export const siteEvents = createNanoEvents<SiteEventsMap>();
  51. let observers: MutationObserver[] = [];
  52. /** Disconnects and deletes all observers. Run `initSiteEvents()` again to create new ones. */
  53. export function removeAllObservers() {
  54. observers.forEach((observer, i) => {
  55. observer.disconnect();
  56. delete observers[i];
  57. });
  58. observers = [];
  59. }
  60. /** Creates MutationObservers that check if parts of the site have changed, then emit an event on the `siteEvents` instance. */
  61. export async function initSiteEvents() {
  62. try {
  63. //#SECTION queue
  64. // the queue container always exists so it doesn't need an extra init function
  65. const queueObs = new MutationObserver(([ { addedNodes, removedNodes, target } ]) => {
  66. if(addedNodes.length > 0 || removedNodes.length > 0) {
  67. info(`Detected queue change - added nodes: ${[...addedNodes.values()].length} - removed nodes: ${[...removedNodes.values()].length}`);
  68. emitSiteEvent("queueChanged", target as HTMLElement);
  69. }
  70. });
  71. // only observe added or removed elements
  72. addSelectorListener("sidePanel", "#contents.ytmusic-player-queue", {
  73. listener: (el) => {
  74. queueObs.observe(el, {
  75. childList: true,
  76. });
  77. },
  78. });
  79. const autoplayObs = new MutationObserver(([ { addedNodes, removedNodes, target } ]) => {
  80. if(addedNodes.length > 0 || removedNodes.length > 0) {
  81. info(`Detected autoplay queue change - added nodes: ${[...addedNodes.values()].length} - removed nodes: ${[...removedNodes.values()].length}`);
  82. emitSiteEvent("autoplayQueueChanged", target as HTMLElement);
  83. }
  84. });
  85. addSelectorListener("sidePanel", "ytmusic-player-queue #automix-contents", {
  86. listener: (el) => {
  87. autoplayObs.observe(el, {
  88. childList: true,
  89. });
  90. },
  91. });
  92. //#SECTION player bar
  93. let lastTitle: string | null = null;
  94. let initialPlay = true;
  95. addSelectorListener("playerBarInfo", "yt-formatted-string.title", {
  96. continuous: true,
  97. listener: (titleElem) => {
  98. const oldTitle = lastTitle;
  99. const newTitle = titleElem.textContent;
  100. if(newTitle === lastTitle || !newTitle)
  101. return;
  102. lastTitle = newTitle;
  103. info(`Detected song change - old title: "${oldTitle}" - new title: "${newTitle}" - initial play: ${initialPlay}`);
  104. emitSiteEvent("songTitleChanged", newTitle, oldTitle, initialPlay);
  105. initialPlay = false;
  106. },
  107. });
  108. info("Successfully initialized SiteEvents observers");
  109. observers = observers.concat([
  110. queueObs,
  111. autoplayObs,
  112. ]);
  113. //#SECTION other
  114. let lastWatchId: string | null = null;
  115. const checkWatchId = () => {
  116. if(location.pathname.startsWith("/watch")) {
  117. const newWatchId = new URL(location.href).searchParams.get("v");
  118. if(newWatchId && newWatchId !== lastWatchId) {
  119. info(`Detected watch ID change - old ID: "${lastWatchId}" - new ID: "${newWatchId}"`);
  120. emitSiteEvent("watchIdChanged", newWatchId, lastWatchId);
  121. lastWatchId = newWatchId;
  122. }
  123. }
  124. setTimeout(checkWatchId, 200);
  125. };
  126. window.addEventListener("bytm:ready", () => checkWatchId(), { once: true });
  127. }
  128. catch(err) {
  129. error("Couldn't initialize SiteEvents observers due to an error:\n", err);
  130. }
  131. }
  132. /** Emits a site event with the given key and arguments */
  133. export function emitSiteEvent<TKey extends keyof SiteEventsMap>(key: TKey, ...args: Parameters<SiteEventsMap[TKey]>) {
  134. siteEvents.emit(key, ...args);
  135. emitInterface(`bytm:siteEvent:${key}`, args as unknown as undefined);
  136. }