diff --git a/dist/kicktiny.user.js b/dist/kicktiny.user.js index c0364a2..4f4b854 100644 --- a/dist/kicktiny.user.js +++ b/dist/kicktiny.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name KickTiny // @namespace https://github.com/reda777/kicktiny -// @version 0.3.7 +// @version 0.4.0 // @description Custom player overlay for Kick.com embeds with DVR // @author Reda777 // @match https://player.kick.com/* @@ -272,8 +272,10 @@ const ADAPTER_MAX_RETRIES = 40; const LATENCY_POLL_INTERVAL_MS = 1_000; // ── DVR controller ──────────────────────────────────────────────────────────── +// FIX: reduced from 8000 — we now use HLS events as primary signal, +// this is only the hard timeout fallback. /** Maximum milliseconds to wait for the HLS seekable window to become available */ -const SEEKABLE_WAIT_MS = 8_000; +const SEEKABLE_WAIT_MS = 4_000; /** Milliseconds before JWT expiry at which we pre-fetch a fresh VOD URL */ const EXPIRY_LEAD_MS = 2 * 60_000; /** Fallback refresh interval (ms) when no JWT expiry can be parsed from the URL */ @@ -293,6 +295,46 @@ const MAX_REAPPLY_ATTEMPTS = 3; /** Maximum page-reload attempts for transient IVS errors before giving up */ const MAX_RELOAD_ATTEMPTS = 3; +// ── CDNs for hls.js — tried in order ──────────────────────────────────────── +const HLS_CDNS = [ + 'https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js', + 'https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.5.13/hls.min.js', +]; + + +// ── hls-loader.js — shared, pre-loaded eagerly ────────────────────────────── +// FIX: hls.js is now loaded once at startup (non-blocking) so it's ready +// by the time the user first clicks into DVR. Previously it was fetched +// lazily on first DVR entry, adding a cold CDN round-trip at the worst moment. + +let _hlsLoadPromise = null; + +function preloadHlsJs() { + if (_hlsLoadPromise) return _hlsLoadPromise; + _hlsLoadPromise = new Promise((resolve, reject) => { + if (window.Hls) { resolve(window.Hls); return; } + let idx = 0; + function tryNext() { + if (idx >= HLS_CDNS.length) { reject(new Error('hls.js failed to load')); return; } + const s = document.createElement('script'); + s.src = HLS_CDNS[idx++]; + s.onload = () => window.Hls ? resolve(window.Hls) : tryNext(); + s.onerror = () => tryNext(); + document.head.appendChild(s); + } + tryNext(); + }); + return _hlsLoadPromise; +} + +// Kick off hls.js download immediately — runs in background, never blocks init. +// By the time the user interacts with DVR it will already be cached. +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => preloadHlsJs(), { once: true }); +} else { + preloadHlsJs(); +} + // ── engines/ivs-engine.js ── // ── engines/ivs-engine.js ──────────────────────────────────────────────────── @@ -588,12 +630,12 @@ function createIvsEngine(store, prefs) { if (q === 'auto') { _player.setAutoQualityMode(true); store.setState({ autoQuality: true, quality: null }); - prefs.save({ quality: null }); + savePrefs({ quality: null }); } else { _player.setAutoQualityMode(false); _player.setQuality(q); store.setState({ autoQuality: false, quality: q }); - prefs.save({ quality: q.name }); + savePrefs({ quality: q.name }); } } @@ -840,29 +882,10 @@ function createDvrEngine(store, api) { const _mb = createManifestBuilder(); - // ── hls.js loader ────────────────────────────────────────────────────────── - - function _loadHlsJs() { - return new Promise((resolve, reject) => { - if (window.Hls) { resolve(window.Hls); return; } - const CDNS = [ - 'https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js', - 'https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.5.13/hls.min.js', - ]; - let idx = 0; - function tryNext() { - if (idx >= CDNS.length) { reject(new Error('hls.js failed to load')); return; } - const s = document.createElement('script'); - s.src = CDNS[idx++]; - s.onload = () => window.Hls ? resolve(window.Hls) : tryNext(); - s.onerror = () => tryNext(); - document.head.appendChild(s); - } - tryNext(); - }); - } - - // ── custom hls.js loader (serves synthetic manifest) ─────────────────────── + // ── hls.js — use shared pre-loader ────────────────────────────────────── + // FIX: replaced the private _loadHlsJs() with the shared preloadHlsJs() + // that already started downloading on page load. First DVR entry is now + // instant (or near-instant) instead of paying a full CDN round-trip. function _buildCustomLoader(DefaultLoader) { return class SyntheticLoader extends DefaultLoader { @@ -943,19 +966,56 @@ function createDvrEngine(store, api) { } } - // ── seekable window ──────────────────────────────────────────────────────── - - async function _waitForSeekable(timeoutMs = SEEKABLE_WAIT_MS) { - const started = Date.now(); - while (Date.now() - started < timeoutMs) { - if (_dvrVideo?.seekable?.length > 0) { - const i = _dvrVideo.seekable.length - 1; - const end = _dvrVideo.seekable.end(i), start = _dvrVideo.seekable.start(i); - if (isFinite(end) && end > start) return { start, end }; + // ── seekable window — event-driven with polling fallback ───────────────── + // FIX: instead of only polling every 100ms up to 8s, we now also listen to + // the HLS.js LEVEL_LOADED and FRAG_LOADED events which fire as soon as data + // is available. This typically resolves in <500ms instead of 2-4s. + + function _waitForSeekable(timeoutMs = SEEKABLE_WAIT_MS) { + return new Promise(resolve => { + // Helper — check current seekable state + const check = () => { + if (_dvrVideo?.seekable?.length > 0) { + const i = _dvrVideo.seekable.length - 1; + const end = _dvrVideo.seekable.end(i); + const start = _dvrVideo.seekable.start(i); + if (isFinite(end) && end > start) return { start, end }; + } + return null; + }; + + // Resolve immediately if already available + const immediate = check(); + if (immediate) { resolve(immediate); return; } + + let settled = false; + const done = result => { + if (settled) return; + settled = true; + clearTimeout(deadline); + if (_hls) { + _hls.off(_Hls.Events.LEVEL_LOADED, onHlsEvent); + _hls.off(_Hls.Events.FRAG_LOADED, onHlsEvent); + } + resolve(result); + }; + + // HLS events fire as soon as the first segment data arrives + const onHlsEvent = () => { const r = check(); if (r) done(r); }; + if (_hls && _Hls) { + _hls.on(_Hls.Events.LEVEL_LOADED, onHlsEvent); + _hls.on(_Hls.Events.FRAG_LOADED, onHlsEvent); } - await new Promise(r => setTimeout(r, 100)); - } - return null; + + // Lightweight poll as safety net (50ms — much tighter than original 100ms) + const poll = setInterval(() => { const r = check(); if (r) { clearInterval(poll); done(r); } }, 50); + + // Hard timeout — resolve with null so the caller can bail gracefully + const deadline = setTimeout(() => { + clearInterval(poll); + done(null); + }, timeoutMs); + }); } function _getSeekableWindow() { @@ -1111,8 +1171,10 @@ function createDvrEngine(store, api) { const wasMuted = s.muted; store.setState({ buffering: true }); + // FIX: use shared preloader — if hls.js already downloaded in the + // background this resolves instantly with no network cost. if (!_Hls) { - try { _Hls = await _loadHlsJs(); } catch (e) { + try { _Hls = await preloadHlsJs(); } catch (e) { console.warn('[KickTiny DVR] hls.js load failed:', e.message); store.setState({ buffering: false }); throw e; } @@ -1137,6 +1199,7 @@ function createDvrEngine(store, api) { _destroyHls(); _createHlsInstance(); + // FIX: event-driven seekable detection replaces the pure polling loop. const win = await _waitForSeekable(); if (!win) { store.setState({ buffering: false }); throw new Error('Seekable window never available'); } @@ -1947,33 +2010,39 @@ function createInfo(store, actions, viewerInterceptor, api) { // ── polling ────────────────────────────────────────────────────────────── + // FIX: _applyChannelData extracted so it can be called from both + // the initial eager fetch AND the recurring poll without duplication. + function _applyChannelData(data) { + if (data.isLive === null) return; + + if (data.title !== null) store.setState({ title: data.title }); + if (data.displayName !== null) store.setState({ displayName: data.displayName }); + if (data.avatar !== null) store.setState({ avatar: data.avatar }); + + live.textContent = data.isLive ? '● LIVE' : '● OFFLINE'; + live.classList.toggle('kt-offline', !data.isLive); + + if (!data.isLive) { _applyOffline(); return; } + + if (data.viewers !== null) { + store.setState({ viewers: data.viewers }); + viewers.textContent = fmtViewers(data.viewers) + ' watching'; + } + store.setState({ vodId: data.vodId ?? null, streamStartTime: data.startTime ?? null }); + if (data.startTime) { + let ts = data.startTime; + if (!ts.includes('T')) ts = ts.replace(' ', 'T'); + if (!/[Zz]$/.test(ts) && !/[+-]\d{2}:?\d{2}$/.test(ts)) ts += 'Z'; + _startUptimeTicker(new Date(ts)); + } + } + async function _poll() { const s = store.getState(); if (!s.username) return; try { const data = await api.fetchChannelInit(s.username); - if (data.isLive === null) return; - - if (data.title !== null) store.setState({ title: data.title }); - if (data.displayName !== null) store.setState({ displayName: data.displayName }); - if (data.avatar !== null) store.setState({ avatar: data.avatar }); - - live.textContent = data.isLive ? '● LIVE' : '● OFFLINE'; - live.classList.toggle('kt-offline', !data.isLive); - - if (!data.isLive) { _applyOffline(); return; } - - if (data.viewers !== null) { - store.setState({ viewers: data.viewers }); - viewers.textContent = fmtViewers(data.viewers) + ' watching'; - } - store.setState({ vodId: data.vodId ?? null, streamStartTime: data.startTime ?? null }); - if (data.startTime) { - let ts = data.startTime; - if (!ts.includes('T')) ts = ts.replace(' ', 'T'); - if (!/[Zz]$/.test(ts) && !/[+-]\d{2}:?\d{2}$/.test(ts)) ts += 'Z'; - _startUptimeTicker(new Date(ts)); - } + _applyChannelData(data); } catch (e) { console.warn('[KickTiny] poll error:', e.message); } } @@ -1985,24 +2054,25 @@ function createInfo(store, actions, viewerInterceptor, api) { live.addEventListener('click', () => { if (!store.getState().atLiveEdge) actions.seekToLive(); }); store.select( - s => ({ - username: s.username, - atLiveEdge: s.atLiveEdge, - engine: s.engine, - dvrBehindLive: s.dvrBehindLive, - uptimeSec: s.uptimeSec - }), - ({ username, atLiveEdge, engine, dvrBehindLive, uptimeSec }) => { - live.classList.toggle('kt-behind', !atLiveEdge); - live.title = atLiveEdge ? '' : 'Jump to live'; - if (username && !pollTimer) _startPolling(); - if (startDate) { - uptime.textContent = engine === 'dvr' - ? fmtDuration(Math.max(0, uptimeSec - Math.round(dvrBehindLive))) - : fmtUptime(startDate); + s => ({ + username: s.username, + atLiveEdge: s.atLiveEdge, + engine: s.engine, + dvrBehindLive: s.dvrBehindLive, + uptimeSec: s.uptimeSec + }), + ({ username, atLiveEdge, engine, dvrBehindLive, uptimeSec }) => { + live.classList.toggle('kt-behind', !atLiveEdge); + live.title = atLiveEdge ? '' : 'Jump to live'; + // FIX: polling no longer auto-starts here — it's kicked off explicitly + // from main.js right after username is set, so there's no wait. + if (startDate) { + uptime.textContent = engine === 'dvr' + ? fmtDuration(Math.max(0, uptimeSec - Math.round(dvrBehindLive))) + : fmtUptime(startDate); + } } - } -); + ); document.addEventListener('visibilitychange', () => { if (!store.getState().username) return; @@ -2015,7 +2085,8 @@ function createInfo(store, actions, viewerInterceptor, api) { } }); - return { live, wrap, destroy: _unsubViewers }; + // Expose _startPolling so main.js can call it directly after username is set. + return { live, wrap, destroy: _unsubViewers, startPolling: _startPolling, applyChannelData: _applyChannelData }; } @@ -2122,7 +2193,8 @@ function createBar(store, actions, viewerInterceptor, api) { const bar = document.createElement('div'); bar.className = 'kt-bar'; - const { live, wrap: infoWrap } = createInfo(store, actions, viewerInterceptor, api); + const info = createInfo(store, actions, viewerInterceptor, api); + const { live, wrap: infoWrap } = info; const left = document.createElement('div'); left.className = 'kt-bar-left'; left.append(createPlayBtn(store, actions), live, createVolumeCtrl(store, actions), infoWrap); @@ -2134,6 +2206,10 @@ function createBar(store, actions, viewerInterceptor, api) { controls.append(left, right); bar.append(createSeekbar(store, actions), controls); + + // Expose info methods so main.js can trigger eager channel fetch + bar._info = info; + return bar; } @@ -2334,7 +2410,9 @@ async function init() { // ── UI ────────────────────────────────────────────────────────────────── injectStyles(CSS); hideNativeControls(); - store.setState({ username: getUsername() }); + + const username = getUsername(); + store.setState({ username }); const root = createRoot(container); const topBar = createTopBar(store); @@ -2344,6 +2422,15 @@ async function init() { root.append(overlay, topBar, bar); initBarHover(root, bar, container, topBar, store); + // ── FIX: Eager channel info fetch ─────────────────────────────────────── + // Previously channel info only loaded after IVS adapter found the player + // (up to 20s) because polling started inside the uptime ticker which was + // itself gated on _onPlayerReady. Now we fire the first fetch immediately + // after setting username — avatar, title, and viewer count appear in <1s. + if (username) { + bar._info.startPolling(); + } + // ── Double-click: single click = play/pause, double = fullscreen ─────── let _clickTimer = null; container.addEventListener('click', e => { @@ -2363,7 +2450,7 @@ async function init() { await engines.init(container); actions.bindKeys(); - console.log('[KickTiny] Initialized for', getUsername() || 'unknown'); + console.log('[KickTiny] Initialized for', username || 'unknown'); } catch (e) { console.warn('[KickTiny] init error:', e.message); } @@ -2375,4 +2462,4 @@ if (document.readyState === 'loading') { init(); } -})(); \ No newline at end of file +})();