fix(#1752): fix replacement video by firefox (not tested)
Some checks are pending
Build / node (22.x) (push) Waiting to run
Build / extension (22.x) (push) Waiting to run
Build / bun (push) Waiting to run
Release / Build Release (push) Waiting to run
Release / create_release (push) Blocked by required conditions

This commit is contained in:
Toil 2026-07-22 14:36:55 +03:00
parent 647bab14cf
commit 20e6f3db25
No known key found for this signature in database
GPG key ID: 57248E2819D32070
5 changed files with 217 additions and 15 deletions

View file

@ -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" } }, ""],

View file

@ -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"
}
}

View file

@ -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.
*/

View file

@ -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) {

View 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]);
});
});