/* ============================================================================
 * YT Player — frontend logic
 * Talks to the native (Zig) side over the zero-native bridge: window.zero.invoke
 * Handlers (implemented in src/bridge.zig):
 *   yt.search   { query }            -> { ok, results:[{id,title,channel,duration,thumbnail}] }
 *   yt.streams  { videoId }          -> { ok, data:{ meta, audioUrl, qualities:[{label,height,url,hasAudio,ext}] } }
 *   store.load  {}                   -> { playlists, history, settings }
 *   store.save  { data }             -> { ok }
 * ========================================================================== */

// ---------- Native bridge adapter ----------
// Works against two shells from the same frontend:
//   • Tauri (Windows / WebView2): window.__TAURI__.core.invoke, snake_case commands
//   • zero-native (Linux / macOS): window.zero.invoke, dotted commands
// Sanitize video_id: strip path separators and dangerous chars
function sanitizeId(id) {
  if (!id || typeof id !== 'string') return '';
  return id.replace(/[/\\:?<>|*"]/g, '').trim();
}
const TAURI = window.__TAURI__ && window.__TAURI__.core ? window.__TAURI__.core : null;
const ZERO = window.zero && typeof window.zero.invoke === 'function' ? window.zero : null;
// WEB mode: running as a PWA served from the Hono server (no native bridge)
const WEB = !TAURI && !ZERO;
const APP_VERSION = '1.0.0';

// call(zeroName, tauriName, payload) — routes to whichever native shell is present.
async function call(zeroName, tauriName, payload = {}) {
  if (TAURI) return await TAURI.invoke(tauriName, payload);
  if (ZERO) return await ZERO.invoke(zeroName, payload);
  throw new Error('No native bridge available — run inside the YT Player app.');
}

// ---------- WEB-mode helpers ----------

// Generic fetch wrapper — returns parsed JSON or throws with a human message.
async function webFetch(path, opts = {}) {
  const res = await fetch(path, opts);
  if (!res.ok) {
    let msg = `HTTP ${res.status}`;
    try { const j = await res.json(); msg = j.error || msg; } catch { /* non-JSON */ }
    throw new Error(msg);
  }
  return res.json();
}

// Persist data to localStorage and fire-and-forget sync to the server.
function loadDataFromStorage() {
  try {
    const raw = localStorage.getItem('_ytpdata');
    return raw ? JSON.parse(raw) : null;
  } catch { return null; }
}

function saveDataToStorage(jsonStr) {
  try { localStorage.setItem('_ytpdata', jsonStr); } catch { /* storage full / blocked */ }
  // Sync playlists to the server (non-blocking — failures are silent)
  try {
    const d = JSON.parse(jsonStr);
    fetch('/api/user/sync', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        fingerprint: window.getFingerprint ? window.getFingerprint() : 'unknown',
        playlists: d.playlists || [],
        appVersion: APP_VERSION,
      }),
    }).catch(() => {});
  } catch { /* ignore parse errors */ }
}

// OPFS bridge wrappers — return the same shape as the Tauri cache_* commands
// so the rest of app.js works without changes.

async function opfsDownload(videoId, { mux = false } = {}) {
  if (!window.OPFS || !window.OPFS.isSupported()) {
    return { ok: false, error: 'OPFS not supported in this browser' };
  }
  const fp = window.getFingerprint ? window.getFingerprint() : '';
  const params = new URLSearchParams();
  if (fp) params.set('fp', fp);
  // mux=1 asks the server to compile bestvideo+bestaudio into a single mp4
  // (used by "Save before playing"); default saves keep the progressive
  // stream exactly as before.
  if (mux) params.set('mux', '1');
  const qs = params.toString();
  const url = `/api/download/${encodeURIComponent(videoId)}${qs ? '?' + qs : ''}`;

  // Preferred path: a dedicated Web Worker does the fetch AND the OPFS writes,
  // so a big save never touches the main thread (no UI jank, no audio
  // stutter). ANY worker failure — unsupported API or a mid-download error —
  // falls back to the legacy main-thread streaming below; the worker's error
  // is kept so it can be reported if the fallback fails too.
  let workerError = null;
  if (typeof window.OPFS.downloadVideo === 'function' && typeof Worker !== 'undefined') {
    const w = await window.OPFS.downloadVideo(videoId, url);
    if (w.ok) return { ok: true, cached: true };
    workerError = w.error || null;
  }

  try {
    const res = await fetch(url);
    if (!res.ok) {
      const j = await res.json().catch(() => ({}));
      return { ok: false, error: j.error || `HTTP ${res.status}` };
    }
    // Determine file extension from Content-Type
    const ct = res.headers.get('content-type') || 'video/mp4';
    const ext = ct.includes('webm') ? 'webm' : ct.includes('ogg') ? 'ogg' : 'mp4';
    await window.OPFS.writeFromResponse(videoId, ext, res);
    return { ok: true, cached: true };
  } catch (err) {
    return { ok: false, error: workerError || err.message };
  }
}

async function opfsStatus(videoId) {
  if (!window.OPFS || !window.OPFS.isSupported()) return { ok: true, cached: false };
  try {
    const cached = await window.OPFS.hasVideo(videoId);
    return { ok: true, cached };
  } catch {
    return { ok: true, cached: false };
  }
}

async function opfsList() {
  if (!window.OPFS || !window.OPFS.isSupported()) return { ok: true, items: [], total: 0 };
  try {
    const items = await window.OPFS.listVideos();
    const total = items.reduce((s, i) => s + (i.size || 0), 0);
    return { ok: true, items, total };
  } catch {
    return { ok: true, items: [], total: 0 };
  }
}

async function opfsDelete(videoId) {
  if (!window.OPFS || !window.OPFS.isSupported()) return { ok: true };
  try { await window.OPFS.deleteVideo(videoId); } catch { /* ignore */ }
  return { ok: true };
}

async function opfsClear() {
  if (!window.OPFS || !window.OPFS.isSupported()) return { ok: true };
  try { await window.OPFS.clearAll(); } catch { /* ignore */ }
  return { ok: true };
}

// Blob URL for the currently playing OPFS video — revoked on next video load.
let _currentBlobUrl = null;

const API = {
  search: (query) => WEB
    ? webFetch(`/api/search?q=${encodeURIComponent(query)}`)
    : call('yt.search', 'yt_search', { query }),
  getChannel: (channel) => WEB
    ? webFetch(`/api/channel?c=${encodeURIComponent(channel)}`)
    : call('yt.channel', 'yt_channel', { channel }),
  getStreams: (videoId) => WEB
    ? webFetch(`/api/streams?v=${encodeURIComponent(videoId)}`)
    : call('yt.streams', 'yt_streams', { videoId }),
  loadData: () => WEB
    ? Promise.resolve(loadDataFromStorage())
    : call('store.load', 'store_load', {}),
  // data is sent pre-stringified so the native side can write it verbatim.
  saveData: (d) => WEB
    ? Promise.resolve(saveDataToStorage(typeof d === 'string' ? d : JSON.stringify(d)))
    : call('store.save', 'store_save', { data: JSON.stringify(d) }),
  // Offline cache — routes to OPFS (WEB) or native cache (Tauri/Zig)
  cacheDownload: (videoId, opts) => WEB
    ? opfsDownload(sanitizeId(videoId), opts)
    : call('cache.download', 'cache_download', { videoId: sanitizeId(videoId) }),
  cacheStatus: (videoId) => WEB
    ? opfsStatus(sanitizeId(videoId))
    : call('cache.status', 'cache_status', { videoId: sanitizeId(videoId) }),
  cacheList: () => WEB
    ? opfsList()
    : call('cache.list', 'cache_list', {}),
  cacheDelete: (videoId) => WEB
    ? opfsDelete(sanitizeId(videoId))
    : call('cache.delete', 'cache_delete', { videoId: sanitizeId(videoId) }),
  cacheClear: () => WEB
    ? opfsClear()
    : call('cache.clear', 'cache_clear', {}),
};

// Resolve a native file path to a URL the WebView can load (Tauri asset
// protocol). Returns null on shells without it.
function toAssetUrl(path) {
  if (TAURI && typeof TAURI.convertFileSrc === 'function') return TAURI.convertFileSrc(path);
  return null;
}

// ---------- State ----------
const DEFAULT_SETTINGS = {
  quality: 'auto', volume: 1, audioOnly: false, autoPreload: true,
  saveBeforePlay: false,    // download (server-muxed single file) before playing instead of streaming
  repeatMode: 'off',        // 'off' | 'all' — repeat the playing list when it ends
  loopOne: false,           // repeat the single current video
  shuffle: false,           // shuffle upcoming tracks when playing a list
  theme: 'dark',            // 'dark' | 'light' | 'contrast'
  fontScale: 'normal',      // 'small' | 'normal' | 'large' | 'xl'
  density: 'comfortable',   // 'comfortable' | 'compact'
  perfMode: false,          // disable heavy visual effects for speed
  reduceMotion: false,      // disable animations
  autoBackupEnabled: false,
  autoBackupIntervalDays: 7,
};
let data = { playlists: [], history: [], queue: [], settings: { ...DEFAULT_SETTINGS }, resumePositions: {}, playCount: {}, abMarkers: {}, lastAutoBackup: 0, profile: null };
let view = { type: 'search' };          // 'search'|'history'|'playlist'|'settings'|'queue'|'saved'|'downloads'|'channel'
let searchResults = [];
let channelData = { name: '', url: '', key: '', results: [], loading: false };
let queue = [];                          // list of video objects for autoplay
let queueIndex = -1;
let queueSource = '';                    // label of what's playing ('queue','playlist:<id>',…)
let current = null;                      // { meta, qualities, audioUrl, localUrl? }
let dragSource = -1;                     // index of card being dragged
let listFilter = '';
let selectMode = false;
let selectedIds = new Set();
let sleepTimerRemaining = 0;
let sleepTimerTick = null;
let abA = null;
let abB = null;
let relatedVideos = [];
let relatedCollapsed = false;
let saveTimer = null;
const cachedIds = new Set();             // video ids that exist in the offline cache
const downloading = new Set();           // video ids with an in-flight download
const downloadMeta = new Map();          // id -> video object, for the Downloads page
const removing = new Set();              // video ids with an in-flight cache removal
const playlistOps = new Set();           // `${playlistId}:${videoId}` add/remove in flight

// Concurrency helper (frontend/async-guard.js) — keeps the save / playlist
// operations from being triggered twice at once for the same target.
const runExclusive = (window.AsyncGuard && window.AsyncGuard.runExclusive)
  || (async (set, key, fn) => {
    if (set.has(key)) return undefined;
    set.add(key);
    try { return await fn(); } finally { set.delete(key); }
  });

// ---------- DOM ----------
const $ = (id) => document.getElementById(id);
const els = {
  video: $('video'),
  audio: $('audio'),
  art: $('artFallback'),
  artImg: $('artImg'),
  placeholder: $('playerPlaceholder'),
  spinner: $('bufferSpinner'),
  controls: $('controls'),
  playerPane: $('playerPane'),
  nowMeta: $('nowPlayingMeta'),
  npTitle: $('npTitle'),
  npChannel: $('npChannel'),
  seek: $('seek'),
  curTime: $('curTime'),
  durTime: $('durTime'),
  playBtn: $('playBtn'),
  prevBtn: $('prevBtn'),
  nextBtn: $('nextBtn'),
  muteBtn: $('muteBtn'),
  volume: $('volume'),
  speed: $('speedSelect'),
  quality: $('qualitySelect'),
  fsBtn: $('fsBtn'),
  shuffleBtn: $('shuffleBtn'),
  loopBtn: $('loopBtn'),
  repeatBtn: $('repeatBtn'),
  queueBtn: $('queueBtn'),
  cards: $('cards'),
  listTitle: $('listTitle'),
  listActions: $('listActions'),
  status: $('status'),
  searchForm: $('searchForm'),
  searchInput: $('searchInput'),
  playlistList: $('playlistList'),
  newPlaylistBtn: $('newPlaylistBtn'),
  audioOnlyToggle: $('audioOnlyToggle'),
  saveBtn: $('saveBtn'),
  addPlaylistBtn: $('addPlaylistBtn'),
};

// ---------- Persistence ----------
function persist() {
  clearTimeout(saveTimer);
  saveTimer = setTimeout(() => API.saveData(data).catch(() => {}), 400);
  scheduleProfilePush();
}

// ---------- Online profile sync (WEB mode) ----------
// The profile NAME acts as the passkey: any device that knows it can load
// and update the same server-side copy of playlists/settings/etc. Sync is
// last-write-wins: every local change pushes (debounced); every app launch
// pulls when the server copy is newer than what this device last synced.

function profilePayload() {
  return {
    playlists:       data.playlists,
    history:         data.history,
    settings:        data.settings,
    resumePositions: data.resumePositions,
    playCount:       data.playCount,
    abMarkers:       data.abMarkers,
  };
}

let profilePushTimer = null;
function scheduleProfilePush() {
  if (!WEB || !data.profile || !data.profile.name) return;
  clearTimeout(profilePushTimer);
  profilePushTimer = setTimeout(pushProfile, 1500);
}

async function pushProfile() {
  if (!WEB || !data.profile || !data.profile.name) return;
  try {
    const res = await fetch('/api/profile/save', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name: data.profile.name, data: profilePayload() }),
    });
    const j = await res.json().catch(() => null);
    if (j && j.ok) {
      data.profile.syncedAt = j.updatedAt || 0;
      // Record syncedAt directly — going through persist() would re-schedule
      // another push forever.
      API.saveData(data).catch(() => {});
    }
  } catch { /* offline — the next persist() retries */ }
}

// Replace the synced slice of local state with a profile's server copy.
function applyProfileData(name, payload, updatedAt) {
  payload = payload || {};
  if (Array.isArray(payload.playlists)) data.playlists = payload.playlists;
  if (Array.isArray(payload.history))   data.history = payload.history;
  if (payload.resumePositions && typeof payload.resumePositions === 'object') data.resumePositions = payload.resumePositions;
  if (payload.playCount && typeof payload.playCount === 'object') data.playCount = payload.playCount;
  if (payload.abMarkers && typeof payload.abMarkers === 'object') data.abMarkers = payload.abMarkers;
  if (payload.settings && typeof payload.settings === 'object') data.settings = { ...DEFAULT_SETTINGS, ...payload.settings };
  data.profile = { name, syncedAt: updatedAt || 0 };
  API.saveData(data).catch(() => {});
}

// On launch: adopt the server copy when it's newer than this device's last
// sync (another device pushed since); otherwise push local state up.
async function pullProfileIfNewer() {
  if (!WEB || !data.profile || !data.profile.name) return;
  try {
    const res = await fetch(`/api/profile/load?name=${encodeURIComponent(data.profile.name)}`);
    if (res.status === 404) return; // profile gone server-side; keep local data
    const j = await res.json().catch(() => null);
    if (!j || !j.ok) return;
    if ((j.updatedAt || 0) > (data.profile.syncedAt || 0)) {
      applyProfileData(j.name, j.data, j.updatedAt);
    } else {
      scheduleProfilePush();
    }
  } catch { /* offline — stay on local data */ }
}

// Refresh the Settings row without a full re-render (no-op on other views).
function updateProfileStatus() {
  const el = document.getElementById('profileStatus');
  if (el) el.textContent = (data.profile && data.profile.name) || 'Not linked';
  const unlink = document.getElementById('profileUnlinkBtn');
  if (unlink) unlink.style.display = data.profile && data.profile.name ? '' : 'none';
}

function createProfileFlow() {
  const body = document.createElement('div');
  body.innerHTML = `
    <p style="margin:0 0 12px;color:var(--text-2);font-size:13px;line-height:1.5">
      Pick a name (3–40 characters: letters, digits, - or _).
      <strong>The name is the key</strong> — anyone who knows it can load this
      profile on their device, so use something hard to guess or go random.
    </p>
    <input id="profileNameInput" type="text" placeholder="e.g. crimson-falcon-8317" autocomplete="off" />`;
  showModal('＋ Create online profile', body, [
    { label: 'Cancel', onClick: closeModal },
    { label: '🎲 Random name', onClick: () => requestCreateProfile(null) },
    { label: 'Create', primary: true, onClick: () => {
      const name = ($('profileNameInput').value || '').trim();
      if (!/^[A-Za-z0-9][A-Za-z0-9_-]{2,39}$/.test(name)) {
        toast('⚠ Name must be 3–40 characters: letters, digits, - or _');
        return; // keep the modal open for another attempt
      }
      requestCreateProfile(name);
    } },
  ]);
  $('profileNameInput').focus();
}

// name === null → let the server generate a unique random one.
async function requestCreateProfile(name) {
  try {
    const res = await fetch('/api/profile/create', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name, data: profilePayload() }),
    });
    const j = await res.json().catch(() => null);
    if (!j || !j.ok) {
      toast('⚠ ' + ((j && j.error) || 'Could not create profile'));
      return; // modal stays open — user can adjust the name
    }
    closeModal();
    data.profile = { name: j.name, syncedAt: j.updatedAt || 0 };
    persist();
    updateProfileStatus();
    toast(`Profile “${j.name}” created ✓ — load it by name on any device`, { duration: 4500 });
  } catch {
    toast('⚠ Network error — try again');
  }
}

function loadProfileFlow() {
  const body = document.createElement('div');
  body.innerHTML = `
    <p style="margin:0 0 12px;color:var(--text-2);font-size:13px;line-height:1.5">
      Enter a profile name to pull its playlists, history and settings onto
      this device. The synced data on this device is replaced, and future
      changes here sync back to that profile.
    </p>
    <input id="profileNameInput" type="text" placeholder="profile name" autocomplete="off" />`;
  showModal('⬇ Load online profile', body, [
    { label: 'Cancel', onClick: closeModal },
    { label: 'Load', primary: true, onClick: async () => {
      const name = ($('profileNameInput').value || '').trim();
      if (!name) return;
      try {
        const res = await fetch(`/api/profile/load?name=${encodeURIComponent(name)}`);
        const j = await res.json().catch(() => null);
        if (!j || !j.ok) {
          toast('⚠ ' + ((j && j.error) || 'Profile not found'));
          return; // keep the modal open
        }
        closeModal();
        applyProfileData(j.name, j.data, j.updatedAt);
        // Re-apply everything the loaded data drives.
        applyAppearance();
        updateLoopRepeatButtons();
        updateQueueBadge();
        els.volume.value = String(data.settings.volume ?? 1);
        els.quality.value = data.settings.quality || 'auto';
        els.audioOnlyToggle.checked = !!data.settings.audioOnly;
        renderSidebar();
        renderSmartSidebar();
        render();
        updateProfileStatus();
        data.playlists.forEach(preloadPlaylist);
        toast(`Profile “${j.name}” loaded ✓ — this device now syncs to it`, { duration: 4000 });
      } catch {
        toast('⚠ Network error — try again');
      }
    } },
  ]);
  $('profileNameInput').focus();
}

// ---------- Helpers ----------
function fmtTime(sec) {
  if (!sec || !isFinite(sec)) return '0:00';
  sec = Math.floor(sec);
  const h = Math.floor(sec / 3600);
  const m = Math.floor((sec % 3600) / 60);
  const s = sec % 60;
  const mm = h ? String(m).padStart(2, '0') : String(m);
  return (h ? h + ':' : '') + mm + ':' + String(s).padStart(2, '0');
}
function toast(msg, { duration = 2200 } = {}) {
  const t = document.createElement('div');
  t.className = 'toast';
  t.textContent = msg;
  const container = $('toastContainer');
  container.appendChild(t);
  // Trigger layout for animation
  t.style.animation = 'none';
  t.offsetHeight; // force reflow
  t.style.animation = '';

  // Auto-remove after duration
  setTimeout(() => {
    t.classList.add('toast-exit');
    setTimeout(() => t.remove(), 280);
  }, duration);

  // Cap at 3 toasts — remove oldest
  while (container.children.length > 3) {
    const first = container.firstChild;
    first.classList.add('toast-exit');
    setTimeout(() => first.remove(), 280);
  }
}
function uid() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 7); }

// Apply theme / font size / density / performance settings to the document root.
// Driven entirely by data-* attributes that styles.css keys off of.
function applyAppearance() {
  const s = data.settings;
  const root = document.documentElement;
  root.dataset.theme = s.theme || 'dark';
  root.dataset.font = s.fontScale || 'normal';
  root.dataset.density = s.density || 'comfortable';
  root.dataset.perf = s.perfMode ? 'on' : 'off';
  root.dataset.motion = s.reduceMotion ? 'reduced' : 'full';
}
function fmtBytes(n) {
  if (!n) return '0 B';
  const u = ['B', 'KB', 'MB', 'GB'];
  let i = 0;
  while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
  return (i ? n.toFixed(1) : n) + ' ' + u[i];
}

// ============================================================================
// Offline cache (preload)
// ============================================================================
async function refreshCachedIds() {
  cachedIds.clear();
  try {
    const res = await API.cacheList();
    if (res && res.ok) for (const it of res.items || []) cachedIds.add(it.id);
  } catch { /* shell without cache support — leave empty */ }
}

// Download a video into the permanent offline cache. Safe to call repeatedly.
async function preload(video, { quiet = false, mux = false } = {}) {
  const id = video.id;
  if (!id || cachedIds.has(id) || downloading.has(id)) return;
  downloading.add(id);
  downloadMeta.set(id, slim(video));
  markCardCacheState(id, 'downloading');
  if (view.type === 'downloads') renderList();
  updateDownloadBadge();
  // Reflect the busy state on the now-playing Save button immediately, so the
  // UI responds the moment the (long) download begins rather than only when it
  // finishes in the `finally` below.
  if (current && current.meta && current.meta.id === id) updateNowPlayingActions();
  if (!quiet) toast(`Saving “${video.title}” for offline…`);
  try {
    const res = await API.cacheDownload(id, { mux });
    if (res && res.ok && res.cached) {
      cachedIds.add(id);
      if (!quiet) toast(`Saved “${video.title}” ✓`);
    } else if (!quiet) {
      toast('⚠ ' + ((res && res.error) || 'Could not save video'));
    }
  } catch (e) {
    if (!quiet) toast('⚠ Saving not supported in this build.');
  } finally {
    downloading.delete(id);
    downloadMeta.delete(id);
    markCardCacheState(id, cachedIds.has(id) ? 'cached' : 'none');
    if (current && current.meta && current.meta.id === id) updateNowPlayingActions();
    updateDownloadBadge();
    if (view.type === 'settings' || view.type === 'downloads' || view.type === 'saved') renderList();
  }
}

// Sidebar badge showing how many downloads are in flight.
function updateDownloadBadge() {
  const n = downloading.size;
  const badge = $('navDlCount');
  if (badge) { badge.textContent = String(n); badge.classList.toggle('hidden', n === 0); }
  // Mirror to bottom-nav badge
  const bb = $('bnDlCount');
  if (bb) { bb.textContent = String(n); bb.classList.toggle('hidden', n === 0); }
}

// Auto-preload every video in a playlist (respecting the setting).
function preloadPlaylist(pl) {
  if (!data.settings.autoPreload || !pl) return;
  pl.videos.forEach((v) => preload(v, { quiet: true }));
}

// Update a single card's saved badge without a full re-render.
function markCardCacheState(id, state) {
  document.querySelectorAll(`.card[data-id="${CSS.escape(id)}"]`).forEach((c) => {
    c.classList.toggle('cached', state === 'cached');
    c.classList.toggle('downloading', state === 'downloading');
    const thumb = c.querySelector('.thumb');
    if (state === 'downloading' && !thumb.querySelector('.dl-progress')) {
      const bar = document.createElement('div');
      bar.className = 'dl-progress';
      bar.innerHTML = '<div class="dl-bar"></div>';
      thumb.appendChild(bar);
    } else if (state !== 'downloading') {
      const bar = thumb.querySelector('.dl-progress');
      if (bar) bar.remove();
    }
  });
}

// ============================================================================
// Player engine — single video, or video+audio synced (adaptive), or audio-only
// ============================================================================
const Player = {
  mode: 'progressive', // 'progressive' | 'dual' | 'audio'
  master: els.video,
  secondary: null,     // synced audio element in dual mode
  driftTimer: null,
  bufferGraceTimer: null, // pending "pause audio after a stall that outlasts the grace window" timer
  _wantsPlaying: false, // tracks user/app *intent* to be playing, independent
                         // of what the underlying <audio>/<video> elements
                         // report — iOS Safari (esp. PWA/standalone on iPhone)
                         // can silently pause a background audio element on
                         // screen-lock or app-switch without ever pausing the
                         // still-visible muted video, and without always
                         // firing a 'pause' event we react to. Comparing
                         // intent against actual element state is how we
                         // detect and recover from that.

  get soundEl() {
    return this.mode === 'dual' ? els.audio : this.master;
  },

  async loadVideo(videoObj, { preferStream = false, resume = true, reveal = true } = {}) {
    // When false (auto-advance / prev), skip resuming the saved timestamp and
    // start from the beginning (or the A marker, if an A-B loop is set).
    this._resumeOnLoad = resume;
    // When false (auto-advance / prev), don't scroll the portrait view back
    // to the player — the user may be browsing another page while listening.
    this._revealOnLoad = reveal;
    // Monotonic load token: any await below may resolve after the user has
    // already started a different video — stale loads must stand down
    // instead of hijacking playback (longest window: save-before-playing).
    const seq = (this._loadSeq = (this._loadSeq || 0) + 1);
    showSpinner(true);
    els.placeholder.classList.add('hidden');
    // Revoke any previous OPFS blob URL to free memory
    if (_currentBlobUrl) {
      if (window.OPFS) window.OPFS.revokeUrl(_currentBlobUrl);
      else URL.revokeObjectURL(_currentBlobUrl);
      _currentBlobUrl = null;
    }
    try {
      // Play from the offline cache when available — instant and works offline.
      if (!preferStream && cachedIds.has(videoObj.id)) {
        let localUrl = null;
        try {
          if (TAURI) {
            const st = await API.cacheStatus(videoObj.id);
            if (st && st.ok && st.cached) localUrl = toAssetUrl(st.path);
          } else if (WEB && window.OPFS) {
            localUrl = await window.OPFS.getFileUrl(videoObj.id);
            if (localUrl) _currentBlobUrl = localUrl;
          }
        } catch { /* fall through to streaming */ }
        if (localUrl) {
          current = { meta: { ...videoObj }, qualities: [], audioUrl: null, localUrl };
          this.afterLoad();
          this.fallbackQueue = []; this.fbIndex = 0;
          this.attach(null);
          return;
        }
      }

      // Opt-in "Save before playing" (Settings → Playback, off by default):
      // download the server-compiled single file into the offline cache
      // first, then play the local copy — one data stream instead of the
      // dual video+audio streams, and the video is kept for offline reuse.
      // Any failure falls through to normal streaming playback.
      if (!preferStream && data.settings.saveBeforePlay && !data.settings.audioOnly &&
          WEB && window.OPFS && window.OPFS.isSupported() && !cachedIds.has(videoObj.id)) {
        await preload(videoObj, { mux: true });
        if (seq !== this._loadSeq) return;
        if (cachedIds.has(videoObj.id)) {
          const localUrl = await window.OPFS.getFileUrl(videoObj.id);
          if (seq !== this._loadSeq) return;
          if (localUrl) {
            _currentBlobUrl = localUrl;
            current = { meta: { ...videoObj }, qualities: [], audioUrl: null, localUrl };
            this.afterLoad();
            this.fallbackQueue = []; this.fbIndex = 0;
            this.attach(null);
            return;
          }
        }
      }

      const res = await API.getStreams(videoObj.id);
      if (seq !== this._loadSeq) return; // a newer load took over meanwhile
      if (!res || !res.ok) throw new Error(res?.error || 'Could not load streams');
      current = res.data;
      // Merge richer metadata we may already have from the list card.
      current.meta = { ...videoObj, ...current.meta };

      this.afterLoad();
      const q = chooseQuality();
      this.buildFallbackQueue(q);
      this.attach(q);
    } catch (err) {
      showSpinner(false);
      toast('⚠ ' + err.message);
      // Show retry button in the player pane
      const retry = els.playerPane.querySelector('.retry-btn');
      if (retry) retry.remove();
      const btn = document.createElement('button');
      btn.className = 'retry-btn';
      btn.textContent = '↻ Retry';
      btn.addEventListener('click', () => {
        btn.remove();
        Player.loadVideo(videoObj, { preferStream, resume, reveal });
      });
      els.playerPane.appendChild(btn);
    }
  },

  // Shared UI updates once `current` is populated (cached or streamed).
  afterLoad() {
    addToHistory(current.meta);
    buildQualityMenu();
    els.playerPane.classList.remove('empty');
    els.controls.classList.remove('hidden');
    els.nowMeta.classList.remove('hidden');
    els.npTitle.textContent = current.meta.title;
    els.npChannel.textContent = current.meta.channel || '';
    updateNowPlayingActions();
    markPlayingCard();
    showMiniBar();
    // Media Session API — OS media keys + lock screen
    if ('mediaSession' in navigator) {
      navigator.mediaSession.metadata = new MediaMetadata({
        title: current.meta.title || '',
        artist: current.meta.channel || '',
        artwork: current.meta.thumbnail ? [{ src: current.meta.thumbnail, sizes: '480x360', type: 'image/jpeg' }] : [],
      });
      navigator.mediaSession.setActionHandler('play', () => Player.play());
      navigator.mediaSession.setActionHandler('pause', () => Player.pause());
      navigator.mediaSession.setActionHandler('previoustrack', () => playPrev());
      navigator.mediaSession.setActionHandler('nexttrack', () => playNext());
      navigator.mediaSession.setActionHandler('seekbackward', (d) => Player.seek(Math.max(0, Player.master.currentTime - (d.seekOffset || 10))));
      navigator.mediaSession.setActionHandler('seekforward', (d) => Player.seek(Player.master.currentTime + (d.seekOffset || 10)));
    }
    // In portrait PWA mode, scroll the player into view so it's immediately
    // visible without the user having to swipe up manually — but only for
    // user-picked tracks; auto-advance must not hijack the scroll position.
    if (this._revealOnLoad) scrollPlayerIntoViewPortrait();
    // Restore A-B markers and load related (non-blocking)
    restoreAbMarkers();
    loadRelated();
    exitSelectMode();
    // Restore saved playback position.
    // On auto-advance (or prev/next) we do NOT resume the previous timestamp —
    // a freshly selected track should start at the beginning, or at the A point
    // when an A-B loop is set for it.
    const id = current.meta.id;
    if (this._resumeOnLoad && data.resumePositions[id] && data.resumePositions[id] > 1) {
      const saved = data.resumePositions[id];
      const restore = () => {
        Player.seek(saved);
        toast('Resumed from ' + fmtTime(saved));
        Player.master.removeEventListener('canplay', restore);
        Player.master.removeEventListener('loadedmetadata', restore);
      };
      Player.master.addEventListener('canplay', restore, { once: true });
      Player.master.addEventListener('loadedmetadata', restore, { once: true });
    } else if (abA !== null && abA > 1) {
      // Not resuming, but an A marker exists — start the loop from A.
      const seekToA = () => {
        Player.seek(abA);
        Player.master.removeEventListener('canplay', seekToA);
        Player.master.removeEventListener('loadedmetadata', seekToA);
      };
      Player.master.addEventListener('canplay', seekToA, { once: true });
      Player.master.addEventListener('loadedmetadata', seekToA, { once: true });
    }
  },

  // Ordered list of qualities to try if playback errors out. Progressive
  // (single-file, has audio) goes near the front because it's the most reliable;
  // then we walk from the lowest resolution up.
  fallbackQueue: [],
  fbIndex: 0,
  buildFallbackQueue(chosen) {
    const qs = (current.qualities || []).slice().sort((a, b) => a.height - b.height);
    const seen = new Set();
    const queue = [];
    const push = (x) => { if (x && !seen.has(x.label)) { seen.add(x.label); queue.push(x); } };
    push(chosen);
    qs.filter((q) => q.hasAudio).forEach(push); // progressive = sturdiest
    qs.forEach(push);                            // then everything, low → high
    this.fallbackQueue = queue;
    this.fbIndex = 0;
  },
  onMediaError() {
    // A cached file failed to play — fall back to live streaming.
    if (current && current.localUrl) {
      const meta = current.meta;
      current.localUrl = null;
      toast('Cached copy unavailable — streaming instead…');
      // Preserve the resume intent of the load that just failed.
      this.loadVideo(meta, { preferStream: true, resume: this._resumeOnLoad, reveal: this._revealOnLoad });
      return;
    }
    // Advance to the next candidate stream; give up with a clear message at the end.
    if (this.fbIndex < this.fallbackQueue.length - 1) {
      this.fbIndex++;
      const next = this.fallbackQueue[this.fbIndex];
      toast('Playback hiccup — trying ' + next.label + '…');
      this.attach(next);
    } else {
      showSpinner(false);
      toast('⚠ This video couldn’t be played. Try another.');
    }
  },

  attach(quality) {
    const V = els.video, A = els.audio;
    const audioOnly = data.settings.audioOnly;

    // Reset
    V.pause(); A.pause();
    this.stopDrift();

    if (current.localUrl) {
      // Cached single file (carries its own audio). Play it directly.
      if (audioOnly) {
        this.mode = 'audio';
        this.master = A;
        this.secondary = null;
        A.src = current.localUrl;
        V.removeAttribute('src'); V.load();
        els.art.classList.remove('hidden');
        els.artImg.src = current.meta.thumbnail || '';
      } else {
        this.mode = 'progressive';
        this.master = V;
        this.secondary = null;
        V.muted = false;
        V.src = current.localUrl;
        A.removeAttribute('src'); A.load();
        els.art.classList.add('hidden');
      }
      this.applyVolume();
      this.applySpeed();
      this.master.load();
      const onReadyLocal = () => { this.play(); this.master.removeEventListener('canplay', onReadyLocal); };
      this.master.addEventListener('canplay', onReadyLocal);
      // Cached/offline playback is just as susceptible to a silently-paused
      // background audio element as streamed playback (audio-only mode still
      // routes through <audio>), so it needs the same watchdog.
      this.startDrift();
      return;
    }

    if (audioOnly) {
      // Play only audio; show cover art.
      this.mode = 'audio';
      this.master = A;
      this.secondary = null;
      A.src = current.audioUrl || (quality && quality.url) || '';
      V.removeAttribute('src'); V.load();
      els.art.classList.remove('hidden');
      els.artImg.src = current.meta.thumbnail || '';
    } else if (quality && quality.hasAudio) {
      // Progressive single stream (video already carries audio).
      this.mode = 'progressive';
      this.master = V;
      this.secondary = null;
      V.muted = false;
      V.src = quality.url;
      A.removeAttribute('src'); A.load();
      els.art.classList.add('hidden');
    } else if (quality && current.audioUrl) {
      // Adaptive: muted video + synced audio for high quality without muxing.
      this.mode = 'dual';
      this.master = V;
      this.secondary = A;
      V.muted = true;
      V.src = quality.url;
      A.src = current.audioUrl;
      els.art.classList.add('hidden');
    } else {
      showSpinner(false);
      toast('No playable stream found for this video.');
      return;
    }

    this.applyVolume();
    this.applySpeed();
    this.master.load();
    if (this.secondary) this.secondary.load();

    const onReady = () => {
      this.play();
      this.master.removeEventListener('canplay', onReady);
    };
    this.master.addEventListener('canplay', onReady);
    this.startDrift();
  },

  play() {
    this._wantsPlaying = true;
    this.master.play().catch(() => {});
    if (this.secondary) {
      this.secondary.currentTime = this.master.currentTime;
      this.secondary.play().catch(() => {});
    }
  },
  pause() {
    this._wantsPlaying = false;
    this.clearBufferGrace();
    this.master.pause();
    if (this.secondary) this.secondary.pause();
  },
  toggle() {
    if (this.master.paused) this.play(); else this.pause();
  },
  seek(t) {
    this.master.currentTime = t;
    if (this.secondary) this.secondary.currentTime = t;
  },
  applyVolume() {
    const v = data.settings.volume;
    this.soundEl.volume = v;
    this.soundEl.muted = v === 0;
    // make sure the non-sound elements stay muted
    if (this.mode === 'dual') els.video.muted = true;
  },
  applySpeed() {
    const r = parseFloat(els.speed.value) || 1;
    this.master.playbackRate = r;
    if (this.secondary) this.secondary.playbackRate = r;
  },

  // Drift-correction thresholds for dual mode (muted video + synced audio).
  // A hard `currentTime` jump on a *playing* <audio> element causes an
  // audible pop/glitch — every earlier version of the watchdog snapped audio
  // back into sync any time drift exceeded 0.3s, which is exactly what read
  // as "audio stutter" (video stayed smooth because it was never touched).
  // We now use a tiered response instead of one hard threshold:
  //   < SOFT_SYNC_THRESHOLD  — inaudible, ignore (avoids constant micro-corrections)
  //   < HARD_SYNC_THRESHOLD  — gently slew playbackRate so the audio eases back
  //                            into sync over ~1s with no audible artifact
  //   >= HARD_SYNC_THRESHOLD — snap (only reached from seeks, buffer flushes,
  //                            or background-resume, where a single correction
  //                            is unavoidable and rare)
  SOFT_SYNC_THRESHOLD: 0.08,
  HARD_SYNC_THRESHOLD: 0.3,
  SLEW_RATE: 0.04, // playbackRate offset applied while easing back into sync

  // Shared by the 1s watchdog tick and the master 'playing' handler so both
  // paths get the same no-pop behavior.
  //
  // AUDIO IS THE CLOCK. Corrections are applied to the muted <video>, never
  // to the playing <audio>: seeking or rate-shifting the silent element is
  // invisible, while the same operation on the sounding element is exactly
  // the "stutter" users hear. On a phone that can't sustain the video
  // bitrate, the video rebuffers in a loop — under the old master-is-clock
  // scheme every one of those cycles seeked the live audio (and backgrounding
  // the app, which halts video decode and this timer, made playback smooth —
  // the reported iPhone symptom).
  correctDrift() {
    if (this.mode !== 'dual' || !this.secondary || this.master.paused) return;
    if (this.secondary.paused) return; // resume paths own this case
    const drift = this.secondary.currentTime - this.master.currentTime;
    const abs = Math.abs(drift);
    if (abs >= this.HARD_SYNC_THRESHOLD) {
      // Snap the muted video onto the audio clock (inaudible).
      this._internalSeek = true;
      this.master.currentTime = this.secondary.currentTime;
      this._resyncSpeed();
    } else if (abs >= this.SOFT_SYNC_THRESHOLD) {
      // Video behind audio -> speed the video up; ahead -> slow it down.
      const base = parseFloat(els.speed.value) || 1;
      this.master.playbackRate = drift > 0 ? base + this.SLEW_RATE : base - this.SLEW_RATE;
    } else {
      this._resyncSpeed();
    }
  },
  // Restore playbackRates to the user-selected speed once drift is within
  // tolerance (or after a hard snap) so a slew correction never lingers and
  // overshoots.
  _resyncSpeed() {
    const base = parseFloat(els.speed.value) || 1;
    if (this.master && this.master.playbackRate !== base) this.master.playbackRate = base;
    if (this.secondary && this.secondary.playbackRate !== base) this.secondary.playbackRate = base;
  },

  // Runs for every mode (not just 'dual') as a watchdog: iOS Safari — most
  // visibly in PWA/standalone mode on iPhone — can silently pause the element
  // actually producing sound (the <audio> tag in 'dual'/'audio' mode) while
  // the screen is locked or the app is backgrounded, without necessarily
  // firing a 'pause' event we react to. The muted <video> element in 'dual'
  // mode is unaffected and keeps rolling, which is why video looks fine while
  // audio silently drops. Comparing `_wantsPlaying` (our intent) against the
  // element's actual `.paused` state lets us detect and resume from that.
  startDrift() {
    this.stopDrift();
    this.driftTimer = setInterval(() => {
      if (!this._wantsPlaying) return;
      // Dual-mode: keep the secondary audio track in sync with the master,
      // via the tiered corrector above (no hard snap for small, normal drift).
      this.correctDrift();
      // The element that's actually producing sound in the current mode.
      const sounder = this.soundEl;
      if (sounder && sounder.paused) {
        sounder.currentTime = this.master.currentTime;
        sounder.play().catch(() => {});
      }
    }, 1000);
  },
  stopDrift() {
    if (this.driftTimer) clearInterval(this.driftTimer);
    this.driftTimer = null;
    this.clearBufferGrace();
  },
  // Cancel a pending "pause synced audio after a stall" timer — used once the
  // video recovers (playing/canplay), on an explicit pause, and when tearing
  // down the current element (attach()/stopDrift()) so a stale timer never
  // fires against a track that's already moved on.
  clearBufferGrace() {
    if (this.bufferGraceTimer) {
      clearTimeout(this.bufferGraceTimer);
      this.bufferGraceTimer = null;
    }
  },
  // Called when the page/tab regains foreground focus (screen unlock, app
  // switch back). This is the single most common trigger for iOS silently
  // pausing background audio, so we react immediately here instead of
  // waiting up to 1s for the next startDrift() tick.
  resumeIfNeeded() {
    if (!this._wantsPlaying) return;
    const sounder = this.soundEl;
    if (sounder && sounder.paused) {
      sounder.currentTime = this.master.currentTime;
      sounder.play().catch(() => {});
    }
    if (this.mode === 'dual' && this.secondary && this.secondary.paused && !this.master.paused) {
      this.secondary.currentTime = this.master.currentTime;
      this.secondary.play().catch(() => {});
    }
  },
};

// Recover from iOS silently pausing background audio the moment the app
// returns to the foreground (screen unlock, switching back from another app,
// or the PWA being restored from a frozen/backgrounded state). This fires far
// faster than the 1s watchdog interval, which matters for a jarring "audio
// paused, video kept playing" UX.
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible') Player.resumeIfNeeded();
});
window.addEventListener('pageshow', () => Player.resumeIfNeeded());
window.addEventListener('focus', () => Player.resumeIfNeeded());

function showSpinner(on) {
  els.spinner.classList.toggle('hidden', !on);
}

function chooseQuality() {
  const qs = current.qualities || [];
  if (!qs.length) return null;
  const pref = data.settings.quality;
  if (pref && pref !== 'auto') {
    const match = qs.find((q) => q.label === pref);
    if (match) return match;
  }
  // auto: best quality at or below 720p, else the lowest available.
  const sorted = [...qs].sort((a, b) => b.height - a.height);
  return sorted.find((q) => q.height <= 720) || sorted[sorted.length - 1];
}

function buildQualityMenu() {
  const qs = current.qualities || [];
  els.quality.innerHTML = '';
  const auto = document.createElement('option');
  auto.value = 'auto';
  auto.textContent = 'Auto';
  els.quality.appendChild(auto);
  const seen = new Set();
  for (const q of qs) {
    if (seen.has(q.label)) continue;
    seen.add(q.label);
    const o = document.createElement('option');
    o.value = q.label;
    o.textContent = q.label + (q.hasAudio ? '' : '');
    els.quality.appendChild(o);
  }
  els.quality.value = data.settings.quality || 'auto';
}

// ---------- Player media events (master mirrors to secondary) ----------
function wirePlayerEvents() {
  const V = els.video, A = els.audio;

  const masterIs = (el) => Player.master === el;

  function bind(el) {
    el.addEventListener('play', () => { if (masterIs(el) && Player.secondary && Player.secondary.paused) { Player.secondary.currentTime = el.currentTime; Player.secondary.play().catch(() => {}); } updatePlayBtn(); });
    el.addEventListener('pause', () => { if (masterIs(el) && Player.secondary) { Player.clearBufferGrace(); Player.secondary.pause(); } updatePlayBtn(); });
    el.addEventListener('seeking', () => {
      if (!masterIs(el) || !Player.secondary) return;
      // Drift correction seeks the muted video onto the audio clock; mirroring
      // that back onto the audio would re-create the audible snap it exists to
      // avoid. Only user/programmatic seeks propagate to the audio.
      if (Player._internalSeek) { Player._internalSeek = false; return; }
      Player.secondary.currentTime = el.currentTime;
    });
    // Video re-buffering must NEVER interrupt the audio: the audio element is
    // the playback clock (see correctDrift). Earlier versions paused the
    // synced audio when a video stall outlasted a 250ms grace window and
    // snapped its clock on recovery — on a phone that can't sustain the video
    // bitrate that cycle repeats indefinitely and is heard as constant
    // stuttering. Now a stalling video just shows the spinner and catches up
    // to the audio clock (or gets snapped forward by the watchdog) when ready.
    el.addEventListener('waiting', () => {
      if (masterIs(el)) showSpinner(true);
    });
    el.addEventListener('playing', () => {
      if (!masterIs(el)) return;
      showSpinner(false);
      Player.clearBufferGrace();
      if (Player.secondary && !el.paused && Player.secondary.paused) {
        // Resume-only path (audio silently paused by iOS or a real stall):
        // seeking a *paused* audio element is inaudible, so sync then play.
        Player.secondary.currentTime = el.currentTime;
        Player.secondary.play().catch(() => {});
      } else if (Player.secondary && !el.paused) {
        // Audio kept playing through the video stall — realign the video.
        Player.correctDrift();
      }
    });
    el.addEventListener('canplay', () => { if (masterIs(el)) showSpinner(false); });
    el.addEventListener('timeupdate', () => {
      if (masterIs(el)) {
        updateProgress();
        // Persist playback position every 10s. timeupdate fires ~4×/s, so
        // gate on the 10s bucket actually changing — otherwise persist()
        // (full JSON.stringify + synchronous localStorage write) ran ~4
        // times back-to-back within each matching second, a periodic
        // main-thread stall on phones.
        if (current && current.meta) {
          const t = Player.master.currentTime;
          const bucket = Math.floor(t / 10);
          if (t > 5 && bucket !== Player._lastPersistBucket) {
            Player._lastPersistBucket = bucket;
            data.resumePositions[current.meta.id] = t;
            persist();
          }
        }
      }
    });
    el.addEventListener('pause', () => {
      if (masterIs(el) && current && current.meta) {
        const t = Player.master.currentTime;
        if (t > 1) {
          data.resumePositions[current.meta.id] = t;
          persist();
        }
      }
    });
    el.addEventListener('loadedmetadata', () => { if (masterIs(el)) updateProgress(); });
    el.addEventListener('ended', () => { if (masterIs(el)) onTrackEnded(); });
    el.addEventListener('error', () => {
      // A failed master stream, or a failed synced-audio track in dual mode,
      // both warrant falling back to the next candidate.
      if (masterIs(el) || el === Player.secondary) Player.onMediaError();
    });
  }
  bind(V);
  bind(A);
}

function updatePlayBtn() {
  els.playBtn.textContent = Player.master.paused ? '▶' : '⏸';
  $('miniPlayBtn').textContent = Player.master.paused ? '▶' : '⏸';
}
// Reflect cache state on the now-playing Save button.
function updateNowPlayingActions() {
  if (!current || !current.meta) return;
  const id = current.meta.id;
  const btn = els.saveBtn;
  if (!btn) return;
  if (removing.has(id)) {
    btn.textContent = '⏳ Removing…';
    btn.classList.remove('done');
    btn.disabled = true;
  } else if (downloading.has(id)) {
    btn.textContent = '⏳ Saving…';
    btn.classList.remove('done');
    btn.disabled = true;
  } else if (cachedIds.has(id)) {
    btn.textContent = '✓ Saved';
    btn.classList.add('done');
    btn.disabled = false;
    btn.title = 'Saved for offline — click to remove from cache';
  } else {
    btn.textContent = '⬇ Save';
    btn.classList.remove('done');
    btn.disabled = false;
    btn.title = 'Save this video for offline playback';
  }
}
function updateProgress() {
  const cur = Player.master.currentTime || 0;
  const dur = Player.master.duration || current?.meta?.duration || 0;
  els.curTime.textContent = fmtTime(cur);
  els.durTime.textContent = fmtTime(dur);
  if (dur) els.seek.value = String((cur / dur) * 1000);
  updateMiniBar();
  // A-B loop: passing B behaves like the track ending. Loop the A-B segment only
  // when "loop current" is on; otherwise B is the effective end of the track and
  // we advance per the repeat settings (next track, or wrap when "repeat list").
  if (abA !== null && abB !== null && abB > abA && cur >= abB) {
    if (data.settings.loopOne) {
      Player.seek(abA);
    } else if (!advanceQueue()) {
      // Nothing to advance to (repeat off, last track) — stop at B like a real end.
      Player.pause();
      Player.seek(abA);
      updatePlayBtn();
      $('upnext').classList.add('hidden');
    }
  }
}

// ============================================================================
// Mini now-playing bar
// ============================================================================
function showMiniBar() {
  if (!current || !current.meta) return;
  $('miniTitle').textContent = current.meta.title;
  const miniCh = $('miniChannel');
  miniCh.textContent = current.meta.channel || '';
  miniCh.classList.toggle('link', !!channelKeyOf(current.meta));
  $('miniBar').classList.remove('hidden');
  updateMiniBar();
}
function updateMiniBar() {
  if (!current || !current.meta) return;
  const cur = Player.master.currentTime || 0;
  const dur = Player.master.duration || 0;
  $('miniTime').textContent = fmtTime(cur);
  if (dur > 0) {
    $('miniProgressFill').style.width = Math.min(100, (cur / dur) * 100) + '%';
  }
}
function hideMiniBar() {
  $('miniBar').classList.add('hidden');
}

// ============================================================================
// History
// ============================================================================
function addToHistory(meta) {
  data.playCount = data.playCount || {};
  data.playCount[meta.id] = (data.playCount[meta.id] || 0) + 1;
  data.history = data.history.filter((v) => v.id !== meta.id);
  data.history.unshift({
    id: meta.id, title: meta.title, channel: meta.channel,
    channelId: meta.channelId || '', channelUrl: meta.channelUrl || '',
    duration: meta.duration, thumbnail: meta.thumbnail,
  });
  if (data.history.length > 200) data.history.length = 200;
  persist();
  if (view.type === 'history') renderList();
}

// ============================================================================
// Queue / navigation
// ============================================================================
function playFromList(list, index, source = '') {
  queue = list;
  queueIndex = index;
  queueSource = source;
  unshuffledQueue = null;
  if (data.settings.shuffle) applyShuffle();
  Player.loadVideo(queue[queueIndex]);
  renderUpNext();
}

// ---------- Shuffle ----------
// Shuffles only the UPCOMING part of the live queue (Fisher-Yates); the
// already-played prefix and the current track stay where they are. The
// pre-shuffle order is kept so turning shuffle off restores it.
let unshuffledQueue = null;

function applyShuffle() {
  if (queue.length <= queueIndex + 2) return; // nothing meaningful to shuffle
  if (!unshuffledQueue) unshuffledQueue = queue.slice();
  const upcoming = queue.slice(queueIndex + 1);
  for (let i = upcoming.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [upcoming[i], upcoming[j]] = [upcoming[j], upcoming[i]];
  }
  queue = queue.slice(0, queueIndex + 1).concat(upcoming);
}

function toggleShuffle() {
  data.settings.shuffle = !data.settings.shuffle;
  if (data.settings.shuffle) {
    applyShuffle();
  } else if (unshuffledQueue) {
    const playingId = current && current.meta && current.meta.id;
    queue = unshuffledQueue;
    unshuffledQueue = null;
    const idx = playingId ? queue.findIndex((v) => v.id === playingId) : -1;
    if (idx >= 0) queueIndex = idx;
  }
  persist();
  updateLoopRepeatButtons();
  renderUpNext();
  toast(data.settings.shuffle ? 'Shuffle on' : 'Shuffle off');
}

// ---------- Temporary queue ----------
function updateQueueBadge() {
  const count = data.queue.length;
  const b = $('navQueueCount');
  if (b) { b.textContent = String(count); b.classList.toggle('hidden', count === 0); }
  // Mirror to bottom-nav badge
  const bb = $('bnQueueCount');
  if (bb) { bb.textContent = String(count); bb.classList.toggle('hidden', count === 0); }
}
function addToQueue(video, { quiet = false } = {}) {
  if (!video || !video.id) return;
  if (data.queue.some((v) => v.id === video.id)) { if (!quiet) toast('Already in queue'); return; }
  data.queue.push(slim(video));
  persist();
  updateQueueBadge();
  if (view.type === 'queue') renderList();
  if (!quiet) toast('Added to queue');
}
function removeFromQueue(id) {
  data.queue = data.queue.filter((v) => v.id !== id);
  // Keep an in-progress queue playback in sync if it was sourced from the queue.
  if (queueSource === 'queue') {
    const playingId = current && current.meta && current.meta.id;
    queue = data.queue.slice();
    queueIndex = playingId ? queue.findIndex((v) => v.id === playingId) : -1;
    renderUpNext();
  }
  persist();
  updateQueueBadge();
  if (view.type === 'queue') renderList();
}
function clearQueue() {
  data.queue = [];
  persist();
  updateQueueBadge();
  if (view.type === 'queue') renderList();
  toast('Queue cleared');
}
function playQueue(index = 0) {
  if (!data.queue.length) return;
  playFromList(data.queue.slice(), index, 'queue');
}

function renderUpNext() {
  const upcoming = queue.slice(queueIndex + 1);
  if (!upcoming.length) { $('upnext').classList.add('hidden'); return; }
  $('upnext').classList.remove('hidden');
  $('upnextCount').textContent = String(upcoming.length);
  const list = $('upnextList');
  list.innerHTML = '';
  upcoming.forEach((v) => {
    const item = document.createElement('div');
    item.className = 'upnext-item';
    item.innerHTML = `
      <img src="${v.thumbnail || ''}" alt="" loading="lazy" />
      <div class="upnext-info">
        <div class="upnext-title"></div>
        <div class="upnext-channel"></div>
      </div>`;
    item.querySelector('.upnext-title').textContent = v.title;
    const upChEl = item.querySelector('.upnext-channel');
    upChEl.textContent = v.channel || '';
    if (channelKeyOf(v)) {
      upChEl.classList.add('link');
      upChEl.title = 'View channel';
      upChEl.addEventListener('click', (e) => { e.stopPropagation(); openChannel(channelKeyOf(v), v.channel); });
    }
    item.addEventListener('click', () => {
      const idx = queue.findIndex((x) => x.id === v.id);
      if (idx >= 0) { queueIndex = idx - 1; playNext(); }
    });
    list.appendChild(item);
  });
}
// Advance to the next track. Wraps to the start when "repeat list" is on.
function advanceQueue() {
  if (queueIndex >= 0 && queueIndex < queue.length - 1) {
    queueIndex++;
  } else if (data.settings.repeatMode === 'all' && queue.length) {
    queueIndex = 0;
  } else {
    return false;
  }
  // Advancing to a new track always starts from the beginning (or its A point),
  // never the previous resume timestamp — and never scrolls the user away
  // from whatever page they are browsing while listening.
  Player.loadVideo(queue[queueIndex], { resume: false, reveal: false });
  renderUpNext();
  return true;
}
function playNext() {
  if (!advanceQueue()) { updatePlayBtn(); $('upnext').classList.add('hidden'); }
}
// Fired when a track finishes on its own — honors single-video loop first.
function onTrackEnded() {
  if (data.settings.loopOne) { Player.seek(0); Player.play(); return; }
  if (!advanceQueue()) { updatePlayBtn(); $('upnext').classList.add('hidden'); }
}
function toggleLoopOne() {
  data.settings.loopOne = !data.settings.loopOne;
  persist();
  updateLoopRepeatButtons();
  toast(data.settings.loopOne ? 'Looping current video' : 'Loop off');
}
function toggleRepeat() {
  data.settings.repeatMode = data.settings.repeatMode === 'all' ? 'off' : 'all';
  persist();
  updateLoopRepeatButtons();
  toast(data.settings.repeatMode === 'all' ? 'Repeating list' : 'Repeat off');
}
function updateLoopRepeatButtons() {
  if (els.loopBtn) els.loopBtn.classList.toggle('active', !!data.settings.loopOne);
  if (els.repeatBtn) els.repeatBtn.classList.toggle('active', data.settings.repeatMode === 'all');
  if (els.shuffleBtn) els.shuffleBtn.classList.toggle('active', !!data.settings.shuffle);
}
function playPrev() {
  if (Player.master.currentTime > 3) { Player.seek(0); return; }
  if (queueIndex > 0) {
    queueIndex--;
    Player.loadVideo(queue[queueIndex], { resume: false, reveal: false });
    renderUpNext();
  }
}

// ============================================================================
// Channel view — list a channel's uploads with quick actions
// ============================================================================
async function openChannel(channelKey, displayName) {
  if (!channelKey) { toast('No channel info for this video'); return; }
  view = { type: 'channel' };
  channelData = { name: displayName || 'Channel', url: channelKey, key: channelKey, results: [], loading: true };
  render();
  try {
    const res = await API.getChannel(channelKey);
    if (view.type !== 'channel' || channelData.key !== channelKey) return; // navigated away
    if (!res || !res.ok) throw new Error(res?.error || 'Could not load channel');
    channelData.results = res.results || [];
    channelData.name = res.channel || displayName || 'Channel';
    channelData.url = res.channelUrl || channelKey;
    channelData.loading = false;
    renderList();
  } catch (err) {
    channelData.loading = false;
    if (view.type === 'channel') {
      els.cards.innerHTML = '';
      els.status.classList.remove('hidden');
      els.status.textContent = '⚠ ' + err.message;
    }
  }
}

// The identifier we hand the backend to look a channel up (URL preferred).
function channelKeyOf(v) {
  return (v && (v.channelUrl || v.channelId)) || '';
}

// ============================================================================
// Rendering
// ============================================================================
function renderSidebar() {
  els.playlistList.innerHTML = '';
  for (const pl of data.playlists) {
    const item = document.createElement('div');
    item.className = 'playlist-item' + (view.type === 'playlist' && view.id === pl.id ? ' active' : '');
    item.innerHTML = `<span class="pl-name"></span><span class="pl-count">${pl.videos.length}</span>`;
    item.querySelector('.pl-name').textContent = pl.name;
    item.addEventListener('click', () => { view = { type: 'playlist', id: pl.id }; render(); });
    els.playlistList.appendChild(item);
  }
  document.querySelectorAll('.nav-item').forEach((b) => {
    b.classList.toggle('active', b.dataset.view === view.type);
  });
  // Keep bottom-nav in sync with sidebar nav active state
  document.querySelectorAll('.bottom-nav-btn').forEach((b) => {
    b.classList.toggle('active', b.dataset.view === view.type);
  });
  updateQueueBadge();
  updateDownloadBadge();
}

function currentList() {
  if (view.type === 'search') return searchResults;
  if (view.type === 'history') return data.history;
  if (view.type === 'queue') return data.queue;
  if (view.type === 'channel') return channelData.results;
  if (view.type === 'smart') return getSmartList(view.smartType);
  if (view.type === 'playlist') {
    const pl = data.playlists.find((p) => p.id === view.id);
    return pl ? pl.videos : [];
  }
  return [];
}

function showSearchSkeletons() {
  els.status.classList.add('hidden');
  els.cards.innerHTML = '';
  for (let i = 0; i < 8; i++) {
    const s = document.createElement('div');
    s.className = 'card skeleton-card';
    s.innerHTML = `
      <div class="thumb skeleton-shimmer"></div>
      <div class="card-info">
        <div class="skeleton-line skeleton-shimmer" style="width:85%"></div>
        <div class="skeleton-line skeleton-shimmer" style="width:55%;margin-top:8px"></div>
      </div>
      <div class="skeleton-dot skeleton-shimmer"></div>`;
    els.cards.appendChild(s);
  }
}

function renderList() {
  if (view.type === 'settings') { renderSettings(); return; }
  if (view.type === 'saved') { renderSaved(); return; }
  if (view.type === 'downloads') { renderDownloads(); return; }

  // Filter bar — show for filterable views
  const filterableViews = ['history', 'playlist', 'queue', 'channel', 'smart'];
  const showFilter = filterableViews.includes(view.type);
  $('listFilterBar').classList.toggle('hidden', !showFilter);
  $('batchBar').classList.toggle('hidden', !selectMode);
  if (selectMode) $('batchCount').textContent = `${selectedIds.size} selected`;

  // Build the list, applying filter if active
  let list = currentList();
  if (listFilter && showFilter) {
    const q = listFilter.toLowerCase();
    list = list.filter((v) => (v.title || '').toLowerCase().includes(q) || (v.channel || '').toLowerCase().includes(q));
  }

  els.cards.innerHTML = '';
  els.cards.classList.toggle('select-mode', selectMode);
  els.listActions.innerHTML = '';

  if (view.type === 'search') {
    els.listTitle.textContent = 'Search results';
  } else if (view.type === 'history') {
    els.listTitle.textContent = 'History';
    if (list.length) {
      const clear = document.createElement('button');
      clear.textContent = 'Clear';
      clear.onclick = () => { data.history = []; persist(); renderList(); };
      els.listActions.appendChild(clear);
    }
  } else if (view.type === 'queue') {
    els.listTitle.textContent = 'Queue';
    if (list.length) {
      const playAll = document.createElement('button');
      playAll.textContent = '▶ Play queue';
      playAll.onclick = () => playQueue(0);
      const clear = document.createElement('button');
      clear.textContent = 'Clear';
      clear.onclick = clearQueue;
      els.listActions.append(playAll, clear);
    }
  } else if (view.type === 'smart') {
    const sp = SMART_PLAYLISTS.find((s) => s.id === view.smartType);
    els.listTitle.textContent = sp ? sp.label : 'Auto Playlist';
    if (list.length) {
      const playAll = document.createElement('button');
      playAll.textContent = '▶ Play all';
      playAll.onclick = () => playFromList(list.slice(), 0, 'smart:' + view.smartType);
      const queueAll = document.createElement('button');
      queueAll.textContent = '＋ Queue all';
      queueAll.onclick = () => { list.forEach((v) => addToQueue(v, { quiet: true })); toast('Added to queue'); };
      els.listActions.append(playAll, queueAll);
    }
  } else if (view.type === 'channel') {
    els.listTitle.textContent = channelData.name || 'Channel';
    if (list.length) {
      const playAll = document.createElement('button');
      playAll.textContent = '▶ Play all';
      playAll.onclick = () => playFromList(channelData.results.slice(), 0, 'channel');
      const queueAll = document.createElement('button');
      queueAll.textContent = '＋ Queue all';
      queueAll.onclick = () => { channelData.results.forEach((v) => addToQueue(v, { quiet: true })); toast('Added channel to queue'); };
      els.listActions.append(playAll, queueAll);
    }
  } else if (view.type === 'playlist') {
    const pl = data.playlists.find((p) => p.id === view.id);
    els.listTitle.textContent = pl ? pl.name : 'Playlist';
    if (pl) {
      const playAll = document.createElement('button');
      playAll.textContent = '▶ Play all';
      playAll.onclick = () => { if (pl.videos.length) playFromList(pl.videos, 0, 'playlist:' + pl.id); };
      const queueAll = document.createElement('button');
      queueAll.textContent = '＋ Queue';
      queueAll.onclick = () => { pl.videos.forEach((v) => addToQueue(v, { quiet: true })); toast('Added playlist to queue'); };
      const rename = document.createElement('button');
      rename.textContent = 'Rename';
      rename.onclick = () => renamePlaylist(pl);
      const del = document.createElement('button');
      del.textContent = 'Delete';
      del.onclick = () => deletePlaylist(pl);
      els.listActions.append(playAll, queueAll, rename, del);
    }
  }

  // Select button for batch-operable views
  const batchViews = ['history', 'playlist', 'queue', 'smart'];
  if (batchViews.includes(view.type) && list.length) {
    const selBtn = document.createElement('button');
    selBtn.textContent = selectMode ? '✓ Selecting' : 'Select';
    selBtn.style.fontWeight = selectMode ? '700' : '';
    selBtn.onclick = toggleSelectMode;
    els.listActions.appendChild(selBtn);
  }

  // Channel still loading — show skeletons.
  if (view.type === 'channel' && channelData.loading && !list.length) {
    showSearchSkeletons();
    return;
  }

  if (!list.length) {
    els.status.classList.add('hidden');
    els.cards.innerHTML = '';
    const empty = document.createElement('div');
    empty.className = 'empty-state';

    if (view.type === 'search') {
      // Hero landing is already in the player pane — list pane stays bare.
      els.status.classList.remove('hidden');
      els.status.textContent = 'Search for something to begin.';
      return;
    }

    if (view.type === 'history') {
      empty.innerHTML = `
        <div class="empty-icon">🕘</div>
        <h3 class="empty-title">Nothing watched yet</h3>
        <p class="empty-desc">Your viewing history shows up here once you start playing videos.</p>
      `;
      addBrowseCta(empty, '🔍  Search videos');
    } else if (view.type === 'queue') {
      empty.innerHTML = `
        <div class="empty-icon">▶</div>
        <h3 class="empty-title">Your queue is empty</h3>
        <p class="empty-desc">Use <strong>+ Queue</strong> on any video to line it up. The queue is temporary and plays in order.</p>
      `;
      addBrowseCta(empty, '🔍  Find something to play');
    } else if (view.type === 'channel') {
      empty.innerHTML = `
        <div class="empty-icon">📺</div>
        <h3 class="empty-title">No videos found</h3>
        <p class="empty-desc">This channel didn't return any uploads.</p>
      `;
      addBrowseCta(empty, '🔍  Back to search');
    } else {
      // Playlist view — empty
      empty.innerHTML = `
        <div class="empty-icon">🎵</div>
        <h3 class="empty-title">This playlist is empty</h3>
        <p class="empty-desc">Add videos from search results or use the <strong>+ Playlist</strong> button while playing.</p>
      `;
      addBrowseCta(empty, '🔍  Browse videos');
    }

    els.cards.appendChild(empty);
    return;
  }
  els.status.classList.add('hidden');

  list.forEach((v, i) => els.cards.appendChild(renderCard(v, i, list)));
  markPlayingCard();
}

function addBrowseCta(empty, label) {
  const cta = document.createElement('button');
  cta.className = 'empty-cta';
  cta.textContent = label;
  cta.addEventListener('click', () => { view = { type: 'search' }; render(); });
  empty.appendChild(cta);
}

// ============================================================================
// Saved videos page — every offline-cached file with sizes + totals
// ============================================================================
async function renderSaved() {
  els.listTitle.textContent = 'Saved videos';
  els.listActions.innerHTML = '';
  els.status.classList.add('hidden');
  const c = els.cards;
  c.innerHTML = '<div class="status">Loading saved videos…</div>';

  let res;
  try { res = await API.cacheList(); } catch { res = null; }
  if (view.type !== 'saved') return; // navigated away

  if (!res || !res.ok) {
    c.innerHTML = '';
    const empty = document.createElement('div');
    empty.className = 'empty-state';
    empty.innerHTML = `
      <div class="empty-icon">💾</div>
      <h3 class="empty-title">Offline cache unavailable</h3>
      <p class="empty-desc">This build doesn't support saving videos for offline playback.</p>`;
    c.appendChild(empty);
    return;
  }

  const items = (res.items || []).slice().sort((a, b) => b.size - a.size);
  const total = res.total || 0;

  if (!items.length) {
    c.innerHTML = '';
    const empty = document.createElement('div');
    empty.className = 'empty-state';
    empty.innerHTML = `
      <div class="empty-icon">💾</div>
      <h3 class="empty-title">Nothing saved yet</h3>
      <p class="empty-desc">Use <strong>⬇ Save</strong> while playing, or add videos to a playlist to keep them offline.</p>`;
    addBrowseCta(empty, '🔍  Find videos');
    c.appendChild(empty);
    return;
  }

  // Header actions: total + clear all.
  const clearAll = document.createElement('button');
  clearAll.textContent = 'Clear all';
  clearAll.onclick = () => {
    showModal('Clear all saved videos?', document.createTextNode('Frees disk space. Playlist entries stay and re-download on demand.'), [
      { label: 'Cancel', onClick: closeModal },
      { label: 'Clear all', danger: true, onClick: async () => {
        try { await API.cacheClear(); } catch {}
        cachedIds.clear();
        closeModal();
        toast('Cache cleared');
        if (current) updateNowPlayingActions();
        renderSaved();
      } },
    ]);
  };
  els.listActions.appendChild(clearAll);

  c.innerHTML = '';
  const summary = document.createElement('div');
  summary.className = 'saved-summary';
  summary.innerHTML = `<span class="saved-total">${fmtBytes(total)}</span><span class="saved-sub">${items.length} video${items.length === 1 ? '' : 's'} stored offline</span>`;
  c.appendChild(summary);

  items.forEach((it) => {
    const v = videoById(it.id) || { id: it.id, title: videoTitleById(it.id), thumbnail: `https://i.ytimg.com/vi/${it.id}/mqdefault.jpg` };
    const row = document.createElement('div');
    row.className = 'card saved-card';
    row.dataset.id = it.id;
    row.innerHTML = `
      <div class="thumb"><img loading="lazy" src="${v.thumbnail || ''}" alt="" /></div>
      <div class="card-info">
        <div class="card-title"></div>
        <div class="card-channel saved-size">${fmtBytes(it.size)}</div>
      </div>
      <button class="card-del" title="Delete saved file">✕</button>`;
    row.querySelector('.card-title').textContent = v.title || it.id;
    row.addEventListener('click', (e) => {
      if (e.target.closest('.card-del')) return;
      playFromList([v], 0, 'saved');
    });
    row.querySelector('.card-del').addEventListener('click', async (e) => {
      e.stopPropagation();
      try { await API.cacheDelete(it.id); } catch {}
      cachedIds.delete(it.id);
      if (current && current.meta && current.meta.id === it.id) updateNowPlayingActions();
      markCardCacheState(it.id, 'none');
      toast('Removed from cache');
      renderSaved();
    });
    c.appendChild(row);
  });
  markPlayingCard();
}

// ============================================================================
// Downloads page — videos currently being saved
// ============================================================================
function renderDownloads() {
  els.listTitle.textContent = 'Downloads';
  els.listActions.innerHTML = '';
  els.status.classList.add('hidden');
  const c = els.cards;
  c.innerHTML = '';

  const active = [...downloadMeta.values()];
  if (!active.length) {
    const empty = document.createElement('div');
    empty.className = 'empty-state';
    empty.innerHTML = `
      <div class="empty-icon">⬇</div>
      <h3 class="empty-title">No active downloads</h3>
      <p class="empty-desc">Saves in progress show here with live status. Finished videos land in <strong>Saved</strong>.</p>`;
    const cta = document.createElement('button');
    cta.className = 'empty-cta';
    cta.textContent = '💾  View saved videos';
    cta.addEventListener('click', () => { view = { type: 'saved' }; render(); });
    empty.appendChild(cta);
    c.appendChild(empty);
    return;
  }

  const note = document.createElement('div');
  note.className = 'saved-summary';
  note.innerHTML = `<span class="saved-total">${active.length}</span><span class="saved-sub">download${active.length === 1 ? '' : 's'} in progress</span>`;
  c.appendChild(note);

  active.forEach((v) => {
    const row = document.createElement('div');
    row.className = 'card downloading';
    row.dataset.id = v.id;
    row.innerHTML = `
      <div class="thumb">
        <img loading="lazy" src="${v.thumbnail || ''}" alt="" />
        <div class="dl-progress"><div class="dl-bar"></div></div>
      </div>
      <div class="card-info">
        <div class="card-title"></div>
        <div class="card-channel">⏳ Saving for offline…</div>
      </div>`;
    row.querySelector('.card-title').textContent = v.title || v.id;
    c.appendChild(row);
  });
}

// ============================================================================
// Settings page
// ============================================================================
function videoById(id) {
  for (const pl of data.playlists) {
    const v = pl.videos.find((x) => x.id === id);
    if (v) return v;
  }
  const q = data.queue.find((x) => x.id === id);
  if (q) return q;
  const h = data.history.find((x) => x.id === id);
  if (h) return h;
  return null;
}
function videoTitleById(id) {
  const v = videoById(id);
  return v ? v.title : id;
}

async function renderSettings() {
  els.listTitle.textContent = 'Settings';
  els.listActions.innerHTML = '';
  const c = els.cards;
  els.status.classList.add('hidden');
  c.innerHTML = '';

  const wrap = document.createElement('div');
  wrap.className = 'settings';

  // ---- Playback ----
  const qualities = ['auto', '1080p', '720p', '480p', '360p', '240p'];
  const qOptions = qualities
    .map((q) => `<option value="${q}"${data.settings.quality === q ? ' selected' : ''}>${q === 'auto' ? 'Auto' : q}</option>`)
    .join('');

  const sel = (id, val, opts) => `<select id="${id}" class="set-select">` +
    opts.map(([v, label]) => `<option value="${v}"${val === v ? ' selected' : ''}>${label}</option>`).join('') +
    `</select>`;

  wrap.innerHTML = `
    <div class="set-group">
      <div class="set-group-title">Appearance &amp; accessibility</div>
      <label class="set-row">
        <span>Theme</span>
        ${sel('setTheme', data.settings.theme, [['dark', 'Dark'], ['light', 'Light'], ['contrast', 'High contrast']])}
      </label>
      <label class="set-row">
        <span>Font size</span>
        ${sel('setFont', data.settings.fontScale, [['small', 'Small'], ['normal', 'Normal'], ['large', 'Large'], ['xl', 'Extra large']])}
      </label>
      <label class="set-row">
        <span>Layout density</span>
        ${sel('setDensity', data.settings.density, [['comfortable', 'Comfortable'], ['compact', 'Compact']])}
      </label>
      <label class="set-row">
        <span>
          Performance mode
          <small>Turns off heavy visual effects (grain, blur, glows) for a faster, lighter UI.</small>
        </span>
        <input id="setPerf" type="checkbox" ${data.settings.perfMode ? 'checked' : ''} />
      </label>
      <label class="set-row">
        <span>
          Reduce motion
          <small>Disables animations and transitions.</small>
        </span>
        <input id="setMotion" type="checkbox" ${data.settings.reduceMotion ? 'checked' : ''} />
      </label>
    </div>

    <div class="set-group">
      <div class="set-group-title">Playback</div>
      <label class="set-row">
        <span>Default quality</span>
        <select id="setQuality" class="set-select">${qOptions}</select>
      </label>
      <label class="set-row">
        <span>Repeat list when finished</span>
        <input id="setRepeat" type="checkbox" ${data.settings.repeatMode === 'all' ? 'checked' : ''} />
      </label>
      <label class="set-row">
        <span>Loop the current video</span>
        <input id="setLoopOne" type="checkbox" ${data.settings.loopOne ? 'checked' : ''} />
      </label>
      <label class="set-row">
        <span>Default volume</span>
        <input id="setVolume" type="range" min="0" max="1" step="0.01" value="${data.settings.volume}" />
      </label>
      <label class="set-row">
        <span>Audio-only mode</span>
        <input id="setAudioOnly" type="checkbox" ${data.settings.audioOnly ? 'checked' : ''} />
      </label>
      <label class="set-row">
        <span>
          Save before playing
          <small>Downloads the whole video to this device first (the server compiles video + audio into one file), then plays the local copy. One stream instead of two, but playback starts after the download finishes.</small>
        </span>
        <input id="setSaveBeforePlay" type="checkbox" ${data.settings.saveBeforePlay ? 'checked' : ''} />
      </label>
    </div>

    <div class="set-group">
      <div class="set-group-title">Offline cache</div>
      <label class="set-row">
        <span>
          Auto-save playlist videos
          <small>Videos you add to a playlist are downloaded and kept for offline playback.</small>
        </span>
        <input id="setAutoPreload" type="checkbox" ${data.settings.autoPreload ? 'checked' : ''} />
      </label>
      <div class="set-row">
        <span>Storage used</span>
        <span id="cacheTotal" class="set-stat">…</span>
      </div>
      <label class="set-row">
        <span>
          Cache size limit
          <small>Auto-evict oldest files when cache exceeds this.</small>
        </span>
        <select id="cacheCapSelect" class="set-select">
          <option value="0">No limit</option>
          <option value="1073741824">1 GB</option>
          <option value="2147483648">2 GB</option>
          <option value="5368709120">5 GB</option>
          <option value="10737418240">10 GB</option>
        </select>
      </label>
      <div class="set-actions">
        <button id="clearCacheBtn" class="btn danger">Clear all cached videos</button>
      </div>
      <div id="cacheList" class="cache-list"></div>
    </div>

    <div class="set-group">
      <div class="set-group-title">Playlists</div>
      <div class="set-row" style="border:none;gap:8px;flex-wrap:wrap">
        <button id="exportBtn" class="btn">Export playlists</button>
        <button id="importBtn" class="btn">Import playlists</button>
        <input id="fileInput" type="file" accept=".json" style="display:none" />
      </div>
    </div>

    <div class="set-group">
      <div class="set-group-title">Backup &amp; restore</div>
      <label class="set-row">
        <span>
          Auto-backup
          <small>Export a full backup JSON on a schedule.</small>
        </span>
        <input id="setAutoBackup" type="checkbox" ${data.settings.autoBackupEnabled ? 'checked' : ''} />
      </label>
      <label class="set-row">
        <span>Backup interval</span>
        ${sel('setBackupInterval', String(data.settings.autoBackupIntervalDays || 7), [['1','Daily'],['7','Weekly'],['30','Monthly']])}
      </label>
      <div class="set-row" style="border:none;gap:8px;flex-wrap:wrap">
        <button id="backupNowBtn" class="btn">Export full backup now</button>
        <button id="importBackupBtn" class="btn">Import backup</button>
        <input id="backupFileInput" type="file" accept=".json" style="display:none" />
      </div>
    </div>

    <div class="set-group">
      <div class="set-group-title">Reset</div>
      <div class="set-row">
        <span>
          Force refresh UI
          <small>Re-fetches the app shell and assets, bypassing the browser cache, then reloads.</small>
        </span>
        <div class="set-actions" style="margin:0">
          <button id="refreshUiBtn" class="btn">Refresh UI</button>
        </div>
      </div>
      <div class="set-row">
        <span>
          Reset app
          <small>Clears all local storage, unregisters the service worker, and deletes every cache. You will need to reload after.</small>
        </span>
        <div class="set-actions" style="margin:0">
          <button id="resetAppBtn" class="btn danger">Reset app</button>
        </div>
      </div>
    </div>

    <div class="set-group">
      <div class="set-group-title">Online profile — sync between devices</div>
      <div class="set-row">
        <span>
          Linked profile
          <small>Playlists, history and settings sync to the server under this name. The name works like a passkey — anyone who knows it can load and change this data, so prefer a random one.</small>
        </span>
        <span id="profileStatus" class="set-stat">${data.profile && data.profile.name ? data.profile.name : 'Not linked'}</span>
      </div>
      <div class="set-actions" style="display:flex;gap:8px;flex-wrap:wrap">
        <button id="profileCreateBtn" class="btn">Create profile</button>
        <button id="profileLoadBtn" class="btn">Load profile</button>
        <button id="profileUnlinkBtn" class="btn danger"${data.profile && data.profile.name ? '' : ' style="display:none"'}>Unlink</button>
      </div>
    </div>

    <div class="set-group">
      <div class="set-group-title">About</div>
      <div class="set-row">
        <span>
          Version
          <small>App version and current build.</small>
        </span>
        <div class="set-actions" style="margin:0">
          <span id="aboutVersion" class="about-version">v${APP_VERSION}</span>
        </div>
      </div>
      <div class="set-row">
        <span>
          Build time
          <small>When the running server build was produced — compare across devices to confirm you're on the latest version.</small>
        </span>
        <span id="aboutBuildTime" class="about-version">…</span>
      </div>
    </div>`;

  c.appendChild(wrap);

  // ---- Wire appearance / accessibility ----
  $('setTheme').addEventListener('change', (e) => { data.settings.theme = e.target.value; applyAppearance(); persist(); });
  $('setFont').addEventListener('change', (e) => { data.settings.fontScale = e.target.value; applyAppearance(); persist(); });
  $('setDensity').addEventListener('change', (e) => { data.settings.density = e.target.value; applyAppearance(); persist(); });
  $('setPerf').addEventListener('change', (e) => { data.settings.perfMode = e.target.checked; applyAppearance(); persist(); });
  $('setMotion').addEventListener('change', (e) => { data.settings.reduceMotion = e.target.checked; applyAppearance(); persist(); });
  $('setRepeat').addEventListener('change', (e) => { data.settings.repeatMode = e.target.checked ? 'all' : 'off'; updateLoopRepeatButtons(); persist(); });
  $('setLoopOne').addEventListener('change', (e) => { data.settings.loopOne = e.target.checked; updateLoopRepeatButtons(); persist(); });

  // ---- Wire playback controls ----
  $('setQuality').addEventListener('change', (e) => {
    data.settings.quality = e.target.value;
    els.quality.value = e.target.value;
    persist();
  });
  $('setVolume').addEventListener('input', (e) => {
    data.settings.volume = parseFloat(e.target.value);
    els.volume.value = e.target.value;
    Player.applyVolume();
    els.muteBtn.textContent = data.settings.volume === 0 ? '🔇' : '🔊';
    persist();
  });
  $('setAudioOnly').addEventListener('change', (e) => {
    data.settings.audioOnly = e.target.checked;
    els.audioOnlyToggle.checked = e.target.checked;
    persist();
  });
  $('setSaveBeforePlay').addEventListener('change', (e) => {
    data.settings.saveBeforePlay = e.target.checked;
    persist();
  });
  $('setAutoPreload').addEventListener('change', (e) => {
    data.settings.autoPreload = e.target.checked;
    persist();
    if (e.target.checked) data.playlists.forEach(preloadPlaylist);
  });

  // ---- Cache management ----
  $('clearCacheBtn').addEventListener('click', () => {
    showModal('Clear all cached videos?', document.createTextNode('This frees disk space. Playlists keep their entries and will re-download on demand.'), [
      { label: 'Cancel', onClick: closeModal },
      {
        label: 'Clear all', danger: true, onClick: async () => {
          try { await API.cacheClear(); } catch {}
          cachedIds.clear();
          closeModal();
          toast('Cache cleared');
          if (current) updateNowPlayingActions();
          renderList();
        },
      },
    ]);
  });

  // ---- Populate live cache stats ----
  try {
    const res = await API.cacheList();
    if (view.type !== 'settings') return; // user navigated away
    const total = (res && res.total) || 0;
    const items = (res && res.items) || [];
    $('cacheTotal').textContent = `${fmtBytes(total)} · ${items.length} video${items.length === 1 ? '' : 's'}`;
    const listEl = $('cacheList');
    if (!items.length) {
      listEl.innerHTML = '<div class="cache-empty">No videos saved offline yet.</div>';
    } else {
      items.sort((a, b) => b.size - a.size);
      items.forEach((it) => {
        const row = document.createElement('div');
        row.className = 'cache-item';
        row.innerHTML = `<span class="ci-title"></span><span class="ci-size">${fmtBytes(it.size)}</span><button class="ci-del" title="Delete from cache">✕</button>`;
        row.querySelector('.ci-title').textContent = videoTitleById(it.id);
        row.querySelector('.ci-del').addEventListener('click', async () => {
          try { await API.cacheDelete(it.id); } catch {}
          cachedIds.delete(it.id);
          if (current && current.meta && current.meta.id === it.id) updateNowPlayingActions();
          toast('Removed from cache');
          renderList();
        });
        listEl.appendChild(row);
      });
    }
  } catch {
    const t = $('cacheTotal');
    if (t) t.textContent = 'Offline cache not available in this build';
  }

  // ---- Cache cap ----
  const capSel = $('cacheCapSelect');
  capSel.value = String(data.settings.cacheCap || 0);
  capSel.addEventListener('change', (e) => {
    data.settings.cacheCap = parseInt(e.target.value) || 0;
    persist();
  });

  // ---- Export / Import playlists ----
  $('exportBtn').addEventListener('click', exportPlaylists);
  $('importBtn').addEventListener('click', () => $('fileInput').click());
  $('fileInput').addEventListener('change', importPlaylists);

  // ---- Backup ----
  $('setAutoBackup').addEventListener('change', (e) => { data.settings.autoBackupEnabled = e.target.checked; persist(); });
  $('setBackupInterval').addEventListener('change', (e) => { data.settings.autoBackupIntervalDays = parseInt(e.target.value) || 7; persist(); });
  $('backupNowBtn').addEventListener('click', () => doAutoBackup());
  $('importBackupBtn').addEventListener('click', () => $('backupFileInput').click());
  $('backupFileInput').addEventListener('change', importBackup);

  // ---- Online profile ----
  $('profileCreateBtn').addEventListener('click', createProfileFlow);
  $('profileLoadBtn').addEventListener('click', loadProfileFlow);
  $('profileUnlinkBtn').addEventListener('click', () => {
    data.profile = null;
    persist();
    updateProfileStatus();
    toast('Profile unlinked — this device stops syncing (server copy is kept)');
  });

  // ---- About: append server build tag + build time (WEB mode only) ----
  if (WEB) {
    try {
      const res = await fetch('/api/version', { cache: 'no-store' });
      if (res.ok && view.type === 'settings') {
        const v = await res.json();
        const el = $('aboutVersion');
        if (el && v.buildTag) el.textContent = `v${v.version || APP_VERSION} · build ${v.buildTag}`;
        const bt = $('aboutBuildTime');
        if (bt) {
          const t = v.buildTime ? new Date(v.buildTime) : null;
          if (t && !isNaN(t)) {
            // Shown in GMT+8 regardless of device/server timezone, plus build age.
            const gmt8 = new Date(t.getTime() + 8 * 3600e3).toISOString().replace('T', ' ').slice(0, 16);
            const s = Math.max(0, Math.floor((Date.now() - t.getTime()) / 1000));
            const ago = s < 60 ? `${s} second${s === 1 ? '' : 's'} ago`
              : s < 3600 ? `${Math.floor(s / 60)} minute${Math.floor(s / 60) === 1 ? '' : 's'} ago`
              : s < 86400 ? `${Math.floor(s / 3600)} hour${Math.floor(s / 3600) === 1 ? '' : 's'} ago`
              : `${Math.floor(s / 86400)} day${Math.floor(s / 86400) === 1 ? '' : 's'} ago`;
            bt.textContent = `${gmt8} +08 · built ${ago}`;
          } else {
            bt.textContent = 'unknown';
          }
        }
      }
    } catch { /* offline — leave the static version in place */ }
  }

  // ---- Reset ----
  $('refreshUiBtn').addEventListener('click', async () => {
    const btn = $('refreshUiBtn');
    btn.disabled = true;
    btn.textContent = 'Refreshing…';
    // hardReloadUI() deletes all caches and unregisters the SW so the
    // next page load always fetches fresh assets from the network.
    await hardReloadUI();
  });

  $('resetAppBtn').addEventListener('click', () => {
    showModal(
      'Reset app?',
      (() => { const p = document.createElement('p'); p.textContent = 'This clears all local data and caches. The page will reload.'; return p; })(),
      [
        { label: 'Cancel', onClick: closeModal },
        {
          label: 'Reset', danger: true, onClick: async () => {
            closeModal();
            try {
              localStorage.clear();
              sessionStorage.clear();
              if ('serviceWorker' in navigator) {
                const regs = await navigator.serviceWorker.getRegistrations();
                await Promise.all(regs.map((r) => r.unregister()));
              }
              if ('caches' in window) {
                const keys = await caches.keys();
                await Promise.all(keys.map((k) => caches.delete(k)));
              }
            } catch { /* best effort — reload regardless */ }
            window.location.href = '/';
          },
        },
      ],
    );
  });
}

function renderCard(v, index, list) {
  const card = document.createElement('div');
  const isCached = cachedIds.has(v.id);
  const isDownloading = downloading.has(v.id);
  card.className = 'card' + (isCached ? ' cached' : '') + (isDownloading ? ' downloading' : '') + (selectedIds.has(v.id) ? ' selected' : '');
  card.dataset.id = v.id;
  card.innerHTML = `
    <div class="batch-check"></div>
    <div class="thumb">
      <img loading="lazy" src="${v.thumbnail || ''}" alt="" />
      ${v.duration ? `<span class="dur">${fmtTime(v.duration)}</span>` : ''}
      <span class="saved-badge" title="${isDownloading ? 'Downloading…' : 'Saved offline'}">${isDownloading ? '' : '⬇'}</span>
      ${isDownloading ? '<div class="dl-progress"><div class="dl-bar"></div></div>' : ''}
    </div>
    <div class="card-info">
      <div class="card-title"></div>
      <div class="card-channel"></div>
    </div>
    <button class="card-menu" title="Add to playlist">＋</button>`;
  card.querySelector('.card-title').textContent = v.title;
  const chEl = card.querySelector('.card-channel');
  chEl.textContent = v.channel || '';
  if (channelKeyOf(v)) {
    chEl.classList.add('link');
    chEl.title = 'View channel';
    chEl.addEventListener('click', (e) => {
      e.stopPropagation();
      openChannel(channelKeyOf(v), v.channel);
    });
  }
  card.addEventListener('click', (e) => {
    if (e.target.closest('.card-menu') || e.target.closest('.card-del') || e.target.closest('.card-channel.link')) return;
    if (selectMode) { toggleSelectCard(v.id); return; }
    playFromList(list, index, view.type === 'playlist' ? 'playlist:' + view.id : view.type);
  });
  card.querySelector('.card-menu').addEventListener('click', (e) => {
    e.stopPropagation();
    openCardMenu(v);
  });

  // Per-item delete in playlist, queue, history, and smart views.
  if (view.type === 'playlist' || view.type === 'queue' || view.type === 'history' || view.type === 'smart') {
    const del = document.createElement('button');
    del.className = 'card-del';
    del.title = view.type === 'queue' ? 'Remove from queue'
              : (view.type === 'history' || view.type === 'smart') ? 'Remove from history'
              : 'Remove from playlist';
    del.textContent = '✕';
    del.addEventListener('click', (e) => {
      e.stopPropagation();
      if (view.type === 'queue') { removeFromQueue(v.id); return; }
      if (view.type === 'history' || view.type === 'smart') {
        data.history = data.history.filter((x) => x.id !== v.id);
        persist();
        renderList();
        return;
      }
      const pl = data.playlists.find((p) => p.id === view.id);
      if (pl) { pl.videos = pl.videos.filter((x) => x.id !== v.id); persist(); render(); toast('Removed from playlist'); }
    });
    card.appendChild(del);
  }

  // Drag-to-reorder in playlist and queue views
  if (view.type === 'playlist' || view.type === 'queue') {
    card.draggable = true;
    card.addEventListener('dragstart', () => {
      card.classList.add('dragging');
      dragSource = index;
    });
    card.addEventListener('dragend', () => {
      card.classList.remove('dragging');
    });
    card.addEventListener('dragover', (e) => {
      e.preventDefault();
      card.classList.add('drag-over');
    });
    card.addEventListener('dragleave', () => {
      card.classList.remove('drag-over');
    });
    card.addEventListener('drop', (e) => {
      e.preventDefault();
      card.classList.remove('drag-over');
      const from = dragSource;
      const to = index;
      if (from === to || from < 0) return;
      const arr = view.type === 'queue'
        ? data.queue
        : (data.playlists.find((p) => p.id === view.id) || {}).videos;
      if (!arr) return;
      const [moved] = arr.splice(from, 1);
      arr.splice(to, 0, moved);
      if (view.type === 'queue' && queueSource === 'queue') {
        const playingId = current && current.meta && current.meta.id;
        queue = data.queue.slice();
        queueIndex = playingId ? queue.findIndex((x) => x.id === playingId) : queueIndex;
        renderUpNext();
      }
      persist();
      renderList();
    });
  }

  return card;
}

function markPlayingCard() {
  document.querySelectorAll('.card').forEach((c) => {
    c.classList.toggle('playing', current && c.dataset.id === current.meta.id);
  });
}

let _lastViewKey = null;
function render() {
  listFilter = '';
  const fi = $('listFilterInput');
  if (fi) fi.value = '';
  // On mobile, navigating (nav item / playlist / search) dismisses the drawer.
  if (typeof closeSidebar === 'function') closeSidebar();
  exitSelectMode();
  renderSidebar();
  renderSmartSidebar();
  renderList();
  // Portrait PWA: reveal the freshly selected view. Only on actual view
  // changes — same-view re-renders (deletes, modal confirms, profile load)
  // must not hijack the scroll position. Skipped on the very first render.
  const viewKey = [view.type, view.id, view.smartType].join('|');
  if (_lastViewKey !== null && viewKey !== _lastViewKey) scrollListIntoViewPortrait();
  _lastViewKey = viewKey;
}

// ============================================================================
// Playlists
// ============================================================================
function exportPlaylists() {
  const json = JSON.stringify(data.playlists, null, 2);
  const blob = new Blob([json], { type: 'application/json' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = `ytplayer-playlists-${new Date().toISOString().slice(0, 10)}.json`;
  a.click();
  URL.revokeObjectURL(url);
  toast('Playlists exported');
}
function importPlaylists(e) {
  const file = e.target.files?.[0];
  if (!file) return;
  const reader = new FileReader();
  reader.onload = (ev) => {
    try {
      const imported = JSON.parse(ev.target.result);
      if (!Array.isArray(imported)) throw new Error('Invalid format');
      // Merge: skip duplicates by id, add new ones
      const existingIds = new Set(data.playlists.map((p) => p.id));
      for (const pl of imported) {
        if (pl.id && pl.name && Array.isArray(pl.videos) && !existingIds.has(pl.id)) {
          data.playlists.push(pl);
          existingIds.add(pl.id);
        }
      }
      persist();
      renderSidebar();
      toast(`Imported ${imported.length} playlist(s)`);
    } catch (err) {
      toast('⚠ Failed to import: ' + err.message);
    }
  };
  reader.readAsText(file);
  e.target.value = ''; // allow re-import of same file
}
function openCardMenu(video) {
  const inPlaylistView = view.type === 'playlist';
  const body = document.createElement('div');
  body.className = 'modal-list';

  // Quick: add to queue
  const queueBtn = document.createElement('button');
  queueBtn.textContent = '▶ Add to queue';
  queueBtn.onclick = () => { addToQueue(video); closeModal(); };
  body.appendChild(queueBtn);

  // Quick: save / remove offline
  const saveBtn = document.createElement('button');
  const isSaved = cachedIds.has(video.id);
  saveBtn.textContent = isSaved ? '✓ Saved offline — remove' : '⬇ Save for offline';
  saveBtn.onclick = async () => {
    closeModal();
    if (cachedIds.has(video.id)) {
      try { await API.cacheDelete(video.id); } catch {}
      cachedIds.delete(video.id);
      markCardCacheState(video.id, 'none');
      if (current && current.meta && current.meta.id === video.id) updateNowPlayingActions();
      toast('Removed from offline cache');
    } else {
      preload(video);
    }
  };
  body.appendChild(saveBtn);

  const divider = document.createElement('div');
  divider.className = 'modal-divider';
  divider.textContent = 'Playlists';
  body.appendChild(divider);

  data.playlists.forEach((pl) => {
    const btn = document.createElement('button');
    btn.textContent = (pl.videos.some((x) => x.id === video.id) ? '✓ ' : '+ ') + pl.name;
    btn.onclick = () => {
      const key = pl.id + ':' + video.id;
      // Guard against rapid double-clicks: the op is keyed by playlist+video,
      // so a second click while the first is in flight is ignored (prevents the
      // same video being pushed twice / state corruption).
      runExclusive(playlistOps, key, async () => {
        btn.disabled = true;
        btn.textContent = '⏳ ' + pl.name;
        // Recompute membership now (not from a flag captured at menu-open),
        // so the decision reflects current state.
        const has = pl.videos.some((x) => x.id === video.id);
        try {
          if (has) {
            pl.videos = pl.videos.filter((x) => x.id !== video.id);
          } else {
            pl.videos.push(slim(video));
            // Auto-cache for offline playback (fire-and-forget; its own guard
            // prevents duplicate downloads).
            preload(video);
          }
          persist();
          toast(has ? `Removed from ${pl.name}` : `Added to ${pl.name}`);
        } catch {
          toast('⚠ Could not update playlist');
        }
        closeModal();
        render();
      });
    };
    body.appendChild(btn);
  });

  const newBtn = document.createElement('button');
  newBtn.textContent = '＋ New playlist…';
  newBtn.onclick = () => { closeModal(); newPlaylist(video); };
  body.appendChild(newBtn);

  const actions = [{ label: 'Close', onClick: closeModal }];
  if (inPlaylistView) {
    actions.unshift({
      label: 'Remove from this playlist', danger: true, onClick: () => {
        const pl = data.playlists.find((p) => p.id === view.id);
        if (pl) { pl.videos = pl.videos.filter((x) => x.id !== video.id); persist(); }
        closeModal(); render();
      },
    });
  }
  showModal('Add to playlist', body, actions);
}

function slim(v) {
  return {
    id: v.id, title: v.title, channel: v.channel,
    channelId: v.channelId || '', channelUrl: v.channelUrl || '',
    duration: v.duration, thumbnail: v.thumbnail,
  };
}

function newPlaylist(addVideo) {
  const input = document.createElement('input');
  input.type = 'text';
  input.placeholder = 'Playlist name';
  showModal('New playlist', input, [
    { label: 'Cancel', onClick: closeModal },
    {
      label: 'Create', primary: true, onClick: () => {
        const name = input.value.trim();
        if (!name) return;
        const pl = { id: uid(), name, videos: addVideo ? [slim(addVideo)] : [] };
        data.playlists.push(pl);
        if (addVideo) preload(addVideo); // auto-cache for offline playback
        persist();
        closeModal();
        view = { type: 'playlist', id: pl.id };
        render();
        toast(`Created “${name}”`);
      },
    },
  ]);
  setTimeout(() => input.focus(), 50);
}

function renamePlaylist(pl) {
  const input = document.createElement('input');
  input.type = 'text';
  input.value = pl.name;
  showModal('Rename playlist', input, [
    { label: 'Cancel', onClick: closeModal },
    {
      label: 'Save', primary: true, onClick: () => {
        const name = input.value.trim();
        if (name) { pl.name = name; persist(); }
        closeModal(); render();
      },
    },
  ]);
  setTimeout(() => { input.focus(); input.select(); }, 50);
}

function deletePlaylist(pl) {
  showModal(`Delete “${pl.name}”?`, document.createTextNode('This cannot be undone.'), [
    { label: 'Cancel', onClick: closeModal },
    {
      label: 'Delete', danger: true, onClick: () => {
        data.playlists = data.playlists.filter((p) => p.id !== pl.id);
        persist();
        closeModal();
        view = { type: 'search' };
        render();
      },
    },
  ]);
}

// ============================================================================
// Keyboard shortcut help overlay
// ============================================================================
function toggleShortcutHelp() {
  const overlay = $('shortcutHelp');
  overlay.classList.toggle('hidden');
}

function wireShortcutHelp() {
  $('shortcutClose').addEventListener('click', () => $('shortcutHelp').classList.add('hidden'));
  $('shortcutHelp').addEventListener('click', (e) => {
    if (e.target.id === 'shortcutHelp') $('shortcutHelp').classList.add('hidden');
  });
}

// ============================================================================
// Modal
// ============================================================================
function showModal(title, bodyNode, actions) {
  $('modalTitle').textContent = title;
  const body = $('modalBody');
  body.innerHTML = '';
  body.appendChild(bodyNode);
  const act = $('modalActions');
  act.innerHTML = '';
  actions.forEach((a) => {
    const b = document.createElement('button');
    b.className = 'btn' + (a.primary ? ' primary' : '') + (a.danger ? ' danger' : '');
    b.textContent = a.label;
    b.onclick = a.onClick;
    act.appendChild(b);
  });
  $('modal').classList.remove('hidden');
}
function closeModal() { $('modal').classList.add('hidden'); }

// Tapping the dimmed backdrop dismisses the modal (same as Cancel/Later).
// Guarantees a modal can never permanently swallow every tap on the page —
// the full-screen backdrop sits above the bottom nav, so a modal the user
// doesn't notice (e.g. the update prompt popping under their thumb) used to
// read as "all navigation buttons stopped working".
$('modal').addEventListener('click', (e) => {
  if (e.target === $('modal')) closeModal();
});

// ============================================================================
// Portrait PWA detection & behavioral layer
// Mirrors the CSS media query: (display-mode: standalone) and (orientation: portrait)
// The JS layer adds:
//   • A `portrait-pwa` class on .app for any JS-driven conditional logic.
//   • Auto-close the sidebar when rotating into portrait-standalone mode.
//   • Auto-scroll the player into view when a video starts in portrait mode.
// ============================================================================
const _portraitPwaMQ = window.matchMedia
  ? window.matchMedia('(display-mode: standalone) and (orientation: portrait)')
  : { matches: false, addEventListener: () => {} };

function isPortraitPWA() {
  return _portraitPwaMQ.matches;
}

function applyPortraitPwaClass() {
  const app = document.querySelector('.app');
  if (!app) return;
  if (isPortraitPWA()) {
    app.classList.add('portrait-pwa');
  } else {
    app.classList.remove('portrait-pwa');
  }
}

// In portrait mode, .body is the single scroll container (player pane on top,
// list pane below). Scrolling it to 0 brings the player fully into view.
function scrollPlayerIntoViewPortrait() {
  if (!isPortraitPWA()) return;
  const body = document.querySelector('.body');
  if (body) {
    body.scrollTo({ top: 0, behavior: 'smooth' });
  }
}

// Bring the list pane to the top of the portrait scroll container so a newly
// rendered view is actually on-screen. While a track is playing, the player
// pane above it is taller than the whole viewport — without this scroll the
// fresh view sits invisibly below the fold and every nav tap looks dead
// (the mobile "can't navigate while playing" bug).
function scrollListIntoViewPortrait() {
  if (!isPortraitPWA()) return;
  const body = document.querySelector('.body');
  const listPane = document.querySelector('.list-pane');
  if (!body || !listPane) return;
  const top = body.scrollTop + listPane.getBoundingClientRect().top - body.getBoundingClientRect().top;
  body.scrollTo({ top, behavior: 'smooth' });
}

function setupPortraitPwaWatcher() {
  applyPortraitPwaClass();
  _portraitPwaMQ.addEventListener('change', () => {
    applyPortraitPwaClass();
    // Rotating into portrait-standalone: close any open sidebar drawer so the
    // full-width layout isn't obscured.
    if (isPortraitPWA()) closeSidebar();
  });
}

// ============================================================================
// Layout-viewport anchor guard (iOS Safari / standalone PWA)
//
// The app is a fixed-viewport layout: body is overflow:hidden and only inner
// panes scroll, so the document itself must always sit at scroll position 0.
// iOS WebKit can still scroll the *layout viewport* behind our back — exiting
// native video fullscreen (webkitEnterFullscreen is the only fullscreen path
// on iPhone, and iOS enters it by itself when the phone rotates while a video
// plays), the on-screen keyboard revealing a focused input, or any
// scrollIntoView() walking up into <html>. Once that happens, fixed elements
// (bottom nav, mini-bar) are still *drawn* in place but their hit-testing
// regions are offset by the stray scroll amount, so taps on the nav buttons
// silently do nothing — and because the body isn't user-scrollable there is
// no gesture that can undo it. That is the "nav buttons stop working after
// playing a video" iPhone bug. Snap the document back to 0 whenever it ends
// up scrolled; skipped while an input is focused so we never fight the
// keyboard auto-scroll, then re-anchored once focus leaves the field.
function setupViewportAnchorGuard() {
  const editing = () => {
    const el = document.activeElement;
    return !!el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable);
  };
  const reanchor = () => {
    if (editing()) return;
    const doc = document.scrollingElement || document.documentElement;
    if (window.scrollY || doc.scrollTop) window.scrollTo(0, 0);
  };
  window.addEventListener('scroll', reanchor);
  document.addEventListener('focusout', () => setTimeout(reanchor, 50));
  // Exiting native video fullscreen is the most reliable reproducer of the
  // stray-scroll state; the scroll event alone doesn't always fire for it.
  els.video.addEventListener('webkitendfullscreen', () => setTimeout(reanchor, 50));
  // iOS can offset the layout viewport WITHOUT firing a window scroll event
  // (keyboard dismissal animations, rotation mid-playback, in-app browser
  // chrome changes). visualViewport does report those; hook it when present.
  if (window.visualViewport) {
    window.visualViewport.addEventListener('scroll', () => setTimeout(reanchor, 50));
    window.visualViewport.addEventListener('resize', () => setTimeout(reanchor, 50));
  }
  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'visible') setTimeout(reanchor, 50);
  });
  // Last-resort safety net for offsets none of the events above report:
  // a 2s tick that reads scrollY (cheap) and snaps back only when displaced,
  // so "nav taps silently do nothing" can never persist longer than a beat.
  setInterval(reanchor, 2000);
}

// ============================================================================
// Events
// ============================================================================
// ============================================================================
// Responsive sidebar drawer (mobile)
// ============================================================================
function openSidebar() {
  const app = document.querySelector('.app');
  if (!app) return;
  app.classList.add('sidebar-open');
  const t = $('sidebarToggle');
  if (t) t.setAttribute('aria-expanded', 'true');
}
function closeSidebar() {
  const app = document.querySelector('.app');
  if (!app) return;
  app.classList.remove('sidebar-open');
  const t = $('sidebarToggle');
  if (t) t.setAttribute('aria-expanded', 'false');
}
function setupSidebarDrawer() {
  const toggle = $('sidebarToggle');
  const backdrop = $('sidebarBackdrop');
  if (toggle) {
    toggle.addEventListener('click', () => {
      const app = document.querySelector('.app');
      if (app && app.classList.contains('sidebar-open')) closeSidebar();
      else openSidebar();
    });
  }
  if (backdrop) backdrop.addEventListener('click', closeSidebar);
}

function wireUI() {
  setupSidebarDrawer();
  els.searchForm.addEventListener('submit', async (e) => {
    e.preventDefault();
    const q = els.searchInput.value.trim();
    if (!q) return;
    view = { type: 'search' };
    render();
    showSearchSkeletons();
    // Even when already on the search view (no view change for render() to
    // detect), a new query means the user wants to see the results.
    scrollListIntoViewPortrait();
    try {
      const res = await API.search(q);
      if (!res || !res.ok) throw new Error(res?.error || 'Search failed');
      searchResults = res.results || [];
      renderList();
    } catch (err) {
      els.cards.innerHTML = '';
      els.status.classList.remove('hidden');
      els.status.textContent = '⚠ ' + err.message;
    }
  });

  document.querySelectorAll('.nav-item').forEach((b) => {
    b.addEventListener('click', () => { view = { type: b.dataset.view }; render(); });
  });

  // Bottom-nav (portrait PWA) — same view switching as sidebar nav
  document.querySelectorAll('.bottom-nav-btn').forEach((b) => {
    b.addEventListener('click', () => { view = { type: b.dataset.view }; render(); });
  });

  els.newPlaylistBtn.addEventListener('click', () => newPlaylist(null));

// Landing-hero quick-search chips — rotate periodically
const CHIP_SETS = [
  [{ q: 'lofi hip hop radio', label: 'lofi beats' }, { q: 'live news', label: 'live news' }, { q: 'relaxing music', label: 'relaxing music' }, { q: 'podcast highlights', label: 'podcasts' }],
  [{ q: 'ambient jazz', label: 'ambient jazz' }, { q: 'tech talk coding', label: 'tech talks' }, { q: 'street food', label: 'street food' }, { q: 'travel vlog', label: 'travel vlogs' }],
  [{ q: 'synthwave mix', label: 'synthwave' }, { q: 'asmr rain', label: 'rain sounds' }, { q: 'documentary', label: 'documentaries' }, { q: 'workout music', label: 'workout' }],
  [{ q: '60s rock classics', label: 'classic rock' }, { q: 'nature sounds', label: 'nature' }, { q: 'data science', label: 'data science' }, { q: 'city walks', label: 'city walks' }],
];
const chipsEl = document.querySelector('.hero-suggests');
let chipRotation = 0;
function rotateChips() {
  chipRotation = (chipRotation + 1) % CHIP_SETS.length;
  const set = CHIP_SETS[chipRotation];
  const buttons = chipsEl.querySelectorAll('.chip');
  buttons.forEach((btn, i) => {
    if (i < set.length) {
      btn.dataset.q = set[i].q;
      btn.textContent = set[i].label;
    }
  });
}
setInterval(rotateChips, 8000);

document.querySelectorAll('.chip').forEach((c) => {
    c.addEventListener('click', () => {
      els.searchInput.value = c.dataset.q || c.textContent.trim();
      els.searchForm.requestSubmit();
    });
  });

  // Sleep timer — click cycles through off/15/30/45/60/90 min
  $('sleepTimerBtn').addEventListener('click', () => {
    const options = [0, 15, 30, 45, 60, 90];
    const curMin = sleepTimerRemaining > 0 ? Math.ceil(sleepTimerRemaining / 60) : 0;
    const next = options.find((o) => o > curMin) ?? 0;
    if (next === 0) { cancelSleepTimer(); toast('Sleep timer off'); }
    else { startSleepTimer(next); toast(`Pausing in ${next} min`); }
  });
  $('sleepCancelBtn').addEventListener('click', () => { cancelSleepTimer(); toast('Sleep timer cancelled'); });

  // A-B markers
  $('abABtn').addEventListener('click', setAbA);
  $('abBBtn').addEventListener('click', setAbB);
  $('abClearBtn').addEventListener('click', clearAb);

  // Related panel collapse toggle
  $('relatedToggleBtn').addEventListener('click', () => {
    relatedCollapsed = !relatedCollapsed;
    $('relatedList').classList.toggle('hidden', relatedCollapsed);
    $('relatedToggleBtn').textContent = relatedCollapsed ? '+' : '−';
  });

  // List filter
  $('listFilterInput').addEventListener('input', (e) => {
    listFilter = e.target.value.toLowerCase();
    renderList();
  });

  // Batch bar actions
  $('batchCancelBtn').addEventListener('click', () => { exitSelectMode(); renderList(); });
  $('batchQueueBtn').addEventListener('click', () => {
    const all = currentList();
    selectedIds.forEach((id) => {
      const v = all.find((x) => x.id === id) || videoById(id);
      if (v) addToQueue(v, { quiet: true });
    });
    toast(`Added ${selectedIds.size} to queue`);
    exitSelectMode(); renderList();
  });
  $('batchDeleteBtn').addEventListener('click', () => {
    const count = selectedIds.size;
    showModal(`Delete ${count} item${count === 1 ? '' : 's'}?`, document.createTextNode('This cannot be undone.'), [
      { label: 'Cancel', onClick: closeModal },
      { label: 'Delete', danger: true, onClick: () => {
        if (view.type === 'history' || view.type === 'smart') {
          data.history = data.history.filter((v) => !selectedIds.has(v.id));
        } else if (view.type === 'playlist') {
          const pl = data.playlists.find((p) => p.id === view.id);
          if (pl) pl.videos = pl.videos.filter((v) => !selectedIds.has(v.id));
        } else if (view.type === 'queue') {
          selectedIds.forEach((id) => removeFromQueue(id));
        }
        persist(); closeModal(); exitSelectMode(); render();
        toast(`Deleted ${count} item${count === 1 ? '' : 's'}`);
      }},
    ]);
  });
  $('batchAddPlaylistBtn').addEventListener('click', () => {
    const all = currentList();
    const videos = [...selectedIds].map((id) => all.find((v) => v.id === id) || videoById(id)).filter(Boolean);
    const body = document.createElement('div');
    body.className = 'modal-list';
    data.playlists.forEach((pl) => {
      const btn = document.createElement('button');
      btn.textContent = '+ ' + pl.name;
      btn.onclick = () => {
        videos.forEach((v) => { if (!pl.videos.some((x) => x.id === v.id)) { pl.videos.push(slim(v)); preload(v, { quiet: true }); } });
        persist(); closeModal(); exitSelectMode();
        toast(`Added ${videos.length} to ${pl.name}`);
      };
      body.appendChild(btn);
    });
    const nb = document.createElement('button');
    nb.textContent = '＋ New playlist…';
    nb.onclick = () => { closeModal(); newPlaylistWithVideos(videos); exitSelectMode(); };
    body.appendChild(nb);
    showModal('Add to playlist', body, [{ label: 'Close', onClick: closeModal }]);
  });

  // Now-playing: Save (preload) + Add to playlist
  els.saveBtn.addEventListener('click', async () => {
    if (!current || !current.meta) return;
    const id = current.meta.id;
    // Ignore clicks while either direction is already in flight for this video.
    if (downloading.has(id) || removing.has(id)) return;
    if (cachedIds.has(id)) {
      // Already saved → remove from cache. Guarded so a rapid double-click
      // can't fire two deletes; the button shows a busy state meanwhile.
      await runExclusive(removing, id, async () => {
        updateNowPlayingActions();
        try {
          const res = await API.cacheDelete(id);
          if (res && res.ok === false) throw new Error(res.error || 'delete failed');
          cachedIds.delete(id);
          markCardCacheState(id, 'none');
          toast('Removed from offline cache');
        } catch {
          // Non-blocking error; leave it marked cached and re-enable the button.
          toast('⚠ Could not remove from cache');
        }
      });
      updateNowPlayingActions();
    } else {
      await preload(current.meta);
    }
  });
  els.addPlaylistBtn.addEventListener('click', () => {
    if (current && current.meta) openCardMenu(current.meta);
  });
  els.queueBtn.addEventListener('click', () => {
    if (current && current.meta) addToQueue(current.meta);
  });
  // Now-playing channel name → channel view
  els.npChannel.addEventListener('click', () => {
    if (current && current.meta && channelKeyOf(current.meta)) {
      openChannel(channelKeyOf(current.meta), current.meta.channel);
    }
  });
  $('miniChannel').addEventListener('click', () => {
    if (current && current.meta && channelKeyOf(current.meta)) {
      openChannel(channelKeyOf(current.meta), current.meta.channel);
    }
  });

  // Controls
  els.playBtn.addEventListener('click', () => Player.toggle());
  els.nextBtn.addEventListener('click', playNext);
  els.prevBtn.addEventListener('click', playPrev);
  els.shuffleBtn.addEventListener('click', toggleShuffle);
  els.loopBtn.addEventListener('click', toggleLoopOne);
  els.repeatBtn.addEventListener('click', toggleRepeat);
  els.fsBtn.addEventListener('click', () => {
    const stage = els.video.parentElement;
    const video = els.video;
    // iOS Safari — including an installed PWA running in standalone mode —
    // does not implement the standard Fullscreen API for arbitrary elements.
    // stage.requestFullscreen is simply undefined there, so the button did
    // nothing (this is the iPhone-in-portrait-PWA bug report). WebKit instead
    // exposes a video-only, non-standard fullscreen API that *does* work in
    // standalone mode: HTMLVideoElement.webkitEnterFullscreen/ExitFullscreen.
    // Try the standard API first everywhere else, then fall back to the
    // WebKit video API before giving up.
    if (document.fullscreenElement || video.webkitDisplayingFullscreen) {
      try {
        if (document.exitFullscreen) document.exitFullscreen()?.catch(() => {});
        else if (video.webkitExitFullscreen) video.webkitExitFullscreen();
      } catch { /* e.g. InvalidStateError — nothing more we can do */ }
      return;
    }
    if (stage.requestFullscreen) {
      // requestFullscreen() can both throw synchronously (e.g.
      // InvalidStateError when preconditions like active user-gesture
      // transient activation aren't met) and return a promise that rejects
      // asynchronously. Guard against both instead of letting either surface
      // as an uncaught error.
      try {
        stage.requestFullscreen()?.catch(() => {});
      } catch { /* no-op — fullscreen simply won't engage this time */ }
    } else if (video.webkitEnterFullscreen) {
      if (Player.mode === 'audio') {
        toast('Fullscreen isn’t available in audio-only mode');
        return;
      }
      try {
        // Throws InvalidStateError if the element has no loaded media (e.g.
        // no video source yet) — nothing to show fullscreen in that case.
        video.webkitEnterFullscreen();
      } catch { /* no-op — no media loaded to go fullscreen with */ }
    } else {
      toast('Fullscreen isn’t supported on this device');
    }
  });

  els.seek.addEventListener('input', () => {
    const dur = Player.master.duration || 0;
    if (dur) Player.seek((parseFloat(els.seek.value) / 1000) * dur);
  });

  els.volume.addEventListener('input', () => {
    data.settings.volume = parseFloat(els.volume.value);
    Player.applyVolume();
    els.muteBtn.textContent = data.settings.volume === 0 ? '🔇' : '🔊';
    persist();
  });
  els.muteBtn.addEventListener('click', () => {
    if (data.settings.volume > 0) { els.muteBtn.dataset.prev = data.settings.volume; data.settings.volume = 0; }
    else { data.settings.volume = parseFloat(els.muteBtn.dataset.prev || '1'); }
    els.volume.value = String(data.settings.volume);
    Player.applyVolume();
    els.muteBtn.textContent = data.settings.volume === 0 ? '🔇' : '🔊';
    persist();
  });

  els.speed.addEventListener('change', () => Player.applySpeed());

  els.quality.addEventListener('change', () => {
    data.settings.quality = els.quality.value;
    persist();
    if (!current) return;
    // reload at the new quality, preserving position + play state
    const t = Player.master.currentTime;
    const wasPlaying = !Player.master.paused;
    const q = chooseQuality();
    Player.attach(q);
    const restore = () => {
      Player.seek(t);
      if (!wasPlaying) Player.pause();
      Player.master.removeEventListener('canplay', restore);
    };
    Player.master.addEventListener('canplay', restore);
  });

  els.audioOnlyToggle.addEventListener('change', () => {
    data.settings.audioOnly = els.audioOnlyToggle.checked;
    persist();
    if (!current) return;
    const t = Player.master.currentTime;
    const q = chooseQuality();
    Player.attach(q);
    const restore = () => { Player.seek(t); Player.master.removeEventListener('canplay', restore); };
    Player.master.addEventListener('canplay', restore);
  });

  // Keyboard
  document.addEventListener('keydown', (e) => {
    if (e.target.tagName === 'INPUT') return;
    if (e.code === 'Space') { e.preventDefault(); Player.toggle(); }
    else if (e.code === 'ArrowRight') Player.seek(Player.master.currentTime + 5);
    else if (e.code === 'ArrowLeft') Player.seek(Math.max(0, Player.master.currentTime - 5));
    else if (e.key === 'f') els.fsBtn.click();
    else if (e.key === 'm') els.muteBtn.click();
    else if (e.key === 'l') toggleLoopOne();
    else if (e.key === 'r') toggleRepeat();
    else if (e.key === 's') toggleShuffle();
    else if (e.key === 'q') { if (current && current.meta) addToQueue(current.meta); }
    else if (e.key === 'a') { if (current) setAbA(); }
    else if (e.key === 'b') { if (current) setAbB(); }
    else if (e.key === '?') toggleShortcutHelp();
    else if (e.key === 'Escape') {
      const app = document.querySelector('.app');
      if (app && app.classList.contains('sidebar-open')) closeSidebar();
      else if (!$('shortcutHelp').classList.contains('hidden')) $('shortcutHelp').classList.add('hidden');
      else if (selectMode) { exitSelectMode(); renderList(); }
    }
  });

  $('modal').addEventListener('click', (e) => { if (e.target.id === 'modal') closeModal(); });

  // Mini now-playing bar
  $('miniPlayBtn').addEventListener('click', (e) => { e.stopPropagation(); Player.toggle(); });
  $('miniBar').addEventListener('click', () => {
    hideMiniBar();
    // Scroll the player into view if needed. In portrait PWA, scroll only the
    // .body pane (the designated scroll container) — scrollIntoView() also
    // scrolls overflow:hidden ancestors up to <html> on iOS, leaving the
    // document offset and fixed-element hit testing broken.
    if (isPortraitPWA()) scrollPlayerIntoViewPortrait();
    else els.playerPane.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
  });
  $('miniCloseBtn').addEventListener('click', (e) => { e.stopPropagation(); hideMiniBar(); });

  // Drag-to-reorder: prevent default on cards container
  els.cards.addEventListener('dragover', (e) => {
    if (view.type === 'playlist' || view.type === 'queue') e.preventDefault();
  });
}

// ============================================================================
// Sleep timer
// ============================================================================
function startSleepTimer(minutes) {
  cancelSleepTimer();
  if (!minutes) return;
  sleepTimerRemaining = minutes * 60;
  updateSleepTimerUI();
  sleepTimerTick = setInterval(() => {
    sleepTimerRemaining--;
    if (sleepTimerRemaining <= 0) {
      cancelSleepTimer();
      Player.pause();
      toast('Sleep timer — paused');
    } else {
      updateSleepTimerUI();
    }
  }, 1000);
}
function cancelSleepTimer() {
  if (sleepTimerTick) clearInterval(sleepTimerTick);
  sleepTimerTick = null;
  sleepTimerRemaining = 0;
  updateSleepTimerUI();
}
function updateSleepTimerUI() {
  const active = sleepTimerRemaining > 0;
  $('sleepStatus').classList.toggle('hidden', !active);
  if (active) {
    const m = Math.floor(sleepTimerRemaining / 60);
    const s = sleepTimerRemaining % 60;
    $('sleepCountdown').textContent = `⏱ Pausing in ${m}:${String(s).padStart(2, '0')}`;
  }
  const btn = $('sleepTimerBtn');
  if (btn) btn.classList.toggle('sleep-active', active);
}

// ============================================================================
// A-B loop markers
// ============================================================================
function setAbA() {
  if (!current) return;
  abA = Player.master.currentTime;
  saveAbMarkers();
  updateAbUI();
  toast(`A set at ${fmtTime(abA)}`);
}
function setAbB() {
  if (!current) return;
  abB = Player.master.currentTime;
  saveAbMarkers();
  updateAbUI();
  toast(`B set at ${fmtTime(abB)}`);
}
function clearAb() {
  abA = null; abB = null;
  if (current && current.meta) {
    delete data.abMarkers[current.meta.id];
    const entry = currentPlaylistEntry();
    if (entry) delete entry.ab;
    persist();
  }
  updateAbUI();
  toast('A-B loop cleared');
}
function saveAbMarkers() {
  if (!current || !current.meta) return;
  if (abA !== null || abB !== null) {
    const marker = { a: abA, b: abB };
    // When playing from a playlist, store the marker on that playlist's own
    // copy of the video so each playlist keeps its own A-B loop and it syncs
    // to the database alongside the playlist. Otherwise fall back to the
    // global per-video map.
    const entry = currentPlaylistEntry();
    if (entry) entry.ab = marker;
    else data.abMarkers[current.meta.id] = marker;
    persist();
  }
}
function restoreAbMarkers() {
  if (!current || !current.meta) { abA = null; abB = null; updateAbUI(); return; }
  // Prefer a marker stored on the current playlist's copy of the video; fall
  // back to the global per-video marker.
  const entry = currentPlaylistEntry();
  const saved = (entry && entry.ab) || data.abMarkers[current.meta.id];
  abA = saved ? saved.a : null;
  abB = saved ? saved.b : null;
  updateAbUI();
}
// Returns the video entry inside the playlist currently being played, if the
// active playback source is a playlist and the playing video belongs to it.
function currentPlaylistEntry() {
  if (!current || !current.meta) return null;
  if (!queueSource || !queueSource.startsWith('playlist:')) return null;
  const plId = queueSource.slice('playlist:'.length);
  const pl = data.playlists.find((p) => p.id === plId);
  if (!pl || !Array.isArray(pl.videos)) return null;
  return pl.videos.find((v) => v.id === current.meta.id) || null;
}
function updateAbUI() {
  const aSet = abA !== null, bSet = abB !== null;
  const aBtn = $('abABtn'), bBtn = $('abBBtn'), clrBtn = $('abClearBtn');
  const ind = $('abIndicator');
  if (aBtn) aBtn.classList.toggle('active', aSet);
  if (bBtn) bBtn.classList.toggle('active', bSet);
  if (clrBtn) clrBtn.classList.toggle('hidden', !(aSet && bSet));
  if (ind) ind.classList.toggle('hidden', !aSet && !bSet);
  const aLbl = $('abALabel'), bLbl = $('abBLabel');
  if (aLbl) { aLbl.textContent = 'A: ' + (aSet ? fmtTime(abA) : '--'); aLbl.classList.toggle('active', aSet); }
  if (bLbl) { bLbl.textContent = 'B: ' + (bSet ? fmtTime(abB) : '--'); bLbl.classList.toggle('active', bSet); }
}

// ============================================================================
// Related videos
// ============================================================================
async function loadRelated() {
  if (!current || !current.meta) return;
  $('relatedPanel').classList.add('hidden');
  relatedVideos = [];
  try {
    const res = await API.search(current.meta.title);
    if (!res || !res.ok || !current) return;
    relatedVideos = (res.results || []).filter((v) => v.id !== current.meta.id).slice(0, 8);
    renderRelated();
  } catch { /* ignore */ }
}
function renderRelated() {
  const panel = $('relatedPanel'), list = $('relatedList');
  if (!panel || !list || !relatedVideos.length) { if (panel) panel.classList.add('hidden'); return; }
  panel.classList.remove('hidden');
  list.classList.toggle('hidden', relatedCollapsed);
  list.innerHTML = '';
  relatedVideos.forEach((v) => {
    const item = document.createElement('div');
    item.className = 'related-item';
    item.innerHTML = `<img src="${v.thumbnail || ''}" alt="" loading="lazy" /><div class="ri-info"><div class="ri-title"></div><div class="ri-channel"></div></div>`;
    item.querySelector('.ri-title').textContent = v.title;
    const riChEl = item.querySelector('.ri-channel');
    riChEl.textContent = v.channel || '';
    if (channelKeyOf(v)) {
      riChEl.classList.add('link');
      riChEl.title = 'View channel';
      riChEl.addEventListener('click', (e) => { e.stopPropagation(); openChannel(channelKeyOf(v), v.channel); });
    }
    item.addEventListener('click', (e) => {
      if (e.target.closest('.ri-channel.link')) return;
      queue = [v]; queueIndex = 0; Player.loadVideo(v); renderUpNext();
    });
    list.appendChild(item);
  });
}

// ============================================================================
// Smart / auto playlists
// ============================================================================
const SMART_PLAYLISTS = [
  { id: 'mostPlayed',       label: 'Most Played' },
  { id: 'recentlyWatched', label: 'Recently Watched' },
  { id: 'unwatched',       label: 'Unwatched' },
  { id: 'autoMix',         label: 'Auto Mix' },
];
function renderSmartSidebar() {
  const list = $('smartPlaylistList');
  if (!list) return;
  list.innerHTML = '';
  SMART_PLAYLISTS.forEach((sp) => {
    const item = document.createElement('div');
    const isActive = view.type === 'smart' && view.smartType === sp.id;
    item.className = 'playlist-item smart-item' + (isActive ? ' active' : '');
    item.innerHTML = `<span class="pl-name">${sp.label}</span>`;
    item.addEventListener('click', () => { view = { type: 'smart', smartType: sp.id }; render(); });
    list.appendChild(item);
  });
}
function getSmartList(smartType) {
  switch (smartType) {
    case 'mostPlayed': {
      const counts = data.playCount || {};
      return data.history.slice().sort((a, b) => (counts[b.id] || 0) - (counts[a.id] || 0)).slice(0, 50);
    }
    case 'recentlyWatched':
      return data.history.slice(0, 50);
    case 'unwatched': {
      const watched = new Set(data.history.map((v) => v.id));
      const seen = new Set();
      const out = [];
      for (const pl of data.playlists)
        for (const v of pl.videos)
          if (!watched.has(v.id) && !seen.has(v.id)) { seen.add(v.id); out.push(v); }
      return out;
    }
    case 'autoMix': {
      const pool = data.history.slice(0, 100);
      for (let i = pool.length - 1; i > 0; i--) {
        const j = Math.floor(Math.random() * (i + 1));
        [pool[i], pool[j]] = [pool[j], pool[i]];
      }
      return pool.slice(0, 30);
    }
    default: return [];
  }
}

// ============================================================================
// Batch operations
// ============================================================================
function exitSelectMode() {
  selectMode = false;
  selectedIds.clear();
  const c = $('cards');
  if (c) c.classList.remove('select-mode');
  $('batchBar').classList.add('hidden');
}
function toggleSelectMode() {
  selectMode = !selectMode;
  selectedIds.clear();
  const c = $('cards');
  if (c) c.classList.toggle('select-mode', selectMode);
  $('batchBar').classList.toggle('hidden', !selectMode);
  $('batchCount').textContent = '0 selected';
  renderList();
}
function toggleSelectCard(id) {
  if (selectedIds.has(id)) selectedIds.delete(id); else selectedIds.add(id);
  $('batchCount').textContent = `${selectedIds.size} selected`;
  document.querySelectorAll(`.card[data-id="${CSS.escape(id)}"]`).forEach((c) => c.classList.toggle('selected', selectedIds.has(id)));
}
function newPlaylistWithVideos(videos) {
  const input = document.createElement('input');
  input.type = 'text'; input.placeholder = 'Playlist name';
  showModal('New playlist', input, [
    { label: 'Cancel', onClick: closeModal },
    { label: 'Create', primary: true, onClick: () => {
      const name = input.value.trim();
      if (!name) return;
      const pl = { id: uid(), name, videos: videos.map(slim) };
      data.playlists.push(pl);
      videos.forEach((v) => preload(v, { quiet: true }));
      persist();
      closeModal();
      view = { type: 'playlist', id: pl.id };
      render();
      toast(`Created "${name}" with ${videos.length} videos`);
    }},
  ]);
  setTimeout(() => input.focus(), 50);
}

// ============================================================================
// Auto-backup
// ============================================================================
function doAutoBackup(silent = false) {
  const json = JSON.stringify({ ...data, _version: 1 }, null, 2);
  const blob = new Blob([json], { type: 'application/json' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = `ytplayer-backup-${new Date().toISOString().slice(0, 10)}.json`;
  a.click();
  URL.revokeObjectURL(url);
  data.lastAutoBackup = Date.now();
  persist();
  if (!silent) toast('Backup exported');
}
function checkAutoBackup() {
  if (!data.settings.autoBackupEnabled) return;
  const interval = (data.settings.autoBackupIntervalDays || 7) * 86400000;
  if (Date.now() - (data.lastAutoBackup || 0) >= interval) doAutoBackup(true);
}
function importBackup(e) {
  const file = e.target.files?.[0];
  if (!file) return;
  const reader = new FileReader();
  reader.onload = (ev) => {
    try {
      const imp = JSON.parse(ev.target.result);
      if (!imp.playlists || !Array.isArray(imp.playlists)) throw new Error('Invalid backup format');
      showModal('Import backup?', document.createTextNode('Merges playlists, history, and resume positions. Existing data is preserved.'), [
        { label: 'Cancel', onClick: closeModal },
        { label: 'Import', primary: true, onClick: () => {
          const existingPl = new Set(data.playlists.map((p) => p.id));
          for (const pl of (imp.playlists || [])) if (pl.id && !existingPl.has(pl.id)) { data.playlists.push(pl); existingPl.add(pl.id); }
          const histIds = new Set(data.history.map((v) => v.id));
          for (const v of (imp.history || [])) if (!histIds.has(v.id)) { data.history.push(v); histIds.add(v.id); }
          Object.assign(data.resumePositions, imp.resumePositions || {});
          for (const [id, cnt] of Object.entries(imp.playCount || {})) data.playCount[id] = Math.max(data.playCount[id] || 0, cnt);
          Object.assign(data.abMarkers, imp.abMarkers || {});
          persist();
          closeModal();
          render();
          toast('Backup imported');
        }},
      ]);
    } catch (err) { toast('⚠ Invalid backup: ' + err.message); }
  };
  reader.readAsText(file);
  e.target.value = '';
}

// ============================================================================
// Build-tag polling — detect server restarts / new deploys (WEB mode only)
//
// The server stamps every response from GET /api/version with a `buildTag`
// that is stable for the lifetime of the process (changes on restart/deploy).
// We baseline the tag on first fetch; any subsequent change means new code is
// live and we surface the existing update banner so the user can reload.
// ============================================================================

let _knownBuildTag = null;

async function checkBuildTag() {
  try {
    const res = await fetch('/api/version', { cache: 'no-store' });
    if (!res.ok) return;
    const data = await res.json();
    const tag = data.buildTag;
    if (!tag) return;
    if (!_knownBuildTag) {
      _knownBuildTag = tag;   // baseline on first successful fetch
      return;
    }
    if (tag !== _knownBuildTag) {
      _knownBuildTag = tag;   // update so we don't show the banner twice
      showUpdateBanner();
    }
  } catch { /* network error — try again next interval */ }
}

function pollBuildTag() {
  checkBuildTag();
  setInterval(checkBuildTag, 5 * 60 * 1000);   // recheck every 5 minutes
}

// ============================================================================
// PWA — service worker registration and update banner (WEB mode only)
// ============================================================================

// Hard-reload helper: wipes every SW cache and unregisters the SW so the
// next page load fetches fresh assets from the network, not stale cache.
// Called from both the update dialog and the Settings → Force refresh UI button.
async function hardReloadUI() {
  try {
    // 1. Delete every named cache (app shell, thumbs, fonts, etc.)
    if ('caches' in window) {
      const keys = await caches.keys();
      await Promise.all(keys.map((k) => caches.delete(k)));
    }
    // 2. Unregister every service worker registration so the browser
    //    fetches sw.js fresh on the next load rather than serving a
    //    cached copy that could re-populate the caches.
    if ('serviceWorker' in navigator) {
      const regs = await navigator.serviceWorker.getRegistrations();
      await Promise.all(regs.map((r) => r.unregister()));
    }
  } catch { /* best effort — reload regardless */ }
  // Hard navigation to the root forces the browser to fetch index.html from
  // the network (no SW is registered any more to intercept it).
  window.location.href = window.location.origin + '/';
}

let _updateBannerShown = false;
function showUpdateBanner() {
  // Only show the dialog once per page load
  if (_updateBannerShown) return;
  _updateBannerShown = true;

  // Show a modal dialog instead of a fleeting toast so the user can't miss it.
  const body = document.createElement('p');
  body.textContent = 'A new version of YT Player is available. Click "Refresh UI" to reload the page with the latest build. All your playlists and history are stored locally and will be preserved.';
  showModal('⬆ Update available', body, [
    { label: 'Later', onClick: closeModal },
    { label: 'Refresh UI', primary: true, onClick: async () => {
      closeModal();
      await applyUpdate();
    }},
  ]);
}

// Tracks the current SW registration so applyUpdate() can reach the
// waiting worker without re-querying getRegistration().
let _swReg = null;

// Applies a pending SW update in place (see sw-update.js for the full
// rationale — this used to call hardReloadUI(), which caused the "Update
// ready" banner to reappear right after being applied).
async function applyUpdate() {
  const reg = _swReg || (('serviceWorker' in navigator) ? await navigator.serviceWorker.getRegistration() : null);
  await window.SwUpdate.applyUpdate({
    reg,
    container: navigator.serviceWorker,
    reload: () => window.location.reload(),
  });
}

async function registerServiceWorker() {
  if (!WEB || !('serviceWorker' in navigator)) return;
  try {
    // updateViaCache:'none' prevents the browser from serving a stale
    // cached copy of sw.js — the server always returns it fresh (no-store).
    const reg = await navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' });
    _swReg = reg;

    // A waiting worker only means "update pending" when this page is already
    // controlled by a previous SW. On a FIRST install (fresh visit, or right
    // after Settings → Force refresh unregisters everything) the brand-new
    // worker passes through the `installed`/waiting state for a moment before
    // activating — with no controller that is not an update, and showing the
    // banner for it is exactly the "Update available keeps coming back after
    // Refresh UI" loop.
    const hasController = () => !!navigator.serviceWorker.controller;

    // If a new SW is already waiting (e.g. user refreshed after an update),
    // show the dialog right away.
    if (reg.waiting && hasController()) { showUpdateBanner(); return; }

    // Listen for a new SW installing after the page is open.
    reg.addEventListener('updatefound', () => {
      const sw = reg.installing;
      if (!sw) return;
      sw.addEventListener('statechange', () => {
        if (sw.state === 'installed' && reg.waiting && hasController()) showUpdateBanner();
      });
    });

    // The SW can also broadcast SW_UPDATE_AVAILABLE on its own activate.
    navigator.serviceWorker.addEventListener('message', (e) => {
      if (e.data && e.data.type === 'SW_UPDATE_AVAILABLE') showUpdateBanner();
    });

    // Check for updates in the background (useful for long-lived sessions)
    reg.update().catch(() => {});
  } catch (err) {
    console.warn('[sw] registration failed:', err.message);
  }
}

// ============================================================================
// Boot
// ============================================================================
async function boot() {
  wirePlayerEvents();
  wireUI();
  wireShortcutHelp();
  setupPortraitPwaWatcher();
  setupViewportAnchorGuard();
  try {
    const loaded = await API.loadData();
    if (loaded && typeof loaded === 'object') {
      data = {
        playlists: loaded.playlists || [],
        history: loaded.history || [],
        queue: loaded.queue || [],
        resumePositions: loaded.resumePositions || {},
        playCount: loaded.playCount || {},
        abMarkers: loaded.abMarkers || {},
        lastAutoBackup: loaded.lastAutoBackup || 0,
        settings: { ...DEFAULT_SETTINGS, ...(loaded.settings || {}) },
        profile: loaded.profile || null,
      };
    }
  } catch {
    // first run / bridge not ready — start with defaults
  }
  // Linked online profile: adopt the server copy if another device pushed a
  // newer one. Runs before any rendering so no re-render pass is needed.
  await pullProfileIfNewer();
  applyAppearance();
  updateLoopRepeatButtons();
  updateQueueBadge();
  els.volume.value = String(data.settings.volume ?? 1);
  els.quality.value = data.settings.quality || 'auto';
  els.audioOnlyToggle.checked = !!data.settings.audioOnly;

  // Learn what's already cached, then top up any playlist videos that aren't.
  await refreshCachedIds();
  data.playlists.forEach(preloadPlaylist);

  renderSmartSidebar();
  checkAutoBackup();
  render();
  els.searchInput.focus();

  // Register service worker + ping server with fingerprint (WEB mode only)
  registerServiceWorker();
  if (WEB) pollBuildTag();
  if (WEB) {
    fetch('/api/user/sync', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        fingerprint: window.getFingerprint ? window.getFingerprint() : 'unknown',
        appVersion: APP_VERSION,
      }),
    }).catch(() => {});
  }
}

document.addEventListener('DOMContentLoaded', boot);
