KickTiny

Custom player overlay for Kick.com embeds with DVR

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 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();
}

})();