Replaces sxyprn post pages with a clean, ad-free custom video player. Built for Firefox Mobile, works everywhere. Follows in-page navigation, so every video gets the player and not just the first.
// ==UserScript==
// @name SxyPrn Clean Player
// @namespace sxyprn-clean-player
// @version 1.4.0
// @description Replaces sxyprn post pages with a clean, ad-free custom video player. Built for Firefox Mobile, works everywhere. Follows in-page navigation, so every video gets the player and not just the first.
// @author Anonymous
// @match https://sxyprn.com/*
// @match https://sxyprn.net/*
// @match https://www.sxyprn.com/*
// @match https://www.sxyprn.net/*
// @grant none
// @run-at document-start
// @noframes
// @license MIT
// ==/UserScript==
/*
* How it works
* ------------
* 1. Runs at document-start and polls the streaming HTML for `.vidsnfo[data-vnfo]`
* (present in the initial markup of every video post, before any ad script runs).
* 2. Decodes the real video URL with the same arithmetic the site's own main2.js
* `getvsrc()` uses (digit sums + a host-bound base64url token + a numeric offset).
* 3. Swaps the document display for a custom player: gesture seeking, buffered
* progress bar, speed control, fullscreen with landscape lock, resume position.
* (Original site elements are hidden visually).
* 4. Follows navigation. sxyprn swaps posts in place with history.pushState,
* so a document-start script only ever fires for the very first video.
* Every URL change (pushState/replaceState/popstate, plus a polling
* backstop) tears the old player down, hands the page back, and rebuilds
* for the post that is now loaded.
*
* If the post has no hosted video (photo post / external links only), or the
* URL is not a post page, the script leaves the page untouched.
*/
(() => {
/* ------------------------------------------------------------------ *
* 1. Extraction *
* ------------------------------------------------------------------ */
const STOP_POLLING_AFTER_MS = 20000;
const POLL_INTERVAL_MS = 40;
// Grace period during which an extraction that still resolves to the video
// that was just playing is treated as leftover markup rather than the new post.
const STALE_GRACE_MS = 4000;
// Backstop for navigations the history hooks miss.
const URL_WATCH_INTERVAL_MS = 400;
function digitSum(str) {
const digits = String(str).replace(/[^0-9]/g, "");
let sum = 0;
for (let i = 0; i < digits.length; i++)
sum += parseInt(digits.charAt(i), 10);
return sum;
}
function b64urlHostBound(a, b, host) {
const targetHost = host || window.location.host;
const raw = `${a}-${targetHost}-${b}`;
return btoa(raw).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ".");
}
// Mirrors getvsrc() from the site's main2.js
// Routes through sxyprn.net (open media mirror) so Cloudflare on sxyprn.com does not block the stream
function resolveVideoUrl(rawPath) {
const tmp = String(rawPath).split("/");
if (tmp.length < 8) return "";
const host = window.location.host.includes("sxyprn.net")
? window.location.host
: "sxyprn.net";
const sum6 = digitSum(tmp[6]);
const sum7 = digitSum(tmp[7]);
tmp[1] += `5/${b64urlHostBound(sum6, sum7, host)}`;
tmp[5] = String(Number(tmp[5]) - (sum6 + sum7));
return `https://${host}${tmp.join("/")}`;
}
function isValidVideoUrl(url) {
if (!url || typeof url !== "string") return false;
const trimmed = url.trim();
if (
!trimmed ||
trimmed === "/" ||
trimmed === window.location.href ||
trimmed === window.location.origin ||
trimmed === `${window.location.origin}/`
) {
return false;
}
return /\.vid|\.mp4|\/cdn5?\/|\/vidi\/|blob:/i.test(trimmed);
}
function isPostUrl(href) {
try {
return /^\/post\//i.test(new URL(href, location.href).pathname);
} catch (_e) {
return false;
}
}
function playerElement() {
return document.querySelector("#player_el, video.player_el");
}
// A post page carries one .vidsnfo per post it renders — the one being
// watched plus every related post below it. After an in-page navigation the
// map holding the wanted video is not necessarily the first in the document,
// so prefer the one keyed by the post the site currently has loaded.
function vnfoMaps() {
const maps = [];
document.querySelectorAll(".vidsnfo[data-vnfo]").forEach((node) => {
let map;
try {
map = JSON.parse(node.getAttribute("data-vnfo"));
} catch (_e) {
return;
}
if (map && typeof map === "object") maps.push({ node: node, map: map });
});
return maps;
}
function postTitle(node) {
let title = "";
const wrap = node?.closest(".post_el, .post_el_small, .post_el_wrap");
const text = wrap?.querySelector(".post_text");
if (text) title = text.textContent || "";
if (!title) {
const metaName = document.querySelector('meta[itemprop="name"]');
if (metaName) title = metaName.getAttribute("content") || "";
}
if (!title) title = document.title || "";
return title.replace(/\s+/g, " ").trim().slice(0, 140);
}
function extract() {
const maps = vnfoMaps();
if (!maps.length) return null;
const playerEl = playerElement();
const activeId = playerEl ? playerEl.getAttribute("data-postid") : null;
let postId = null;
let rawPath = null;
let holder = null;
if (activeId) {
for (const entry of maps) {
if (entry.map[activeId]) {
postId = activeId;
rawPath = entry.map[activeId];
holder = entry.node;
break;
}
}
}
if (!rawPath) {
const first = maps[0];
postId = Object.keys(first.map)[0];
rawPath = first.map[postId];
holder = first.node;
}
if (!rawPath) return null;
let poster = playerEl ? playerEl.getAttribute("poster") : null;
if (!poster) {
const thumb = document.querySelector('meta[itemprop="thumbnailUrl"]');
if (thumb) poster = thumb.getAttribute("content");
}
if (poster?.startsWith("//")) poster = `https:${poster}`;
const fullSrc = resolveVideoUrl(rawPath);
if (!isValidVideoUrl(fullSrc)) return null;
return {
src: fullSrc,
poster: poster || "",
title: postTitle(holder),
postId: postId,
};
}
// Fallback: site JS (or markup changes) may populate the video element
// directly. Only accept real media stream URLs.
function extractFromVideoElement() {
const el = document.querySelector(
"#player_el, video.player_el, video[src]",
);
if (!el) return null;
const candidates = [
el.currentSrc,
el.getAttribute("src"),
el.getAttribute("data-puri"),
el.querySelector("source")?.getAttribute("src"),
];
let src = "";
for (let i = 0; i < candidates.length; i++) {
const c = candidates[i];
if (isValidVideoUrl(c)) {
src = c;
break;
}
}
if (!src) return null;
if (src.startsWith("//")) src = `https:${src}`;
else if (src.charAt(0) === "/") src = location.origin + src;
let poster = el.getAttribute("poster");
if (poster?.startsWith("//")) poster = `https:${poster}`;
let title = "";
const metaName = document.querySelector('meta[itemprop="name"]');
if (metaName) title = metaName.getAttribute("content") || "";
if (!title) title = document.title || "";
title = title.replace(/\s+/g, " ").trim().slice(0, 140);
return {
src: src,
poster: poster || "",
title: title,
postId: el.getAttribute("data-postid") || location.pathname,
};
}
/* ------------------------------------------------------------------ *
* 1b. Navigation *
* ------------------------------------------------------------------ *
* sxyprn swaps posts in place and only updates the address bar with
* history.pushState. A userscript runs once per document, so without the
* watcher below the player would only ever appear for whichever post the
* document happened to load with — every video after the first would keep
* the site's own player. Each URL change disposes of the current player,
* restores the page, and starts a fresh scan for the new post.
*/
let scanTimer = 0;
let navToken = 0;
let lastKey = null;
function locationKey() {
return location.pathname + location.search;
}
function scan(token, deadline, staleSrc, staleUntil) {
if (token !== navToken) return;
// 1. Primary: Extract from .vidsnfo when it appears in the streaming HTML
let data = extract();
// 2. Fallback: Only check video element once document is ready
if (!data && document.readyState === "complete") {
data = extractFromVideoElement();
}
const ready = !!data && isValidVideoUrl(data.src);
// Right after a navigation the outgoing post's markup can linger for a few
// frames. Resolving to the video that was just playing means the new markup
// has not landed yet — unless the grace period is up, in which case the
// user really did come back to the same post.
const stale = ready && data.src === staleSrc && Date.now() < staleUntil;
if (ready && !stale) {
buildPlayer(data);
return;
}
if (!stale && document.readyState === "complete" && Date.now() > deadline) {
return; // not a video post — leave the site alone
}
scanTimer = setTimeout(
() => scan(token, deadline, staleSrc, staleUntil),
POLL_INTERVAL_MS,
);
}
function onNavigate(force) {
const key = locationKey();
if (!force && key === lastKey) return;
lastKey = key;
navToken++;
clearTimeout(scanTimer);
const previousSrc = session ? session.src : "";
destroySession();
if (!isPostUrl(location.href)) return;
const now = Date.now();
scan(
navToken,
now + STOP_POLLING_AFTER_MS,
previousSrc,
now + STALE_GRACE_MS,
);
}
function watchNavigation() {
for (const method of ["pushState", "replaceState"]) {
const original = history[method];
if (typeof original !== "function") continue;
try {
history[method] = function patched(...args) {
const result = original.apply(this, args);
// After the site's own handler has finished swapping the DOM.
setTimeout(() => onNavigate(false), 0);
return result;
};
} catch (_e) {
/* locked down — the interval below still covers it */
}
}
window.addEventListener("popstate", () => onNavigate(false));
window.addEventListener("hashchange", () => onNavigate(false));
window.addEventListener("pageshow", () => onNavigate(false));
// Safety net for navigations that never reach the hooks above (a replaced
// history object, a same-document swap done some other way).
setInterval(() => onNavigate(false), URL_WATCH_INTERVAL_MS);
}
/* ------------------------------------------------------------------ *
* 2. Page takeover *
* ------------------------------------------------------------------ */
let session = null;
// Puts the page back exactly as it was found: the site's <video> returns to
// its own container, every listener and timer this session created is
// cancelled, and the takeover styling is lifted. Called before each rebuild
// and whenever navigation leaves a post page.
function destroySession() {
const s = session;
if (!s) return;
session = null;
try {
s.abort.abort();
} catch (_e) {}
try {
s.observer?.disconnect();
} catch (_e) {}
for (const id of s.timers) clearTimeout(id);
s.timers.clear();
try {
s.video.pause();
} catch (_e) {}
if (s.origWindowOpen) {
try {
window.open = s.origWindowOpen;
} catch (_e) {}
}
if (screen.orientation?.unlock) {
try {
screen.orientation.unlock();
} catch (_e) {}
}
// Hand the site's own <video> back where it came from. The next extraction
// reads #player_el[data-postid] to tell which post is loaded, so leaving it
// detached would make every later video resolve to the wrong source.
const home = s.videoHome;
if (home?.parent?.isConnected) {
try {
s.video.classList.remove("scp-video-element");
if (home.controls) s.video.setAttribute("controls", "");
if (home.next?.parentNode === home.parent) {
home.parent.insertBefore(s.video, home.next);
} else {
home.parent.appendChild(s.video);
}
} catch (_e) {}
}
try {
s.app.remove();
} catch (_e) {}
try {
s.style.remove();
} catch (_e) {}
document.documentElement.classList.remove("scp-active");
}
function buildPlayer(data) {
if (session) return;
const token = navToken;
// Neutralize popunders from anything that already ran while saving original
let origWindowOpen = null;
try {
origWindowOpen = window.open;
window.open = () => null;
} catch (_e) {
/* ignore */
}
// Pause and strip any secondary video elements outside the primary player
const primaryVideo = playerElement() || document.querySelector("video");
document.querySelectorAll("video").forEach((v) => {
if (v !== primaryVideo) {
try {
v.pause();
v.removeAttribute("src");
v.load();
} catch (_e) {}
}
});
// Suppress injected third-party ad iframes and floating modal overlays
let observer = null;
try {
observer = new MutationObserver((mutations) => {
for (const m of mutations) {
for (const node of m.addedNodes) {
if (
node.nodeType === 1 &&
node.id !== "scp-app" &&
node.tagName !== "SCRIPT" &&
node.tagName !== "STYLE"
) {
const isAd =
node.tagName === "IFRAME" ||
node.tagName === "INS" ||
Boolean(node.querySelector?.("iframe, ins")) ||
(node.style?.zIndex &&
Number.parseInt(node.style.zIndex, 10) > 100);
if (isAd) {
node.style.setProperty("display", "none", "important");
node.style.setProperty("pointer-events", "none", "important");
node.style.setProperty("visibility", "hidden", "important");
}
}
}
}
});
observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
} catch (_e) {}
const style = document.createElement("style");
style.textContent = CSS;
(document.head || document.documentElement).appendChild(style);
document.title = data.title || "Player";
const app = document.createElement("div");
app.id = "scp-app";
app.innerHTML = PLAYER_HTML;
// Adopt and graft onto the site's original video element. Its id is left
// alone (the site keys off #player_el) and where it lived is remembered so
// it can be put back on teardown.
let video = primaryVideo;
let videoHome = null;
if (video) {
videoHome = {
parent: video.parentNode,
next: video.nextSibling,
controls: video.hasAttribute("controls"),
};
} else {
video = document.createElement("video");
}
video.removeAttribute("controls");
video.setAttribute("playsinline", "");
video.setAttribute("preload", "metadata");
video.classList.add("scp-video-element");
const stage = app.querySelector("#scp-stage");
stage.appendChild(video);
const active = {
app: app,
style: style,
video: video,
videoHome: videoHome,
observer: observer,
origWindowOpen: origWindowOpen,
src: data.src,
postId: data.postId,
abort: new AbortController(),
timers: new Set(),
};
session = active;
function mount() {
if (session !== active || token !== navToken) return;
document.documentElement.classList.add("scp-active");
(document.body || document.documentElement).appendChild(app);
initPlayer(active, data);
}
if (document.body) {
mount();
} else {
document.addEventListener("DOMContentLoaded", mount, {
once: true,
signal: active.abort.signal,
});
}
}
/* ------------------------------------------------------------------ *
* 3. Markup + styles *
* ------------------------------------------------------------------ */
const CSS = `
html.scp-active, html.scp-active body { margin:0; padding:0; height:100%; background:#000 !important; overflow:hidden !important;
-webkit-user-select:none; user-select:none; font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; }
html.scp-active body > *:not(#scp-app):not(script):not(style) { display: none !important; pointer-events: none !important; visibility: hidden !important; }
html.scp-active > *:not(body):not(head) { display: none !important; pointer-events: none !important; visibility: hidden !important; }
#scp-app { position:fixed; inset:0; z-index:2147483647; background:#000; }
#scp-stage { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; }
/* Keeps its own id so the site's scripts keep working, so the sizing has to
out-rank any id-based rule the site has for #player_el. */
#scp-app .scp-video-element { width:100% !important; height:100% !important;
max-width:none !important; max-height:none !important; min-width:0 !important; min-height:0 !important;
object-fit:contain; background:#000; position:static !important; }
#scp-tapzone { position:absolute; inset:0; touch-action:none; }
.scp-layer { position:absolute; left:0; right:0; pointer-events:none;
transition:opacity .25s ease, transform .25s ease; }
#scp-app.scp-hidden .scp-layer { opacity:0; }
#scp-app.scp-hidden .scp-layer,
#scp-app.scp-hidden .scp-layer * { pointer-events:none !important; }
#scp-app.scp-hidden #scp-topbar { transform:translateY(-8px); }
#scp-app.scp-hidden #scp-ctrlbar { transform:translateY(8px); }
#scp-topbar { top:0; padding:max(10px, env(safe-area-inset-top, 0px)) max(14px, env(safe-area-inset-right, 0px)) 26px max(14px, env(safe-area-inset-left, 0px));
background:linear-gradient(to bottom, rgba(0,0,0,.75), transparent);
display:flex; align-items:flex-start; gap:10px; }
#scp-title { color:#fff; font-size:13px; line-height:1.35; opacity:.9; flex:1;
overflow:hidden; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical;
text-shadow:0 1px 2px rgba(0,0,0,.8); }
#scp-center { position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
pointer-events:none; }
#scp-spinner { width:52px; height:52px; border-radius:50%;
border:4px solid rgba(255,255,255,.25); border-top-color:#fff; position:absolute;
animation:scp-spin .8s linear infinite; display:none; }
#scp-app.scp-buffering #scp-spinner { display:block; }
@keyframes scp-spin { to { transform:rotate(360deg); } }
#scp-bigtoggle { width:76px; height:76px; border-radius:50%; background:rgba(0,0,0,.55);
display:none; align-items:center; justify-content:center; cursor:pointer; }
#scp-app.scp-paused:not(.scp-buffering) #scp-bigtoggle { display:flex; }
#scp-bigtoggle svg { width:38px; height:38px; fill:#fff; margin-left:4px; }
#scp-seekfx { position:absolute; color:#fff; font-size:15px; font-weight:600;
background:rgba(0,0,0,.55); border-radius:20px; padding:6px 14px; opacity:0;
transition:opacity .2s ease; }
#scp-seekfx.scp-show { opacity:1; }
#scp-ctrlbar { bottom:0; padding:30px max(12px, env(safe-area-inset-right, 0px)) max(12px, env(safe-area-inset-bottom, 0px)) max(12px, env(safe-area-inset-left, 0px));
background:linear-gradient(to top, rgba(0,0,0,.8), transparent);
pointer-events:none; }
#scp-ctrlbar > * { pointer-events:auto; }
#scp-progress { position:relative; height:28px; display:flex; align-items:center;
touch-action:none; cursor:pointer; }
#scp-track { position:relative; height:4px; width:100%; border-radius:2px;
background:rgba(255,255,255,.25); transition:height .12s ease; }
#scp-progress:hover #scp-track, #scp-progress.scp-scrubbing #scp-track { height:6px; }
#scp-buffered { position:absolute; left:0; top:0; bottom:0; width:0;
background:rgba(255,255,255,.35); border-radius:2px; }
#scp-fill { position:absolute; left:0; top:0; bottom:0; width:0;
background:#e83e8c; border-radius:2px; }
#scp-thumb { position:absolute; top:50%; width:14px; height:14px; border-radius:50%;
background:#fff; transform:translate(-50%,-50%) scale(0);
transition:transform .12s ease; box-shadow:0 1px 4px rgba(0,0,0,.5); }
#scp-progress:hover #scp-thumb, #scp-progress.scp-scrubbing #scp-thumb { transform:translate(-50%,-50%) scale(1); }
#scp-bubble { position:absolute; bottom:34px; transform:translateX(-50%);
background:rgba(0,0,0,.85); color:#fff; font-size:12px; padding:3px 8px;
border-radius:4px; display:none; white-space:nowrap; }
#scp-progress.scp-scrubbing #scp-bubble { display:block; }
#scp-buttons { display:flex; align-items:center; gap:6px; margin-top:4px; height:44px; }
.scp-btn { min-width:44px; height:44px; border:0; background:transparent; color:#fff;
display:flex; align-items:center; justify-content:center; cursor:pointer;
font-size:14px; font-weight:600; font-family:inherit; border-radius:8px; padding:0 8px; }
.scp-btn:active { background:rgba(255,255,255,.15); }
.scp-btn svg { width:26px; height:26px; fill:#fff; }
#scp-time { color:#fff; font-size:13px; font-variant-numeric:tabular-nums;
padding:0 6px; white-space:nowrap; }
#scp-time b { font-weight:400; opacity:.7; }
.scp-spacer { flex:1; }
#scp-speed { font-variant-numeric:tabular-nums; }
#scp-speed::after { content:'x'; font-size:11px; opacity:.7; margin-left:1px; }
#scp-holdspeed { position:absolute; top:60px; left:50%; transform:translateX(-50%) scale(.9);
background:rgba(0,0,0,.75); backdrop-filter:blur(8px); -webkit-backdrop-filter:blur(8px);
color:#fff; font-size:13px; font-weight:700; letter-spacing:.5px; padding:7px 16px; border-radius:20px;
display:flex; align-items:center; gap:6px; opacity:0; pointer-events:none;
transition:opacity .18s ease, transform .18s ease; z-index:10; border:1px solid rgba(255,255,255,.15); }
#scp-holdspeed.scp-show { opacity:1; transform:translateX(-50%) scale(1); }
#scp-holdspeed svg { width:18px; height:18px; fill:#fff; }
#scp-speedwrap { position:relative; display:flex; align-items:center; }
#scp-speedmenu { position:absolute; bottom:52px; right:0;
background:rgba(18,18,20,.92); backdrop-filter:blur(12px); -webkit-backdrop-filter:blur(12px);
border:1px solid rgba(255,255,255,.12); border-radius:12px; padding:12px; width:220px;
display:none; flex-direction:column; gap:10px; box-shadow:0 8px 32px rgba(0,0,0,.6); z-index:20; }
#scp-speedwrap.scp-open #scp-speedmenu { display:flex; }
.scp-menu-head { color:rgba(255,255,255,.7); font-size:11px; font-weight:700;
text-transform:uppercase; letter-spacing:.8px; }
.scp-speed-grid { display:grid; grid-template-columns:repeat(3, 1fr); gap:6px; }
.scp-speed-chip { background:rgba(255,255,255,.1); border:1px solid transparent; color:#fff;
font-size:12px; font-weight:600; font-family:inherit; padding:6px 0; border-radius:6px;
cursor:pointer; text-align:center; transition:background .15s, border-color .15s; }
.scp-speed-chip:hover { background:rgba(255,255,255,.2); }
.scp-speed-chip.scp-active { background:#e83e8c; border-color:rgba(255,255,255,.3); color:#fff; }
.scp-speed-custom { display:flex; align-items:center; gap:8px; padding-top:4px;
border-top:1px solid rgba(255,255,255,.1); }
#scp-speed-slider { flex:1; accent-color:#e83e8c; height:4px; cursor:pointer; }
#scp-speed-val { color:#fff; font-size:12px; font-variant-numeric:tabular-nums; min-width:38px; text-align:right; }
#scp-volwrap { position:relative; display:flex; align-items:center; }
#scp-volslider { position:absolute; bottom:48px; left:50%; transform:translateX(-50%) scaleX(0);
transform-origin:left center; width:120px; height:34px; background:rgba(0,0,0,.85);
border-radius:8px; display:flex; align-items:center; padding:0 12px;
transition:transform .18s ease; touch-action:none; }
#scp-volwrap.scp-open #scp-volslider { transform:translateX(-50%) scaleX(1); }
#scp-voltrack { position:relative; width:100%; height:4px; background:rgba(255,255,255,.3);
border-radius:2px; }
#scp-volfill { position:absolute; left:0; top:0; bottom:0; background:#fff; border-radius:2px; }
#scp-toast { position:absolute; bottom:110px; left:50%; transform:translateX(-50%);
background:rgba(0,0,0,.8); color:#fff; font-size:13px; padding:8px 16px; border-radius:20px;
opacity:0; transition:opacity .25s ease; pointer-events:none; white-space:nowrap; }
#scp-toast.scp-show { opacity:1; }
#scp-error { position:absolute; inset:0; display:none; flex-direction:column;
align-items:center; justify-content:center; gap:14px; background:rgba(0,0,0,.85);
color:#fff; font-size:14px; text-align:center; padding:20px; }
#scp-error.scp-show { display:flex; }
#scp-error button { background:#e83e8c; color:#fff; border:0; border-radius:8px;
padding:10px 22px; font-size:14px; font-family:inherit; cursor:pointer; }
#scp-error a { color:#8ab4f8; font-size:13px; }
`;
const PLAYER_HTML = `
<div id="scp-stage"></div>
<div id="scp-tapzone"></div>
<div id="scp-center">
<div id="scp-spinner"></div>
<div id="scp-bigtoggle"><svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg></div>
<div id="scp-holdspeed">
<svg viewBox="0 0 24 24"><path d="M4 18l8.5-6L4 6v12zm9-12v12l8.5-6L13 6z"/></svg>
<span id="scp-holdspeed-text">2X Speed</span>
</div>
<div id="scp-seekfx"></div>
</div>
<div id="scp-topbar" class="scp-layer">
<div id="scp-title"></div>
</div>
<div id="scp-ctrlbar" class="scp-layer">
<div id="scp-progress">
<div id="scp-track">
<div id="scp-buffered"></div>
<div id="scp-fill"></div>
<div id="scp-thumb"></div>
</div>
<div id="scp-bubble">0:00</div>
</div>
<div id="scp-buttons">
<button class="scp-btn" id="scp-play" aria-label="Play">
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
</button>
<button class="scp-btn" id="scp-back" aria-label="Back 10 seconds">
<svg viewBox="0 0 24 24"><path d="M12 5V1L7 6l5 5V7c3.3 0 6 2.7 6 6s-2.7 6-6 6-6-2.7-6-6H4c0 4.4 3.6 8 8 8s8-3.6 8-8-3.6-8-8-8z"/></svg>
</button>
<button class="scp-btn" id="scp-fwd" aria-label="Forward 10 seconds">
<svg viewBox="0 0 24 24"><path d="M12 5V1l5 5-5 5V7c-3.3 0-6 2.7-6 6s2.7 6 6 6 6-2.7 6-6h2c0 4.4-3.6 8-8 8s-8-3.6-8-8 3.6-8 8-8z"/></svg>
</button>
<div id="scp-time"><span id="scp-cur">0:00</span> <b>/</b> <span id="scp-dur">—</span></div>
<div class="scp-spacer"></div>
<div id="scp-speedwrap">
<button class="scp-btn" id="scp-speed" aria-label="Playback speed">1</button>
<div id="scp-speedmenu">
<div class="scp-menu-head">Playback Speed</div>
<div class="scp-speed-grid">
<button class="scp-speed-chip" data-speed="0.5">0.5x</button>
<button class="scp-speed-chip" data-speed="0.75">0.75x</button>
<button class="scp-speed-chip scp-active" data-speed="1">1x</button>
<button class="scp-speed-chip" data-speed="1.25">1.25x</button>
<button class="scp-speed-chip" data-speed="1.5">1.5x</button>
<button class="scp-speed-chip" data-speed="1.75">1.75x</button>
<button class="scp-speed-chip" data-speed="2">2x</button>
<button class="scp-speed-chip" data-speed="2.5">2.5x</button>
<button class="scp-speed-chip" data-speed="3">3x</button>
</div>
<div class="scp-speed-custom">
<input type="range" id="scp-speed-slider" min="0.25" max="3" step="0.05" value="1">
<span id="scp-speed-val">1.00x</span>
</div>
</div>
</div>
<div id="scp-volwrap">
<button class="scp-btn" id="scp-mute" aria-label="Mute">
<svg viewBox="0 0 24 24" id="scp-vol-on"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.8-1-3.3-2.5-4v8c1.5-.7 2.5-2.2 2.5-4zM14 3.2v2.1c2.9.9 5 3.5 5 6.7s-2.1 5.8-5 6.7v2.1c4-.9 7-4.5 7-8.8s-3-7.9-7-8.8z"/></svg>
<svg viewBox="0 0 24 24" id="scp-vol-off" style="display:none"><path d="M16.5 12c0-1.8-1-3.3-2.5-4v2.2l2.4 2.4c.1-.2.1-.4.1-.6zM19 12c0 .9-.2 1.8-.5 2.6l1.5 1.5c.7-1.2 1-2.6 1-4.1 0-4.3-3-7.9-7-8.8v2.1c2.9.9 5 3.5 5 6.7zM4.3 3L3 4.3 7.7 9H3v6h4l5 5v-6.7l4.3 4.3c-.7.5-1.4.9-2.3 1.2v2.1c1.4-.3 2.6-.9 3.7-1.8l2 2L21 19.7 4.3 3zM12 4L9.9 6.1 12 8.2V4z"/></svg>
</button>
<div id="scp-volslider"><div id="scp-voltrack"><div id="scp-volfill"></div></div></div>
</div>
<button class="scp-btn" id="scp-fs" aria-label="Fullscreen">
<svg viewBox="0 0 24 24" id="scp-fs-on"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/></svg>
<svg viewBox="0 0 24 24" id="scp-fs-off" style="display:none"><path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"/></svg>
</button>
</div>
</div>
<div id="scp-toast"></div>
<div id="scp-error">
<div id="scp-errmsg">Playback failed.</div>
<button id="scp-retry">Retry</button>
<a id="scp-direct" href="#" target="_blank" rel="noreferrer">Open direct file</a>
</div>
`;
/* ------------------------------------------------------------------ *
* 4. Player logic *
* ------------------------------------------------------------------ */
const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3];
const SEEK_TIERS = [10, 20, 30, 60, 180, 300, 600]; // 10s, 20s, 30s, 1m, 3m, 5m, 10m
const HIDE_DELAY_MS = 2800;
const RESUME_MIN_S = 30;
const RESUME_TAIL_S = 45;
const SAVE_EVERY_S = 5;
function fmtTime(t, refDur) {
if (!Number.isFinite(t) || t < 0) t = 0;
t = Math.floor(t);
const h = Math.floor(t / 3600);
const m = Math.floor((t % 3600) / 60);
const s = t % 60;
const forceHours = Number.isFinite(refDur) && refDur >= 3600;
if (h > 0 || forceHours) {
return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
return `${m}:${String(s).padStart(2, "0")}`;
}
function fmtSeekDelta(seconds) {
const abs = Math.abs(seconds);
const sign = seconds > 0 ? "+" : "-";
if (abs >= 60) {
const m = Math.floor(abs / 60);
const s = abs % 60;
return s > 0 ? `${sign}${m}m ${s}s` : `${sign}${m}m`;
}
return `${sign}${abs}s`;
}
function initPlayer(s, data) {
const app = s.app;
const video = s.video;
const signal = s.abort.signal;
const storeKey = `scp:pos:${data.postId}`;
// Scoped to this session's root, so a torn-down player can never be
// addressed by a newer one.
function $(id) {
return app.querySelector(`#${id}`);
}
// Every timer is tracked so teardown can cancel it.
function later(fn, ms) {
const id = setTimeout(() => {
s.timers.delete(id);
fn();
}, ms);
s.timers.add(id);
return id;
}
// An adopted element may still carry the previous post's source, so the
// URL resolved for this post always wins.
if (video.getAttribute("src") !== data.src) {
video.src = data.src;
}
if (data.poster) video.poster = data.poster;
video.load();
$("scp-title").textContent = data.title;
$("scp-direct").href = data.src;
const state = {
hideTimer: 0,
toastTimer: 0,
scrubbing: false,
lastTap: 0,
lastZone: "",
seekStreak: 0,
lastSeekTime: 0,
lastSeekDir: 0,
cumulativeSeek: 0,
singleTapTimer: 0,
holdTimer: 0,
isHoldingSpeed: false,
isKeyHoldingSpeed: false,
prevSpeed: 1,
shownAt: 0,
savedAt: 0,
speedIdx: 2,
};
/* ---- helpers ---- */
function showControls() {
if (app.classList.contains("scp-hidden")) state.shownAt = Date.now();
app.classList.remove("scp-hidden");
scheduleHide();
}
function scheduleHide() {
clearTimeout(state.hideTimer);
if (!video.paused && !state.scrubbing) {
state.hideTimer = later(() => {
app.classList.add("scp-hidden");
}, HIDE_DELAY_MS);
}
}
function toast(msg) {
const el = $("scp-toast");
el.textContent = msg;
el.classList.add("scp-show");
clearTimeout(state.toastTimer);
state.toastTimer = later(() => {
el.classList.remove("scp-show");
}, 1600);
}
function setPlayingUI() {
$("scp-play").innerHTML =
'<svg viewBox="0 0 24 24"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>';
app.classList.remove("scp-paused");
}
function setPausedUI() {
$("scp-play").innerHTML =
'<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>';
app.classList.add("scp-paused");
showControls();
}
function togglePlay() {
if (video.ended) {
video.currentTime = 0;
video.play();
return;
}
if (video.paused) video.play();
else video.pause();
}
function seekBy(delta, displayTotal) {
if (!Number.isFinite(video.duration)) return;
const target = Math.min(
Math.max(video.currentTime + delta, 0),
video.duration,
);
video.currentTime = target;
const fx = $("scp-seekfx");
const totalToDisplay = displayTotal !== undefined ? displayTotal : delta;
fx.textContent = fmtSeekDelta(totalToDisplay);
fx.style.left = delta > 0 ? "70%" : "30%";
fx.classList.add("scp-show");
clearTimeout(fx._t);
fx._t = later(() => {
fx.classList.remove("scp-show");
}, 600);
updateProgress();
}
function triggerProgressiveSeek(direction) {
const now = Date.now();
if (now - state.lastSeekTime > 650 || state.lastSeekDir !== direction) {
state.seekStreak = 0;
state.cumulativeSeek = 0;
}
state.lastSeekTime = now;
state.lastSeekDir = direction;
const tierIdx = Math.min(state.seekStreak, SEEK_TIERS.length - 1);
const step = SEEK_TIERS[tierIdx];
const delta = step * direction;
state.seekStreak++;
state.cumulativeSeek += delta;
seekBy(delta, state.cumulativeSeek);
}
/* ---- Hold to Speed ---- */
function activateHoldSpeed() {
if (video.paused || video.ended || state.isHoldingSpeed) return;
state.isHoldingSpeed = true;
state.prevSpeed = video.playbackRate || 1;
const boostRate = state.prevSpeed >= 2 ? 3 : 2;
video.playbackRate = boostRate;
$("scp-holdspeed-text").textContent = `${boostRate}X Speed`;
$("scp-holdspeed").classList.add("scp-show");
clearTimeout(state.singleTapTimer);
}
function cancelHoldSpeed() {
clearTimeout(state.holdTimer);
if (state.isHoldingSpeed) {
state.isHoldingSpeed = false;
video.playbackRate = state.prevSpeed;
$("scp-holdspeed").classList.remove("scp-show");
return true;
}
return false;
}
/* ---- Speed Menu ---- */
function setSpeed(rate, showToast = true) {
rate = Math.max(0.25, Math.min(4, Math.round(rate * 100) / 100));
video.playbackRate = rate;
$("scp-speed").textContent = String(rate);
app.querySelectorAll(".scp-speed-chip").forEach((chip) => {
const chipSpeed = Number.parseFloat(
chip.getAttribute("data-speed") || "0",
);
if (Math.abs(chipSpeed - rate) < 0.01) {
chip.classList.add("scp-active");
} else {
chip.classList.remove("scp-active");
}
});
const slider = $("scp-speed-slider");
if (slider) slider.value = String(rate);
const valEl = $("scp-speed-val");
if (valEl) valEl.textContent = `${rate.toFixed(2)}x`;
if (showToast) toast(`Speed ${rate}x`);
}
/* ---- progress bar ---- */
function posToTime(clientX) {
const rect = $("scp-track").getBoundingClientRect();
const ratio = Math.min(
Math.max((clientX - rect.left) / rect.width, 0),
1,
);
return ratio * (video.duration || 0);
}
function updateProgress() {
if (state.scrubbing) return;
const dur = video.duration;
if (Number.isFinite(dur) && dur > 0) {
const pct = (video.currentTime / dur) * 100;
$("scp-fill").style.width = `${pct}%`;
$("scp-thumb").style.left = `${pct}%`;
}
$("scp-cur").textContent = fmtTime(video.currentTime, video.duration);
}
function updateBuffered() {
const dur = video.duration;
if (!Number.isFinite(dur)) return;
let end = 0;
for (let i = 0; i < video.buffered.length; i++) {
if (video.buffered.start(i) <= video.currentTime) {
end = Math.max(end, video.buffered.end(i));
}
}
$("scp-buffered").style.width = `${(end / dur) * 100}%`;
}
const progress = $("scp-progress");
progress.addEventListener("pointerdown", (e) => {
if (!Number.isFinite(video.duration)) return;
state.scrubbing = true;
progress.classList.add("scp-scrubbing");
progress.setPointerCapture(e.pointerId);
onScrub(e);
e.preventDefault();
});
progress.addEventListener("pointermove", (e) => {
if (state.scrubbing) onScrub(e);
});
progress.addEventListener("pointerup", (e) => {
if (!state.scrubbing) return;
state.scrubbing = false;
progress.classList.remove("scp-scrubbing");
const t = posToTime(e.clientX);
if (Number.isFinite(t)) video.currentTime = t;
scheduleHide();
});
progress.addEventListener("pointercancel", () => {
state.scrubbing = false;
progress.classList.remove("scp-scrubbing");
});
function onScrub(e) {
const t = posToTime(e.clientX);
if (!Number.isFinite(t)) return;
const dur = video.duration || 1;
const pct = (t / dur) * 100;
$("scp-fill").style.width = `${pct}%`;
$("scp-thumb").style.left = `${pct}%`;
$("scp-bubble").textContent = fmtTime(t, video.duration);
const rect = $("scp-track").getBoundingClientRect();
$("scp-bubble").style.left =
`${Math.min(Math.max(e.clientX - rect.left, 24), rect.width - 24)}px`;
}
/* ---- tap zones (single tap = toggle UI, double tap = seek / play, hold = 2x speed) ---- */
const tapzone = $("scp-tapzone");
let tapStartX = 0,
tapStartY = 0;
tapzone.addEventListener("pointerdown", (e) => {
tapStartX = e.clientX;
tapStartY = e.clientY;
clearTimeout(state.holdTimer);
if (!video.paused && !video.ended) {
state.holdTimer = later(() => {
activateHoldSpeed();
}, 400);
}
});
tapzone.addEventListener("pointermove", (e) => {
if (
Math.abs(e.clientX - tapStartX) > 12 ||
Math.abs(e.clientY - tapStartY) > 12
) {
clearTimeout(state.holdTimer);
}
});
tapzone.addEventListener("pointerup", (e) => {
if (cancelHoldSpeed()) {
return; // Handled hold release; don't tap/seek
}
if (
Math.abs(e.clientX - tapStartX) > 12 ||
Math.abs(e.clientY - tapStartY) > 12
) {
return; // it was a swipe, not a tap
}
const rect = tapzone.getBoundingClientRect();
const x = e.clientX - rect.left;
const zone =
x < rect.width / 3
? "left"
: x > (rect.width * 2) / 3
? "right"
: "center";
const now = Date.now();
if (now - state.lastTap > 300 || zone !== state.lastZone) {
state.seekStreak = 0;
}
if (now - state.lastTap <= 300 && zone === state.lastZone) {
// chained double / triple tap
clearTimeout(state.singleTapTimer);
state.lastTap = now;
if (zone === "center") {
togglePlay();
} else {
triggerProgressiveSeek(zone === "left" ? -1 : 1);
}
} else {
// first tap (debounced for double-tap detection)
state.lastTap = now;
state.lastZone = zone;
state.seekStreak = 0;
clearTimeout(state.singleTapTimer);
state.singleTapTimer = later(() => {
state.seekStreak = 0;
if (app.classList.contains("scp-hidden")) {
showControls();
} else if (!video.paused && Date.now() - state.shownAt > 300) {
app.classList.add("scp-hidden");
}
}, 300);
}
});
tapzone.addEventListener("pointercancel", () => {
cancelHoldSpeed();
});
$("scp-bigtoggle").addEventListener("click", togglePlay);
/* ---- buttons ---- */
$("scp-play").addEventListener("click", togglePlay);
$("scp-back").addEventListener("click", () => {
triggerProgressiveSeek(-1);
showControls();
});
$("scp-fwd").addEventListener("click", () => {
triggerProgressiveSeek(1);
showControls();
});
$("scp-speed").addEventListener("click", (e) => {
e.stopPropagation();
const speedwrap = $("scp-speedwrap");
const isOpen = speedwrap.classList.toggle("scp-open");
if (isOpen) {
volwrap.classList.remove("scp-open");
}
showControls();
});
app.querySelectorAll(".scp-speed-chip").forEach((chip) => {
chip.addEventListener("click", (e) => {
e.stopPropagation();
const rate = Number.parseFloat(chip.getAttribute("data-speed") || "1");
setSpeed(rate);
$("scp-speedwrap")?.classList.remove("scp-open");
showControls();
});
});
const speedSlider = $("scp-speed-slider");
speedSlider?.addEventListener("input", (e) => {
e.stopPropagation();
const rate = Number.parseFloat(speedSlider.value);
setSpeed(rate, false);
});
speedSlider?.addEventListener("change", () => {
toast(`Speed ${video.playbackRate}x`);
});
// Tap outside closes popups
app.addEventListener("pointerdown", (e) => {
if (!e.target.closest("#scp-speedwrap")) {
$("scp-speedwrap")?.classList.remove("scp-open");
}
if (!e.target.closest("#scp-volwrap")) {
$("scp-volwrap")?.classList.remove("scp-open");
}
});
/* ---- volume ---- */
const volwrap = $("scp-volwrap");
const volslider = $("scp-volslider");
let volScrubbing = false;
function setVolume(v) {
v = Math.min(Math.max(v, 0), 1);
video.volume = v;
video.muted = v === 0;
$("scp-volfill").style.width = `${v * 100}%`;
$("scp-vol-on").style.display = video.muted ? "none" : "block";
$("scp-vol-off").style.display = video.muted ? "block" : "none";
}
$("scp-mute").addEventListener("click", () => {
if (video.muted || video.volume === 0) {
setVolume(0.7);
} else {
setVolume(0);
}
});
function volFromEvent(e) {
const rect = $("scp-voltrack").getBoundingClientRect();
return (e.clientX - rect.left) / rect.width;
}
volslider.addEventListener("pointerdown", (e) => {
volScrubbing = true;
volslider.setPointerCapture(e.pointerId);
setVolume(volFromEvent(e));
e.preventDefault();
e.stopPropagation();
});
volslider.addEventListener("pointermove", (e) => {
if (volScrubbing) setVolume(volFromEvent(e));
});
volslider.addEventListener("pointerup", () => {
volScrubbing = false;
});
// tap mute button-and-hold opens slider; simple approach: tap toggles, long area opens on pointerenter is unreliable on touch — open on tap of the wrap when already unmuted
$("scp-mute").addEventListener("pointerenter", () => {
volwrap.classList.add("scp-open");
clearTimeout(volwrap._t);
});
volwrap.addEventListener("pointerleave", () => {
volwrap._t = later(() => {
volwrap.classList.remove("scp-open");
}, 1200);
});
setVolume(1);
/* ---- fullscreen ---- */
function isFullscreen() {
return !!(
document.fullscreenElement ||
document.webkitFullscreenElement ||
document.mozFullScreenElement
);
}
$("scp-fs").addEventListener("click", () => {
if (isFullscreen()) {
exitFullscreen();
} else {
const el = app;
const req =
el.requestFullscreen ||
el.webkitRequestFullscreen ||
el.mozRequestFullScreen;
if (req) {
const p = req.call(el, { navigationUI: "hide" });
if (p?.catch) p.catch(() => {});
} else if (video.webkitEnterFullscreen) {
video.webkitEnterFullscreen(); // iOS Safari fallback
}
}
});
function exitFullscreen() {
const ex =
document.exitFullscreen ||
document.webkitExitFullscreen ||
document.mozCancelFullScreen;
if (ex) {
const p = ex.call(document);
if (p?.catch) p.catch(() => {});
}
}
function onFsChange() {
const fs = isFullscreen();
$("scp-fs-on").style.display = fs ? "none" : "block";
$("scp-fs-off").style.display = fs ? "block" : "none";
if (screen.orientation?.lock) {
if (fs) {
const isPortrait =
video.videoHeight > 0 &&
video.videoWidth > 0 &&
video.videoHeight > video.videoWidth;
const p = screen.orientation.lock(
isPortrait ? "portrait" : "landscape",
);
if (p?.catch)
p.catch(() => {
/* unsupported or rejected */
});
} else if (screen.orientation.unlock) {
try {
screen.orientation.unlock();
} catch (_e) {
/* ignore */
}
}
}
}
document.addEventListener("fullscreenchange", onFsChange, { signal });
document.addEventListener("webkitfullscreenchange", onFsChange, { signal });
document.addEventListener("mozfullscreenchange", onFsChange, { signal });
/* ---- video events ---- */
video.addEventListener(
"play",
() => {
setPlayingUI();
scheduleHide();
},
{ signal },
);
video.addEventListener(
"pause",
() => {
setPausedUI();
},
{ signal },
);
video.addEventListener(
"ended",
() => {
setPausedUI();
savePosition(true);
},
{ signal },
);
video.addEventListener(
"timeupdate",
() => {
updateProgress();
maybeSave();
},
{ signal },
);
video.addEventListener("progress", updateBuffered, { signal });
video.addEventListener(
"durationchange",
() => {
$("scp-dur").textContent = fmtTime(video.duration, video.duration);
updateProgress();
},
{ signal },
);
video.addEventListener(
"loadedmetadata",
() => {
$("scp-error")?.classList.remove("scp-show");
$("scp-dur").textContent = fmtTime(video.duration, video.duration);
updateBuffered();
restorePosition();
},
{ signal },
);
video.addEventListener(
"waiting",
() => {
app.classList.add("scp-buffering");
},
{ signal },
);
video.addEventListener(
"playing",
() => {
$("scp-error")?.classList.remove("scp-show");
app.classList.remove("scp-buffering");
},
{ signal },
);
video.addEventListener(
"canplay",
() => {
$("scp-error")?.classList.remove("scp-show");
app.classList.remove("scp-buffering");
},
{ signal },
);
video.addEventListener(
"stalled",
() => {
app.classList.add("scp-buffering");
},
{ signal },
);
video.addEventListener(
"error",
() => {
if (video.readyState >= 1 || (video.duration && video.duration > 0)) {
return;
}
app.classList.remove("scp-buffering");
const err = video.error;
const msg = err
? {
1: "Loading aborted.",
2: "Network error.",
3: "Decode failed.",
4: "Video source not supported or unreachable.",
}[err.code] || "Playback failed."
: "Playback failed.";
$("scp-errmsg").textContent = `Playback failed: ${msg}`;
$("scp-error").classList.add("scp-show");
},
{ signal },
);
$("scp-retry").addEventListener("click", () => {
$("scp-error").classList.remove("scp-show");
const t = video.currentTime;
video.src = data.src;
video.load();
if (t > 0) video.currentTime = t;
video.play().catch(() => {});
});
/* ---- resume / persist ---- */
function maybeSave() {
const now = Date.now();
if (now - state.savedAt < SAVE_EVERY_S * 1000) return;
state.savedAt = now;
savePosition();
}
function savePosition(clear) {
try {
if (clear || !Number.isFinite(video.duration) || video.duration === 0) {
localStorage.removeItem(storeKey);
} else {
localStorage.setItem(storeKey, String(Math.floor(video.currentTime)));
}
} catch (_e) {
/* private mode */
}
}
function restorePosition() {
let pos = 0;
try {
pos = parseInt(localStorage.getItem(storeKey) || "0", 10) || 0;
} catch (_e) {}
if (
pos > RESUME_MIN_S &&
Number.isFinite(video.duration) &&
pos < video.duration - RESUME_TAIL_S
) {
video.currentTime = pos;
toast(`Resumed from ${fmtTime(pos, video.duration)}`);
}
}
window.addEventListener(
"pagehide",
() => {
savePosition();
},
{ signal },
);
document.addEventListener(
"visibilitychange",
() => {
if (document.hidden) savePosition();
},
{ signal },
);
/* ---- keyboard (desktop bonus) ---- */
document.addEventListener(
"keydown",
(e) => {
if (e.target && /^(input|textarea)$/i.test(e.target.tagName)) return;
let handled = true;
switch (e.key) {
case " ":
case "k":
case "K":
togglePlay();
break;
case "ArrowLeft":
case "j":
case "J":
triggerProgressiveSeek(-1);
break;
case "ArrowRight":
case "l":
case "L":
triggerProgressiveSeek(1);
break;
case "ArrowUp":
setVolume(video.volume + 0.1);
toast(`Volume ${Math.round(video.volume * 100)}%`);
break;
case "ArrowDown":
setVolume(video.volume - 0.1);
toast(`Volume ${Math.round(video.volume * 100)}%`);
break;
case "m":
case "M":
$("scp-mute").click();
break;
case "f":
case "F":
$("scp-fs").click();
break;
case ",":
if (video.paused && Number.isFinite(video.currentTime)) {
video.currentTime = Math.max(0, video.currentTime - 1 / 25);
updateProgress();
}
break;
case ".":
if (video.paused && Number.isFinite(video.currentTime)) {
video.currentTime = Math.min(
video.duration || 0,
video.currentTime + 1 / 25,
);
updateProgress();
}
break;
case "[":
case "<": {
const curIdx = SPEEDS.indexOf(video.playbackRate);
const nextIdx = curIdx > 0 ? curIdx - 1 : SPEEDS.length - 1;
state.speedIdx = nextIdx;
setSpeed(SPEEDS[state.speedIdx]);
break;
}
case "]":
case ">": {
const curIdx = SPEEDS.indexOf(video.playbackRate);
const nextIdx = (curIdx + 1) % SPEEDS.length;
state.speedIdx = nextIdx;
setSpeed(SPEEDS[state.speedIdx]);
break;
}
case "0":
case "1":
case "2":
case "3":
case "4":
case "5":
case "6":
case "7":
case "8":
case "9":
if (Number.isFinite(video.duration) && video.duration > 0) {
const pct = Number.parseInt(e.key, 10) / 10;
video.currentTime = pct * video.duration;
updateProgress();
}
break;
default:
handled = false;
break;
}
if (handled) {
e.preventDefault();
showControls();
}
},
{ signal },
);
/* ---- activity re-shows controls ---- */
app.addEventListener("pointermove", () => {
showControls();
});
setPausedUI();
updateProgress();
}
/* ------------------------------------------------------------------ *
* 5. Bootstrap *
* ------------------------------------------------------------------ *
* Declared last so every function and `let` above it is initialized by the
* time the first scan runs.
*/
watchNavigation();
onNavigate(true);
})();