Fix controller visibility issues and improve Shadow DOM video detection

This commit is contained in:
joshpatra committed 2026-05-29 15:23:52 -04:00
1 parent e61d56b59a
commit f626c5aa33
3 files changed
+148 -15

No files matched your search

-5
View File
@@ -11,11 +11,6 @@
overflow: visible !important; 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 { .vsc-nosource {
display: none !important; display: none !important;
} }
+73 -10
View File
@@ -59,6 +59,7 @@ var YT_NATIVE_MIN = 0.25;
var YT_NATIVE_MAX = 2.0; var YT_NATIVE_MAX = 2.0;
var YT_NATIVE_STEP = 0.05; var YT_NATIVE_STEP = 0.05;
var vscObservedRoots = new WeakSet(); var vscObservedRoots = new WeakSet();
var vscConnectedScannedRoots = new WeakSet();
var requestIdle = var requestIdle =
typeof window.requestIdleCallback === "function" typeof window.requestIdleCallback === "function"
? window.requestIdleCallback.bind(window) ? window.requestIdleCallback.bind(window)
@@ -1039,6 +1040,22 @@ function scanNodeForMedia(node, parent, added) {
if (node.shadowRoot) { if (node.shadowRoot) {
observeRoot(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 <play-av>) 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) { function getScanNodeForRoot(root) {
@@ -1052,6 +1069,8 @@ function getScanNodeForRoot(root) {
function rootMayContainMedia(root) { function rootMayContainMedia(root) {
if (!root) return false; if (!root) return false;
if (root.nodeType === Node.DOCUMENT_NODE) return true; 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; if (typeof root.querySelector !== "function") return true;
try { try {
@@ -1061,6 +1080,7 @@ function rootMayContainMedia(root) {
} }
} }
function scanRootForMedia(root) { function scanRootForMedia(root) {
var scanRoot = getScanNodeForRoot(root); var scanRoot = getScanNodeForRoot(root);
if (!scanRoot) return; if (!scanRoot) return;
@@ -1071,13 +1091,30 @@ function scanRootForMedia(root) {
} }
function observeRoot(root) { function observeRoot(root) {
if (!root || vscObservedRoots.has(root)) return; if (!root) return;
vscObservedRoots.add(root);
setupListener(root); var isConnected = false;
attachMutationObserver(root); try {
attachMediaDetectionListeners(root); isConnected = root.nodeType === Node.DOCUMENT_NODE ||
if (rootMayContainMedia(root)) { root.isConnected ||
scanRootForMedia(root); (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) { chrome.storage.sync.get(tc.settings, function(storage) {
var storedBindings = Array.isArray(storage.keyBindings) var storedBindings = Array.isArray(storage.keyBindings)
? storage.keyBindings ? storage.keyBindings
@@ -1227,7 +1270,7 @@ chrome.storage.sync.get(tc.settings, function(storage) {
chrome.storage.sync.set({ keyBindings: tc.settings.keyBindings }); chrome.storage.sync.set({ keyBindings: tc.settings.keyBindings });
} }
captureSiteRuleBase(); captureSiteRuleBase();
patchAttachShadow(); // patchAttachShadow() is now called at top-level before this callback
// Add a listener for messages from the popup. // Add a listener for messages from the popup.
// We use a global flag to ensure the listener is only attached once. // We use a global flag to ensure the listener is only attached once.
if (!window.vscMessageListener) { if (!window.vscMessageListener) {
@@ -1832,8 +1875,10 @@ function defineVideoController() {
if (!this.video.src && !this.video.currentSrc) if (!this.video.src && !this.video.currentSrc)
wrapper.classList.add("vsc-nosource"); wrapper.classList.add("vsc-nosource");
if (tc.settings.startHidden) wrapper.classList.add("vsc-hidden"); if (tc.settings.startHidden) wrapper.classList.add("vsc-hidden");
// Use lower z-index for non-YouTube sites to avoid overlapping modals // z-index is handled by the base .vsc-controller CSS rule (2147483646).
if (!isOnYouTube()) wrapper.classList.add("vsc-non-youtube"); // 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 shadow = wrapper.attachShadow({ mode: "open" });
var shadowStylesheet = doc.createElement("link"); var shadowStylesheet = doc.createElement("link");
shadowStylesheet.rel = "stylesheet"; shadowStylesheet.rel = "stylesheet";
@@ -2481,6 +2526,24 @@ function initializeNow(doc, forceReinit = false) {
attachNavigationListeners(); attachNavigationListeners();
observeRoot(doc); observeRoot(doc);
// Delayed rescan to catch custom elements whose shadow roots were created
// before our attachShadow patch was installed (e.g. archive.org's <play-av>
// 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) { if (forceReinit) {
log("Force re-initialization requested", 4); log("Force re-initialization requested", 4);
refreshAllControllerGeometry(); refreshAllControllerGeometry();
+75
View File
@@ -160,4 +160,79 @@ describe("inject runtime", () => {
window.resetSettingsFromSiteRuleBase(); window.resetSettingsFromSiteRuleBase();
expect(window.tc.settings.subtitleNudgeEnabledByDefault).toBe(true); 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("<!doctype html><html><body></body></html>");
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();
});
}); });