v6.0.9.1-beta.1

This commit is contained in:
joshpatra committed 2026-09-27 16:59:10 -04:00
commit c049705717
7 files changed
+523 -31

No files matched your search

+1
View File
@@ -11,6 +11,7 @@
/* Native media controls can fullscreen the replaced <video> element without
calling the page's requestFullscreen method. A manual popover keeps the
controller in the browser top layer without nesting it inside <video>. */
.vsc-controller.vsc-media-document,
.vsc-controller.vsc-fullscreen-popover {
position: fixed !important;
margin: 0 !important;
+99 -29
View File
@@ -1,3 +1,40 @@
// Keep in sync with the page-world bridge guard. These checks run before any
// startup hooks because verification frames inspect native DOM/history APIs.
function isChallengeUrl(value) {
try {
var url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
var hostname = url.hostname.toLowerCase().replace(/\.$/, "");
return (
hostname === "challenges.cloudflare.com" ||
hostname.endsWith(".challenges.cloudflare.com") ||
url.pathname === "/cdn-cgi/challenge-platform" ||
url.pathname.indexOf("/cdn-cgi/challenge-platform/") === 0
);
} catch (_error) {
return false;
}
}
function isChallengeDocument(win) {
try {
if (isChallengeUrl(win.location.href)) return true;
if (!/^about:(blank|srcdoc)(?:[?#]|$)/i.test(win.location.href)) {
return false;
}
if (
isChallengeUrl(win.document.baseURI) ||
isChallengeUrl(win.document.referrer)
) {
return true;
}
return win.parent !== win && isChallengeDocument(win.parent);
} catch (_error) {
return false;
}
}
var vscIsChallengeDocument = isChallengeDocument(window);
var lastToggleSpeed = {}; // Store last toggle speeds per video
var speederShared =
typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {};
@@ -1788,7 +1825,7 @@ function ensureController(node, parent) {
var fullscreenElement = getFullscreenElement(node.ownerDocument);
var normalMount = getControllerMount(node) || parent || node.parentNode;
if (normalMount) existingController.normalControllerMount = normalMount;
if (normalMount) setNormalControllerMount(existingController, normalMount);
if (
fullscreenElement &&
(fullscreenElement === node ||
@@ -2870,8 +2907,10 @@ function finishStartupStorageWatch() {
// 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();
installPageShadowBridge();
if (!vscIsChallengeDocument) {
patchAttachShadow();
installPageShadowBridge();
}
function loadInitialRuntimeSettings(attempt) {
chrome.storage.sync.get(null, function(rawStorage) {
@@ -3172,9 +3211,11 @@ function loadInitialRuntimeSettings(attempt) {
// Install before async settings hydration so SPA-owned window capture handlers
// cannot hide later key events from Speeder.
attachKeydownListeners(document);
beginStartupStorageWatch();
loadInitialRuntimeSettings(0);
if (!vscIsChallengeDocument) {
attachKeydownListeners(document);
beginStartupStorageWatch();
loadInitialRuntimeSettings(0);
}
function getKeyBindings(action, what = "value") {
try {
@@ -3329,6 +3370,24 @@ function getControllerGeometryMount(mount) {
return isShadowRootNode(mount) ? mount.host : mount;
}
function setNormalControllerMount(videoController, mount) {
videoController.normalControllerMount = mount;
// Some site integrations deliberately use a sibling overlay (e.g. Apple
// TV's scrim). Only invalidate containment for mounts that owned the media.
videoController.normalMountContainedMedia = isComposedDescendant(
videoController.video,
getControllerGeometryMount(mount)
);
}
function isNativeMediaDocument(doc) {
return Boolean(
doc &&
(doc.mozSyntheticDocument === true ||
/^(?:video\/|audio\/|application\/ogg(?:;|$))/i.test(doc.contentType || ""))
);
}
function getControllerMount(video, boundary) {
if (!video) return null;
@@ -3357,6 +3416,10 @@ function getControllerMount(video, boundary) {
return boundary;
}
if (isNativeMediaDocument(video.ownerDocument)) {
return video.ownerDocument.body;
}
// YouTube's .html5-video-container is often absolutely laid out and can
// report a zero-height box even while its video is visibly playing. The
// surrounding .html5-video-player is the real player/overlay boundary for
@@ -3444,7 +3507,10 @@ function positionControllerHost(wrapper, video, mount) {
}
var videoRect = video.getBoundingClientRect();
cacheVideoRect(wrapper, video, videoRect);
if (wrapper.classList.contains("vsc-fullscreen-popover")) {
if (
wrapper.classList.contains("vsc-fullscreen-popover") ||
wrapper.classList.contains("vsc-media-document")
) {
if (videoRect.width <= 0 || videoRect.height <= 0) {
wrapper.classList.add("vsc-geometry-hidden");
wrapper.style.setProperty("display", "none", "important");
@@ -3596,6 +3662,11 @@ function setupControllerHostTracking(videoController, wrapper, mount) {
var win = doc.defaultView || window;
var geometryMount = getControllerGeometryMount(mount);
if (!geometryMount || !geometryMount.style) return;
// Firefox's standalone media body can be 0px tall. Making it positioned
// replaces the viewport containing block of its absolutely positioned video
// and collapses playback. Anchor our own host to the viewport instead.
var nativeMediaDocument = isNativeMediaDocument(doc);
wrapper.classList.toggle("vsc-media-document", nativeMediaDocument);
var frameId = null;
var geometryRetryTimer = null;
var geometryRetryAttempts = 0;
@@ -3636,7 +3707,7 @@ function setupControllerHostTracking(videoController, wrapper, mount) {
frameId = win.requestAnimationFrame(update);
};
if (win.getComputedStyle(geometryMount).position === "static") {
if (!nativeMediaDocument && win.getComputedStyle(geometryMount).position === "static") {
geometryMount.dataset.vscPositionOwner = "true";
geometryMount.dataset.vscOriginalPosition =
geometryMount.style.getPropertyValue("position");
@@ -3646,7 +3717,7 @@ function setupControllerHostTracking(videoController, wrapper, mount) {
geometryMount.style.setProperty("position", "relative");
}
if (!createsControllerStackingContext(geometryMount)) {
if (!nativeMediaDocument && !createsControllerStackingContext(geometryMount)) {
geometryMount.dataset.vscIsolationOwner = "true";
geometryMount.dataset.vscOriginalIsolation =
geometryMount.style.getPropertyValue("isolation");
@@ -3844,7 +3915,7 @@ function enableDirectFullscreenPopover(videoController) {
);
if (!normalMountIsConnected) {
normalMount = getControllerMount(videoController.video);
videoController.normalControllerMount = normalMount;
setNormalControllerMount(videoController, normalMount);
}
if (!normalMount) return false;
if (wrapper.parentNode !== normalMount) {
@@ -3876,12 +3947,22 @@ function syncControllerFullscreenMount(videoController) {
var doc = video.ownerDocument;
var fullscreenElement = getFullscreenElement(doc);
var targetMount = videoController.normalControllerMount;
var normalGeometryMount = getControllerGeometryMount(targetMount);
if (
!normalGeometryMount ||
!normalGeometryMount.isConnected ||
(videoController.normalMountContainedMedia === true &&
!isComposedDescendant(video, normalGeometryMount))
) {
targetMount = getControllerMount(video);
setNormalControllerMount(videoController, targetMount);
normalGeometryMount = getControllerGeometryMount(targetMount);
}
var ownsFullscreen = Boolean(
fullscreenElement &&
(fullscreenElement === video ||
isComposedDescendant(video, fullscreenElement))
);
var normalGeometryMount = getControllerGeometryMount(targetMount);
var normalMountIsAlreadyFullscreenVisible = Boolean(
fullscreenElement &&
fullscreenElement !== video &&
@@ -3891,9 +3972,6 @@ function syncControllerFullscreenMount(videoController) {
if (ownsFullscreen && !normalMountIsAlreadyFullscreenVisible) {
targetMount = getControllerMount(video, fullscreenElement);
} else if (!fullscreenElement && (!targetMount || !targetMount.isConnected)) {
targetMount = getControllerMount(video);
videoController.normalControllerMount = targetMount;
}
if (!targetMount) return false;
@@ -4681,7 +4759,7 @@ function defineVideoController() {
) {
log("No suitable parent found, appending to body", 4);
doc.body.appendChild(wrapper);
this.normalControllerMount = doc.body;
setNormalControllerMount(this, doc.body);
setupControllerHostTracking(this, wrapper, doc.body);
return wrapper;
}
@@ -4718,14 +4796,14 @@ function defineVideoController() {
break;
}
mountEl.appendChild(wrapper);
this.normalControllerMount = mountEl;
setNormalControllerMount(this, mountEl);
setupControllerHostTracking(this, wrapper, mountEl);
log("Controller successfully inserted into DOM", 4);
} catch (error) {
log(`Error inserting controller: ${error.message}`, 2);
// Fallback to body insertion
doc.body.appendChild(wrapper);
this.normalControllerMount = doc.body;
setNormalControllerMount(this, doc.body);
setupControllerHostTracking(this, wrapper, doc.body);
}
@@ -5342,18 +5420,10 @@ function scheduleMediaLifecycleReconcile(media, videoController) {
}
var nextMount = getControllerMount(media);
if (
nextMount &&
(nextMount !== videoController.controllerHostMount ||
videoController.div.parentNode !== nextMount ||
!videoController.div.isConnected)
) {
remountControllerHost(videoController, nextMount);
return;
}
if (typeof videoController.controllerHostSchedule === "function") {
videoController.controllerHostSchedule();
}
if (nextMount) setNormalControllerMount(videoController, nextMount);
// A play/metadata event can arrive after fullscreenchange. Resolve the
// active fullscreen boundary again instead of restoring the normal host.
syncControllerFullscreenMount(videoController);
}
);
}
+40
View File
@@ -1,6 +1,46 @@
(function() {
"use strict";
function isChallengeUrl(value) {
try {
var url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
var hostname = url.hostname.toLowerCase().replace(/\.$/, "");
return (
hostname === "challenges.cloudflare.com" ||
hostname.endsWith(".challenges.cloudflare.com") ||
url.pathname === "/cdn-cgi/challenge-platform" ||
url.pathname.indexOf("/cdn-cgi/challenge-platform/") === 0
);
} catch (_error) {
return false;
}
}
function isChallengeDocument(win) {
try {
if (isChallengeUrl(win.location.href)) return true;
if (!/^about:(blank|srcdoc)(?:[?#]|$)/i.test(win.location.href)) {
return false;
}
// match_about_blank can inherit a challenge document's eligibility.
if (
isChallengeUrl(win.document.baseURI) ||
isChallengeUrl(win.document.referrer)
) {
return true;
}
return win.parent !== win && isChallengeDocument(win.parent);
} catch (_error) {
// A cross-origin ancestor is inaccessible; use only the URLs we can read.
return false;
}
}
// Challenge pages inspect native APIs. Leave the page untouched, including
// bridge flags and readiness events, even if the manifest was bypassed.
if (isChallengeDocument(window)) return;
function dispatchBridgeEvent(eventName) {
try {
document.dispatchEvent(
+1
View File
@@ -9,6 +9,7 @@
/* Important declarations inside a shadow tree outrank important page styles.
Repeat the top-layer positioning here so fullscreen cannot fall back to the
base absolute host rule. */
:host(.vsc-media-document),
:host(.vsc-fullscreen-popover) {
position: fixed !important;
margin: 0 !important;
+6 -2
View File
@@ -1,7 +1,7 @@
{
"name": "Speeder",
"short_name": "Speeder",
"version": "6.0.9.0",
"version": "6.0.9.1",
"manifest_version": 2,
"description": "Speed up, slow down, advance and rewind HTML5 audio/video with shortcuts (New and improved version of \"Video Speed Controller\")",
"homepage_url": "https://github.com/SoPat712/speeder",
@@ -55,7 +55,11 @@
"exclude_matches": [
"https://plus.google.com/hangouts/*",
"https://hangouts.google.com/*",
"https://meet.google.com/*"
"https://meet.google.com/*",
"*://*.challenges.cloudflare.com/*",
"*://*/cdn-cgi/challenge-platform",
"*://*/cdn-cgi/challenge-platform?*",
"*://*/cdn-cgi/challenge-platform/*"
],
"css": [
"content/inject.css"
+238
View File
@@ -0,0 +1,238 @@
import { JSDOM } from "jsdom";
import { afterEach, describe, expect, it, vi } from "vitest";
const { readWorkspaceFile } = require("./helpers/extension-test-utils");
const manifest = JSON.parse(
readWorkspaceFile("extension/manifest.json")
);
const bridgeSource = readWorkspaceFile(
"extension/content/shadow-bridge.js"
);
const injectSource = readWorkspaceFile(
"extension/content/inject.js"
);
const challengeUrls = [
"https://challenges.cloudflare.com/turnstile/v0/api.js",
"http://challenges.cloudflare.com/",
"https://nested.challenges.cloudflare.com/turnstile/",
"https://example.org/cdn-cgi/challenge-platform/h/b/turnstile/if/ov2/av0/",
"http://example.org/cdn-cgi/challenge-platform/",
"https://example.org/cdn-cgi/challenge-platform",
"https://example.org/cdn-cgi/challenge-platform?token=example#challenge"
];
const ordinaryUrls = [
"https://cloudflare.com/",
"https://www.cloudflare.com/products/stream/",
"https://watch.cloudflarestream.com/video",
"https://example.org/watch?v=123",
"https://example.org/cdn-cgi/image/width=320/video-poster.jpg",
"https://example.org/cdn-cgi/challenge-platform-guide",
"https://challenges.cloudflare.com.example.org/video",
"https://otherchallenges.cloudflare.com/video",
"https://example.org/watch?next=/cdn-cgi/challenge-platform/"
];
// Firefox match-pattern paths include the query string, but exclude fragments.
function matchesPattern(pattern, href) {
const [, scheme, host, path] = pattern.match(/^(\*|https?):\/\/([^/]+)(\/.*)$/);
const url = new URL(href);
const hostname = host.startsWith("*.") ? host.slice(2) : host;
const hostMatches =
host === "*" ||
url.hostname === hostname ||
(host.startsWith("*.") && url.hostname.endsWith("." + hostname));
const pathExpression = path
.split("*")
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
.join(".*");
return (
(scheme === "*" || url.protocol === scheme + ":") &&
hostMatches &&
new RegExp("^" + pathExpression + "$").test(url.pathname + url.search)
);
}
let activeDom;
function createWindow(url, options = {}) {
activeDom = new JSDOM("<!doctype html><html><body></body></html>", {
url,
runScripts: "dangerously",
...options
});
return activeDom.window;
}
function observeBridge(win) {
const ready = vi.fn();
const navigationReady = vi.fn();
const navigationListener = vi.fn();
win.document.addEventListener("speeder-page-bridge-ready", ready);
win.document.addEventListener(
"speeder-page-navigation-api-ready",
navigationReady
);
win.navigation = { addEventListener: navigationListener };
return {
attachShadow: win.Element.prototype.attachShadow,
pushState: win.history.pushState,
replaceState: win.history.replaceState,
ready,
navigationReady,
navigationListener
};
}
function expectInertBridge(win) {
const original = observeBridge(win);
win.eval(bridgeSource);
expect(win.Element.prototype.attachShadow).toBe(original.attachShadow);
expect(win.history.pushState).toBe(original.pushState);
expect(win.history.replaceState).toBe(original.replaceState);
expect(win.__speederPageShadowBridgeInstalled).toBeUndefined();
expect(win.__speederPageNavigationApiBridgeInstalled).toBeUndefined();
expect(original.ready).not.toHaveBeenCalled();
expect(original.navigationReady).not.toHaveBeenCalled();
expect(original.navigationListener).not.toHaveBeenCalled();
// The content runtime must be inert too, including when it is injected into
// an inherited blank frame outside the manifest's ordinary URL matching.
const addListener = vi.fn();
const getSettings = vi.fn();
win.chrome = {
runtime: { getURL: vi.fn(), onMessage: { addListener } },
storage: {
sync: { get: getSettings },
local: { get: getSettings },
onChanged: { addListener }
}
};
win.eval(injectSource);
expect(win.Element.prototype.attachShadow).toBe(original.attachShadow);
expect(win.history.pushState).toBe(original.pushState);
expect(win.history.replaceState).toBe(original.replaceState);
expect(win.document.querySelector("script")).toBeNull();
expect(win.vscKeydownListenerAttached).toBeUndefined();
expect(win.vscAttachShadowPatched).toBeUndefined();
expect(win.vscPageBridgeListenersAttached).toBeUndefined();
expect(getSettings).not.toHaveBeenCalled();
expect(addListener).not.toHaveBeenCalled();
expect(win.chrome.runtime.getURL).not.toHaveBeenCalled();
}
afterEach(() => {
if (activeDom) activeDom.window.close();
activeDom = null;
});
describe("challenge content-script exclusions", () => {
it("uses Firefox host and path match patterns for every content-script entry", () => {
for (const script of manifest.content_scripts) {
expect(script.exclude_matches).toEqual(
expect.arrayContaining([
"*://*.challenges.cloudflare.com/*",
"*://*/cdn-cgi/challenge-platform",
"*://*/cdn-cgi/challenge-platform?*",
"*://*/cdn-cgi/challenge-platform/*"
])
);
}
});
it.each(challengeUrls)("excludes challenge document %s", (url) => {
for (const script of manifest.content_scripts) {
expect(script.exclude_matches.some((pattern) => matchesPattern(pattern, url)))
.toBe(true);
}
});
it.each(ordinaryUrls)("preserves ordinary document %s", (url) => {
for (const script of manifest.content_scripts) {
expect(script.exclude_matches.some((pattern) => matchesPattern(pattern, url)))
.toBe(false);
}
});
});
describe("challenge page bridge guard", () => {
it.each(challengeUrls)("leaves native APIs untouched at %s", (url) => {
expectInertBridge(createWindow(url));
});
it.each(["about:blank", "about:srcdoc", "about:blank#frame"])(
"leaves inherited challenge document %s untouched",
(url) => {
expectInertBridge(
createWindow(url, { referrer: "https://challenges.cloudflare.com/" })
);
}
);
it("recognizes the inherited base URL when a challenge hides its referrer", () => {
const parent = createWindow(
"https://example.org/cdn-cgi/challenge-platform/frame"
);
const iframe = parent.document.createElement("iframe");
parent.document.body.appendChild(iframe);
const child = iframe.contentWindow;
Object.defineProperty(child.document, "referrer", { value: "" });
expect(child.location.href).toBe("about:blank");
expectInertBridge(child);
});
it("recognizes challenge ancestors through nested inherited documents", () => {
const parent = createWindow("https://challenges.cloudflare.com/");
const iframe = parent.document.createElement("iframe");
parent.document.body.appendChild(iframe);
const child = iframe.contentWindow;
Object.defineProperty(child.document, "referrer", { value: "" });
Object.defineProperty(child.document, "baseURI", { value: "about:blank" });
const nestedFrame = child.document.createElement("iframe");
child.document.body.appendChild(nestedFrame);
const nested = nestedFrame.contentWindow;
Object.defineProperty(nested.document, "referrer", { value: "" });
Object.defineProperty(nested.document, "baseURI", { value: "about:blank" });
expectInertBridge(nested);
});
it.each(ordinaryUrls)("keeps the normal bridge available at %s", (url) => {
const win = createWindow(url, {
referrer: "https://challenges.cloudflare.com/"
});
const original = observeBridge(win);
win.eval(bridgeSource);
expect(win.Element.prototype.attachShadow).not.toBe(original.attachShadow);
expect(win.history.pushState).not.toBe(original.pushState);
expect(win.history.replaceState).not.toBe(original.replaceState);
expect(win.__speederPageShadowBridgeInstalled).toBe(true);
expect(original.ready).toHaveBeenCalledTimes(1);
expect(original.navigationReady).toHaveBeenCalledTimes(1);
expect(original.navigationListener).toHaveBeenCalledTimes(1);
});
it("preserves native return values and bridge events on an ordinary page", () => {
const win = createWindow("https://example.org/video");
const shadowAttached = vi.fn();
const locationChanged = vi.fn();
win.document.addEventListener("speeder-shadow-root-attached", shadowAttached);
win.document.addEventListener("speeder-location-changed", locationChanged);
const original = observeBridge(win);
win.eval(bridgeSource);
const host = win.document.createElement("div");
win.document.body.appendChild(host);
const shadow = host.attachShadow({ mode: "open" });
expect(shadow).toBe(host.shadowRoot);
expect(shadowAttached).toHaveBeenCalledTimes(1);
expect(win.history.pushState({ position: 1 }, "", "/next-video")).toBeUndefined();
expect(win.history.replaceState({ position: 2 }, "", "/final-video"))
.toBeUndefined();
expect(locationChanged).toHaveBeenCalledTimes(2);
expect(win.history.state).toEqual({ position: 2 });
expect(win.location.pathname).toBe("/final-video");
const wrappedAttachShadow = win.Element.prototype.attachShadow;
win.eval(bridgeSource);
expect(win.Element.prototype.attachShadow).toBe(wrappedAttachShadow);
expect(original.ready).toHaveBeenCalledTimes(2);
expect(original.navigationListener).toHaveBeenCalledTimes(1);
});
});
+138
View File
@@ -939,6 +939,144 @@ describe("inject.js media/controller lifecycle regressions", () => {
expect(document.documentElement.style.isolation).toBe("");
});
it.each(["video/mp4", "video/webm", "audio/ogg", "application/ogg"])(
"preserves native %s document layout while keeping speed controls",
async (contentType) => {
bootInject({
configureWindow(win) {
Object.defineProperty(win.document, "contentType", { value: contentType });
}
});
await settleLifecycle();
// Firefox centers an absolutely positioned video against the viewport.
// Its empty body has no height; positioning it collapses the video.
document.body.style.position = "static";
const video = document.createElement("video");
video.src = "https://example.org/native-media";
video.controls = true;
video.getBoundingClientRect = () =>
document.body.style.position === "static"
? makeRect(80, 100, 640, 360)
: makeRect(0, 0, 0, 0);
setRect(document.body, makeRect(8, 8, 784, 0));
document.body.appendChild(video);
const controller = window.ensureController(video, document.body);
expect(document.body.style.position).toBe("static");
expect(document.body.style.isolation).toBe("");
expect(controller.div.parentNode).toBe(document.body);
expect(hostIsGeometrySuppressed(controller.div)).toBe(false);
expect(controller.div.style.top).toBe("100px");
expect(controller.div.style.left).toBe("80px");
expect(controller.div.style.height).toBe("360px");
expect(video.controls).toBe(true);
window.setSpeed(video, 1.5, false, false);
expect(video.playbackRate).toBe(1.5);
controller.div.showPopover = vi.fn();
controller.div.hidePopover = vi.fn();
Object.defineProperty(document, "fullscreenElement", {
configurable: true, value: video
});
document.dispatchEvent(new Event("fullscreenchange"));
expect(controller.div.showPopover).toHaveBeenCalledOnce();
Object.defineProperty(document, "fullscreenElement", {
configurable: true, value: null
});
document.dispatchEvent(new Event("fullscreenchange"));
await settleLifecycle();
expect(hostIsGeometrySuppressed(controller.div)).toBe(false);
expect(document.body.style.position).toBe("static");
window.removeController(video);
expect(document.body.style.position).toBe("static");
expect(document.body.style.isolation).toBe("");
}
);
it("keeps the YouTube host in fullscreen through subsequent media events", async () => {
bootInject({ url: "https://www.youtube.com/watch?v=fullscreen" });
await settleLifecycle();
const player = document.createElement("div");
player.className = "html5-video-player";
const provider = document.createElement("div");
const rect = makeRect(0, 0, 640, 360);
setRect(player, rect);
setRect(provider, rect);
setBoxMetrics(player, 640, 360);
setBoxMetrics(provider, 640, 360);
player.appendChild(provider);
document.body.appendChild(player);
const { video, wrapper } = createControlledVideo({ mount: provider });
expect(wrapper.parentNode).toBe(player);
await settleLifecycle(8);
Object.defineProperty(document, "fullscreenElement", {
configurable: true, value: provider
});
document.dispatchEvent(new Event("fullscreenchange"));
expect(wrapper.parentNode).toBe(provider);
await settleLifecycle(8);
["play", "playing", "loadedmetadata", "canplay"].forEach((type) => {
video.dispatchEvent(new Event(type));
});
await settleLifecycle(8);
expect(wrapper.parentNode).toBe(provider);
expect(hostIsGeometrySuppressed(wrapper)).toBe(false);
Object.defineProperty(document, "fullscreenElement", {
configurable: true, value: null
});
document.dispatchEvent(new Event("fullscreenchange"));
await settleLifecycle();
expect(wrapper.parentNode).toBe(player);
});
it("uses the current player when fullscreen follows a video move before reconciliation", async () => {
bootInject({ url: "https://www.youtube.com/watch?v=moved-player" });
await settleLifecycle();
const first = document.createElement("div");
const second = document.createElement("div");
const rect = makeRect(0, 0, 640, 360);
[first, second].forEach((player) => {
player.className = "html5-video-player";
setRect(player, rect);
setBoxMetrics(player, 640, 360);
document.body.appendChild(player);
});
const { video, wrapper } = createControlledVideo({ mount: first });
second.appendChild(video);
Object.defineProperty(document, "fullscreenElement", {
configurable: true, value: document.documentElement
});
document.dispatchEvent(new Event("fullscreenchange"));
expect(wrapper.parentNode).toBe(second);
expect(video.vsc.normalControllerMount).toBe(second);
});
it("preserves an intentional sibling overlay mount during fullscreen sync", async () => {
bootInject({ url: "https://tv.apple.com/" });
await settleLifecycle();
const scrim = document.createElement("div");
scrim.className = "scrim";
setRect(scrim, makeRect(0, 0, 640, 360));
setBoxMetrics(scrim, 640, 360);
document.body.appendChild(scrim);
const { video, wrapper, controller } = createControlledVideo();
expect(wrapper.parentNode).toBe(scrim);
expect(scrim.contains(video)).toBe(false);
Object.defineProperty(document, "fullscreenElement", {
configurable: true, value: document.documentElement
});
document.dispatchEvent(new Event("fullscreenchange"));
expect(wrapper.parentNode).toBe(scrim);
Object.defineProperty(document, "fullscreenElement", {
configurable: true, value: null
});
window.syncControllerFullscreenMount(controller);
expect(wrapper.parentNode).toBe(scrim);
});
it("only promotes the directly-fullscreen video's controller", async () => {
bootInject();
await settleLifecycle();