SxyPrn Clean Player

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

이 스크립트를 설치하려면 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.1.0
// @description  Replaces sxyprn post pages with a clean, ad-free custom video player. Built for Firefox Mobile, works everywhere.
// @author       Anonymous
// @match        https://sxyprn.com/post/*
// @match        https://sxyprn.net/post/*
// @match        https://www.sxyprn.com/post/*
// @match        https://www.sxyprn.net/post/*
// @grant        none
// @run-at       document-start
// @noframes
// @license      MIT
// ==/UserScript==

/*
 * How it works
 * ------------
 * 1. Runs at document-start and polls the streaming HTML for `.vidsnfo[data-vnfo]`
 *    (present in the initial markup of every video post, before any ad script runs).
 * 2. Decodes the real video URL with the same arithmetic the site's own main2.js
 *    `getvsrc()` uses (digit sums + a host-bound base64url token + a numeric offset).
 * 3. Swaps the document display for a custom player: gesture seeking, buffered
 *    progress bar, speed control, fullscreen with landscape lock, resume position.
 *    (Original site elements are hidden visually).
 *
 * If the post has no hosted video (photo post / external links only), the script
 * leaves the page untouched.
 */

(() => {
	/* ------------------------------------------------------------------ *
	 * 1. Extraction                                                       *
	 * ------------------------------------------------------------------ */

	const STOP_POLLING_AFTER_MS = 20000;
	const startedAt = Date.now();

	function digitSum(str) {
		const digits = String(str).replace(/[^0-9]/g, "");
		let sum = 0;
		for (let i = 0; i < digits.length; i++)
			sum += parseInt(digits.charAt(i), 10);
		return sum;
	}

	function b64urlHostBound(a, b, host) {
		const targetHost = host || window.location.host;
		const raw = `${a}-${targetHost}-${b}`;
		return btoa(raw).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ".");
	}

	// Mirrors getvsrc() from the site's main2.js
	// Routes through sxyprn.net (open media mirror) so Cloudflare on sxyprn.com does not block the stream
	function resolveVideoUrl(rawPath) {
		const tmp = String(rawPath).split("/");
		const host = window.location.host.includes("sxyprn.net")
			? window.location.host
			: "sxyprn.net";
		tmp[1] += `5/${b64urlHostBound(digitSum(tmp[6]), digitSum(tmp[7]), host)}`;
		tmp[5] -= parseInt(digitSum(tmp[6]), 10) + parseInt(digitSum(tmp[7]), 10);
		return `https://${host}${tmp.join("/")}`;
	}

	function isValidVideoUrl(url) {
		if (!url || typeof url !== "string") return false;
		const trimmed = url.trim();
		if (
			!trimmed ||
			trimmed === "/" ||
			trimmed === window.location.href ||
			trimmed === window.location.origin ||
			trimmed === `${window.location.origin}/`
		) {
			return false;
		}
		return /\.vid|\.mp4|\/cdn5?\/|\/vidi\/|blob:/i.test(trimmed);
	}

	function extract() {
		const info = document.querySelector(".vidsnfo");
		if (!info) return null;
		let vnfo;
		try {
			vnfo = JSON.parse(info.getAttribute("data-vnfo"));
		} catch (_e) {
			return null;
		}

		const playerEl = document.querySelector("#player_el");
		let postId = playerEl ? playerEl.getAttribute("data-postid") : null;
		if (!postId || !vnfo[postId]) postId = Object.keys(vnfo)[0];
		const rawPath = vnfo[postId];
		if (!rawPath) return null;

		let poster = playerEl ? playerEl.getAttribute("poster") : null;
		if (!poster) {
			const thumb = document.querySelector('meta[itemprop="thumbnailUrl"]');
			if (thumb) poster = thumb.getAttribute("content");
		}
		if (poster?.startsWith("//")) poster = `https:${poster}`;

		let title = "";
		const metaName = document.querySelector('meta[itemprop="name"]');
		if (metaName) title = metaName.getAttribute("content") || "";
		if (!title) title = document.title || "";
		title = title.replace(/\s+/g, " ").trim().slice(0, 140);

		const fullSrc = resolveVideoUrl(rawPath);
		if (!isValidVideoUrl(fullSrc)) return null;

		return {
			src: fullSrc,
			poster: poster || "",
			title: title,
			postId: postId,
		};
	}

	// Fallback: site JS (or markup changes) may populate the video element
	// directly. Only accept real media stream URLs.
	function extractFromVideoElement() {
		const el = document.querySelector(
			"#player_el, video.player_el, video[src]",
		);
		if (!el) return null;
		const candidates = [
			el.currentSrc,
			el.getAttribute("src"),
			el.getAttribute("data-puri"),
			el.querySelector("source")?.getAttribute("src"),
		];
		let src = "";
		for (let i = 0; i < candidates.length; i++) {
			const c = candidates[i];
			if (isValidVideoUrl(c)) {
				src = c;
				break;
			}
		}
		if (!src) return null;
		if (src.startsWith("//")) src = `https:${src}`;
		else if (src.charAt(0) === "/") src = location.origin + src;

		let poster = el.getAttribute("poster");
		if (poster?.startsWith("//")) poster = `https:${poster}`;

		let title = "";
		const metaName = document.querySelector('meta[itemprop="name"]');
		if (metaName) title = metaName.getAttribute("content") || "";
		if (!title) title = document.title || "";
		title = title.replace(/\s+/g, " ").trim().slice(0, 140);

		return {
			src: src,
			poster: poster || "",
			title: title,
			postId: el.getAttribute("data-postid") || location.pathname,
		};
	}

	function poll() {
		// 1. Primary: Extract from .vidsnfo when it appears in the streaming HTML
		let data = extract();

		// 2. Fallback: Only check video element once document is ready
		if (!data && document.readyState === "complete") {
			data = extractFromVideoElement();
		}

		if (data && isValidVideoUrl(data.src)) {
			buildPlayer(data);
			return;
		}

		if (
			document.readyState === "complete" &&
			Date.now() - startedAt > STOP_POLLING_AFTER_MS
		) {
			return; // not a video post — leave the site alone
		}
		setTimeout(poll, 40);
	}
	poll();

	/* ------------------------------------------------------------------ *
	 * 2. Page takeover                                                   *
	 * ------------------------------------------------------------------ */
	let built = false;
	function buildPlayer(data) {
		if (built) return;
		built = true;

		// Neutralize popunders from anything that already ran
		try {
			window.open = () => null;
		} catch (_e) {
			/* ignore */
		}

		// Pause and strip any secondary video elements outside the primary player
		const primaryVideo =
			document.getElementById("player_el") ||
			document.querySelector("video.player_el, video");
		document.querySelectorAll("video").forEach((v) => {
			if (v !== primaryVideo) {
				try {
					v.pause();
					v.removeAttribute("src");
					v.load();
				} catch (_e) {}
			}
		});
		// Suppress injected third-party ad iframes and floating modal overlays
		try {
			const obs = new MutationObserver((mutations) => {
				for (const m of mutations) {
					for (const node of m.addedNodes) {
						if (
							node.nodeType === 1 &&
							node.id !== "scp-app" &&
							node.tagName !== "SCRIPT" &&
							node.tagName !== "STYLE"
						) {
							if (
								node.tagName === "IFRAME" ||
								node.tagName === "INS" ||
								(node.style?.zIndex &&
									Number.parseInt(node.style.zIndex, 10) > 100)
							) {
								node.style.setProperty("display", "none", "important");
								node.style.setProperty("pointer-events", "none", "important");
								node.style.setProperty("visibility", "hidden", "important");
							}
						}
					}
				}
			});
			obs.observe(document.documentElement, { childList: true, subtree: true });
		} catch (_e) {}

		const style = document.createElement("style");
		style.textContent = CSS;
		(document.head || document.documentElement).appendChild(style);

		document.title = data.title || "Player";

		const app = document.createElement("div");
		app.id = "scp-app";
		app.innerHTML = PLAYER_HTML;

		// Adopt and graft onto the site's original video element
		let video = primaryVideo;
		if (!video) {
			video = document.createElement("video");
		}
		video.id = "scp-video";
		video.removeAttribute("controls");
		video.setAttribute("playsinline", "");
		video.setAttribute("preload", "metadata");
		video.className = "scp-video-element";

		const stage = app.querySelector("#scp-stage");
		stage.appendChild(video);

		function mount() {
			if (document.body) {
				document.body.appendChild(app);
			} else {
				document.documentElement.appendChild(app);
			}
			initPlayer(app, data, video);
		}

		if (document.body) {
			mount();
		} else {
			document.addEventListener("DOMContentLoaded", mount, { once: true });
		}
	}
	/* ------------------------------------------------------------------ *
	 * 3. Markup + styles                                                  *
	 * ------------------------------------------------------------------ */

	const CSS = `
  html, body { margin:0; padding:0; height:100%; background:#000 !important; overflow:hidden !important;
    -webkit-user-select:none; user-select:none; font-family:system-ui,-apple-browser,sans-serif; }
  body > *:not(#scp-app):not(script):not(style) { display: none !important; pointer-events: none !important; visibility: hidden !important; }
  html > *:not(body):not(head) { display: none !important; pointer-events: none !important; visibility: hidden !important; }
  #scp-app { position:fixed; inset:0; z-index:2147483647; background:#000; }
  #scp-stage { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; }
  #scp-video { width:100%; height:100%; object-fit:contain; background:#000; }

  #scp-tapzone { position:absolute; inset:0; touch-action:none; }

  .scp-layer { position:absolute; left:0; right:0; pointer-events:none;
    transition:opacity .25s ease, transform .25s ease; }
  #scp-app.scp-hidden .scp-layer { opacity:0; }
  #scp-app.scp-hidden .scp-layer,
  #scp-app.scp-hidden .scp-layer * { pointer-events:none !important; }
  #scp-app.scp-hidden #scp-topbar { transform:translateY(-8px); }
  #scp-app.scp-hidden #scp-ctrlbar { transform:translateY(8px); }

  #scp-topbar { top:0; padding:10px 14px 26px;
    background:linear-gradient(to bottom, rgba(0,0,0,.75), transparent);
    display:flex; align-items:flex-start; gap:10px; }
  #scp-title { color:#fff; font-size:13px; line-height:1.35; opacity:.9; flex:1;
    overflow:hidden; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical;
    text-shadow:0 1px 2px rgba(0,0,0,.8); }

  #scp-center { position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
    pointer-events:none; }
  #scp-spinner { width:52px; height:52px; border-radius:50%;
    border:4px solid rgba(255,255,255,.25); border-top-color:#fff; position:absolute;
    animation:scp-spin .8s linear infinite; display:none; }
  #scp-app.scp-buffering #scp-spinner { display:block; }
  @keyframes scp-spin { to { transform:rotate(360deg); } }

  #scp-bigtoggle { width:76px; height:76px; border-radius:50%; background:rgba(0,0,0,.55);
    display:none; align-items:center; justify-content:center; cursor:pointer; }
  #scp-app.scp-paused:not(.scp-buffering) #scp-bigtoggle { display:flex; }
  #scp-bigtoggle svg { width:38px; height:38px; fill:#fff; margin-left:4px; }

  #scp-seekfx { position:absolute; color:#fff; font-size:15px; font-weight:600;
    background:rgba(0,0,0,.55); border-radius:20px; padding:6px 14px; opacity:0;
    transition:opacity .2s ease; }
  #scp-seekfx.scp-show { opacity:1; }

  #scp-ctrlbar { bottom:0; padding:30px 12px 12px;
    background:linear-gradient(to top, rgba(0,0,0,.8), transparent);
    pointer-events:none; }
  #scp-ctrlbar > * { pointer-events:auto; }

  #scp-progress { position:relative; height:28px; display:flex; align-items:center;
    touch-action:none; cursor:pointer; }
  #scp-track { position:relative; height:4px; width:100%; border-radius:2px;
    background:rgba(255,255,255,.25); transition:height .12s ease; }
  #scp-progress:hover #scp-track, #scp-progress.scp-scrubbing #scp-track { height:6px; }
  #scp-buffered { position:absolute; left:0; top:0; bottom:0; width:0;
    background:rgba(255,255,255,.35); border-radius:2px; }
  #scp-fill { position:absolute; left:0; top:0; bottom:0; width:0;
    background:#e83e8c; border-radius:2px; }
  #scp-thumb { position:absolute; top:50%; width:14px; height:14px; border-radius:50%;
    background:#fff; transform:translate(-50%,-50%) scale(0);
    transition:transform .12s ease; box-shadow:0 1px 4px rgba(0,0,0,.5); }
  #scp-progress:hover #scp-thumb, #scp-progress.scp-scrubbing #scp-thumb { transform:translate(-50%,-50%) scale(1); }
  #scp-bubble { position:absolute; bottom:34px; transform:translateX(-50%);
    background:rgba(0,0,0,.85); color:#fff; font-size:12px; padding:3px 8px;
    border-radius:4px; display:none; white-space:nowrap; }
  #scp-progress.scp-scrubbing #scp-bubble { display:block; }

  #scp-buttons { display:flex; align-items:center; gap:6px; margin-top:4px; height:44px; }
  .scp-btn { min-width:44px; height:44px; border:0; background:transparent; color:#fff;
    display:flex; align-items:center; justify-content:center; cursor:pointer;
    font-size:14px; font-weight:600; font-family:inherit; border-radius:8px; padding:0 8px; }
  .scp-btn:active { background:rgba(255,255,255,.15); }
  .scp-btn svg { width:26px; height:26px; fill:#fff; }
  #scp-time { color:#fff; font-size:13px; font-variant-numeric:tabular-nums;
    padding:0 6px; white-space:nowrap; }
  #scp-time b { font-weight:400; opacity:.7; }
  .scp-spacer { flex:1; }
  #scp-speed { font-variant-numeric:tabular-nums; }
  #scp-speed::after { content:'x'; font-size:11px; opacity:.7; margin-left:1px; }

  #scp-volwrap { position:relative; display:flex; align-items:center; }
  #scp-volslider { position:absolute; bottom:48px; left:50%; transform:translateX(-50%) scaleX(0);
    transform-origin:left center; width:120px; height:34px; background:rgba(0,0,0,.85);
    border-radius:8px; display:flex; align-items:center; padding:0 12px;
    transition:transform .18s ease; touch-action:none; }
  #scp-volwrap.scp-open #scp-volslider { transform:translateX(-50%) scaleX(1); }
  #scp-voltrack { position:relative; width:100%; height:4px; background:rgba(255,255,255,.3);
    border-radius:2px; }
  #scp-volfill { position:absolute; left:0; top:0; bottom:0; background:#fff; border-radius:2px; }

  #scp-toast { position:absolute; bottom:110px; left:50%; transform:translateX(-50%);
    background:rgba(0,0,0,.8); color:#fff; font-size:13px; padding:8px 16px; border-radius:20px;
    opacity:0; transition:opacity .25s ease; pointer-events:none; white-space:nowrap; }
  #scp-toast.scp-show { opacity:1; }

  #scp-error { position:absolute; inset:0; display:none; flex-direction:column;
    align-items:center; justify-content:center; gap:14px; background:rgba(0,0,0,.85);
    color:#fff; font-size:14px; text-align:center; padding:20px; }
  #scp-error.scp-show { display:flex; }
  #scp-error button { background:#e83e8c; color:#fff; border:0; border-radius:8px;
    padding:10px 22px; font-size:14px; font-family:inherit; cursor:pointer; }
  #scp-error a { color:#8ab4f8; font-size:13px; }
  `;

	const PLAYER_HTML = `
  <div id="scp-stage"></div>
  <div id="scp-tapzone"></div>
  <div id="scp-center">
    <div id="scp-spinner"></div>
    <div id="scp-bigtoggle"><svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg></div>
    <div id="scp-seekfx"></div>
  </div>
  <div id="scp-topbar" class="scp-layer">
    <div id="scp-title"></div>
  </div>
  <div id="scp-ctrlbar" class="scp-layer">
    <div id="scp-progress">
      <div id="scp-track">
        <div id="scp-buffered"></div>
        <div id="scp-fill"></div>
        <div id="scp-thumb"></div>
      </div>
      <div id="scp-bubble">0:00</div>
    </div>
    <div id="scp-buttons">
      <button class="scp-btn" id="scp-play" aria-label="Play">
        <svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
      </button>
      <button class="scp-btn" id="scp-back" aria-label="Back 10 seconds">
        <svg viewBox="0 0 24 24"><path d="M12 5V1L7 6l5 5V7c3.3 0 6 2.7 6 6s-2.7 6-6 6-6-2.7-6-6H4c0 4.4 3.6 8 8 8s8-3.6 8-8-3.6-8-8-8z"/></svg>
      </button>
      <button class="scp-btn" id="scp-fwd" aria-label="Forward 10 seconds">
        <svg viewBox="0 0 24 24"><path d="M12 5V1l5 5-5 5V7c-3.3 0-6 2.7-6 6s2.7 6 6 6 6-2.7 6-6h2c0 4.4-3.6 8-8 8s-8-3.6-8-8 3.6-8 8-8z"/></svg>
      </button>
      <div id="scp-time"><span id="scp-cur">0:00</span> <b>/</b> <span id="scp-dur">&mdash;</span></div>
      <div class="scp-spacer"></div>
      <button class="scp-btn" id="scp-speed" aria-label="Playback speed">1</button>
      <div id="scp-volwrap">
        <button class="scp-btn" id="scp-mute" aria-label="Mute">
          <svg viewBox="0 0 24 24" id="scp-vol-on"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.8-1-3.3-2.5-4v8c1.5-.7 2.5-2.2 2.5-4zM14 3.2v2.1c2.9.9 5 3.5 5 6.7s-2.1 5.8-5 6.7v2.1c4-.9 7-4.5 7-8.8s-3-7.9-7-8.8z"/></svg>
          <svg viewBox="0 0 24 24" id="scp-vol-off" style="display:none"><path d="M16.5 12c0-1.8-1-3.3-2.5-4v2.2l2.4 2.4c.1-.2.1-.4.1-.6zM19 12c0 .9-.2 1.8-.5 2.6l1.5 1.5c.7-1.2 1-2.6 1-4.1 0-4.3-3-7.9-7-8.8v2.1c2.9.9 5 3.5 5 6.7zM4.3 3L3 4.3 7.7 9H3v6h4l5 5v-6.7l4.3 4.3c-.7.5-1.4.9-2.3 1.2v2.1c1.4-.3 2.6-.9 3.7-1.8l2 2L21 19.7 4.3 3zM12 4L9.9 6.1 12 8.2V4z"/></svg>
        </button>
        <div id="scp-volslider"><div id="scp-voltrack"><div id="scp-volfill"></div></div></div>
      </div>
      <button class="scp-btn" id="scp-fs" aria-label="Fullscreen">
        <svg viewBox="0 0 24 24" id="scp-fs-on"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/></svg>
        <svg viewBox="0 0 24 24" id="scp-fs-off" style="display:none"><path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"/></svg>
      </button>
    </div>
  </div>
  <div id="scp-toast"></div>
  <div id="scp-error">
    <div id="scp-errmsg">Playback failed.</div>
    <button id="scp-retry">Retry</button>
    <a id="scp-direct" href="#" target="_blank" rel="noreferrer">Open direct file</a>
  </div>
  `;

	/* ------------------------------------------------------------------ *
	 * 4. Player logic                                                     *
	 * ------------------------------------------------------------------ */

	const SPEEDS = [1, 1.25, 1.5, 1.75, 2, 0.75, 0.5];
	const HIDE_DELAY_MS = 2800;
	const RESUME_MIN_S = 30;
	const RESUME_TAIL_S = 45;
	const SAVE_EVERY_S = 5;

	function fmtTime(t) {
		if (!Number.isFinite(t) || t < 0) t = 0;
		t = Math.floor(t);
		const h = Math.floor(t / 3600);
		const m = Math.floor((t % 3600) / 60);
		const s = t % 60;
		return h > 0
			? `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`
			: `${m}:${String(s).padStart(2, "0")}`;
	}

	function $(id) {
		return document.getElementById(id);
	}

	function initPlayer(app, data, video) {
		const storeKey = `scp:pos:${data.postId}`;

		if (
			!isValidVideoUrl(video.currentSrc) &&
			!isValidVideoUrl(video.getAttribute("src"))
		) {
			video.src = data.src;
		}
		if (data.poster && !video.poster) video.poster = data.poster;
		video.load();
		$("scp-title").textContent = data.title;
		$("scp-direct").href = data.src;

		const state = {
			hideTimer: 0,
			toastTimer: 0,
			scrubbing: false,
			lastTap: 0,
			lastZone: "",
			seekStreak: 0,
			singleTapTimer: 0,
			shownAt: 0,
			savedAt: 0,
			speedIdx: 0,
		};

		/* ---- helpers ---- */

		function showControls() {
			if (app.classList.contains("scp-hidden")) state.shownAt = Date.now();
			app.classList.remove("scp-hidden");
			scheduleHide();
		}

		function scheduleHide() {
			clearTimeout(state.hideTimer);
			if (!video.paused && !state.scrubbing) {
				state.hideTimer = setTimeout(() => {
					app.classList.add("scp-hidden");
				}, HIDE_DELAY_MS);
			}
		}

		function toast(msg) {
			const el = $("scp-toast");
			el.textContent = msg;
			el.classList.add("scp-show");
			clearTimeout(state.toastTimer);
			state.toastTimer = setTimeout(() => {
				el.classList.remove("scp-show");
			}, 1600);
		}

		function setPlayingUI() {
			$("scp-play").innerHTML =
				'<svg viewBox="0 0 24 24"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>';
			app.classList.remove("scp-paused");
		}

		function setPausedUI() {
			$("scp-play").innerHTML =
				'<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>';
			app.classList.add("scp-paused");
			showControls();
		}

		function togglePlay() {
			if (video.ended) {
				video.currentTime = 0;
				video.play();
				return;
			}
			if (video.paused) video.play();
			else video.pause();
		}

		function seekBy(delta) {
			if (!Number.isFinite(video.duration)) return;
			const target = Math.min(
				Math.max(video.currentTime + delta, 0),
				video.duration,
			);
			video.currentTime = target;
			const fx = $("scp-seekfx");
			fx.textContent = `${(delta > 0 ? "+" : "") + Math.round(delta)}s`;
			fx.style.left = delta > 0 ? "70%" : "30%";
			fx.classList.add("scp-show");
			clearTimeout(fx._t);
			fx._t = setTimeout(() => {
				fx.classList.remove("scp-show");
			}, 500);
			updateProgress();
		}

		/* ---- progress bar ---- */

		function posToTime(clientX) {
			const rect = $("scp-track").getBoundingClientRect();
			const ratio = Math.min(
				Math.max((clientX - rect.left) / rect.width, 0),
				1,
			);
			return ratio * (video.duration || 0);
		}

		function updateProgress() {
			const dur = video.duration;
			if (Number.isFinite(dur) && dur > 0) {
				const pct = (video.currentTime / dur) * 100;
				$("scp-fill").style.width = `${pct}%`;
				$("scp-thumb").style.left = `${pct}%`;
			}
			$("scp-cur").textContent = fmtTime(video.currentTime);
		}

		function updateBuffered() {
			const dur = video.duration;
			if (!Number.isFinite(dur)) return;
			let end = 0;
			for (let i = 0; i < video.buffered.length; i++) {
				if (video.buffered.start(i) <= video.currentTime) {
					end = Math.max(end, video.buffered.end(i));
				}
			}
			$("scp-buffered").style.width = `${(end / dur) * 100}%`;
		}

		const progress = $("scp-progress");
		progress.addEventListener("pointerdown", (e) => {
			if (!Number.isFinite(video.duration)) return;
			state.scrubbing = true;
			progress.classList.add("scp-scrubbing");
			progress.setPointerCapture(e.pointerId);
			onScrub(e);
			e.preventDefault();
		});
		progress.addEventListener("pointermove", (e) => {
			if (state.scrubbing) onScrub(e);
		});
		progress.addEventListener("pointerup", (e) => {
			if (!state.scrubbing) return;
			state.scrubbing = false;
			progress.classList.remove("scp-scrubbing");
			const t = posToTime(e.clientX);
			if (Number.isFinite(t)) video.currentTime = t;
			scheduleHide();
		});
		progress.addEventListener("pointercancel", () => {
			state.scrubbing = false;
			progress.classList.remove("scp-scrubbing");
		});

		function onScrub(e) {
			const t = posToTime(e.clientX);
			if (!Number.isFinite(t)) return;
			const pct = (t / (video.duration || 1)) * 100;
			$("scp-fill").style.width = `${pct}%`;
			$("scp-thumb").style.left = `${pct}%`;
			$("scp-bubble").textContent = fmtTime(t);
			const rect = $("scp-track").getBoundingClientRect();
			$("scp-bubble").style.left =
				`${Math.min(Math.max(e.clientX - rect.left, 24), rect.width - 24)}px`;
		}

		/* ---- tap zones (single tap = toggle UI, double tap = seek / play) ---- */

		const tapzone = $("scp-tapzone");
		let tapStartX = 0,
			tapStartY = 0;

		tapzone.addEventListener("pointerdown", (e) => {
			tapStartX = e.clientX;
			tapStartY = e.clientY;
		});

		tapzone.addEventListener("pointerup", (e) => {
			if (
				Math.abs(e.clientX - tapStartX) > 12 ||
				Math.abs(e.clientY - tapStartY) > 12
			) {
				return; // it was a swipe, not a tap
			}
			const rect = tapzone.getBoundingClientRect();
			const x = e.clientX - rect.left;
			const zone =
				x < rect.width / 3
					? "left"
					: x > (rect.width * 2) / 3
						? "right"
						: "center";
			const now = Date.now();

			if (now - state.lastTap > 600 || zone !== state.lastZone) {
				state.seekStreak = 0;
			}

			if (now - state.lastTap <= 600 && zone === state.lastZone) {
				// chained tap (double, triple, quad...)
				clearTimeout(state.singleTapTimer);
				state.lastTap = now;

				if (zone === "center") {
					togglePlay();
				} else {
					state.seekStreak++;
					let amt = 10;
					if (state.seekStreak === 2) amt = 30;
					else if (state.seekStreak >= 3) amt = 60;

					seekBy(zone === "left" ? -amt : amt);
				}
			} else {
				// first tap
				state.lastTap = now;
				state.lastZone = zone;
				state.seekStreak = 0;

				if (zone === "center") {
					togglePlay();
				}

				clearTimeout(state.singleTapTimer);
				state.singleTapTimer = setTimeout(() => {
					state.seekStreak = 0;
					if (app.classList.contains("scp-hidden")) showControls();
					else if (!video.paused && Date.now() - state.shownAt > 600) {
						app.classList.add("scp-hidden");
					}
				}, 600);
			}
		});

		$("scp-bigtoggle").addEventListener("click", togglePlay);

		/* ---- buttons ---- */

		$("scp-play").addEventListener("click", togglePlay);
		$("scp-back").addEventListener("click", () => {
			seekBy(-10);
			showControls();
		});
		$("scp-fwd").addEventListener("click", () => {
			seekBy(10);
			showControls();
		});

		$("scp-speed").addEventListener("click", () => {
			state.speedIdx = (state.speedIdx + 1) % SPEEDS.length;
			const rate = SPEEDS[state.speedIdx];
			video.playbackRate = rate;
			$("scp-speed").textContent = String(rate);
			toast(`Speed ${rate}x`);
			showControls();
		});

		/* ---- volume ---- */

		const volwrap = $("scp-volwrap");
		const volslider = $("scp-volslider");
		let volScrubbing = false;

		function setVolume(v) {
			v = Math.min(Math.max(v, 0), 1);
			video.volume = v;
			video.muted = v === 0;
			$("scp-volfill").style.width = `${v * 100}%`;
			$("scp-vol-on").style.display = video.muted ? "none" : "block";
			$("scp-vol-off").style.display = video.muted ? "block" : "none";
		}

		$("scp-mute").addEventListener("click", () => {
			if (video.muted || video.volume === 0) {
				setVolume(0.7);
			} else {
				setVolume(0);
			}
		});

		function volFromEvent(e) {
			const rect = $("scp-voltrack").getBoundingClientRect();
			return (e.clientX - rect.left) / rect.width;
		}

		volslider.addEventListener("pointerdown", (e) => {
			volScrubbing = true;
			volslider.setPointerCapture(e.pointerId);
			setVolume(volFromEvent(e));
			e.preventDefault();
			e.stopPropagation();
		});
		volslider.addEventListener("pointermove", (e) => {
			if (volScrubbing) setVolume(volFromEvent(e));
		});
		volslider.addEventListener("pointerup", () => {
			volScrubbing = false;
		});

		// tap mute button-and-hold opens slider; simple approach: tap toggles, long area opens on pointerenter is unreliable on touch — open on tap of the wrap when already unmuted
		$("scp-mute").addEventListener("pointerenter", () => {
			volwrap.classList.add("scp-open");
			clearTimeout(volwrap._t);
		});
		volwrap.addEventListener("pointerleave", () => {
			volwrap._t = setTimeout(() => {
				volwrap.classList.remove("scp-open");
			}, 1200);
		});

		setVolume(1);

		/* ---- fullscreen ---- */

		function isFullscreen() {
			return !!(
				document.fullscreenElement ||
				document.webkitFullscreenElement ||
				document.mozFullScreenElement
			);
		}

		$("scp-fs").addEventListener("click", () => {
			if (isFullscreen()) {
				exitFullscreen();
			} else {
				const el = app;
				const req =
					el.requestFullscreen ||
					el.webkitRequestFullscreen ||
					el.mozRequestFullScreen;
				if (req) {
					const p = req.call(el, { navigationUI: "hide" });
					if (p?.catch) p.catch(() => {});
				} else if (video.webkitEnterFullscreen) {
					video.webkitEnterFullscreen(); // iOS Safari fallback
				}
			}
		});

		function exitFullscreen() {
			const ex =
				document.exitFullscreen ||
				document.webkitExitFullscreen ||
				document.mozCancelFullScreen;
			if (ex) {
				const p = ex.call(document);
				if (p?.catch) p.catch(() => {});
			}
		}

		function onFsChange() {
			const fs = isFullscreen();
			$("scp-fs-on").style.display = fs ? "none" : "block";
			$("scp-fs-off").style.display = fs ? "block" : "none";
			if (screen.orientation?.lock) {
				if (fs) {
					const p = screen.orientation.lock("landscape");
					if (p?.catch)
						p.catch(() => {
							/* unsupported or rejected */
						});
				} else if (screen.orientation.unlock) {
					try {
						screen.orientation.unlock();
					} catch (_e) {
						/* ignore */
					}
				}
			}
		}

		document.addEventListener("fullscreenchange", onFsChange);
		document.addEventListener("webkitfullscreenchange", onFsChange);
		document.addEventListener("mozfullscreenchange", onFsChange);

		/* ---- video events ---- */

		video.addEventListener("play", () => {
			setPlayingUI();
			scheduleHide();
		});
		video.addEventListener("pause", () => {
			setPausedUI();
		});
		video.addEventListener("ended", () => {
			setPausedUI();
			savePosition(true);
		});
		video.addEventListener("timeupdate", () => {
			updateProgress();
			maybeSave();
		});
		video.addEventListener("progress", updateBuffered);
		video.addEventListener("durationchange", () => {
			$("scp-dur").textContent = fmtTime(video.duration);
		});
		video.addEventListener("loadedmetadata", () => {
			$("scp-error")?.classList.remove("scp-show");
			$("scp-dur").textContent = fmtTime(video.duration);
			updateBuffered();
			restorePosition();
		});
		video.addEventListener("waiting", () => {
			app.classList.add("scp-buffering");
		});
		video.addEventListener("playing", () => {
			$("scp-error")?.classList.remove("scp-show");
			app.classList.remove("scp-buffering");
		});
		video.addEventListener("canplay", () => {
			$("scp-error")?.classList.remove("scp-show");
			app.classList.remove("scp-buffering");
		});
		video.addEventListener("stalled", () => {
			app.classList.add("scp-buffering");
		});

		video.addEventListener("error", () => {
			if (video.readyState >= 1 || (video.duration && video.duration > 0)) {
				return;
			}
			app.classList.remove("scp-buffering");
			const err = video.error;
			const msg = err
				? {
						1: "Loading aborted.",
						2: "Network error.",
						3: "Decode failed.",
						4: "Video source not supported or unreachable.",
					}[err.code] || "Playback failed."
				: "Playback failed.";
			$("scp-errmsg").textContent = `Playback failed: ${msg}`;
			$("scp-error").classList.add("scp-show");
		});

		$("scp-retry").addEventListener("click", () => {
			$("scp-error").classList.remove("scp-show");
			const t = video.currentTime;
			video.src = data.src;
			video.load();
			if (t > 0) video.currentTime = t;
			video.play().catch(() => {});
		});

		/* ---- resume / persist ---- */

		function maybeSave() {
			const now = Date.now();
			if (now - state.savedAt < SAVE_EVERY_S * 1000) return;
			state.savedAt = now;
			savePosition();
		}

		function savePosition(clear) {
			try {
				if (clear || !Number.isFinite(video.duration) || video.duration === 0) {
					localStorage.removeItem(storeKey);
				} else {
					localStorage.setItem(storeKey, String(Math.floor(video.currentTime)));
				}
			} catch (_e) {
				/* private mode */
			}
		}

		function restorePosition() {
			let pos = 0;
			try {
				pos = parseInt(localStorage.getItem(storeKey) || "0", 10) || 0;
			} catch (_e) {}
			if (
				pos > RESUME_MIN_S &&
				Number.isFinite(video.duration) &&
				pos < video.duration - RESUME_TAIL_S
			) {
				video.currentTime = pos;
				toast(`Resumed from ${fmtTime(pos)}`);
			}
		}

		window.addEventListener("pagehide", () => {
			savePosition();
		});
		document.addEventListener("visibilitychange", () => {
			if (document.hidden) savePosition();
		});

		/* ---- keyboard (desktop bonus) ---- */

		document.addEventListener("keydown", (e) => {
			if (e.target && /^(input|textarea)$/i.test(e.target.tagName)) return;
			switch (e.key) {
				case " ":
				case "k":
					togglePlay();
					e.preventDefault();
					break;
				case "ArrowLeft":
					seekBy(-10);
					break;
				case "ArrowRight":
					seekBy(10);
					break;
				case "ArrowUp":
					setVolume(video.volume + 0.1);
					toast(`Volume ${Math.round(video.volume * 100)}%`);
					break;
				case "ArrowDown":
					setVolume(video.volume - 0.1);
					toast(`Volume ${Math.round(video.volume * 100)}%`);
					break;
				case "m":
					$("scp-mute").click();
					break;
				case "f":
					$("scp-fs").click();
					break;
			}
			showControls();
		});

		/* ---- activity re-shows controls ---- */

		app.addEventListener("pointermove", () => {
			showControls();
		});

		setPausedUI();
		updateProgress();
	}
})();