mirror of
https://github.com/ilyhalight/voice-over-translation.git
synced 2026-10-13 13:10:02 +00:00
fix(#1752): fix replacement video by firefox (not tested)
This commit is contained in:
parent
647bab14cf
commit
20e6f3db25
5 changed files with 217 additions and 15 deletions
22
bun.lock
22
bun.lock
|
|
@ -6,17 +6,17 @@
|
|||
"name": "voice-over-translation",
|
||||
"dependencies": {
|
||||
"@toil/gm-types": "^1.0.5",
|
||||
"@vot.js/ext": "^2.4.19",
|
||||
"@vot.js/shared": "^2.4.19",
|
||||
"@vot.js/ext": "^2.4.20",
|
||||
"@vot.js/shared": "^2.4.20",
|
||||
"bowser": "^2.14.1",
|
||||
"chaimu": "^1.0.6",
|
||||
"chaimu": "^1.1.0",
|
||||
"lit-html": "^3.3.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@crxjs/vite-plugin": "^2.7.1",
|
||||
"@toil/translate": "^1.0.9",
|
||||
"@types/bun": "^1.3.14",
|
||||
"@vot.js/core": "^2.4.19",
|
||||
"@vot.js/core": "^2.4.20",
|
||||
"lefthook": "^2.1.10",
|
||||
"npm-run-all2": "^9.0.2",
|
||||
"sass": "^1.101.0",
|
||||
|
|
@ -91,11 +91,11 @@
|
|||
|
||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, ""],
|
||||
|
||||
"@vot.js/core": ["@vot.js/core@2.4.19", "", { "dependencies": { "@vot.js/shared": "^2.4.19" }, "peerDependencies": { "typescript": "^6.0.0" } }, "sha512-qiu0FHFYH6jPzYJPHQ02VD0iu5ULMiHfNHe1hyHpaEmnygxSCuxKCAn0pMkLIyt32bwWBBfAUUu34k1ZLgGHSQ=="],
|
||||
"@vot.js/core": ["@vot.js/core@2.4.20", "", { "dependencies": { "@vot.js/shared": "^2.4.20" }, "peerDependencies": { "typescript": "^6.0.0" } }, "sha512-BzI3Lw7NbSKQh2H74swSemgkNK+/l7L6xK2R6yONGjyS2minrWptrnoPjz6TN2zUndD0Gd2ZPnWevWlFDRMmKw=="],
|
||||
|
||||
"@vot.js/ext": ["@vot.js/ext@2.4.19", "", { "dependencies": { "@vot.js/core": "^2.4.19", "@vot.js/shared": "^2.4.19" }, "peerDependencies": { "typescript": "^6.0.0" } }, "sha512-UYEEx6OQQvYTE1MvohbsFu1u25Vx1/Df3W80fTPCsMu74C02PsHy/eDJ4UP/rXXPjNwRBnTsom0dMN339WhShg=="],
|
||||
"@vot.js/ext": ["@vot.js/ext@2.4.20", "", { "dependencies": { "@vot.js/core": "^2.4.20", "@vot.js/shared": "^2.4.20" }, "peerDependencies": { "typescript": "^6.0.0" } }, "sha512-1IZCFUjDWpdrG1Xk9rfJOHguQWxPari5DBtqWKePeLRBS7TOxgT4fCW0v40Movcxy6o9F90ddnLd2TGlzvwgWw=="],
|
||||
|
||||
"@vot.js/shared": ["@vot.js/shared@2.4.19", "", { "dependencies": { "@bufbuild/protobuf": "^2.0.0" }, "peerDependencies": { "typescript": "^6.0.0" } }, "sha512-yo3AjuSP11C/xnhUSe0aNirZLwLdRdH2+Ak3jpCQ5wspjlgNlHy9cS1cjScYtk/ievcx7pSAJkwiitU1UKFICQ=="],
|
||||
"@vot.js/shared": ["@vot.js/shared@2.4.20", "", { "dependencies": { "@bufbuild/protobuf": "^2.0.0" }, "peerDependencies": { "typescript": "^6.0.0" } }, "sha512-8eaw9flGwpLL32DVkQZMRTW7mRFGBYUUNvwOLa7w5RQmKDvgOHL5hsII0sMQ7zM+rMDu7mO4+L7TcjQL1Z8I4Q=="],
|
||||
|
||||
"@webcomponents/custom-elements": ["@webcomponents/custom-elements@1.6.0", "", {}, "sha512-CqTpxOlUCPWRNUPZDxT5v2NnHXA4oox612iUGnmTUGQFhZ1Gkj8kirtl/2wcF6MqX7+PqqicZzOCBKKfIn0dww=="],
|
||||
|
||||
|
|
@ -113,7 +113,7 @@
|
|||
|
||||
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||
|
||||
"chaimu": ["chaimu@1.0.6", "", {}, "sha512-9PLbKaD1+RLc+BzLoQzSZ57rmuy0yKQ+5yrfHSwDS9CNkMvn8VZJDXiqPio8Ic7gSA5yiECpal8+4rJwQGBXiQ=="],
|
||||
"chaimu": ["chaimu@1.1.0", "", {}, "sha512-lctamq+xsBJzy5gr/RjtVzUp7ByWZVdOxnh28jkOe49h/B+DENh5qRf3E01jLbrekncNUQJ2B7jgd/yzVBSeTQ=="],
|
||||
|
||||
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
||||
|
||||
|
|
@ -151,6 +151,8 @@
|
|||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
|
||||
|
||||
"immutable": ["immutable@5.1.5", "", {}, "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A=="],
|
||||
|
|
@ -271,6 +273,8 @@
|
|||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||
|
||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||
|
|
@ -287,6 +291,8 @@
|
|||
|
||||
"which": ["which@7.0.0", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-RancgH2dmbLdHl6LRhEqvklWMgl/Hdnun0Y90KhBOLkMefg8Qa7/Zel8Sm+8HEcP6DEjzsWzpkuBQEZok58isA=="],
|
||||
|
||||
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
|
||||
|
||||
"zip-a-folder": ["zip-a-folder@6.1.3", "", { "dependencies": { "lzma": "^2.3.2", "tinyglobby": "^0.2.17" }, "bin": { "zip-a-folder": "dist/cli.mjs" } }, "sha512-6A4bKRyxLv9oVZwcBuF8zkV9YOdGD0XCVUgCKWiuK9lBXTQ3WSDYzGsA0ROSFfX2alGfvpf+zYtrfof2DFOGzg=="],
|
||||
|
||||
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, ""],
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
"@crxjs/vite-plugin": "^2.7.1",
|
||||
"@toil/translate": "^1.0.9",
|
||||
"@types/bun": "^1.3.14",
|
||||
"@vot.js/core": "^2.4.19",
|
||||
"@vot.js/core": "^2.4.20",
|
||||
"lefthook": "^2.1.10",
|
||||
"npm-run-all2": "^9.0.2",
|
||||
"sass": "^1.101.0",
|
||||
|
|
@ -46,10 +46,10 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@toil/gm-types": "^1.0.5",
|
||||
"@vot.js/ext": "^2.4.19",
|
||||
"@vot.js/shared": "^2.4.19",
|
||||
"@vot.js/ext": "^2.4.20",
|
||||
"@vot.js/shared": "^2.4.20",
|
||||
"bowser": "^2.14.1",
|
||||
"chaimu": "^1.0.6",
|
||||
"chaimu": "^1.1.0",
|
||||
"lit-html": "^3.3.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1461,6 +1461,22 @@ export class VideoHandler {
|
|||
*/
|
||||
handleSrcChanged = () => this.lifecycleController.handleSrcChanged();
|
||||
|
||||
/** Rebinds an active handler when the page replaces its video element. */
|
||||
async replaceVideo(video: HTMLVideoElement): Promise<void> {
|
||||
if (this.video === video) return;
|
||||
|
||||
debug.log("[VideoHandler] replaceVideo", video);
|
||||
await this.audioPlayer.replaceVideo(video);
|
||||
this.abortController.abort();
|
||||
this.releaseExtraEvents();
|
||||
|
||||
this.video = video;
|
||||
this.abortController = new AbortController();
|
||||
this.fullscreenHelper?.updateVideo(video);
|
||||
this.resetSubtitlesWidget();
|
||||
this.initExtraEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases resources and removes event listeners.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
import type { ServiceConf } from "@vot.js/ext/types/service";
|
||||
import { getVideoID } from "@vot.js/ext/utils/videoData";
|
||||
import { GM_fetch } from "../utils/gm";
|
||||
import type { VideoObserver } from "../utils/VideoObserver";
|
||||
|
||||
type VideoHandlerLike = {
|
||||
init(): Promise<void>;
|
||||
setCanPlay(): Promise<void>;
|
||||
getVideoData(): Promise<unknown>;
|
||||
videoData?: unknown;
|
||||
hasActiveSource(): boolean;
|
||||
replaceVideo(video: HTMLVideoElement): Promise<void>;
|
||||
site: ServiceConf;
|
||||
videoData?: { videoId?: string };
|
||||
release(): Promise<void> | void;
|
||||
};
|
||||
|
||||
|
|
@ -23,6 +28,10 @@ type BindObserverListenersOptions = {
|
|||
container: HTMLElement,
|
||||
site: ServiceConf,
|
||||
) => VideoHandlerLike;
|
||||
resolveVideoId?: (
|
||||
site: ServiceConf,
|
||||
video: HTMLVideoElement,
|
||||
) => Promise<string | undefined>;
|
||||
};
|
||||
|
||||
type SiteContainerMatch = {
|
||||
|
|
@ -43,6 +52,8 @@ export function bindObserverListeners(
|
|||
getServicesCached,
|
||||
findContainer,
|
||||
createVideoHandler,
|
||||
resolveVideoId = (site, video) =>
|
||||
getVideoID(site, { fetchFn: GM_fetch, video }),
|
||||
} = options;
|
||||
|
||||
if (boundObservers.has(videoObserver)) return;
|
||||
|
|
@ -78,7 +89,9 @@ export function bindObserverListeners(
|
|||
} catch (error) {
|
||||
console.error(`[VOT] Failed to release videoHandler (${reason})`, error);
|
||||
} finally {
|
||||
videosWrappers.delete(video);
|
||||
if (videosWrappers.get(video) === videoHandler) {
|
||||
videosWrappers.delete(video);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -101,6 +114,37 @@ export function bindObserverListeners(
|
|||
: site;
|
||||
};
|
||||
|
||||
const tryReplaceVideo = async (
|
||||
oldVideo: HTMLVideoElement,
|
||||
newVideo: HTMLVideoElement,
|
||||
container: HTMLElement,
|
||||
): Promise<boolean> => {
|
||||
const videoHandler = videosWrappers.get(oldVideo);
|
||||
const previousVideoId = videoHandler?.videoData?.videoId;
|
||||
if (!videoHandler?.hasActiveSource() || !previousVideoId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const nextVideoId = await resolveVideoId(videoHandler.site, newVideo);
|
||||
if (nextVideoId !== previousVideoId) return false;
|
||||
await videoHandler.replaceVideo(newVideo);
|
||||
} catch (error) {
|
||||
console.error("[VOT] Failed to replace video element", error);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (videosWrappers.get(oldVideo) !== videoHandler) {
|
||||
return videosWrappers.get(newVideo) === videoHandler;
|
||||
}
|
||||
videosWrappers.delete(oldVideo);
|
||||
videoContainers.delete(oldVideo);
|
||||
videosWrappers.set(newVideo, videoHandler);
|
||||
videoContainers.set(newVideo, container);
|
||||
containerOwners.set(container, newVideo);
|
||||
return true;
|
||||
};
|
||||
|
||||
const promotePendingVideo = async (
|
||||
container?: HTMLElement,
|
||||
): Promise<void> => {
|
||||
|
|
@ -140,6 +184,10 @@ export function bindObserverListeners(
|
|||
return;
|
||||
}
|
||||
|
||||
if (await tryReplaceVideo(activeVideoForContainer, video, container)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await releaseVideoHandler(activeVideoForContainer, "stale container");
|
||||
clearContainerOwner(activeVideoForContainer);
|
||||
}
|
||||
|
|
@ -193,7 +241,22 @@ export function bindObserverListeners(
|
|||
videoObserver.onVideoAdded.addListener(handleVideoAdded);
|
||||
|
||||
videoObserver.onVideoRemoved.addListener(async (video) => {
|
||||
const container = clearContainerOwner(video);
|
||||
const container = videoContainers.get(video);
|
||||
const replacement =
|
||||
container &&
|
||||
Array.from(container.querySelectorAll("video")).find(
|
||||
(candidate) => candidate !== video && candidate.isConnected,
|
||||
);
|
||||
if (
|
||||
container &&
|
||||
replacement &&
|
||||
(await tryReplaceVideo(video, replacement, container))
|
||||
) {
|
||||
initializingVideos.delete(video);
|
||||
return;
|
||||
}
|
||||
|
||||
clearContainerOwner(video);
|
||||
await releaseVideoHandler(video, "video removed");
|
||||
initializingVideos.delete(video);
|
||||
if (container && pendingVideoByContainer.get(container) === video) {
|
||||
|
|
|
|||
117
tests/video-observer-binding.test.ts
Normal file
117
tests/video-observer-binding.test.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { bindObserverListeners } from "../src/bootstrap/videoObserverBinding";
|
||||
|
||||
class AsyncEvent<T> {
|
||||
private listener?: (value: T) => void | Promise<void>;
|
||||
|
||||
addListener(listener: (value: T) => void | Promise<void>): void {
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
async dispatch(value: T): Promise<void> {
|
||||
await this.listener?.(value);
|
||||
}
|
||||
}
|
||||
|
||||
function createFixture(resolveVideoId: (video: HTMLVideoElement) => string) {
|
||||
const added = new AsyncEvent<HTMLVideoElement>();
|
||||
const removed = new AsyncEvent<HTMLVideoElement>();
|
||||
const videos: HTMLVideoElement[] = [];
|
||||
const container = {
|
||||
querySelectorAll: () => videos,
|
||||
} as unknown as HTMLElement;
|
||||
const site = { host: "youtube", url: "https://youtu.be/" } as any;
|
||||
const wrappers = new WeakMap<HTMLVideoElement, any>();
|
||||
const handlers: any[] = [];
|
||||
|
||||
bindObserverListeners({
|
||||
videoObserver: {
|
||||
onVideoAdded: added,
|
||||
onVideoRemoved: removed,
|
||||
} as any,
|
||||
videosWrappers: wrappers,
|
||||
ensureRuntimeActivated: async () => {},
|
||||
getServicesCached: () => [site],
|
||||
findContainer: () => container,
|
||||
createVideoHandler: (video) => {
|
||||
const handler = {
|
||||
site,
|
||||
video,
|
||||
videoData: { videoId: resolveVideoId(video) },
|
||||
active: true,
|
||||
init: async () => {},
|
||||
setCanPlay: async () => {},
|
||||
getVideoData: async () => undefined,
|
||||
hasActiveSource() {
|
||||
return this.active;
|
||||
},
|
||||
async replaceVideo(nextVideo: HTMLVideoElement) {
|
||||
this.video = nextVideo;
|
||||
},
|
||||
async release() {
|
||||
this.released = true;
|
||||
},
|
||||
released: false,
|
||||
};
|
||||
handlers.push(handler);
|
||||
return handler;
|
||||
},
|
||||
resolveVideoId: async (_site, video) => resolveVideoId(video),
|
||||
});
|
||||
|
||||
return { added, container, handlers, removed, videos, wrappers };
|
||||
}
|
||||
|
||||
function createVideo(isConnected: boolean): HTMLVideoElement {
|
||||
return { isConnected } as HTMLVideoElement;
|
||||
}
|
||||
|
||||
describe("video observer binding", () => {
|
||||
test("reuses an active handler when the old video element is removed", async () => {
|
||||
const videoIds = new WeakMap<HTMLVideoElement, string>();
|
||||
const fixture = createFixture((video) => videoIds.get(video) ?? "");
|
||||
const oldVideo = createVideo(true);
|
||||
const newVideo = createVideo(true);
|
||||
videoIds.set(oldVideo, "same-video");
|
||||
videoIds.set(newVideo, "same-video");
|
||||
fixture.videos.push(oldVideo);
|
||||
|
||||
await fixture.added.dispatch(oldVideo);
|
||||
const handler = fixture.handlers[0];
|
||||
Object.defineProperty(oldVideo, "isConnected", { value: false });
|
||||
fixture.videos.splice(0, 1, newVideo);
|
||||
|
||||
await fixture.removed.dispatch(oldVideo);
|
||||
|
||||
expect(fixture.handlers).toHaveLength(1);
|
||||
expect(handler.released).toBe(false);
|
||||
expect(handler.video).toBe(newVideo);
|
||||
expect(fixture.wrappers.get(oldVideo)).toBeUndefined();
|
||||
expect(fixture.wrappers.get(newVideo)).toBe(handler);
|
||||
|
||||
await fixture.added.dispatch(newVideo);
|
||||
expect(fixture.handlers).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("keeps the normal lifecycle when the video ID changes", async () => {
|
||||
const videoIds = new WeakMap<HTMLVideoElement, string>();
|
||||
const fixture = createFixture((video) => videoIds.get(video) ?? "");
|
||||
const oldVideo = createVideo(true);
|
||||
const newVideo = createVideo(true);
|
||||
videoIds.set(oldVideo, "first-video");
|
||||
videoIds.set(newVideo, "second-video");
|
||||
fixture.videos.push(oldVideo);
|
||||
|
||||
await fixture.added.dispatch(oldVideo);
|
||||
const oldHandler = fixture.handlers[0];
|
||||
Object.defineProperty(oldVideo, "isConnected", { value: false });
|
||||
fixture.videos.splice(0, 1, newVideo);
|
||||
|
||||
await fixture.added.dispatch(newVideo);
|
||||
|
||||
expect(fixture.handlers).toHaveLength(2);
|
||||
expect(oldHandler.released).toBe(true);
|
||||
expect(fixture.wrappers.get(newVideo)).toBe(fixture.handlers[1]);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue