diff --git a/inject.css b/inject.css index 8859189..851ca39 100644 --- a/inject.css +++ b/inject.css @@ -11,11 +11,6 @@ overflow: visible !important; } -/* Use minimal z-index for non-YouTube sites to avoid overlapping modals */ -.vsc-controller.vsc-non-youtube { - z-index: 1 !important; -} - .vsc-nosource { display: none !important; } diff --git a/inject.js b/inject.js index c6eb7b2..f78c1b4 100644 --- a/inject.js +++ b/inject.js @@ -59,6 +59,7 @@ var YT_NATIVE_MIN = 0.25; var YT_NATIVE_MAX = 2.0; var YT_NATIVE_STEP = 0.05; var vscObservedRoots = new WeakSet(); +var vscConnectedScannedRoots = new WeakSet(); var requestIdle = typeof window.requestIdleCallback === "function" ? window.requestIdleCallback.bind(window) @@ -1039,6 +1040,22 @@ function scanNodeForMedia(node, parent, added) { if (node.shadowRoot) { observeRoot(node.shadowRoot); } + + // Deep-scan descendant elements for shadow roots we haven't observed yet. + // This catches custom elements (like archive.org's ) whose shadow + // roots were created before our attachShadow patch was installed. + if (added && typeof node.querySelectorAll === "function") { + try { + var allElements = node.querySelectorAll("*"); + for (var j = 0; j < allElements.length; j++) { + if (allElements[j].shadowRoot && !vscObservedRoots.has(allElements[j].shadowRoot)) { + observeRoot(allElements[j].shadowRoot); + } + } + } catch (e) { + // querySelectorAll may throw on detached or unusual nodes + } + } } function getScanNodeForRoot(root) { @@ -1052,6 +1069,8 @@ function getScanNodeForRoot(root) { function rootMayContainMedia(root) { if (!root) return false; if (root.nodeType === Node.DOCUMENT_NODE) return true; + // Always scan shadow roots so we can find nested shadow roots or media. + if (root.host || (typeof ShadowRoot !== "undefined" && root instanceof ShadowRoot)) return true; if (typeof root.querySelector !== "function") return true; try { @@ -1061,6 +1080,7 @@ function rootMayContainMedia(root) { } } + function scanRootForMedia(root) { var scanRoot = getScanNodeForRoot(root); if (!scanRoot) return; @@ -1071,13 +1091,30 @@ function scanRootForMedia(root) { } function observeRoot(root) { - if (!root || vscObservedRoots.has(root)) return; - vscObservedRoots.add(root); - setupListener(root); - attachMutationObserver(root); - attachMediaDetectionListeners(root); - if (rootMayContainMedia(root)) { - scanRootForMedia(root); + if (!root) return; + + var isConnected = false; + try { + isConnected = root.nodeType === Node.DOCUMENT_NODE || + root.isConnected || + (root.host && (root.host.isConnected || (root.host.ownerDocument && root.host.ownerDocument.contains(root.host)))) || + (root.ownerDocument && root.ownerDocument.contains(root)); + } catch (e) { + isConnected = true; + } + + if (!vscObservedRoots.has(root)) { + vscObservedRoots.add(root); + setupListener(root); + attachMutationObserver(root); + attachMediaDetectionListeners(root); + } + + if (isConnected && !vscConnectedScannedRoots.has(root)) { + vscConnectedScannedRoots.add(root); + if (rootMayContainMedia(root)) { + scanRootForMedia(root); + } } } @@ -1122,6 +1159,12 @@ function log(message, level) { } } +// Patch attachShadow immediately — before any async operations — so we +// catch shadow roots created while chrome.storage.sync.get is pending. +// Sites like archive.org create Lit/LitElement shadow DOMs during page load; +// waiting for the storage callback would miss them entirely. +patchAttachShadow(); + chrome.storage.sync.get(tc.settings, function(storage) { var storedBindings = Array.isArray(storage.keyBindings) ? storage.keyBindings @@ -1227,7 +1270,7 @@ chrome.storage.sync.get(tc.settings, function(storage) { chrome.storage.sync.set({ keyBindings: tc.settings.keyBindings }); } captureSiteRuleBase(); - patchAttachShadow(); + // patchAttachShadow() is now called at top-level before this callback // Add a listener for messages from the popup. // We use a global flag to ensure the listener is only attached once. if (!window.vscMessageListener) { @@ -1832,8 +1875,10 @@ function defineVideoController() { if (!this.video.src && !this.video.currentSrc) wrapper.classList.add("vsc-nosource"); if (tc.settings.startHidden) wrapper.classList.add("vsc-hidden"); - // Use lower z-index for non-YouTube sites to avoid overlapping modals - if (!isOnYouTube()) wrapper.classList.add("vsc-non-youtube"); + // z-index is handled by the base .vsc-controller CSS rule (2147483646). + // The controller lives inside the video container, so high z-index only + // makes it topmost within the local stacking context — it won't overlay + // page-level modals or dialogs. var shadow = wrapper.attachShadow({ mode: "open" }); var shadowStylesheet = doc.createElement("link"); shadowStylesheet.rel = "stylesheet"; @@ -2481,6 +2526,24 @@ function initializeNow(doc, forceReinit = false) { attachNavigationListeners(); observeRoot(doc); + // Delayed rescan to catch custom elements whose shadow roots were created + // before our attachShadow patch was installed (e.g. archive.org's + // Lit component, or any site that lazily creates shadow DOM video players). + if (!doc.vscDelayedShadowScanDone) { + doc.vscDelayedShadowScanDone = true; + setTimeout(function() { + if (!doc.body) return; + try { + var els = doc.body.querySelectorAll("*"); + for (var i = 0; i < els.length; i++) { + if (els[i].shadowRoot && !vscObservedRoots.has(els[i].shadowRoot)) { + observeRoot(els[i].shadowRoot); + } + } + } catch (e) {} + }, 3000); + } + if (forceReinit) { log("Force re-initialization requested", 4); refreshAllControllerGeometry(); diff --git a/tests/inject.test.js b/tests/inject.test.js index a3852b2..34a5110 100644 --- a/tests/inject.test.js +++ b/tests/inject.test.js @@ -160,4 +160,79 @@ describe("inject runtime", () => { window.resetSettingsFromSiteRuleBase(); expect(window.tc.settings.subtitleNudgeEnabledByDefault).toBe(true); }); + + it("detects media inside dynamically added shadow DOMs", async () => { + await bootInject(); + + vi.useFakeTimers(); + + expect(window.vscAttachShadowPatched).toBe(true); + + const host = document.createElement("custom-player"); + const shadow = host.attachShadow({ mode: "open" }); + const video = document.createElement("video"); + video.src = "https://example.org/dynamic.mp4"; + shadow.appendChild(video); + document.body.appendChild(host); + + // Flush MutationObserver microtasks so that the observer callback runs + // and schedules requestIdleCallback's setTimeout. + await flushAsyncWork(); + // Run the scheduled timers (requestIdleCallback) + vi.runAllTimers(); + // Flush any remaining microtasks/promises + await flushAsyncWork(); + + expect(video.vsc).toBeDefined(); + expect(video.vsc.div).toBeDefined(); + expect(video.vsc.div.classList.contains("vsc-non-youtube")).toBe(false); + + vi.useRealTimers(); + }); + + it("detects media in pre-existing shadow DOMs via delayed rescan", async () => { + vi.useFakeTimers(); + loadHtmlString(""); + + const host = document.createElement("custom-player"); + const shadow = host.attachShadow({ mode: "open" }); + const video = document.createElement("video"); + video.src = "https://example.org/pre-existing.mp4"; + shadow.appendChild(video); + document.body.appendChild(host); + + globalThis.chrome = createChromeMock({ sync: {}, local: {} }); + window.chrome = globalThis.chrome; + globalThis.chrome.runtime.onMessage = { + addListener: vi.fn() + }; + const originalSyncGet = globalThis.chrome.storage.sync.get; + const originalLocalGet = globalThis.chrome.storage.local.get; + globalThis.chrome.storage.sync.get = vi.fn((keys, callback) => { + Promise.resolve().then(() => originalSyncGet(keys, callback)); + }); + globalThis.chrome.storage.local.get = vi.fn((keys, callback) => { + Promise.resolve().then(() => originalLocalGet(keys, callback)); + }); + + loadScript("shared/controller-utils.js"); + loadScript("shared/key-bindings.js"); + loadScript("shared/site-rules.js"); + loadScript("ui-icons.js"); + loadScript("inject.js"); + + // Fast-forward 3000ms for delayed rescan to trigger + vi.advanceTimersByTime(3000); + + for (let i = 0; i < 5; i += 1) { + await flushAsyncWork(); + } + + expect(video.vsc).toBeDefined(); + expect(video.vsc.div).toBeDefined(); + expect(video.vsc.div.classList.contains("vsc-non-youtube")).toBe(false); + + vi.useRealTimers(); + }); }); +