Keeps iwara.tv video stream URLs fresh so idle-then-play and pause/resume work without a page reload.
// ==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,
};
}