Files
ytplayer/frontend/app.js
Claude Worker fc61defba5 feat: fix portrait mode design in pwa
Task #44 completed by ClaudeQueue

ClaudeQueue
2026-06-30 15:36:12 +00:00

2724 lines
103 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ============================================================================
* 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:<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'),
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 = '<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,
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)));
}
// In portrait PWA mode, scroll the player into view so it's immediately
// visible without the user having to swipe up manually.
scrollPlayerIntoViewPortrait();
// 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 couldnt 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 = `
<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;
}
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 = `<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);
});
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>
</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>`;
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 = '<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);
}
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);
});
}
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'); }
// ============================================================================
// 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');
}
}
// Scroll the list-pane to the top so the player content is at the start of the
// list area. In portrait mode, .list-pane is the scroll container (not .body).
function scrollPlayerIntoViewPortrait() {
if (!isPortraitPWA()) return;
const listPane = document.querySelector('.list-pane');
if (listPane) {
listPane.scrollTo({ top: 0, 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();
});
}
// ============================================================================
// 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 = `<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 = '';
}
// ============================================================================
// 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 — <button class="toast-reload-btn">Reload now</button>';
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();
setupPortraitPwaWatcher();
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);