KickTiny

Custom player overlay for Kick.com embeds with DVR

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey, Greasemonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

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

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

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

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्क्रिप्ट व्यवस्थापक एक्स्टेंशन इंस्टॉल करावे लागेल.

(माझ्याकडे आधीच युझर स्क्रिप्ट व्यवस्थापक आहे, मला इंस्टॉल करू द्या!)

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

(माझ्याकडे आधीच युझर स्टाईल व्यवस्थापक आहे, मला इंस्टॉल करू द्या!)

// ==UserScript==
// @name         KickTiny
// @namespace    https://github.com/reda777/kicktiny
// @version      0.3.7
// @description  Custom player overlay for Kick.com embeds with DVR
// @author       Reda777
// @match        https://player.kick.com/*
// @supportURL   https://github.com/reda777/kicktiny
// @grant        none
// @run-at       document-start
// @license      MIT
// ==/UserScript==

(function() {
'use strict';

// ── store.js ──
// ── store.js ──────────────────────────────────────────────────────────────────
// Creates the application state store. Call createStore() once in main.js and
// pass the returned object to every module that needs it.
//
// API:
//   store.getState()          → state object (treat as readonly outside store)
//   store.setState(patch)     → merge patch, notify subscribers on change
//   store.subscribe(fn)       → fn(state) on every change; returns unsub()
//   store.select(sel, cb)     → cb(slice) only when selected slice changes; returns unsub()

function createStore() {
  const state = {
    // lifecycle
    engine:  'ivs',
    alive:   false,

    // DVR
    dvrAvailable:  false,
    uptimeSec:     0,
    dvrBehindLive: 0,
    dvrWindowSec:  0,
    dvrQualities:  [],
    dvrQuality:    null,

    // stream metadata
    vodId:           null,
    streamStartTime: null,

    // playback
    playing:     false,
    buffering:   false,
    qualities:   [],
    quality:     null,
    autoQuality: true,
    volume:      50,
    muted:       false,
    fullscreen:  false,
    rate:        1,
    atLiveEdge:  true,

    // channel
    username:    '',
    displayName: '',
    avatar:      '',
    viewers:     null,
    title:       null,
    error:       null,
  };

  const _knownKeys = new Set(Object.keys(state));
  const _listeners = new Set();

  // Handles primitives, flat arrays, and plain objects (e.g. quality objects).
  // Without object support, quality comparisons always fail reference equality,
  // causing unnecessary subscriber re-renders on every quality-changed event.
  function shallowEqual(a, b) {
    if (a === b) return true;
    if (a == null || b == null) return false;
    if (Array.isArray(a) && Array.isArray(b))
      return a.length === b.length && a.every((v, i) => v === b[i]);
    if (typeof a === 'object' && typeof b === 'object') {
      const ka = Object.keys(a), kb = Object.keys(b);
      if (ka.length !== kb.length) return false;
      return ka.every(k => a[k] === b[k]);
    }
    return false;
  }

  function getState() { return { ...state }; }

  function setState(patch) {
    let changed = false;
    for (const k in patch) {
      if (!_knownKeys.has(k)) {
        console.warn(`[KickTiny] setState: unknown key "${k}" — typo?`);
        continue;
      }
      if (!shallowEqual(state[k], patch[k])) {
        state[k] = patch[k];
        changed = true;
      }
    }
    if (changed) _listeners.forEach(fn => fn(state));
  }

  function subscribe(fn) {
    _listeners.add(fn);
    return () => _listeners.delete(fn);
  }

  // Fires callback only when the selected slice actually changes.
  // Use this in UI components to avoid re-renders from unrelated state updates
  // (e.g. the 500ms position poll or 1s uptime ticker).
  function select(selectorFn, callback) {
    let prev = selectorFn(state);
    return subscribe(s => {
      const next = selectorFn(s);
      if (!shallowEqual(prev, next)) { prev = next; callback(next, s); }
    });
  }

  return { getState, setState, subscribe, select };
}


// ── prefs.js ──
const KEYS = {
  quality: 'kt.quality',
  volume:  'kt.volume',
};

function loadPrefs() {
  return {
    quality: localStorage.getItem(KEYS.quality) || null,
    volume:  localStorage.getItem(KEYS.volume) !== null
               ? Number(localStorage.getItem(KEYS.volume)) : null,
  };
}

function savePrefs(patch) {
  if ('quality' in patch) {
    if (patch.quality === null) localStorage.removeItem(KEYS.quality);
    else localStorage.setItem(KEYS.quality, patch.quality);
  }
  if ('volume' in patch) {
    localStorage.setItem(KEYS.volume, String(patch.volume));
  }
}


// ── api.js ──
const BASE = 'https://kick.com';

async function get(path) {
  const res = await fetch(BASE + path, {
    credentials: 'omit',
    headers: { 'Accept': 'application/json' },
  });
  if (!res.ok) throw new Error(`${res.status} ${path}`);
  return res.json();
}

async function fetchChannelInfo(username) {
  return get(`/api/v2/channels/${username}/info`);
}

async function fetchChannelInit(username) {
  try {
    const data = await fetchChannelInfo(username);
    const ls = data?.livestream ?? null;
    return {
      isLive:       ls?.is_live === true,
      displayName:  data?.user?.username    ?? null,
      avatar:       data?.user?.profile_pic ?? null,
      vodId:        ls?.vod_id              ?? null,
      livestreamId: ls?.id                  ?? null,
      viewers:      ls?.viewer_count        ?? null,
      startTime:    ls?.start_time          ?? null,
      title:        ls?.session_title       ?? null,
    };
  } catch {
    return { isLive: null, displayName: null, avatar: null, vodId: null, livestreamId: null, viewers: null, startTime: null, title: null };
  }
}

function getDeviceId() {
  const KEY = 'kt.deviceId';
  let id = localStorage.getItem(KEY);
  if (!id) {
    id = crypto.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
    localStorage.setItem(KEY, id);
  }
  return id;
}

async function fetchVodPlaybackUrl(vodId) {
  try {
    const res = await fetch(
      `https://web.kick.com/api/v1/stream/${encodeURIComponent(vodId)}/playback`,
      {
        method: 'POST',
        credentials: 'include',
        headers: {
          'Accept':       'application/json',
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          video_player: {
            player: {
              player_name:             'web',
              player_version:          'web_7a224cf6',
              player_software:         'IVS Player',
              player_software_version: '1.49.0',
            },
            mux_sdk:        { sdk_available: false },
            datazoom_sdk:   { sdk_available: false },
            google_ads_sdk: { sdk_available: false },
          },
          video_session: {
            page_type:              'channel',
            player_remote_played:   false,
            viewer_connection_type: '',
            enable_sampling:        false,
          },
          user_session: {
            player_device_id:               getDeviceId(),
            player_resettable_id:           '',
            player_resettable_consent_type: '',
          },
        }),
      },
    );
    if (!res.ok) throw new Error(`${res.status}`);
    const data = await res.json();
    const dvr = data?.playback_url?.vod ?? null;
    if (!dvr) throw new Error('vod field missing from response');
    return dvr;
  } catch (e) {
    console.warn('[KickTiny DVR] fetchVodPlaybackUrl failed:', e.message);
    return null;
  }
}

// ── constants.js ──
// ── constants.js ──────────────────────────────────────────────────────────────
// Single source of truth for every magic number in KickTiny.
// Import this module wherever a numeric literal would otherwise appear.

// ── live-edge thresholds ──────────────────────────────────────────────────────
/** IVS live-latency (seconds) below which we consider playback at the live edge */
const LIVE_EDGE_LATENCY_SEC    = 3.5;
/** DVR behindLive (seconds) below which we consider playback at the live edge */
const LIVE_EDGE_BEHIND_SEC     = 30;

// ── UI timers ─────────────────────────────────────────────────────────────────
/** Milliseconds before the control bar auto-hides after the mouse stops moving */
const CONTROLS_HIDE_DELAY_MS   = 3_000;
/** Milliseconds after the mouse leaves the bar before it fades */
const CONTROLS_LEAVE_DELAY_MS  = 500;
/** Milliseconds between clicks to count as a double-click (fullscreen toggle) */
const DOUBLE_CLICK_WINDOW_MS   = 250;
/** Milliseconds between channel-info poll requests */
const POLL_INTERVAL_MS         = 60_000;
/** Milliseconds to debounce saving volume to localStorage */
const VOLUME_SAVE_DEBOUNCE_MS  = 300;

// ── IVS adapter ───────────────────────────────────────────────────────────────
/** Milliseconds between retries when searching for the IVS player in the React tree */
const ADAPTER_RETRY_INTERVAL_MS = 500;
/** Maximum number of IVS player extraction retries before giving up */
const ADAPTER_MAX_RETRIES       = 40;
/** Milliseconds between live-latency samples (used for atLiveEdge updates) */
const LATENCY_POLL_INTERVAL_MS  = 1_000;

// ── DVR controller ────────────────────────────────────────────────────────────
/** Maximum milliseconds to wait for the HLS seekable window to become available */
const SEEKABLE_WAIT_MS          = 8_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 */
const FALLBACK_REFRESH_MS       = 50 * 60_000;
/** Milliseconds between catch-up segment extrapolation attempts */
const CATCH_UP_INTERVAL_MS      = 12_500;
/** Seconds from the seekable end at which catch-up mode activates */
const NEAR_END_THRESHOLD_SEC    = 60;
/** Milliseconds between DVR position-poll ticks */
const POSITION_POLL_INTERVAL_MS = 500;

// ── error recovery ────────────────────────────────────────────────────────────
/** IVS recoverable-error codes that trigger a full page reload */
const RECONNECT_CODES           = new Set([-2, -3]);
/** Maximum times we re-apply a saved quality preference before giving up */
const MAX_REAPPLY_ATTEMPTS      = 3;
/** Maximum page-reload attempts for transient IVS errors before giving up */
const MAX_RELOAD_ATTEMPTS       = 3;


// ── engines/ivs-engine.js ──
// ── engines/ivs-engine.js ────────────────────────────────────────────────────
// IVS player adapter. Extracted from adapter.js.
// Receives the store and prefs as constructor parameters — no global imports.
//
// Usage:
//   const ivs = createIvsEngine(store, prefs);
//   ivs.init();
//   ivs.play(); ivs.setVolume(80); ...
//   ivs.destroy();


const EV = {
  STATE_CHANGED:         'PlayerStateChanged',
  QUALITY_CHANGED:       'PlayerQualityChanged',
  VOLUME_CHANGED:        'PlayerVolumeChanged',
  MUTED_CHANGED:         'PlayerMutedChanged',
  PLAYBACK_RATE_CHANGED: 'PlayerPlaybackRateChanged',
  ERROR:                 'PlayerError',
  RECOVERABLE_ERROR:     'PlayerRecoverableError',
};
const PS = { PLAYING: 'Playing', BUFFERING: 'Buffering' };

function createIvsEngine(store, prefs) {
  let _player      = null;
  let _boundPlayer = null;
  let _retryTimer  = null;
  let _latencyTimer = null;

  // ── extraction ─────────────────────────────────────────────────────────────

  function init() {
    clearTimeout(_retryTimer);
    _tryExtract(0);
  }

  function _tryExtract(attempt) {
    const p = _extractPlayer();
    if (p) { _player = p; _onPlayerReady(); return; }
    if (attempt < ADAPTER_MAX_RETRIES) {
      _retryTimer = setTimeout(() => _tryExtract(attempt + 1), ADAPTER_RETRY_INTERVAL_MS);
    } else {
      console.warn('[KickTiny] Could not find IVS player after', ADAPTER_MAX_RETRIES, 'attempts');
    }
  }

  function _extractPlayer() {
    try {
      const video = document.querySelector('video');
      if (!video) return null;
      const fiberKey = Object.keys(video).find(k => k.startsWith('__reactFiber'));
      if (!fiberKey) return null;
      return _walkFiber(video[fiberKey]);
    } catch (e) {
      // The fiber walk can throw on partially-constructed React trees — recoverable.
      console.warn('[KickTiny] extractPlayer error (will retry):', e.message);
    }
    return null;
  }

  function _walkFiber(fiber) {
    const isPlayer = v =>
      v && typeof v === 'object' &&
      typeof v.getState === 'function' &&
      typeof v.getQualities === 'function' &&
      typeof v.setQuality === 'function' &&
      typeof v.getVolume === 'function' &&
      typeof v.setVolume === 'function' &&
      typeof v.addEventListener === 'function';

    const seen = new Set();

    function walkHooks(node) {
      let s = node?.memoizedState;
      while (s) {
        const val = s.memoizedState;
        if (isPlayer(val)) return val;
        if (val && typeof val === 'object' && isPlayer(val.current)) return val.current;
        if (val && typeof val === 'object') {
          try {
            for (const v of Object.values(val)) {
              if (isPlayer(v)) return v;
              if (v && typeof v === 'object' && isPlayer(v?.current)) return v.current;
            }
          } catch (_) {}
        }
        s = s.next;
      }
      return null;
    }

    function walk(node, depth) {
      if (!node || depth > 50 || seen.has(node)) return null;
      seen.add(node);
      if (isPlayer(node.stateNode)) return node.stateNode;
      const h = walkHooks(node);
      if (h) return h;
      return walk(node.return, depth + 1)
          || walk(node.child, depth + 1)
          || walk(node.sibling, depth + 1);
    }

    return walk(fiber, 0);
  }

  // ── player ready ───────────────────────────────────────────────────────────

  function _onPlayerReady() {
    const p = _player;
    if (!p || _boundPlayer === p) return;
    _boundPlayer = p;

    const savedPrefs = prefs.load();
    const vol = savedPrefs.volume !== null ? savedPrefs.volume : Math.round(p.getVolume() * 100);

    store.setState({
      alive:       true,
      playing:     p.getState() === PS.PLAYING,
      buffering:   p.getState() === PS.BUFFERING,
      qualities:   p.getQualities() || [],
      quality:     p.getQuality(),
      autoQuality: p.isAutoQualityMode(),
      volume:      vol,
      muted:       p.isMuted(),
      rate:        p.getPlaybackRate(),
    });

    if (savedPrefs.volume !== null) p.setVolume(savedPrefs.volume / 100);
    let qualityApplied = false;
    if (savedPrefs.quality !== null) qualityApplied = _applyQualityPref(p, savedPrefs.quality);

    let _reapplying = false;
    let _reapplyAttempts = 0;

    p.addEventListener(EV.STATE_CHANGED, e => {
      const s = store.getState();
      if (s.engine !== 'ivs') return;
      const ps        = e?.state ?? e;
      const buffering = ps === PS.BUFFERING;
      const playing   = ps === PS.PLAYING;

      if (playing) {
        sessionStorage.removeItem('kt.reloads');
      }

      store.setState({ playing, buffering });
    });

    p.addEventListener(EV.QUALITY_CHANGED, e => {
      if (store.getState().engine !== 'ivs') return;
      const q  = e?.name ? e : (e?.quality ?? null);
      const qs = p.getQualities();
      if (qs?.length) store.setState({ qualities: qs });

      if (!qualityApplied && savedPrefs.quality !== null && qs?.length) {
        qualityApplied = _applyQualityPref(p, savedPrefs.quality);
        if (qualityApplied) return;
      }

      const savedName = prefs.load().quality;
      const st        = store.getState();

      if (!st.autoQuality && savedName && q?.name !== savedName) {
        if (_reapplyAttempts >= MAX_REAPPLY_ATTEMPTS) {
          _reapplying = false; _reapplyAttempts = 0;
          store.setState({ quality: q, autoQuality: st.autoQuality });
          return;
        }
        if (!_reapplying) {
          const all   = qs || st.qualities;
          const match = all.find(x => x.name === savedName)
            || all.find(x => x.name.replace(/\d+$/, '') === savedName.replace(/\d+$/, ''));
          if (match) {
            _reapplying = true; _reapplyAttempts++;
            p.setAutoQualityMode(false); p.setQuality(match);
          } else {
            _reapplying = false; _reapplyAttempts = 0;
            store.setState({ quality: q, autoQuality: st.autoQuality });
          }
        }
        return;
      }

      _reapplying = false; _reapplyAttempts = 0;
      store.setState({ quality: q, autoQuality: st.autoQuality });
    });

    p.addEventListener(EV.VOLUME_CHANGED, e => {
      if (store.getState().engine !== 'ivs') return;
      const vol = typeof e === 'number' ? e : (e?.volume ?? p.getVolume());
      store.setState({ volume: Math.round(vol * 100) });
    });

    p.addEventListener(EV.MUTED_CHANGED, e => {
      if (store.getState().engine !== 'ivs') return;
      store.setState({ muted: typeof e === 'boolean' ? e : (e?.muted ?? p.isMuted()) });
    });

    p.addEventListener(EV.PLAYBACK_RATE_CHANGED, e => {
      if (store.getState().engine !== 'ivs') return;
      store.setState({ rate: typeof e === 'number' ? e : (e?.playbackRate ?? p.getPlaybackRate()) });
    });

    p.addEventListener(EV.ERROR, err => {
      if (store.getState().engine !== 'ivs') return;
      store.setState({ error: err });
      console.error('[KickTiny] IVS Error:', err);
      if (err?.type === 'ErrorInvalidData' && err?.source === 'MediaPlaylist') {
        console.warn('[KickTiny] Bad M3U8 — attempting recovery play()');
        setTimeout(() => {
          try { p.play(); } catch (_) {
            console.warn('[KickTiny] Recovery failed — reloading page');
            window.location.reload();
          }
        }, 1500);
      }
    });

    p.addEventListener(EV.RECOVERABLE_ERROR, err => {
      const code = err?.code ?? null;
      if (RECONNECT_CODES.has(code)) {
        const key   = 'kt.reloads';
        const count = Number(sessionStorage.getItem(key) || 0);
        if (count >= MAX_RELOAD_ATTEMPTS) {
          console.error('[KickTiny] Too many reload attempts, giving up.');
          sessionStorage.removeItem(key);
          return;
        }
        sessionStorage.setItem(key, String(count + 1));
        console.warn('[KickTiny] IVS fatal worker error, reloading... (attempt', count + 1, 'of', MAX_RELOAD_ATTEMPTS, ')');
        setTimeout(() => window.location.reload(), 2000);
      }
    });

    document.addEventListener('fullscreenchange', () => {
      store.setState({ fullscreen: !!document.fullscreenElement });
    });

    // Fallback quality init after 2s (IVS may not have fired QUALITY_CHANGED yet)
    setTimeout(() => {
      const qs = p.getQualities();
      if (qs?.length) {
        if (!store.getState().qualities.length) store.setState({ qualities: qs });
        if (!qualityApplied && savedPrefs.quality !== null) qualityApplied = _applyQualityPref(p, savedPrefs.quality);
      }
    }, 2000);

    clearInterval(_latencyTimer);
    _latencyTimer = setInterval(() => {
      if (store.getState().engine !== 'ivs') return;
      try {
        const latency = p.getLiveLatency?.();
        if (latency == null || !isFinite(latency)) return;
        store.setState({ atLiveEdge: latency <= LIVE_EDGE_LATENCY_SEC });
      } catch (_) {}
    }, LATENCY_POLL_INTERVAL_MS);

    console.log('[KickTiny] Adapter ready. IVS player attached.');
  }

  function _applyQualityPref(p, savedName) {
    const qs = p.getQualities();
    if (!qs?.length) return false;
    const stripped = savedName.replace(/\d+$/, '');
    const match    = qs.find(q => q.name === savedName)
      || qs.find(q => q.name.replace(/\d+$/, '') === stripped);
    if (match) {
      p.setAutoQualityMode(false);
      p.setQuality(match);
      store.setState({ autoQuality: false, quality: match });
      return true;
    }
    return false;
  }

  // ── PlaybackEngine interface ───────────────────────────────────────────────

  function play()  { _player?.play(); }
  function pause() { _player?.pause(); }

  function setVolume(pct) {
    if (!_player) return;
    _player.setVolume(pct / 100);
    if (pct > 0 && _player.isMuted()) _player.setMuted(false);
  }

  function setMuted(m)  { _player?.setMuted(m); }
  function setRate(r)   { _player?.setPlaybackRate(r); }

  function setQuality(q) {
    if (!_player) return;
    if (q === 'auto') {
      _player.setAutoQualityMode(true);
      store.setState({ autoQuality: true, quality: null });
      prefs.save({ quality: null });
    } else {
      _player.setAutoQualityMode(false);
      _player.setQuality(q);
      store.setState({ autoQuality: false, quality: q });
      prefs.save({ quality: q.name });
    }
  }

  function seekToLive() {
    if (!_player) return;
    _player.setPlaybackRate(2);
    const check = setInterval(() => {
      const lat = _player.getLiveLatency?.();
      if (lat == null || !isFinite(lat) || lat <= LIVE_EDGE_LATENCY_SEC) {
        _player.setPlaybackRate(1);
        clearInterval(check);
      }
    }, 250);
  }

  /** Escape hatch for engine-manager to restore state after DVR→IVS transition. */
  function getRawPlayer() { return _player; }

  function destroy() {
    clearTimeout(_retryTimer);
    clearInterval(_latencyTimer);
    _player = null; _boundPlayer = null;
  }

  return { init, destroy, play, pause, setVolume, setMuted, setRate, setQuality, seekToLive, getRawPlayer };
}


// ── engines/manifest-builder.js ──
// ── engines/manifest-builder.js ──────────────────────────────────────────────
// Owns the segment array and all synthetic HLS manifest logic.
// Pure module — no store dependency, no side-effects.
// The DVR engine owns a single instance and delegates all manifest work here.


const SYNTHETIC_URL = 'https://kt.local/dvr.m3u8';

function createManifestBuilder() {
  let _segments       = [];
  let _lastSegUrl     = '';
  let _targetDuration = 10;

  // ── public API ─────────────────────────────────────────────────────────────

  function reset() {
    _segments   = [];
    _lastSegUrl = '';
  }

  function generate() {
    let out = _buildHeader();
    for (const seg of _segments) {
      if (seg.discontinuity) out += '#EXT-X-DISCONTINUITY\n';
      if (seg.pdt)           out += seg.pdt + '\n';
      out += seg.duration + '\n';
      out += seg.url + '\n';
    }
    return out;
  }

  // Merge incoming playlist text into the segment array.
  // Returns the number of new segments added (0 = nothing new).
  function merge(text, baseUrl) {
    const cleaned  = text.replace(/#EXT-X-ENDLIST.*/g, '');
    const incoming = _parse(cleaned, baseUrl);
    if (!incoming.length) return 0;

    let startIdx = 0;
    if (_lastSegUrl) {
      let overlapIdx = -1;
      for (let i = incoming.length - 1; i >= 0; i--) {
        if (incoming[i].url === _lastSegUrl) {
          overlapIdx = i;
          break;
        }
      }
      startIdx = overlapIdx >= 0 ? overlapIdx + 1 : 0;
    }

    const newSegs = incoming.slice(startIdx);
    if (!newSegs.length) return 0;

    _segments.push(...newSegs);
    _lastSegUrl = _segments[_segments.length - 1].url;
    console.log('[KickTiny DVR] Merged', newSegs.length, 'new segments, total:', _segments.length,
      '\n  tail:', _lastSegUrl.split('/').slice(-1)[0]);
    return newSegs.length;
  }

  // Append the next predicted segment by incrementing the last URL's sequence number.
  // Zero network requests — used during catch-up mode.
  function extrapolate() {
    if (!_segments.length) return false;
    const last  = _segments[_segments.length - 1];
    const match = last.url.match(/^(.*\/)(\d+)\.ts$/);
    if (!match) {
      console.warn('[KickTiny DVR] Cannot extrapolate — URL pattern not recognised');
      return false;
    }

    const url = `${match[1]}${parseInt(match[2], 10) + 1}.ts`;
    let pdt = null;
    if (last.pdt) {
      const m = last.pdt.match(/^#EXT-X-PROGRAM-DATE-TIME:(.+)$/);
      if (m) {
        const nextMs = new Date(m[1]).getTime() + _targetDuration * 1000;
        pdt = `#EXT-X-PROGRAM-DATE-TIME:${new Date(nextMs).toISOString()}`;
      }
    }
    _segments.push({ duration: last.duration, url, pdt, discontinuity: false });
    _lastSegUrl = url;
    console.log('[KickTiny DVR] Extrapolated next segment:', url.split('/').slice(-1)[0]);
    return true;
  }

  // Pick the best variant URL from a multivariant playlist, optionally honouring
  // a preferred quality name (falls back to middle-bandwidth variant).
  function pickVariant(text, baseUrl, preferredName) {
    const lines   = text.split('\n');
    const streams = [];
    for (let i = 0; i < lines.length; i++) {
      const t = lines[i].trim();
      if (!t.startsWith('#EXT-X-STREAM-INF')) continue;
      const res  = t.match(/RESOLUTION=\d+x(\d+)/);
      const bw   = t.match(/BANDWIDTH=(\d+)/);
      const name = t.match(/VIDEO="([^"]+)"/);
      const url  = lines[i + 1]?.trim();
      if (!url || url.startsWith('#')) continue;
      streams.push({
        url:       url.startsWith('http') ? url : new URL(url, baseUrl).href,
        height:    res  ? parseInt(res[1], 10)  : 0,
        bandwidth: bw   ? parseInt(bw[1], 10)   : 0,
        name:      name ? name[1]           : '',
      });
    }
    if (!streams.length) return baseUrl;

    if (preferredName) {
      let m = streams.find(s => s.name === preferredName);
      if (!m) {
        const stripped = preferredName.replace(/\d+$/, '');
        m = streams.find(s => s.name.replace(/\d+$/, '') === stripped);
      }
      if (m) { console.log('[KickTiny DVR] Picked variant:', m.name); return m.url; }
    }

    const sorted = [...streams].sort((a, b) => b.bandwidth - a.bandwidth);
    const pick   = sorted[Math.floor(sorted.length / 2)] ?? sorted[0];
    console.log('[KickTiny DVR] No quality match, picking middle variant:', pick.name);
    return pick.url;
  }

  // Parse quality options from a multivariant playlist. Returns sorted array.
  function parseQualities(text) {
    const lines   = text.split('\n');
    const streams = [];
    for (let i = 0; i < lines.length; i++) {
      const t = lines[i].trim();
      if (!t.startsWith('#EXT-X-STREAM-INF')) continue;
      const name = t.match(/VIDEO="([^"]+)"/);
      const bw   = t.match(/BANDWIDTH=(\d+)/);
      if (name) streams.push({ name: name[1], index: streams.length, bandwidth: bw ? parseInt(bw[1], 10) : 0 });
    }
    if (!streams.length) return [];
    streams.sort((a, b) => b.bandwidth - a.bandwidth);
    streams.forEach((s, i) => { s.index = i; });
    return streams;
  }

  // True when playback is close enough to the seekable end to warrant extrapolation.
  function nearEnd(currentTime, seekableEnd) {
    return isFinite(seekableEnd) && (seekableEnd - currentTime) < NEAR_END_THRESHOLD_SEC;
  }

  // ── getters ────────────────────────────────────────────────────────────────

  function segmentCount()   { return _segments.length; }
  function getLastSegUrl()  { return _lastSegUrl; }
  function targetDuration() { return _targetDuration; }

  // ── private ────────────────────────────────────────────────────────────────

  function _buildHeader() {
    return [
      '#EXTM3U',
      '#EXT-X-VERSION:3',
      '#EXT-X-PLAYLIST-TYPE:EVENT',
      `#EXT-X-TARGETDURATION:${_targetDuration}`,
      '#EXT-X-MEDIA-SEQUENCE:0',
    ].join('\n') + '\n';
  }

  function _parse(text, baseUrl) {
    const lines  = text.split('\n');
    const result = [];
    let duration = null, pdt = null, discontinuity = false;
    for (const line of lines) {
      const t = line.trim();
      if (t.startsWith('#EXT-X-TARGETDURATION:')) { _targetDuration = parseInt(t.split(':')[1], 10) || _targetDuration; continue; }
      if (t === '#EXT-X-DISCONTINUITY')            { discontinuity = true; continue; }
      if (t.startsWith('#EXT-X-PROGRAM-DATE-TIME:')){ pdt = t; continue; }
      if (t.startsWith('#EXTINF:'))                 { duration = t; continue; }
      if (duration && t && !t.startsWith('#')) {
        const url = t.startsWith('http') ? t : new URL(t, baseUrl).href;
        result.push({ duration, url, pdt, discontinuity });
        duration = null; pdt = null; discontinuity = false;
      }
    }
    return result;
  }

  return {
    reset, generate, merge, extrapolate,
    pickVariant, parseQualities, nearEnd,
    segmentCount, getLastSegUrl, targetDuration,
  };
}


// ── engines/dvr-engine.js ──
// ── engines/dvr-engine.js ────────────────────────────────────────────────────
// HLS.js DVR controller. Extracted from dvr/controller.js.
// Receives store and api as constructor parameters — no global imports.
//
// Usage:
//   const dvr = createDvrEngine(store, api);
//   await dvr.setupContainer(container);
//   await dvr.enter(behindSec);
//   dvr.seekToBehindLive(60);
//   dvr.exit();
//   dvr.destroy();


function createDvrEngine(store, api) {
  let _Hls          = null;
  let _hls          = null;
  let _dvrVideo     = null;
  let _nativeVideo  = null;
  let _posTimer     = null;
  let _expiryTimer  = null;
  let _catchUpTimer = null;
  let _refreshing   = false;
  let _manifestOffset = 0;

  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) ───────────────────────

  function _buildCustomLoader(DefaultLoader) {
    return class SyntheticLoader extends DefaultLoader {
      load(context, config, callbacks) {
        if (context.url === SYNTHETIC_URL) {
          const data = _mb.generate();
          const now  = performance.now();
          setTimeout(() => callbacks.onSuccess(
            { data, url: SYNTHETIC_URL },
            {
              aborted: false, loaded: data.length, total: data.length, retry: 0,
              trequest: now, tfirst: now, tload: now, chunkCount: 0, bwEstimate: Infinity,
              loading: { start: now, first: now, end: now },
              parsing: { start: now, end: now },
              buffering: { start: now, first: now, end: now },
            },
            context
          ), 0);
          return;
        }
        super.load(context, config, callbacks);
      }
      abort() {}
    };
  }

  function _createHlsInstance() {
    if (_hls) { _hls.destroy(); _hls = null; }
    _hls = new _Hls({
      loader:                  _buildCustomLoader(_Hls.DefaultConfig.loader),
      liveDurationInfinity:    true,
      backBufferLength:        Infinity,
      enableWorker:            true,
      lowLatencyMode:          false,
      autoStartLoad:           true,
      manifestLoadingTimeOut:  5000,
      manifestLoadingMaxRetry: 2,
    });
    _hls.loadSource(SYNTHETIC_URL);
    _hls.attachMedia(_dvrVideo);
    _hls.on(_Hls.Events.MANIFEST_PARSED, (_, data) => {
      console.log('[KickTiny DVR] Manifest parsed —', data.levels.length, 'level(s),', _mb.segmentCount(), 'segments');
      store.setState({ dvrAvailable: true });
    });
    _hls.on(_Hls.Events.ERROR, (_, data) => {
      if (!data.fatal) return;
      console.error('[KickTiny DVR] Fatal error:', data.details);
      _hls.recoverMediaError();
    });
  }

  function _destroyHls() {
    if (_hls) { _hls.destroy(); _hls = null; }
  }

  // ── snapshot fetch ─────────────────────────────────────────────────────────

  async function _fetchAndMergeSnapshot(snapshotUrl) {
    try {
      const res  = await fetch(snapshotUrl);
      if (!res.ok) throw new Error(`snapshot ${res.status}`);
      const text = await res.text();

      if (text.includes('#EXT-X-STREAM-INF')) {
        const qualities = _mb.parseQualities(text);
        if (qualities.length) store.setState({ dvrQualities: qualities });

        const s          = store.getState();
        const variantUrl = _mb.pickVariant(text, snapshotUrl, s.quality?.name ?? null);
        const varRes     = await fetch(variantUrl);
        if (!varRes.ok) throw new Error(`variant playlist ${varRes.status}`);
        return _mb.merge(await varRes.text(), variantUrl);
      }
      return _mb.merge(text, snapshotUrl);
    } catch (e) {
      console.warn('[KickTiny DVR] Snapshot fetch failed:', e.message);
      return 0;
    }
  }

  // ── 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 };
      }
      await new Promise(r => setTimeout(r, 100));
    }
    return null;
  }

  function _getSeekableWindow() {
    if (!_dvrVideo?.seekable?.length) return null;
    const i = _dvrVideo.seekable.length - 1;
    return { start: _dvrVideo.seekable.start(i), end: _dvrVideo.seekable.end(i) };
  }

  // ── JWT expiry ─────────────────────────────────────────────────────────────

  function _getTokenExpiryMs(url) {
    try {
      const jwt   = new URL(url).searchParams.get('init');
      if (!jwt) return null;
      const parts = jwt.split('.');
      if (parts.length < 2) return null;
      let b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
      while (b64.length % 4) b64 += '=';
      const payload = JSON.parse(atob(b64));
      return payload?.exp ? payload.exp * 1000 : null;
    } catch { return null; }
  }

  function _scheduleExpiryRefresh(url) {
    clearTimeout(_expiryTimer);
    const expMs = _getTokenExpiryMs(url);
    const delay = expMs
      ? Math.max(5000, expMs - Date.now() - EXPIRY_LEAD_MS)
      : FALLBACK_REFRESH_MS;
    if (expMs) console.log('[KickTiny DVR] Token expires in', Math.round((expMs - Date.now()) / 1000), 's — refresh in', Math.round(delay / 1000), 's');
    _expiryTimer = setTimeout(() => {
      if (store.getState().engine === 'dvr' && !_refreshing) _fetchAndExtendManifest();
    }, delay);
  }

  async function _fetchAndExtendManifest() {
    if (_refreshing || !store.getState().vodId) return;
    _refreshing = true;
    console.log('[KickTiny DVR] Fetching fresh VOD URL (expiry refresh)');
    const newUrl = await api.fetchVodPlaybackUrl(store.getState().vodId);
    if (newUrl) { await _fetchAndMergeSnapshot(newUrl); _scheduleExpiryRefresh(newUrl); }
    _refreshing = false;
  }

  // ── catch-up timer (segment extrapolation) ─────────────────────────────────

  function _startCatchUpTimer() {
    if (_catchUpTimer) return;
    console.log('[KickTiny DVR] Entering catch-up mode (extrapolation)');
    _mb.extrapolate();
    _catchUpTimer = setInterval(() => {
      if (store.getState().engine !== 'dvr') { _stopCatchUpTimer(); return; }
      const win = _getSeekableWindow();
      if (win && _mb.nearEnd(_dvrVideo.currentTime, win.end)) _mb.extrapolate();
    }, CATCH_UP_INTERVAL_MS);
  }

  function _stopCatchUpTimer() {
    if (!_catchUpTimer) return;
    clearInterval(_catchUpTimer); _catchUpTimer = null;
    console.log('[KickTiny DVR] Exiting catch-up mode');
  }

  // ── position poll ──────────────────────────────────────────────────────────

  function _startPositionPoll() {
    _stopPositionPoll();
    _posTimer = setInterval(() => {
      if (!_dvrVideo || store.getState().engine !== 'dvr') { _stopPositionPoll(); return; }
      const win            = _getSeekableWindow();
      const manifestOffset = win ? Math.max(0, store.getState().uptimeSec - win.end) : _manifestOffset;
      const behindLive     = win ? Math.max(0, (win.end - _dvrVideo.currentTime) + manifestOffset) : 0;
      const windowSec      = win ? Math.max(0, win.end - win.start) : 0;

      store.setState({ dvrBehindLive: behindLive, dvrWindowSec: windowSec, atLiveEdge: behindLive <= LIVE_EDGE_BEHIND_SEC });

      if (win) {
        const secsFromEnd = win.end - _dvrVideo.currentTime;
        if (secsFromEnd < NEAR_END_THRESHOLD_SEC) {
          _startCatchUpTimer();
        } else if (secsFromEnd > NEAR_END_THRESHOLD_SEC * 2 && _catchUpTimer) {
          _stopCatchUpTimer();
        }
      }
    }, POSITION_POLL_INTERVAL_MS);
  }

  function _stopPositionPoll() { clearInterval(_posTimer); _posTimer = null; }

  // ── quality switch ─────────────────────────────────────────────────────────

  async function _switchVariant(q) {
    if (!store.getState().vodId) return;
    const savedPos = _dvrVideo?.currentTime ?? 0;
    const vodUrl   = await api.fetchVodPlaybackUrl(store.getState().vodId);
    if (!vodUrl) return;

    const res  = await fetch(vodUrl);
    if (!res.ok) { console.warn('[KickTiny DVR] variant manifest fetch failed:', res.status); return; }
    const text = await res.text();
    if (!text.includes('#EXT-X-STREAM-INF')) return;

    const variantUrl = _mb.pickVariant(text, vodUrl, q.name);
    if (!variantUrl || variantUrl === vodUrl) return;

    console.log('[KickTiny DVR] Switching to variant:', q.name);
    _mb.reset();
    const varRes = await fetch(variantUrl);
    if (!varRes.ok) { console.warn('[KickTiny DVR] variant fetch failed:', varRes.status); return; }
    _mb.merge(await varRes.text(), variantUrl);
    _scheduleExpiryRefresh(vodUrl);
    _destroyHls(); _createHlsInstance();

    const onReady = () => { _dvrVideo.currentTime = savedPos; _dvrVideo.play().catch(() => {}); };
    if (_dvrVideo.readyState >= 1) onReady();
    else _dvrVideo.addEventListener('loadedmetadata', onReady, { once: true });

    store.setState({ dvrQuality: q });
  }

  // ── UI helpers ─────────────────────────────────────────────────────────────

  function _returnToLiveUi() {
    if (_dvrVideo) _dvrVideo.style.display = 'none';
    if (_nativeVideo) _nativeVideo.style.visibility = 'visible';
  }

  // ── PlaybackEngine interface ───────────────────────────────────────────────

  async function setupContainer(container) {
    if (_dvrVideo) return;
    _nativeVideo = container.querySelector('video');
    if (!_nativeVideo) { console.warn('[KickTiny DVR] No native video found'); return; }
    const cs = window.getComputedStyle(container);
    if (cs.position === 'static') container.style.position = 'relative';
    _dvrVideo = document.createElement('video');
    _dvrVideo.playsInline = true;
    _dvrVideo.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:none;z-index:2;background:#000';
    container.appendChild(_dvrVideo);
    _dvrVideo.addEventListener('playing',      () => { if (store.getState().engine === 'dvr') store.setState({ playing: true,  buffering: false }); });
    _dvrVideo.addEventListener('pause',        () => { if (store.getState().engine === 'dvr') store.setState({ playing: false }); });
    _dvrVideo.addEventListener('waiting',      () => { if (store.getState().engine === 'dvr') store.setState({ buffering: true }); });
    _dvrVideo.addEventListener('volumechange', () => {
      if (store.getState().engine === 'dvr') store.setState({ volume: Math.round(_dvrVideo.volume * 100), muted: _dvrVideo.muted });
    });
    console.log('[KickTiny DVR] Container ready');
  }

  async function enter(behindSec) {
    // Preconditions checked by engine-manager before calling
    const s         = store.getState();
    const wasVolume = s.volume;
    const wasMuted  = s.muted;
    store.setState({ buffering: true });

    if (!_Hls) {
      try { _Hls = await _loadHlsJs(); } catch (e) {
        console.warn('[KickTiny DVR] hls.js load failed:', e.message);
        store.setState({ buffering: false }); throw e;
      }
      if (!_Hls.isSupported()) {
        store.setState({ buffering: false });
        throw new Error('hls.js not supported');
      }
    }

    _nativeVideo.style.visibility = 'hidden';
    _dvrVideo.style.display  = 'block';
    _dvrVideo.volume         = wasVolume / 100;
    _dvrVideo.muted          = wasMuted;
    _dvrVideo.playbackRate   = s.rate;

    const url = await api.fetchVodPlaybackUrl(s.vodId);
    if (!url) { store.setState({ buffering: false }); throw new Error('Could not fetch VOD URL'); }

    _mb.reset();
    const appended = await _fetchAndMergeSnapshot(url);
    if (appended === 0) { store.setState({ buffering: false }); throw new Error('No segments in snapshot'); }

    _destroyHls(); _createHlsInstance();

    const win = await _waitForSeekable();
    if (!win) { store.setState({ buffering: false }); throw new Error('Seekable window never available'); }

    _manifestOffset = Math.max(0, s.uptimeSec - win.end);
    const target = Math.max(0, Math.min(win.end - 1, win.end - (behindSec - _manifestOffset)));
    console.log('[KickTiny DVR] Seekable', win.start.toFixed(1), '–', win.end.toFixed(1),
      '| offset', _manifestOffset.toFixed(1), '→ seeking to', target.toFixed(1));
    _dvrVideo.currentTime = target;

    const trueBehind = Math.max(0, win.end - target) + _manifestOffset;
    store.setState({
      engine:        'dvr',
      buffering:     false,
      dvrAvailable:  true,
      dvrWindowSec:  Math.max(0, win.end - win.start),
      dvrBehindLive: trueBehind,
      atLiveEdge:    trueBehind <= LIVE_EDGE_BEHIND_SEC,
    });

    _startPositionPoll();
    _scheduleExpiryRefresh(url);
    _dvrVideo.play().catch(() => {});

    // Match IVS quality selection if possible
    const st = store.getState();
    if (st.quality !== null && st.dvrQualities?.length) {
      const match = st.dvrQualities.find(q => q.name === st.quality?.name)
        || st.dvrQualities.find(q => q.name.replace(/\d+$/, '') === st.quality?.name.replace(/\d+$/, ''));
      if (match) store.setState({ dvrQuality: match });
    }

    console.log('[KickTiny DVR] DVR mode active');
  }

  function exit() {
    if (!_dvrVideo || !_nativeVideo) return;
    _dvrVideo.pause();
    _destroyHls();
    _returnToLiveUi();
    clearTimeout(_expiryTimer); _expiryTimer = null;
    _stopPositionPoll();
    _stopCatchUpTimer();
    _manifestOffset = 0;
    store.setState({ engine: 'ivs', atLiveEdge: true, dvrBehindLive: 0, dvrWindowSec: 0, buffering: false });
    console.log('[KickTiny DVR] Exited DVR mode');
  }

  function play()  { _dvrVideo?.play().catch(() => {}); }
  function pause() { _dvrVideo?.pause(); }

  function setVolume(pct) {
    if (!_dvrVideo) return;
    _dvrVideo.volume = pct / 100;
    if (pct > 0) _dvrVideo.muted = false;
    store.setState({ volume: pct, muted: _dvrVideo.muted });
  }

  function setMuted(m) {
    if (!_dvrVideo) return;
    _dvrVideo.muted = m;
    store.setState({ muted: m });
  }

  function setRate(r) {
    if (!_dvrVideo) return;
    _dvrVideo.playbackRate = r;
    store.setState({ rate: r });
  }

  function setQuality(q) {
    if (!_hls) return;
    if (q === 'auto') {
      const qs  = store.getState().dvrQualities || [];
      const mid = qs[Math.floor(qs.length / 2)];
      if (mid) _switchVariant(mid);
      store.setState({ dvrQuality: null });
    } else {
      const target = typeof q === 'object' ? q : (store.getState().dvrQualities?.find(x => x.index === q));
      if (target) _switchVariant(target);
    }
  }

  function seekToBehindLive(behindSec) {
    if (!_dvrVideo) return;
    const win = _getSeekableWindow();
    if (!win) return;
    const manifestOffset = Math.max(0, store.getState().uptimeSec - win.end);
    const target = Math.max(0, Math.min(win.end - 1, win.end - (behindSec - manifestOffset)));
    _dvrVideo.currentTime = target;
  }

  function getVideo() { return _dvrVideo; }

  function destroy() {
    _destroyHls();
    _stopPositionPoll();
    _stopCatchUpTimer();
    clearTimeout(_expiryTimer); _expiryTimer = null;
  }

  return {
    setupContainer, enter, exit, destroy,
    play, pause, setVolume, setMuted, setRate,
    setQuality, seekToBehindLive,
    seekToLive: exit,  // unified interface alias
    getVideo,
  };
}


// ── engine-manager.js ──
// ── engine-manager.js ────────────────────────────────────────────────────────
// Coordinates the IVS and DVR engines. This is the key decoupling piece:
// it eliminates all if (inDvr()) checks from actions and UI components by
// exposing a single unified interface that delegates to whichever engine
// is currently active.
//
// Usage:
//   const engines = createEngineManager(store, prefs, api);
//   engines.init(container);
//   engines.play(); engines.setVolume(80);   // no engine awareness needed
//   engines.enterDvr(90);                    // switch to DVR 90s behind live
//   engines.exitDvr();                       // switch back to IVS live


function createEngineManager(store, prefs, api) {
  let _ivs      = null;
  let _dvr      = null;
  let _entering = false;  // race-condition guard on IVS→DVR transition

  function _active() {
    return store.getState().engine === 'dvr' ? _dvr : _ivs;
  }

  // ── Unified PlaybackEngine interface ───────────────────────────────────────
  // Actions and UI call these — they never know which engine is active.

  function play()         { _active()?.play?.(); }
  function pause()        { _active()?.pause?.(); }
  function setVolume(pct) { _active()?.setVolume?.(pct); }
  function setMuted(m)    { _active()?.setMuted?.(m); }
  function setRate(r)     { _active()?.setRate?.(r); }
  function setQuality(q)  { _active()?.setQuality?.(q); }
  function seekToLive() {
    if (store.getState().engine === 'dvr') {
      exitDvr();
    } else {
      _ivs.seekToLive();
    }
  }

  // ── DVR-specific ───────────────────────────────────────────────────────────

  async function enterDvr(behindSec) {
    if (_entering) {
      console.warn('[EngineManager] enterDvr already in progress — ignoring duplicate call');
      return;
    }
    const s = store.getState();
    if (!s.vodId) { console.warn('[EngineManager] enterDvr: no vodId'); return; }

    // If already in DVR, just seek to the requested position
    if (s.engine === 'dvr') {
      _dvr.seekToBehindLive(behindSec);
      return;
    }

    _entering = true;
    const rawPlayer = _ivs.getRawPlayer();
    const wasPlaying = s.playing;
    const wasVolume  = s.volume;
    const wasMuted   = s.muted;

    try {
      if (rawPlayer) rawPlayer.pause();
      await _dvr.enter(behindSec);
    } catch (e) {
      console.warn('[EngineManager] DVR entry failed:', e.message);
      // Restore IVS live playback on failure
      _dvr.exit();
      _restoreIvs(rawPlayer, wasPlaying, wasVolume, wasMuted);
    } finally {
      _entering = false;
    }
  }

  function exitDvr() {
    if (store.getState().engine !== 'dvr') return;
    const rawPlayer = _ivs.getRawPlayer();
    const s = store.getState();

    _dvr.exit();

    // Restore IVS quality and resume from near live edge
    if (rawPlayer) {
      rawPlayer.setVolume(s.volume / 100);
      rawPlayer.setMuted(s.muted);

      // Carry quality selection across the transition
      if (s.dvrQuality !== null && s.qualities?.length) {
        const match = s.qualities.find(q => q.name === s.dvrQuality.name)
          || s.qualities.find(q => q.name.replace(/\d+$/, '') === s.dvrQuality.name.replace(/\d+$/, ''));
        if (match) { rawPlayer.setAutoQualityMode(false); rawPlayer.setQuality(match); }
      }

      // Nudge playback head past accumulated latency
      try {
        const pos     = rawPlayer.getPosition?.() ?? 0;
        const latency = rawPlayer.getLiveLatency?.() ?? 0;
        if (isFinite(pos) && isFinite(latency) && latency > 0) rawPlayer.seekTo(pos + latency + 0.25);
      } catch (_) {}

      rawPlayer.play();
    }

    console.log('[EngineManager] Returned to IVS live');
  }

  function dvrSeekToBehindLive(sec) {
    if (store.getState().engine !== 'dvr') return;
    _dvr.seekToBehindLive(sec);
  }

  // ── lifecycle ──────────────────────────────────────────────────────────────

  async function init(container) {
    _ivs = createIvsEngine(store, prefs);
    _dvr = createDvrEngine(store, api);

    _ivs.init();

    // Pre-create the DVR video element — no URL is fetched here.
    // DVR init happens lazily when the user seeks into the past.
    await _dvr.setupContainer(container).catch(e => {
      console.warn('[EngineManager] DVR container setup failed:', e.message);
    });
  }

  function destroy() {
    _ivs?.destroy();
    _dvr?.destroy();
  }

  function isInDvr() { return store.getState().engine === 'dvr'; }

  // ── private ────────────────────────────────────────────────────────────────

  function _restoreIvs(player, wasPlaying, wasVolume, wasMuted) {
    if (!player) return;
    player.setVolume(wasVolume / 100);
    player.setMuted(!!wasMuted);
    if (wasPlaying) player.play();
  }

  return {
    // Unified interface
    play, pause, setVolume, setMuted, setRate, setQuality, seekToLive,
    // DVR transitions
    enterDvr, exitDvr, dvrSeekToBehindLive,
    // Lifecycle
    init, destroy, isInDvr,
  };
}


// ── actions.js ──
// ── actions.js ───────────────────────────────────────────────────────────────
// High-level user-intent actions. The ONLY thing UI components import.
// No if (inDvr()) checks — that complexity lives in the engine manager.
//
// Usage:
//   const actions = createActions(store, engineManager, prefs);
//   actions.togglePlay();
//   actions.setVolume(80);
//   // pass to UI: createBar(store, actions)


function createActions(store, engineManager, prefs) {

  // ── play / pause ────────────────────────────────────────────────────────────

  function play()  { engineManager.play(); }
  function pause() { engineManager.pause(); }

  function togglePlay() {
    store.getState().playing ? engineManager.pause() : engineManager.play();
  }

  // ── volume / mute ───────────────────────────────────────────────────────────

  let _volSaveTimer = null;

  function setVolume(pct) {
    const v = Math.max(0, Math.min(100, pct));
    engineManager.setVolume(v);
    clearTimeout(_volSaveTimer);
    _volSaveTimer = setTimeout(() => prefs.save({ volume: v }), VOLUME_SAVE_DEBOUNCE_MS);
  }

  function setMuted(m) { engineManager.setMuted(m); }

  function toggleMute() {
    const s = store.getState();
    if (s.muted || s.volume === 0) {
      const restore = s.volume > 0 ? s.volume : 5;
      engineManager.setVolume(restore);
      engineManager.setMuted(false);
    } else {
      engineManager.setMuted(true);
    }
  }

  // ── quality / rate ──────────────────────────────────────────────────────────

  function setQuality(q) { engineManager.setQuality(q); }

  function setRate(r) { engineManager.setRate(Math.max(0.25, Math.min(2, r))); }

  // ── live edge ───────────────────────────────────────────────────────────────

  function seekToLive() { engineManager.seekToLive(); }

  // ── DVR ─────────────────────────────────────────────────────────────────────

  function enterDvr(sec)            { engineManager.enterDvr(sec); }
  function dvrSeekToBehindLive(sec) { engineManager.dvrSeekToBehindLive(sec); }

  // ── fullscreen ──────────────────────────────────────────────────────────────

  function toggleFullscreen() {
    const container = document.querySelector('.aspect-video-responsive')
      || document.querySelector('div[class*="aspect-video"]')
      || document.body;
    if (!document.fullscreenElement) {
      container.requestFullscreen?.()?.catch(() => {});
    } else {
      document.exitFullscreen?.();
    }
  }

  // ── keyboard bindings ───────────────────────────────────────────────────────
  // Bound once in main.js via actions.bindKeys().

  let _keysBound = false;
  function bindKeys() {
    if (_keysBound) return;
    _keysBound = true;
    document.addEventListener('keydown', e => {
      if (['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)) return;
      if (e.ctrlKey || e.metaKey || e.altKey) return;
      const s = store.getState();
      switch (e.key) {
        case ' ':
        case 'k': e.preventDefault(); togglePlay(); break;
        case 'm': toggleMute(); break;
        case 'ArrowUp':   e.preventDefault(); setVolume(s.volume + 5); break;
        case 'ArrowDown': e.preventDefault(); setVolume(s.volume - 5); break;
        case 'ArrowLeft':
          e.preventDefault();
          if (s.engine === 'dvr') {
            dvrSeekToBehindLive(s.dvrBehindLive + 10);
          } else if (s.vodId) {
            enterDvr(60);
          }
          break;
        case 'ArrowRight':
          e.preventDefault();
          if (s.engine === 'dvr') {
            const next = Math.max(0, s.dvrBehindLive - 10);
            if (next <= LIVE_EDGE_BEHIND_SEC) seekToLive();
            else dvrSeekToBehindLive(next);
          }
          break;
        case 'f': toggleFullscreen(); break;
        case 'l': seekToLive(); break;
      }
    });
  }

  return {
    play, pause, togglePlay,
    setVolume, setMuted, toggleMute,
    setQuality, setRate,
    seekToLive, enterDvr, dvrSeekToBehindLive,
    toggleFullscreen, bindKeys,
    DOUBLE_CLICK_WINDOW_MS, // exposed so main.js click handler can read it
  };
}


// ── services/viewer-interceptor.js ──
// ── services/viewer-interceptor.js ───────────────────────────────────────────
// Intercepts Kick's own current-viewers fetches so we can read viewer counts
// with zero extra network requests. Isolated here so the side-effect of
// monkey-patching window.fetch is explicit and contained.
//
// Usage:
//   const viewer = createViewerInterceptor();
//   const unsub  = viewer.onViewerCount(count => setState({ viewers: count }));
//   unsub(); // stop listening

function createViewerInterceptor() {
  const _callbacks = new Set();

  const _origFetch = window.fetch;
  window.fetch = async function (...args) {
    const url = typeof args[0] === 'string' ? args[0] : args[0]?.url ?? '';
    const res = await _origFetch.apply(this, args);

    if (url.includes('current-viewers') && _callbacks.size > 0) {
      res.clone().json().then(data => {
        if (Array.isArray(data) && data[0]?.viewers != null) {
          for (const cb of _callbacks) cb(data[0].viewers);
        }
      }).catch(() => {});
    }

    return res;
  };

  return {
    /** Register a viewer-count callback. Returns an unsubscribe function. */
    onViewerCount(cb) {
      _callbacks.add(cb);
      return () => _callbacks.delete(cb);
    },
  };
}


// ── ui/icons.js ──
// ── ui/icons.js ───────────────────────────────────────────────────────────────
// All SVG icon functions in one place. Importing from here keeps individual
// component files free of inline SVG strings.

const svgPlay = () =>
  `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>`;

const svgPause = () =>
  `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>`;

const svgSpin = () =>
  `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="kt-spin"><circle cx="12" cy="12" r="9" stroke-dasharray="30 60"/></svg>`;

const svgExpand = () =>
  `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/></svg>`;

const svgCompress = () =>
  `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"/></svg>`;

function svgVolume(muted) {
  return muted
    ? `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/></svg>`
    : `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/></svg>`;
}


// ── ui/play-button.js ──

function createPlayBtn(store, actions) {
  const btn = document.createElement('button');
  btn.className = 'kt-btn kt-play';
  btn.title = 'Play/Pause (k)';
  btn.innerHTML = svgPlay();
  btn.addEventListener('click', actions.togglePlay);

  // select() fires only when playing or buffering changes — the 500ms position
  // poll and 1s uptime ticker no longer trigger needless DOM updates here.
  store.select(
    s => ({ playing: s.playing, buffering: s.buffering }),
    ({ playing, buffering }) => {
      btn.innerHTML = buffering ? svgSpin() : playing ? svgPause() : svgPlay();
      btn.title = playing ? 'Pause (k)' : 'Play (k)';
    }
  );

  return btn;
}


// ── ui/volume-control.js ──

function createVolumeCtrl(store, actions) {
  const wrap = document.createElement('div');
  wrap.className = 'kt-vol-wrap';

  const btn = document.createElement('button');
  btn.className = 'kt-btn kt-mute';
  btn.addEventListener('click', e => { e.stopPropagation(); actions.toggleMute(); });

  const sliderWrap = document.createElement('div');
  sliderWrap.className = 'kt-vol-slider-wrap';

  const slider = document.createElement('input');
  slider.type = 'range'; slider.className = 'kt-vol-slider';
  slider.min = '0'; slider.max = '100'; slider.step = '1';

  sliderWrap.appendChild(slider);
  wrap.append(btn, sliderWrap);

  let _dragging = false;
  slider.addEventListener('mousedown', () => {
    _dragging = true;
    const up = () => { _dragging = false; document.removeEventListener('mouseup', up); };
    document.addEventListener('mouseup', up);
  });
  slider.addEventListener('input', () => {
    actions.setVolume(Number(slider.value));
    _updateFill(Number(slider.value));
  });

  function syncUi(volume, muted) {
    const isMuted = muted || volume === 0;
    btn.innerHTML = svgVolume(isMuted);
    btn.title     = isMuted ? 'Unmute (m)' : 'Mute (m)';
    if (!_dragging) { slider.value = isMuted ? 0 : volume; _updateFill(isMuted ? 0 : volume); }
  }

  function _updateFill(pct) { slider.style.setProperty('--kt-vol-pct', pct + '%'); }

  // Only re-render when volume or muted actually changes
  store.select(
    s => ({ volume: s.volume, muted: s.muted }),
    ({ volume, muted }) => syncUi(volume, muted)
  );
  syncUi(store.getState().volume, store.getState().muted);

  return wrap;
}


// ── ui/popup.js ──
let _popupGlobalsBound = false;
function bindPopupGlobals() {
  if (_popupGlobalsBound) return;
  _popupGlobalsBound = true;
  document.addEventListener('click', () => {
    document.querySelectorAll('.kt-popup').forEach(p => { p.hidden = true; });
  });
  document.addEventListener('keydown', e => {
    if (e.key === 'Escape')
      document.querySelectorAll('.kt-popup').forEach(p => { p.hidden = true; });
  });
  window.addEventListener('resize', () => {
    document.querySelectorAll('.kt-popup').forEach(p => { p.hidden = true; });
  });
}

function openPopup(popup, triggerBtn) {
  popup.hidden = false;
  popup.style.visibility = 'hidden';
  const rect = triggerBtn.getBoundingClientRect();
  const vw = window.innerWidth;
  const popupW = popup.offsetWidth || 120;
  const popupH = popup.offsetHeight || 100;

  const availableH = rect.top - 8 - 4;
  const maxH = Math.max(80, availableH);
  popup.style.maxHeight = maxH + 'px';

  let top = rect.top - Math.min(popupH, maxH) - 8;
  if (top < 4) top = 4;

  let left = rect.right - popupW;
  if (left < 4) left = 4;
  if (left + popupW > vw - 4) left = vw - popupW - 4;

  popup.style.left = left + 'px';
  popup.style.top = top + 'px';
  popup.style.visibility = '';
}

function setupPopupToggle(btn, popup, onOpen) {
  bindPopupGlobals();
  btn.addEventListener('click', e => {
    e.stopPropagation();
    if (!popup.hidden) { popup.hidden = true; return; }
    document.querySelectorAll('.kt-popup').forEach(p => { p.hidden = true; });
    if (onOpen) onOpen();
    openPopup(popup, btn);
  });
}


// ── utils/format.js ──
function fmtViewers(n) {
  if (n === null || n === undefined) return '';
  if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K';
  return String(n);
}

function fmtUptime(startDate) {
  if (!startDate) return '';
  const secs = Math.floor((Date.now() - startDate.getTime()) / 1000);
  const h = Math.floor(secs / 3600);
  const m = Math.floor((secs % 3600) / 60);
  const s = secs % 60;
  if (h > 0) return `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
  return `${m}:${String(s).padStart(2,'0')}`;
}

function fmtQuality(name) {
  if (!name) return name;
  // Remove frame rate suffix if 30fps or less (e.g. "480p30" → "480p", "1080p60" stays)
  return name.replace(/(\d+p)(\d+)$/, (_, res, fps) => parseInt(fps, 10) > 30 ? res + fps : res);
}

function fmtDuration(totalSec) {
  const t = Math.max(0, Math.floor(totalSec));
  const h = Math.floor(t / 3600);
  const m = Math.floor((t % 3600) / 60);
  const s = t % 60;
  if (h > 0) return `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
  return `${m}:${String(s).padStart(2,'0')}`;
}

// ── ui/quality-menu.js ──

function createQualityBtn(store, actions) {
  const wrap = document.createElement('div');
  wrap.className = 'kt-popup-wrap';

  const btn = document.createElement('button');
  btn.className = 'kt-btn kt-qual-btn';
  btn.title = 'Quality'; btn.textContent = 'AUTO';

  const popup = document.createElement('div');
  popup.className = 'kt-popup kt-qual-popup';
  popup.hidden = true;

  let _snap = { engine: 'ivs', qualities: [], quality: null, autoQuality: true, dvrQualities: [], dvrQuality: null };

  setupPopupToggle(btn, popup, () => _renderPopup());
  document.body.appendChild(popup);
  wrap.append(btn);

  store.select(
    s => ({ engine: s.engine, qualities: s.qualities, quality: s.quality, autoQuality: s.autoQuality, dvrQualities: s.dvrQualities, dvrQuality: s.dvrQuality }),
    snap => {
      _snap = snap;
      btn.textContent = snap.engine === 'dvr'
        ? (snap.dvrQuality ? fmtQuality(snap.dvrQuality.name) : 'AUTO')
        : (snap.autoQuality ? 'AUTO' : fmtQuality(snap.quality?.name ?? '?'));
      if (!popup.hidden) _renderPopup();
    }
  );

  function _renderPopup() {
    const items = _snap.engine === 'dvr'
      ? [
          { label: 'Auto', active: _snap.dvrQuality === null, onClick: () => actions.setQuality('auto') },
          ...(_snap.dvrQualities || []).map(q => ({
            label:   fmtQuality(q.name),
            active:  _snap.dvrQuality?.index === q.index,
            onClick: () => actions.setQuality(q),
          })),
        ]
      : [
          { label: 'Auto', active: _snap.autoQuality, onClick: () => actions.setQuality('auto') },
          ...(_snap.qualities || []).map(q => ({
            label:   q.name,
            active:  !_snap.autoQuality && _snap.quality?.name === q.name,
            onClick: () => actions.setQuality(q),
          })),
        ];

    // Diff instead of full re-render when item count is unchanged
    const existing = Array.from(popup.querySelectorAll('.kt-popup-item'));
    if (!popup.hidden && existing.length === items.length) {
      items.forEach((item, i) => {
        const el = existing[i];
        if (el.textContent !== item.label) el.textContent = item.label;
        el.classList.toggle('kt-active', item.active);
        el.onclick = e => { e.stopPropagation(); item.onClick(); popup.hidden = true; };
      });
      return;
    }
    popup.innerHTML = '';
    items.forEach(({ label, active, onClick }) => {
      const item = document.createElement('button');
      item.className = 'kt-popup-item' + (active ? ' kt-active' : '');
      item.textContent = label;
      item.addEventListener('click', e => { e.stopPropagation(); onClick(); popup.hidden = true; });
      popup.appendChild(item);
    });
  }

  return wrap;
}


// ── ui/speed-menu.js ──

const RATES = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2];

function createSpeedBtn(store, actions) {
  const wrap = document.createElement('div');
  wrap.className = 'kt-popup-wrap';

  const btn = document.createElement('button');
  btn.className = 'kt-btn kt-speed-btn';
  btn.title = 'Speed'; btn.textContent = '1×';

  const popup = document.createElement('div');
  popup.className = 'kt-popup kt-speed-popup';
  popup.hidden = true;

  RATES.forEach(r => {
    const item = document.createElement('button');
    item.className = 'kt-popup-item';
    item.dataset.rate = r;
    item.textContent = r === 1 ? '1× (normal)' : r + '×';
    item.addEventListener('click', e => { e.stopPropagation(); actions.setRate(r); popup.hidden = true; });
    popup.appendChild(item);
  });

  setupPopupToggle(btn, popup);
  document.body.appendChild(popup);
  wrap.append(btn);

  store.select(
    s => ({ rate: s.rate }),
    ({ rate }) => {
      btn.textContent = rate === 1 ? '1×' : rate + '×';
      popup.querySelectorAll('.kt-popup-item[data-rate]').forEach(item => {
        item.classList.toggle('kt-active', Number(item.dataset.rate) === rate);
      });
    }
  );

  return wrap;
}


// ── ui/fullscreen-button.js ──

function createFullscreenBtn(store, actions) {
  const btn = document.createElement('button');
  btn.className = 'kt-btn kt-fs';
  btn.title = 'Fullscreen (f)';
  btn.innerHTML = svgExpand();
  btn.addEventListener('click', actions.toggleFullscreen);

  store.select(
    s => ({ fullscreen: s.fullscreen }),
    ({ fullscreen }) => {
      btn.innerHTML = fullscreen ? svgCompress() : svgExpand();
      btn.title = fullscreen ? 'Exit fullscreen (f)' : 'Fullscreen (f)';
    }
  );

  return btn;
}


// ── ui/info.js ──

function createInfo(store, actions, viewerInterceptor, api) {
  const wrap    = document.createElement('div');  wrap.className    = 'kt-info';
  const live    = document.createElement('span'); live.className    = 'kt-live-badge'; live.textContent = '● LIVE';
  const viewers = document.createElement('span'); viewers.className = 'kt-viewers';
  const uptime  = document.createElement('span'); uptime.className  = 'kt-uptime';

  wrap.append(viewers, uptime);

  let pollTimer   = null;
  let uptimeTimer = null;
  let startDate   = null;

  // Register viewer-count callback immediately — no null-callback window
  const _unsubViewers = viewerInterceptor.onViewerCount(count => {
    viewers.textContent = fmtViewers(count) + ' watching';
  });

  // ── uptime ticker ────────────────────────────────────────────────────────

  function _startUptimeTicker(start) {
    if (!start || !isFinite(start.getTime())) return;
    if (startDate && start.getTime() === startDate.getTime() && uptimeTimer) return;
    startDate = start;
    clearInterval(uptimeTimer);
    const tick = () => {
      const s = store.getState();
      if (s.engine !== 'dvr') {
        uptime.textContent = fmtUptime(startDate);
      }
      store.setState({ uptimeSec: Math.floor((Date.now() - startDate.getTime()) / 1000) });
      if (store.getState().username && !pollTimer) _startPolling();
    };
    tick();
    uptimeTimer = setInterval(tick, 1000);
  }

  function _stopUptimeTicker() { clearInterval(uptimeTimer); uptimeTimer = null; startDate = null; }

  // ── offline ──────────────────────────────────────────────────────────────

  function _applyOffline() {
    live.textContent = '● OFFLINE';
    live.classList.add('kt-offline');
    viewers.textContent = '';
    uptime.textContent  = '';
    _stopUptimeTicker();
    if (store.getState().engine !== 'dvr') {
      store.setState({ vodId: null, streamStartTime: null, uptimeSec: 0 });
    }
  }

  // ── polling ──────────────────────────────────────────────────────────────

  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));
      }
    } catch (e) { console.warn('[KickTiny] poll error:', e.message); }
  }

  function _startPolling() { clearInterval(pollTimer); _poll(); pollTimer = setInterval(_poll, POLL_INTERVAL_MS); }
  function _stopPolling()  { clearInterval(pollTimer); pollTimer = null; }

  // ── live badge ───────────────────────────────────────────────────────────

  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);
    }
  }
);

  document.addEventListener('visibilitychange', () => {
    if (!store.getState().username) return;
    if (document.hidden) {
      _stopPolling();
      clearInterval(uptimeTimer); uptimeTimer = null;
    } else {
      if (startDate) _startUptimeTicker(startDate);
      _startPolling();
    }
  });

  return { live, wrap, destroy: _unsubViewers };
}


// ── ui/seekbar.js ──

function createSeekbar(store, actions) {
  const wrap  = document.createElement('div');  wrap.className  = 'kt-seekbar';
  const track = document.createElement('div');  track.className = 'kt-seekbar-track';
  const prog  = document.createElement('div');  prog.className  = 'kt-seekbar-prog';
  const thumb = document.createElement('div');  thumb.className = 'kt-seekbar-thumb';
  const tip   = document.createElement('div');  tip.className   = 'kt-seekbar-tip';

  track.append(prog, thumb);
  wrap.append(track, tip);

  let _dragging        = false;
  let _uptimeSec       = 0;
  let _pendingBehindSec = null; // DVR entry deferred to mouseup

  function render(uiPos, uptimeSec) {
    if (uptimeSec <= 0) { prog.style.width = '0%'; thumb.style.left = '0%'; return; }
    const pct = Math.min(1, Math.max(0, uiPos / uptimeSec)) * 100;
    prog.style.width = `${pct}%`;
    thumb.style.left = `${pct}%`;
  }

  function showTip(e) {
    if (_uptimeSec <= 0) return;
    const rect  = track.getBoundingClientRect();
    const wRect = wrap.getBoundingClientRect();
    const pct   = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
    const behind = _uptimeSec - pct * _uptimeSec;

    tip.textContent = behind <= LIVE_EDGE_BEHIND_SEC ? 'LIVE' : '-' + fmtDuration(behind);
    tip.style.display = 'block';

    const tipW = tip.offsetWidth;
    const hPad = rect.left - wRect.left;
    tip.style.bottom = (wrap.offsetHeight - track.offsetTop + 6) + 'px';
    let left = hPad + (e.clientX - rect.left) - tipW / 2;
    tip.style.left = `${Math.max(0, Math.min(wRect.width - tipW, left))}px`;
  }

  function hideTip() { if (!_dragging) tip.style.display = 'none'; }

  function seekFromEvent(e) {
    if (_uptimeSec <= 0) return;
    const rect = track.getBoundingClientRect();
    const pct  = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
    const uiPos   = pct * _uptimeSec;
    const behind  = _uptimeSec - uiPos;
    render(uiPos, _uptimeSec);

    if (behind <= LIVE_EDGE_BEHIND_SEC) {
      if (store.getState().engine === 'dvr') actions.seekToLive();
      _pendingBehindSec = null;
      return;
    }
    if (store.getState().engine === 'dvr') {
      actions.dvrSeekToBehindLive(behind);
      _pendingBehindSec = null;
      return;
    }
    _pendingBehindSec = behind;
  }

  wrap.addEventListener('mouseenter', e => showTip(e));
  wrap.addEventListener('mousemove',  e => showTip(e));
  wrap.addEventListener('mouseleave', () => hideTip());
  wrap.addEventListener('mousedown',  e => { _dragging = true; seekFromEvent(e); e.preventDefault(); });

  document.addEventListener('mousemove', e => { if (!_dragging) return; showTip(e); seekFromEvent(e); });
  document.addEventListener('mouseup', () => {
    if (!_dragging) return;
    _dragging = false;
    tip.style.display = 'none';
    if (_pendingBehindSec !== null && store.getState().engine !== 'dvr') {
      const behind = _pendingBehindSec;
      _pendingBehindSec = null;
      actions.enterDvr(behind);
    } else {
      _pendingBehindSec = null;
    }
  });

  store.select(
    s => ({ uptimeSec: s.uptimeSec, dvrBehindLive: s.dvrBehindLive, engine: s.engine }),
    ({ uptimeSec, dvrBehindLive, engine }) => {
      wrap.style.display = uptimeSec > 0 ? 'block' : 'none';
      if (uptimeSec <= 0) return;
      _uptimeSec = uptimeSec;
      if (_dragging) return;
      render(engine === 'ivs' ? uptimeSec : Math.max(0, uptimeSec - dvrBehindLive), uptimeSec);
  });

  wrap.style.display = 'none';
  return wrap;
}


// ── ui/bar.js ──

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 left = document.createElement('div'); left.className = 'kt-bar-left';
  left.append(createPlayBtn(store, actions), live, createVolumeCtrl(store, actions), infoWrap);

  const right = document.createElement('div'); right.className = 'kt-bar-right';
  right.append(createSpeedBtn(store, actions), createQualityBtn(store, actions), createFullscreenBtn(store, actions));

  const controls = document.createElement('div'); controls.className = 'kt-controls';
  controls.append(left, right);

  bar.append(createSeekbar(store, actions), controls);
  return bar;
}

function initBarHover(root, bar, container, topBar, store) {
  let hideTimer   = null;

  function hide() {
    bar.classList.remove('kt-bar-visible');
    if (topBar) topBar.classList.remove('kt-top-bar-visible');
    root.classList.add('kt-idle');
    container.classList.add('kt-idle');
  }

  function show() {
    bar.classList.add('kt-bar-visible');
    if (topBar) topBar.classList.add('kt-top-bar-visible');
    root.classList.remove('kt-idle');
    container.classList.remove('kt-idle');
    clearTimeout(hideTimer);
    hideTimer = setTimeout(() => { if (store.getState().playing) hide(); }, CONTROLS_HIDE_DELAY_MS);
  }

  let _moveRaf = 0;
  container.addEventListener('mousemove', () => {
    if (_moveRaf) return;
    _moveRaf = requestAnimationFrame(() => { show(); _moveRaf = 0; });
  });

  container.addEventListener('mouseleave', () => {
    clearTimeout(hideTimer);
    hideTimer = setTimeout(() => {
      bar.classList.remove('kt-bar-visible');
      if (topBar) topBar.classList.remove('kt-top-bar-visible');
      root.classList.remove('kt-idle');
      container.classList.remove('kt-idle');
    }, CONTROLS_LEAVE_DELAY_MS);
  });

  bar.addEventListener('mouseenter', () => {
    clearTimeout(hideTimer);
    bar.classList.add('kt-bar-visible');
    if (topBar) topBar.classList.add('kt-top-bar-visible');
  });

  if (topBar) {
    topBar.addEventListener('mouseenter', () => {
      clearTimeout(hideTimer);
      topBar.classList.add('kt-top-bar-visible');
      bar.classList.add('kt-bar-visible');
    });
  }

  // Only react to actual playing state changes — not every setState tick.
  // The position poll (500ms) and uptime ticker (1s) would otherwise reset
  // the hide timer on every tick, keeping the controls visible forever.
  store.select(
    s => ({ playing: s.playing }),
    ({ playing }) => {
      if (!playing) {
        clearTimeout(hideTimer);
        bar.classList.add('kt-bar-visible');
        if (topBar) topBar.classList.add('kt-top-bar-visible');
        root.classList.remove('kt-idle');
        container.classList.remove('kt-idle');
      } else {
        show();
      }
    }
  );
}


// ── ui/overlay.js ──
function createOverlay(store, actions) {
  const overlay = document.createElement('div');
  overlay.className = 'kt-overlay';
  overlay.innerHTML = `
    <button class="kt-overlay-btn" title="Play (k)">
      <svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
    </button>
  `;

  overlay.querySelector('button').addEventListener('click', actions.togglePlay);

  store.select(
    s => ({ alive: s.alive, playing: s.playing, buffering: s.buffering }),
    ({ alive, playing, buffering }) => {
      overlay.classList.toggle('kt-overlay-hidden', !alive || playing || buffering);
    }
  );

  return overlay;
}


// ── ui/topbar.js ──
function createTopBar(store) {
  const bar = document.createElement('div');
  bar.className = 'kt-top-bar';

  const channelLink = document.createElement('a');
  channelLink.className = 'kt-channel-link';
  channelLink.target = '_blank'; channelLink.rel = 'noopener noreferrer';

  const title = document.createElement('div');
  title.className = 'kt-stream-title';

  const avatar = document.createElement('img');
  avatar.className = 'kt-avatar'; avatar.alt = ''; avatar.draggable = false;

  const channelWrap = document.createElement('div');
  channelWrap.className = 'kt-channel-wrap';
  channelWrap.append(avatar, channelLink);
  bar.append(channelWrap, title);

  let _ready = false;
  store.select(
    s => ({ username: s.username, displayName: s.displayName, avatar: s.avatar, title: s.title }),
    ({ username, displayName, avatar: avatarUrl, title: stateTitle }) => {
      if (username && !_ready) {
        _ready = true;
        channelLink.href = `https://www.kick.com/${username}`;
      }
      if (displayName && channelLink.textContent !== displayName) channelLink.textContent = displayName;
      if (avatarUrl  && avatar.src !== avatarUrl)                 avatar.src = avatarUrl;
      if (stateTitle && stateTitle !== title.textContent)         title.textContent = stateTitle;
  });

  return bar;
}


// ── main.js ──
// ── main.js ───────────────────────────────────────────────────────────────────
// Entry point — wiring only. Creates the core services, wires them together,
// and mounts the UI. No business logic lives here.


const CSS = `:root{--kt-black:#0d0d0d;--kt-white:#f0f0f0;--kt-green:#53fc18;--kt-dim:rgba(255,255,255,0.55);--kt-bar-h:42px;--kt-radius:5px;--kt-font:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;--kt-size:13px;--kt-trans:0.2s ease}#kt-root{position:absolute;inset:0;z-index:9999;pointer-events:none;font-family:var(--kt-font);font-size:var(--kt-size);color:var(--kt-white);user-select:none;-webkit-user-select:none}#kt-root.kt-idle{cursor:none}.kt-idle,.kt-idle *{cursor:none !important}.kt-top-bar{position:absolute;top:0;left:0;right:0;padding:10px 14px;display:flex;flex-direction:column;gap:2px;background:linear-gradient(to bottom,rgba(0,0,0,0.85) 0%,rgba(0,0,0,0.5) 60%,transparent 100%);pointer-events:all;opacity:0;transition:opacity var(--kt-trans)}.kt-top-bar-visible{opacity:1}.kt-channel-wrap{display:flex;align-items:center;gap:8px}.kt-avatar{width:28px;height:28px;border-radius:50%;object-fit:cover;flex-shrink:0;border:1.5px solid rgba(255,255,255,0.2)}.kt-channel-link{font-size:15px;font-weight:700;color:var(--kt-white);text-decoration:none;line-height:1.2;pointer-events:auto}.kt-channel-link:hover{color:var(--kt-green)}.kt-stream-title{font-size:13px;color:var(--kt-white);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:1.4;padding-bottom:2px}.kt-bar{position:absolute;bottom:0;left:0;right:0;display:flex;flex-direction:column;padding:0;gap:0;background:linear-gradient(to top,rgba(0,0,0,0.75) 0%,transparent 100%);pointer-events:all;opacity:0;transition:opacity var(--kt-trans);overflow:visible}.kt-bar-visible{opacity:1}.kt-controls{height:var(--kt-bar-h);display:flex;align-items:stretch;justify-content:space-between;padding:0 10px;gap:6px;overflow:visible;min-width:0}.kt-bar-left,.kt-bar-right{display:flex;align-items:center;gap:4px;overflow:visible;flex-shrink:0;min-width:0}.kt-info{display:flex;align-items:center;align-self:stretch;gap:6px;padding:0 4px;flex-shrink:1;overflow:hidden}.kt-bar-left,.kt-bar-right{display:flex;align-items:center;gap:4px;overflow:visible;flex-shrink:0}.kt-seekbar{width:100%;padding:10px 10px 4px;box-sizing:border-box;cursor:pointer;position:relative}.kt-seekbar-track{position:relative;height:3px;border-radius:2px;background:rgba(255,255,255,0.25);transition:height var(--kt-trans)}.kt-seekbar:hover .kt-seekbar-track{height:5px}.kt-seekbar-prog{position:absolute;left:0;top:0;height:100%;width:0%;background:var(--kt-green);border-radius:2px;pointer-events:none;z-index:1}.kt-seekbar-thumb{position:absolute;top:50%;left:0%;width:13px;height:13px;border-radius:50%;background:#fff;transform:translate(-50%,-50%) scale(0);transition:transform 0.15s ease;pointer-events:none;z-index:2}.kt-seekbar:hover .kt-seekbar-thumb{transform:translate(-50%,-50%) scale(1)}.kt-seekbar-tip{position:absolute;display:none;background:rgba(18,18,18,0.9);color:var(--kt-white);font-size:11px;font-weight:600;padding:3px 7px;border-radius:4px;white-space:nowrap;pointer-events:none;user-select:none}.kt-btn:focus-visible,.kt-popup-item:focus-visible,.kt-channel-link:focus-visible,.kt-overlay-btn:focus-visible{outline:2px solid var(--kt-green);outline-offset:2px}.kt-btn{background:none;border:none;padding:0 6px;align-self:center;height:80%;cursor:pointer;color:var(--kt-white);display:flex;align-items:center;justify-content:center;border-radius:var(--kt-radius);transition:color var(--kt-trans),background var(--kt-trans);line-height:0}.kt-btn:hover{color:var(--kt-green);background:rgba(255,255,255,0.08)}.kt-btn svg{width:20px;height:20px}@keyframes kt-spin{to{transform:rotate(360deg)}}.kt-spin{animation:kt-spin 0.8s linear infinite}.kt-vol-wrap{display:flex;align-items:center;align-self:stretch;flex-shrink:0;gap:4px}.kt-vol-slider-wrap{display:none;align-items:center}.kt-vol-wrap:hover .kt-vol-slider-wrap{display:flex}.kt-vol-slider{-webkit-appearance:none;appearance:none;width:70px;height:16px;border-radius:2px;outline:none;cursor:pointer;background:transparent}.kt-vol-slider::-webkit-slider-runnable-track{height:3px;border-radius:2px;background:linear-gradient(to right,var(--kt-green) 0%,var(--kt-green) var(--kt-vol-pct,100%),rgba(255,255,255,0.3) var(--kt-vol-pct,100%),rgba(255,255,255,0.3) 100% )}.kt-vol-slider::-webkit-slider-thumb{-webkit-appearance:none;width:12px;height:12px;margin-top:-4.5px;border-radius:50%;background:#fff;cursor:pointer}.kt-vol-slider::-moz-range-thumb{width:12px;height:12px;border-radius:50%;background:#fff;cursor:pointer;border:none}.kt-vol-slider::-moz-range-track{height:3px;border-radius:2px;background:rgba(255,255,255,0.3)}.kt-vol-slider::-moz-range-progress{height:3px;border-radius:2px;background:var(--kt-green)}.kt-live-badge{background:#b30906;color:#fff;font-size:10px;font-weight:700;letter-spacing:0.05em;padding:0 8px;height:22px;align-self:center;display:flex;align-items:center;border-radius:var(--kt-radius);line-height:1;transition:background var(--kt-trans)}.kt-live-badge.kt-offline{background:#555}.kt-live-badge.kt-behind{background:#555;cursor:pointer}.kt-live-badge.kt-behind:hover{background:#b30906}.kt-viewers,.kt-uptime{color:var(--kt-dim);font-size:12px;white-space:nowrap}.kt-popup-wrap{position:relative;align-self:stretch;display:flex;align-items:center}.kt-popup{position:fixed;min-width:120px;overflow-y:auto;background:rgba(18,18,18,0.97);border:1px solid rgba(255,255,255,0.12);border-radius:10px;padding:6px;z-index:99999;box-shadow:0 8px 24px rgba(0,0,0,0.6);font-family:var(--kt-font);pointer-events:all;cursor:default}.kt-popup[hidden]{display:none}.kt-popup-item{display:block;width:100%;padding:7px 12px;text-align:left;background:none;border:none;color:var(--kt-white);font-size:var(--kt-size);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;cursor:pointer;white-space:nowrap;border-radius:6px;transition:color 0.2s ease,background 0.2s ease}.kt-popup-item:hover{color:var(--kt-white);background:rgba(255,255,255,0.1)}.kt-popup-item.kt-active{color:var(--kt-green)}.kt-qual-btn,.kt-speed-btn{font-size:12px;font-weight:600;padding:6px 8px;letter-spacing:0.02em}.kt-overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;pointer-events:none;transition:opacity var(--kt-trans)}.kt-overlay-hidden{opacity:0}.kt-overlay-btn{pointer-events:auto;background:rgba(0,0,0,0.5);border:none;border-radius:50%;width:60px;height:60px;display:flex;align-items:center;justify-content:center;cursor:pointer;color:var(--kt-white);transition:transform var(--kt-trans),background var(--kt-trans)}.kt-overlay-hidden .kt-overlay-btn{pointer-events:none}.kt-overlay-btn:hover{transform:scale(1.1);background:rgba(83,252,24,0.25);color:var(--kt-green)}.kt-overlay-btn svg{width:32px;height:32px}`;

function injectStyles(css) {
  const style = document.createElement('style');
  style.id = 'kt-styles'; style.textContent = css;
  document.head.appendChild(style);
}

function hideNativeControls() {
  const style = document.createElement('style');
  style.textContent = '.z-controls { display: none !important; }';
  document.head.appendChild(style);
}

function getUsername() {
  return location.pathname.replace(/^\//, '').split('/')[0] || '';
}

function createRoot(container) {
  const root = document.createElement('div');
  root.id = 'kt-root';
  container.appendChild(root);
  return root;
}

function waitForContainer(maxAttempts = 60) {
  return new Promise((resolve, reject) => {
    let attempts = 0;
    const check = () => {
      const c = document.querySelector('.aspect-video-responsive')
        || document.querySelector('div[class*="aspect-video"]');
      if (c) { resolve(c); return; }
      if (++attempts >= maxAttempts) { reject(new Error('[KickTiny] Container not found')); return; }
      setTimeout(check, 200);
    };
    check();
  });
}

let _initialized = false;

async function init() {
  if (_initialized) return;
  _initialized = true;
  try {
    const container = await waitForContainer();

    // ── Core services ───────────────────────────────────────────────────────
    const store   = createStore();
    const prefs   = { load: loadPrefs, save: savePrefs };
    const api     = { fetchChannelInit, fetchVodPlaybackUrl };
    const viewer  = createViewerInterceptor();

    // ── Engine layer ────────────────────────────────────────────────────────
    const engines = createEngineManager(store, prefs, api);

    // ── Actions — the only thing UI touches ────────────────────────────────
    const actions = createActions(store, engines, prefs);

    // ── UI ──────────────────────────────────────────────────────────────────
    injectStyles(CSS);
    hideNativeControls();
    store.setState({ username: getUsername() });

    const root   = createRoot(container);
    const topBar = createTopBar(store);
    const bar = createBar(store, actions, viewer, api);
    const overlay = createOverlay(store, actions);

    root.append(overlay, topBar, bar);
    initBarHover(root, bar, container, topBar, store);

    // ── Double-click: single click = play/pause, double = fullscreen ───────
    let _clickTimer = null;
    container.addEventListener('click', e => {
      if (bar.contains(e.target) || topBar.contains(e.target)) return;
      if (_clickTimer) {
        clearTimeout(_clickTimer); _clickTimer = null;
        actions.toggleFullscreen();
      } else {
        _clickTimer = setTimeout(() => {
          _clickTimer = null;
          actions.togglePlay();
        }, actions.DOUBLE_CLICK_WINDOW_MS);
      }
    });

    // ── Init engines (IVS extraction + DVR container setup) ────────────────
    await engines.init(container);
    actions.bindKeys();

    console.log('[KickTiny] Initialized for', getUsername() || 'unknown');
  } catch (e) {
    console.warn('[KickTiny] init error:', e.message);
  }
}

if (document.readyState === 'loading') {
  document.addEventListener('DOMContentLoaded', init);
} else {
  init();
}

})();