Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
255 changes: 171 additions & 84 deletions dist/kicktiny.user.js
Original file line number Diff line number Diff line change
@@ -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/*
Expand Down Expand Up @@ -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 */
Expand All @@ -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 ────────────────────────────────────────────────────
Expand Down Expand Up @@ -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 });
}
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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;
}
Expand All @@ -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'); }

Expand Down Expand Up @@ -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); }
}

Expand All @@ -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;
Expand All @@ -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 };
}


Expand Down Expand Up @@ -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);
Expand All @@ -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;
}

Expand Down Expand Up @@ -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);
Expand All @@ -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 => {
Expand All @@ -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);
}
Expand All @@ -2375,4 +2462,4 @@ if (document.readyState === 'loading') {
init();
}

})();
})();