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.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

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

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 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);
})();