v5.3.5.0-beta.1

This commit is contained in:
2026-07-14 21:07:29 -04:00
4 changed files with 271 additions and 9 deletions
+140 -8
View File
@@ -993,6 +993,15 @@ function ensureController(node, parent) {
return node.vsc;
}
function ensureControllerForMediaChild(node) {
if (!node || node.nodeType !== Node.ELEMENT_NODE) return null;
if (node.nodeName !== "SOURCE") return null;
var media = node.parentElement;
if (!isMediaElement(media)) return null;
return ensureController(media, media.parentElement || media.parentNode);
}
function removeController(node) {
if (node && node.vsc) node.vsc.remove();
}
@@ -1019,6 +1028,10 @@ function scanNodeForMedia(node, parent, added) {
if (isMediaElement(node)) {
if (added) ensureController(node, parent);
else removeController(node);
} else if (added) {
// Players such as Vidstack often connect an empty <video> and append its
// <source> later. Retry the owning video when that source becomes usable.
ensureControllerForMediaChild(node);
}
// Use querySelectorAll instead of recursive child walking — the browser's
@@ -1465,9 +1478,17 @@ function createsControllerStackingContext(element) {
);
}
function getControllerMount(video) {
function getControllerMount(video, boundary) {
if (!video || !video.parentElement) return null;
var mountBoundary =
boundary &&
boundary !== video &&
typeof boundary.contains === "function" &&
boundary.contains(video)
? boundary
: null;
var videoRect = video.getBoundingClientRect();
var mount = video.parentElement;
var candidate = mount;
@@ -1477,7 +1498,15 @@ function getControllerMount(video) {
// Climb through tightly-sized wrappers so our host shares their stacking
// context, but stop before broad page-layout containers.
while (candidate && candidate.parentElement && depth < 5) {
if (mountBoundary && candidate === mountBoundary) break;
var next = candidate.parentElement;
if (
mountBoundary &&
next !== mountBoundary &&
!mountBoundary.contains(next)
) {
break;
}
var nextRect = next.getBoundingClientRect();
var widthLimit = Math.max(videoRect.width * 1.35, videoRect.width + 80);
var heightLimit = Math.max(videoRect.height * 1.35, videoRect.height + 80);
@@ -1501,6 +1530,10 @@ function getControllerMount(video) {
candidate = next;
depth += 1;
// In fullscreen, the wrapper must remain inside the exact subtree the
// browser promotes to its top layer.
if (mountBoundary && next === mountBoundary) break;
// Never climb out of a player-owned stacking context. Doing so lets the
// controller's high local z-index escape above sticky page headers.
if (createsControllerStackingContext(next)) break;
@@ -1509,6 +1542,17 @@ function getControllerMount(video) {
return mount;
}
function getFullscreenElement(doc) {
if (!doc) return null;
return (
doc.fullscreenElement ||
doc.webkitFullscreenElement ||
doc.mozFullScreenElement ||
doc.msFullscreenElement ||
null
);
}
function positionControllerHost(wrapper, video, mount) {
if (!wrapper || !video || !mount || !wrapper.isConnected) return;
@@ -1589,15 +1633,25 @@ function setupControllerHostTracking(videoController, wrapper, mount) {
mount.addEventListener("scroll", schedule, { passive: true });
update();
videoController.controllerHostCleanup = function() {
videoController.controllerHostMount = mount;
videoController.controllerHostCleanup = function(forceRestore) {
if (resizeObserver) resizeObserver.disconnect();
if (frameId !== null) win.cancelAnimationFrame(frameId);
win.removeEventListener("resize", schedule);
doc.removeEventListener("fullscreenchange", schedule);
mount.removeEventListener("scroll", schedule);
var hasOtherController = false;
try {
hasOtherController = Array.from(
mount.querySelectorAll(".vsc-controller")
).some(function(controllerHost) {
return controllerHost !== wrapper;
});
} catch (e) {}
if (
mount.dataset.vscPositionOwner === "true" &&
!mount.querySelector(".vsc-controller")
!hasOtherController &&
(forceRestore === true || !wrapper.isConnected)
) {
if (mount.dataset.vscOriginalPosition) {
mount.style.setProperty(
@@ -1614,7 +1668,8 @@ function setupControllerHostTracking(videoController, wrapper, mount) {
}
if (
mount.dataset.vscIsolationOwner === "true" &&
!mount.querySelector(".vsc-controller")
!hasOtherController &&
(forceRestore === true || !wrapper.isConnected)
) {
if (mount.dataset.vscOriginalIsolation) {
mount.style.setProperty(
@@ -1632,6 +1687,53 @@ function setupControllerHostTracking(videoController, wrapper, mount) {
};
}
function remountControllerHost(videoController, mount) {
if (
!videoController ||
!videoController.div ||
!mount ||
!mount.isConnected ||
mount === videoController.controllerHostMount
) {
return false;
}
if (videoController.controllerHostCleanup) {
videoController.controllerHostCleanup(true);
videoController.controllerHostCleanup = null;
}
mount.insertBefore(videoController.div, mount.firstChild);
setupControllerHostTracking(videoController, videoController.div, mount);
return true;
}
function syncControllerFullscreenMount(videoController) {
if (!videoController || !videoController.video || !videoController.div) {
return false;
}
var video = videoController.video;
var doc = video.ownerDocument;
var fullscreenElement = getFullscreenElement(doc);
var targetMount = videoController.normalControllerMount;
if (
fullscreenElement &&
fullscreenElement !== video &&
typeof fullscreenElement.contains === "function" &&
fullscreenElement.contains(video)
) {
targetMount = getControllerMount(video, fullscreenElement);
} else if (!fullscreenElement && (!targetMount || !targetMount.isConnected)) {
targetMount = getControllerMount(video);
videoController.normalControllerMount = targetMount;
}
if (!targetMount) return false;
return remountControllerHost(videoController, targetMount);
}
function defineVideoController() {
tc.videoController = function(target, parent) {
if (target.vsc) return target.vsc;
@@ -1681,6 +1783,7 @@ function defineVideoController() {
event.type === "loadeddata" ||
event.type === "canplay"
) {
if (this.div) this.div.classList.remove("vsc-nosource");
applySourceTransitionPolicy(event.target, false);
}
@@ -1776,7 +1879,10 @@ function defineVideoController() {
if (this.div) {
this.stopSubtitleNudge();
if (!mutation.target.src && !mutation.target.currentSrc) {
this.div.classList.add("vsc-nosource");
this.div.classList.toggle(
"vsc-nosource",
!hasUsableMediaSource(mutation.target)
);
} else {
this.div.classList.remove("vsc-nosource");
applySourceTransitionPolicy(this.video, true);
@@ -2090,7 +2196,7 @@ function defineVideoController() {
const speed = this.video.playbackRate.toFixed(2);
var wrapper = doc.createElement("div");
wrapper.classList.add("vsc-controller");
if (!this.video.src && !this.video.currentSrc)
if (!hasUsableMediaSource(this.video))
wrapper.classList.add("vsc-nosource");
if (tc.settings.startHidden) wrapper.classList.add("vsc-hidden");
// z-index is handled by the base .vsc-controller CSS rule (2147483646).
@@ -2229,6 +2335,7 @@ function defineVideoController() {
if (!parentEl || !parentEl.parentNode) {
log("No suitable parent found, appending to body", 4);
doc.body.appendChild(wrapper);
this.normalControllerMount = doc.body;
setupControllerHostTracking(this, wrapper, doc.body);
return wrapper;
}
@@ -2263,12 +2370,14 @@ function defineVideoController() {
break;
}
mountEl.insertBefore(wrapper, mountEl.firstChild);
this.normalControllerMount = 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;
setupControllerHostTracking(this, wrapper, doc.body);
}
@@ -2615,6 +2724,15 @@ function attachMutationObserver(root) {
return;
}
if (
target.nodeName === "SOURCE" &&
mutation.attributeName === "src"
) {
ensureControllerForMediaChild(target);
return;
}
if (
mutation.attributeName === "aria-hidden" &&
target.attributes["aria-hidden"] &&
@@ -2752,6 +2870,9 @@ function initializeNow(doc, forceReinit = false) {
if (forceReinit) {
log("Force re-initialization requested", 4);
// A root is normally scanned only once. A user-requested rescan must also
// revisit media that was present but source-less during the initial scan.
scanRootForMedia(doc);
refreshAllControllerGeometry();
}
@@ -3192,11 +3313,22 @@ function showController(controller, duration = 2000, forced = false) {
}, duration);
}
// Add global listener to handle fullscreen transitions and adjust controller positions
document.addEventListener("fullscreenchange", () => {
// Keep each controller inside the subtree promoted to the fullscreen top layer,
// then restore its normal player-local mount when fullscreen exits.
function handleFullscreenControllerTransition() {
tc.mediaElements.forEach((video) => {
if (video.vsc) {
syncControllerFullscreenMount(video.vsc);
applyControllerLocation(video.vsc, video.vsc.controllerLocation);
}
});
}
[
"fullscreenchange",
"webkitfullscreenchange",
"mozfullscreenchange",
"MSFullscreenChange"
].forEach(function(eventName) {
document.addEventListener(eventName, handleFullscreenControllerTransition);
});
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "Speeder",
"short_name": "Speeder",
"version": "5.3.4.0",
"version": "5.3.5.0",
"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",
+102
View File
@@ -214,4 +214,106 @@ describe("inject.js helper logic", () => {
expect(window.getControllerMount(video)).toBe(isolatedPlayer);
});
it("keeps the controller inside a nested fullscreen player subtree", async () => {
bootInject();
await flushAsyncWork(3);
const isolatedPlayer = document.createElement("div");
isolatedPlayer.style.isolation = "isolate";
const fullscreenPlayer = document.createElement("media-player");
const provider = document.createElement("media-provider");
const video = document.createElement("video");
provider.appendChild(video);
fullscreenPlayer.appendChild(provider);
isolatedPlayer.appendChild(fullscreenPlayer);
document.body.appendChild(isolatedPlayer);
const rect = {
left: 0,
top: 0,
right: 640,
bottom: 360,
width: 640,
height: 360
};
[video, provider, fullscreenPlayer, isolatedPlayer].forEach((element) => {
element.getBoundingClientRect = () => rect;
});
expect(window.getControllerMount(video)).toBe(isolatedPlayer);
expect(window.getControllerMount(video, fullscreenPlayer)).toBe(
fullscreenPlayer
);
});
it("remounts the controller on fullscreen entry and restores it on exit", async () => {
bootInject();
await flushAsyncWork(3);
const isolatedPlayer = document.createElement("div");
isolatedPlayer.style.isolation = "isolate";
const fullscreenPlayer = document.createElement("media-player");
const provider = document.createElement("media-provider");
const video = document.createElement("video");
const wrapper = document.createElement("div");
wrapper.className = "vsc-controller";
provider.appendChild(video);
fullscreenPlayer.appendChild(provider);
isolatedPlayer.append(fullscreenPlayer, wrapper);
document.body.appendChild(isolatedPlayer);
const rect = {
left: 0,
top: 0,
right: 640,
bottom: 360,
width: 640,
height: 360
};
[video, provider, fullscreenPlayer, isolatedPlayer].forEach((element) => {
element.getBoundingClientRect = () => rect;
});
const controller = {
video,
div: wrapper,
normalControllerMount: isolatedPlayer
};
window.setupControllerHostTracking(controller, wrapper, isolatedPlayer);
Object.defineProperty(document, "fullscreenElement", {
configurable: true,
value: fullscreenPlayer
});
expect(window.syncControllerFullscreenMount(controller)).toBe(true);
expect(wrapper.parentElement).toBe(fullscreenPlayer);
Object.defineProperty(document, "fullscreenElement", {
configurable: true,
value: null
});
expect(window.syncControllerFullscreenMount(controller)).toBe(true);
expect(wrapper.parentElement).toBe(isolatedPlayer);
wrapper.remove();
controller.controllerHostCleanup();
});
it("force-rescans media missed during initial hydration", async () => {
bootInject();
await flushAsyncWork(3);
const video = document.createElement("video");
const source = document.createElement("source");
source.src = "https://example.org/late-source.mp4";
video.appendChild(source);
document.body.appendChild(video);
expect(video.vsc).toBeUndefined();
window.initializeWhenReady(document, true);
expect(video.vsc).toBeDefined();
expect(video.vsc.div).toBeDefined();
});
});
+28
View File
@@ -190,6 +190,34 @@ describe("inject runtime", () => {
vi.useRealTimers();
});
it("attaches a controller when Vidstack appends a source after the video", async () => {
await bootInject();
vi.useFakeTimers();
const provider = document.createElement("media-provider");
const video = document.createElement("video");
provider.appendChild(video);
document.body.appendChild(provider);
await flushAsyncWork();
vi.runAllTimers();
await flushAsyncWork();
expect(video.vsc).toBeUndefined();
const source = document.createElement("source");
source.src = "https://example.org/vidstack.mp4";
video.appendChild(source);
await flushAsyncWork();
vi.runAllTimers();
await flushAsyncWork();
expect(video.vsc).toBeDefined();
expect(video.vsc.div).toBeDefined();
expect(video.vsc.div.classList.contains("vsc-nosource")).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>");