diff --git a/bun.lock b/bun.lock index aa9d54d5..72acdec8 100644 --- a/bun.lock +++ b/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" } }, ""], diff --git a/package.json b/package.json index e877f17f..c490df11 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/VideoHandler.ts b/src/VideoHandler.ts index 236403d0..c693ac09 100644 --- a/src/VideoHandler.ts +++ b/src/VideoHandler.ts @@ -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 { + 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. */ diff --git a/src/bootstrap/videoObserverBinding.ts b/src/bootstrap/videoObserverBinding.ts index 4610e166..3854aa4a 100644 --- a/src/bootstrap/videoObserverBinding.ts +++ b/src/bootstrap/videoObserverBinding.ts @@ -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; setCanPlay(): Promise; getVideoData(): Promise; - videoData?: unknown; + hasActiveSource(): boolean; + replaceVideo(video: HTMLVideoElement): Promise; + site: ServiceConf; + videoData?: { videoId?: string }; release(): Promise | void; }; @@ -23,6 +28,10 @@ type BindObserverListenersOptions = { container: HTMLElement, site: ServiceConf, ) => VideoHandlerLike; + resolveVideoId?: ( + site: ServiceConf, + video: HTMLVideoElement, + ) => Promise; }; 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 => { + 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 => { @@ -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) { diff --git a/tests/video-observer-binding.test.ts b/tests/video-observer-binding.test.ts new file mode 100644 index 00000000..37042c5f --- /dev/null +++ b/tests/video-observer-binding.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from "bun:test"; + +import { bindObserverListeners } from "../src/bootstrap/videoObserverBinding"; + +class AsyncEvent { + private listener?: (value: T) => void | Promise; + + addListener(listener: (value: T) => void | Promise): void { + this.listener = listener; + } + + async dispatch(value: T): Promise { + await this.listener?.(value); + } +} + +function createFixture(resolveVideoId: (video: HTMLVideoElement) => string) { + const added = new AsyncEvent(); + const removed = new AsyncEvent(); + 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(); + 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(); + 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(); + 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]); + }); +});