-
-
Notifications
You must be signed in to change notification settings - Fork 11
/
AllMediaElementsController.ts
744 lines (684 loc) · 35 KB
/
AllMediaElementsController.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
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
/**
* @license
* Copyright (C) 2020, 2021, 2022 WofWca <wofwca@protonmail.com>
*
* This file is part of Jump Cutter Browser Extension.
*
* Jump Cutter Browser Extension is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Jump Cutter Browser Extension is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Jump Cutter Browser Extension. If not, see <https://www.gnu.org/licenses/>.
*/
import browser from '@/webextensions-api';
import {
Settings, getSettings, setSettings, addOnStorageChangedListener, MyStorageChanges, ControllerKind,
removeOnStorageChangedListener, settingsChanges2NewValues,
} from '@/settings';
import { clamp, assertNever, assertDev } from '@/helpers';
import { isSourceCrossOrigin } from '@/entry-points/content/helpers';
import type ElementPlaybackControllerStretching from
'./ElementPlaybackControllerStretching/ElementPlaybackControllerStretching';
import type ElementPlaybackControllerCloning from './ElementPlaybackControllerCloning/ElementPlaybackControllerCloning';
import type ElementPlaybackControllerAlwaysSounded from './ElementPlaybackControllerAlwaysSounded';
import type TimeSavedTracker from './TimeSavedTracker';
import extensionSettings2ControllerSettings from './helpers/extensionSettings2ControllerSettings';
import { HotkeyAction, HotkeyBinding } from '@/hotkeys';
import type { keydownEventToActions } from '@/hotkeys';
import broadcastStatus from './broadcastStatus';
import once from 'lodash/once';
import debounce from 'lodash/debounce';
import {
mediaElementSourcesMap
} from '@/entry-points/content/getOrCreateMediaElementSourceAndUpdateMap';
import {
lastPlaybackRateSetByThisExtensionMap, lastDefaultPlaybackRateSetByThisExtensionMap,
setPlaybackRateAndRememberIt
} from './playbackRateChangeTracking';
type SomeController =
ElementPlaybackControllerStretching
| ElementPlaybackControllerCloning
| ElementPlaybackControllerAlwaysSounded;
export type TelemetryMessage =
SomeController['telemetry']
& TimeSavedTracker['timeSavedData']
& {
controllerType: ControllerKind,
elementLikelyCorsRestricted: boolean,
elementCurrentSrc?: string,
createMediaElementSourceCalledForElement: boolean,
};
function executeNonSettingsActions(
el: HTMLMediaElement,
nonSettingsActions: Exclude<ReturnType<typeof keydownEventToActions>, undefined>[1]
) {
for (const action of nonSettingsActions) {
switch (action.action) {
case HotkeyAction.REWIND: el.currentTime -= (action as HotkeyBinding<HotkeyAction.REWIND>).actionArgument; break;
case HotkeyAction.ADVANCE: el.currentTime += (action as HotkeyBinding<HotkeyAction.ADVANCE>).actionArgument; break;
case HotkeyAction.TOGGLE_PAUSE: el.paused ? el.play() : el.pause(); break;
case HotkeyAction.TOGGLE_MUTE: el.muted = !el.muted; break;
case HotkeyAction.INCREASE_VOLUME:
case HotkeyAction.DECREASE_VOLUME: {
const unitVector = action.action === HotkeyAction.INCREASE_VOLUME ? 1 : -1;
const toAdd = unitVector * (action as HotkeyBinding<HotkeyAction.INCREASE_VOLUME>).actionArgument / 100;
el.volume = clamp(el.volume + toAdd, 0, 1);
break;
}
default: assertNever(action.action);
}
}
}
let allMediaElementsControllerActive = false;
type ControllerType<T extends ControllerKind> =
T extends ControllerKind.STRETCHING ? typeof ElementPlaybackControllerStretching
: T extends ControllerKind.CLONING ? typeof ElementPlaybackControllerCloning
: T extends ControllerKind.ALWAYS_SOUNDED ? typeof ElementPlaybackControllerAlwaysSounded
: never;
const controllerTypeDependsOnSettings = [
'experimentalControllerType',
'dontAttachToCrossOriginMedia',
] as const;
function getAppropriateControllerType(
settings: Pick<Settings, typeof controllerTypeDependsOnSettings[number]>,
elementSourceIsCrossOrigin: boolean,
): ControllerKind {
// Analyzing audio data of a CORS-restricted media element is impossible because its
// `MediaElementAudioSourceNode` outputs silence (see
// https://webaudio.github.io/web-audio-api/#MediaElementAudioSourceOptions-security,
// https://github.com/WofWca/jumpcutter/issues/47,
// https://html.spec.whatwg.org/multipage/media.html#security-and-privacy-considerations),
// so it's not that we only are unable to analyze it - the user also becomes unable to hear its sound.
// The following is to avoid that.
//
// Actually, the fact that a source is cross-origin doesn't guarantee that `MediaElementAudioSourceNode`
// will output silence. For example, if the media data is served with `Access-Control-Allow-Origin`
// header set to `document.location.origin`. But currently it's not easy to detect that. See
// https://github.com/WebAudio/web-audio-api/issues/2453.
// It's better to not attach to an element than to risk muting it as it's more confusing to the user.
return elementSourceIsCrossOrigin && settings.dontAttachToCrossOriginMedia
? ControllerKind.ALWAYS_SOUNDED
: settings.experimentalControllerType
}
async function importAndCreateController<T extends ControllerKind>(
kind: T,
// Not just `constructorArgs` because e.g. settings can change while `import()` is ongoing.
getConstructorArgs: () => ConstructorParameters<ControllerType<T>>
) {
let Controller;
switch (kind) {
case ControllerKind.STRETCHING: {
({ default: Controller } = await import(
/* webpackExports: ['default'] */
'./ElementPlaybackControllerStretching/ElementPlaybackControllerStretching'
));
break;
}
case ControllerKind.CLONING: {
({ default: Controller } = await import(
/* webpackExports: ['default'] */
'./ElementPlaybackControllerCloning/ElementPlaybackControllerCloning'
));
break;
}
case ControllerKind.ALWAYS_SOUNDED: {
({ default: Controller } = await import(
/* webpackExports: ['default'] */
'./ElementPlaybackControllerAlwaysSounded'
));
break;
}
default: assertNever(kind);
}
type Hack = ConstructorParameters<typeof ElementPlaybackControllerCloning>;
const controller = new Controller(...(getConstructorArgs() as Hack));
return controller;
}
function isElementIneligibleBecauseMuted(el: HTMLMediaElement, settings: Pick<Settings, 'omitMutedElements'>) {
return settings.omitMutedElements
? el.muted
: false;
}
// type BasicSettings = Pick<Settings, 'omitMutedElements'>;
export default class AllMediaElementsController {
activeMediaElement: HTMLMediaElement | undefined;
activeMediaElementSourceIsCrossOrigin: boolean | undefined;
unhandledNewElements = new Set<HTMLMediaElement>();
handledElements = new WeakSet<HTMLMediaElement>();
private handledMutedElements = new WeakSet<HTMLMediaElement>();
elementLastActivatedAt: number | undefined;
controller: SomeController | undefined;
timeSavedTracker: TimeSavedTracker | undefined;
private settings: Settings | undefined;
// This is so we don't have to load all the settings keys just for basic functionality.
// This is pretty stupid. Maybe it could be soumehow refactored to look less stupid.
private basicSettingsP: Promise<Pick<Settings, 'omitMutedElements'>>;
private basicSettings: Awaited<typeof this.basicSettingsP> | undefined;
private _resolveDestroyedPromise!: () => void;
private _destroyedPromise = new Promise<void>(r => this._resolveDestroyedPromise = r);
// Whatever is added to `_destroyedPromise.then` doesn't need to be added to `_onDetachFromActiveElementCallbacks`,
// it will be called in `destroy`.
private _onDetachFromActiveElementCallbacks: Array<() => void> = [];
constructor() {
if (IS_DEV_MODE) {
if (allMediaElementsControllerActive) {
console.error("AllMediaElementsController is supposed to be a singletone, but it another was created while "
+ "one has not been destroyed");
}
allMediaElementsControllerActive = true;
}
this.basicSettingsP = getSettings('omitMutedElements').then(s => this.basicSettings = s);
// Keep in mind that this listener is also responsible for the desturction of this instance in case
// `enabled` gets changed to `false`.
const reactToStorageChanges = (changes: MyStorageChanges) => {
this.reactToSettingsNewValues(settingsChanges2NewValues(changes));
}
addOnStorageChangedListener(reactToStorageChanges);
this._destroyedPromise.then(() => removeOnStorageChangedListener(reactToStorageChanges));
}
private destroy() {
this.detachFromActiveElement();
this._resolveDestroyedPromise();
if (IS_DEV_MODE) {
allMediaElementsControllerActive = false;
}
}
private detachFromActiveElement() {
// TODO It is possible to call this function before the `_onDetachFromActiveElementCallbacks` array has been filled
// and `controller` has been assigned.
// Also keep in mind that it's possible to never attached to any elements at all, even if `onNewMediaElements()`
// has been called (see that function).
this.controller?.destroy();
this.controller = undefined;
this._onDetachFromActiveElementCallbacks.forEach(cb => cb());
this._onDetachFromActiveElementCallbacks = [];
}
public broadcastStatus(): void {
broadcastStatus({ elementLastActivatedAt: this.elementLastActivatedAt });
}
private async _loadSettings() {
this.settings = await getSettings();
}
private ensureLoadSettings = once(this._loadSettings);
private reactToSettingsNewValues(newValues: Partial<Settings>) {
if (newValues.enabled === false) {
this.destroy();
return;
}
if (Object.keys(newValues).length === 0) return;
if (this.basicSettings) {
// This also saves keys other than `keyof typeof this.basicSettings`. Who asked tho?
Object.assign(this.basicSettings, newValues);
}
if (!this.settings) {
// The fact that the settings haven't yet been loaded means that nothing is initialized yet because
// it couldn't have been initialized because nobody knows how to initialize it.
// Might want to refactor this in the future.
return;
}
Object.assign(this.settings, newValues);
assertDev(this.controller);
if (controllerTypeDependsOnSettings.some(key => key in newValues)) {
const currentController = this.controller;
const el = currentController.element;
assertDev(typeof this.activeMediaElementSourceIsCrossOrigin === 'boolean');
const newControllerType = getAppropriateControllerType(this.settings, this.activeMediaElementSourceIsCrossOrigin);
if (newControllerType !== (currentController.constructor as any).controllerType) {
const oldController = currentController;
this.controller = undefined;
(async () => {
await oldController.destroy();
assertDev(this.settings);
const controller = this.controller = await importAndCreateController(
newControllerType,
() => [
el,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
extensionSettings2ControllerSettings(this.settings!),
this.timeSavedTracker,
]
);
controller.init();
// Controller destruction is done in `detachFromActiveElement`.
})();
}
} else {
// See the `updateSettingsAndMaybeCreateNewInstance` method - `this.controller` may be uninitialized after that.
// TODO maybe it would be more clear to explicitly reinstantiate it in this file, rather than in that method?
this.controller = this.controller.updateSettingsAndMaybeCreateNewInstance(
extensionSettings2ControllerSettings(this.settings) // TODO creating a new object on each settings change? SMH.
);
// Controller destruction is done in `detachFromActiveElement`.
}
}
private onConnect = (port: browser.runtime.Port) => {
let listener: (msg: unknown) => void;
switch (port.name) {
case 'telemetry': {
listener = (msg: unknown) => {
if (IS_DEV_MODE) {
if (msg !== 'getTelemetry') {
throw new Error('Unsupported message type')
}
}
if (this.controller?.initialized && this.timeSavedTracker) {
assertDev(typeof this.activeMediaElementSourceIsCrossOrigin === 'boolean');
assertDev(this.activeMediaElement);
const elementLikelyCorsRestricted = this.activeMediaElementSourceIsCrossOrigin;
const telemetryMessage: TelemetryMessage = {
...this.controller.telemetry,
...this.timeSavedTracker.timeSavedData,
controllerType: (this.controller.constructor as any).controllerType,
elementLikelyCorsRestricted,
// `undefined` for performance.
elementCurrentSrc: elementLikelyCorsRestricted ? this.activeMediaElement.currentSrc : undefined,
// TODO check if the map lookup is too slow to do it several times per second.
createMediaElementSourceCalledForElement: !!mediaElementSourcesMap.get(this.activeMediaElement),
};
port.postMessage(telemetryMessage);
}
};
break;
}
case 'nonSettingsActions': {
listener = (msg: unknown) => {
if (this.activeMediaElement) {
executeNonSettingsActions(this.activeMediaElement, msg as Parameters<typeof executeNonSettingsActions>[1]);
}
};
break;
}
default: {
if (IS_DEV_MODE) {
throw new Error(`Unrecognized port name "${port.name}"`);
}
return;
}
}
port.onMessage.addListener(listener);
this._destroyedPromise.then(() => port.onMessage.removeListener(listener));
}
private _addOnConnectListener() {
browser.runtime.onConnect.addListener(this.onConnect);
this._destroyedPromise.then(() => browser.runtime.onConnect.removeListener(this.onConnect));
}
private ensureAddOnConnectListener = once(this._addOnConnectListener);
private async _initHotkeyListener() {
const { keydownEventToActions, eventTargetIsInput } = await import(
/* webpackExports: ['keydownEventToActions', 'eventTargetIsInput'] */
'@/hotkeys'
);
// TODO how about put this into './hotkeys.ts' in the form of a curried function that takes arguments that look
// something like `getSettings: () => Settings`?
const handleKeydown = (e: KeyboardEvent) => {
if (eventTargetIsInput(e)) return;
assertDev(this.settings);
const actions = keydownEventToActions(e, this.settings);
if (!actions) {
return;
}
const [ settingsNewValues, nonSettingsActions, overrideWebsiteHotkeys ] = actions;
// Works because `useCapture` of `addEventListener` is `true`. However, it's not guaranteed to work on every
// website, as they might as well set `useCapture` to `true`. TODO fix. Somehow. Maybe attach it before
// website's listeners get attached, by adding a `"run_at": "document_start"` content script.
// https://github.com/igrigorik/videospeed/blob/56eb7a08459d6746a0019b0b0c4edf974c022114/inject.js#L592-L596
if (overrideWebsiteHotkeys) {
e.preventDefault();
e.stopPropagation();
}
// TODO but this will cause `reactToSettingsNewValues` to get called twice – immediately and on storage change.
// Nothing critical, but not great for performance.
// How about we only update the`settings` object synchronously (so sequential changes can be made, as
// `keydownEventToActions` depends on it), but do not take any action until the onChanged event fires?
// Better yet, rewrite settings changes with messages API already so the script that made the change doesn't
// have to react to its own settings changes because it doesn't receive its own settings update message.
this.reactToSettingsNewValues(settingsNewValues);
setSettings(settingsNewValues);
executeNonSettingsActions(this.activeMediaElement!, nonSettingsActions);
};
// You might ask "Why don't you just use the native [commands API](https://developer.chrome.com/apps/commands)?"
// And the answer is – you may be right. But here's a longer version:
// * Our hotkeys are different from hotkeys you might have seen in videogames in the fact that ours are mostyly
// associated with an argument. Native hotkeys don't have this feature. We might have just strapped arguments to
// native hotkeys on the options page, but it'd be a bit confusing.
// * Docs say, "An extension can have many commands but only 4 suggested keys can be specified". Our extension has
// quite a lot of hotkeys, each user would have to manually bind each of them.
// * Native hotkeys are global to the browser, so it's quite nice when our hotkeys are only active when the
// extension is enabled (with `enabled` setting) and is attached to a video.
// * What gains are there? Would performance overhead be that much lower? Would it be lower at all?
// * Keeps opportunities for more fine-grained control.
// * Because I haven't considered it thorougly enough.
//
// Adding the listener to `document` instead of `video` because some websites (like YouTube) use custom players,
// which wrap around a video element, which is not ever supposed to be in focus.
assertDev(this.settings);
// Why not always attach with `useCapture = true`? For performance.
// TODO but if the user changed `overrideWebsiteHotkeys` for some binding, an extension reload will
// be required. React to settings changes?
if (this.settings.hotkeys.some(binding => binding.overrideWebsiteHotkeys)) {
// `useCapture` is true because see `overrideWebsiteHotkeys`.
document.addEventListener('keydown', handleKeydown, true);
this._destroyedPromise.then(() => document.removeEventListener('keydown', handleKeydown, true));
} else {
// Deferred because it's not top priority. But maybe it should be?
// Yes, it would mean that the `if (overrideWebsiteHotkeys) {` inside `handleKeydown` will always
// be false.
const handleKeydownDeferred =
(...args: Parameters<typeof handleKeydown>) => setTimeout(handleKeydown, undefined, ...args);
document.addEventListener('keydown', handleKeydownDeferred, { passive: true });
this._destroyedPromise.then(() => document.removeEventListener('keydown', handleKeydownDeferred));
}
// this.hotkeyListenerAttached = true;
}
private ensureInitHotkeyListener = once(this._initHotkeyListener);
private async esnureAttachToElement(el: HTMLMediaElement) {
if (IS_DEV_MODE) {
if (el.readyState < HTMLMediaElement.HAVE_METADATA) {
// We shouldn't be doing that because this probably means that the element has no source or is still loading
// so it doesn't make sense to assess whether it's CORS-restricted or whether we can use the cloning
// algorithm.
// TODO fix: I think this can happen when the video is muted initially and you unmute
// it while it's still not loaded.
console.warn('Attaching to an element with `el.readyState < HTMLMediaElement.HAVE_METADATA`');
}
}
const calledAt = Date.now();
if (this.activeMediaElement === el) {
// Need to do this even if it's already the active element, for the case when there are multiple iframe-embedded
// media elements on the page.
this.elementLastActivatedAt = calledAt;
return;
}
if (this.activeMediaElement) {
this.detachFromActiveElement();
}
this.activeMediaElement = el;
assertDev(this._onDetachFromActiveElementCallbacks.length === 0, 'I think `_onDetachFromActiveElementCallbacks` '
+ `should be empty here. Instead it it is ${this._onDetachFromActiveElementCallbacks.length} items long`);
// Currently this is technically not required since `this.activeMediaElement` is immediately reassigned
// in the line above after the `detachFromActiveElement` call.
this._onDetachFromActiveElementCallbacks.push(() => this.activeMediaElement = undefined);
await this.ensureLoadSettings();
assertDev(this.settings)
let resolveTimeSavedTrackerPromise: (timeSavedTracker: TimeSavedTracker) => void;
const timeSavedTrackerPromise = new Promise<TimeSavedTracker>(r => resolveTimeSavedTrackerPromise = r);
const elCrossOrigin = this.activeMediaElementSourceIsCrossOrigin = isSourceCrossOrigin(el);
const onMaybeSourceChange = () => {
this.activeMediaElementSourceIsCrossOrigin = isSourceCrossOrigin(el);
// TODO perhaps we also need to re-run the controller selection code (which is inside
// `reactToSettingsNewValues` right now)? But what if `createMediaElementSource` has already been
// called? There isn't really a point in switching to the `ALWAYS_SOUNDED` controller in that case,
// is there?
};
// I believe 'loadstart' might get emited even if the source didn't change (e.g. `el.load()`
// has been called manually), but you pretty much can't change source and begin its playback
// without firing the 'loadstart' event.
// So this is reliable.
el.addEventListener('loadstart', onMaybeSourceChange, { passive: true });
this._onDetachFromActiveElementCallbacks.push(() => el.removeEventListener('loadstart', onMaybeSourceChange));
const controllerP = importAndCreateController(
getAppropriateControllerType(this.settings, elCrossOrigin),
() => [
el,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
extensionSettings2ControllerSettings(this.settings!),
timeSavedTrackerPromise,
]
).then(async controller => {
this.controller = controller;
await controller.init();
// Controller destruction is done in `detachFromActiveElement`.
return controller;
});
let hotkeyListenerP;
if (this.settings.enableHotkeys) {
hotkeyListenerP = this.ensureInitHotkeyListener();
}
// TODO an option to disable it.
(async () => {
const { default: TimeSavedTracker } = await import(
/* webpackExports: ['default'] */
'./TimeSavedTracker'
);
await controllerP; // It doesn't make sense to measure its effectiveness if it hasn't actually started working yet.
const timeSavedTracker = this.timeSavedTracker = new TimeSavedTracker(
el,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.settings!,
addOnStorageChangedListener,
removeOnStorageChangedListener,
);
this._onDetachFromActiveElementCallbacks.push(() => timeSavedTracker.destroy());
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
resolveTimeSavedTrackerPromise!(timeSavedTracker);
})();
{
// TODO perf: dynamically import this.
// Listen to playback rate changes and maybe update `settings.soundedSpeed` or prevent the
// change, depending on settings.
// I think that this should only apply to elements whose playbackRate this extension is controlling.
// Which it is now.
// Keep in mind that several events may be fired in the same event cycle. And, for example,
// if you do `el.playbackRate = 2; el.playbackRate = 3;`, two events will fire, but `el.playbackRate`
// will be `3`.
// Also keep in mind that changing `defaultPlaybackRate` also fires the 'ratechange' event.
// Also keep in mind that when media element load algorithm is executed, it does
// `el.playbackRate = el.defaultPlaybackRate`.
// https://html.spec.whatwg.org/multipage/media.html#media-element-load-algorithm
// Video Speed Controller extension does this too, but that code is not really of use to us
// because we also switch to silenceSpeed, in which case we must not update soundedSpeed.
// https://github.com/igrigorik/videospeed/blob/caacb45d614db312cf565e5f92e09a14e52ccf62/inject.js#L467-L493
// Ensure that the values for this element exist in the map. Currently they should already be
// there, but let's super-ensure it.
// Semantically it says "we approve the current values".
lastPlaybackRateSetByThisExtensionMap.set(el, el.playbackRate);
lastDefaultPlaybackRateSetByThisExtensionMap.set(el, el.defaultPlaybackRate);
// A quick and dirty fix for Twitch (https://github.com/WofWca/jumpcutter/issues/25).
// TODO improvement: https://github.com/WofWca/jumpcutter/issues/101.
// So people don't have to go to settings.
const forcePrevent = (
// Check if the setting has the default value (so the user didn't change it, otherwise
// they probably want different behavior).
[undefined, 'updateSoundedSpeed'].includes(
this.settings!.onPlaybackRateChangeFromOtherScripts
)
// So people have a way of tirning this off.
// @ts-expect-error 2339
&& !this.settings.dontForcePreventPlaybackRateChangesOnTwitch
&& ['www.twitch.tv', 'twitch.tv'].includes(document.location.host)
// 2050-01-01. In case I get hit by a bus.
&& Date.now() < 2524608000000
);
const ratechangeListener = (event: Event) => {
const el_ = event.target as HTMLMediaElement;
if (IS_DEV_MODE) {
if (lastPlaybackRateSetByThisExtensionMap.get(el_) === undefined) {
console.warn('Expected playbackRate to have been set by us at least once');
}
if (lastDefaultPlaybackRateSetByThisExtensionMap.get(el_) === undefined) {
console.warn('Expected defaultPlaybackRate to have been set by us at least once');
}
}
switch (
!forcePrevent
? this.settings!.onPlaybackRateChangeFromOtherScripts
: 'prevent'
) {
case 'updateSoundedSpeed': {
const lastPlaybackRateSetByUs = lastPlaybackRateSetByThisExtensionMap.get(el_);
if (
el_.playbackRate !== lastPlaybackRateSetByUs
&& lastPlaybackRateSetByUs !== undefined
) {
// TODO improvement: hey, how about we watch `defaultPlaybackRate` instead of `playbackRate`?
// While it may make more, sense, unfortunately it's rare that websites ever use `defaultPlaybackRate`.
// Even YouTube doesn't update it. Make it an option at least? And should we maybe reach out to
// these services / other extensions' developers to encourage them to update `defaultPlaybackRate`?
// TODO improvement: how about we check if it's currently silence, therefore we should
// be more careful with updating soundedSpeed, because some websites/extensions could
// just be doing `el.playbackRate += increment`;
const settingsNewValues = { soundedSpeed: el_.playbackRate };
this.reactToSettingsNewValues(settingsNewValues);
setSettings(settingsNewValues);
if (IS_DEV_MODE) {
console.warn('Updating soundedSpeed because apparently playbackRate was changed by'
+ ' something other that this extension.');
}
}
break;
}
case 'prevent': {
// Consider doing this for `defaultPlaybackRate` as well.
const lastPlaybackRateSetByUs = lastPlaybackRateSetByThisExtensionMap.get(el_);
if (
el_.playbackRate !== lastPlaybackRateSetByUs
// Just in case.
&& lastPlaybackRateSetByUs !== undefined
) {
setPlaybackRateAndRememberIt(el_, lastPlaybackRateSetByUs);
// The website may be listening to 'ratechange' events and update `playbackRate`
// inside the listener. Let's make it so that it doesn't receive the event.
// This happens on Twitch (https://github.com/WofWca/jumpcutter/issues/25).
event.stopImmediatePropagation();
}
break;
}
}
};
const listenerOptions = {
// Need `capture` so that this listener gets executed before all the other ones that other scripts
// might have added (unless they as well do `capture: true`), so it can
// `event.stopImmediatePropagation()`. Yes, it's only needed when
// `onPlaybackRateChangeFromOtherScripts === 'prevent'`.
capture: true,
passive: true,
}
// TODO perf: we could be not attaching the listener at all if
// `onPlaybackRateChangeFromOtherScripts === 'doNothing'`, and then attach it when
// this gets changed.
el.addEventListener('ratechange', ratechangeListener, listenerOptions);
this._onDetachFromActiveElementCallbacks.push(
() => el.removeEventListener('ratechange', ratechangeListener, listenerOptions)
);
}
await controllerP;
hotkeyListenerP && await hotkeyListenerP;
await timeSavedTrackerPromise;
this.ensureAddOnConnectListener();
// Not doing this at the beginning of the function, beside `this.activeMediaElement = el;` because the popup
// considers that `elementLastActivatedAt !== undefined` means that it's free to connect, but
// `ensureAddOnConnectListener` can still have not been called. TODO refactor?
this.elementLastActivatedAt = calledAt;
this.broadcastStatus();
}
private ensureAttachToEventTargetElementIfEligible = async (e: Event) => {
await this.basicSettingsP;
assertDev(this.basicSettings);
const el = e.target as HTMLMediaElement;
if (!isElementIneligibleBecauseMuted(el, this.basicSettings)) {
this.esnureAttachToElement(el);
}
}
// private ensureAttachToEventTargetElementIfGotUnmutedAndIsPlayingAndOmitMutedIsTrue = async (e: Event) => {
private onvolumechange = async (e: Event) => {
const el = e.target as HTMLMediaElement;
// I think the fact that the element was muted when we attached the 'volumechange' listener and the
// listener got invoked doesn't necessarily mean that it's now not muted, because it may get
// unmuted and then muted again in the same event loop cycle, so we need to check `el.muted`
// in addition to `handledMutedElements.has(el)`.
const gotUnmuted = this.handledMutedElements.has(el) && !el.muted;
this.handledMutedElements.delete(el);
if (gotUnmuted && !el.paused) {
await this.basicSettingsP;
assertDev(this.basicSettings);
if (this.basicSettings.omitMutedElements) {
this.esnureAttachToElement(el);
}
}
}
private handleNewElements(basicSettings: Exclude<typeof this.basicSettings, undefined>) {
const newElements = this.unhandledNewElements;
this.unhandledNewElements = new Set();
for (const el of newElements) {
if (this.handledElements.has(el)) {
continue;
}
this.handledElements.add(el);
// Make the active element the one that got started last.
// Why not 'play'? See the comment about `el.readyState` below.
el.addEventListener('playing', this.ensureAttachToEventTargetElementIfEligible, { passive: true });
this._destroyedPromise.then(() => el.removeEventListener('playing', this.ensureAttachToEventTargetElementIfEligible));
if (el.muted) {
this.handledMutedElements.add(el);
}
el.addEventListener('volumechange', this.onvolumechange, { passive: true });
this._destroyedPromise.then(() => el.removeEventListener('volumechange', this.onvolumechange));
// TODO should we detach when it gets muted again? Maybe make a separate option for this?
// Or should we maybe move this logic to the Controller?
// TODO also react to settings changes, e.g. if `omitMutedElements` becomes false, attach to a muted one?
}
const eligibleForAttachmentElements: HTMLMediaElement[] = [];
newElements.forEach(el => {
if (!isElementIneligibleBecauseMuted(el, basicSettings)) {
eligibleForAttachmentElements.push(el);
}
})
// Attach to the first new element that is not paused, even if we're already attached to another.
// The thoguht process is like this - if such an element has been inserted, it is most likely due to the user
// wanting to switch his attention to it (e.g. pressing the "play" button on a custom media player, or scrolling
// a page with an infinite scroll with autoplaying videos).
// It may be that the designer of the website is an asshole and inserts new media elements whenever he feels like
// it, or I missed some other common cases. TODO think about it.
for (const el of eligibleForAttachmentElements) {
if (!el.paused) {
this.esnureAttachToElement(el);
break;
}
}
// Useful when the extension is disabled at first, then the user pauses the video to give himself time to enable it.
if (!this.activeMediaElement) {
for (const el of eligibleForAttachmentElements) {
if (
el.currentTime > 0
// It is possilble for an element to have `currentTime > 0` while having its `readyState === HAVE_NOTHING`.
// For example, this can happen if a website resumes playback from where the user stopped watching it on
// another occasion (e.g. Odysee). Or with streams. This is mostly to ensure that we don't attach to
// an element until its `currentSrc` is set to check if it cross-origin or not.
// If this happens, we'll attach to it later, on a 'playing' event.
// How about move this condition to `isElementIneligible` in order to also check it before
// every other call to `ensureAttach`. Or make `ensureAttach` an async function
// that awaits for the element to become ready. Don't forget to cancel the attachment
// if it was called again with a new element.
&& el.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA
) {
this.esnureAttachToElement(el);
break;
}
}
}
// Otherwise it seems that the only benefit of attaching to some other element is that it can be started with a
// pause/unpause hotkey.
}
private debouncedHandleNewElements = debounce(this.handleNewElements, 0, { maxWait: 3000 });
/**
* Calling with the same element multiple times is fine, calling multiple times on the same tick is fine.
* Order in which elements are passed in fact matters, but in practice not very much.
*/
public onNewMediaElements(...newElements: HTMLMediaElement[]): void {
newElements.forEach(el => this.unhandledNewElements.add(el));
// TODO actually we don't currently have to await for `this.basicSettingsP` if the element is not muted,
// so something like `isPotentiallyIneligibleForAttachment` would do in that case. It would probably
// unreasonably complicate the code a lot though.
this.basicSettingsP.then(() => {
assertDev(this.basicSettings);
this.debouncedHandleNewElements(this.basicSettings);
})
}
}