Replaces sxyprn post pages with a clean, ad-free custom video player. Built for Firefox Mobile, works everywhere.
// ==UserScript==
// @name SxyPrn Clean Player
// @namespace sxyprn-clean-player
// @version 1.1.0
// @description Replaces sxyprn post pages with a clean, ad-free custom video player. Built for Firefox Mobile, works everywhere.
// @author Anonymous
// @match https://sxyprn.com/post/*
// @match https://sxyprn.net/post/*
// @match https://www.sxyprn.com/post/*
// @match https://www.sxyprn.net/post/*
// @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).
*
* If the post has no hosted video (photo post / external links only), the script
* leaves the page untouched.
*/
(() => {
/* ------------------------------------------------------------------ *
* 1. Extraction *
* ------------------------------------------------------------------ */
const STOP_POLLING_AFTER_MS = 20000;
const startedAt = Date.now();
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("/");
const host = window.location.host.includes("sxyprn.net")
? window.location.host
: "sxyprn.net";
tmp[1] += `5/${b64urlHostBound(digitSum(tmp[6]), digitSum(tmp[7]), host)}`;
tmp[5] -= parseInt(digitSum(tmp[6]), 10) + parseInt(digitSum(tmp[7]), 10);
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 extract() {
const info = document.querySelector(".vidsnfo");
if (!info) return null;
let vnfo;
try {
vnfo = JSON.parse(info.getAttribute("data-vnfo"));
} catch (_e) {
return null;
}
const playerEl = document.querySelector("#player_el");
let postId = playerEl ? playerEl.getAttribute("data-postid") : null;
if (!postId || !vnfo[postId]) postId = Object.keys(vnfo)[0];
const rawPath = vnfo[postId];
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}`;
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);
const fullSrc = resolveVideoUrl(rawPath);
if (!isValidVideoUrl(fullSrc)) return null;
return {
src: fullSrc,
poster: poster || "",
title: title,
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,
};
}
function poll() {
// 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();
}
if (data && isValidVideoUrl(data.src)) {
buildPlayer(data);
return;
}
if (
document.readyState === "complete" &&
Date.now() - startedAt > STOP_POLLING_AFTER_MS
) {
return; // not a video post — leave the site alone
}
setTimeout(poll, 40);
}
poll();
/* ------------------------------------------------------------------ *
* 2. Page takeover *
* ------------------------------------------------------------------ */
let built = false;
function buildPlayer(data) {
if (built) return;
built = true;
// Neutralize popunders from anything that already ran
try {
window.open = () => null;
} catch (_e) {
/* ignore */
}
// Pause and strip any secondary video elements outside the primary player
const primaryVideo =
document.getElementById("player_el") ||
document.querySelector("video.player_el, 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
try {
const obs = 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"
) {
if (
node.tagName === "IFRAME" ||
node.tagName === "INS" ||
(node.style?.zIndex &&
Number.parseInt(node.style.zIndex, 10) > 100)
) {
node.style.setProperty("display", "none", "important");
node.style.setProperty("pointer-events", "none", "important");
node.style.setProperty("visibility", "hidden", "important");
}
}
}
}
});
obs.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
let video = primaryVideo;
if (!video) {
video = document.createElement("video");
}
video.id = "scp-video";
video.removeAttribute("controls");
video.setAttribute("playsinline", "");
video.setAttribute("preload", "metadata");
video.className = "scp-video-element";
const stage = app.querySelector("#scp-stage");
stage.appendChild(video);
function mount() {
if (document.body) {
document.body.appendChild(app);
} else {
document.documentElement.appendChild(app);
}
initPlayer(app, data, video);
}
if (document.body) {
mount();
} else {
document.addEventListener("DOMContentLoaded", mount, { once: true });
}
}
/* ------------------------------------------------------------------ *
* 3. Markup + styles *
* ------------------------------------------------------------------ */
const CSS = `
html, 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-browser,sans-serif; }
body > *:not(#scp-app):not(script):not(style) { display: none !important; pointer-events: none !important; visibility: hidden !important; }
html > *: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; }
#scp-video { width:100%; height:100%; object-fit:contain; background:#000; }
#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:10px 14px 26px;
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 12px 12px;
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-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-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>
<button class="scp-btn" id="scp-speed" aria-label="Playback speed">1</button>
<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 = [1, 1.25, 1.5, 1.75, 2, 0.75, 0.5];
const HIDE_DELAY_MS = 2800;
const RESUME_MIN_S = 30;
const RESUME_TAIL_S = 45;
const SAVE_EVERY_S = 5;
function fmtTime(t) {
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;
return h > 0
? `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`
: `${m}:${String(s).padStart(2, "0")}`;
}
function $(id) {
return document.getElementById(id);
}
function initPlayer(app, data, video) {
const storeKey = `scp:pos:${data.postId}`;
if (
!isValidVideoUrl(video.currentSrc) &&
!isValidVideoUrl(video.getAttribute("src"))
) {
video.src = data.src;
}
if (data.poster && !video.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,
singleTapTimer: 0,
shownAt: 0,
savedAt: 0,
speedIdx: 0,
};
/* ---- 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 = setTimeout(() => {
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 = setTimeout(() => {
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) {
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");
fx.textContent = `${(delta > 0 ? "+" : "") + Math.round(delta)}s`;
fx.style.left = delta > 0 ? "70%" : "30%";
fx.classList.add("scp-show");
clearTimeout(fx._t);
fx._t = setTimeout(() => {
fx.classList.remove("scp-show");
}, 500);
updateProgress();
}
/* ---- 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() {
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);
}
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 pct = (t / (video.duration || 1)) * 100;
$("scp-fill").style.width = `${pct}%`;
$("scp-thumb").style.left = `${pct}%`;
$("scp-bubble").textContent = fmtTime(t);
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) ---- */
const tapzone = $("scp-tapzone");
let tapStartX = 0,
tapStartY = 0;
tapzone.addEventListener("pointerdown", (e) => {
tapStartX = e.clientX;
tapStartY = e.clientY;
});
tapzone.addEventListener("pointerup", (e) => {
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 > 600 || zone !== state.lastZone) {
state.seekStreak = 0;
}
if (now - state.lastTap <= 600 && zone === state.lastZone) {
// chained tap (double, triple, quad...)
clearTimeout(state.singleTapTimer);
state.lastTap = now;
if (zone === "center") {
togglePlay();
} else {
state.seekStreak++;
let amt = 10;
if (state.seekStreak === 2) amt = 30;
else if (state.seekStreak >= 3) amt = 60;
seekBy(zone === "left" ? -amt : amt);
}
} else {
// first tap
state.lastTap = now;
state.lastZone = zone;
state.seekStreak = 0;
if (zone === "center") {
togglePlay();
}
clearTimeout(state.singleTapTimer);
state.singleTapTimer = setTimeout(() => {
state.seekStreak = 0;
if (app.classList.contains("scp-hidden")) showControls();
else if (!video.paused && Date.now() - state.shownAt > 600) {
app.classList.add("scp-hidden");
}
}, 600);
}
});
$("scp-bigtoggle").addEventListener("click", togglePlay);
/* ---- buttons ---- */
$("scp-play").addEventListener("click", togglePlay);
$("scp-back").addEventListener("click", () => {
seekBy(-10);
showControls();
});
$("scp-fwd").addEventListener("click", () => {
seekBy(10);
showControls();
});
$("scp-speed").addEventListener("click", () => {
state.speedIdx = (state.speedIdx + 1) % SPEEDS.length;
const rate = SPEEDS[state.speedIdx];
video.playbackRate = rate;
$("scp-speed").textContent = String(rate);
toast(`Speed ${rate}x`);
showControls();
});
/* ---- 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 = setTimeout(() => {
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 p = screen.orientation.lock("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);
document.addEventListener("webkitfullscreenchange", onFsChange);
document.addEventListener("mozfullscreenchange", onFsChange);
/* ---- video events ---- */
video.addEventListener("play", () => {
setPlayingUI();
scheduleHide();
});
video.addEventListener("pause", () => {
setPausedUI();
});
video.addEventListener("ended", () => {
setPausedUI();
savePosition(true);
});
video.addEventListener("timeupdate", () => {
updateProgress();
maybeSave();
});
video.addEventListener("progress", updateBuffered);
video.addEventListener("durationchange", () => {
$("scp-dur").textContent = fmtTime(video.duration);
});
video.addEventListener("loadedmetadata", () => {
$("scp-error")?.classList.remove("scp-show");
$("scp-dur").textContent = fmtTime(video.duration);
updateBuffered();
restorePosition();
});
video.addEventListener("waiting", () => {
app.classList.add("scp-buffering");
});
video.addEventListener("playing", () => {
$("scp-error")?.classList.remove("scp-show");
app.classList.remove("scp-buffering");
});
video.addEventListener("canplay", () => {
$("scp-error")?.classList.remove("scp-show");
app.classList.remove("scp-buffering");
});
video.addEventListener("stalled", () => {
app.classList.add("scp-buffering");
});
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");
});
$("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)}`);
}
}
window.addEventListener("pagehide", () => {
savePosition();
});
document.addEventListener("visibilitychange", () => {
if (document.hidden) savePosition();
});
/* ---- keyboard (desktop bonus) ---- */
document.addEventListener("keydown", (e) => {
if (e.target && /^(input|textarea)$/i.test(e.target.tagName)) return;
switch (e.key) {
case " ":
case "k":
togglePlay();
e.preventDefault();
break;
case "ArrowLeft":
seekBy(-10);
break;
case "ArrowRight":
seekBy(10);
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":
$("scp-mute").click();
break;
case "f":
$("scp-fs").click();
break;
}
showControls();
});
/* ---- activity re-shows controls ---- */
app.addEventListener("pointermove", () => {
showControls();
});
setPausedUI();
updateProgress();
}
})();