2724 lines
103 KiB
JavaScript
Executable File
2724 lines
103 KiB
JavaScript
Executable File
/* ============================================================================
|
||
* 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 couldn’t be played. Try another.');
|
||
}
|
||
},
|
||
|
||
attach(quality) {
|
||
const V = els.video, A = els.audio;
|
||
const audioOnly = data.settings.audioOnly;
|
||
|
||
// Reset
|
||
V.pause(); A.pause();
|
||
this.stopDrift();
|
||
|
||
if (current.localUrl) {
|
||
// Cached single file (carries its own audio). Play it directly.
|
||
if (audioOnly) {
|
||
this.mode = 'audio';
|
||
this.master = A;
|
||
this.secondary = null;
|
||
A.src = current.localUrl;
|
||
V.removeAttribute('src'); V.load();
|
||
els.art.classList.remove('hidden');
|
||
els.artImg.src = current.meta.thumbnail || '';
|
||
} else {
|
||
this.mode = 'progressive';
|
||
this.master = V;
|
||
this.secondary = null;
|
||
V.muted = false;
|
||
V.src = current.localUrl;
|
||
A.removeAttribute('src'); A.load();
|
||
els.art.classList.add('hidden');
|
||
}
|
||
this.applyVolume();
|
||
this.applySpeed();
|
||
this.master.load();
|
||
const onReadyLocal = () => { this.play(); this.master.removeEventListener('canplay', onReadyLocal); };
|
||
this.master.addEventListener('canplay', onReadyLocal);
|
||
return;
|
||
}
|
||
|
||
if (audioOnly) {
|
||
// Play only audio; show cover art.
|
||
this.mode = 'audio';
|
||
this.master = A;
|
||
this.secondary = null;
|
||
A.src = current.audioUrl || (quality && quality.url) || '';
|
||
V.removeAttribute('src'); V.load();
|
||
els.art.classList.remove('hidden');
|
||
els.artImg.src = current.meta.thumbnail || '';
|
||
} else if (quality && quality.hasAudio) {
|
||
// Progressive single stream (video already carries audio).
|
||
this.mode = 'progressive';
|
||
this.master = V;
|
||
this.secondary = null;
|
||
V.muted = false;
|
||
V.src = quality.url;
|
||
A.removeAttribute('src'); A.load();
|
||
els.art.classList.add('hidden');
|
||
} else if (quality && current.audioUrl) {
|
||
// Adaptive: muted video + synced audio for high quality without muxing.
|
||
this.mode = 'dual';
|
||
this.master = V;
|
||
this.secondary = A;
|
||
V.muted = true;
|
||
V.src = quality.url;
|
||
A.src = current.audioUrl;
|
||
els.art.classList.add('hidden');
|
||
} else {
|
||
showSpinner(false);
|
||
toast('No playable stream found for this video.');
|
||
return;
|
||
}
|
||
|
||
this.applyVolume();
|
||
this.applySpeed();
|
||
this.master.load();
|
||
if (this.secondary) this.secondary.load();
|
||
|
||
const onReady = () => {
|
||
this.play();
|
||
this.master.removeEventListener('canplay', onReady);
|
||
};
|
||
this.master.addEventListener('canplay', onReady);
|
||
this.startDrift();
|
||
},
|
||
|
||
play() {
|
||
this.master.play().catch(() => {});
|
||
if (this.secondary) {
|
||
this.secondary.currentTime = this.master.currentTime;
|
||
this.secondary.play().catch(() => {});
|
||
}
|
||
},
|
||
pause() {
|
||
this.master.pause();
|
||
if (this.secondary) this.secondary.pause();
|
||
},
|
||
toggle() {
|
||
if (this.master.paused) this.play(); else this.pause();
|
||
},
|
||
seek(t) {
|
||
this.master.currentTime = t;
|
||
if (this.secondary) this.secondary.currentTime = t;
|
||
},
|
||
applyVolume() {
|
||
const v = data.settings.volume;
|
||
this.soundEl.volume = v;
|
||
this.soundEl.muted = v === 0;
|
||
// make sure the non-sound elements stay muted
|
||
if (this.mode === 'dual') els.video.muted = true;
|
||
},
|
||
applySpeed() {
|
||
const r = parseFloat(els.speed.value) || 1;
|
||
this.master.playbackRate = r;
|
||
if (this.secondary) this.secondary.playbackRate = r;
|
||
},
|
||
|
||
startDrift() {
|
||
this.stopDrift();
|
||
this.driftTimer = setInterval(() => {
|
||
if (this.mode !== 'dual' || this.master.paused) return;
|
||
const drift = Math.abs(this.secondary.currentTime - this.master.currentTime);
|
||
if (drift > 0.3) this.secondary.currentTime = this.master.currentTime;
|
||
}, 1000);
|
||
},
|
||
stopDrift() {
|
||
if (this.driftTimer) clearInterval(this.driftTimer);
|
||
this.driftTimer = null;
|
||
},
|
||
};
|
||
|
||
function showSpinner(on) {
|
||
els.spinner.classList.toggle('hidden', !on);
|
||
}
|
||
|
||
function chooseQuality() {
|
||
const qs = current.qualities || [];
|
||
if (!qs.length) return null;
|
||
const pref = data.settings.quality;
|
||
if (pref && pref !== 'auto') {
|
||
const match = qs.find((q) => q.label === pref);
|
||
if (match) return match;
|
||
}
|
||
// auto: best quality at or below 720p, else the lowest available.
|
||
const sorted = [...qs].sort((a, b) => b.height - a.height);
|
||
return sorted.find((q) => q.height <= 720) || sorted[sorted.length - 1];
|
||
}
|
||
|
||
function buildQualityMenu() {
|
||
const qs = current.qualities || [];
|
||
els.quality.innerHTML = '';
|
||
const auto = document.createElement('option');
|
||
auto.value = 'auto';
|
||
auto.textContent = 'Auto';
|
||
els.quality.appendChild(auto);
|
||
const seen = new Set();
|
||
for (const q of qs) {
|
||
if (seen.has(q.label)) continue;
|
||
seen.add(q.label);
|
||
const o = document.createElement('option');
|
||
o.value = q.label;
|
||
o.textContent = q.label + (q.hasAudio ? '' : '');
|
||
els.quality.appendChild(o);
|
||
}
|
||
els.quality.value = data.settings.quality || 'auto';
|
||
}
|
||
|
||
// ---------- Player media events (master mirrors to secondary) ----------
|
||
function wirePlayerEvents() {
|
||
const V = els.video, A = els.audio;
|
||
|
||
const masterIs = (el) => Player.master === el;
|
||
|
||
function bind(el) {
|
||
el.addEventListener('play', () => { if (masterIs(el) && Player.secondary && Player.secondary.paused) { Player.secondary.currentTime = el.currentTime; Player.secondary.play().catch(() => {}); } updatePlayBtn(); });
|
||
el.addEventListener('pause', () => { if (masterIs(el) && Player.secondary) Player.secondary.pause(); updatePlayBtn(); });
|
||
el.addEventListener('seeking', () => { if (masterIs(el) && Player.secondary) Player.secondary.currentTime = el.currentTime; });
|
||
el.addEventListener('waiting', () => { if (masterIs(el)) { showSpinner(true); if (Player.secondary) Player.secondary.pause(); } });
|
||
el.addEventListener('playing', () => { if (masterIs(el)) { showSpinner(false); if (Player.secondary && !el.paused) { Player.secondary.currentTime = el.currentTime; Player.secondary.play().catch(() => {}); } } });
|
||
el.addEventListener('canplay', () => { if (masterIs(el)) showSpinner(false); });
|
||
el.addEventListener('timeupdate', () => {
|
||
if (masterIs(el)) {
|
||
updateProgress();
|
||
// Persist playback position every 10s
|
||
if (current && current.meta) {
|
||
const t = Player.master.currentTime;
|
||
if (t > 5 && Math.floor(t) % 10 === 0) {
|
||
data.resumePositions[current.meta.id] = t;
|
||
persist();
|
||
}
|
||
}
|
||
}
|
||
});
|
||
el.addEventListener('pause', () => {
|
||
if (masterIs(el) && current && current.meta) {
|
||
const t = Player.master.currentTime;
|
||
if (t > 1) {
|
||
data.resumePositions[current.meta.id] = t;
|
||
persist();
|
||
}
|
||
}
|
||
});
|
||
el.addEventListener('loadedmetadata', () => { if (masterIs(el)) updateProgress(); });
|
||
el.addEventListener('ended', () => { if (masterIs(el)) onTrackEnded(); });
|
||
el.addEventListener('error', () => {
|
||
// A failed master stream, or a failed synced-audio track in dual mode,
|
||
// both warrant falling back to the next candidate.
|
||
if (masterIs(el) || el === Player.secondary) Player.onMediaError();
|
||
});
|
||
}
|
||
bind(V);
|
||
bind(A);
|
||
}
|
||
|
||
function updatePlayBtn() {
|
||
els.playBtn.textContent = Player.master.paused ? '▶' : '⏸';
|
||
$('miniPlayBtn').textContent = Player.master.paused ? '▶' : '⏸';
|
||
}
|
||
// Reflect cache state on the now-playing Save button.
|
||
function updateNowPlayingActions() {
|
||
if (!current || !current.meta) return;
|
||
const id = current.meta.id;
|
||
const btn = els.saveBtn;
|
||
if (!btn) return;
|
||
if (removing.has(id)) {
|
||
btn.textContent = '⏳ Removing…';
|
||
btn.classList.remove('done');
|
||
btn.disabled = true;
|
||
} else if (downloading.has(id)) {
|
||
btn.textContent = '⏳ Saving…';
|
||
btn.classList.remove('done');
|
||
btn.disabled = true;
|
||
} else if (cachedIds.has(id)) {
|
||
btn.textContent = '✓ Saved';
|
||
btn.classList.add('done');
|
||
btn.disabled = false;
|
||
btn.title = 'Saved for offline — click to remove from cache';
|
||
} else {
|
||
btn.textContent = '⬇ Save';
|
||
btn.classList.remove('done');
|
||
btn.disabled = false;
|
||
btn.title = 'Save this video for offline playback';
|
||
}
|
||
}
|
||
function updateProgress() {
|
||
const cur = Player.master.currentTime || 0;
|
||
const dur = Player.master.duration || current?.meta?.duration || 0;
|
||
els.curTime.textContent = fmtTime(cur);
|
||
els.durTime.textContent = fmtTime(dur);
|
||
if (dur) els.seek.value = String((cur / dur) * 1000);
|
||
updateMiniBar();
|
||
// A-B loop: if both points are set and we pass B, jump to A
|
||
if (abA !== null && abB !== null && abB > abA && cur >= abB) Player.seek(abA);
|
||
}
|
||
|
||
// ============================================================================
|
||
// Mini now-playing bar
|
||
// ============================================================================
|
||
function showMiniBar() {
|
||
if (!current || !current.meta) return;
|
||
$('miniTitle').textContent = current.meta.title;
|
||
const miniCh = $('miniChannel');
|
||
miniCh.textContent = current.meta.channel || '';
|
||
miniCh.classList.toggle('link', !!channelKeyOf(current.meta));
|
||
$('miniBar').classList.remove('hidden');
|
||
updateMiniBar();
|
||
}
|
||
function updateMiniBar() {
|
||
if (!current || !current.meta) return;
|
||
const cur = Player.master.currentTime || 0;
|
||
const dur = Player.master.duration || 0;
|
||
$('miniTime').textContent = fmtTime(cur);
|
||
if (dur > 0) {
|
||
$('miniProgressFill').style.width = Math.min(100, (cur / dur) * 100) + '%';
|
||
}
|
||
}
|
||
function hideMiniBar() {
|
||
$('miniBar').classList.add('hidden');
|
||
}
|
||
|
||
// ============================================================================
|
||
// History
|
||
// ============================================================================
|
||
function addToHistory(meta) {
|
||
data.playCount = data.playCount || {};
|
||
data.playCount[meta.id] = (data.playCount[meta.id] || 0) + 1;
|
||
data.history = data.history.filter((v) => v.id !== meta.id);
|
||
data.history.unshift({
|
||
id: meta.id, title: meta.title, channel: meta.channel,
|
||
channelId: meta.channelId || '', channelUrl: meta.channelUrl || '',
|
||
duration: meta.duration, thumbnail: meta.thumbnail,
|
||
});
|
||
if (data.history.length > 200) data.history.length = 200;
|
||
persist();
|
||
if (view.type === 'history') renderList();
|
||
}
|
||
|
||
// ============================================================================
|
||
// Queue / navigation
|
||
// ============================================================================
|
||
function playFromList(list, index, source = '') {
|
||
queue = list;
|
||
queueIndex = index;
|
||
queueSource = source;
|
||
Player.loadVideo(list[index]);
|
||
renderUpNext();
|
||
}
|
||
|
||
// ---------- Temporary queue ----------
|
||
function updateQueueBadge() {
|
||
const b = $('navQueueCount');
|
||
if (!b) return;
|
||
b.textContent = String(data.queue.length);
|
||
b.classList.toggle('hidden', data.queue.length === 0);
|
||
}
|
||
function addToQueue(video, { quiet = false } = {}) {
|
||
if (!video || !video.id) return;
|
||
if (data.queue.some((v) => v.id === video.id)) { if (!quiet) toast('Already in queue'); return; }
|
||
data.queue.push(slim(video));
|
||
persist();
|
||
updateQueueBadge();
|
||
if (view.type === 'queue') renderList();
|
||
if (!quiet) toast('Added to queue');
|
||
}
|
||
function removeFromQueue(id) {
|
||
data.queue = data.queue.filter((v) => v.id !== id);
|
||
// Keep an in-progress queue playback in sync if it was sourced from the queue.
|
||
if (queueSource === 'queue') {
|
||
const playingId = current && current.meta && current.meta.id;
|
||
queue = data.queue.slice();
|
||
queueIndex = playingId ? queue.findIndex((v) => v.id === playingId) : -1;
|
||
renderUpNext();
|
||
}
|
||
persist();
|
||
updateQueueBadge();
|
||
if (view.type === 'queue') renderList();
|
||
}
|
||
function clearQueue() {
|
||
data.queue = [];
|
||
persist();
|
||
updateQueueBadge();
|
||
if (view.type === 'queue') renderList();
|
||
toast('Queue cleared');
|
||
}
|
||
function playQueue(index = 0) {
|
||
if (!data.queue.length) return;
|
||
playFromList(data.queue.slice(), index, 'queue');
|
||
}
|
||
|
||
function renderUpNext() {
|
||
const upcoming = queue.slice(queueIndex + 1);
|
||
if (!upcoming.length) { $('upnext').classList.add('hidden'); return; }
|
||
$('upnext').classList.remove('hidden');
|
||
$('upnextCount').textContent = String(upcoming.length);
|
||
const list = $('upnextList');
|
||
list.innerHTML = '';
|
||
upcoming.forEach((v) => {
|
||
const item = document.createElement('div');
|
||
item.className = 'upnext-item';
|
||
item.innerHTML = `
|
||
<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 & 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 & 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);
|