Sleazy Fork is available in English.
The perfect tool for gooning on Twitter/X ! Fullscreen video auto-scroll + replaces black side bars for vertical videos with more goon.
// ==UserScript==
// @name TwitterGoonScroller
// @namespace local.x-goon-scroller
// @version 1.5.1
// @description The perfect tool for gooning on Twitter/X ! Fullscreen video auto-scroll + replaces black side bars for vertical videos with more goon.
// @match https://x.com/*
// @match https://twitter.com/*
// @match https://www.twitter.com/*
// @run-at document-idle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @grant unsafeWindow
// @license MIT
// ==/UserScript==
(() => {
"use strict";
const modes = {
duplicate: "Next media on both sides",
different: "Next left / second next right",
blur: "Blur main video",
};
let mode = GM_getValue("sidefill.mode", "duplicate");
if (!modes[mode]) mode = "duplicate";
let enabled = GM_getValue("sidefill.enabled", true);
// Auto-play is a core feature: switch it on once for existing installs that
// saved it as off; later choices in the panel are respected.
if (!GM_getValue("sidefill.autoAdvanceDefaulted", false)) {
GM_setValue("sidefill.autoAdvance", true);
GM_setValue("sidefill.autoAdvanceDefaulted", true);
}
let autoAdvance = GM_getValue("sidefill.autoAdvance", true);
let sideAudio = GM_getValue("sidefill.sideAudio", false);
let active = null;
let candidates = [];
let lastScan = 0;
let lastFrame = 0;
let dirty = true;
let transition = null;
// Browser fullscreen needs a user gesture, so it cannot be re-entered when a
// video ends. Instead, X's fullscreen request is redirected to <html> (which
// stays fullscreen), and the requested player is lifted into the top layer.
// Advancing then only moves that "pseudo fullscreen" to the next player.
let pseudoTarget = null;
let pseudoDepth = 0;
// While switching videos, X is told fullscreen ended so its feed updates
// (it freezes loading/mounting posts while a player is fullscreen).
let spoofSuspended = false;
let handoffActive = false;
// Entering fullscreen is asynchronous; do not treat the gap before the
// browser's fullscreenchange as "fullscreen was left".
let pseudoPendingUntil = 0;
const pageWindow = typeof unsafeWindow === "undefined" ? window : unsafeWindow;
const nativeFullscreenGet = Object.getOwnPropertyDescriptor(
Document.prototype,
"fullscreenElement",
)?.get;
const realFullscreenElement = () =>
nativeFullscreenGet
? nativeFullscreenGet.call(document)
: document.fullscreenElement;
const fullscreenElement = () =>
(pseudoTarget?.isConnected && pseudoTarget) || realFullscreenElement();
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const advanceLog = [];
function logAdvance(message) {
advanceLog.push(message);
if (advanceLog.length > 12) advanceLog.shift();
}
const sidePlayers = new Map();
const resolvedMedia = new WeakMap();
const routeOK = () =>
/^\/(home|i\/(history|bookmarks))(\/|$)/.test(location.pathname);
// X loads video metadata lazily, so upcoming videos often have no size yet.
// Their thumbnail has the same aspect ratio and is available immediately.
const posters = new Map();
function posterImage(video) {
return cachedImage(
video.poster ||
video
.closest('[data-testid="videoPlayer"]')
?.querySelector("img")?.src ||
"",
);
}
function cachedImage(url) {
if (!url) return null;
let img = posters.get(url);
if (!img) {
img = new Image();
img.decoding = "async";
img.src = url;
posters.set(url, img);
if (posters.size > 200) posters.delete(posters.keys().next().value);
}
return img.complete && img.naturalWidth ? img : null;
}
const dimensions = (el) => {
if (!(el instanceof HTMLVideoElement))
return [el.naturalWidth, el.naturalHeight];
if (el.videoWidth) return [el.videoWidth, el.videoHeight];
const poster = el.isConnected ? posterImage(el) : null;
return poster ? [poster.naturalWidth, poster.naturalHeight] : [0, 0];
};
const portrait = (el) => {
const [w, h] = dimensions(el);
return w > 0 && h > w;
};
const visible = (el) => {
const r = el.getBoundingClientRect();
return (
r.width > 0 &&
r.height > 0 &&
r.bottom > 0 &&
r.top < innerHeight &&
r.right > 0 &&
r.left < innerWidth
);
};
const panel = document.createElement("div");
panel.id = "x-sidefill-settings";
const shadow = panel.attachShadow({ mode: "open" });
shadow.innerHTML = `<style>
:host { position:fixed; bottom:16px; right:128px; z-index:2147483647; font:13px system-ui,sans-serif; color:#eee; }
details { background:#16181cee; border:1px solid #536471; border-radius:12px; padding:10px 12px; max-width:280px; box-shadow:0 3px 18px #0007; }
summary { cursor:pointer; user-select:none; display:flex; align-items:center; list-style:none; }
summary::-webkit-details-marker { display:none; }
.logo { font:900 16px/1.1 Georgia, 'Times New Roman', serif; letter-spacing:.12em; color:#fff; border:1px solid #8899a6; border-radius:5px; padding:3px 5px 3px 6px; }
.logo-g { color:#e0245e; }
select { display:block; width:100%; margin-top:6px; background:#222; color:white; padding:6px; border:1px solid #777; border-radius:5px; }
p { margin:10px 0 0; color:#bbc3cb; line-height:1.4; }
</style><details><summary><span class="logo">T<span class="logo-g">G</span>S</span></summary><label><input id="enabled" type="checkbox"> Enabled</label>
<label>Fill mode<select></select></label>
<label><input id="advance" type="checkbox"> Auto-play next video</label>
<label><input id="audio" type="checkbox"> Play side-video audio</label>
<p>Auto-play follows every loaded video. Portrait videos get side fills. Duplicate sides share one audio track.</p><p role="status"></p>
<button id="resume-fullscreen" hidden>Enter X fullscreen</button></details>`;
const checkbox = shadow.querySelector("#enabled");
const advanceCheckbox = shadow.querySelector("#advance");
const audioCheckbox = shadow.querySelector("#audio");
const select = shadow.querySelector("select");
const status = shadow.querySelector("[role=status]");
const resumeFullscreen = shadow.querySelector("#resume-fullscreen");
resumeFullscreen.addEventListener("click", () => {
if (!active) return;
const button = findControl(active, "enter");
if (button) {
button.click();
active.notice = "";
} else active.notice = "X fullscreen controls are not loaded yet.";
});
for (const [value, label] of Object.entries(modes))
select.add(new Option(label, value));
select.value = mode;
checkbox.checked = enabled;
advanceCheckbox.checked = autoAdvance;
audioCheckbox.checked = sideAudio;
function configure() {
enabled = checkbox.checked;
mode = select.value;
GM_setValue("sidefill.enabled", enabled);
GM_setValue("sidefill.mode", mode);
teardown();
dirty = true;
}
checkbox.addEventListener("change", configure);
select.addEventListener("change", configure);
advanceCheckbox.addEventListener("change", () => {
autoAdvance = advanceCheckbox.checked;
GM_setValue("sidefill.autoAdvance", autoAdvance);
if (!autoAdvance) cancelTransition();
if (active) {
active.video.loop = autoAdvance ? false : active.originalLoop;
active.notice = "";
}
});
audioCheckbox.addEventListener("change", () => {
sideAudio = audioCheckbox.checked;
GM_setValue("sidefill.sideAudio", sideAudio);
// Apply within the click gesture so browsers can grant audible playback.
for (const saved of sidePlayers.values()) {
saved.audioBlocked = false;
saved.player.muted = !sideAudio;
saved.player.volume = active?.video.volume ?? 1;
if (active && !active.video.paused && !active.video.ended)
playSide(saved);
}
});
GM_registerMenuCommand("Portrait side fill: settings", () => {
shadow.querySelector("details").open = true;
});
for (const [value, label] of Object.entries(modes))
GM_registerMenuCommand(label, () => {
select.value = value;
configure();
});
document.body.append(panel);
const pseudoStyle = document.createElement("style");
pseudoStyle.textContent = `
[data-tgs-fs] { position:fixed !important; inset:0 !important; width:100vw !important; height:100vh !important;
min-width:0 !important; min-height:0 !important; max-width:none !important; max-height:none !important;
margin:0 !important; padding:0 !important; border:0 !important; box-sizing:border-box !important;
transform:none !important; background:#000 !important; color:inherit !important;
overflow:hidden !important; z-index:2147483646 !important; object-fit:contain; }
[data-tgs-fs]::backdrop { background:#000; }
html[data-tgs-fs-active], html[data-tgs-fs-active] body {
scrollbar-width:none !important; scrollbar-gutter:auto !important; }
html[data-tgs-fs-active]::-webkit-scrollbar, html[data-tgs-fs-active] body::-webkit-scrollbar { display:none !important; }
[data-tgs-fs], [data-tgs-fs] * { scrollbar-width:none !important; }
[data-tgs-fs]::-webkit-scrollbar, [data-tgs-fs] *::-webkit-scrollbar { display:none !important; }`;
document.head.append(pseudoStyle);
function openPseudo(node) {
node.setAttribute("data-tgs-fs", "");
try {
if (!node.matches(":popover-open")) {
node.setAttribute("popover", "manual");
node.showPopover();
}
} catch {
// No popover support: fall back to plain fixed positioning.
node.removeAttribute("popover");
}
}
function closePseudo(node) {
try {
if (node.matches(":popover-open")) node.hidePopover();
} catch {
/* unsupported */
}
node.removeAttribute("popover");
node.removeAttribute("data-tgs-fs");
}
function showPseudo(target) {
const previous = pseudoTarget;
pseudoTarget = target;
document.documentElement.setAttribute("data-tgs-fs-active", "");
// Open the new player before closing the old one so nothing flashes.
openPseudo(target);
if (previous && previous !== target) closePseudo(previous);
}
// A still of the finished video that covers the feed while X re-renders.
const curtain = document.createElement("canvas");
curtain.dataset.xSidefill = "true";
curtain.setAttribute("aria-hidden", "true");
curtain.setAttribute("popover", "manual");
curtain.style.cssText =
"position:fixed;inset:0;width:100vw;height:100vh;max-width:none;max-height:none;margin:0;padding:0;border:0;background:#000;z-index:2147483647;";
function raiseCurtain() {
// Re-inserting puts it on top of anything added to the top layer since.
curtain.remove();
document.body.append(curtain);
try {
curtain.showPopover();
} catch {
curtain.removeAttribute("popover");
}
}
function showCurtain(video, extras = []) {
curtain.width = innerWidth;
curtain.height = innerHeight;
const ctx = curtain.getContext("2d");
ctx.fillStyle = "#000";
ctx.fillRect(0, 0, curtain.width, curtain.height);
for (const el of [video, ...extras]) {
try {
if (!el?.isConnected || el.style.display === "none") continue;
const r = el.getBoundingClientRect();
if (el instanceof HTMLVideoElement) {
if (!el.videoWidth) continue;
const fit = Math.min(
r.width / el.videoWidth,
r.height / el.videoHeight,
);
const w = el.videoWidth * fit;
const h = el.videoHeight * fit;
ctx.drawImage(
el,
r.left + (r.width - w) / 2,
r.top + (r.height - h) / 2,
w,
h,
);
} else ctx.drawImage(el, r.left, r.top, r.width, r.height);
} catch {
/* frame not drawable */
}
}
raiseCurtain();
}
function hideCurtain() {
curtain.remove();
}
function beginHandoff(video) {
if (handoffActive || !pseudoTarget) return;
handoffActive = true;
showCurtain(video, active?.canvases);
spoofSuspended = true;
announceFullscreenChange([pseudoTarget]);
}
// Hand fullscreen back to X for whichever player is the pseudo target now.
function endHandoff(keepCurtain = false) {
if (!handoffActive) return;
handoffActive = false;
spoofSuspended = false;
announceFullscreenChange([pseudoTarget]);
if (!keepCurtain)
requestAnimationFrame(() => setTimeout(hideCurtain, 120));
}
function clearPseudo() {
spoofSuspended = false;
handoffActive = false;
hideCurtain();
pseudoPendingUntil = 0;
if (pseudoTarget) closePseudo(pseudoTarget);
pseudoTarget = null;
document.documentElement.removeAttribute("data-tgs-fs-active");
}
// Let X's players re-read document.fullscreenElement and update their UI.
function announceFullscreenChange(nodes) {
for (const node of new Set(nodes))
if (node?.isConnected)
node.dispatchEvent(
new Event("fullscreenchange", { bubbles: true }),
);
}
function pseudoTargetFor(video) {
let node = video;
for (let i = 0; i < pseudoDepth && node.parentElement; i++)
node = node.parentElement;
const valid =
node === video ||
(node !== document.body &&
node !== document.documentElement &&
node.querySelectorAll("video").length === 1);
return valid
? node
: video.closest('[data-testid="videoPlayer"]') ||
video.parentElement;
}
function requestFullscreenFor(target, native, args) {
const video =
target instanceof HTMLVideoElement
? target
: target?.querySelector?.("video");
if (
!enabled ||
!autoAdvance ||
!routeOK() ||
!video ||
!collectFeedVideos().includes(video)
)
return native.apply(target, args);
let depth = 0;
for (let n = video; n && n !== target; n = n.parentElement) depth++;
pseudoDepth = depth;
const previous = pseudoTarget;
showPseudo(target);
if (realFullscreenElement()) {
announceFullscreenChange([previous, target]);
return pageWindow.Promise.resolve();
}
pseudoPendingUntil = performance.now() + 3000;
return native.apply(document.documentElement, args);
}
function maintainPseudo() {
if (!pseudoTarget) return;
if (!realFullscreenElement()) {
if (performance.now() < pseudoPendingUntil) return;
clearPseudo();
return;
}
const video = active?.video;
if (
pseudoTarget.isConnected &&
(!video || !video.isConnected || pseudoTarget.contains(video))
) {
openPseudo(pseudoTarget);
return;
}
// X re-rendered the player (or tracking moved); follow the main video.
if (video?.isConnected && !transition) {
const old = pseudoTarget;
const target = pseudoTargetFor(video);
showPseudo(target);
announceFullscreenChange([old, target]);
}
}
(function installFullscreenBridge() {
// Page-world patch; exportFunction is needed in Firefox sandboxes.
const expose = (fn) =>
typeof exportFunction === "function"
? exportFunction(fn, pageWindow)
: fn;
const define = (proto, name, descriptor) => {
const d = new pageWindow.Object();
for (const [key, value] of Object.entries(descriptor))
d[key] = value;
pageWindow.Object.defineProperty(proto, name, d);
};
try {
const docProto = pageWindow.Document.prototype;
const elProto = pageWindow.Element.prototype;
for (const name of [
"fullscreenElement",
"webkitFullscreenElement",
"webkitCurrentFullScreenElement",
]) {
const original = pageWindow.Object.getOwnPropertyDescriptor(
docProto,
name,
);
if (!original?.get) continue;
const nativeGet = original.get;
define(docProto, name, {
configurable: true,
enumerable: original.enumerable,
get: expose(function () {
const real = nativeGet.call(this);
if (spoofSuspended && real?.nodeName === "HTML")
return null;
return pseudoTarget?.isConnected &&
real?.nodeName === "HTML"
? pseudoTarget
: real;
}),
});
}
for (const name of [
"requestFullscreen",
"webkitRequestFullscreen",
"webkitRequestFullScreen",
]) {
const original = pageWindow.Object.getOwnPropertyDescriptor(
elProto,
name,
);
if (typeof original?.value !== "function") continue;
const native = original.value;
define(elProto, name, {
configurable: true,
writable: true,
enumerable: original.enumerable,
value: expose(function (...args) {
try {
return requestFullscreenFor(this, native, args);
} catch {
clearPseudo();
return native.apply(this, args);
}
}),
});
}
for (const name of [
"exitFullscreen",
"webkitExitFullscreen",
"webkitCancelFullScreen",
]) {
const original = pageWindow.Object.getOwnPropertyDescriptor(
docProto,
name,
);
if (typeof original?.value !== "function") continue;
const native = original.value;
define(docProto, name, {
configurable: true,
writable: true,
enumerable: original.enumerable,
value: expose(function (...args) {
clearPseudo();
return native.apply(this, args);
}),
});
}
} catch (error) {
logAdvance(`Fullscreen bridge unavailable: ${error.name}`);
}
})();
function releaseUnused(used) {
for (const [el, saved] of sidePlayers)
if (!used.has(el)) {
saved.player.pause();
saved.player.srcObject = null;
saved.player.removeAttribute("src");
saved.player.load();
sidePlayers.delete(el);
}
}
function cancelTransition() {
if (transition) transition.cancelled = true;
transition = null;
if (active) {
active.advancing = false;
active.autoStarting = false;
if (active.playTimer) clearInterval(active.playTimer);
active.playTimer = null;
}
}
function teardown(preserveTransition = false) {
if (!preserveTransition) cancelTransition();
if (active) {
active.video.removeEventListener("ended", active.onEnded);
active.video.removeEventListener("timeupdate", active.onTimeUpdate);
active.video.removeEventListener("seeking", active.onSeeking);
if (active.playTimer) clearInterval(active.playTimer);
active.video.loop = active.originalLoop;
for (const [node, saved] of active.controlStyles) {
for (const [property, value] of Object.entries(saved)) {
if (
node.style.getPropertyValue(property) === value.applied
) {
if (value.original)
node.style.setProperty(
property,
value.original,
value.priority,
);
else node.style.removeProperty(property);
}
}
}
for (const c of active.canvases) c.remove();
if (
active.changedPosition &&
active.host.style.position === "relative"
)
active.host.style.position = active.oldPosition;
active = null;
}
releaseUnused(new Set());
}
function attach(video, preserveTransition = false) {
teardown(preserveTransition);
resumeFullscreen.hidden = true;
const host = video.parentElement;
const oldPosition = host.style.position;
const changedPosition = getComputedStyle(host).position === "static";
if (changedPosition) host.style.position = "relative";
const canvases = [0, 1].map(() => {
const canvas = document.createElement("canvas");
canvas.dataset.xSidefill = "true";
canvas.setAttribute("aria-hidden", "true");
canvas.style.cssText =
"position:absolute;pointer-events:none;z-index:1;display:none;";
host.append(canvas);
return canvas;
});
const onEnded = () => advance(video);
active = {
video,
host,
oldPosition,
changedPosition,
canvases,
originalLoop: video.loop,
onEnded,
advancing: false,
notice: "",
pinUntil: 0,
controlStyles: new Map(),
playerRoot: video.closest('[data-testid="videoPlayer"]') || host,
article: video.closest('article[data-testid="tweet"]'),
lastEndCheck: 0,
completionHandled: false,
playTimer: null,
autoStarting: false,
playClicked: false,
fullscreenRoot: fullscreenElement() || null,
};
active.lastTime = video.currentTime;
active.maxTime = video.currentTime;
active.duration = video.duration;
active.progressed = !video.paused;
active.manualPause = false;
active.manualUntil = 0;
active.resetDetected = false;
active.trackedSource = video.srcObject || video.currentSrc || video.src;
active.onTimeUpdate = () => {
if (active?.video === video) samplePlayback(performance.now());
};
active.onSeeking = () => {
// Seeking is user initiated unless it happens during an advance.
if (active?.video === video && !active.advancing) {
const duration = active.duration;
const automaticReset =
Number.isFinite(duration) &&
duration > 0 &&
active.maxTime >=
duration -
Math.min(1.25, Math.max(0.25, duration * 0.1)) &&
video.currentTime <=
Math.min(1.5, Math.max(0.75, duration * 0.03));
if (automaticReset) return;
active.manualPause = true;
active.manualUntil = performance.now() + 1500;
active.resetDetected = false;
}
};
logAdvance("Tracking main video");
if (autoAdvance) video.loop = false;
video.addEventListener("ended", onEnded);
video.addEventListener("timeupdate", active.onTimeUpdate);
video.addEventListener("seeking", active.onSeeking);
}
function collectMedia() {
const media = [];
for (const article of document.querySelectorAll(
'article[data-testid="tweet"]',
)) {
// Exclude avatars, link previews, and quoted posts; retain feed order.
for (const el of article.querySelectorAll(
'video, [data-testid="tweetPhoto"] img',
)) {
if (
el.closest("article") !== article ||
el.closest('[data-testid="quoteTweet"]')
)
continue;
if (
el instanceof HTMLImageElement &&
el.closest('[data-testid="videoPlayer"]')
)
continue;
if (portrait(el)) media.push(el);
}
}
return media;
}
function collectFeedVideos() {
const videos = [];
for (const article of document.querySelectorAll(
'article[data-testid="tweet"]',
)) {
for (const video of article.querySelectorAll("video")) {
if (
video.closest("article") === article &&
!video.closest('[data-testid="quoteTweet"]')
)
videos.push(video);
}
}
return videos;
}
function rebindReplacement() {
if (!active || active.video.isConnected) return false;
const old = active;
const fullscreenRoot = fullscreenElement();
const root = old.playerRoot?.isConnected ? old.playerRoot : old.article;
const replacement =
(fullscreenRoot instanceof HTMLVideoElement
? fullscreenRoot
: fullscreenRoot?.querySelector("video")) ||
root?.querySelector("video");
if (
!(replacement instanceof HTMLVideoElement) ||
!replacement.videoWidth ||
!replacement.videoHeight
)
return false;
const history = {
maxTime: old.maxTime,
duration: old.duration,
progressed: old.progressed,
manualPause: old.manualPause,
manualUntil: old.manualUntil,
resetDetected: old.resetDetected,
completionHandled: old.completionHandled,
notice: old.notice,
pinUntil: old.pinUntil,
article: old.article,
advancing: old.advancing,
};
attach(replacement);
Object.assign(active, history);
active.playerRoot =
replacement.closest('[data-testid="videoPlayer"]') ||
replacement.parentElement;
active.article = history.article?.isConnected
? history.article
: replacement.closest('article[data-testid="tweet"]');
active.fullscreenRoot = fullscreenRoot || null;
logAdvance("Rebound to replacement video in the same post");
return true;
}
function syncFullscreen() {
if (transition) return false;
const fs = fullscreenElement();
if (!fs || !active) return false;
const fsVideo =
fs instanceof HTMLVideoElement ? fs : fs.querySelector("video");
if (
!(fsVideo instanceof HTMLVideoElement) ||
!fsVideo.videoWidth ||
!fsVideo.videoHeight ||
fsVideo === active.video
)
return false;
const old = active;
const history = {
maxTime: old.maxTime,
duration: old.duration,
progressed: old.progressed,
manualPause: old.manualPause,
manualUntil: old.manualUntil,
resetDetected: old.resetDetected,
completionHandled: old.completionHandled,
notice: old.notice,
pinUntil: performance.now() + 2500,
advancing: old.advancing,
};
attach(fsVideo);
Object.assign(active, history);
active.article ||= old.article;
active.fullscreenRoot = fs;
logAdvance("Switched tracking to the fullscreen video");
return true;
}
function scan() {
// During fullscreen exit/promotion X briefly hides the old player. Do not
// let that transient state select another feed video and cancel the move.
if (transition || active?.advancing) return;
syncFullscreen();
rebindReplacement();
const media = collectMedia();
candidates = media;
// Keep ownership stable while X pauses, rewinds, or swaps its replay UI.
// Otherwise the nearest paused feed video can replace the real main player
// and its duration/history, which breaks end detection on long videos.
if (active?.video.isConnected && visible(active.video)) return;
if (active?.video.isConnected && performance.now() < active.pinUntil)
return;
// A finished/paused main must not lose ownership to a nearby paused video
// before X inserts its replay overlay (which can arrive a little later).
if (
active &&
!active.completionHandled &&
(visible(active.playerRoot) ||
(!active.playerRoot.isConnected &&
active.article?.isConnected &&
visible(active.article))) &&
(active.video.paused || !active.video.isConnected) &&
!collectFeedVideos().some(
(el) => el !== active.video && !el.paused && visible(el),
)
)
return;
const videos = collectFeedVideos().filter(visible);
// Playing media wins, then the player closest to the viewport center.
videos.sort(
(a, b) =>
Number(a.paused) - Number(b.paused) ||
Math.abs(
a.getBoundingClientRect().top +
a.getBoundingClientRect().height / 2 -
innerHeight / 2,
) -
Math.abs(
b.getBoundingClientRect().top +
b.getBoundingClientRect().height / 2 -
innerHeight / 2,
),
);
const main = videos[0];
if (!main) teardown();
else if (active?.video !== main) attach(main);
}
async function advance(video) {
if (
!enabled ||
!autoAdvance ||
!routeOK() ||
document.hidden ||
active?.video !== video ||
active.advancing ||
active.completionHandled
)
return;
active.completionHandled = true;
logAdvance("Completion detected; looking for next video");
candidates = collectMedia();
const article = active.article;
const pseudo = Boolean(pseudoTarget?.isConnected);
if (pseudo) beginHandoff(video);
let next = findNextVideo(video, article);
if (!next) {
// X only creates players near the viewport. Scroll on (hidden behind
// the player in fullscreen) until the next one exists, rather than
// stopping and letting X replay the finished video.
active.advancing = true;
const state = active;
for (let attempt = 0; !next && attempt < 8; attempt++) {
if (!enabled || !autoAdvance || active !== state) break;
logAdvance("Next video not in the page yet; scrolling on");
window.scrollBy({
top: innerHeight * 0.8,
behavior: "instant",
});
for (let i = 0; i < 10 && !next; i++) {
await delay(60);
next = findNextVideo(video, article);
}
}
if (active === state) state.advancing = false;
if (active !== state) {
endHandoff();
return;
}
}
if (!next) {
endHandoff();
logAdvance("No next loaded video found");
// Let checkCompletion retry shortly instead of leaving X's replay screen.
active.gaveUpAt = performance.now();
active.notice = "No next loaded video. Scroll to load more posts.";
releaseUnused(new Set());
return;
}
active.advancing = true;
logAdvance("Next video found; starting playback");
const volume = video.volume;
const muted = video.muted;
const old = active;
// Legacy path only: real fullscreen that the bridge could not redirect.
const wasFullscreen = !pseudo && isXFullscreen(old);
const move = { phase: pseudo ? "swap" : "exit", cancelled: false };
transition = move;
if (!video.paused) video.pause();
releaseUnused(new Set());
try {
if (pseudo) {
// Stay in fullscreen. Behind the curtain, X believes fullscreen
// ended, so scrolling to the next post mounts the posts after it
// normally. Then fullscreen is handed to the next player.
const frame = () =>
new Promise((resolve) => requestAnimationFrame(resolve));
let nextArticle = next.closest('article[data-testid="tweet"]');
centerForTransition(nextArticle);
await frame();
await delay(60);
// X may restore its own scroll position when leaving fullscreen.
if (!next.isConnected)
next = findNextVideo(video, article) || next;
nextArticle = next.closest('article[data-testid="tweet"]');
centerForTransition(nextArticle);
if (!validTransition(move) || !next.isConnected) return;
const nextTarget = pseudoTargetFor(next);
showPseudo(nextTarget);
raiseCurtain();
endHandoff(true);
logAdvance("Moved fullscreen to next video");
// X re-renders the player for fullscreen and may swap its <video>.
// Start playback only after that, so it does not restart mid-play.
await frame();
await delay(60);
const settled = nextTarget.isConnected
? nextTarget.querySelector("video") ||
(nextTarget instanceof HTMLVideoElement && nextTarget)
: null;
if (settled && settled !== next) next = settled;
hideCurtain();
} else if (wasFullscreen) {
logAdvance("Leaving fullscreen before switching main video");
if (realFullscreenElement()) await document.exitFullscreen();
else findControl(old, "exit")?.click();
const deadline = performance.now() + 2500;
while (
validTransition(move) &&
isXFullscreen(old) &&
performance.now() < deadline
)
await delay(80);
if (!validTransition(move)) return;
if (isXFullscreen(old)) {
old.notice =
"X did not exit fullscreen. Use its exit control to continue.";
return;
}
}
if (!validTransition(move)) return;
if (!next.isConnected) {
old.notice =
"The next video was unloaded. Scroll to load it again.";
return;
}
attach(next, true);
const state = active;
state.advancing = true;
state.pinUntil = performance.now() + 14000;
if (!pseudo) centerForTransition(next);
next.volume = volume;
next.muted = muted;
try {
if (next.paused && next.currentTime > 0) next.currentTime = 0;
} catch {
/* not yet seekable */
}
await startMain(state, wasFullscreen, move);
} catch (error) {
logAdvance(`Advance failed: ${error.name}`);
if (validTransition(move) && active)
active.notice =
"Could not switch player. Press play to continue.";
} finally {
// Never leave X believing fullscreen ended, or the curtain up.
if (handoffActive) endHandoff();
else hideCurtain();
if (transition === move) {
transition = null;
if (active) {
active.advancing = false;
active.autoStarting = false;
active.pinUntil = 0;
}
}
dirty = true;
}
}
function findNextVideo(video, article) {
const orderedVideos = collectFeedVideos();
const index = orderedVideos.indexOf(video);
// X may replace the finished <video> with its replay UI. Keep the post as
// an ordering anchor so we can still find the next loaded video; if the
// post itself was unmounted, anything still on/below screen follows it.
const following =
index >= 0
? orderedVideos.slice(index + 1)
: orderedVideos.filter((el) =>
article?.isConnected
? !article.contains(el) &&
article.compareDocumentPosition(el) &
Node.DOCUMENT_POSITION_FOLLOWING
: el.getBoundingClientRect().bottom > 0,
);
return (
following.find(
(el) => el instanceof HTMLVideoElement && el.isConnected,
) || null
);
}
function validTransition(move) {
return (
transition === move &&
!move.cancelled &&
enabled &&
autoAdvance &&
routeOK() &&
!document.hidden
);
}
function centerForTransition(el) {
if (!(el instanceof Element) || !el.isConnected) return;
try {
el.scrollIntoView({
block: "center",
inline: "nearest",
behavior: "instant",
});
} catch {
el.scrollIntoView();
}
}
// X keeps only ~6 posts in the DOM, but its app state holds the whole loaded
// timeline (order + media). Upcoming side media comes from there, so it does
// not depend on X having created (or buffered) those players yet.
let timelineStore = null;
let timelineCache = { time: -Infinity, value: null };
function fiberOf(node) {
const target = node.wrappedJSObject || node;
const key = pageWindow.Object.keys(target).find((k) =>
k.startsWith("__reactFiber$"),
);
return key ? target[key] : null;
}
function timelineData(now) {
if (now - timelineCache.time < 300) return timelineCache.value;
let value = null;
try {
const cell = document.querySelector('[data-testid="cellInnerDiv"]');
let fiber = cell && fiberOf(cell);
let list = null;
for (let d = 0; fiber && d < 300; d++, fiber = fiber.return) {
const props = fiber.memoizedProps;
if (
!list &&
Array.isArray(props?.list) &&
props.list[0]?.data?.entryId
)
list = props.list;
const store = props?.store || props?.value?.store;
if (!timelineStore && typeof store?.getState === "function")
timelineStore = store;
if (list && timelineStore) break;
}
const tweets =
timelineStore?.getState()?.entities?.tweets?.entities;
if (list && tweets) value = { list, tweets };
} catch {
/* X internals changed or are inaccessible; use the DOM instead */
}
timelineCache = { time: now, value };
return value;
}
function statusId(article) {
const href = article
?.querySelector('a[href*="/status/"] time')
?.closest("a")
?.getAttribute("href");
return href?.match(/\/status\/(\d+)/)?.[1] || "";
}
function sourceTweet(tweets, id) {
const tweet = tweets[id];
const retweet = tweet?.retweeted_status;
return (
(retweet && tweets[typeof retweet === "string" ? retweet : retweet.id_str]) ||
tweet
);
}
function portraitTimelineMedia(tweet) {
const items = [];
for (const media of tweet?.extended_entities?.media || []) {
const { width, height } = media.original_info || {};
if (!(width > 0 && height > width)) continue;
if (media.type === "photo") {
items.push({
timeline: true,
kind: "image",
key: media.media_url_https,
url: `${media.media_url_https}?name=small`,
});
continue;
}
// Side strips do not need the top bitrate; it only loads slower.
const variants = (media.video_info?.variants || [])
.filter((v) => v.content_type === "video/mp4" && v.url)
.sort((a, b) => (a.bitrate || 0) - (b.bitrate || 0));
const pick =
variants.filter((v) => (v.bitrate || 0) <= 2200000).pop() ||
variants[0];
if (pick)
items.push({
timeline: true,
kind: "video",
key: pick.url,
url: pick.url,
poster: media.media_url_https,
});
}
return items;
}
function timelineSideItems(state, count, now) {
const data = timelineData(now);
const id = statusId(state.article);
if (!data || !id) return [];
const idOf = (entry) => entry.data?.content?.id;
const index = data.list.findIndex(
(entry) =>
idOf(entry) === id ||
sourceTweet(data.tweets, idOf(entry))?.id_str === id,
);
if (index < 0) return [];
const items = [];
for (const entry of data.list.slice(index + 1)) {
if (items.length >= count) break;
const tweetId = idOf(entry);
if (tweetId)
items.push(
...portraitTimelineMedia(sourceTweet(data.tweets, tweetId)),
);
}
return items.slice(0, count);
}
function sideItems(state) {
if (mode === "blur" || !portrait(state.video)) return [];
const count = mode === "different" ? 2 : 1;
const fromTimeline = timelineSideItems(state, count, performance.now());
if (fromTimeline.length) return fromTimeline;
const media = collectMedia();
const index = media.indexOf(state.video);
const following =
index >= 0
? media.slice(index + 1)
: media.filter(
(el) =>
state.article?.isConnected &&
!state.article.contains(el) &&
state.article.compareDocumentPosition(el) &
Node.DOCUMENT_POSITION_FOLLOWING,
);
return mode === "different"
? following.slice(0, 2)
: following.slice(0, 1);
}
async function startMain(state, returnFullscreen, move) {
// Side videos load in the background (thumbnails show meanwhile); never
// hold the main video for them.
state.autoStarting = true;
state.notice = "";
move.phase = "starting main";
const deadline = performance.now() + 9000;
let lastAttempt = -Infinity;
let lastTime = state.video.currentTime;
let stableSince = null;
let fullscreenTask = null;
let failure = "";
let pendingPlay = false;
let lastCenter = -Infinity;
let attempts = 0;
while (
validTransition(move) &&
active === state &&
state.video.isConnected &&
performance.now() < deadline
) {
const now = performance.now();
const v = state.video;
// Exiting X fullscreen can cause its virtualized feed to restore the
// previous scroll position. Re-center the selected player while the
// new video is being initialized, then stop touching scroll state.
if (returnFullscreen && now - lastCenter >= 300) {
centerForTransition(v);
lastCenter = now;
}
if (v.ended) break;
if (v.paused) {
stableSince = null;
if (!pendingPlay && now - lastAttempt >= 650) {
lastAttempt = now;
// A direct play() first; clicking X's control as well on the
// same attempt can toggle it straight back to paused.
if (attempts++ > 0) {
revealControls(state);
const button = findControl(state, "play");
if (button) {
button.click();
logAdvance("Activated X play control for next video");
}
}
pendingPlay = true;
v.play()
.catch((error) => {
failure = error.name;
logAdvance(
`Next video play() failed: ${error.name}`,
);
})
.finally(() => {
pendingPlay = false;
});
}
} else if (v.currentTime > lastTime + 0.005) {
if (stableSince === null) {
stableSince = now;
logAdvance("Next video playback progressing");
}
if (returnFullscreen && !fullscreenTask) {
fullscreenTask = requestXFullscreen(state, move);
}
// Survive the post-play pause during X's first player initialization.
if (
now - stableSince >= 2500 &&
(!returnFullscreen || state.fullscreenAttemptDone)
)
break;
}
if (failure === "NotAllowedError" && v.paused) break;
lastTime = v.currentTime;
await delay(100);
}
if (fullscreenTask) await fullscreenTask;
if (!validTransition(move) || active !== state) return;
if (state.video.isConnected && state.video.paused && !state.video.ended)
state.notice =
"Auto-play was blocked. Press play on the new main video.";
state.autoStarting = false;
state.advancing = false;
// A short clip can finish during startup; do not discard that completion.
if (state.video.ended) {
state.completionHandled = false;
dirty = true;
}
}
function controlRoot(state) {
const v = state.video;
const root = v.closest('[data-testid="videoPlayer"]');
if (root) return root;
let parent = v.parentElement;
while (
parent?.parentElement &&
parent.parentElement !== document.body &&
parent.parentElement.querySelectorAll("video").length === 1
)
parent = parent.parentElement;
return parent || state.host;
}
function findControl(state, kind, allowHidden = false) {
if (!state) return null;
return (
[
...controlRoot(state).querySelectorAll(
'button, [role="button"]',
),
].find((item) => {
if (
item.disabled ||
item.getAttribute("aria-disabled") === "true"
)
return false;
if (
!allowHidden &&
(!visible(item) ||
getComputedStyle(item).visibility === "hidden")
)
return false;
const owner = item.closest('[data-testid="videoPlayer"]');
if (
owner &&
owner !== state.video.closest('[data-testid="videoPlayer"]')
)
return false;
const label = (
item.getAttribute("aria-label") ||
item.title ||
""
).trim();
const testid = item.dataset.testid || "";
if (kind === "play")
return (
/^videoPlayerPlayButton$/i.test(testid) ||
/^(play( video)?|watch again|replay)$/i.test(label)
);
if (kind === "exit")
return (
/^videoPlayerExitFullScreenButton$/i.test(testid) ||
/^exit full ?screen$/i.test(label)
);
return (
!/exit/i.test(label + testid) &&
(/^(videoPlayerFullScreenButton|fullscreenButton)$/i.test(
testid,
) ||
/^(enter )?full ?screen$/i.test(label))
);
}) || null
);
}
function revealControls(state) {
const root = controlRoot(state);
const r = state.video.getBoundingClientRect();
const point = {
bubbles: true,
clientX: r.left + r.width / 2,
clientY: r.top + r.height / 2,
};
// X exposes the player controls after pointer hover. Dispatch the full
// family of hover events because different player revisions listen to
// different ones (mousemove alone is not sufficient in some revisions).
for (const type of [
"pointerover",
"pointermove",
"mouseover",
"mousemove",
]) {
const EventType = type.startsWith("pointer")
? PointerEvent
: MouseEvent;
state.video.dispatchEvent(
new EventType(type, {
...point,
pointerId: 1,
pointerType: "mouse",
}),
);
root.dispatchEvent(
new EventType(type, {
...point,
pointerId: 1,
pointerType: "mouse",
}),
);
}
}
function isXFullscreen(state = active) {
if (!state) return false;
const fs = fullscreenElement();
return (
Boolean(fs && (fs === state.video || fs.contains(state.video))) ||
Boolean(findControl(state, "exit"))
);
}
async function requestXFullscreen(state, move) {
move.phase = "returning fullscreen";
const deadline = performance.now() + 4500;
let lastClick = -Infinity;
let clicks = 0;
while (
validTransition(move) &&
active === state &&
performance.now() < deadline
) {
if (isXFullscreen(state)) {
revealControls(state);
setTimeout(() => {
if (active?.video === state.video) revealControls(state);
}, 180);
setTimeout(() => {
if (active?.video === state.video) revealControls(state);
}, 500);
state.fullscreenAttemptDone = true;
logAdvance("Confirmed X fullscreen for next video");
return;
}
const now = performance.now();
revealControls(state);
const button =
findControl(state, "enter") ||
findControl(state, "enter", true);
if (button && clicks < 3 && now - lastClick >= 1000) {
lastClick = now;
clicks++;
button.click();
logAdvance("Requested selected player fullscreen");
}
if (state.fullscreenDenied) break;
await delay(100);
}
state.fullscreenAttemptDone = true;
if (validTransition(move) && active === state) {
state.notice =
"Fullscreen did not open. Click Enter X fullscreen to retry with a user gesture.";
resumeFullscreen.hidden = false;
logAdvance("Fullscreen not confirmed; waiting for user gesture");
}
}
function samplePlayback(now) {
const state = active;
if (!state) return;
const video = state.video;
const time = video.currentTime;
const source = video.srcObject || video.currentSrc || video.src;
if (source !== state.trackedSource) {
state.trackedSource = source;
state.lastTime = time;
state.progressed = false;
state.resetDetected = false;
state.duration = video.duration;
}
if (Number.isFinite(video.duration) && video.duration > 0)
state.duration = video.duration;
const duration = state.duration;
const endWindow = Math.min(1.25, Math.max(0.25, duration * 0.1));
// X's reset frame is commonly around 0.5 seconds rather than exactly 0.
const startWindow = Math.min(1.5, Math.max(0.75, duration * 0.03));
// X can pause and rewind (or loop) without leaving ended=true. Remember
// the transition itself before a later poll sees only the first frame.
if (
state.progressed &&
!state.manualPause &&
now >= state.manualUntil &&
Number.isFinite(duration) &&
duration > 0 &&
state.maxTime >= duration - endWindow &&
time <= startWindow &&
state.maxTime - time > duration / 2
) {
if (!state.resetDetected) logAdvance("Detected end-to-start reset");
state.resetDetected = true;
}
if (!video.paused && time > state.lastTime + 0.01) {
state.progressed = true;
if (now >= state.manualUntil) state.manualPause = false;
}
state.lastTime = time;
state.maxTime = Math.max(state.maxTime, time);
}
function checkCompletion(now) {
// A handled completion that did not advance (nothing loaded yet, or X
// swapped its player mid-way) would otherwise stay on "Watch again".
if (
active?.completionHandled &&
!active.advancing &&
!transition &&
(active.retries || 0) < 3
) {
if (active.gaveUpAt && now - active.gaveUpAt > 1500) {
active.retries = (active.retries || 0) + 1;
active.gaveUpAt = 0;
active.completionHandled = false;
logAdvance("Retrying advance");
} else if (
!active.gaveUpAt &&
(active.video.paused || !active.video.isConnected)
)
active.gaveUpAt = now;
}
if (
!active ||
!autoAdvance ||
active.completionHandled ||
(active.video.isConnected && now - active.lastEndCheck < 200)
)
return;
active.lastEndCheck = now;
const { video, playerRoot, article } = active;
const time = video.currentTime;
samplePlayback(now);
// Some X player states never deliver an ended event to our listener.
// Read the media state and X's replay UI as independent completion signals.
const root = article?.isConnected ? article : playerRoot;
const replay =
root.isConnected &&
[
...root.querySelectorAll(
'button, [role="button"], [data-testid], span, div',
),
].some((button) => {
// X sometimes renders replay as plain text outside videoPlayer, not a button.
if (
button.closest(
'[data-testid="tweetText"], [data-testid="quoteTweet"]',
)
)
return false;
const owner = button.closest('[data-testid="videoPlayer"]');
if (
owner &&
owner !== playerRoot &&
video.isConnected &&
owner.querySelector("video")
)
return false;
const label = (
button.getAttribute("aria-label") ||
button.textContent ||
""
)
.replace(/\s+/g, " ")
.trim();
const identified =
/^videoReplay(Button)?$/.test(
button.dataset.testid || "",
) || /^(watch again|replay)$/i.test(label);
const textLeaf =
button.children.length === 0 ||
button.matches(
'button, [role="button"], [data-testid="videoReplay"], [data-testid="videoReplayButton"]',
);
return (
identified &&
textLeaf &&
visible(button) &&
button.getClientRects().length > 0 &&
getComputedStyle(button).visibility !== "hidden" &&
getComputedStyle(button).opacity !== "0"
);
});
const reachedEnd =
active.progressed &&
!active.manualPause &&
video.paused &&
Number.isFinite(active.duration) &&
active.duration > 0 &&
time >= active.duration - 0.12;
active.replayDetected = replay;
// The visible replay screen is authoritative even if X has already reset
// or restarted the underlying media element behind that screen.
if (video.ended || reachedEnd || replay || active.resetDetected)
advance(video);
}
function playSide(saved) {
saved.lastPlay = performance.now();
saved.player.play().catch((error) => {
if (![...sidePlayers.values()].includes(saved)) return;
if (error.name === "NotAllowedError" && !saved.player.muted) {
saved.audioBlocked = true;
saved.player.muted = true;
saved.player.play().catch(() => {});
}
});
}
function resolveVideoURL(el, now) {
const direct = el.currentSrc || el.src;
if (/^https?:/i.test(direct)) return direct;
const article = el.closest('article[data-testid="tweet"]');
if (!article) return "";
const thumbnail =
el.poster ||
el.closest('[data-testid="videoPlayer"]')?.querySelector("img")
?.src ||
"";
const identity = `${direct}|${thumbnail}`;
const cached = resolvedMedia.get(el);
if (cached?.identity === identity && now - cached.time < 3000)
return cached.url;
// X often exposes only a MediaSource blob on <video>, but the rendered
// post's React props still contain video_info.variants with playable MP4s.
// Read only that article's props; never start its player or call private APIs.
const pageWindow =
typeof unsafeWindow === "undefined" ? window : unsafeWindow;
const roots = [];
for (const element of [
article,
...article.querySelectorAll('[data-testid="videoPlayer"], video'),
]) {
const node = element.wrappedJSObject || element;
for (const key of pageWindow.Object.keys(node)) {
if (key.startsWith("__reactProps$")) roots.push(node[key]);
if (key.startsWith("__reactFiber$")) {
let fiber = node[key];
for (let i = 0; fiber && i < 16; i++, fiber = fiber.return)
roots.push(fiber.memoizedProps);
}
}
}
const seen = new Set();
const records = [];
const queue = roots.map((value) => ({ value, depth: 0 }));
for (let i = 0; i < queue.length && seen.size < 4000; i++) {
const { value, depth } = queue[i];
if (
!value ||
typeof value !== "object" ||
seen.has(value) ||
value instanceof Node
)
continue;
seen.add(value);
if (Array.isArray(value.video_info?.variants)) records.push(value);
if (depth >= 12) continue;
for (const key of Object.keys(value)) {
if (
[
"_owner",
"children",
"return",
"child",
"sibling",
"stateNode",
"alternate",
].includes(key)
)
continue;
// Avoid evaluating getters in page-owned objects.
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (
queue.length < 12000 &&
descriptor &&
"value" in descriptor &&
descriptor.value &&
typeof descriptor.value === "object"
) {
queue.push({ value: descriptor.value, depth: depth + 1 });
}
}
}
const posterKey = (url) => {
try {
return new URL(url).pathname.replace(/\.[a-z0-9]+$/i, "");
} catch {
return "";
}
};
const poster = posterKey(thumbnail);
// Match the thumbnail, rather than assuming the first variant belongs to
// this player (quoted posts and multi-video posts may have several).
const matches = records.filter(
(record) =>
poster &&
[record.media_url_https, record.media_url, record.poster].some(
(url) => posterKey(url) === poster,
),
);
const urls = new Map();
for (const record of matches)
for (const variant of record.video_info.variants) {
if (
variant.content_type !== "video/mp4" ||
typeof variant.url !== "string"
)
continue;
try {
const url = new URL(variant.url);
if (
url.protocol === "https:" &&
url.hostname === "video.twimg.com"
)
urls.set(url.href, Number(variant.bitrate) || 0);
} catch {
/* malformed media metadata */
}
}
const url = [...urls].sort((a, b) => b[1] - a[1])[0]?.[0] || "";
resolvedMedia.set(el, { identity, time: now, url });
return url;
}
function sideSource(item, main, now, used) {
if (item.timeline && item.kind === "image")
return cachedImage(item.url) || item;
if (!item.timeline && !(item instanceof HTMLVideoElement)) return item;
// Side players are keyed by X's element, or by URL for timeline media.
const el = item.timeline ? item.key : item;
// Never play, pause, or mute X's other players: their play events can make
// X's single-player coordinator immediately pause the main video.
const stream =
!item.timeline && item.srcObject instanceof MediaStream
? item.srcObject
: null;
let url = item.timeline ? item.url : "";
try {
if (!stream && !item.timeline) url = resolveVideoURL(item, now);
} catch {
/* page metadata may be inaccessible */
}
const key = stream || url;
// MediaSource blob URLs cannot safely be attached to a second player.
if (!stream && !url) return item;
if (sidePlayers.has(el) && sidePlayers.get(el).key !== key) {
const keep = new Set(sidePlayers.keys());
keep.delete(el);
releaseUnused(keep);
}
used.add(el);
if (!sidePlayers.has(el)) {
// Keep detached so media events never reach X's document listeners.
const player = document.createElement("video");
player.muted = true;
player.defaultMuted = true;
player.loop = true;
player.playsInline = true;
player.preload = "auto";
if (stream) player.srcObject = stream;
else player.src = url;
sidePlayers.set(el, {
key,
player,
lastPlay: -Infinity,
audioBlocked: false,
});
}
const saved = sidePlayers.get(el);
const player = saved.player;
player.muted = !sideAudio || saved.audioBlocked;
player.volume = main.volume;
if (main.paused || main.ended) {
if (!player.paused) player.pause();
return player;
}
if (player.paused && !player.error && now - saved.lastPlay > 1000) {
playSide(saved);
}
return player;
}
function paint(canvas, source, width, height, blur) {
const [sw, sh] = dimensions(source);
if (
!sw ||
!sh ||
(source instanceof HTMLVideoElement && source.readyState < 2)
)
return false;
// A capped backing resolution keeps three simultaneous videos reasonable on laptops.
const scale = Math.min(devicePixelRatio || 1, 1.5, 960 / height);
const cw = Math.max(1, Math.round(width * scale));
const ch = Math.max(1, Math.round(height * scale));
if (canvas.width !== cw || canvas.height !== ch) {
canvas.width = cw;
canvas.height = ch;
}
const ctx = canvas.getContext("2d");
// Match the main video's full height. Cover-scaling can crop the top/bottom
// of narrow side videos, making their contents appear vertically displaced.
const zoom = blur ? Math.max(cw / sw, ch / sh) * 1.18 : ch / sh;
ctx.filter = "none";
ctx.fillStyle = "#000";
ctx.fillRect(0, 0, cw, ch);
ctx.filter = blur
? `blur(${Math.max(8, cw * 0.08)}px) brightness(0.65)`
: "none";
try {
ctx.drawImage(
source,
(cw - sw * zoom) / 2,
(ch - sh * zoom) / 2,
sw * zoom,
sh * zoom,
);
return true;
} catch {
return false;
}
}
function raiseControls() {
const { video, host, controlStyles } = active;
const root = video.closest('[data-testid="videoPlayer"]') || host;
for (const control of root.querySelectorAll(
'[role="slider"], [role="progressbar"], button, [role="button"], [data-testid="videoControls"]',
)) {
if (control.contains(video)) continue;
// Raise the separate controls branch at its common ancestor with the video.
// Raising only a slider inside a lower stacking context is insufficient.
let branch = control;
while (
branch.parentElement &&
branch.parentElement !== root &&
!branch.parentElement.contains(video)
)
branch = branch.parentElement;
if (branch.contains(video)) continue;
const computed = getComputedStyle(branch);
const changes = {};
if (computed.position === "static") changes.position = "relative";
if (!(Number(computed.zIndex) >= 2)) changes["z-index"] = "2";
for (const [property, applied] of Object.entries(changes)) {
if (!controlStyles.has(branch)) controlStyles.set(branch, {});
const saved = controlStyles.get(branch);
if (!saved[property])
saved[property] = {
original: branch.style.getPropertyValue(property),
priority: branch.style.getPropertyPriority(property),
applied,
};
branch.style.setProperty(property, applied);
}
}
}
function render(now) {
const { video, host, canvases } = active;
const r = video.getBoundingClientRect();
const hr = host.getBoundingClientRect();
const style = getComputedStyle(video);
if (!portrait(video)) {
canvases.forEach((c) => {
c.style.display = "none";
});
releaseUnused(new Set());
status.textContent =
active.notice ||
"Horizontal/square video · auto-play continues in feed order";
return;
}
// X's contained video has centered letterboxing. Do not cover cropped/stretched video.
if (
!["contain", "scale-down"].includes(style.objectFit) ||
style.objectPosition !== "50% 50%"
) {
canvases.forEach((c) => {
c.style.display = "none";
});
if (!active.autoStarting) releaseUnused(new Set());
status.textContent =
active.notice ||
"Waiting for a centered, contained portrait player.";
return;
}
const fit = Math.min(
r.width / video.videoWidth,
r.height / video.videoHeight,
style.objectFit === "scale-down" ? 1 : Infinity,
);
const contentWidth = video.videoWidth * fit;
const contentHeight = video.videoHeight * fit;
const contentTop = (r.height - contentHeight) / 2;
const bar = (r.width - contentWidth) / 2;
if (bar < 3 || r.height < 1) {
canvases.forEach((c) => {
c.style.display = "none";
});
if (!active.autoStarting) releaseUnused(new Set());
status.textContent =
active.notice || "This player has no side bars to fill.";
return;
}
const next = sideItems(active);
const sources =
mode === "blur"
? [video, video]
: [next[0], mode === "different" ? next[1] : next[0]];
const used = new Set();
let fallback = false;
let missingMedia = false;
// Keep full-height side pictures and paint the real controls above them.
raiseControls();
sources.forEach((source, i) => {
const c = canvases[i];
if (!source) {
source = video;
missingMedia = true;
}
const original = source;
if (source !== video) source = sideSource(source, video, now, used);
const blurred = source === video;
c.style.left = `${r.left - hr.left - host.clientLeft + host.scrollLeft + (i ? r.width - bar : 0)}px`;
c.style.top = `${r.top - hr.top - host.clientTop + host.scrollTop + contentTop}px`;
c.style.width = `${bar}px`;
c.style.height = `${contentHeight}px`;
let painted = paint(c, source, bar, contentHeight, blurred);
if (
mode !== "blur" &&
original !== video &&
(!painted ||
(source === original && source instanceof HTMLVideoElement))
) {
// Keep the requested upcoming media visible as a still when it cannot
// play independently. Do not silently turn a next-media mode into blur.
if (!painted && !original.timeline)
painted = paint(c, original, bar, contentHeight, false);
if (!painted) {
const poster = original.timeline
? cachedImage(original.poster)
: original instanceof HTMLVideoElement &&
posterImage(original);
if (poster)
painted = paint(c, poster, bar, contentHeight, false);
}
fallback = true;
}
if (blurred && mode !== "blur") fallback = true;
c.style.display = painted ? "block" : "none";
});
releaseUnused(used);
const audioStatus = sideAudio ? "side audio on" : "side videos muted";
const audioBlocked = [...sidePlayers.values()].some(
(saved) => saved.audioBlocked,
);
status.textContent =
active.notice ||
(audioBlocked
? "Browser blocked side audio. Toggle side-video audio off and on to retry."
: missingMedia
? "Missing upcoming media uses the blurred main video. Scroll to load more posts."
: fallback
? "Upcoming media is loading or unavailable for independent playback; a still may be shown. Scroll to load more posts."
: `Active ·
${audioStatus}`);
}
// Observe feed additions and src changes without reacting to our own canvas/style updates.
new MutationObserver((records) => {
if (
records.some(
(r) =>
r.type === "attributes" ||
[...r.addedNodes, ...r.removedNodes].some(
(n) => n.nodeType === 1 && !n.dataset?.xSidefill,
),
)
)
dirty = true;
}).observe(document.body, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ["src", "poster"],
});
document.addEventListener(
"loadedmetadata",
() => {
dirty = true;
},
true,
);
document.addEventListener(
"play",
(event) => {
if (event.target === active?.video) {
active.notice = "";
active.completionHandled = false;
active.manualPause = false;
active.maxTime = event.target.currentTime;
active.gaveUpAt = 0;
// Keep the short retry window alive: X can emit play, then pause the
// player while its fullscreen/inline controls finish updating.
}
dirty = true;
},
true,
);
document.addEventListener(
"pointerdown",
(event) => {
if (active?.playerRoot.contains(event.target)) {
cancelTransition();
active.manualPause = true;
active.manualUntil = performance.now() + 1500;
active.resetDetected = false;
active.autoStarting = false;
if (active.playTimer) clearInterval(active.playTimer);
active.playTimer = null;
}
const player = event.target.closest?.(
'[data-testid="videoPlayer"]',
);
const video = player?.querySelector("video");
if (enabled && routeOK() && video && video !== active?.video) {
attach(video);
dirty = true;
}
},
true,
);
document.addEventListener(
"keydown",
(event) => {
if (
active &&
!event.target.matches?.(
'input, textarea, [contenteditable="true"]',
) &&
[
" ",
"k",
"K",
"j",
"J",
"l",
"L",
"ArrowLeft",
"ArrowRight",
"Home",
"End",
..."0123456789",
].includes(event.key)
) {
cancelTransition();
active.manualPause = true;
active.manualUntil = performance.now() + 1500;
active.resetDetected = false;
}
},
true,
);
// The root no longer uses overflow:hidden (it may stop X updating its feed),
// so keep the wheel from scrolling the feed behind a fullscreen player.
window.addEventListener(
"wheel",
(event) => {
if (pseudoTarget && !panel.contains(event.target))
event.preventDefault();
},
{ capture: true, passive: false },
);
document.addEventListener("visibilitychange", () => {
if (document.hidden) teardown();
dirty = true;
});
document.addEventListener(
"fullscreenerror",
() => {
if (!realFullscreenElement()) clearPseudo();
if (active && transition) {
active.fullscreenDenied = true;
logAdvance("Browser rejected fullscreen request");
}
},
true,
);
document.addEventListener(
"fullscreenchange",
() => {
dirty = true;
if (realFullscreenElement()) pseudoPendingUntil = 0;
else clearPseudo();
requestAnimationFrame(() => {
if (!transition) {
syncFullscreen();
rebindReplacement();
}
dirty = true;
});
},
true,
);
function tick(now) {
requestAnimationFrame(tick);
const onPage = routeOK();
panel.hidden = !onPage;
maintainPseudo();
if (!onPage || !enabled || document.hidden) {
if (active) teardown();
return;
}
checkCompletion(now);
if ((dirty && now - lastScan > 200) || now - lastScan > 1000) {
dirty = false;
lastScan = now;
scan();
}
if (active && autoAdvance && active.video.loop)
active.video.loop = false;
if (active && now - lastFrame >= 1000 / 30) {
lastFrame = now;
render(now);
}
}
requestAnimationFrame(tick);
})();