mirror of
https://github.com/SoPat712/Speeder.git
synced 2026-10-06 18:55:39 -04:00
v6.0.9.1-beta.1
This commit is contained in:
7 files changed
+523
-31
No files matched your search
@@ -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
@@ -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);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user