From e70a7981a7249ea7674e19b1e09339a56fc76984 Mon Sep 17 00:00:00 2001 From: WofWca Date: Sun, 11 Jun 2023 16:42:34 +0400 Subject: [PATCH] WIP --- package.json | 2 +- src/_locales | 2 +- .../ElementPlaybackControllerCloning.ts | 19 +- .../Lookahead.ts | 112 ++- .../canPlayCurrentSrc.ts | 23 + .../createCloneElementWithSameSrc.ts | 68 ++ .../getFinalCloneElement.ts | 72 ++ .../sourceMayBeMediaSource.ts | 68 ++ .../content/cloneMediaSources/constants.ts | 3 + .../getMediaSourceCloneElement.ts | 139 ++++ .../content/cloneMediaSources/lib.ts | 709 ++++++++++++++++++ .../main-for-extension-world.ts | 78 ++ .../cloneMediaSources/main-for-page-world.ts | 222 ++++++ ...artAttachingCloneMediaSourcesToElements.ts | 82 ++ src/manifest.json | 8 + webpack.config.js | 23 +- 16 files changed, 1619 insertions(+), 11 deletions(-) create mode 100644 src/entry-points/content/ElementPlaybackControllerCloning/canPlayCurrentSrc.ts create mode 100644 src/entry-points/content/ElementPlaybackControllerCloning/createCloneElementWithSameSrc.ts create mode 100644 src/entry-points/content/ElementPlaybackControllerCloning/getFinalCloneElement.ts create mode 100644 src/entry-points/content/ElementPlaybackControllerCloning/sourceMayBeMediaSource.ts create mode 100644 src/entry-points/content/cloneMediaSources/constants.ts create mode 100644 src/entry-points/content/cloneMediaSources/getMediaSourceCloneElement.ts create mode 100644 src/entry-points/content/cloneMediaSources/lib.ts create mode 100644 src/entry-points/content/cloneMediaSources/main-for-extension-world.ts create mode 100644 src/entry-points/content/cloneMediaSources/main-for-page-world.ts create mode 100644 src/entry-points/content/cloneMediaSources/startAttachingCloneMediaSourcesToElements.ts diff --git a/package.json b/package.json index 6d9993c..1ae5907 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ }, "homepage": "https://github.com/WofWca/jumpcutter#readme", "scripts": { - "_abstract-build": "yarn lint && NODE_ENV=production webpack --mode=production", + "_abstract-build": "NODE_ENV=production webpack --mode=production", "build:gecko": "yarn run _abstract-build --env browser=gecko", "build:chromium": "yarn run _abstract-build --env browser=chromium", "_abstract-build-and-package": "yarn run build:$BROWSER -- --env noreport && cd dist-$BROWSER && rm -f ../dist-$BROWSER.zip && zip -r ../dist-$BROWSER.zip .", diff --git a/src/_locales b/src/_locales index 5065e91..46e77a0 160000 --- a/src/_locales +++ b/src/_locales @@ -1 +1 @@ -Subproject commit 5065e9151502339dee4440b25829be1367ca7175 +Subproject commit 46e77a0e56fc2f6bb9590542c752f3f9f781975a diff --git a/src/entry-points/content/ElementPlaybackControllerCloning/ElementPlaybackControllerCloning.ts b/src/entry-points/content/ElementPlaybackControllerCloning/ElementPlaybackControllerCloning.ts index 52779d2..4f62c7d 100644 --- a/src/entry-points/content/ElementPlaybackControllerCloning/ElementPlaybackControllerCloning.ts +++ b/src/entry-points/content/ElementPlaybackControllerCloning/ElementPlaybackControllerCloning.ts @@ -188,6 +188,13 @@ export default class Controller { _didNotDoDesyncCorrectionForNSpeedSwitches = 0; + // TODO refactor: make this a constructor parameter for this Controller. + private readonly getMediaSourceCloneElement: ConstructorParameters[2] = + (originalElement) => import( + /* webpackExports: ['getMediaSourceCloneElement']*/ + '@/entry-points/content/cloneMediaSources/getMediaSourceCloneElement' + ).then(({ getMediaSourceCloneElement }) => getMediaSourceCloneElement(originalElement)); + constructor( element: HTMLMediaElement, controllerSettings: ControllerSettings, @@ -196,7 +203,11 @@ export default class Controller { this.element = element; this.settings = controllerSettings; - const lookahead = this.lookahead = new Lookahead(element, this.settings); + const lookahead = this.lookahead = new Lookahead( + element, + this.settings, + this.getMediaSourceCloneElement + ); // Destruction is performed in `this.destroy` directly. lookahead.ensureInit(); @@ -677,7 +688,11 @@ export default class Controller { } private _initLookahead() { - const lookahead = this.lookahead = new Lookahead(this.element, this.settings); + const lookahead = this.lookahead = new Lookahead( + this.element, + this.settings, + this.getMediaSourceCloneElement + ); // Destruction is performed in `this.destroy` directly. lookahead.ensureInit(); } diff --git a/src/entry-points/content/ElementPlaybackControllerCloning/Lookahead.ts b/src/entry-points/content/ElementPlaybackControllerCloning/Lookahead.ts index eac75a8..ce6c94e 100644 --- a/src/entry-points/content/ElementPlaybackControllerCloning/Lookahead.ts +++ b/src/entry-points/content/ElementPlaybackControllerCloning/Lookahead.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright (C) 2021, 2022 WofWca + * Copyright (C) 2021, 2022, 2023 WofWca * * This file is part of Jump Cutter Browser Extension. * @@ -28,6 +28,17 @@ import SilenceDetectorNode, { SilenceDetectorEventType, SilenceDetectorMessage } from '@/entry-points/content/SilenceDetector/SilenceDetectorNode'; import VolumeFilterNode from '@/entry-points/content/VolumeFilter/VolumeFilterNode'; import lookaheadVolumeFilterSmoothing from './lookaheadVolumeFilterSmoothing.json' +// import * as mediaSourcesCloning from '../cloneMediaSources'; + + +function getBufferedRanges(el: HTMLMediaElement) { + const arr = []; + for (let i = 0; i < el.buffered.length; i++) { + arr.push([el.buffered.start(i), el.buffered.end(i)]); + } + return arr; +} + // A more semantically correct version would be `Array<[start: MediaTime, end: MediaTime]>`, // but I think this is a bit faster. @@ -59,7 +70,9 @@ type LookaheadSettings = Pick for performance - so the browser doesn't have to decode video frames. @@ -75,10 +88,18 @@ export default class Lookahead { private _resolveDestroyedPromise!: () => void; private _destroyedPromise = new Promise(r => this._resolveDestroyedPromise = r); + /** + * @param getFallbackCloneElement a function that returns a clone element. It is used when + * the `Lookahead` could not reuse the same source as the original element. The current + * use case is when the original element uses `MediaSource`. The function may return the same + * clone element for different calls. + */ constructor( private originalElement: HTMLMediaElement, private settings: LookaheadSettings, // public onNewSilenceRange: (start: Time, end: Time) => void, + private readonly getFallbackCloneElement: + undefined | ((originalElement: HTMLMediaElement) => Promise) ) { const clone = document.createElement('audio'); this.clone = clone; @@ -86,6 +107,7 @@ export default class Lookahead { // TODO this probably doesn't cover all cases. Maybe it's better to just `originalElement.cloneNode(true)`? // TODO also need to watch for changes of `crossOrigin` // (in `ElementPlaybackControllerCloning.ts`). + // TODO wait, we gotta do the same for the `MediaSource` clone element, no? clone.crossOrigin = originalElement.crossOrigin; clone.src = originalElement.currentSrc; @@ -115,10 +137,77 @@ export default class Lookahead { private async _init(): Promise { const originalElement = this.originalElement; - const clone = this.clone; + // const clone = this.clone; const toAwait: Array> = []; + + // TODO what if `originalElement.srcObject` + // const mediaSourceFromSrcUrl = + // mediaSourcesCloning.getMediaSourceFromObjectUrl(originalElement.src); + + // URLs returned by `createObjectURL` are guaranteed to `.startsWith('blob:')`: + // https://w3c.github.io/FileAPI/#unicodeBlobURL + // + // TODO fuck. What if it's not `MediaSource` but a `File` (like it is in the + // "local-file-player") or a `Blob`? We could just use the same `src`. How do we determine + // which one it is? How about try to `fetch` it? If it's `MediaSource` the fetch will fail + // (based on my testing). + // Do we have to wait for the `MediaSource`-tracking script to return the response?? + const isSrcObjectUrl = originalElement.src.startsWith('blob:') + if (isSrcObjectUrl) { + // Reusing the same `mediaSourceFromSrcUrl` for the clone element is not possible, see + // https://github.com/WofWca/jumpcutter/issues/2 + toAwait.push(this.getFallbackCloneElement?.(originalElement)!.then(cloneEl => { + const clone = cloneEl!; + this.clone = clone; + + + // const cloneUrl = URL.createObjectURL(cloneMediaSource); + // clone.src = cloneUrl; + console.log(clone); + // TODO refactor: see the same line of code below. + // But make sure to `clone.src = ''` _before_ `revokeObjectURL`. + + // this._destroyedPromise.then(() => clone.src = ''); + + // this._destroyedPromise.then(() => URL.revokeObjectURL(cloneUrl)); + + // // Yes, we could also do `clone.src = URL.createObjectURL(cloneMediaSource)` + // clone.srcObject = cloneMediaSource; + })); + } else { + this.clone.src = originalElement.currentSrc; + // TODO refactor: see the same line of code below. + this._destroyedPromise.then(() => clone.src = ''); + } + + await Promise.all(toAwait); + const clone = this.clone; + + + // Not doing this appears to cause a resource (memory and processing) leak in Chromium + // manifesting itself when creating new instances of Lookahead (and discarding the old ones). + // Looks like it's because + // https://html.spec.whatwg.org/multipage/media.html#playing-the-media-resource + // > only once a media element is in a state where no further audio could ever be + // > played by that element may the element be garbage collected + // Here's the advice that tells us to do exactly this: + // https://html.spec.whatwg.org/multipage/media.html#best-practices-for-authors-using-media-elements + // > or, even better, by setting the element's src attribute to an empty string + // BTW, `clone.pause()` also works (sometimes?) + this._destroyedPromise.then(() => clone.src = ''); + + + + + // clone.addEventListener('timeupdate', e => { + // console.warn('timeupdate', clone.currentTime, clone.duration, getBufferedRanges(clone)) + // }) + console.log(originalElement, clone); + originalElement.addEventListener('error', e => console.error(e, e.target.error)); + clone.addEventListener('error', e => console.error(e, e.target.error)); + const ctx = new AudioContext({ latencyHint: 'playback', }); @@ -238,6 +327,9 @@ export default class Lookahead { : (seekTo: MediaTime) => { clone.currentTime = seekTo }; const seekCloneIfOriginalElIsPlayingUnprocessedRange = () => { const originalElementTime = originalElement.currentTime; + // TODO fix: `clone.played` doesn't mean that we've actually played back that part. + // E.g. if you play a video in Odysee then reload the page such that the video starts + // playing from the middle then this is gonna say that it `played` it from start to middle. const playingUnprocessedRange = !inRanges(clone.played, originalElementTime); // Keep in mind that `originalElement.seeking` could be `true`, so make sure not to repeatedly // call this so that it gets stuck in that state. @@ -291,11 +383,21 @@ export default class Lookahead { if (IS_DEV_MODE) { // TODO improvement: this can happen when a seek is performed. In that case speed will jump to max. if (aheadSeconds < 0) { - console.warn('aheadSeconds < 0:', aheadSeconds, clone.currentTime, originalElement.currentTime); + console.warn( + 'aheadSeconds < 0:', + aheadSeconds, + clone.currentTime, + getBufferedRanges(clone), + originalElement.currentTime, + ); } } // Speed is max when 0 seconds ahead and is 0 when 3 minutes ahead (but we'll clamp it, below). - const playbackRateUnclamped = maxClonePlaybackRate * (1 - (aheadSeconds / (3 * 60))); + // TODO improvement: make this depend on `clone.buffered`? Especially useful for the + // `MediaSource` cloning algorithm where the clone's `buffered` range is the same as + // the original element's `buffered` range so it can't run ahead too much, only as far as + // `buffered` goes. + const playbackRateUnclamped = maxClonePlaybackRate * (1 - (aheadSeconds / (1 * 60))); // Min clamp shouldn't be low because of performance - playing a video at 0.01 speed for 100 seconds // is much worse than playing it at 1 speed for 1 second. // TODO perf: Need to pause the video instead when it goes below the lower bound. diff --git a/src/entry-points/content/ElementPlaybackControllerCloning/canPlayCurrentSrc.ts b/src/entry-points/content/ElementPlaybackControllerCloning/canPlayCurrentSrc.ts new file mode 100644 index 0000000..ea8acac --- /dev/null +++ b/src/entry-points/content/ElementPlaybackControllerCloning/canPlayCurrentSrc.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright (C) 2023 WofWca + * + * 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 . + */ + +export async function canPlayCurrentSrc(element: HTMLMediaElement): Promise { + +} diff --git a/src/entry-points/content/ElementPlaybackControllerCloning/createCloneElementWithSameSrc.ts b/src/entry-points/content/ElementPlaybackControllerCloning/createCloneElementWithSameSrc.ts new file mode 100644 index 0000000..4eb8bb9 --- /dev/null +++ b/src/entry-points/content/ElementPlaybackControllerCloning/createCloneElementWithSameSrc.ts @@ -0,0 +1,68 @@ +/** + * @license + * Copyright (C) 2023 WofWca + * + * 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 . + */ + + +// * Try to create a clone `HTMLMediaElement` that can play the same source as the `originalElement`. + +/** + * Create a clone `HTMLMediaElement` that uses the same source as the original one. + */ +export function createCloneElementWithSameSrc( + originalElement: HTMLMediaElement, +): HTMLAudioElement { + // Also see {@link `getOriginalMediaSource`}. It is very similar. + // Maybe even too similar. + + // To recap: + // * attempting to play a `MediaSource` will [most likely](https://github.com/WofWca/jumpcutter/issues/2#issuecomment-1571654947) + // fail. + // * For `Blob` (including `File`) it's gonna work (I think?). + // * Not sure about `MediaStream`, but either way, I think our extension is not applicable to streams. + // * `MediaSource`, `Blob`, `MediaStream` can be used either as `srcObject` or + // `src = URL.createObjectURL(object)` + + + const cloneEl = document.createElement('audio'); + + // https://html.spec.whatwg.org/multipage/media.html#concept-media-load-algorithm + // > If mode is object + // > 1. Set the currentSrc attribute to the empty string. + const { currentSrc } = originalElement; + const isSrcObjectUsedOrNoSourceAtAll = !currentSrc; + if (isSrcObjectUsedOrNoSourceAtAll) { + const { srcObject } = originalElement; + + if (!srcObject) { + if (IS_DEV_MODE) { + console.warn('Making a clone element for an element with no source. You probably' + + ' should have waited before the original element gets a source'); + } + return cloneEl; + } + + cloneEl.srcObject = srcObject; + } else { + cloneEl.src = currentSrc; + } + return cloneEl; + + // Playback may also fail due to internet getting cut off, or the server returning an error + // (e.g. HTTP 429). +} diff --git a/src/entry-points/content/ElementPlaybackControllerCloning/getFinalCloneElement.ts b/src/entry-points/content/ElementPlaybackControllerCloning/getFinalCloneElement.ts new file mode 100644 index 0000000..974756e --- /dev/null +++ b/src/entry-points/content/ElementPlaybackControllerCloning/getFinalCloneElement.ts @@ -0,0 +1,72 @@ +/** + * @license + * Copyright (C) 2023 WofWca + * + * 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 . + */ + +import { createCloneElementWithSameSrc } from "./createCloneElementWithSameSrc"; +import { sourceMayBeMediaSource } from "./sourceMayBeMediaSource"; + +/** + * This must be called _synchronously_ after the source has been assigned to `element`, otherwise + * it may not work. + */ +async function canPlayCurrentSrc(element: HTMLMediaElement): Promise { + +} + +export async function getFinalCloneElement( + originalElement: HTMLMediaElement, + getFallbackCloneElement: + undefined | ((originalElement: HTMLMediaElement) => Promise), +): Promise { + const sameSourceClone = createCloneElementWithSameSrc(originalElement); + if (await canPlayCurrentSrc(sameSourceClone)) { + return sameSourceClone; + } + const fallbackElementIsSuposedToExist = + getFallbackCloneElement + // You might ask "what about `MediaSourceHandle`? Aren't we supposed to have a clone + // in this case?". Well, better check the `../cloneMediaSources` folder for the answer. + // Search for `MediaSourceHandle`. + && sourceMayBeMediaSource(originalElement); + // You might ask "why don't we try to `getFallbackCloneElement` unconditionally at this point? + // If there is one, let's use it". The answer is that we get the fallback element from the page's + // world's scripts which we don't really trust, while `sourceMayBeMediaSource` is fully under + // our control. + // But then, the page might still make the original element use `MediaSource`, and forge + // the fallback element the way it likes, so maybe there isn't really a point to this check, + // maybe it only adds a point of failure for no good reason. + // Not only that, making this check here IMO adds unnecessary code coupling. + // So, TODO refactor: reconsider the `sourceMayBeMediaSource` check. + if (fallbackElementIsSuposedToExist) { + const fallbackCloneElement = await getFallbackCloneElement(originalElement); + if (fallbackCloneElement) { + return fallbackCloneElement; + } + } else { + if (IS_DEV_MODE) { + if (await getFallbackCloneElement?.(originalElement)) { + console.warn('Expected no fallback element to exist, but it actually does.' + + ' Is the pre-condition check outdated, or did the website\'s script make one itself?'); + } + } + } + // No fallback element, the only option is to return `sameSourceClone` that we can't play. + // Maybe we'll be able to play it later for some reason idk. + return sameSourceClone; +} diff --git a/src/entry-points/content/ElementPlaybackControllerCloning/sourceMayBeMediaSource.ts b/src/entry-points/content/ElementPlaybackControllerCloning/sourceMayBeMediaSource.ts new file mode 100644 index 0000000..98240a2 --- /dev/null +++ b/src/entry-points/content/ElementPlaybackControllerCloning/sourceMayBeMediaSource.ts @@ -0,0 +1,68 @@ +/** + * @license + * Copyright (C) 2023 WofWca + * + * 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 . + */ + +/** + * @returns `true` if there is a non-zero (up to 100%) chance that the source of `element` is a + * `MediaSource`. + * If the source is known to be a `MediaSourceHandle`, `false` is returned. + */ +export function sourceMayBeMediaSource(element: HTMLMediaElement): boolean { + // Also see {@link `createCloneElementWithSameSrc`}. It is very similar. + // Maybe even too similar. + + // https://html.spec.whatwg.org/multipage/media.html#concept-media-load-algorithm + // > If mode is object + // > 1. Set the currentSrc attribute to the empty string. + const { currentSrc } = element; + const isSrcObjectUsedOrNoSourceAtAll = !currentSrc; + if (isSrcObjectUsedOrNoSourceAtAll) { + const { srcObject } = element; + if (!srcObject) { + return false; + } + if (srcObject instanceof MediaSource) { + return true; + } + return false; + } else { + // URLs returned by `createObjectURL` are guaranteed to `.startsWith('blob:')`: + // https://w3c.github.io/FileAPI/#unicodeBlobURL + if (!currentSrc.startsWith('blob:')) { + URL.createObjectURL + // Just a regular src, like `https://example.com/bbb.mp4`. + // Hold up, but is `URL.createObjectURL` the only way to create a URL from a `MediaSource`? + return false; + } + // At this point we know that `currentSrc` is a `blob:` URL, which, with the current web spec, + // may be a `Blob` (including `File`), or `MediaSource`: + // https://w3c.github.io/FileAPI/#blob-url-entry + // + // We could try to further determine if it's `Blob` or `MediaSource` by fetching the URL. + // Based on my testing, fetching fails for `MediaSource` and succeeds for `Blob` and `File`. + // But I'm not sure if this failure is a reliable way to test for it, I haven't found + // this being said in the spec. Maybe one day they decide to make `fetch` succeed for + // `MediaSource`. + // Also, if you look at the context of where this function is used, it's good enough the + // way it is now, because this function is only called when we're unable to play the source, + // which most likely indicates that it's a `MediaSource`. But it could simply be that the + // `Blob` data is not valid media data. + return true; + } +} diff --git a/src/entry-points/content/cloneMediaSources/constants.ts b/src/entry-points/content/cloneMediaSources/constants.ts new file mode 100644 index 0000000..18e8640 --- /dev/null +++ b/src/entry-points/content/cloneMediaSources/constants.ts @@ -0,0 +1,3 @@ +export const GET_CLONE_REQUEST_EVENT_NAME = 'jumpCutterGetCloneRequest'; +export const GET_CLONE_RESPONSE_EVENT_NAME = 'jumpCutterGetCloneResponse'; +export const BRIDGE_ELEMENT_ID_AND_PROP_NAME = '__jumpCutterBridgeElement'; diff --git a/src/entry-points/content/cloneMediaSources/getMediaSourceCloneElement.ts b/src/entry-points/content/cloneMediaSources/getMediaSourceCloneElement.ts new file mode 100644 index 0000000..1c7ed5a --- /dev/null +++ b/src/entry-points/content/cloneMediaSources/getMediaSourceCloneElement.ts @@ -0,0 +1,139 @@ +/** + * @license + * Copyright (C) 2023 WofWca + * + * 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 . + */ + +import type { + GlobalThisWithBridgeElement, +} from '@/entry-points/content/cloneMediaSources/main-for-extension-world'; +import { + BRIDGE_ELEMENT_ID_AND_PROP_NAME, GET_CLONE_REQUEST_EVENT_NAME, GET_CLONE_RESPONSE_EVENT_NAME, +} from '@/entry-points/content/cloneMediaSources/constants'; + +const bridgeElement = + (globalThis as GlobalThisWithBridgeElement)[BRIDGE_ELEMENT_ID_AND_PROP_NAME]; + +// async function getCloneSrc(objectUrlSrc: `blob:${string}`): Promise { +// async function getCloneSrc(objectUrlSrc: `blob:${string}`): Promise { +// async function getCloneSrc(objectUrlSrc: `blob:${string}`): Promise { +export async function getMediaSourceCloneElement( + originalEl: HTMLMediaElement +): Promise { +return new Promise(r_ => { + + // `Math.random()` is good enough to avoid collisions, since events are handled within + // a couple of event cycles. + const requestId = Math.random(); + + // Keep in mind that we receive these events from the page's context. + // They can be website-generated, or manipulated. Treat them as potentially malicious. + const listener = (e_: Event) => { + // `unknown` because the website may create such events as well, so we need to be careful. + if (!(e_ instanceof CustomEvent)) { + if (IS_DEV_MODE) { + // Not sure if this is possible. + console.warn("Received event, but it's not CustomEvent"); + } + return; + } + const e: CustomEvent = e_; + + // if (e.detail?.url !== objectUrlSrc) { + // return; + // } + + + + // if (!(e.detail?.cloneMediaSource instanceof MediaSource)) { + // // TODO reject the promise + // return; + // } + // resolvePromiseAndRemoveListener(e.detail?.cloneMediaSource); + + + // const el = document.getElementById(e.detail?.dummyElementId)!; + // el.remove(); + + + // const el = document.createElement('audio'); + // el.src = e.detail.cloneUrl; + + // setTimeout(() => { + // URL.revokeObjectURL(el.src); + // }) + + // TODO handle error response. Perhaps retry in a while, and fail after + // some time. + + if (e.detail?.requestId !== requestId) { + return; + } + const cloneEl = e.target; + if (!(cloneEl instanceof HTMLMediaElement)) { + // TODO error. + return; + } + + + cloneEl.remove(); + resolvePromiseAndRemoveListener( + // el.srcObject + // el.src + cloneEl + ); + + + + // TODO handle the `error` response. + }; + const resolvePromiseAndRemoveListener = (...resolveParams: Parameters) => { + r_(...resolveParams); + // TODO refactor: DRY event name format + document.removeEventListener(GET_CLONE_RESPONSE_EVENT_NAME, listener); + } + // TODO refactor: DRY event name format + // TODO remove listener on timeout and error out. + bridgeElement.addEventListener( + GET_CLONE_RESPONSE_EVENT_NAME, + listener, + { passive: true } + ); + + + // // TODO refactor: DRY event name format + // const requestEvent = new CustomEvent(GET_CLONE_REQUEST_EVENT_NAME, { + // // // TODO not sure about the whole event approach. + // // // Also `cloneInto` doesn't work in Chromium (probably)? + // // detail: (typeof cloneInto !== 'undefined' ? cloneInto : a => a)( + // // { + // // url: objectUrlSrc + // // }, + // // window + // // ) + // }); + // document.dispatchEvent(requestEvent); + + originalEl.dispatchEvent(new CustomEvent(GET_CLONE_REQUEST_EVENT_NAME, { + bubbles: true, + detail: { + requestId, + } + })); + +}); +} diff --git a/src/entry-points/content/cloneMediaSources/lib.ts b/src/entry-points/content/cloneMediaSources/lib.ts new file mode 100644 index 0000000..9e9164f --- /dev/null +++ b/src/entry-points/content/cloneMediaSources/lib.ts @@ -0,0 +1,709 @@ +/** + * @license + * Copyright (C) 2023 WofWca + * Copyright (C) 2023 Jonas Herzig + * + * 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 . + */ + +import type { KeysOfType } from "@/helpers"; + +// TODO refactor: hmmm there is a lot of `stopSomething: () => void`. Maybe we can utilize +// `WeakRef`s and `FinalizationRegistry`? Why doesn't everybody does it? + +export function startCloningMediaSources(): [ + // objectUrlToMediaSource: + // TODO in what cases do these return `undefined`? + getMediaSourceFromObjectUrl: + (url: ReturnType) => MediaSource | undefined, + // getMediaSourceClone: (originalMediaSource: MediaSource) => MediaSource | undefined, + // getCloneElement: (originalMediaSource: MediaSource) => HTMLMediaElement | undefined, + getCloneElement: (originalElement: HTMLMediaElement) => HTMLMediaElement | undefined, + stopCloningMediaSources: () => void, +] { + const [objectUrlToMediaSourceMap, stopMaintainingUrlMap] = + createMaintainedObjectUrlToMediaSourceMap(); + // const [mediaSourceToMediaSourceCloneMap, stopMaintainingCloneMap] = + // createMaintainedMediaSourceToMediaSourceCloneMap(); + const [mediaSourceToCloneMediaElementMap, stopMaintainingCloneMap] = + createMaintainedMediaSourceToCloneMediaElementMap(); + + function getCloneElement(originalElement: HTMLMediaElement): HTMLMediaElement | undefined { + const originalMediaSource = getOriginalMediaSource(originalElement); + if (!originalMediaSource) { + if (IS_DEV_MODE) { + console.error('No original `MediaSource` found for the requested original element') + } + return; + } + const cloneEl = mediaSourceToCloneMediaElementMap.get(originalMediaSource); + if (!cloneEl) { + if (IS_DEV_MODE) { + console.error('No clone element found for `MediaSource`. How did we miss it?'); + } + return; + } + return cloneEl; + } + function getOriginalMediaSource(originalElement: HTMLMediaElement): MediaSource | undefined { + // Also see {@link `createCloneElementWithSameSrc`}. It is very similar. + // Maybe even too similar. + + // https://html.spec.whatwg.org/multipage/media.html#concept-media-load-algorithm + // > If mode is object + // > 1. Set the currentSrc attribute to the empty string. + const { currentSrc } = originalElement; + const isSrcObjectUsedOrNoSourceAtAll = !currentSrc; + if (isSrcObjectUsedOrNoSourceAtAll) { + const { srcObject } = originalElement; + if (!srcObject) { + // TODO maybe return different errors which the caller can differentiate between? + return; + } + if (!(srcObject instanceof MediaSource)) { + // This doesn't mean that this extension only supports `MediaSource` `srcObject`. + // (they can also be `Blob`, `File`s): + // https://html.spec.whatwg.org/multipage/media.html#media-elements:dom-media-srcobject + // They're simply handled in a different part of the code. + return; + } + return srcObject; + } + + if (IS_DEV_MODE) { + // URLs returned by `createObjectURL` are guaranteed to `.startsWith('blob:')`: + // https://w3c.github.io/FileAPI/#unicodeBlobURL + if (!currentSrc.startsWith('blob:')) { + console.warn('Requested a clone element for original element whose `currentSrc` is not' + + ' empty and is not a URL made from `URL.createObjectURL`'); + } + } + + const fromObjectUrlMap = objectUrlToMediaSourceMap.get(currentSrc); + if (!fromObjectUrlMap) { + if (IS_DEV_MODE) { + console.error('No MediaSource for this objectURL. How did we miss it?') + } + return; + } + const derefed = fromObjectUrlMap.deref(); + if (!derefed) { + if (IS_DEV_MODE) { + console.error('An original `MediaSource` for an `objectURL` used to exist,' + + ' but it is now garbage-collected. How did we get a request for it then,' + + ' if there are no references to it?'); + } + return; + } + return derefed; + } + + return [ + (url) => objectUrlToMediaSourceMap.get(url)?.deref(), + // (originalMediaSource) => mediaSourceToMediaSourceCloneMap.get(originalMediaSource), + getCloneElement, + () => { + stopMaintainingUrlMap(); + stopMaintainingCloneMap(); + }, + ]; +} + +// function getCloneElement(originalElement: HTMLMediaElement): HTMLMediaElement | undefined { +// // https://html.spec.whatwg.org/multipage/media.html#concept-media-load-algorithm +// // > If mode is object +// // > 1. Set the currentSrc attribute to the empty string. +// const isSrcObjectUsedOrNoSourceAtAll = !originalElement.currentSrc; +// if (isSrcObjectUsedOrNoSourceAtAll) { +// if (!originalElement.srcObject) { +// // TODO maube return different errors which the called can differentiate between? +// return; +// } +// } +// } + + + +// I have no particular explanation on why I'm using `Map` instead of a plain object, but +// my intuition says that it's probably better. +type ObjectUrlToItsMediaSource = Map< + ReturnType, + // `WeakRef` for extra protection from memory leaks. We could rely on just `revokeObjectURL`, + // but let's play it safe(r). It's fine to not hold a strong reference because we would never + // actually need a `MediaSource` if it is already not used by the page. + // + // Also FYI in Chromium currently it's the case that if you + // `someEl.src = URL.createObjectURL(mediaSource)` but never `URL.revokeObjectURL(someEl.src)`, + // it would still be possible for `mediaSource` to get garbage collected. If a website + // relies on this (although they probably shouldn't) then we'd leak memory here. + WeakRef +>; +/** + * Creates and maintains a map of all URLs created with `URL.createObjectURL` + * to the `MediaSource` that the URL was created for. + * The map is automatically cleaned up, when `URL.revokeObjectURL` is called for a URL, or when + * the target `MediaSource` is garbage-collected. + * + * TODO I think there must be a native way to resolve objectURLs to the underlying thing they're + * holding. + */ +function createMaintainedObjectUrlToMediaSourceMap(): [ + map: ObjectUrlToItsMediaSource, + stopWatching: () => void, +] { + const map: ObjectUrlToItsMediaSource = new Map>(); + // TODO perf potential smol memory leak: add `FinalizationRegistry` (see the comment in + // `ObjectUrlToItsMediaSource`). + + const stopInterceptingCreateObjectUrlCalls = startInterceptingMethodCalls( + URL, + 'createObjectURL', + ([obj], url) => { + if (obj instanceof MediaSource) { + map.set(url, new WeakRef(obj)); + } + } + ); + // Damit. The fact that `revokeObjectURL` has been called + // doesn't mean that we need to remove it from the map, because an + // `HTMLMediaElement` can still have that URL as `src` and play its + // `MediaSource` properly, as long as started loading the URL before it + // got revoked: + // https://w3c.github.io/FileAPI/#creating-revoking + // > Requests that were started before the url was revoked should still succeed. + // TODO refactor: adjust other comments about this. + // + // const stopInterceptingRevokeObjectUrlCalls = startInterceptingMethodCalls( + // URL, + // 'revokeObjectURL', + // ([url]) => map.delete(url) + // ); + + return [ + map, + () => { + // stopInterceptingRevokeObjectUrlCalls(); + stopInterceptingCreateObjectUrlCalls(); + } + ]; +} + +function createMaintainedMediaSourceToMediaSourceCloneMap(): [ + map: WeakMap, + stopMaintainingMap: () => void, +] { + const map = new WeakMap(); + // Keep in mind that `HTMLMediaElement` sources can also be made out of `MediaSourceHandle`. + // However, we currently don't (and can't, I think) intercept the corresponding `MediaSource` + // constructor invokations since, according to the current spec, such `MediaSource`s can only + // be constructed in workers (also see `MediaSource.canConstructInDedicatedWorker`). Sigh. TODO? + const stopInterceptingMediaSourceConstructorCalls = startInterceptingMediaSourceConstructorCalls( + (constructorArgs, originalMediaSource) => { + // TODO handle `stopMaintainingMediaSourceClone` so the clone can be GCd. + const maintainedClone = makeMaintainedMediaSourceClone(originalMediaSource, constructorArgs); + + // TODO debugging, remove + maintainedClone.addEventListener('sourceopen', e => console.log('clone sourceopen', e)); + maintainedClone.addEventListener('sourceclose', e => console.log('clone sourceclose', e)); + maintainedClone.addEventListener('sourceended', e => console.log('clone sourceended', e)); + originalMediaSource.addEventListener('sourceopen', e => console.log('original sourceopen', e)); + originalMediaSource.addEventListener('sourceclose', e => console.log('original sourceclose', e)); + originalMediaSource.addEventListener('sourceended', e => console.log('original sourceended', e)); + + map.set(originalMediaSource, maintainedClone); + } + ) + return [ + map, + () => stopInterceptingMediaSourceConstructorCalls(), + ]; +} + +// function createMaintainedMediaSourceToCloneMediaSourceAndCloneMediaElementMap(): [ +// mediaSourceToMediaSourceCloneMap: WeakMap, +// cloneMediaSourceToCloneMediaElementMap: WeakMap, +// stopMaintainingMap: () => void, +// ] { +// const mediaSourceToMediaSourceCloneMap = new WeakMap(); +// const mediaSourceCloneToMediaElementMap = new WeakMap(); +// const stopInterceptingMediaSourceConstructorCalls = startInterceptingMediaSourceConstructorCalls( +// (constructorArgs, originalMediaSource) => { +// // TODO handle `stopMaintainingMediaSourceClone` so the clone can be GCd. +// const maintainedClone = makeMaintainedMediaSourceClone(constructorArgs, originalMediaSource); + +// mediaSourceToMediaSourceCloneMap.set(originalMediaSource, maintainedClone); +// } +// ) +// return [ +// mediaSourceToMediaElementMap, +// () => stopInterceptingMediaSourceConstructorCalls(), +// ]; +// } +function createMaintainedMediaSourceToCloneMediaElementMap(): [ + map: WeakMap, + stopMaintainingMap: () => void, +] { + const map = new WeakMap(); + // Keep in mind that `HTMLMediaElement` sources can also be made out of `MediaSourceHandle`. + // However, we currently don't (and can't, I think) intercept the corresponding `MediaSource` + // constructor invokations since, according to the current spec, such `MediaSource`s can only + // be constructed in workers (also see `MediaSource.canConstructInDedicatedWorker`). Sigh. TODO? + const stopInterceptingMediaSourceConstructorCalls = startInterceptingMediaSourceConstructorCalls( + (constructorArgs, originalMediaSource) => { + // TODO handle `stopMaintainingMediaSourceClone` so the clone can be GCd. + const maintainedMediaSourceClone = + makeMaintainedMediaSourceClone(originalMediaSource, constructorArgs); + + // TODO refactor: add tests that show that the clone `HTMLMediaElement` gets GCd when + // the original `MediaSource` becomes unreachable. + const cloneElement = document.createElement('audio'); + // Keep in mind that `URL.createObjectURL` is intercepted by us. Currently this is fine. + const cloneMediaSourceUrl = URL.createObjectURL(maintainedMediaSourceClone); + cloneElement.src = cloneMediaSourceUrl; + // We can `URL.revokeObjectURL` as soon as the `HTMLMediaElement` gets a hold of the + // underlying `MediaSource` and it will work fine. At least based on my tests, and this note: + // https://w3c.github.io/FileAPI/#creating-revoking + // > Requests that were started before the url was revoked should still succeed. + // TODO refactor: the spec seems a bit vague in this regard. I.e. can you really use the + // term "request" in regards to `MediaSource`s being used for `HTMLMediaElement` playback? + { + const revokeAndRemoveListener = () => { + URL.revokeObjectURL(cloneMediaSourceUrl); + cloneElement.removeEventListener('loadstart', revokeAndRemoveListener); + clearTimeout(timeoutId); + } + cloneElement.addEventListener('loadstart', revokeAndRemoveListener, { once: true }); + // Failsafe, just in case the event didn't fire for some reason. + let timeoutId = setTimeout(() => { + // Two `setTimeout`s in case one event cycle takes longer than serveral seconds idk lol. + timeoutId = setTimeout(revokeAndRemoveListener); + }, 10000); + } + + // TODO fix: memory leak. Even when an `HTMLMediaSource` becomes unreachable, + // it doesn't guarantee that it's gonna get GCd as long as it can potentially play audio: + // https://html.spec.whatwg.org/multipage/media.html#best-practices-for-authors-using-media-elements + // https://github.com/WofWca/jumpcutter/blob/505b55924871ebc3c433c54d431b828a052c470c/src/entry-points/content/ElementPlaybackControllerCloning/Lookahead.ts#L92-L102 + // + // I'd suggest "well, just `.src = ''` when it's not needed and assign it when it is needed". + // Doesn't work because you can only attach a `MediaSource` to a `HTMLMediaElement` once. + // + // I guess what we have to do is ensure that this extension doesn't hold strong references + // to the original `MediaSource` (currently the clone `MediaSource` strongy references it) + // and use `FinalizationRegistry` to watch for when the original `MediaSource` gets GCd + // and clean up the clones. + // + // Another idea: since we know that `MediaSource`s can only be attached once to an + // `HTMLMediaElement` (or do we know that? this comment + // https://github.com/WofWca/jumpcutter/issues/2#issuecomment-1571654947 + // says that they can?? But in that case, we'd not have to create a clone `MediaSource`, + // would we?), we could watch the original element that the original `MediaSource` gets + // attached to and when it does get detached, we are good to clean our clones up. + + map.set(originalMediaSource, cloneElement); + } + ) + return [ + map, + () => stopInterceptingMediaSourceConstructorCalls(), + ]; +} + +function makeIntercepted unknown>( + originalFn: T, + callback: (args: Parameters, retVal: ReturnType) => void, +): T { + // TODO perf: should we use `Proxy.revokable()`? + return new Proxy(originalFn, { + apply(target, thisArg, argArray, ...rest) { + const originalRetVal = Reflect.apply(target, thisArg, argArray, ...rest); + // `queueMicrotask` ensures that if `callback` throws, the intercepted call still returns + // as normal. It's also probably good for performance as it gives the website's code + // the priority. + queueMicrotask(() => callback(argArray, originalRetVal)); + return originalRetVal; + }, + }) +} +/** + * When called, starts invoking `callback` whenever `object[methodName]` is called. + * This mutates `object` (but we try to cause as little side effects as possible). + * @param addOriginalProperty - whether to add a property to `object` that holds the original + * function. Juuuuust in case. Idk, maybe someone's making an extension that is supposed to + * work together with ours. + * @returns stop intercepting + */ +function startInterceptingMethodCalls< + T extends Record, + U extends KeysOfType any> & string, +>( + object: T, + methodName: U, + callback: (params: Parameters, retVal: ReturnType) => void, + addOriginalProperty = true +): () => void { + const original = object[methodName]; + // TODO fix: consider overriding the prototype instead (`MediaSource.prototype.addSourceBuffer`). + // (note that we can't do `MediaSource.prototype = new Proxy(MediaSource.prototype)` since + // the assignment wouldn't work: + // https://stackoverflow.com/questions/76366764/how-to-proxy-function-prototype). + // Why? Because a website may call something like + // `Object.getPrototypeOf(object).addSourceBuffer.apply(object, args)`, + // which would bypass the override. + // This also applies to `startInterceptingSetters`. + // + // This is also probably good for performance as we're not creating a bunch of proxies for each + // object. + // + // That would, of course, require some refactoring since the same callback would be called for + // each object, so it needs to be universal. + object[methodName] = makeIntercepted(object[methodName], callback); + + const originalValuePropName = `_jumpCutterExtensionOriginal_${methodName}` as const; + type OriginalValuePropName = typeof originalValuePropName; + type MutatedOriginalObject = T & { + OriginalValuePropName?: T[U] + }; + if (addOriginalProperty) { + // Also consider `Object.defineProperty` with `enumberable: false`. + // @ts-expect-error 2322 Idk what's this about. + (object as MutatedOriginalObject)[originalValuePropName] = original; + } + + return () => { + object[methodName] = original; + delete (object as MutatedOriginalObject)[originalValuePropName]; + } +} + +// You might ask "why not just do `new Proxy(object, { set(...`". Well, how are you gonna do it +// for `addSourceBuffer`, smartass? +/** + * @param objectClass Must be the class that actually defines the `propName` property. + * May be any class of the `object`'s prototype chain, e.g. for a `MediaSource` instance + * it can be `MediaSource`, `EventTarget`, or `Object`. + */ +function startInterceptingSetters< + C extends new (...args: any) => any, + T extends InstanceType, + P extends keyof T, +>( + object: T, + propName: P, + objectClass: C, + callback: (newVal: T[P]) => void, +) { + const prototype = objectClass.prototype; + const originalDescriptor = Object.getOwnPropertyDescriptor(prototype, propName); + if (!originalDescriptor) { + if (IS_DEV_MODE) { + console.error('No such property.', object, propName, prototype); + } + return; + } + const originalSet = originalDescriptor.set; + if (!originalSet) { + if (IS_DEV_MODE) { + console.error('No setter for', propName, prototype, object); + } + return; + } + + // TODO `addOriginalProperty` as in `startInterceptingMethodCalls`. + Object.defineProperty(object, propName, { + ...originalDescriptor, + set(newVal, ...rest) { + const retVal = originalSet.call(this, newVal, ...rest); + queueMicrotask(() => callback(newVal)); + return retVal; + }, + }); + + return () => { + delete object[propName]; + } +} + +/** + * @returns stop intercepting + */ +function startInterceptingMediaSourceConstructorCalls( + callback: + ( + constructorArgs: ConstructorParameters, + newMediaSource: MediaSource, + ) => void, +): () => void { + // TODO refactor: this code is very similar to `startInterceptingMethod`. + + const original = MediaSource; + globalThis.MediaSource = new Proxy(MediaSource, { + construct(target, argArray, newTarget, ...rest) { + const originalRetVal = Reflect.construct(target, argArray, newTarget, ...rest); + queueMicrotask(() => callback(argArray, originalRetVal)); + return originalRetVal; + }, + // TODO refactor: this does not belong to the function named + // `startInterceptingMediaSourceConstructorCalls` + get(target, propName, receiver) { + if (propName === 'canConstructInDedicatedWorker') { + // We cannot intercept `MediaSource`s that are created inside dedicated workers. + // Let's try to trick the website into falling back to creating `MediaSource` in + // the page's context, where we can intercept it. + // This currently works on e.g. twitch.tv. + // TODO an option to turn this off. + return false; + } + return Reflect.get(target, propName, receiver) + }, + }); + + type MutatedGlobalThis = typeof globalThis & { + _jumpCutterExtensionOriginal_MediaSource?: typeof MediaSource + }; + // Also make it accessible to the whole page juuuust in case. + (globalThis as MutatedGlobalThis)._jumpCutterExtensionOriginal_MediaSource = original; + + return () => { + globalThis.MediaSource = original; + delete (globalThis as MutatedGlobalThis)._jumpCutterExtensionOriginal_MediaSource; + } +} + +function makeMaintainedMediaSourceClone( + originalMS: MediaSource, + constructorArgs: ConstructorParameters, +): MediaSource { + // The "MS" abbreviation means "MediaSource". + + // Calling the original one so that a clone is not created for the clone. + // TODO refactor: write this properly somehow, decouple. Maybe add a way to signal to the + // interceptor that this constructor is not to be intercepted? Extra constructor parameter? + // Or some global variable, like `dontCloneNextMediaSourceInstance`. Or make + // `startInterceptingMediaSourceConstructorCalls` also return `(pause/unpause)Intercepting`. + const cloneMS = + new _jumpCutterExtensionOriginal_MediaSource(...constructorArgs) as MediaSource; + + // Many mutations of `MediaSource` (like `addSourceBuffer`) throw if its state is not "open", + // so need to await. + // https://w3c.github.io/media-source/#dom-mediasource-addsourcebuffer + // TODO `readyState` can transition back from "open" actually, so maybe need to check and await + // every time, riughly like `execWhenSourceBufferReady`. + const cloneMSOpenP = new Promise(r => { + cloneMS.addEventListener('sourceopen', () => r(cloneMS), { once: true, passive: true }); + }); + + if (IS_DEV_MODE) { + const timeoutId = setTimeout(() => { + console.error("Created a clone `MediaSource` for", cloneMS, "5 seconds ago, but it's" + + " still not 'open'. Potential memory leak. See if" + + " `startAttachingCloneMediaSourcesToElements` is working"); + cloneMSOpenP.then(() => { + console.warn(cloneMS, "is finally 'open'. Sheesh, that took a while"); + }); + }, 5000); + cloneMSOpenP.then(() => clearTimeout(timeoutId)); + } + + const originalToCloneSourceBufferP = + new WeakMap>(); + + // TODO perf: `stopIntercepting`. + // + // Currently known functions that we're not intercepting: + // setLiveSeekableRange, clearLiveSeekableRange, + // `EventTarget` ones (`addEventListener`, `dispatchEvent`) + // + // We're probably not intercepting something we should. But this works for the majority of sites. + // + // TODO refactor: consider a black-list approach instead, for forwards compatibility, or + // maybe even dynamically determine which properties are settable + // (`Object.getOwnPropertyDescriptors(MediaSource.prototype)`). + startInterceptingMethodCalls(originalMS, 'addSourceBuffer', (params, originalSourceBuffer) => { + const cloneSourceBufferP = makeMaintainedSourceBufferCloneWhenOpen( + originalSourceBuffer, + params, + cloneMSOpenP + ); + originalToCloneSourceBufferP.set(originalSourceBuffer, cloneSourceBufferP); + }); + startInterceptingMethodCalls( + originalMS, + 'removeSourceBuffer', + ([originalSourceBuffer]) => { + const cloneSourceBufferP = originalToCloneSourceBufferP.get(originalSourceBuffer); + cloneSourceBufferP!.then(cloneSourceBuffer => cloneMS.removeSourceBuffer(cloneSourceBuffer)); + } + ); + // Tbh I'm not sure if it's of any use to replicate `endOfStream`. + startInterceptingMethodCalls(originalMS, 'endOfStream', (params) => { + // TODO this throws if one or more of the `SourceBuffer`s are `.updating === true`. + // https://developer.mozilla.org/en-US/docs/Web/API/MediaSource/endOfStream#exceptions + // M8, how am I supposed to track that? + cloneMSOpenP.then(cloneMS => cloneMS.endOfStream(...params)); + }); + + startInterceptingSetters(originalMS, 'duration', MediaSource, newVal => { + // TODO this throws if one or more of the `SourceBuffer`s are `.updating === true`. + // And I actually encountered it in the wild. + cloneMSOpenP.then(cloneMS => cloneMS.duration = newVal); + }); + + return cloneMS; +} + +/** + * @returns a Promise that resolves that the clone SourceBuffer + */ +function makeMaintainedSourceBufferCloneWhenOpen( + originalSourceBuffer: ReturnType, + addSourceBufferParams: Parameters, + cloneMediaSourceOpenP: Promise, +): Promise { + const cloneSourceBufferP = cloneMediaSourceOpenP.then(cloneMS => { + return cloneMS.addSourceBuffer(...addSourceBufferParams); + }); + + // TODO perf: `stopIntercepting`. + // + // TODO fix: appendBufferAsync, removeAsync + // Currently known functions that we're not intercepting: + // `EventTarget` ones (`addEventListener`, `dispatchEvent`) + // TODO there are also settable properties that we may need to intercept. + const methodNamesToReplicateCallsFor = [ + 'appendBuffer', + // `abort` may seem unimportant based on its name (like "let's just save time and not process" + // the last chunk"), but it actually also + // > resets the segment parser + // , which is important for when you perform a seek to an unbuffered range, i.e. when + // you `appendBuffer` that does not directly follow the last appended one in media-time + // (or something like that, I'm not good at codecs). + 'abort', + // We're replicating this mostly to avoid memory leaks, but I don't know if it's of essense + // to playback. + 'remove', + 'changeType', + ] as const /* satisfies Array */; + for (const methodName of methodNamesToReplicateCallsFor) { + // Yes, we need to start intercepting _before_ the `cloneSourceBuffer` is created, in order + // to not skip any calls. + startInterceptingMethodCalls(originalSourceBuffer, methodName, async (params) => { + const cloneSourceBuffer = await cloneSourceBufferP; + execWhenSourceBufferReady( + cloneSourceBuffer, + () => cloneSourceBuffer[methodName](...params), + ); + }); + } + // startInterceptingMethodCalls(originalSourceBuffer, 'appendBuffer', (params) => { + // execWhenSourceBufferReady( + // cloneSourceBuffer, + // () => cloneSourceBuffer.appendBuffer(...params), + // ); + // }); + // startInterceptingMethodCalls(originalSourceBuffer, 'changeType', (params) => { + // execWhenSourceBufferReady( + // cloneSourceBuffer, + // () => cloneSourceBuffer.changeType(...params), + // ); + // }); + // startInterceptingMethodCalls(originalSourceBuffer, 'remove', (params) => { + + const propNamesToReplicateSettersFor = [ + 'appendWindowStart', + 'appendWindowEnd', + 'mode', + 'timestampOffset', + ] as const /* satisfies Array */; + for (const propName of propNamesToReplicateSettersFor) { + startInterceptingSetters(originalSourceBuffer, propName, SourceBuffer, async newVal => { + const cloneSourceBuffer = await cloneSourceBufferP; + execWhenSourceBufferReady( + cloneSourceBuffer, + () => cloneSourceBuffer[propName] = newVal + ); + }); + } + + return cloneSourceBufferP; +} + +/** + * Execute `fn` synchronously when `sourceBuffer.updating` becomes `false`. + * If this function was called several times while `sourceBuffer.updating === true` then + * `fn`s are executed in the same order as this function was called. + */ +async function execWhenSourceBufferReady(sourceBuffer: SourceBuffer, fn: () => void) { + // TODO refactor: this comment is written with an assumption that the reader knows + // the context of where it is used. + // + // Why `while` instead of just `if`? Because if there were several calls to + // `originalSourceBuffer.appendBuffer` then several instances of this function could be + // `await`ing here. The event is gonna trigger all of them, but when the first awaiter + // does `cloneSourceBuffer.appendBuffer()`, it's gonna set `cloneSourceBuffer.updating` + // to `true` again. So the next awaiter is gonna need to wait again, hence `while`. + // + // This usually only happens during initialization, when `cloneMediaSource`'s 'sourceopen' + // fires. + // + // Don't worry, buffers are appended to the clone in the same order as they are appended + // to the original `SourceBuffer` (I believe so at least), in case `.mode === "sequence"`. + // + // Be careful if you decide to refactor this function as it is important that + // `fn` is executed _synchronously_ after the `sourceBuffer.updating` check, otherwise + // `sourceBuffer.updating` may actually be `true`. + // + // FYI we could also rewrite it using an explicit queue. + const uuid = Math.random(); + console.log('execWhenSourceBufferReady start', uuid); + while (sourceBuffer.updating) { + console.log('execWhenSourceBufferReady waiting...', uuid); + await new Promise(r => { + // TODO fix: wait, 'updateend' is not the only event that signals the transition of + // `.updating` from `true` to `false`. + // https://www.w3.org/TR/media-source-2/#sourcebuffer-events + // So there is an error when where `fn` is called in a different order than for the original + // sourceBuffer. If one call waits `execWhenSourceBufferReady` + // + // TODO fix: actually, I think it's not the only case where this can happen. + // Suppose currently `cloneSourceBuffer.updating === true` and the following website code + // gets executed: + // ``` + // originalSourceBuffer.appendBuffer(...); + // originalSourceBuffer.addEventListener('updateend', () => { + // originalSourceBuffer.appendBuffer(...)) + // }); + // ``` + // Also suppose that for every `SourceBuffer` after `appendBuffer` `.updating` becomes + // `false` for the next event loop. + // After this is executed, in terms of the event loop queue, the first thing in the queue + // is gonna be the first line's interceptor's `queueMicrotask`. Then there's gonna be the + // second line's addEventListener. After the `queueMicrotask`'s task is executed, + // `clone.addEventListener('updateend'` will get appended. + // wait, actually it's fine because of `queueMicrotask` again. + // + // Anyways, maybe it's time to switch to an explicit queue, as I said above? + sourceBuffer.addEventListener('updateend', r, { once: true, passive: true }); + }) + } + console.log('execWhenSourceBufferReady: executing', uuid); + try { + fn(); + } catch (e) { + console.log(e); + throw e; + } +} diff --git a/src/entry-points/content/cloneMediaSources/main-for-extension-world.ts b/src/entry-points/content/cloneMediaSources/main-for-extension-world.ts new file mode 100644 index 0000000..6b5939d --- /dev/null +++ b/src/entry-points/content/cloneMediaSources/main-for-extension-world.ts @@ -0,0 +1,78 @@ +/** + * @license + * Copyright (C) 2023 WofWca + * + * 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 . + */ + +import { browserOrChrome } from '@/webextensions-api-browser-or-chrome'; +import { BRIDGE_ELEMENT_ID_AND_PROP_NAME } from './constants'; + +sendBridgeElement(); + +// In Manifest V2 there is no direct way to execute a script in the page's +// [world](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/scripting/ExecutionWorld) +// , hence this helper script that does it. +// The approach is taken from: +// https://stackoverflow.com/questions/9515704/access-variables-and-functions-defined-in-page-context-using-a-content-script +// +// TODO fix: _this_ script runs at `document_start` (see `manifest.json`), but I'm not sure whether +// the script we inject below also runs before other scripts. Manifest V3 with its +// `content_scripts.world` should fix it. +// +// TODO add an option to disable the execution of this script. Because of it, now +// in Chromium if the user has "Site access" for this extension set to "activate on click", +// the browser will prompt them to reload the page after they do click on it. +// Also it slows down page load, and introduces potential side effects because it mutates +// built-in objects. +// `scripting.registerContentScripts()` or `contentScripts.register()` is the way. +// Or maybe add a way to execute it on `document_idle` as well, in case the target websites work +// ok that way. +const scriptEl = document.createElement('script'); +scriptEl.src = browserOrChrome.runtime.getURL('content/cloneMediaSources-for-page-world.js'); +// TODO perf: consider adding `defer`, `async`, etc. +scriptEl.onload = () => scriptEl.remove(); +// Wait, is it legal to inject scripts as direct children of `` (in case `document.head` is +// `undefined`? Appears to work, idk. +(document.head || document.documentElement).prepend(scriptEl); + +/** + * Since we can't directly pass objects such as `HTMLElement`s and `MediaSource`s using events + * and messages between worlds (see + * - https://stackoverflow.com/questions/9515704/access-variables-and-functions-defined-in-page-context-using-a-content-script/19312198#19312198 + * - https://developer.chrome.com/docs/extensions/mv3/content_scripts/#host-page-communication + * - https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Sharing_objects_with_page_scripts + * + * ), let's create a dummy element that both this world's script and the extension world's script + * can get a reference to through DOM, and use it like a message channel. + * Why not just use `document.body` for this purpose? To avoid side effects as much as possible. + * + * The page's world's script is supposed to remove the element from the DOM as soon as it gets + * a reference to it, see {@link receiveBridgeElement}. + */ +function sendBridgeElement(): void { + const el = document.createElement('div'); + el.id = BRIDGE_ELEMENT_ID_AND_PROP_NAME; + (document.body || document.documentElement).append(el); + + // This is so other scripts of this (the extension's) world (not the page's world) + // can get a reference to it as well. This does nothing to the page's world. + (globalThis as GlobalThisWithBridgeElement)[BRIDGE_ELEMENT_ID_AND_PROP_NAME] = el; +} + +export type GlobalThisWithBridgeElement = typeof globalThis & { + [BRIDGE_ELEMENT_ID_AND_PROP_NAME]: HTMLDivElement; +}; diff --git a/src/entry-points/content/cloneMediaSources/main-for-page-world.ts b/src/entry-points/content/cloneMediaSources/main-for-page-world.ts new file mode 100644 index 0000000..bbe61dd --- /dev/null +++ b/src/entry-points/content/cloneMediaSources/main-for-page-world.ts @@ -0,0 +1,222 @@ +/** + * @license + * Copyright (C) 2023 WofWca + * Copyright (C) 2023 Jonas Herzig + * + * 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 . + */ + +import { BRIDGE_ELEMENT_ID_AND_PROP_NAME, GET_CLONE_REQUEST_EVENT_NAME, GET_CLONE_RESPONSE_EVENT_NAME } from "./constants"; +import { startCloningMediaSources } from "./lib"; + +/** + * This script is executed in the environment (world) of the web page, unlike regular + * extension scripts. + * Be careful not to introduce security vulnerabilities. + * - https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/scripting/ExecutionWorld + * - https://developer.chrome.com/docs/extensions/reference/scripting/#type-ExecutionWorld + * Why do we need to execute in this world? Because its relies on modifying built-in objects, + * which is something that world isolation is supposed to prevent: + * - https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Content_scripts#content_script_environment + * - https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Sharing_objects_with_page_scripts + * + * This script is needed to support the `ElementPlaybackControllerCloning` for `HTMLMediaElement`s + * that use `MediaSource` (either with `el.src = URL.createObjectURL(mediaSource)` or + * `el.srcObject = mediaSource`). + * For such elements, we can't simply do `cloneEl.src = originalEl.src` since the clone + * element would be unable to playback the media. See https://github.com/WofWca/jumpcutter/issues/2 + * TODO refactor: might need to support this claim with a link to a part of the spec. + * + * What we do instead is create a new `MediaSource` for each `MediaSource` that is created + * by the website and replicate all of the changes made to the original one to our clone. + * The resulting clone `MediaSource` can be used as the source for the clone `HTMLMediaElement` + * in `ElementPlaybackControllerCloning`. + * + * Therefore this script also needs to get executed before the page's script interacts with + * the APIs that we modify here. + */ + +const bridgeElement = receiveBridgeElement(); + +const [ + getMediaSourceFromObjectUrl, + getCloneElement, + stopCloningMediaSources, +] = startCloningMediaSources(); + +// Just for testing. +if (IS_DEV_MODE) { + window.testCloneMediaSources = [ + getMediaSourceFromObjectUrl, + getCloneElement, + stopCloningMediaSources, + ]; + window[BRIDGE_ELEMENT_ID_AND_PROP_NAME] = bridgeElement; +} + +// Keep in mind that the website itself (or other extensions) can also dispatch such an event. +// TODO perf: `removeEventListener` when appropriate (when the extension is disabled, idk). +document.addEventListener(GET_CLONE_REQUEST_EVENT_NAME, async e_ => { + if (!(e_ instanceof CustomEvent)) { + console.warn('Not a CustomEvent'); + return; + } + const e: CustomEvent = e_; + // TODO fix: the extension might request a clone for an element that is not actually + // in the document tree, so this event won't get caught. + // Such elements can still play audio: + // https://html.spec.whatwg.org/multipage/media.html#playing-the-media-resource + // > Media elements that are potentially playing while not in a document must + // > not play any video, but should play any audio component + // therefore they still are a valid target for us. Thought websites are rarely made this way. + const originalEl = e.target; + if (!(originalEl instanceof HTMLMediaElement)) { + console.warn('Requested a clone for an element that is not an HTMLMediaElement'); + return; + } + + // Let's wait an event cycle, just in case the clone has not yet been created. + // Though currently I don't know a particular case where this is necessary. + // TODO or maybe just return whatever we have and rely on the requester to + // maybe retry in a while. + await new Promise(r => setTimeout(r)); + + const cloneEl = getCloneElement(originalEl); + if (!cloneEl) { + // TODO return an error response. + return; + } + + // Keep in mind that the original element's source might change before our response is + // received. + + // The receiver will remove the clone element from the bridge element. + bridgeElement.appendChild(cloneEl); + cloneEl.dispatchEvent(new CustomEvent(GET_CLONE_RESPONSE_EVENT_NAME, { + bubbles: true, // The event shall be received on the bridgeElement. + // TODO send `requestId`. + detail: { + requestId: e.detail?.requestId, + } + })); +}, { passive: true }); +// async function makeResponse(e: Event) { +// } +// function getCloneElement(originalEl: HTMLMediaElement) { +// } + +// // https://stackoverflow.com/questions/9515704/access-variables-and-functions-defined-in-page-context-using-a-content-script/19312198#19312198 +// const eventName = 'cloneMediaSourceFromObjectUrlRequest'; +// document.addEventListener(eventName, e_ => { +// // `unknown` because the website may create such events as well, so we need to be careful. +// const e = e_ as CustomEvent; +// const url = e.detail?.url; +// if (typeof url !== 'string') { +// throw new Error('Invalid event data'); +// } +// const originalMediaSource = getMediaSourceFromObjectUrl(url); +// if (!originalMediaSource) { +// // TODO respond with error. +// return; +// } + +// // const mediaSourceClone = getCloneElement(originalMediaSource); +// // if (!mediaSourceClone) { +// // // TODO respond with error. +// // return; +// // } + +// const cloneElement = getCloneElement(originalMediaSource); +// if (!cloneElement) { +// // TODO respond with error. +// return; +// } +// bridgeElement.appendChild(cloneElement); + + + +// // const dummyElement = document.createElement('audio'); +// // dummyElement.id = Math.random(); +// // // dummyElement.srcObject = mediaSourceClone; +// // // dummyElement.src = URL.createObjectURL(mediaSourceClone); + +// // const cloneUrl = (URL as MutatedURL)._jumpCutterExtensionOriginal_createObjectURL(mediaSourceClone); +// // // dummyElement.src = cloneUrl; +// // // setTimeout(() => URL.revokeObjectURL(dummyElement.src)); + +// // // TODO `revokeObjectURL` +// // // dummyElement.play() +// // dummyElement.style.display = 'none'; +// // // TODO but it's not a dummy element, it's the actual clone right now. +// // document.body.appendChild(dummyElement); +// // console.log('created dummyElement', dummyElement) + + + + +// // // TODO this is just for testing. Remove this. +// // const cloneAudio = document.createElement('audio'); +// // // cloneAudio.srcObject = cloneMediaSource; +// // cloneAudio.src = (URL as MutatedURL)._jumpCutterExtensionOriginalCreateObjectURL( +// // mediaSourceClone +// // ); +// // cloneAudio.play() +// // console.log(cloneAudio) + + + + + + + +// // document.dispatchEvent( +// cloneElement.dispatchEvent( +// new CustomEvent('cloneMediaSourceFromObjectUrlResponse', { +// bubbles: true, // The event shall be received on the bridge element. +// detail: { +// url, +// // TODO fucksksks. In Chromium such objects cannot be passed apparently +// // (`event.detail === null` if we try to do that). In Gecko it works. But to send something +// // in the other direction you have to use `cloneInto`. See +// // https://stackoverflow.com/questions/9515704/access-variables-and-functions-defined-in-page-context-using-a-content-script/19312198#19312198 +// // https://developer.chrome.com/docs/extensions/mv3/content_scripts/#host-page-communication +// // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Sharing_objects_with_page_scripts +// // Or maybe try `window.postMessage`? + +// // TODO refactor: naming seems inconsistent. +// // cloneMediaSource: mediaSourceClone + + +// // testEl: document.createElement('video') + + +// // dummyElementId: dummyElement.id + +// // cloneUrl, +// } +// }) +// ); +// }); + +/** + * @see {@link sendBridgeElement} + */ +function receiveBridgeElement(): HTMLDivElement { + const el = document.getElementById(BRIDGE_ELEMENT_ID_AND_PROP_NAME) as HTMLDivElement; + el.id = ''; // No need for it anymore. + el.remove(); + return el; +} diff --git a/src/entry-points/content/cloneMediaSources/startAttachingCloneMediaSourcesToElements.ts b/src/entry-points/content/cloneMediaSources/startAttachingCloneMediaSourcesToElements.ts new file mode 100644 index 0000000..befddab --- /dev/null +++ b/src/entry-points/content/cloneMediaSources/startAttachingCloneMediaSourcesToElements.ts @@ -0,0 +1,82 @@ +/** + * @license + * Copyright (C) 2023 WofWca + * + * 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 . + */ + +import { NEW_MEDIA_SOURCE_CLONE_EVENT_NAME } from "./constants"; + +// "Hold up, why can't we attach clone `MediaSource`s to clone `HTMLMediaElement`s or demand, +// when we need a clone element to play back?". TL;DR: to avoid a memory leak. +// Well, look at how the `makeMaintainedMediaSourceClone` function works. +// Practically every operation on a `MediaSource` requires its `readyState` to be `"open"`. +// So, when an operation is performed on the original `MediaSource`, we re-apply the operation +// on the clone _through `cloneMSOpenP.then`_. This means that operations are queued until the +// clone's state is "open". And they're not light-weight, especially the `appendBuffer` ones, +// which are a huge memory leak hazard. + +type ObjectUrlToElementMap = Map< + ReturnType, + // `WeakRef` for better garbage collection. If the original `MediaSource` goes away (no longer + // referenced by the page), the clone `MediaSource` is also supposed to go away (see), and so + // is the element that uses the clone `MediaSource`. + // + // I assume that as long as a `MediaSource` is around, the `HTMLMediaElement` that it's attached + // to is also kept (i.e. `MediaSource` keeps a strong reference to the said `HTMLMediaElement`). + // Maybe there is a way to write stuff without relying on this assumption, e.g. keep a + // `WeakMap`, but unfortunately with the current + // arhitecture we can't get a reference to the clone `MediaSource` here. + // + // See `createMaintainedObjectUrlToMediaSourceMap`, it's very similar. + // TODO perf: in that case also need to remove the `WeakRef` itself from the map, using + // `FinalizationRegistry` + WeakRef +>; + +function warn(...args: unknown[]) { + console.warn(...args); +} + +// export function startAttachingCloneMediaSourcesToElements(): [ +// cloneObjectURLToCloneElement: ObjectUrlToElementMap, +// stop: () => void, +// ] { +// const map: ObjectUrlToElementMap = new Map>(); + +// // Keep in mind that we receive these events from the page's context. +// // They can be website-generated, or manipulated. Treat them as potentially malicious. + +// // /** @returns Whether the event was handled successfully */ +// // const handleEvent: () => boolean + +// const listener = (e: Event) => { +// if (!(e instanceof CustomEvent)) { +// // Idk if this actually can happen, given that there is no standart event with such name. +// warn('Not a CustomEvent'); +// return; +// } + + +// }; +// document.addEventListener(NEW_MEDIA_SOURCE_CLONE_EVENT_NAME, listener, { passive: true }); + +// return [ +// () => { +// document.removeEventListener(NEW_MEDIA_SOURCE_CLONE_EVENT_NAME, listener); +// } +// ]; +// } diff --git a/src/manifest.json b/src/manifest.json index fd6a678..70cbaff 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -21,9 +21,17 @@ "all_frames": true, "run_at": "document_idle", "match_about_blank": true + }, + { + "matches": ["http://*/*", "https://*/*"], + "js": ["content/cloneMediaSources-for-extension-world.js"], + "all_frames": true, + "run_at": "document_start", + "match_about_blank": true } ], "web_accessible_resources": [ + "content/cloneMediaSources-for-page-world.js", "content/SilenceDetectorProcessor.js", "content/VolumeFilterProcessor.js", "chunks/*.js" diff --git a/webpack.config.js b/webpack.config.js index da6a998..ec31d6e 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -65,7 +65,13 @@ module.exports = env => { rules: [ { test: /\.tsx?$/, - use: 'ts-loader', + use: { + loader: 'ts-loader', + options: { + // Prototyping + transpileOnly: true, + }, + }, exclude: /node_modules/, }, { @@ -93,6 +99,12 @@ module.exports = env => { entry: { content: './src/entry-points/content/main.ts', + // Yes, the following are also entry points, but I'm not convinced they should be put in a + // separate `./src/entry-points/*` directory. + 'cloneMediaSources-for-extension-world': + './src/entry-points/content/cloneMediaSources/main-for-extension-world.ts', + 'cloneMediaSources-for-page-world': + './src/entry-points/content/cloneMediaSources/main-for-page-world.ts', SilenceDetectorProcessor: './src/entry-points/content/SilenceDetector/SilenceDetectorProcessor.ts', VolumeFilterProcessor: './src/entry-points/content/VolumeFilter/VolumeFilterProcessor.ts', @@ -107,7 +119,14 @@ module.exports = env => { path: path.resolve(__dirname, `dist-${env.browser}`), filename: (pathData, assetInfo) => { const chunkName = pathData.chunk.name; - if (['SilenceDetectorProcessor', 'VolumeFilterProcessor'].includes(chunkName)) { + if ( + [ + 'cloneMediaSources-for-extension-world', + 'cloneMediaSources-for-page-world', + 'SilenceDetectorProcessor', + 'VolumeFilterProcessor' + ].includes(chunkName) + ) { return `content/${chunkName}.js`; } return `${chunkName}/main.js`;