SxyPrn Clean Player

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.

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.

ستحتاج إلى تثبيت إضافة مثل Stylus لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتتمكن من تثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

(لدي بالفعل مثبت أنماط للمستخدم، دعني أقم بتثبيته!)

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