/* ============================================================================ * 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) { if (!window.OPFS || !window.OPFS.isSupported()) { return { ok: false, error: 'OPFS not supported in this browser' }; } try { const fp = window.getFingerprint ? window.getFingerprint() : ''; const url = `/api/download/${encodeURIComponent(videoId)}${fp ? '?fp=' + encodeURIComponent(fp) : ''}`; 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: 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) => WEB ? opfsDownload(sanitizeId(videoId)) : 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, repeatMode: 'off', // 'off' | 'all' — repeat the playing list when it ends loopOne: false, // repeat the single current video 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 }; 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:',…) 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'), 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); } // ---------- 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 } = {}) { 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); 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 badge = $('navDlCount'); if (!badge) return; const n = downloading.size; badge.textContent = String(n); badge.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 = '
'; 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, get soundEl() { return this.mode === 'dual' ? els.audio : this.master; }, async loadVideo(videoObj, { preferStream = false } = {}) { 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; } } const res = await API.getStreams(videoObj.id); 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 }); }); 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))); } // Restore A-B markers and load related (non-blocking) restoreAbMarkers(); loadRelated(); exitSelectMode(); // Restore saved playback position const id = current.meta.id; if (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 }); } }, // 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…'); this.loadVideo(meta, { preferStream: true }); 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); 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.master.play().catch(() => {}); if (this.secondary) { this.secondary.currentTime = this.master.currentTime; this.secondary.play().catch(() => {}); } }, pause() { 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; }, startDrift() { this.stopDrift(); this.driftTimer = setInterval(() => { if (this.mode !== 'dual' || this.master.paused) return; const drift = Math.abs(this.secondary.currentTime - this.master.currentTime); if (drift > 0.3) this.secondary.currentTime = this.master.currentTime; }, 1000); }, stopDrift() { if (this.driftTimer) clearInterval(this.driftTimer); this.driftTimer = null; }, }; 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.secondary.pause(); updatePlayBtn(); }); el.addEventListener('seeking', () => { if (masterIs(el) && Player.secondary) Player.secondary.currentTime = el.currentTime; }); el.addEventListener('waiting', () => { if (masterIs(el)) { showSpinner(true); if (Player.secondary) Player.secondary.pause(); } }); el.addEventListener('playing', () => { if (masterIs(el)) { showSpinner(false); if (Player.secondary && !el.paused) { Player.secondary.currentTime = el.currentTime; Player.secondary.play().catch(() => {}); } } }); el.addEventListener('canplay', () => { if (masterIs(el)) showSpinner(false); }); el.addEventListener('timeupdate', () => { if (masterIs(el)) { updateProgress(); // Persist playback position every 10s if (current && current.meta) { const t = Player.master.currentTime; if (t > 5 && Math.floor(t) % 10 === 0) { 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: if both points are set and we pass B, jump to A if (abA !== null && abB !== null && abB > abA && cur >= abB) Player.seek(abA); } // ============================================================================ // 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; Player.loadVideo(list[index]); renderUpNext(); } // ---------- Temporary queue ---------- function updateQueueBadge() { const b = $('navQueueCount'); if (!b) return; b.textContent = String(data.queue.length); b.classList.toggle('hidden', data.queue.length === 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 = `
`; 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; } Player.loadVideo(queue[queueIndex]); 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'); } function playPrev() { if (Player.master.currentTime > 3) { Player.seek(0); return; } if (queueIndex > 0) { queueIndex--; Player.loadVideo(queue[queueIndex]); 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 = `${pl.videos.length}`; 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); }); 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 = `
`; 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 = `
🕘

Nothing watched yet

Your viewing history shows up here once you start playing videos.

`; addBrowseCta(empty, '🔍 Search videos'); } else if (view.type === 'queue') { empty.innerHTML = `

Your queue is empty

Use + Queue on any video to line it up. The queue is temporary and plays in order.

`; addBrowseCta(empty, '🔍 Find something to play'); } else if (view.type === 'channel') { empty.innerHTML = `
📺

No videos found

This channel didn't return any uploads.

`; addBrowseCta(empty, '🔍 Back to search'); } else { // Playlist view — empty empty.innerHTML = `
🎵

This playlist is empty

Add videos from search results or use the + Playlist button while playing.

`; 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 = '
Loading saved videos…
'; 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 = `
💾

Offline cache unavailable

This build doesn't support saving videos for offline playback.

`; 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 = `
💾

Nothing saved yet

Use ⬇ Save while playing, or add videos to a playlist to keep them offline.

`; 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 = `${fmtBytes(total)}${items.length} video${items.length === 1 ? '' : 's'} stored offline`; 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 = `
${fmtBytes(it.size)}
`; 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 = `

No active downloads

Saves in progress show here with live status. Finished videos land in Saved.

`; 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 = `${active.length}download${active.length === 1 ? '' : 's'} in progress`; c.appendChild(note); active.forEach((v) => { const row = document.createElement('div'); row.className = 'card downloading'; row.dataset.id = v.id; row.innerHTML = `
⏳ Saving for offline…
`; 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) => ``) .join(''); const sel = (id, val, opts) => ``; wrap.innerHTML = `
Appearance & accessibility
Playback
Offline cache
Storage used
Playlists
Backup & restore
`; 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(); }); $('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 = '
No videos saved offline yet.
'; } else { items.sort((a, b) => b.size - a.size); items.forEach((it) => { const row = document.createElement('div'); row.className = 'cache-item'; row.innerHTML = `${fmtBytes(it.size)}`; 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); } 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 = `
${v.duration ? `${fmtTime(v.duration)}` : ''} ${isDownloading ? '' : '⬇'} ${isDownloading ? '
' : ''}
`; 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); }); } 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(); } // ============================================================================ // 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'); } // ============================================================================ // 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(); 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(); }); }); 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.loopBtn.addEventListener('click', toggleLoopOne); els.repeatBtn.addEventListener('click', toggleRepeat); els.fsBtn.addEventListener('click', () => { const stage = els.video.parentElement; if (document.fullscreenElement) document.exitFullscreen(); else stage.requestFullscreen?.(); }); 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 === '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 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]; persist(); } updateAbUI(); toast('A-B loop cleared'); } function saveAbMarkers() { if (!current || !current.meta) return; if (abA !== null || abB !== null) { data.abMarkers[current.meta.id] = { a: abA, b: abB }; persist(); } } function restoreAbMarkers() { if (!current || !current.meta) { abA = null; abB = null; updateAbUI(); return; } const saved = data.abMarkers[current.meta.id]; abA = saved ? saved.a : null; abB = saved ? saved.b : null; updateAbUI(); } 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 = `
`; 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 = `${sp.label}`; 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 = ''; } // ============================================================================ // PWA — service worker registration and update banner (WEB mode only) // ============================================================================ function showUpdateBanner() { // Use a persistent, click-to-reload toast instead of the standard 2.2s one const t = document.createElement('div'); t.className = 'toast toast-update'; t.innerHTML = '⬆ Update ready — '; const container = $('toastContainer'); container.appendChild(t); t.querySelector('.toast-reload-btn').addEventListener('click', () => { // Tell the waiting SW to skip waiting, then reload once it takes control. if (navigator.serviceWorker.controller) { navigator.serviceWorker.controller.postMessage({ type: 'SKIP_WAITING' }); } navigator.serviceWorker.addEventListener('controllerchange', () => window.location.reload(), { once: true }); // Fallback: reload after a short delay in case controllerchange already fired setTimeout(() => window.location.reload(), 500); }); } async function registerServiceWorker() { if (!WEB || !('serviceWorker' in navigator)) return; try { const reg = await navigator.serviceWorker.register('/sw.js'); // If a new SW is already waiting (e.g. user refreshed after an update), // show the banner right away. if (reg.waiting) { 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) 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(); 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 || {}) }, }; } } catch { // first run / bridge not ready — start with defaults } 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) { 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);