Files
ytplayer/frontend/app.js
2026-06-21 16:15:30 +08:00

1952 lines
73 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;
// call(zeroName, tauriName, payload) — routes to whichever 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.');
}
const API = {
search: (query) => call('yt.search', 'yt_search', { query }),
getChannel: (channel) => call('yt.channel', 'yt_channel', { channel }),
getStreams: (videoId) => call('yt.streams', 'yt_streams', { videoId }),
loadData: () => call('store.load', 'store_load', {}),
// data is sent pre-stringified so the native side can write it verbatim.
saveData: (data) => call('store.save', 'store_save', { data: JSON.stringify(data) }),
// Offline cache (Tauri shell). Calls are wrapped where used so the Linux
// shell — which doesn't implement these yet — degrades gracefully.
cacheDownload: (videoId) => call('cache.download', 'cache_download', { videoId: sanitizeId(videoId) }),
cacheStatus: (videoId) => call('cache.status', 'cache_status', { videoId: sanitizeId(videoId) }),
cacheList: () => call('cache.list', 'cache_list', {}),
cacheDelete: (videoId) => call('cache.delete', 'cache_delete', { videoId: sanitizeId(videoId) }),
cacheClear: () => 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
};
let data = { playlists: [], history: [], queue: [], settings: { ...DEFAULT_SETTINGS }, resumePositions: {} };
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 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');
try {
// Play from the offline cache when available — instant and works offline.
if (!preferStream && cachedIds.has(videoObj.id)) {
let localUrl = null;
try {
const st = await API.cacheStatus(videoObj.id);
if (st && st.ok && st.cached) localUrl = toAssetUrl(st.path);
} 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();
// 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();
}
// ============================================================================
// Mini now-playing bar
// ============================================================================
function showMiniBar() {
if (!current || !current.meta) return;
$('miniTitle').textContent = current.meta.title;
$('miniChannel').textContent = current.meta.channel || '';
$('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.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">${v.title}</div>
<div class="upnext-channel">${v.channel || ''}</div>
</div>`;
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 === '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; }
const list = currentList();
els.cards.innerHTML = '';
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 === '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);
}
}
// 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>`;
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);
}
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' : '');
card.dataset.id = v.id;
card.innerHTML = `
<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;
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 and queue views.
if (view.type === 'playlist' || view.type === 'queue') {
const del = document.createElement('button');
del.className = 'card-del';
del.title = view.type === 'queue' ? 'Remove from queue' : 'Remove from playlist';
del.textContent = '✕';
del.addEventListener('click', (e) => {
e.stopPropagation();
if (view.type === 'queue') { removeFromQueue(v.id); 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() {
renderSidebar();
renderList();
}
// ============================================================================
// Playlists
// ============================================================================
function exportPlaylists() {
const json = JSON.stringify(data.playlists, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `ytplayer-playlists-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
toast('Playlists exported');
}
function importPlaylists(e) {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const imported = JSON.parse(ev.target.result);
if (!Array.isArray(imported)) throw new Error('Invalid format');
// Merge: skip duplicates by id, add new ones
const existingIds = new Set(data.playlists.map((p) => p.id));
for (const pl of imported) {
if (pl.id && pl.name && Array.isArray(pl.videos) && !existingIds.has(pl.id)) {
data.playlists.push(pl);
existingIds.add(pl.id);
}
}
persist();
renderSidebar();
toast(`Imported ${imported.length} playlist(s)`);
} catch (err) {
toast('⚠ Failed to import: ' + err.message);
}
};
reader.readAsText(file);
e.target.value = ''; // allow re-import of same file
}
function openCardMenu(video) {
const inPlaylistView = view.type === 'playlist';
const body = document.createElement('div');
body.className = 'modal-list';
// Quick: add to queue
const queueBtn = document.createElement('button');
queueBtn.textContent = '▶ Add to queue';
queueBtn.onclick = () => { addToQueue(video); closeModal(); };
body.appendChild(queueBtn);
// Quick: save / remove offline
const saveBtn = document.createElement('button');
const isSaved = cachedIds.has(video.id);
saveBtn.textContent = isSaved ? '✓ Saved offline — remove' : '⬇ Save for offline';
saveBtn.onclick = async () => {
closeModal();
if (cachedIds.has(video.id)) {
try { await API.cacheDelete(video.id); } catch {}
cachedIds.delete(video.id);
markCardCacheState(video.id, 'none');
if (current && current.meta && current.meta.id === video.id) updateNowPlayingActions();
toast('Removed from offline cache');
} else {
preload(video);
}
};
body.appendChild(saveBtn);
const divider = document.createElement('div');
divider.className = 'modal-divider';
divider.textContent = 'Playlists';
body.appendChild(divider);
data.playlists.forEach((pl) => {
const btn = document.createElement('button');
btn.textContent = (pl.videos.some((x) => x.id === video.id) ? '✓ ' : '+ ') + pl.name;
btn.onclick = () => {
const key = pl.id + ':' + video.id;
// Guard against rapid double-clicks: the op is keyed by playlist+video,
// so a second click while the first is in flight is ignored (prevents the
// same video being pushed twice / state corruption).
runExclusive(playlistOps, key, async () => {
btn.disabled = true;
btn.textContent = '⏳ ' + pl.name;
// Recompute membership now (not from a flag captured at menu-open),
// so the decision reflects current state.
const has = pl.videos.some((x) => x.id === video.id);
try {
if (has) {
pl.videos = pl.videos.filter((x) => x.id !== video.id);
} else {
pl.videos.push(slim(video));
// Auto-cache for offline playback (fire-and-forget; its own guard
// prevents duplicate downloads).
preload(video);
}
persist();
toast(has ? `Removed from ${pl.name}` : `Added to ${pl.name}`);
} catch {
toast('⚠ Could not update playlist');
}
closeModal();
render();
});
};
body.appendChild(btn);
});
const newBtn = document.createElement('button');
newBtn.textContent = ' New playlist…';
newBtn.onclick = () => { closeModal(); newPlaylist(video); };
body.appendChild(newBtn);
const actions = [{ label: 'Close', onClick: closeModal }];
if (inPlaylistView) {
actions.unshift({
label: 'Remove from this playlist', danger: true, onClick: () => {
const pl = data.playlists.find((p) => p.id === view.id);
if (pl) { pl.videos = pl.videos.filter((x) => x.id !== video.id); persist(); }
closeModal(); render();
},
});
}
showModal('Add to playlist', body, actions);
}
function slim(v) {
return {
id: v.id, title: v.title, channel: v.channel,
channelId: v.channelId || '', channelUrl: v.channelUrl || '',
duration: v.duration, thumbnail: v.thumbnail,
};
}
function newPlaylist(addVideo) {
const input = document.createElement('input');
input.type = 'text';
input.placeholder = 'Playlist name';
showModal('New playlist', input, [
{ label: 'Cancel', onClick: closeModal },
{
label: 'Create', primary: true, onClick: () => {
const name = input.value.trim();
if (!name) return;
const pl = { id: uid(), name, videos: addVideo ? [slim(addVideo)] : [] };
data.playlists.push(pl);
if (addVideo) preload(addVideo); // auto-cache for offline playback
persist();
closeModal();
view = { type: 'playlist', id: pl.id };
render();
toast(`Created “${name}`);
},
},
]);
setTimeout(() => input.focus(), 50);
}
function renamePlaylist(pl) {
const input = document.createElement('input');
input.type = 'text';
input.value = pl.name;
showModal('Rename playlist', input, [
{ label: 'Cancel', onClick: closeModal },
{
label: 'Save', primary: true, onClick: () => {
const name = input.value.trim();
if (name) { pl.name = name; persist(); }
closeModal(); render();
},
},
]);
setTimeout(() => { input.focus(); input.select(); }, 50);
}
function deletePlaylist(pl) {
showModal(`Delete “${pl.name}”?`, document.createTextNode('This cannot be undone.'), [
{ label: 'Cancel', onClick: closeModal },
{
label: 'Delete', danger: true, onClick: () => {
data.playlists = data.playlists.filter((p) => p.id !== pl.id);
persist();
closeModal();
view = { type: 'search' };
render();
},
},
]);
}
// ============================================================================
// Keyboard shortcut help overlay
// ============================================================================
function toggleShortcutHelp() {
const overlay = $('shortcutHelp');
overlay.classList.toggle('hidden');
}
function wireShortcutHelp() {
$('shortcutClose').addEventListener('click', () => $('shortcutHelp').classList.add('hidden'));
$('shortcutHelp').addEventListener('click', (e) => {
if (e.target.id === 'shortcutHelp') $('shortcutHelp').classList.add('hidden');
});
}
// ============================================================================
// Modal
// ============================================================================
function showModal(title, bodyNode, actions) {
$('modalTitle').textContent = title;
const body = $('modalBody');
body.innerHTML = '';
body.appendChild(bodyNode);
const act = $('modalActions');
act.innerHTML = '';
actions.forEach((a) => {
const b = document.createElement('button');
b.className = 'btn' + (a.primary ? ' primary' : '') + (a.danger ? ' danger' : '');
b.textContent = a.label;
b.onclick = a.onClick;
act.appendChild(b);
});
$('modal').classList.remove('hidden');
}
function closeModal() { $('modal').classList.add('hidden'); }
// ============================================================================
// Events
// ============================================================================
function wireUI() {
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();
});
});
// 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);
}
});
// 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 === '?') toggleShortcutHelp();
else if (e.key === 'Escape' && !$('shortcutHelp').classList.contains('hidden')) { $('shortcutHelp').classList.add('hidden'); }
});
$('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();
});
}
// ============================================================================
// Boot
// ============================================================================
async function boot() {
wirePlayerEvents();
wireUI();
wireShortcutHelp();
try {
const loaded = await API.loadData();
if (loaded && typeof loaded === 'object') {
data = {
playlists: loaded.playlists || [],
history: loaded.history || [],
queue: loaded.queue || [],
resumePositions: loaded.resumePositions || {},
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);
render();
els.searchInput.focus();
}
document.addEventListener('DOMContentLoaded', boot);