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