Iwara Keepalive

iwara.tv の動画ストリーム URL を更新し、放置後の再生や一時停止/再開をページ再読み込みなしで動かします。

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Iwara Keepalive
// @name:zh-CN Iwara Keepalive
// @name:zh-TW Iwara Keepalive
// @name:ja Iwara Keepalive
// @name:ko Iwara Keepalive
// @namespace    iwara-keepalive
// @version      0.1.2
// @description  Keeps iwara.tv video stream URLs fresh so idle-then-play and pause/resume work without a page reload.
// @description:zh-CN 持续更新 iwara.tv 视频流 URL,让闲置后播放和暂停/继续无需重新加载页面。
// @description:zh-TW 持續更新 iwara.tv 影片串流 URL,讓閒置後播放與暫停/繼續不必重新載入頁面。
// @description:ja iwara.tv の動画ストリーム URL を更新し、放置後の再生や一時停止/再開をページ再読み込みなしで動かします。
// @description:ko iwara.tv 동영상 스트림 URL을 최신 상태로 유지해 대기 후 재생과 일시정지/재개가 페이지 새로고침 없이 동작하게 합니다.
// @author       0xdev
// @license      LGPL-3.0-or-later
// @match        https://www.iwara.tv/*
// @match        https://iwara.tv/*
// @grant        none
// @run-at       document-start
// ==/UserScript==

// THIS FILE IS AUTO-GENERATED — DO NOT MANUALLY EDIT.
// Source: keepalive/iwara-keepalive.user.js
'use strict';
const NEAR_EXPIRY_MARGIN_SEC = 90;
function parseExpirySeconds(url) {
    let raw;
    try {
        raw = new URL(url, 'https://www.iwara.tv').searchParams.get('expires');
    }
    catch {
        return null;
    }
    if (raw == null || raw === '')
        return null;
    const n = Number(raw);
    if (!Number.isFinite(n) || n <= 0)
        return null;
    return n > 1e12 ? Math.floor(n / 1000) : n;
}
function classifyFreshness(url, nowSec, marginSec = NEAR_EXPIRY_MARGIN_SEC) {
    const exp = parseExpirySeconds(url);
    if (exp == null)
        return 'stale';
    if (nowSec >= exp)
        return 'stale';
    if (nowSec >= exp - marginSec)
        return 'near';
    return 'fresh';
}
function isStaleOrNear(url, nowSec, marginSec = NEAR_EXPIRY_MARGIN_SEC) {
    const f = classifyFreshness(url, nowSec, marginSec);
    return f === 'stale' || f === 'near';
}
function extractVideoId(pathname) {
    const m = /^\/video\/([^/]+)/.exec(pathname || '');
    return m ? m[1] : null;
}
function toAbsoluteUrl(url) {
    return url.startsWith('//') ? `https:${url}` : url;
}
function parseVariantName(viewUrl) {
    let filename;
    try {
        filename = new URL(viewUrl, 'https://www.iwara.tv').searchParams.get('filename');
    }
    catch {
        return null;
    }
    if (!filename)
        return null;
    const m = /_([^_.]+)\.[a-z0-9]+$/i.exec(filename);
    return m ? m[1] : null;
}
function pickVariant(variants, currentViewUrl) {
    if (!Array.isArray(variants) || variants.length === 0)
        return null;
    const byName = (name) => variants.find((v) => v && v.name === name);
    const desired = parseVariantName(currentViewUrl);
    return ((desired ? byName(desired) : undefined) ||
        byName('Source') ||
        variants.find((v) => v && v.name !== 'preview') ||
        variants[0]);
}
function shouldRefresh(currentUrl, nowSec, opts = {}) {
    if (opts.force)
        return true;
    return isStaleOrNear(currentUrl, nowSec, opts.marginSec);
}
const FULLY_BUFFERED_SLACK_SEC = 0.5;
function isFullyBuffered(video, slackSec = FULLY_BUFFERED_SLACK_SEC) {
    const dur = video.duration;
    if (typeof dur !== 'number' || !Number.isFinite(dur) || dur <= 0)
        return false;
    const buffered = video.buffered;
    if (!buffered || !buffered.length)
        return false;
    const ranges = [];
    try {
        for (let i = 0; i < buffered.length; i++)
            ranges.push([buffered.start(i), buffered.end(i)]);
    }
    catch {
        return false;
    }
    ranges.sort((a, b) => a[0] - b[0]);
    let covered = 0;
    for (const [start, end] of ranges) {
        if (start > covered + slackSec)
            return false; // a gap a seek could land in
        if (end > covered)
            covered = end;
    }
    return covered >= dur - slackSec;
}
// Which player errors an URL refresh can actually cure: code 4 (src rejected
// outright) always; code 2 (download died part-way) only when the URL is
// stale/near — that is expiry killing an unbuffered fetch (mid-play or a seek
// into an unbuffered region). Code 2 on a fresh URL is a real network problem
// and code 3 is corrupt media; a swap fixes neither.
function shouldForceRefreshOnError(code, currentUrl, nowSec) {
    if (code === 4)
        return true;
    if (code === 2)
        return isStaleOrNear(currentUrl, nowSec);
    return false;
}
const HARVEST_HEADER_KEYS = ['authorization', 'x-version', 'x-site'];
function pickHarvestableHeaders(url, headers) {
    let host;
    try {
        host = new URL(url, 'https://www.iwara.tv').host;
    }
    catch {
        return null;
    }
    if (!/(^|\.)iwara\.tv$/i.test(host))
        return null;
    const out = {};
    for (const k of HARVEST_HEADER_KEYS) {
        if (headers && headers[k])
            out[k] = headers[k];
    }
    return Object.keys(out).length ? out : null;
}
// ── Auth tokens ─────────────────────────────────────────────────────────────
// The API silently IGNORES invalid/expired Bearer tokens (no 401 — the request
// is treated as anonymous) and answers 404 for videos that are tag-gated to
// logged-in users. Signed stream URLs live ~50 min, about as long as an access
// token, so by the time a refresh is needed the harvested token is dead and a
// gated video 404s. These helpers classify tokens and renew them the same way
// the site does: POST /user/token with the long-lived token as Bearer.
const TOKEN_EXPIRY_MARGIN_SEC = 30;
function stripBearer(value) {
    return String(value ?? '').replace(/^\s*Bearer\s+/i, '').trim();
}
function decodeJwtPayload(value) {
    const parts = stripBearer(value).split('.');
    if (parts.length !== 3)
        return null;
    try {
        const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
        const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
        const parsed = JSON.parse(atob(padded));
        return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
            ? parsed
            : null;
    }
    catch {
        return null;
    }
}
// 'opaque' (undecodable) stays usable as an access token — that is what the
// script always did before tokens were classified, and the server ignores bad
// tokens anyway. Expiry wins over type: an expired refresh token is 'stale'.
function classifyAuthValue(value, nowSec, marginSec = TOKEN_EXPIRY_MARGIN_SEC) {
    const payload = decodeJwtPayload(value);
    if (!payload)
        return 'opaque';
    const exp = typeof payload.exp === 'number' && Number.isFinite(payload.exp) ? payload.exp : null;
    if (exp != null && nowSec >= exp - marginSec)
        return 'stale';
    if (typeof payload.type === 'string' && /refresh/i.test(payload.type))
        return 'renewal';
    return 'access';
}
// The site keeps its long-lived token in localStorage; the stored shape is not
// pinned by iwara, so accept a raw JWT, a JSON-quoted JWT, or a JSON object
// with a token-ish field.
function extractStoredToken(raw) {
    if (!raw)
        return null;
    const looksJwt = (s) => typeof s === 'string' && /^[\w-]+\.[\w-]+\.[\w-]+$/.test(s);
    const trimmed = String(raw).trim();
    if (looksJwt(trimmed))
        return trimmed;
    try {
        const parsed = JSON.parse(trimmed);
        if (looksJwt(parsed))
            return parsed;
        if (parsed && typeof parsed === 'object') {
            for (const key of ['token', 'accessToken', 'refreshToken']) {
                const v = parsed[key];
                if (looksJwt(v))
                    return v;
            }
        }
    }
    catch { /* not JSON */ }
    return null;
}
async function renewAccessToken(renewalToken, fetchImpl = fetch) {
    const res = await fetchImpl('https://api.iwara.tv/user/token', {
        method: 'POST',
        headers: { 'x-site': 'www.iwara.tv', authorization: `Bearer ${stripBearer(renewalToken)}` },
    });
    if (!res.ok)
        throw new Error(`renew ${res.status}`);
    const body = (await res.json());
    if (!body || typeof body.accessToken !== 'string' || !body.accessToken) {
        throw new Error('no accessToken in renewal response');
    }
    return body.accessToken;
}
// Deliberately NO `credentials: 'include'`: the API only returns a readable
// CORS response in non-credentialed mode. With cookies it omits
// Access-Control-Allow-Origin and the browser blocks the fetch. iwara authorizes
// via the Bearer token we harvest into `headers`, not cookies.
async function fetchFreshVariants(videoId, headers, fetchImpl = fetch) {
    const base = { 'x-site': 'www.iwara.tv', ...(headers || {}) };
    const metaRes = await fetchImpl(`https://api.iwara.tv/video/${encodeURIComponent(videoId)}`, { headers: base });
    if (!metaRes.ok)
        throw new Error(`metadata ${metaRes.status}`);
    const meta = (await metaRes.json());
    if (!meta || !meta.fileUrl)
        throw new Error('no fileUrl in metadata');
    const manRes = await fetchImpl(meta.fileUrl, { headers: base });
    if (!manRes.ok)
        throw new Error(`manifest ${manRes.status}`);
    const variants = (await manRes.json());
    if (!Array.isArray(variants))
        throw new Error('manifest not an array');
    return variants;
}
async function selectFreshViewUrl(videoId, currentViewUrl, headers, fetchImpl = fetch) {
    const variants = await fetchFreshVariants(videoId, headers, fetchImpl);
    const variant = pickVariant(variants, currentViewUrl);
    if (!variant || !variant.src || !variant.src.view)
        throw new Error('no usable variant');
    return toAbsoluteUrl(variant.src.view);
}
// One metadata attempt with the best token we hold (none, if it is provably
// dead — the server would ignore it and the anonymous result is the same),
// then on 404 renew once and retry once. A 404 with a working renewal token is
// the tag-gated case; without one it is genuinely deleted/private. Non-404
// failures are not auth problems and propagate untouched.
async function selectFreshViewUrlWithAuth(videoId, currentViewUrl, auth, nowSec, fetchImpl = fetch) {
    const base = { ...(auth.base || {}) };
    delete base.authorization; // the chain owns the Authorization header
    const attempt = (token) => {
        const headers = token ? { ...base, authorization: `Bearer ${stripBearer(token)}` } : base;
        return selectFreshViewUrl(videoId, currentViewUrl, headers, fetchImpl);
    };
    const access = auth.accessToken || null;
    const cls = access ? classifyAuthValue(access, nowSec) : null;
    const usableAccess = access && (cls === 'access' || cls === 'opaque') ? access : null;
    try {
        return { viewUrl: await attempt(usableAccess), renewedAccessToken: null };
    }
    catch (e) {
        const msg = e instanceof Error ? e.message : String(e);
        const renewal = auth.renewalToken || null;
        if (!/^metadata 404\b/.test(msg) || !renewal)
            throw e;
        let renewed;
        try {
            renewed = await renewAccessToken(renewal, fetchImpl);
        }
        catch (renewErr) {
            const rmsg = renewErr instanceof Error ? renewErr.message : String(renewErr);
            throw new Error(`${msg}; renew failed: ${rmsg}`, { cause: renewErr });
        }
        return { viewUrl: await attempt(renewed), renewedAccessToken: renewed };
    }
}
function isVideoJsPlayer(value) {
    return !!value && typeof value === 'object' && typeof value.src === 'function';
}
function findVideoJsPlayer(video) {
    const win = video.ownerDocument.defaultView;
    const videojs = win?.videojs;
    const playerEl = video.closest('.video-js');
    const attachedPlayer = video.player || playerEl?.player;
    if (isVideoJsPlayer(attachedPlayer))
        return attachedPlayer;
    if (!videojs)
        return null;
    const candidates = [video];
    if (playerEl) {
        candidates.push(playerEl);
        if (playerEl.id)
            candidates.push(playerEl.id);
    }
    if (typeof videojs.getPlayer === 'function') {
        for (const candidate of candidates) {
            try {
                const player = videojs.getPlayer(candidate);
                if (isVideoJsPlayer(player))
                    return player;
            }
            catch { }
        }
    }
    if (playerEl?.id) {
        const player = videojs.players?.[playerEl.id];
        if (isVideoJsPlayer(player))
            return player;
    }
    return null;
}
function mediaTypeForUrl(viewUrl) {
    let sourceName;
    try {
        const url = new URL(viewUrl, 'https://www.iwara.tv');
        sourceName = url.searchParams.get('filename') || url.pathname;
    }
    catch {
        sourceName = viewUrl;
    }
    const lower = sourceName.toLowerCase();
    if (lower.endsWith('.mp4'))
        return 'video/mp4';
    if (lower.endsWith('.webm'))
        return 'video/webm';
    if (lower.endsWith('.m3u8'))
        return 'application/x-mpegURL';
    return undefined;
}
function readPlaybackState(video, player) {
    let at = video.currentTime;
    let wasPlaying = !video.paused;
    let rate = video.playbackRate;
    try {
        const playerTime = player?.currentTime?.();
        if (typeof playerTime === 'number' && Number.isFinite(playerTime))
            at = playerTime;
    }
    catch { }
    try {
        const playerPaused = player?.paused?.();
        if (typeof playerPaused === 'boolean')
            wasPlaying = !playerPaused;
    }
    catch { }
    try {
        const playerRate = player?.playbackRate?.();
        if (typeof playerRate === 'number' && Number.isFinite(playerRate))
            rate = playerRate;
    }
    catch { }
    return { at, wasPlaying, rate };
}
function restorePlayback(video, player, at, rate, wasPlaying) {
    try {
        if (player?.currentTime)
            player.currentTime(at);
        else
            video.currentTime = at;
    }
    catch { /* seek may be clamped; ignore */ }
    try {
        if (player?.playbackRate)
            player.playbackRate(rate);
        else
            video.playbackRate = rate;
    }
    catch { }
    if (wasPlaying) {
        try {
            const p = player?.play ? player.play() : video.play();
            if (p && typeof p.catch === 'function')
                p.catch(() => { });
        }
        catch { }
    }
}
function onceLoadedMetadata(video, player, handler) {
    if (player?.one) {
        try {
            player.one('loadedmetadata', handler);
            return;
        }
        catch { }
    }
    video.addEventListener('loadedmetadata', handler, { once: true });
}
function lockVideoJsLayout(video, player) {
    if (!player)
        return () => { };
    const playerEl = video.closest('.video-js');
    if (!playerEl)
        return () => { };
    const rect = playerEl.getBoundingClientRect();
    if (!Number.isFinite(rect.height) || rect.height <= 0)
        return () => { };
    const previousMinHeight = playerEl.style.minHeight;
    playerEl.style.minHeight = `${Math.round(rect.height)}px`;
    let released = false;
    return () => {
        if (released)
            return;
        released = true;
        playerEl.style.minHeight = previousMinHeight;
    };
}
function swapSource(video, viewUrl, opts = {}) {
    const freshUrl = toAbsoluteUrl(viewUrl);
    const player = findVideoJsPlayer(video);
    const { at, wasPlaying, rate } = readPlaybackState(video, player);
    // After a fatal media error the element may have dropped its position and
    // playing state; the caller passes what it last observed. A live element
    // reading always wins — the memory only fills in what the error erased.
    const restoreAt = Number.isFinite(at) && at > 0.25
        ? at
        : typeof opts.restoreAt === 'number' && Number.isFinite(opts.restoreAt) && opts.restoreAt > 0
            ? opts.restoreAt
            : at;
    const resume = wasPlaying || opts.resume === true;
    const unlockLayout = lockVideoJsLayout(video, player);
    let restored = false;
    const restore = () => {
        if (restored)
            return;
        restored = true;
        restorePlayback(video, player, restoreAt, rate, resume);
        unlockLayout();
    };
    if (player?.src) {
        onceLoadedMetadata(video, player, restore);
        const source = { src: freshUrl };
        const type = mediaTypeForUrl(freshUrl);
        if (type)
            source.type = type;
        try {
            player.src(source);
            return;
        }
        catch { }
    }
    video.addEventListener('loadedmetadata', restore, { once: true });
    video.src = freshUrl;
    video.load();
}
// ── Terminal-failure handling ───────────────────────────────────────────────
// A video whose refresh keeps 404ing even after token renewal cannot be kept
// alive (deleted, private, or the account cannot see it). Without a gate the
// script would re-hit the API on every play/seek forever — the observed
// failure was 23 identical 404s with the only feedback buried in the console.
// A refresh only helps if it hands back a DIFFERENT url that reads as fresh.
// Anything else (the same url again, one already past its expiry, or one whose
// expiry cannot be read at all) leaves the next freshness check saying "stale"
// — and since restoring playback fires `play`, that check runs immediately.
function isProductiveRefresh(freshUrl, currentUrl, nowSec) {
    if (!freshUrl || freshUrl === currentUrl)
        return false;
    return !isStaleOrNear(freshUrl, nowSec);
}
// Two independent stops, because the two failure shapes need opposite answers
// to `force`. Hard failures (the video cannot be fetched at all) block
// everything, including error-driven retries. Unproductive successes only
// suppress the AUTOMATIC checks — a real player error still gets one attempt,
// since that is recovery rather than a loop.
function createRefreshGate(maxConsecutiveFailures = 3, maxUnproductive = 2) {
    let currentId = null;
    let failures = 0;
    let tripped = false;
    let unproductive = 0;
    let suppressed = false;
    const sync = (id) => {
        if (id !== currentId) {
            currentId = id;
            failures = 0;
            tripped = false;
            unproductive = 0;
            suppressed = false;
        }
    };
    return {
        allow(id, opts = {}) {
            sync(id);
            if (tripped)
                return false;
            return !suppressed || opts.force === true;
        },
        recordFailure(id) {
            sync(id);
            if (tripped)
                return false;
            failures += 1;
            if (failures < maxConsecutiveFailures)
                return false;
            tripped = true;
            return true; // just tripped — caller shows its one-time notice on this
        },
        recordSuccess(id, opts = {}) {
            sync(id);
            failures = 0;
            tripped = false;
            if (opts.productive === false) {
                if (suppressed)
                    return false;
                unproductive += 1;
                if (unproductive < maxUnproductive)
                    return false;
                suppressed = true;
                return true; // just suppressed — caller logs this once
            }
            unproductive = 0;
            suppressed = false;
            return false;
        },
    };
}
const NOTICE_ID = 'iwara-keepalive-notice';
const NOTICE_TIMEOUT_MS = 12_000;
function showRefreshNotice(doc, message) {
    const existing = doc.getElementById(NOTICE_ID);
    if (existing)
        return existing;
    if (!doc.body)
        return null;
    const el = doc.createElement('div');
    el.id = NOTICE_ID;
    el.textContent = message;
    el.style.cssText = [
        'position:fixed',
        'right:16px',
        'bottom:16px',
        'z-index:2147483647',
        'max-width:340px',
        'padding:10px 14px',
        'border-radius:8px',
        'background:rgba(20,20,24,0.92)',
        'color:#fff',
        'font:13px/1.45 system-ui,sans-serif',
        'box-shadow:0 4px 16px rgba(0,0,0,0.35)',
        'pointer-events:none',
    ].join(';');
    doc.body.appendChild(el);
    setTimeout(() => { try {
        el.remove();
    }
    catch { /* already gone */ } }, NOTICE_TIMEOUT_MS);
    return el;
}
if (typeof window !== 'undefined')
    (function main() {
        const LOG = '[iwara-keepalive]';
        const debug = (...a) => { try {
            console.debug(LOG, ...a);
        }
        catch { /* console may be gone */ } };
        let harvestedHeaders = {};
        let harvestedAccessToken = null;
        let harvestedRenewalToken = null;
        const nativeFetch = window.fetch ? window.fetch.bind(window) : null;
        if (nativeFetch) {
            window.fetch = function keepaliveFetch(input, init) {
                try {
                    const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
                    const raw = {};
                    const collect = (h) => {
                        if (!h)
                            return;
                        if (h instanceof Headers)
                            h.forEach((v, k) => { raw[k.toLowerCase()] = v; });
                        else if (Array.isArray(h))
                            for (const [k, v] of h)
                                raw[k.toLowerCase()] = v;
                        else
                            for (const k of Object.keys(h))
                                raw[k.toLowerCase()] = h[k];
                    };
                    if (input instanceof Request)
                        collect(input.headers);
                    if (init && init.headers)
                        collect(init.headers);
                    const picked = pickHarvestableHeaders(url, raw);
                    if (picked) {
                        // Authorization is routed into per-type token slots instead of the
                        // shared header bag: the SPA's own token-renewal POST carries the
                        // long-lived refresh token, and letting that overwrite a good access
                        // token (or vice versa) poisons later refreshes.
                        const { authorization, ...rest } = picked;
                        harvestedHeaders = { ...harvestedHeaders, ...rest };
                        if (authorization) {
                            const cls = classifyAuthValue(authorization, Date.now() / 1000);
                            if (cls === 'renewal')
                                harvestedRenewalToken = stripBearer(authorization);
                            else if (cls !== 'stale')
                                harvestedAccessToken = stripBearer(authorization);
                        }
                    }
                }
                catch { /* harvesting is best-effort; never break the site's fetch */ }
                return nativeFetch(input, init);
            };
        }
        // The site keeps its long-lived token in localStorage under `token`; used as
        // the renewal fallback when no refresh-type Authorization was harvested yet.
        const storedRenewalToken = () => {
            try {
                return extractStoredToken(window.localStorage.getItem('token'));
            }
            catch {
                return null; // storage access can throw in hardened contexts
            }
        };
        let refreshInFlight = false;
        const refreshGate = createRefreshGate();
        const maybeRefresh = async (video, opts = {}) => {
            if (refreshInFlight)
                return;
            const videoId = extractVideoId(location.pathname);
            if (!videoId)
                return;
            if (!refreshGate.allow(videoId, { force: opts.force }))
                return;
            // A fully buffered video cannot die from URL expiry — playback and seeks
            // are served from the buffer, and a swap would only throw that away.
            if (!opts.force && isFullyBuffered(video))
                return;
            const current = video.currentSrc || video.src || '';
            const nowSec = Date.now() / 1000;
            if (!shouldRefresh(current, nowSec, opts))
                return;
            refreshInFlight = true;
            try {
                const { viewUrl, renewedAccessToken } = await selectFreshViewUrlWithAuth(videoId, current, {
                    base: harvestedHeaders,
                    accessToken: harvestedAccessToken,
                    renewalToken: harvestedRenewalToken || storedRenewalToken(),
                }, nowSec);
                if (renewedAccessToken)
                    harvestedAccessToken = renewedAccessToken;
                const productive = isProductiveRefresh(viewUrl, current, Date.now() / 1000);
                if (refreshGate.recordSuccess(videoId, { productive })) {
                    debug('refresh keeps returning a source that still reads as stale; automatic refreshes paused');
                }
                if (viewUrl && viewUrl !== current) {
                    debug('refreshing stale source', opts.force ? '(forced)' : '');
                    swapSource(video, viewUrl, { restoreAt: opts.restoreAt, resume: opts.resume });
                }
            }
            catch (e) {
                debug('refresh failed:', e instanceof Error ? e.message : e);
                if (refreshGate.recordFailure(videoId)) {
                    showRefreshNotice(document, 'Iwara Keepalive: could not refresh this video — it may be deleted, private, or hidden unless logged in. Reload the page to retry.');
                }
            }
            finally {
                refreshInFlight = false;
            }
        };
        // How often the freshness check runs during playback. Driven by the
        // element's own `timeupdate` events (~4 Hz while playing, silent otherwise),
        // NOT a timer — well inside the 90 s near-expiry margin, so a playing video
        // is re-signed before its URL can go stale under it.
        const PLAYING_CHECK_INTERVAL_SEC = 20;
        const BOUND = new WeakSet();
        const bindVideo = (video) => {
            if (!video || BOUND.has(video))
                return;
            BOUND.add(video);
            // Last position/playing state observed while the element was healthy —
            // a fatal media error can wipe both, and recovery needs somewhere to land.
            // During `seeking`, currentTime is already the seek TARGET, so a seek that
            // dies on an expired URL is revived at the position the user clicked.
            const last = { t: 0, playing: false };
            let lastFreshnessCheckSec = 0;
            const remember = () => {
                const t = video.currentTime;
                if (Number.isFinite(t) && t > 0)
                    last.t = t;
                if (!video.error)
                    last.playing = !video.paused && !video.ended;
            };
            video.addEventListener('play', () => { remember(); maybeRefresh(video); });
            video.addEventListener('seeking', () => { remember(); maybeRefresh(video); });
            video.addEventListener('pause', () => { remember(); });
            video.addEventListener('timeupdate', () => {
                remember();
                const nowSec = Date.now() / 1000;
                if (nowSec - lastFreshnessCheckSec < PLAYING_CHECK_INTERVAL_SEC)
                    return;
                lastFreshnessCheckSec = nowSec;
                maybeRefresh(video);
            });
            video.addEventListener('error', () => {
                const code = video.error && video.error.code;
                const current = video.currentSrc || video.src || '';
                if (!shouldForceRefreshOnError(code, current, Date.now() / 1000))
                    return;
                debug('reviving dead player, error code', code);
                maybeRefresh(video, { force: true, restoreAt: last.t, resume: last.playing });
            });
            debug('bound video element');
        };
        const scanAndBind = () => { document.querySelectorAll('video').forEach(bindVideo); };
        document.addEventListener('visibilitychange', () => {
            if (document.visibilityState !== 'visible')
                return;
            document.querySelectorAll('video').forEach((v) => maybeRefresh(v));
        });
        let scanTimer = null;
        const observer = new MutationObserver(() => {
            if (scanTimer)
                return;
            scanTimer = setTimeout(() => { scanTimer = null; scanAndBind(); }, 200);
        });
        const start = () => {
            scanAndBind();
            observer.observe(document.documentElement, { childList: true, subtree: true });
            debug('active');
        };
        if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', start, { once: true });
        }
        else {
            start();
        }
    })();
if (typeof window === 'undefined' && typeof module !== 'undefined' && module.exports) {
    module.exports = {
        NEAR_EXPIRY_MARGIN_SEC,
        parseExpirySeconds,
        classifyFreshness,
        isStaleOrNear,
        extractVideoId,
        toAbsoluteUrl,
        parseVariantName,
        pickVariant,
        shouldRefresh,
        isFullyBuffered,
        shouldForceRefreshOnError,
        isProductiveRefresh,
        HARVEST_HEADER_KEYS,
        pickHarvestableHeaders,
        fetchFreshVariants,
        selectFreshViewUrl,
        swapSource,
        TOKEN_EXPIRY_MARGIN_SEC,
        decodeJwtPayload,
        classifyAuthValue,
        extractStoredToken,
        renewAccessToken,
        selectFreshViewUrlWithAuth,
        createRefreshGate,
        showRefreshNotice,
    };
}