SxyPrn Clean Player

Replaces sxyprn post pages with a clean, ad-free custom video player. Built for Firefox Mobile, works everywhere.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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">&mdash;</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();
	}
})();