Files
ytplayer/frontend/app.js

3992 lines
161 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

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

/* ============================================================================
* YT Player — frontend logic
* Talks to the native (Zig) side over the zero-native bridge: window.zero.invoke
* Handlers (implemented in src/bridge.zig):
* yt.search { query } -> { ok, results:[{id,title,channel,duration,thumbnail}] }
* yt.streams { videoId } -> { ok, data:{ meta, audioUrl, qualities:[{label,height,url,hasAudio,ext}] } }
* store.load {} -> { playlists, history, settings }
* store.save { data } -> { ok }
* ========================================================================== */
// ---------- Native bridge adapter ----------
// Works against two shells from the same frontend:
// • Tauri (Windows / WebView2): window.__TAURI__.core.invoke, snake_case commands
// • zero-native (Linux / macOS): window.zero.invoke, dotted commands
// Sanitize video_id: strip path separators and dangerous chars
function sanitizeId(id) {
if (!id || typeof id !== 'string') return '';
return id.replace(/[/\\:?<>|*"]/g, '').trim();
}
const TAURI = window.__TAURI__ && window.__TAURI__.core ? window.__TAURI__.core : null;
const ZERO = window.zero && typeof window.zero.invoke === 'function' ? window.zero : null;
// WEB mode: running as a PWA served from the Hono server (no native bridge)
const WEB = !TAURI && !ZERO;
const APP_VERSION = '1.0.0';
// call(zeroName, tauriName, payload) — routes to whichever native shell is present.
async function call(zeroName, tauriName, payload = {}) {
if (TAURI) return await TAURI.invoke(tauriName, payload);
if (ZERO) return await ZERO.invoke(zeroName, payload);
throw new Error('No native bridge available — run inside the YT Player app.');
}
// ---------- WEB-mode helpers ----------
// Generic fetch wrapper — returns parsed JSON or throws with a human message.
async function webFetch(path, opts = {}) {
const res = await fetch(path, opts);
if (!res.ok) {
let msg = `HTTP ${res.status}`;
try { const j = await res.json(); msg = j.error || msg; } catch { /* non-JSON */ }
throw new Error(msg);
}
return res.json();
}
// Persist data to localStorage and fire-and-forget sync to the server.
function loadDataFromStorage() {
try {
const raw = localStorage.getItem('_ytpdata');
return raw ? JSON.parse(raw) : null;
} catch { return null; }
}
function saveDataToStorage(jsonStr) {
try { localStorage.setItem('_ytpdata', jsonStr); } catch { /* storage full / blocked */ }
// Sync playlists to the server (non-blocking — failures are silent)
try {
const d = JSON.parse(jsonStr);
fetch('/api/user/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fingerprint: window.getFingerprint ? window.getFingerprint() : 'unknown',
playlists: d.playlists || [],
appVersion: APP_VERSION,
}),
}).catch(() => {});
} catch { /* ignore parse errors */ }
}
// OPFS bridge wrappers — return the same shape as the Tauri cache_* commands
// so the rest of app.js works without changes.
async function opfsDownload(videoId, { mux = false } = {}) {
if (!window.OPFS || !window.OPFS.isSupported()) {
return { ok: false, error: 'OPFS not supported in this browser' };
}
const fp = window.getFingerprint ? window.getFingerprint() : '';
const params = new URLSearchParams();
if (fp) params.set('fp', fp);
// mux=1 asks the server to compile bestvideo+bestaudio into a single mp4
// (used by "Save before playing"); default saves keep the progressive
// stream exactly as before.
if (mux) params.set('mux', '1');
const qs = params.toString();
const url = `/api/download/${encodeURIComponent(videoId)}${qs ? '?' + qs : ''}`;
// Preferred path: a dedicated Web Worker does the fetch AND the OPFS writes,
// so a big save never touches the main thread (no UI jank, no audio
// stutter). ANY worker failure — unsupported API or a mid-download error —
// falls back to the legacy main-thread streaming below; the worker's error
// is kept so it can be reported if the fallback fails too.
let workerError = null;
if (typeof window.OPFS.downloadVideo === 'function' && typeof Worker !== 'undefined') {
const w = await window.OPFS.downloadVideo(videoId, url);
if (w.ok) return { ok: true, cached: true };
workerError = w.error || null;
}
try {
const res = await fetch(url);
if (!res.ok) {
const j = await res.json().catch(() => ({}));
return { ok: false, error: j.error || `HTTP ${res.status}` };
}
// Determine file extension from Content-Type
const ct = res.headers.get('content-type') || 'video/mp4';
const ext = ct.includes('webm') ? 'webm' : ct.includes('ogg') ? 'ogg' : 'mp4';
await window.OPFS.writeFromResponse(videoId, ext, res);
return { ok: true, cached: true };
} catch (err) {
return { ok: false, error: workerError || err.message };
}
}
// "Edit & download": ask the server to trim `sourceId` to the given keep
// segments and store the resulting custom cut in OPFS under `customId` (which
// is NOT a real YouTube id — it's edit_<src>_<uid>). Same worker-first /
// main-thread-fallback strategy as opfsDownload, but the URL targets the
// source video with ?edit=1&keep=… while the file is written under customId.
async function opfsDownloadEdited(customId, sourceId, keepParam) {
if (!window.OPFS || !window.OPFS.isSupported()) {
return { ok: false, error: 'OPFS not supported in this browser' };
}
const fp = window.getFingerprint ? window.getFingerprint() : '';
const params = new URLSearchParams();
params.set('edit', '1');
params.set('keep', keepParam);
if (fp) params.set('fp', fp);
const url = `/api/download/${encodeURIComponent(sourceId)}?${params.toString()}`;
let workerError = null;
if (typeof window.OPFS.downloadVideo === 'function' && typeof Worker !== 'undefined') {
const w = await window.OPFS.downloadVideo(customId, url);
if (w.ok) return { ok: true, cached: true };
workerError = w.error || null;
}
try {
const res = await fetch(url);
if (!res.ok) {
const j = await res.json().catch(() => ({}));
return { ok: false, error: j.error || `HTTP ${res.status}` };
}
const ct = res.headers.get('content-type') || 'video/mp4';
const ext = ct.includes('webm') ? 'webm' : ct.includes('ogg') ? 'ogg' : 'mp4';
await window.OPFS.writeFromResponse(customId, ext, res);
return { ok: true, cached: true };
} catch (err) {
return { ok: false, error: workerError || err.message };
}
}
async function opfsStatus(videoId) {
if (!window.OPFS || !window.OPFS.isSupported()) return { ok: true, cached: false };
try {
const cached = await window.OPFS.hasVideo(videoId);
return { ok: true, cached };
} catch {
return { ok: true, cached: false };
}
}
async function opfsList() {
if (!window.OPFS || !window.OPFS.isSupported()) return { ok: true, items: [], total: 0 };
try {
const items = await window.OPFS.listVideos();
const total = items.reduce((s, i) => s + (i.size || 0), 0);
return { ok: true, items, total };
} catch {
return { ok: true, items: [], total: 0 };
}
}
async function opfsDelete(videoId) {
if (!window.OPFS || !window.OPFS.isSupported()) return { ok: true };
try { await window.OPFS.deleteVideo(videoId); } catch { /* ignore */ }
return { ok: true };
}
async function opfsClear() {
if (!window.OPFS || !window.OPFS.isSupported()) return { ok: true };
try { await window.OPFS.clearAll(); } catch { /* ignore */ }
return { ok: true };
}
// Blob URL for the currently playing OPFS video — revoked on next video load.
let _currentBlobUrl = null;
const API = {
search: (query) => WEB
? webFetch(`/api/search?q=${encodeURIComponent(query)}`)
: call('yt.search', 'yt_search', { query }),
getChannel: (channel) => WEB
? webFetch(`/api/channel?c=${encodeURIComponent(channel)}`)
: call('yt.channel', 'yt_channel', { channel }),
getStreams: (videoId) => WEB
? webFetch(`/api/streams?v=${encodeURIComponent(videoId)}`)
: call('yt.streams', 'yt_streams', { videoId }),
loadData: () => WEB
? Promise.resolve(loadDataFromStorage())
: call('store.load', 'store_load', {}),
// data is sent pre-stringified so the native side can write it verbatim.
saveData: (d) => WEB
? Promise.resolve(saveDataToStorage(typeof d === 'string' ? d : JSON.stringify(d)))
: call('store.save', 'store_save', { data: JSON.stringify(d) }),
// Offline cache — routes to OPFS (WEB) or native cache (Tauri/Zig)
cacheDownload: (videoId, opts) => WEB
? opfsDownload(sanitizeId(videoId), opts)
: call('cache.download', 'cache_download', { videoId: sanitizeId(videoId) }),
// Download a server-edited cut of `sourceId` into the cache under a custom
// id. WEB (OPFS) only — the native shells have no ffmpeg edit pipeline.
cacheDownloadEdited: (customId, sourceId, keepParam) => WEB
? opfsDownloadEdited(sanitizeId(customId), sanitizeId(sourceId), keepParam)
: Promise.resolve({ ok: false, error: 'Editing is only available in the web app' }),
cacheStatus: (videoId) => WEB
? opfsStatus(sanitizeId(videoId))
: call('cache.status', 'cache_status', { videoId: sanitizeId(videoId) }),
cacheList: () => WEB
? opfsList()
: call('cache.list', 'cache_list', {}),
cacheDelete: (videoId) => WEB
? opfsDelete(sanitizeId(videoId))
: call('cache.delete', 'cache_delete', { videoId: sanitizeId(videoId) }),
cacheClear: () => WEB
? opfsClear()
: call('cache.clear', 'cache_clear', {}),
};
// Resolve a native file path to a URL the WebView can load (Tauri asset
// protocol). Returns null on shells without it.
function toAssetUrl(path) {
if (TAURI && typeof TAURI.convertFileSrc === 'function') return TAURI.convertFileSrc(path);
return null;
}
// ---------- State ----------
const DEFAULT_SETTINGS = {
quality: 'auto', volume: 1, audioOnly: false, autoPreload: true,
saveBeforePlay: false, // download (server-muxed single file) before playing instead of streaming
repeatMode: 'off', // 'off' | 'all' — repeat the playing list when it ends
loopOne: false, // repeat the single current video
shuffle: false, // shuffle upcoming tracks when playing a list
theme: 'dark', // 'dark' | 'light' | 'contrast'
fontScale: 'normal', // 'small' | 'normal' | 'large' | 'xl'
density: 'comfortable', // 'comfortable' | 'compact'
perfMode: false, // disable heavy visual effects for speed
reduceMotion: false, // disable animations
autoBackupEnabled: false,
autoBackupIntervalDays: 7,
};
let data = { playlists: [], history: [], queue: [], customVideos: [], settings: { ...DEFAULT_SETTINGS }, resumePositions: {}, rememberPos: {}, playCount: {}, abMarkers: {}, lastAutoBackup: 0, profile: null };
let view = { type: 'search' }; // 'search'|'history'|'playlist'|'settings'|'queue'|'saved'|'downloads'|'channel'
let searchResults = [];
let channelData = { name: '', url: '', key: '', results: [], loading: false };
let queue = []; // list of video objects for autoplay
let queueIndex = -1;
let queueSource = ''; // label of what's playing ('queue','playlist:<id>',…)
let playFullMode = false; // "play in full" session: ignore resume positions,
// A-B markers, shuffle, loop and repeat — play the
// list start-to-finish, then stop.
let current = null; // { meta, qualities, audioUrl, localUrl? }
let dragSource = -1; // index of card being dragged
let listFilter = '';
let selectMode = false;
let selectedIds = new Set();
let sleepTimerRemaining = 0;
let sleepTimerTick = null;
let abA = null;
let abB = null;
let relatedVideos = [];
let relatedCollapsed = false;
let saveTimer = null;
const cachedIds = new Set(); // video ids that exist in the offline cache
const downloading = new Set(); // video ids with an in-flight download
const downloadMeta = new Map(); // id -> video object, for the Downloads page
const removing = new Set(); // video ids with an in-flight cache removal
const playlistOps = new Set(); // `${playlistId}:${videoId}` add/remove in flight
// Concurrency helper (frontend/async-guard.js) — keeps the save / playlist
// operations from being triggered twice at once for the same target.
const runExclusive = (window.AsyncGuard && window.AsyncGuard.runExclusive)
|| (async (set, key, fn) => {
if (set.has(key)) return undefined;
set.add(key);
try { return await fn(); } finally { set.delete(key); }
});
// ---------- DOM ----------
const $ = (id) => document.getElementById(id);
const els = {
video: $('video'),
audio: $('audio'),
art: $('artFallback'),
artImg: $('artImg'),
placeholder: $('playerPlaceholder'),
spinner: $('bufferSpinner'),
controls: $('controls'),
playerPane: $('playerPane'),
nowMeta: $('nowPlayingMeta'),
npTitle: $('npTitle'),
npChannel: $('npChannel'),
seek: $('seek'),
curTime: $('curTime'),
durTime: $('durTime'),
playBtn: $('playBtn'),
prevBtn: $('prevBtn'),
nextBtn: $('nextBtn'),
muteBtn: $('muteBtn'),
volume: $('volume'),
speed: $('speedSelect'),
quality: $('qualitySelect'),
fsBtn: $('fsBtn'),
shuffleBtn: $('shuffleBtn'),
loopBtn: $('loopBtn'),
repeatBtn: $('repeatBtn'),
queueBtn: $('queueBtn'),
cards: $('cards'),
listTitle: $('listTitle'),
listActions: $('listActions'),
status: $('status'),
searchForm: $('searchForm'),
searchInput: $('searchInput'),
playlistList: $('playlistList'),
newPlaylistBtn: $('newPlaylistBtn'),
audioOnlyToggle: $('audioOnlyToggle'),
saveBtn: $('saveBtn'),
editBtn: $('editBtn'),
addPlaylistBtn: $('addPlaylistBtn'),
};
// ---------- Persistence ----------
function persist() {
clearTimeout(saveTimer);
saveTimer = setTimeout(() => API.saveData(data).catch(() => {}), 400);
scheduleProfilePush();
}
// ---------- Online profile sync (WEB mode) ----------
// The profile NAME acts as the passkey: any device that knows it can load
// and update the same server-side copy of playlists/settings/etc. Sync is
// last-write-wins: every local change pushes (debounced); every app launch
// pulls when the server copy is newer than what this device last synced.
function profilePayload() {
return {
playlists: data.playlists,
history: data.history,
customVideos: data.customVideos,
settings: data.settings,
resumePositions: data.resumePositions,
rememberPos: data.rememberPos,
playCount: data.playCount,
abMarkers: data.abMarkers,
};
}
let profilePushTimer = null;
function scheduleProfilePush() {
if (!WEB || !data.profile || !data.profile.name) return;
clearTimeout(profilePushTimer);
profilePushTimer = setTimeout(pushProfile, 1500);
}
async function pushProfile() {
if (!WEB || !data.profile || !data.profile.name) return;
try {
const res = await fetch('/api/profile/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: data.profile.name, data: profilePayload() }),
});
const j = await res.json().catch(() => null);
if (j && j.ok) {
data.profile.syncedAt = j.updatedAt || 0;
// Record syncedAt directly — going through persist() would re-schedule
// another push forever.
API.saveData(data).catch(() => {});
}
} catch { /* offline — the next persist() retries */ }
}
// Replace the synced slice of local state with a profile's server copy.
function applyProfileData(name, payload, updatedAt) {
payload = payload || {};
if (Array.isArray(payload.playlists)) data.playlists = payload.playlists;
if (Array.isArray(payload.history)) data.history = payload.history;
if (Array.isArray(payload.customVideos)) data.customVideos = payload.customVideos;
if (payload.resumePositions && typeof payload.resumePositions === 'object') data.resumePositions = payload.resumePositions;
if (payload.rememberPos && typeof payload.rememberPos === 'object') data.rememberPos = payload.rememberPos;
if (payload.playCount && typeof payload.playCount === 'object') data.playCount = payload.playCount;
if (payload.abMarkers && typeof payload.abMarkers === 'object') data.abMarkers = payload.abMarkers;
if (payload.settings && typeof payload.settings === 'object') data.settings = { ...DEFAULT_SETTINGS, ...payload.settings };
data.profile = { name, syncedAt: updatedAt || 0 };
API.saveData(data).catch(() => {});
}
// On launch: adopt the server copy when it's newer than this device's last
// sync (another device pushed since); otherwise push local state up.
async function pullProfileIfNewer() {
if (!WEB || !data.profile || !data.profile.name) return;
try {
const res = await fetch(`/api/profile/load?name=${encodeURIComponent(data.profile.name)}`);
if (res.status === 404) return; // profile gone server-side; keep local data
const j = await res.json().catch(() => null);
if (!j || !j.ok) return;
if ((j.updatedAt || 0) > (data.profile.syncedAt || 0)) {
applyProfileData(j.name, j.data, j.updatedAt);
} else {
scheduleProfilePush();
}
} catch { /* offline — stay on local data */ }
}
// Refresh the Settings row without a full re-render (no-op on other views).
function updateProfileStatus() {
const el = document.getElementById('profileStatus');
if (el) el.textContent = (data.profile && data.profile.name) || 'Not linked';
const unlink = document.getElementById('profileUnlinkBtn');
if (unlink) unlink.style.display = data.profile && data.profile.name ? '' : 'none';
}
function createProfileFlow() {
const body = document.createElement('div');
body.innerHTML = `
<p style="margin:0 0 12px;color:var(--text-2);font-size:13px;line-height:1.5">
Pick a name (340 characters: letters, digits, - or _).
<strong>The name is the key</strong> — anyone who knows it can load this
profile on their device, so use something hard to guess or go random.
</p>
<input id="profileNameInput" type="text" placeholder="e.g. crimson-falcon-8317" autocomplete="off" />`;
showModal(' Create online profile', body, [
{ label: 'Cancel', onClick: closeModal },
{ label: '🎲 Random name', onClick: () => requestCreateProfile(null) },
{ label: 'Create', primary: true, onClick: () => {
const name = ($('profileNameInput').value || '').trim();
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{2,39}$/.test(name)) {
toast('⚠ Name must be 340 characters: letters, digits, - or _');
return; // keep the modal open for another attempt
}
requestCreateProfile(name);
} },
]);
$('profileNameInput').focus();
}
// name === null → let the server generate a unique random one.
async function requestCreateProfile(name) {
try {
const res = await fetch('/api/profile/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, data: profilePayload() }),
});
const j = await res.json().catch(() => null);
if (!j || !j.ok) {
toast('⚠ ' + ((j && j.error) || 'Could not create profile'));
return; // modal stays open — user can adjust the name
}
closeModal();
data.profile = { name: j.name, syncedAt: j.updatedAt || 0 };
persist();
updateProfileStatus();
toast(`Profile “${j.name}” created ✓ — load it by name on any device`, { duration: 4500 });
} catch {
toast('⚠ Network error — try again');
}
}
function loadProfileFlow() {
const body = document.createElement('div');
body.innerHTML = `
<p style="margin:0 0 12px;color:var(--text-2);font-size:13px;line-height:1.5">
Enter a profile name to pull its playlists, history and settings onto
this device. The synced data on this device is replaced, and future
changes here sync back to that profile.
</p>
<input id="profileNameInput" type="text" placeholder="profile name" autocomplete="off" />`;
showModal('⬇ Load online profile', body, [
{ label: 'Cancel', onClick: closeModal },
{ label: 'Load', primary: true, onClick: async () => {
const name = ($('profileNameInput').value || '').trim();
if (!name) return;
try {
const res = await fetch(`/api/profile/load?name=${encodeURIComponent(name)}`);
const j = await res.json().catch(() => null);
if (!j || !j.ok) {
toast('⚠ ' + ((j && j.error) || 'Profile not found'));
return; // keep the modal open
}
closeModal();
applyProfileData(j.name, j.data, j.updatedAt);
// Re-apply everything the loaded data drives.
applyAppearance();
updateLoopRepeatButtons();
updateQueueBadge();
els.volume.value = String(data.settings.volume ?? 1);
els.quality.value = data.settings.quality || 'auto';
els.audioOnlyToggle.checked = !!data.settings.audioOnly;
renderSidebar();
renderSmartSidebar();
render();
updateProfileStatus();
data.playlists.forEach(preloadPlaylist);
toast(`Profile “${j.name}” loaded ✓ — this device now syncs to it`, { duration: 4000 });
} catch {
toast('⚠ Network error — try again');
}
} },
]);
$('profileNameInput').focus();
}
// ---------- Helpers ----------
function fmtTime(sec) {
if (!sec || !isFinite(sec)) return '0:00';
sec = Math.floor(sec);
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
const mm = h ? String(m).padStart(2, '0') : String(m);
return (h ? h + ':' : '') + mm + ':' + String(s).padStart(2, '0');
}
function toast(msg, { duration = 2200 } = {}) {
const t = document.createElement('div');
t.className = 'toast';
t.textContent = msg;
const container = $('toastContainer');
container.appendChild(t);
// Trigger layout for animation
t.style.animation = 'none';
t.offsetHeight; // force reflow
t.style.animation = '';
// Auto-remove after duration
setTimeout(() => {
t.classList.add('toast-exit');
setTimeout(() => t.remove(), 280);
}, duration);
// Cap at 3 toasts — remove oldest
while (container.children.length > 3) {
const first = container.firstChild;
first.classList.add('toast-exit');
setTimeout(() => first.remove(), 280);
}
}
function uid() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 7); }
// Apply theme / font size / density / performance settings to the document root.
// Driven entirely by data-* attributes that styles.css keys off of.
function applyAppearance() {
const s = data.settings;
const root = document.documentElement;
root.dataset.theme = s.theme || 'dark';
root.dataset.font = s.fontScale || 'normal';
root.dataset.density = s.density || 'comfortable';
root.dataset.perf = s.perfMode ? 'on' : 'off';
root.dataset.motion = s.reduceMotion ? 'reduced' : 'full';
}
function fmtBytes(n) {
if (!n) return '0 B';
const u = ['B', 'KB', 'MB', 'GB'];
let i = 0;
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
return (i ? n.toFixed(1) : n) + ' ' + u[i];
}
// ============================================================================
// Offline cache (preload)
// ============================================================================
async function refreshCachedIds() {
cachedIds.clear();
try {
const res = await API.cacheList();
if (res && res.ok) for (const it of res.items || []) cachedIds.add(it.id);
} catch { /* shell without cache support — leave empty */ }
}
// Download a video into the permanent offline cache. Safe to call repeatedly.
async function preload(video, { quiet = false, mux = false } = {}) {
const id = video.id;
// Custom (edited) videos have no YouTube source to (re)download — their
// media is produced once by the editor. Never route them through the normal
// cache-download path (a fake edit_… id would 404 on /api/download).
if (video.custom) return;
if (!id || cachedIds.has(id) || downloading.has(id)) return;
downloading.add(id);
downloadMeta.set(id, slim(video));
markCardCacheState(id, 'downloading');
if (view.type === 'downloads') renderList();
updateDownloadBadge();
// Reflect the busy state on the now-playing Save button immediately, so the
// UI responds the moment the (long) download begins rather than only when it
// finishes in the `finally` below.
if (current && current.meta && current.meta.id === id) updateNowPlayingActions();
if (!quiet) toast(`Saving “${video.title}” for offline…`);
try {
const res = await API.cacheDownload(id, { mux });
if (res && res.ok && res.cached) {
cachedIds.add(id);
if (!quiet) toast(`Saved “${video.title}” ✓`);
} else if (!quiet) {
toast('⚠ ' + ((res && res.error) || 'Could not save video'));
}
} catch (e) {
if (!quiet) toast('⚠ Saving not supported in this build.');
} finally {
downloading.delete(id);
downloadMeta.delete(id);
markCardCacheState(id, cachedIds.has(id) ? 'cached' : 'none');
if (current && current.meta && current.meta.id === id) updateNowPlayingActions();
updateDownloadBadge();
if (view.type === 'settings' || view.type === 'downloads' || view.type === 'saved') renderList();
}
}
// Sidebar badge showing how many downloads are in flight.
function updateDownloadBadge() {
const n = downloading.size;
const badge = $('navDlCount');
if (badge) { badge.textContent = String(n); badge.classList.toggle('hidden', n === 0); }
// Mirror to bottom-nav badge
const bb = $('bnDlCount');
if (bb) { bb.textContent = String(n); bb.classList.toggle('hidden', n === 0); }
}
// Auto-preload every video in a playlist (respecting the setting).
function preloadPlaylist(pl) {
if (!data.settings.autoPreload || !pl) return;
pl.videos.forEach((v) => preload(v, { quiet: true }));
}
// Update a single card's saved badge without a full re-render.
function markCardCacheState(id, state) {
document.querySelectorAll(`.card[data-id="${CSS.escape(id)}"]`).forEach((c) => {
c.classList.toggle('cached', state === 'cached');
c.classList.toggle('downloading', state === 'downloading');
const thumb = c.querySelector('.thumb');
if (state === 'downloading' && !thumb.querySelector('.dl-progress')) {
const bar = document.createElement('div');
bar.className = 'dl-progress';
bar.innerHTML = '<div class="dl-bar"></div>';
thumb.appendChild(bar);
} else if (state !== 'downloading') {
const bar = thumb.querySelector('.dl-progress');
if (bar) bar.remove();
}
});
}
// ============================================================================
// Video editor — cut parts out of a video and save a custom offline copy
//
// The editor works on a SOURCE video (any card / the now-playing video). The
// user marks one or more CUT ranges; everything outside those ranges survives.
// On confirm we compute the keep segments (VideoEdit.invertCuts), ask the
// server to trim+concat the source into one continuous mp4 (?edit=1&keep=…),
// store it in OPFS under a fresh custom id, and register a custom video object
// in data.customVideos so it plays offline and can be added to playlists just
// like a normal video.
// ============================================================================
// Make a stable-ish unique id for a custom cut. Not a YouTube id — the
// `edit_` prefix is how the rest of the app recognises an offline-only video.
function customVideoId(sourceId) {
return 'edit_' + sanitizeId(sourceId) + '_' + uid();
}
// Kick off the server-side edit + OPFS save for a custom video object, driving
// the same download/cache UI state (badges, toasts) as a normal save.
async function downloadEdited(customVideo) {
const id = customVideo.id;
if (!id || cachedIds.has(id) || downloading.has(id)) return;
downloading.add(id);
downloadMeta.set(id, slim(customVideo));
markCardCacheState(id, 'downloading');
if (view.type === 'downloads') renderList();
updateDownloadBadge();
toast(`Rendering “${customVideo.title}”…`);
try {
const res = await API.cacheDownloadEdited(id, customVideo.sourceId, customVideo.keep);
if (res && res.ok && res.cached) {
cachedIds.add(id);
// Only persist the custom video once its media is actually stored, so a
// failed render never leaves a dangling entry the user can't play.
if (!(data.customVideos || []).some((v) => v.id === id)) {
data.customVideos = data.customVideos || [];
data.customVideos.push(customVideo);
persist();
}
toast(`Saved edited “${customVideo.title}” ✓`);
if (view.type === 'downloads' || view.type === 'saved') renderList();
} else {
toast('⚠ ' + ((res && res.error) || 'Could not render edited video'));
}
} catch (e) {
toast('⚠ ' + (e && e.message ? e.message : 'Editing failed'));
} finally {
downloading.delete(id);
downloadMeta.delete(id);
markCardCacheState(id, cachedIds.has(id) ? 'cached' : 'none');
updateDownloadBadge();
if (view.type === 'settings' || view.type === 'downloads' || view.type === 'saved') renderList();
}
}
// Remove a custom (edited) video entirely: its cached media file, its cache
// membership, its registry entry, and any playlist references. Unlike a normal
// "remove from cache", the media can't be re-fetched, so this is a true delete.
async function deleteCustomVideo(id) {
try { await API.cacheDelete(id); } catch { /* best-effort */ }
cachedIds.delete(id);
data.customVideos = (data.customVideos || []).filter((v) => v.id !== id);
data.playlists.forEach((pl) => { pl.videos = pl.videos.filter((x) => x.id !== id); });
data.queue = (data.queue || []).filter((x) => x.id !== id);
persist();
markCardCacheState(id, 'none');
if (current && current.meta && current.meta.id === id) updateNowPlayingActions();
toast('Deleted edited video');
if (view.type === 'saved' || view.type === 'downloads' || view.type === 'playlist') renderList();
}
// Open the editor modal for a source video. `duration` seconds is needed to
// compute keep segments; we take it from the live player when the video is
// currently playing, else from the card metadata.
function openVideoEditor(source) {
if (!(WEB && window.OPFS && window.OPFS.isSupported())) {
toast('⚠ Editing needs offline storage, which this browser doesnt support');
return;
}
// Prefer the precise live duration when editing the now-playing video.
let duration = 0;
if (current && current.meta && current.meta.id === source.id && Player.master && Player.master.duration) {
duration = Player.master.duration;
}
if (!duration) duration = Number(source.duration) || 0;
if (!duration || !isFinite(duration)) {
toast('⚠ Play the video first so its length is known, then edit');
return;
}
const cuts = []; // [{start,end}] the user is removing
let lastMarker = null; // 'A' or 'B'
const body = document.createElement('div');
body.className = 'video-editor';
body.innerHTML = `
<p class="ve-intro">Mark the parts to <strong>remove</strong>. Everything else is kept and saved as a new offline video.</p>
<div class="ve-scrubber-track">
<div class="ve-scrubber-bar"></div>
<div class="ve-marker ve-marker-a">A</div>
<div class="ve-marker ve-marker-b">B</div>
</div>
<div class="ve-add-row">
<label>From <input type="text" class="ve-from" placeholder="0:30" inputmode="numeric" /></label>
<label>To <input type="text" class="ve-to" placeholder="1:15" inputmode="numeric" /></label>
<button type="button" class="btn ve-add">Add cut</button>
</div>
<div class="ve-move-row" style="margin-top:10px">
<button type="button" class="btn ve-move">Move video to last clicked marker</button>
</div>
<div class="ve-error" hidden></div>
<div class="ve-cuts"></div>
<label class="ve-title-row">Name <input type="text" class="ve-title" maxlength="120" /></label>
<div class="ve-summary"></div>`;
const fromEl = body.querySelector('.ve-from');
const toEl = body.querySelector('.ve-to');
const addBtn = body.querySelector('.ve-add');
const moveBtn = body.querySelector('.ve-move');
const errEl = body.querySelector('.ve-error');
const cutsEl = body.querySelector('.ve-cuts');
const titleEl = body.querySelector('.ve-title');
const sumEl = body.querySelector('.ve-summary');
const trackEl = body.querySelector('.ve-scrubber-track');
const markerA = body.querySelector('.ve-marker-a');
const markerB = body.querySelector('.ve-marker-b');
titleEl.value = (source.title || 'Video') + ' (edit)';
// Update markers based on duration
function updateScrubber() {
const a = VideoEdit.parseTime(fromEl.value);
const b = VideoEdit.parseTime(toEl.value);
if (a !== null) markerA.style.left = (a / duration * 100) + '%';
if (b !== null) markerB.style.left = (b / duration * 100) + '%';
}
trackEl.onclick = (e) => {
const rect = trackEl.getBoundingClientRect();
const percent = (e.clientX - rect.left) / rect.width;
const time = Math.round(percent * duration);
if (!fromEl.value || lastMarker === 'B') {
fromEl.value = VideoEdit.fmtTime(time);
lastMarker = 'A';
} else {
toEl.value = VideoEdit.fmtTime(time);
lastMarker = 'B';
}
updateScrubber();
};
markerA.onclick = (e) => { e.stopPropagation(); lastMarker = 'A'; };
markerB.onclick = (e) => { e.stopPropagation(); lastMarker = 'B'; };
moveBtn.onclick = () => {
if (lastMarker === 'A' && fromEl.value) {
Player.seek(VideoEdit.parseTime(fromEl.value));
} else if (lastMarker === 'B' && toEl.value) {
Player.seek(VideoEdit.parseTime(toEl.value));
}
};
fromEl.oninput = updateScrubber;
toEl.oninput = updateScrubber;
function showErr(msg) {
errEl.textContent = msg;
errEl.hidden = !msg;
}
function refresh() {
cutsEl.innerHTML = '';
const sorted = cuts.slice().sort((a, b) => a.start - b.start);
sorted.forEach((cut) => {
const row = document.createElement('div');
row.className = 've-cut';
row.innerHTML = `<span>✂ ${VideoEdit.fmtTime(cut.start)} ${VideoEdit.fmtTime(cut.end)}</span>`;
const del = document.createElement('button');
del.className = 've-cut-del';
del.type = 'button';
del.textContent = '✕';
del.title = 'Remove this cut';
del.onclick = () => {
const i = cuts.indexOf(cut);
if (i > -1) cuts.splice(i, 1);
refresh();
};
row.appendChild(del);
cutsEl.appendChild(row);
});
const keep = VideoEdit.invertCuts(cuts, duration);
const finalLen = VideoEdit.keepDuration(keep);
sumEl.innerHTML = cuts.length
? `Final length: <strong>${VideoEdit.fmtTime(finalLen)}</strong> of ${VideoEdit.fmtTime(duration)}`
: `No cuts yet — the whole ${VideoEdit.fmtTime(duration)} video would be saved.`;
}
addBtn.onclick = () => {
showErr('');
const a = VideoEdit.parseTime(fromEl.value);
const b = VideoEdit.parseTime(toEl.value);
if (a === null || b === null) { showErr('Enter valid times, e.g. 0:30 and 1:15.'); return; }
if (b <= a) { showErr('“To” must be after “From”.'); return; }
if (a >= duration) { showErr(`Times must be within the video (0 ${VideoEdit.fmtTime(duration)}).`); return; }
cuts.push({ start: a, end: Math.min(b, duration) });
fromEl.value = '';
toEl.value = '';
fromEl.focus();
refresh();
};
refresh();
showModal('Edit & download', body, [
{ label: 'Cancel', onClick: closeModal },
{
label: 'Save edited copy', primary: true, onClick: () => {
if (!VideoEdit.hasEdits(cuts, duration)) {
showErr('Add at least one cut, or use ⬇ Save for the full video.');
return;
}
const keep = VideoEdit.invertCuts(cuts, duration);
if (!keep.length) {
showErr('That would remove the entire video — leave something to keep.');
return;
}
const custom = {
id: customVideoId(source.id),
title: (titleEl.value || '').trim() || ((source.title || 'Video') + ' (edit)'),
channel: source.channel || '',
channelId: source.channelId || '',
channelUrl: source.channelUrl || '',
thumbnail: source.thumbnail || '',
duration: Math.round(VideoEdit.keepDuration(keep)),
custom: true,
sourceId: source.id,
keep: VideoEdit.keepToParam(keep),
};
closeModal();
downloadEdited(custom);
},
},
]);
setTimeout(() => fromEl.focus(), 50);
}
// ============================================================================
// Player engine — single video, or video+audio synced (adaptive), or audio-only
// ============================================================================
const Player = {
mode: 'progressive', // 'progressive' | 'dual' | 'audio'
master: els.video,
secondary: null, // synced audio element in dual mode
driftTimer: null,
bufferGraceTimer: null, // pending "pause audio after a stall that outlasts the grace window" timer
_wantsPlaying: false, // tracks user/app *intent* to be playing, independent
// of what the underlying <audio>/<video> elements
// report — iOS Safari (esp. PWA/standalone on iPhone)
// can silently pause a background audio element on
// screen-lock or app-switch without ever pausing the
// still-visible muted video, and without always
// firing a 'pause' event we react to. Comparing
// intent against actual element state is how we
// detect and recover from that.
get soundEl() {
return this.mode === 'dual' ? els.audio : this.master;
},
async loadVideo(videoObj, { preferStream = false, resume = true, reveal = true } = {}) {
// When false (auto-advance / prev), skip resuming the saved timestamp and
// start from the beginning (or the A marker, if an A-B loop is set).
this._resumeOnLoad = resume;
// When false (auto-advance / prev), don't scroll the portrait view back
// to the player — the user may be browsing another page while listening.
this._revealOnLoad = reveal;
// Monotonic load token: any await below may resolve after the user has
// already started a different video — stale loads must stand down
// instead of hijacking playback (longest window: save-before-playing).
const seq = (this._loadSeq = (this._loadSeq || 0) + 1);
showSpinner(true);
els.placeholder.classList.add('hidden');
// Revoke any previous OPFS blob URL to free memory
if (_currentBlobUrl) {
if (window.OPFS) window.OPFS.revokeUrl(_currentBlobUrl);
else URL.revokeObjectURL(_currentBlobUrl);
_currentBlobUrl = null;
}
try {
// Play from the offline cache when available — instant and works offline.
if (!preferStream && cachedIds.has(videoObj.id)) {
let localUrl = null;
try {
if (TAURI) {
const st = await API.cacheStatus(videoObj.id);
if (st && st.ok && st.cached) localUrl = toAssetUrl(st.path);
} else if (WEB && window.OPFS) {
localUrl = await window.OPFS.getFileUrl(videoObj.id);
if (localUrl) _currentBlobUrl = localUrl;
}
} catch { /* fall through to streaming */ }
if (localUrl) {
current = { meta: { ...videoObj }, qualities: [], audioUrl: null, localUrl };
this.afterLoad();
this.fallbackQueue = []; this.fbIndex = 0;
this.attach(null);
return;
}
}
// Custom (edited) videos exist ONLY in the offline cache — there is no
// YouTube stream to fall back to. If the cached file is missing (e.g.
// cleared, or synced from another device that never had the media),
// surface a clear error instead of trying to stream a fake video id.
if (videoObj.custom) {
throw new Error('This edited video isnt available offline on this device.');
}
// Opt-in "Save before playing" (Settings → Playback, off by default):
// download the server-compiled single file into the offline cache
// first, then play the local copy — one data stream instead of the
// dual video+audio streams, and the video is kept for offline reuse.
// Any failure falls through to normal streaming playback.
if (!preferStream && data.settings.saveBeforePlay && !data.settings.audioOnly &&
WEB && window.OPFS && window.OPFS.isSupported() && !cachedIds.has(videoObj.id)) {
await preload(videoObj, { mux: true });
if (seq !== this._loadSeq) return;
if (cachedIds.has(videoObj.id)) {
const localUrl = await window.OPFS.getFileUrl(videoObj.id);
if (seq !== this._loadSeq) return;
if (localUrl) {
_currentBlobUrl = localUrl;
current = { meta: { ...videoObj }, qualities: [], audioUrl: null, localUrl };
this.afterLoad();
this.fallbackQueue = []; this.fbIndex = 0;
this.attach(null);
return;
}
}
}
const res = await API.getStreams(videoObj.id);
if (seq !== this._loadSeq) return; // a newer load took over meanwhile
if (!res || !res.ok) throw new Error(res?.error || 'Could not load streams');
current = res.data;
// Merge richer metadata we may already have from the list card.
current.meta = { ...videoObj, ...current.meta };
this.afterLoad();
const q = chooseQuality();
this.buildFallbackQueue(q);
this.attach(q);
} catch (err) {
showSpinner(false);
toast('⚠ ' + err.message);
// Show retry button in the player pane
const retry = els.playerPane.querySelector('.retry-btn');
if (retry) retry.remove();
const btn = document.createElement('button');
btn.className = 'retry-btn';
btn.textContent = '↻ Retry';
btn.addEventListener('click', () => {
btn.remove();
Player.loadVideo(videoObj, { preferStream, resume, reveal });
});
els.playerPane.appendChild(btn);
}
},
// Shared UI updates once `current` is populated (cached or streamed).
afterLoad() {
addToHistory(current.meta);
buildQualityMenu();
els.playerPane.classList.remove('empty');
els.controls.classList.remove('hidden');
els.nowMeta.classList.remove('hidden');
els.npTitle.textContent = current.meta.title;
els.npChannel.textContent = current.meta.channel || '';
updateNowPlayingActions();
markPlayingCard();
showMiniBar();
// Media Session API — OS media keys + lock screen
if ('mediaSession' in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
title: current.meta.title || '',
artist: current.meta.channel || '',
artwork: current.meta.thumbnail ? [{ src: current.meta.thumbnail, sizes: '480x360', type: 'image/jpeg' }] : [],
});
navigator.mediaSession.setActionHandler('play', () => Player.play());
navigator.mediaSession.setActionHandler('pause', () => Player.pause());
navigator.mediaSession.setActionHandler('previoustrack', () => playPrev());
navigator.mediaSession.setActionHandler('nexttrack', () => playNext());
navigator.mediaSession.setActionHandler('seekbackward', (d) => Player.seek(Math.max(0, Player.master.currentTime - (d.seekOffset || 10))));
navigator.mediaSession.setActionHandler('seekforward', (d) => Player.seek(Player.master.currentTime + (d.seekOffset || 10)));
}
// In portrait PWA mode, scroll the player into view so it's immediately
// visible without the user having to swipe up manually — but only for
// user-picked tracks; auto-advance must not hijack the scroll position.
if (this._revealOnLoad) scrollPlayerIntoViewPortrait();
// Restore A-B markers and load related (non-blocking)
restoreAbMarkers();
updateRememberPosUI();
loadRelated();
exitSelectMode();
// Restore saved playback position — only when this song's "remember
// position" flag is on (per-playlist copy first, global fallback), and
// never during a "play in full" session.
// On auto-advance (or prev/next) we do NOT resume the previous timestamp —
// a freshly selected track should start at the beginning, or at the A point
// when an A-B loop is set for it.
const id = current.meta.id;
if (playFullMode) {
// Play-in-full ignores resume positions and A markers alike.
} else if (this._resumeOnLoad && rememberPosEnabled() && data.resumePositions[id] && data.resumePositions[id] > 1) {
const saved = data.resumePositions[id];
const restore = () => {
Player.seek(saved);
toast('Resumed from ' + fmtTime(saved));
Player.master.removeEventListener('canplay', restore);
Player.master.removeEventListener('loadedmetadata', restore);
};
Player.master.addEventListener('canplay', restore, { once: true });
Player.master.addEventListener('loadedmetadata', restore, { once: true });
} else if (abA !== null && abA > 1) {
// Not resuming, but an A marker exists — start the loop from A.
const seekToA = () => {
Player.seek(abA);
Player.master.removeEventListener('canplay', seekToA);
Player.master.removeEventListener('loadedmetadata', seekToA);
};
Player.master.addEventListener('canplay', seekToA, { once: true });
Player.master.addEventListener('loadedmetadata', seekToA, { once: true });
}
},
// Ordered list of qualities to try if playback errors out. Progressive
// (single-file, has audio) goes near the front because it's the most reliable;
// then we walk from the lowest resolution up.
fallbackQueue: [],
fbIndex: 0,
buildFallbackQueue(chosen) {
const qs = (current.qualities || []).slice().sort((a, b) => a.height - b.height);
const seen = new Set();
const queue = [];
const push = (x) => { if (x && !seen.has(x.label)) { seen.add(x.label); queue.push(x); } };
push(chosen);
qs.filter((q) => q.hasAudio).forEach(push); // progressive = sturdiest
qs.forEach(push); // then everything, low → high
this.fallbackQueue = queue;
this.fbIndex = 0;
},
onMediaError() {
// A cached file failed to play — fall back to live streaming.
if (current && current.localUrl) {
const meta = current.meta;
current.localUrl = null;
toast('Cached copy unavailable — streaming instead…');
// Preserve the resume intent of the load that just failed.
this.loadVideo(meta, { preferStream: true, resume: this._resumeOnLoad, reveal: this._revealOnLoad });
return;
}
// Advance to the next candidate stream; give up with a clear message at the end.
if (this.fbIndex < this.fallbackQueue.length - 1) {
this.fbIndex++;
const next = this.fallbackQueue[this.fbIndex];
toast('Playback hiccup — trying ' + next.label + '…');
this.attach(next);
} else {
showSpinner(false);
toast('⚠ This video couldnt be played. Try another.');
}
},
attach(quality) {
const V = els.video, A = els.audio;
const audioOnly = data.settings.audioOnly;
// Reset
V.pause(); A.pause();
this.stopDrift();
if (current.localUrl) {
// Cached single file (carries its own audio). Play it directly.
if (audioOnly) {
this.mode = 'audio';
this.master = A;
this.secondary = null;
A.src = current.localUrl;
V.removeAttribute('src'); V.load();
els.art.classList.remove('hidden');
els.artImg.src = current.meta.thumbnail || '';
} else {
this.mode = 'progressive';
this.master = V;
this.secondary = null;
V.muted = false;
V.src = current.localUrl;
A.removeAttribute('src'); A.load();
els.art.classList.add('hidden');
}
this.applyVolume();
this.applySpeed();
this.master.load();
const onReadyLocal = () => { this.play(); this.master.removeEventListener('canplay', onReadyLocal); };
this.master.addEventListener('canplay', onReadyLocal);
// Cached/offline playback is just as susceptible to a silently-paused
// background audio element as streamed playback (audio-only mode still
// routes through <audio>), so it needs the same watchdog.
this.startDrift();
return;
}
if (audioOnly) {
// Play only audio; show cover art.
this.mode = 'audio';
this.master = A;
this.secondary = null;
A.src = current.audioUrl || (quality && quality.url) || '';
V.removeAttribute('src'); V.load();
els.art.classList.remove('hidden');
els.artImg.src = current.meta.thumbnail || '';
} else if (quality && quality.hasAudio) {
// Progressive single stream (video already carries audio).
this.mode = 'progressive';
this.master = V;
this.secondary = null;
V.muted = false;
V.src = quality.url;
A.removeAttribute('src'); A.load();
els.art.classList.add('hidden');
} else if (quality && current.audioUrl) {
// Adaptive: muted video + synced audio for high quality without muxing.
this.mode = 'dual';
this.master = V;
this.secondary = A;
V.muted = true;
V.src = quality.url;
A.src = current.audioUrl;
els.art.classList.add('hidden');
} else {
showSpinner(false);
toast('No playable stream found for this video.');
return;
}
this.applyVolume();
this.applySpeed();
this.master.load();
if (this.secondary) this.secondary.load();
const onReady = () => {
this.play();
this.master.removeEventListener('canplay', onReady);
};
this.master.addEventListener('canplay', onReady);
this.startDrift();
},
play() {
this._wantsPlaying = true;
this.master.play().catch(() => {});
if (this.secondary) {
this.secondary.currentTime = this.master.currentTime;
this.secondary.play().catch(() => {});
}
},
pause() {
this._wantsPlaying = false;
this.clearBufferGrace();
this.master.pause();
if (this.secondary) this.secondary.pause();
},
toggle() {
if (this.master.paused) this.play(); else this.pause();
},
seek(t) {
this.master.currentTime = t;
if (this.secondary) this.secondary.currentTime = t;
},
applyVolume() {
const v = data.settings.volume;
this.soundEl.volume = v;
this.soundEl.muted = v === 0;
// make sure the non-sound elements stay muted
if (this.mode === 'dual') els.video.muted = true;
},
applySpeed() {
const r = parseFloat(els.speed.value) || 1;
this.master.playbackRate = r;
if (this.secondary) this.secondary.playbackRate = r;
},
// Drift-correction thresholds for dual mode (muted video + synced audio).
// A hard `currentTime` jump on a *playing* <audio> element causes an
// audible pop/glitch — every earlier version of the watchdog snapped audio
// back into sync any time drift exceeded 0.3s, which is exactly what read
// as "audio stutter" (video stayed smooth because it was never touched).
// We now use a tiered response instead of one hard threshold:
// < SOFT_SYNC_THRESHOLD — inaudible, ignore (avoids constant micro-corrections)
// < HARD_SYNC_THRESHOLD — gently slew playbackRate so the audio eases back
// into sync over ~1s with no audible artifact
// >= HARD_SYNC_THRESHOLD — snap (only reached from seeks, buffer flushes,
// or background-resume, where a single correction
// is unavoidable and rare)
SOFT_SYNC_THRESHOLD: 0.08,
HARD_SYNC_THRESHOLD: 0.3,
SLEW_RATE: 0.04, // playbackRate offset applied while easing back into sync
// Shared by the 1s watchdog tick and the master 'playing' handler so both
// paths get the same no-pop behavior.
//
// AUDIO IS THE CLOCK. Corrections are applied to the muted <video>, never
// to the playing <audio>: seeking or rate-shifting the silent element is
// invisible, while the same operation on the sounding element is exactly
// the "stutter" users hear. On a phone that can't sustain the video
// bitrate, the video rebuffers in a loop — under the old master-is-clock
// scheme every one of those cycles seeked the live audio (and backgrounding
// the app, which halts video decode and this timer, made playback smooth —
// the reported iPhone symptom).
correctDrift() {
if (this.mode !== 'dual' || !this.secondary || this.master.paused) return;
if (this.secondary.paused) return; // resume paths own this case
const drift = this.secondary.currentTime - this.master.currentTime;
const abs = Math.abs(drift);
if (abs >= this.HARD_SYNC_THRESHOLD) {
// Snap the muted video onto the audio clock (inaudible).
this._internalSeek = true;
this.master.currentTime = this.secondary.currentTime;
this._resyncSpeed();
} else if (abs >= this.SOFT_SYNC_THRESHOLD) {
// Video behind audio -> speed the video up; ahead -> slow it down.
const base = parseFloat(els.speed.value) || 1;
this.master.playbackRate = drift > 0 ? base + this.SLEW_RATE : base - this.SLEW_RATE;
} else {
this._resyncSpeed();
}
},
// Restore playbackRates to the user-selected speed once drift is within
// tolerance (or after a hard snap) so a slew correction never lingers and
// overshoots.
_resyncSpeed() {
const base = parseFloat(els.speed.value) || 1;
if (this.master && this.master.playbackRate !== base) this.master.playbackRate = base;
if (this.secondary && this.secondary.playbackRate !== base) this.secondary.playbackRate = base;
},
// Runs for every mode (not just 'dual') as a watchdog: iOS Safari — most
// visibly in PWA/standalone mode on iPhone — can silently pause the element
// actually producing sound (the <audio> tag in 'dual'/'audio' mode) while
// the screen is locked or the app is backgrounded, without necessarily
// firing a 'pause' event we react to. The muted <video> element in 'dual'
// mode is unaffected and keeps rolling, which is why video looks fine while
// audio silently drops. Comparing `_wantsPlaying` (our intent) against the
// element's actual `.paused` state lets us detect and resume from that.
startDrift() {
this.stopDrift();
this.driftTimer = setInterval(() => {
if (!this._wantsPlaying) return;
// Dual-mode: keep the secondary audio track in sync with the master,
// via the tiered corrector above (no hard snap for small, normal drift).
this.correctDrift();
// The element that's actually producing sound in the current mode.
const sounder = this.soundEl;
if (sounder && sounder.paused) {
sounder.currentTime = this.master.currentTime;
sounder.play().catch(() => {});
}
}, 1000);
},
stopDrift() {
if (this.driftTimer) clearInterval(this.driftTimer);
this.driftTimer = null;
this.clearBufferGrace();
},
// Cancel a pending "pause synced audio after a stall" timer — used once the
// video recovers (playing/canplay), on an explicit pause, and when tearing
// down the current element (attach()/stopDrift()) so a stale timer never
// fires against a track that's already moved on.
clearBufferGrace() {
if (this.bufferGraceTimer) {
clearTimeout(this.bufferGraceTimer);
this.bufferGraceTimer = null;
}
},
// Called when the page/tab regains foreground focus (screen unlock, app
// switch back). This is the single most common trigger for iOS silently
// pausing background audio, so we react immediately here instead of
// waiting up to 1s for the next startDrift() tick.
resumeIfNeeded() {
if (!this._wantsPlaying) return;
const sounder = this.soundEl;
if (sounder && sounder.paused) {
sounder.currentTime = this.master.currentTime;
sounder.play().catch(() => {});
}
if (this.mode === 'dual' && this.secondary && this.secondary.paused && !this.master.paused) {
this.secondary.currentTime = this.master.currentTime;
this.secondary.play().catch(() => {});
}
},
};
// Recover from iOS silently pausing background audio the moment the app
// returns to the foreground (screen unlock, switching back from another app,
// or the PWA being restored from a frozen/backgrounded state). This fires far
// faster than the 1s watchdog interval, which matters for a jarring "audio
// paused, video kept playing" UX.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') Player.resumeIfNeeded();
});
window.addEventListener('pageshow', () => Player.resumeIfNeeded());
window.addEventListener('focus', () => Player.resumeIfNeeded());
function showSpinner(on) {
els.spinner.classList.toggle('hidden', !on);
}
function chooseQuality() {
const qs = current.qualities || [];
if (!qs.length) return null;
const pref = data.settings.quality;
if (pref && pref !== 'auto') {
const match = qs.find((q) => q.label === pref);
if (match) return match;
}
// auto: best quality at or below 720p, else the lowest available.
const sorted = [...qs].sort((a, b) => b.height - a.height);
return sorted.find((q) => q.height <= 720) || sorted[sorted.length - 1];
}
function buildQualityMenu() {
const qs = current.qualities || [];
els.quality.innerHTML = '';
const auto = document.createElement('option');
auto.value = 'auto';
auto.textContent = 'Auto';
els.quality.appendChild(auto);
const seen = new Set();
for (const q of qs) {
if (seen.has(q.label)) continue;
seen.add(q.label);
const o = document.createElement('option');
o.value = q.label;
o.textContent = q.label + (q.hasAudio ? '' : '');
els.quality.appendChild(o);
}
els.quality.value = data.settings.quality || 'auto';
}
// ---------- Player media events (master mirrors to secondary) ----------
function wirePlayerEvents() {
const V = els.video, A = els.audio;
const masterIs = (el) => Player.master === el;
function bind(el) {
el.addEventListener('play', () => { if (masterIs(el) && Player.secondary && Player.secondary.paused) { Player.secondary.currentTime = el.currentTime; Player.secondary.play().catch(() => {}); } updatePlayBtn(); });
el.addEventListener('pause', () => { if (masterIs(el) && Player.secondary) { Player.clearBufferGrace(); Player.secondary.pause(); } updatePlayBtn(); });
el.addEventListener('seeking', () => {
if (!masterIs(el) || !Player.secondary) return;
// Drift correction seeks the muted video onto the audio clock; mirroring
// that back onto the audio would re-create the audible snap it exists to
// avoid. Only user/programmatic seeks propagate to the audio.
if (Player._internalSeek) { Player._internalSeek = false; return; }
Player.secondary.currentTime = el.currentTime;
});
// Video re-buffering must NEVER interrupt the audio: the audio element is
// the playback clock (see correctDrift). Earlier versions paused the
// synced audio when a video stall outlasted a 250ms grace window and
// snapped its clock on recovery — on a phone that can't sustain the video
// bitrate that cycle repeats indefinitely and is heard as constant
// stuttering. Now a stalling video just shows the spinner and catches up
// to the audio clock (or gets snapped forward by the watchdog) when ready.
el.addEventListener('waiting', () => {
if (masterIs(el)) showSpinner(true);
});
el.addEventListener('playing', () => {
if (!masterIs(el)) return;
showSpinner(false);
Player.clearBufferGrace();
if (Player.secondary && !el.paused && Player.secondary.paused) {
// Resume-only path (audio silently paused by iOS or a real stall):
// seeking a *paused* audio element is inaudible, so sync then play.
Player.secondary.currentTime = el.currentTime;
Player.secondary.play().catch(() => {});
} else if (Player.secondary && !el.paused) {
// Audio kept playing through the video stall — realign the video.
Player.correctDrift();
}
});
el.addEventListener('canplay', () => { if (masterIs(el)) showSpinner(false); });
el.addEventListener('timeupdate', () => {
if (masterIs(el)) {
updateProgress();
// Persist playback position every 10s. timeupdate fires ~4×/s, so
// gate on the 10s bucket actually changing — otherwise persist()
// (full JSON.stringify + synchronous localStorage write) ran ~4
// times back-to-back within each matching second, a periodic
// main-thread stall on phones.
if (current && current.meta) {
const t = Player.master.currentTime;
const bucket = Math.floor(t / 10);
if (t > 5 && bucket !== Player._lastPersistBucket) {
Player._lastPersistBucket = bucket;
if (!playFullMode && rememberPosEnabled()) {
data.resumePositions[current.meta.id] = t;
persist();
}
}
}
}
});
el.addEventListener('pause', () => {
if (masterIs(el) && current && current.meta && !playFullMode && rememberPosEnabled()) {
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;
// Custom (edited) videos are already a saved-offline cut of a source video;
// re-saving or re-editing them makes no sense, so hide those actions.
const isCustom = !!current.meta.custom;
if (els.editBtn) els.editBtn.hidden = isCustom;
if (els.saveBtn) els.saveBtn.hidden = isCustom;
const btn = els.saveBtn;
if (!btn) return;
if (removing.has(id)) {
btn.textContent = '⏳ Removing…';
btn.classList.remove('done');
btn.disabled = true;
} else if (downloading.has(id)) {
btn.textContent = '⏳ Saving…';
btn.classList.remove('done');
btn.disabled = true;
} else if (cachedIds.has(id)) {
btn.textContent = '✓ Saved';
btn.classList.add('done');
btn.disabled = false;
btn.title = 'Saved for offline — click to remove from cache';
} else {
btn.textContent = '⬇ Save';
btn.classList.remove('done');
btn.disabled = false;
btn.title = 'Save this video for offline playback';
}
}
function updateProgress() {
const cur = Player.master.currentTime || 0;
const dur = Player.master.duration || current?.meta?.duration || 0;
els.curTime.textContent = fmtTime(cur);
els.durTime.textContent = fmtTime(dur);
if (dur) els.seek.value = String((cur / dur) * 1000);
updateMiniBar();
// A-B loop: passing B behaves like the track ending. Loop the A-B segment only
// when "loop current" is on; otherwise B is the effective end of the track and
// we advance per the repeat settings (next track, or wrap when "repeat list").
if (!playFullMode && abA !== null && abB !== null && abB > abA && cur >= abB) {
if (data.settings.loopOne) {
Player.seek(abA);
} else if (!advanceQueue()) {
// Nothing to advance to (repeat off, last track) — stop at B like a real end.
Player.pause();
Player.seek(abA);
updatePlayBtn();
$('upnext').classList.add('hidden');
}
}
}
// ============================================================================
// Mini now-playing bar
// ============================================================================
function showMiniBar() {
if (!current || !current.meta) return;
$('miniTitle').textContent = current.meta.title;
const miniCh = $('miniChannel');
miniCh.textContent = current.meta.channel || '';
miniCh.classList.toggle('link', !!channelKeyOf(current.meta));
$('miniBar').classList.remove('hidden');
updateMiniBar();
}
function updateMiniBar() {
if (!current || !current.meta) return;
const cur = Player.master.currentTime || 0;
const dur = Player.master.duration || 0;
$('miniTime').textContent = fmtTime(cur);
if (dur > 0) {
$('miniProgressFill').style.width = Math.min(100, (cur / dur) * 100) + '%';
}
}
function hideMiniBar() {
$('miniBar').classList.add('hidden');
}
// ============================================================================
// History
// ============================================================================
function addToHistory(meta) {
data.playCount = data.playCount || {};
data.playCount[meta.id] = (data.playCount[meta.id] || 0) + 1;
data.history = data.history.filter((v) => v.id !== meta.id);
data.history.unshift({
id: meta.id, title: meta.title, channel: meta.channel,
channelId: meta.channelId || '', channelUrl: meta.channelUrl || '',
duration: meta.duration, thumbnail: meta.thumbnail,
});
if (data.history.length > 200) data.history.length = 200;
persist();
if (view.type === 'history') renderList();
}
// ============================================================================
// Queue / navigation
// ============================================================================
function playFromList(list, index, source = '', { playFull = false } = {}) {
queue = list;
queueIndex = index;
queueSource = source;
playFullMode = playFull;
unshuffledQueue = null;
// Play-in-full keeps the list order — a service set plays as arranged.
if (data.settings.shuffle && !playFull) applyShuffle();
Player.loadVideo(queue[queueIndex]);
renderUpNext();
}
// ---------- Shuffle ----------
// Shuffles only the UPCOMING part of the live queue (Fisher-Yates); the
// already-played prefix and the current track stay where they are. The
// pre-shuffle order is kept so turning shuffle off restores it.
let unshuffledQueue = null;
function applyShuffle() {
if (queue.length <= queueIndex + 2) return; // nothing meaningful to shuffle
if (!unshuffledQueue) unshuffledQueue = queue.slice();
const upcoming = queue.slice(queueIndex + 1);
for (let i = upcoming.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[upcoming[i], upcoming[j]] = [upcoming[j], upcoming[i]];
}
queue = queue.slice(0, queueIndex + 1).concat(upcoming);
}
function toggleShuffle() {
data.settings.shuffle = !data.settings.shuffle;
if (data.settings.shuffle) {
applyShuffle();
} else if (unshuffledQueue) {
const playingId = current && current.meta && current.meta.id;
queue = unshuffledQueue;
unshuffledQueue = null;
const idx = playingId ? queue.findIndex((v) => v.id === playingId) : -1;
if (idx >= 0) queueIndex = idx;
}
persist();
updateLoopRepeatButtons();
renderUpNext();
toast(data.settings.shuffle ? 'Shuffle on' : 'Shuffle off');
}
// ---------- Temporary queue ----------
function updateQueueBadge() {
const count = data.queue.length;
const b = $('navQueueCount');
if (b) { b.textContent = String(count); b.classList.toggle('hidden', count === 0); }
// Mirror to bottom-nav badge
const bb = $('bnQueueCount');
if (bb) { bb.textContent = String(count); bb.classList.toggle('hidden', count === 0); }
}
function addToQueue(video, { quiet = false } = {}) {
if (!video || !video.id) return;
if (data.queue.some((v) => v.id === video.id)) { if (!quiet) toast('Already in queue'); return; }
data.queue.push(slim(video));
persist();
updateQueueBadge();
if (view.type === 'queue') renderList();
if (!quiet) toast('Added to queue');
}
function removeFromQueue(id) {
data.queue = data.queue.filter((v) => v.id !== id);
// Keep an in-progress queue playback in sync if it was sourced from the queue.
if (queueSource === 'queue') {
const playingId = current && current.meta && current.meta.id;
queue = data.queue.slice();
queueIndex = playingId ? queue.findIndex((v) => v.id === playingId) : -1;
renderUpNext();
}
persist();
updateQueueBadge();
if (view.type === 'queue') renderList();
}
function clearQueue() {
data.queue = [];
persist();
updateQueueBadge();
if (view.type === 'queue') renderList();
toast('Queue cleared');
}
function playQueue(index = 0) {
if (!data.queue.length) return;
playFromList(data.queue.slice(), index, 'queue');
}
function renderUpNext() {
const upcoming = queue.slice(queueIndex + 1);
if (!upcoming.length) { $('upnext').classList.add('hidden'); return; }
$('upnext').classList.remove('hidden');
$('upnextCount').textContent = String(upcoming.length);
const list = $('upnextList');
list.innerHTML = '';
upcoming.forEach((v) => {
const item = document.createElement('div');
item.className = 'upnext-item';
item.innerHTML = `
<img src="${v.thumbnail || ''}" alt="" loading="lazy" />
<div class="upnext-info">
<div class="upnext-title"></div>
<div class="upnext-channel"></div>
</div>`;
item.querySelector('.upnext-title').textContent = v.title;
const upChEl = item.querySelector('.upnext-channel');
upChEl.textContent = v.channel || '';
if (channelKeyOf(v)) {
upChEl.classList.add('link');
upChEl.title = 'View channel';
upChEl.addEventListener('click', (e) => { e.stopPropagation(); openChannel(channelKeyOf(v), v.channel); });
}
item.addEventListener('click', () => {
const idx = queue.findIndex((x) => x.id === v.id);
if (idx >= 0) { queueIndex = idx - 1; playNext(); }
});
list.appendChild(item);
});
}
// Advance to the next track. Wraps to the start when "repeat list" is on.
function advanceQueue() {
if (queueIndex >= 0 && queueIndex < queue.length - 1) {
queueIndex++;
} else if (!playFullMode && data.settings.repeatMode === 'all' && queue.length) {
queueIndex = 0;
} else {
return false;
}
// Advancing to a new track always starts from the beginning (or its A point),
// never the previous resume timestamp — and never scrolls the user away
// from whatever page they are browsing while listening.
Player.loadVideo(queue[queueIndex], { resume: false, reveal: false });
renderUpNext();
return true;
}
function playNext() {
if (!advanceQueue()) { updatePlayBtn(); $('upnext').classList.add('hidden'); }
}
// Fired when a track finishes on its own — honors single-video loop first.
function onTrackEnded() {
if (!playFullMode && data.settings.loopOne) { Player.seek(0); Player.play(); return; }
if (!advanceQueue()) {
if (playFullMode) { playFullMode = false; toast('Finished playing in full'); }
updatePlayBtn();
$('upnext').classList.add('hidden');
}
}
function toggleLoopOne() {
data.settings.loopOne = !data.settings.loopOne;
persist();
updateLoopRepeatButtons();
toast(data.settings.loopOne ? 'Looping current video' : 'Loop off');
}
function toggleRepeat() {
data.settings.repeatMode = data.settings.repeatMode === 'all' ? 'off' : 'all';
persist();
updateLoopRepeatButtons();
toast(data.settings.repeatMode === 'all' ? 'Repeating list' : 'Repeat off');
}
function updateLoopRepeatButtons() {
if (els.loopBtn) els.loopBtn.classList.toggle('active', !!data.settings.loopOne);
if (els.repeatBtn) els.repeatBtn.classList.toggle('active', data.settings.repeatMode === 'all');
if (els.shuffleBtn) els.shuffleBtn.classList.toggle('active', !!data.settings.shuffle);
}
function playPrev() {
if (Player.master.currentTime > 3) { Player.seek(0); return; }
if (queueIndex > 0) {
queueIndex--;
Player.loadVideo(queue[queueIndex], { resume: false, reveal: false });
renderUpNext();
}
}
// ============================================================================
// Channel view — list a channel's uploads with quick actions
// ============================================================================
async function openChannel(channelKey, displayName) {
if (!channelKey) { toast('No channel info for this video'); return; }
view = { type: 'channel' };
channelData = { name: displayName || 'Channel', url: channelKey, key: channelKey, results: [], loading: true };
render();
try {
const res = await API.getChannel(channelKey);
if (view.type !== 'channel' || channelData.key !== channelKey) return; // navigated away
if (!res || !res.ok) throw new Error(res?.error || 'Could not load channel');
channelData.results = res.results || [];
channelData.name = res.channel || displayName || 'Channel';
channelData.url = res.channelUrl || channelKey;
channelData.loading = false;
renderList();
} catch (err) {
channelData.loading = false;
if (view.type === 'channel') {
els.cards.innerHTML = '';
els.status.classList.remove('hidden');
els.status.textContent = '⚠ ' + err.message;
}
}
}
// The identifier we hand the backend to look a channel up (URL preferred).
function channelKeyOf(v) {
return (v && (v.channelUrl || v.channelId)) || '';
}
// ============================================================================
// Rendering
// ============================================================================
function renderSidebar() {
els.playlistList.innerHTML = '';
for (const pl of data.playlists) {
const item = document.createElement('div');
item.className = 'playlist-item' + (view.type === 'playlist' && view.id === pl.id ? ' active' : '');
item.innerHTML = `<span class="pl-name"></span><span class="pl-count">${pl.videos.length}</span>`;
item.querySelector('.pl-name').textContent = pl.name;
item.addEventListener('click', () => { view = { type: 'playlist', id: pl.id }; render(); });
els.playlistList.appendChild(item);
}
document.querySelectorAll('.nav-item').forEach((b) => {
b.classList.toggle('active', b.dataset.view === view.type);
});
// Keep bottom-nav in sync with sidebar nav active state
document.querySelectorAll('.bottom-nav-btn').forEach((b) => {
b.classList.toggle('active', b.dataset.view === view.type);
});
updateQueueBadge();
updateDownloadBadge();
}
function currentList() {
if (view.type === 'search') return searchResults;
if (view.type === 'history') return data.history;
if (view.type === 'queue') return data.queue;
if (view.type === 'channel') return channelData.results;
if (view.type === 'smart') return getSmartList(view.smartType);
if (view.type === 'playlist') {
const pl = data.playlists.find((p) => p.id === view.id);
return pl ? pl.videos : [];
}
return [];
}
function showSearchSkeletons() {
els.status.classList.add('hidden');
els.cards.innerHTML = '';
for (let i = 0; i < 8; i++) {
const s = document.createElement('div');
s.className = 'card skeleton-card';
s.innerHTML = `
<div class="thumb skeleton-shimmer"></div>
<div class="card-info">
<div class="skeleton-line skeleton-shimmer" style="width:85%"></div>
<div class="skeleton-line skeleton-shimmer" style="width:55%;margin-top:8px"></div>
</div>
<div class="skeleton-dot skeleton-shimmer"></div>`;
els.cards.appendChild(s);
}
}
function renderList() {
if (view.type === 'settings') { renderSettings(); return; }
if (view.type === 'saved') { renderSaved(); return; }
if (view.type === 'downloads') { renderDownloads(); return; }
// Filter bar — show for filterable views
const filterableViews = ['history', 'playlist', 'queue', 'channel', 'smart'];
const showFilter = filterableViews.includes(view.type);
$('listFilterBar').classList.toggle('hidden', !showFilter);
$('batchBar').classList.toggle('hidden', !selectMode);
if (selectMode) $('batchCount').textContent = `${selectedIds.size} selected`;
// Build the list, applying filter if active
let list = currentList();
if (listFilter && showFilter) {
const q = listFilter.toLowerCase();
list = list.filter((v) => (v.title || '').toLowerCase().includes(q) || (v.channel || '').toLowerCase().includes(q));
}
els.cards.innerHTML = '';
els.cards.classList.toggle('select-mode', selectMode);
els.listActions.innerHTML = '';
if (view.type === 'search') {
els.listTitle.textContent = 'Search results';
} else if (view.type === 'history') {
els.listTitle.textContent = 'History';
if (list.length) {
const clear = document.createElement('button');
clear.textContent = 'Clear';
clear.onclick = () => { data.history = []; persist(); renderList(); };
els.listActions.appendChild(clear);
}
} else if (view.type === 'queue') {
els.listTitle.textContent = 'Queue';
if (list.length) {
const playAll = document.createElement('button');
playAll.textContent = '▶ Play queue';
playAll.onclick = () => playQueue(0);
const clear = document.createElement('button');
clear.textContent = 'Clear';
clear.onclick = clearQueue;
els.listActions.append(playAll, clear);
}
} else if (view.type === 'smart') {
const sp = SMART_PLAYLISTS.find((s) => s.id === view.smartType);
els.listTitle.textContent = sp ? sp.label : 'Auto Playlist';
if (list.length) {
const playAll = document.createElement('button');
playAll.textContent = '▶ Play all';
playAll.onclick = () => playFromList(list.slice(), 0, 'smart:' + view.smartType);
const queueAll = document.createElement('button');
queueAll.textContent = ' Queue all';
queueAll.onclick = () => { list.forEach((v) => addToQueue(v, { quiet: true })); toast('Added to queue'); };
els.listActions.append(playAll, queueAll);
}
} else if (view.type === 'channel') {
els.listTitle.textContent = channelData.name || 'Channel';
if (list.length) {
const playAll = document.createElement('button');
playAll.textContent = '▶ Play all';
playAll.onclick = () => playFromList(channelData.results.slice(), 0, 'channel');
const queueAll = document.createElement('button');
queueAll.textContent = ' Queue all';
queueAll.onclick = () => { channelData.results.forEach((v) => addToQueue(v, { quiet: true })); toast('Added channel to queue'); };
els.listActions.append(playAll, queueAll);
}
} else if (view.type === 'playlist') {
const pl = data.playlists.find((p) => p.id === view.id);
els.listTitle.textContent = pl ? pl.name : 'Playlist';
if (pl) {
const playAll = document.createElement('button');
playAll.textContent = '▶ Play all';
playAll.onclick = () => { if (pl.videos.length) playFromList(pl.videos, 0, 'playlist:' + pl.id); };
const playFull = document.createElement('button');
playFull.textContent = '▶ Play in full';
playFull.title = 'Play every video start to finish — ignores saved positions and A-B loops, stops after the last one';
playFull.onclick = () => {
if (!pl.videos.length) return;
playFromList(pl.videos, 0, 'playlist:' + pl.id, { playFull: true });
toast('Playing in full — stops after the last video');
};
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, playFull, 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. Normal videos re-download on demand, but edited videos are removed for good (they exist only here).'), [
{ label: 'Cancel', onClick: closeModal },
{ label: 'Clear all', danger: true, onClick: async () => {
try { await API.cacheClear(); } catch {}
cachedIds.clear();
// Custom cuts live only in the cache — clearing it destroys their media,
// so drop their registry entries and playlist references too.
const customIds = new Set((data.customVideos || []).map((v) => v.id));
if (customIds.size) {
data.customVideos = [];
data.playlists.forEach((pl) => { pl.videos = pl.videos.filter((x) => !customIds.has(x.id)); });
data.queue = (data.queue || []).filter((x) => !customIds.has(x.id));
persist();
}
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 known = videoById(it.id);
// A custom (edited) id has no YouTube thumbnail; fall back to its source's
// thumbnail when we know it, else a neutral placeholder.
const isCustom = (known && known.custom) || String(it.id).startsWith('edit_');
const fallbackThumb = isCustom ? '' : `https://i.ytimg.com/vi/${it.id}/mqdefault.jpg`;
const v = known || { id: it.id, title: videoTitleById(it.id), thumbnail: fallbackThumb };
const row = document.createElement('div');
row.className = 'card saved-card' + (isCustom ? ' custom' : '');
row.dataset.id = it.id;
row.innerHTML = `
<div class="thumb"><img loading="lazy" src="${v.thumbnail || ''}" alt="" />${isCustom ? '<span class="edit-badge" title="Edited copy">✂</span>' : ''}</div>
<div class="card-info">
<div class="card-title"></div>
<div class="card-channel saved-size">${isCustom ? '✂ edited · ' : ''}${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();
if (isCustom) { await deleteCustomVideo(it.id); renderSaved(); return; }
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;
// Custom (edited) videos live only in data.customVideos — their media is in
// the offline cache, never on YouTube, so nothing else references them.
const cv = (data.customVideos || []).find((x) => x.id === id);
if (cv) return cv;
return null;
}
function videoTitleById(id) {
const v = videoById(id);
return v ? v.title : id;
}
async function renderSettings() {
els.listTitle.textContent = 'Settings';
els.listActions.innerHTML = '';
const c = els.cards;
els.status.classList.add('hidden');
c.innerHTML = '';
const wrap = document.createElement('div');
wrap.className = 'settings';
// ---- Playback ----
const qualities = ['auto', '1080p', '720p', '480p', '360p', '240p'];
const qOptions = qualities
.map((q) => `<option value="${q}"${data.settings.quality === q ? ' selected' : ''}>${q === 'auto' ? 'Auto' : q}</option>`)
.join('');
const sel = (id, val, opts) => `<select id="${id}" class="set-select">` +
opts.map(([v, label]) => `<option value="${v}"${val === v ? ' selected' : ''}>${label}</option>`).join('') +
`</select>`;
wrap.innerHTML = `
<div class="set-group">
<div class="set-group-title">Appearance &amp; accessibility</div>
<label class="set-row">
<span>Theme</span>
${sel('setTheme', data.settings.theme, [['dark', 'Dark'], ['light', 'Light'], ['contrast', 'High contrast']])}
</label>
<label class="set-row">
<span>Font size</span>
${sel('setFont', data.settings.fontScale, [['small', 'Small'], ['normal', 'Normal'], ['large', 'Large'], ['xl', 'Extra large']])}
</label>
<label class="set-row">
<span>Layout density</span>
${sel('setDensity', data.settings.density, [['comfortable', 'Comfortable'], ['compact', 'Compact']])}
</label>
<label class="set-row">
<span>
Performance mode
<small>Turns off heavy visual effects (grain, blur, glows) for a faster, lighter UI.</small>
</span>
<input id="setPerf" type="checkbox" ${data.settings.perfMode ? 'checked' : ''} />
</label>
<label class="set-row">
<span>
Reduce motion
<small>Disables animations and transitions.</small>
</span>
<input id="setMotion" type="checkbox" ${data.settings.reduceMotion ? 'checked' : ''} />
</label>
</div>
<div class="set-group">
<div class="set-group-title">Playback</div>
<label class="set-row">
<span>Default quality</span>
<select id="setQuality" class="set-select">${qOptions}</select>
</label>
<label class="set-row">
<span>Repeat list when finished</span>
<input id="setRepeat" type="checkbox" ${data.settings.repeatMode === 'all' ? 'checked' : ''} />
</label>
<label class="set-row">
<span>Loop the current video</span>
<input id="setLoopOne" type="checkbox" ${data.settings.loopOne ? 'checked' : ''} />
</label>
<label class="set-row">
<span>Default volume</span>
<input id="setVolume" type="range" min="0" max="1" step="0.01" value="${data.settings.volume}" />
</label>
<label class="set-row">
<span>Audio-only mode</span>
<input id="setAudioOnly" type="checkbox" ${data.settings.audioOnly ? 'checked' : ''} />
</label>
<label class="set-row">
<span>
Save before playing
<small>Downloads the whole video to this device first (the server compiles video + audio into one file), then plays the local copy. One stream instead of two, but playback starts after the download finishes.</small>
</span>
<input id="setSaveBeforePlay" type="checkbox" ${data.settings.saveBeforePlay ? 'checked' : ''} />
</label>
</div>
<div class="set-group">
<div class="set-group-title">Offline cache</div>
<label class="set-row">
<span>
Auto-save playlist videos
<small>Videos you add to a playlist are downloaded and kept for offline playback.</small>
</span>
<input id="setAutoPreload" type="checkbox" ${data.settings.autoPreload ? 'checked' : ''} />
</label>
<div class="set-row">
<span>Storage used</span>
<span id="cacheTotal" class="set-stat">…</span>
</div>
<label class="set-row">
<span>
Cache size limit
<small>Auto-evict oldest files when cache exceeds this.</small>
</span>
<select id="cacheCapSelect" class="set-select">
<option value="0">No limit</option>
<option value="1073741824">1 GB</option>
<option value="2147483648">2 GB</option>
<option value="5368709120">5 GB</option>
<option value="10737418240">10 GB</option>
</select>
</label>
<div class="set-actions">
<button id="clearCacheBtn" class="btn danger">Clear all cached videos</button>
</div>
<div id="cacheList" class="cache-list"></div>
</div>
<div class="set-group">
<div class="set-group-title">Playlists</div>
<div class="set-row" style="border:none;gap:8px;flex-wrap:wrap">
<button id="exportBtn" class="btn">Export playlists</button>
<button id="importBtn" class="btn">Import playlists</button>
<input id="fileInput" type="file" accept=".json" style="display:none" />
</div>
</div>
<div class="set-group">
<div class="set-group-title">Backup &amp; restore</div>
<label class="set-row">
<span>
Auto-backup
<small>Export a full backup JSON on a schedule.</small>
</span>
<input id="setAutoBackup" type="checkbox" ${data.settings.autoBackupEnabled ? 'checked' : ''} />
</label>
<label class="set-row">
<span>Backup interval</span>
${sel('setBackupInterval', String(data.settings.autoBackupIntervalDays || 7), [['1','Daily'],['7','Weekly'],['30','Monthly']])}
</label>
<div class="set-row" style="border:none;gap:8px;flex-wrap:wrap">
<button id="backupNowBtn" class="btn">Export full backup now</button>
<button id="importBackupBtn" class="btn">Import backup</button>
<input id="backupFileInput" type="file" accept=".json" style="display:none" />
</div>
</div>
<div class="set-group">
<div class="set-group-title">Reset</div>
<div class="set-row">
<span>
Force refresh UI
<small>Re-fetches the app shell and assets, bypassing the browser cache, then reloads.</small>
</span>
<div class="set-actions" style="margin:0">
<button id="refreshUiBtn" class="btn">Refresh UI</button>
</div>
</div>
<div class="set-row">
<span>
Reset app
<small>Clears all local storage, unregisters the service worker, and deletes every cache. You will need to reload after.</small>
</span>
<div class="set-actions" style="margin:0">
<button id="resetAppBtn" class="btn danger">Reset app</button>
</div>
</div>
</div>
<div class="set-group">
<div class="set-group-title">Online profile — sync between devices</div>
<div class="set-row">
<span>
Linked profile
<small>Playlists, history and settings sync to the server under this name. The name works like a passkey — anyone who knows it can load and change this data, so prefer a random one.</small>
</span>
<span id="profileStatus" class="set-stat">${data.profile && data.profile.name ? data.profile.name : 'Not linked'}</span>
</div>
<div class="set-actions" style="display:flex;gap:8px;flex-wrap:wrap">
<button id="profileCreateBtn" class="btn">Create profile</button>
<button id="profileLoadBtn" class="btn">Load profile</button>
<button id="profileUnlinkBtn" class="btn danger"${data.profile && data.profile.name ? '' : ' style="display:none"'}>Unlink</button>
</div>
</div>
<div class="set-group">
<div class="set-group-title">About</div>
<div class="set-row">
<span>
Version
<small>App version and current build.</small>
</span>
<div class="set-actions" style="margin:0">
<span id="aboutVersion" class="about-version">v${APP_VERSION}</span>
</div>
</div>
<div class="set-row">
<span>
Build time
<small>When the running server build was produced — compare across devices to confirm you're on the latest version.</small>
</span>
<span id="aboutBuildTime" class="about-version">…</span>
</div>
</div>`;
c.appendChild(wrap);
// ---- Wire appearance / accessibility ----
$('setTheme').addEventListener('change', (e) => { data.settings.theme = e.target.value; applyAppearance(); persist(); });
$('setFont').addEventListener('change', (e) => { data.settings.fontScale = e.target.value; applyAppearance(); persist(); });
$('setDensity').addEventListener('change', (e) => { data.settings.density = e.target.value; applyAppearance(); persist(); });
$('setPerf').addEventListener('change', (e) => { data.settings.perfMode = e.target.checked; applyAppearance(); persist(); });
$('setMotion').addEventListener('change', (e) => { data.settings.reduceMotion = e.target.checked; applyAppearance(); persist(); });
$('setRepeat').addEventListener('change', (e) => { data.settings.repeatMode = e.target.checked ? 'all' : 'off'; updateLoopRepeatButtons(); persist(); });
$('setLoopOne').addEventListener('change', (e) => { data.settings.loopOne = e.target.checked; updateLoopRepeatButtons(); persist(); });
// ---- Wire playback controls ----
$('setQuality').addEventListener('change', (e) => {
data.settings.quality = e.target.value;
els.quality.value = e.target.value;
persist();
});
$('setVolume').addEventListener('input', (e) => {
data.settings.volume = parseFloat(e.target.value);
els.volume.value = e.target.value;
Player.applyVolume();
els.muteBtn.textContent = data.settings.volume === 0 ? '🔇' : '🔊';
persist();
});
$('setAudioOnly').addEventListener('change', (e) => {
data.settings.audioOnly = e.target.checked;
els.audioOnlyToggle.checked = e.target.checked;
persist();
});
$('setSaveBeforePlay').addEventListener('change', (e) => {
data.settings.saveBeforePlay = e.target.checked;
persist();
});
$('setAutoPreload').addEventListener('change', (e) => {
data.settings.autoPreload = e.target.checked;
persist();
if (e.target.checked) data.playlists.forEach(preloadPlaylist);
});
// ---- Cache management ----
$('clearCacheBtn').addEventListener('click', () => {
showModal('Clear all cached videos?', document.createTextNode('This frees disk space. Playlists keep their entries and will re-download on demand.'), [
{ label: 'Cancel', onClick: closeModal },
{
label: 'Clear all', danger: true, onClick: async () => {
try { await API.cacheClear(); } catch {}
cachedIds.clear();
closeModal();
toast('Cache cleared');
if (current) updateNowPlayingActions();
renderList();
},
},
]);
});
// ---- Populate live cache stats ----
try {
const res = await API.cacheList();
if (view.type !== 'settings') return; // user navigated away
const total = (res && res.total) || 0;
const items = (res && res.items) || [];
$('cacheTotal').textContent = `${fmtBytes(total)} · ${items.length} video${items.length === 1 ? '' : 's'}`;
const listEl = $('cacheList');
if (!items.length) {
listEl.innerHTML = '<div class="cache-empty">No videos saved offline yet.</div>';
} else {
items.sort((a, b) => b.size - a.size);
items.forEach((it) => {
const row = document.createElement('div');
row.className = 'cache-item';
row.innerHTML = `<span class="ci-title"></span><span class="ci-size">${fmtBytes(it.size)}</span><button class="ci-del" title="Delete from cache">✕</button>`;
row.querySelector('.ci-title').textContent = videoTitleById(it.id);
row.querySelector('.ci-del').addEventListener('click', async () => {
try { await API.cacheDelete(it.id); } catch {}
cachedIds.delete(it.id);
if (current && current.meta && current.meta.id === it.id) updateNowPlayingActions();
toast('Removed from cache');
renderList();
});
listEl.appendChild(row);
});
}
} catch {
const t = $('cacheTotal');
if (t) t.textContent = 'Offline cache not available in this build';
}
// ---- Cache cap ----
const capSel = $('cacheCapSelect');
capSel.value = String(data.settings.cacheCap || 0);
capSel.addEventListener('change', (e) => {
data.settings.cacheCap = parseInt(e.target.value) || 0;
persist();
});
// ---- Export / Import playlists ----
$('exportBtn').addEventListener('click', exportPlaylists);
$('importBtn').addEventListener('click', () => $('fileInput').click());
$('fileInput').addEventListener('change', importPlaylists);
// ---- Backup ----
$('setAutoBackup').addEventListener('change', (e) => { data.settings.autoBackupEnabled = e.target.checked; persist(); });
$('setBackupInterval').addEventListener('change', (e) => { data.settings.autoBackupIntervalDays = parseInt(e.target.value) || 7; persist(); });
$('backupNowBtn').addEventListener('click', () => doAutoBackup());
$('importBackupBtn').addEventListener('click', () => $('backupFileInput').click());
$('backupFileInput').addEventListener('change', importBackup);
// ---- Online profile ----
$('profileCreateBtn').addEventListener('click', createProfileFlow);
$('profileLoadBtn').addEventListener('click', loadProfileFlow);
$('profileUnlinkBtn').addEventListener('click', () => {
data.profile = null;
persist();
updateProfileStatus();
toast('Profile unlinked — this device stops syncing (server copy is kept)');
});
// ---- About: append server build tag + build time (WEB mode only) ----
if (WEB) {
try {
const res = await fetch('/api/version', { cache: 'no-store' });
if (res.ok && view.type === 'settings') {
const v = await res.json();
const el = $('aboutVersion');
if (el && v.buildTag) el.textContent = `v${v.version || APP_VERSION} · build ${v.buildTag}`;
const bt = $('aboutBuildTime');
if (bt) {
const t = v.buildTime ? new Date(v.buildTime) : null;
if (t && !isNaN(t)) {
// Shown in GMT+8 regardless of device/server timezone, plus build age.
const gmt8 = new Date(t.getTime() + 8 * 3600e3).toISOString().replace('T', ' ').slice(0, 16);
const s = Math.max(0, Math.floor((Date.now() - t.getTime()) / 1000));
const ago = s < 60 ? `${s} second${s === 1 ? '' : 's'} ago`
: s < 3600 ? `${Math.floor(s / 60)} minute${Math.floor(s / 60) === 1 ? '' : 's'} ago`
: s < 86400 ? `${Math.floor(s / 3600)} hour${Math.floor(s / 3600) === 1 ? '' : 's'} ago`
: `${Math.floor(s / 86400)} day${Math.floor(s / 86400) === 1 ? '' : 's'} ago`;
bt.textContent = `${gmt8} +08 · built ${ago}`;
} else {
bt.textContent = 'unknown';
}
}
}
} catch { /* offline — leave the static version in place */ }
}
// ---- Reset ----
$('refreshUiBtn').addEventListener('click', async () => {
const btn = $('refreshUiBtn');
btn.disabled = true;
btn.textContent = 'Refreshing…';
// hardReloadUI() deletes all caches and unregisters the SW so the
// next page load always fetches fresh assets from the network.
await hardReloadUI();
});
$('resetAppBtn').addEventListener('click', () => {
showModal(
'Reset app?',
(() => { const p = document.createElement('p'); p.textContent = 'This clears all local data and caches. The page will reload.'; return p; })(),
[
{ label: 'Cancel', onClick: closeModal },
{
label: 'Reset', danger: true, onClick: async () => {
closeModal();
try {
localStorage.clear();
sessionStorage.clear();
if ('serviceWorker' in navigator) {
const regs = await navigator.serviceWorker.getRegistrations();
await Promise.all(regs.map((r) => r.unregister()));
}
if ('caches' in window) {
const keys = await caches.keys();
await Promise.all(keys.map((k) => caches.delete(k)));
}
} catch { /* best effort — reload regardless */ }
window.location.href = '/';
},
},
],
);
});
}
function renderCard(v, index, list) {
const card = document.createElement('div');
const isCached = cachedIds.has(v.id);
const isDownloading = downloading.has(v.id);
card.className = 'card' + (isCached ? ' cached' : '') + (isDownloading ? ' downloading' : '') + (selectedIds.has(v.id) ? ' selected' : '');
card.dataset.id = v.id;
card.innerHTML = `
<div class="batch-check"></div>
<div class="thumb">
<img loading="lazy" src="${v.thumbnail || ''}" alt="" />
${v.duration ? `<span class="dur">${fmtTime(v.duration)}</span>` : ''}
<span class="saved-badge" title="${isDownloading ? 'Downloading…' : 'Saved offline'}">${isDownloading ? '' : '⬇'}</span>
${isDownloading ? '<div class="dl-progress"><div class="dl-bar"></div></div>' : ''}
</div>
<div class="card-info">
<div class="card-title"></div>
<div class="card-channel"></div>
</div>
<button class="card-menu" title="Add to playlist"></button>`;
card.querySelector('.card-title').textContent = v.title;
const chEl = card.querySelector('.card-channel');
chEl.textContent = v.channel || '';
if (channelKeyOf(v)) {
chEl.classList.add('link');
chEl.title = 'View channel';
chEl.addEventListener('click', (e) => {
e.stopPropagation();
openChannel(channelKeyOf(v), v.channel);
});
}
card.addEventListener('click', (e) => {
if (e.target.closest('.card-menu') || e.target.closest('.card-del') || e.target.closest('.card-channel.link')) return;
if (selectMode) { toggleSelectCard(v.id); return; }
playFromList(list, index, view.type === 'playlist' ? 'playlist:' + view.id : view.type);
});
card.querySelector('.card-menu').addEventListener('click', (e) => {
e.stopPropagation();
openCardMenu(v);
});
// Per-item delete in playlist, queue, history, and smart views.
if (view.type === 'playlist' || view.type === 'queue' || view.type === 'history' || view.type === 'smart') {
const del = document.createElement('button');
del.className = 'card-del';
del.title = view.type === 'queue' ? 'Remove from queue'
: (view.type === 'history' || view.type === 'smart') ? 'Remove from history'
: 'Remove from playlist';
del.textContent = '✕';
del.addEventListener('click', (e) => {
e.stopPropagation();
if (view.type === 'queue') { removeFromQueue(v.id); return; }
if (view.type === 'history' || view.type === 'smart') {
data.history = data.history.filter((x) => x.id !== v.id);
persist();
renderList();
return;
}
const pl = data.playlists.find((p) => p.id === view.id);
if (pl) { pl.videos = pl.videos.filter((x) => x.id !== v.id); persist(); render(); toast('Removed from playlist'); }
});
card.appendChild(del);
}
// Drag-to-reorder in playlist and queue views
if (view.type === 'playlist' || view.type === 'queue') {
card.draggable = true;
card.addEventListener('dragstart', () => {
card.classList.add('dragging');
dragSource = index;
});
card.addEventListener('dragend', () => {
card.classList.remove('dragging');
});
card.addEventListener('dragover', (e) => {
e.preventDefault();
card.classList.add('drag-over');
});
card.addEventListener('dragleave', () => {
card.classList.remove('drag-over');
});
card.addEventListener('drop', (e) => {
e.preventDefault();
card.classList.remove('drag-over');
const from = dragSource;
const to = index;
if (from === to || from < 0) return;
const arr = view.type === 'queue'
? data.queue
: (data.playlists.find((p) => p.id === view.id) || {}).videos;
if (!arr) return;
const [moved] = arr.splice(from, 1);
arr.splice(to, 0, moved);
if (view.type === 'queue' && queueSource === 'queue') {
const playingId = current && current.meta && current.meta.id;
queue = data.queue.slice();
queueIndex = playingId ? queue.findIndex((x) => x.id === playingId) : queueIndex;
renderUpNext();
}
persist();
renderList();
});
}
return card;
}
function markPlayingCard() {
document.querySelectorAll('.card').forEach((c) => {
c.classList.toggle('playing', current && c.dataset.id === current.meta.id);
});
}
let _lastViewKey = null;
function render() {
listFilter = '';
const fi = $('listFilterInput');
if (fi) fi.value = '';
// On mobile, navigating (nav item / playlist / search) dismisses the drawer.
if (typeof closeSidebar === 'function') closeSidebar();
exitSelectMode();
renderSidebar();
renderSmartSidebar();
renderList();
// Portrait PWA: reveal the freshly selected view. Only on actual view
// changes — same-view re-renders (deletes, modal confirms, profile load)
// must not hijack the scroll position. Skipped on the very first render.
const viewKey = [view.type, view.id, view.smartType].join('|');
if (_lastViewKey !== null && viewKey !== _lastViewKey) scrollListIntoViewPortrait();
_lastViewKey = viewKey;
}
// ============================================================================
// Playlists
// ============================================================================
function exportPlaylists() {
const json = JSON.stringify(data.playlists, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `ytplayer-playlists-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
toast('Playlists exported');
}
function importPlaylists(e) {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const imported = JSON.parse(ev.target.result);
if (!Array.isArray(imported)) throw new Error('Invalid format');
// Merge: skip duplicates by id, add new ones
const existingIds = new Set(data.playlists.map((p) => p.id));
for (const pl of imported) {
if (pl.id && pl.name && Array.isArray(pl.videos) && !existingIds.has(pl.id)) {
data.playlists.push(pl);
existingIds.add(pl.id);
}
}
persist();
renderSidebar();
toast(`Imported ${imported.length} playlist(s)`);
} catch (err) {
toast('⚠ Failed to import: ' + err.message);
}
};
reader.readAsText(file);
e.target.value = ''; // allow re-import of same file
}
function openCardMenu(video) {
const inPlaylistView = view.type === 'playlist';
const body = document.createElement('div');
body.className = 'modal-list';
// Quick: add to queue
const queueBtn = document.createElement('button');
queueBtn.textContent = '▶ Add to queue';
queueBtn.onclick = () => { addToQueue(video); closeModal(); };
body.appendChild(queueBtn);
const isCustom = !!video.custom;
// Quick: save / remove offline. For a custom (edited) video there's no
// source to re-download, so "remove" deletes the edit entirely.
const saveBtn = document.createElement('button');
const isSaved = cachedIds.has(video.id);
if (isCustom) {
saveBtn.textContent = '🗑 Delete edited video';
saveBtn.className = 'danger';
saveBtn.onclick = () => { closeModal(); deleteCustomVideo(video.id); };
} else {
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);
// Quick: edit & download (only for real source videos — a custom cut can't
// be re-cut server-side because its media lives only in the browser cache).
if (!isCustom) {
const editBtn = document.createElement('button');
editBtn.textContent = '✂ Edit & download';
editBtn.onclick = () => { closeModal(); openVideoEditor(video); };
body.appendChild(editBtn);
}
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) {
const s = {
id: v.id, title: v.title, channel: v.channel,
channelId: v.channelId || '', channelUrl: v.channelUrl || '',
duration: v.duration, thumbnail: v.thumbnail,
};
// Preserve the markers that make a custom (edited) video self-contained, so
// a slimmed copy sitting in a playlist still knows it's an offline-only cut.
if (v.custom) {
s.custom = true;
s.sourceId = v.sourceId || '';
if (v.keep) s.keep = v.keep;
}
return s;
}
function newPlaylist(addVideo) {
const input = document.createElement('input');
input.type = 'text';
input.placeholder = 'Playlist name';
showModal('New playlist', input, [
{ label: 'Cancel', onClick: closeModal },
{
label: 'Create', primary: true, onClick: () => {
const name = input.value.trim();
if (!name) return;
const pl = { id: uid(), name, videos: addVideo ? [slim(addVideo)] : [] };
data.playlists.push(pl);
if (addVideo) preload(addVideo); // auto-cache for offline playback
persist();
closeModal();
view = { type: 'playlist', id: pl.id };
render();
toast(`Created “${name}`);
},
},
]);
setTimeout(() => input.focus(), 50);
}
function renamePlaylist(pl) {
const input = document.createElement('input');
input.type = 'text';
input.value = pl.name;
showModal('Rename playlist', input, [
{ label: 'Cancel', onClick: closeModal },
{
label: 'Save', primary: true, onClick: () => {
const name = input.value.trim();
if (name) { pl.name = name; persist(); }
closeModal(); render();
},
},
]);
setTimeout(() => { input.focus(); input.select(); }, 50);
}
function deletePlaylist(pl) {
showModal(`Delete “${pl.name}”?`, document.createTextNode('This cannot be undone.'), [
{ label: 'Cancel', onClick: closeModal },
{
label: 'Delete', danger: true, onClick: () => {
data.playlists = data.playlists.filter((p) => p.id !== pl.id);
persist();
closeModal();
view = { type: 'search' };
render();
},
},
]);
}
// ============================================================================
// Keyboard shortcut help overlay
// ============================================================================
function toggleShortcutHelp() {
const overlay = $('shortcutHelp');
overlay.classList.toggle('hidden');
}
function wireShortcutHelp() {
$('shortcutClose').addEventListener('click', () => $('shortcutHelp').classList.add('hidden'));
$('shortcutHelp').addEventListener('click', (e) => {
if (e.target.id === 'shortcutHelp') $('shortcutHelp').classList.add('hidden');
});
}
// ============================================================================
// Modal
// ============================================================================
function showModal(title, bodyNode, actions) {
$('modalTitle').textContent = title;
const body = $('modalBody');
body.innerHTML = '';
body.appendChild(bodyNode);
const act = $('modalActions');
act.innerHTML = '';
actions.forEach((a) => {
const b = document.createElement('button');
b.className = 'btn' + (a.primary ? ' primary' : '') + (a.danger ? ' danger' : '');
b.textContent = a.label;
b.onclick = a.onClick;
act.appendChild(b);
});
$('modal').classList.remove('hidden');
}
function closeModal() { $('modal').classList.add('hidden'); }
// Tapping the dimmed backdrop dismisses the modal (same as Cancel/Later).
// Guarantees a modal can never permanently swallow every tap on the page —
// the full-screen backdrop sits above the bottom nav, so a modal the user
// doesn't notice (e.g. the update prompt popping under their thumb) used to
// read as "all navigation buttons stopped working".
$('modal').addEventListener('click', (e) => {
if (e.target === $('modal')) closeModal();
});
// ============================================================================
// Portrait PWA detection & behavioral layer
// Mirrors the CSS media query: (display-mode: standalone) and (orientation: portrait)
// The JS layer adds:
// • A `portrait-pwa` class on .app for any JS-driven conditional logic.
// • Auto-close the sidebar when rotating into portrait-standalone mode.
// • Auto-scroll the player into view when a video starts in portrait mode.
// ============================================================================
const _portraitPwaMQ = window.matchMedia
? window.matchMedia('(display-mode: standalone) and (orientation: portrait)')
: { matches: false, addEventListener: () => {} };
function isPortraitPWA() {
return _portraitPwaMQ.matches;
}
function applyPortraitPwaClass() {
const app = document.querySelector('.app');
if (!app) return;
if (isPortraitPWA()) {
app.classList.add('portrait-pwa');
} else {
app.classList.remove('portrait-pwa');
}
}
// In portrait mode, .body is the single scroll container (player pane on top,
// list pane below). Scrolling it to 0 brings the player fully into view.
function scrollPlayerIntoViewPortrait() {
if (!isPortraitPWA()) return;
const body = document.querySelector('.body');
if (body) {
body.scrollTo({ top: 0, behavior: 'smooth' });
}
}
// Bring the list pane to the top of the portrait scroll container so a newly
// rendered view is actually on-screen. While a track is playing, the player
// pane above it is taller than the whole viewport — without this scroll the
// fresh view sits invisibly below the fold and every nav tap looks dead
// (the mobile "can't navigate while playing" bug).
function scrollListIntoViewPortrait() {
if (!isPortraitPWA()) return;
const body = document.querySelector('.body');
const listPane = document.querySelector('.list-pane');
if (!body || !listPane) return;
const top = body.scrollTop + listPane.getBoundingClientRect().top - body.getBoundingClientRect().top;
body.scrollTo({ top, behavior: 'smooth' });
}
function setupPortraitPwaWatcher() {
applyPortraitPwaClass();
_portraitPwaMQ.addEventListener('change', () => {
applyPortraitPwaClass();
// Rotating into portrait-standalone: close any open sidebar drawer so the
// full-width layout isn't obscured.
if (isPortraitPWA()) closeSidebar();
});
}
// ============================================================================
// Layout-viewport anchor guard (iOS Safari / standalone PWA)
//
// The app is a fixed-viewport layout: body is overflow:hidden and only inner
// panes scroll, so the document itself must always sit at scroll position 0.
// iOS WebKit can still scroll the *layout viewport* behind our back — exiting
// native video fullscreen (webkitEnterFullscreen is the only fullscreen path
// on iPhone, and iOS enters it by itself when the phone rotates while a video
// plays), the on-screen keyboard revealing a focused input, or any
// scrollIntoView() walking up into <html>. Once that happens, fixed elements
// (bottom nav, mini-bar) are still *drawn* in place but their hit-testing
// regions are offset by the stray scroll amount, so taps on the nav buttons
// silently do nothing — and because the body isn't user-scrollable there is
// no gesture that can undo it. That is the "nav buttons stop working after
// playing a video" iPhone bug. Snap the document back to 0 whenever it ends
// up scrolled; skipped while an input is focused so we never fight the
// keyboard auto-scroll, then re-anchored once focus leaves the field.
function setupViewportAnchorGuard() {
const editing = () => {
const el = document.activeElement;
return !!el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable);
};
const reanchor = () => {
if (editing()) return;
const doc = document.scrollingElement || document.documentElement;
if (window.scrollY || doc.scrollTop) window.scrollTo(0, 0);
};
window.addEventListener('scroll', reanchor);
document.addEventListener('focusout', () => setTimeout(reanchor, 50));
// Exiting native video fullscreen is the most reliable reproducer of the
// stray-scroll state; the scroll event alone doesn't always fire for it.
els.video.addEventListener('webkitendfullscreen', () => setTimeout(reanchor, 50));
// iOS can offset the layout viewport WITHOUT firing a window scroll event
// (keyboard dismissal animations, rotation mid-playback, in-app browser
// chrome changes). visualViewport does report those; hook it when present.
if (window.visualViewport) {
window.visualViewport.addEventListener('scroll', () => setTimeout(reanchor, 50));
window.visualViewport.addEventListener('resize', () => setTimeout(reanchor, 50));
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') setTimeout(reanchor, 50);
});
// Last-resort safety net for offsets none of the events above report:
// a 2s tick that reads scrollY (cheap) and snaps back only when displaced,
// so "nav taps silently do nothing" can never persist longer than a beat.
setInterval(reanchor, 2000);
}
// ============================================================================
// Events
// ============================================================================
// ============================================================================
// Responsive sidebar drawer (mobile)
// ============================================================================
function openSidebar() {
const app = document.querySelector('.app');
if (!app) return;
app.classList.add('sidebar-open');
const t = $('sidebarToggle');
if (t) t.setAttribute('aria-expanded', 'true');
}
function closeSidebar() {
const app = document.querySelector('.app');
if (!app) return;
app.classList.remove('sidebar-open');
const t = $('sidebarToggle');
if (t) t.setAttribute('aria-expanded', 'false');
}
function setupSidebarDrawer() {
const toggle = $('sidebarToggle');
const backdrop = $('sidebarBackdrop');
if (toggle) {
toggle.addEventListener('click', () => {
const app = document.querySelector('.app');
if (app && app.classList.contains('sidebar-open')) closeSidebar();
else openSidebar();
});
}
if (backdrop) backdrop.addEventListener('click', closeSidebar);
}
function wireUI() {
setupSidebarDrawer();
els.searchForm.addEventListener('submit', async (e) => {
e.preventDefault();
const q = els.searchInput.value.trim();
if (!q) return;
view = { type: 'search' };
render();
showSearchSkeletons();
// Even when already on the search view (no view change for render() to
// detect), a new query means the user wants to see the results.
scrollListIntoViewPortrait();
try {
const res = await API.search(q);
if (!res || !res.ok) throw new Error(res?.error || 'Search failed');
searchResults = res.results || [];
renderList();
} catch (err) {
els.cards.innerHTML = '';
els.status.classList.remove('hidden');
els.status.textContent = '⚠ ' + err.message;
}
});
document.querySelectorAll('.nav-item').forEach((b) => {
b.addEventListener('click', () => { view = { type: b.dataset.view }; render(); });
});
// Bottom-nav (portrait PWA) — same view switching as sidebar nav
document.querySelectorAll('.bottom-nav-btn').forEach((b) => {
b.addEventListener('click', () => { view = { type: b.dataset.view }; render(); });
});
els.newPlaylistBtn.addEventListener('click', () => newPlaylist(null));
// Landing-hero quick-search chips — rotate periodically
const CHIP_SETS = [
[{ q: 'lofi hip hop radio', label: 'lofi beats' }, { q: 'live news', label: 'live news' }, { q: 'relaxing music', label: 'relaxing music' }, { q: 'podcast highlights', label: 'podcasts' }],
[{ q: 'ambient jazz', label: 'ambient jazz' }, { q: 'tech talk coding', label: 'tech talks' }, { q: 'street food', label: 'street food' }, { q: 'travel vlog', label: 'travel vlogs' }],
[{ q: 'synthwave mix', label: 'synthwave' }, { q: 'asmr rain', label: 'rain sounds' }, { q: 'documentary', label: 'documentaries' }, { q: 'workout music', label: 'workout' }],
[{ q: '60s rock classics', label: 'classic rock' }, { q: 'nature sounds', label: 'nature' }, { q: 'data science', label: 'data science' }, { q: 'city walks', label: 'city walks' }],
];
const chipsEl = document.querySelector('.hero-suggests');
let chipRotation = 0;
function rotateChips() {
chipRotation = (chipRotation + 1) % CHIP_SETS.length;
const set = CHIP_SETS[chipRotation];
const buttons = chipsEl.querySelectorAll('.chip');
buttons.forEach((btn, i) => {
if (i < set.length) {
btn.dataset.q = set[i].q;
btn.textContent = set[i].label;
}
});
}
setInterval(rotateChips, 8000);
document.querySelectorAll('.chip').forEach((c) => {
c.addEventListener('click', () => {
els.searchInput.value = c.dataset.q || c.textContent.trim();
els.searchForm.requestSubmit();
});
});
// Sleep timer — click cycles through off/15/30/45/60/90 min
$('sleepTimerBtn').addEventListener('click', () => {
const options = [0, 15, 30, 45, 60, 90];
const curMin = sleepTimerRemaining > 0 ? Math.ceil(sleepTimerRemaining / 60) : 0;
const next = options.find((o) => o > curMin) ?? 0;
if (next === 0) { cancelSleepTimer(); toast('Sleep timer off'); }
else { startSleepTimer(next); toast(`Pausing in ${next} min`); }
});
$('sleepCancelBtn').addEventListener('click', () => { cancelSleepTimer(); toast('Sleep timer cancelled'); });
// A-B markers
$('abABtn').addEventListener('click', setAbA);
$('abBBtn').addEventListener('click', setAbB);
$('abClearBtn').addEventListener('click', clearAb);
$('rememberPosBtn').addEventListener('click', toggleRememberPos);
// 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);
});
if (els.editBtn) els.editBtn.addEventListener('click', () => {
if (current && current.meta) openVideoEditor(current.meta);
});
els.queueBtn.addEventListener('click', () => {
if (current && current.meta) addToQueue(current.meta);
});
// Now-playing channel name → channel view
els.npChannel.addEventListener('click', () => {
if (current && current.meta && channelKeyOf(current.meta)) {
openChannel(channelKeyOf(current.meta), current.meta.channel);
}
});
$('miniChannel').addEventListener('click', () => {
if (current && current.meta && channelKeyOf(current.meta)) {
openChannel(channelKeyOf(current.meta), current.meta.channel);
}
});
// Controls
els.playBtn.addEventListener('click', () => Player.toggle());
els.nextBtn.addEventListener('click', playNext);
els.prevBtn.addEventListener('click', playPrev);
els.shuffleBtn.addEventListener('click', toggleShuffle);
els.loopBtn.addEventListener('click', toggleLoopOne);
els.repeatBtn.addEventListener('click', toggleRepeat);
els.fsBtn.addEventListener('click', () => {
const stage = els.video.parentElement;
const video = els.video;
// iOS Safari — including an installed PWA running in standalone mode —
// does not implement the standard Fullscreen API for arbitrary elements.
// stage.requestFullscreen is simply undefined there, so the button did
// nothing (this is the iPhone-in-portrait-PWA bug report). WebKit instead
// exposes a video-only, non-standard fullscreen API that *does* work in
// standalone mode: HTMLVideoElement.webkitEnterFullscreen/ExitFullscreen.
// Try the standard API first everywhere else, then fall back to the
// WebKit video API before giving up.
if (document.fullscreenElement || video.webkitDisplayingFullscreen) {
try {
if (document.exitFullscreen) document.exitFullscreen()?.catch(() => {});
else if (video.webkitExitFullscreen) video.webkitExitFullscreen();
} catch { /* e.g. InvalidStateError — nothing more we can do */ }
return;
}
if (stage.requestFullscreen) {
// requestFullscreen() can both throw synchronously (e.g.
// InvalidStateError when preconditions like active user-gesture
// transient activation aren't met) and return a promise that rejects
// asynchronously. Guard against both instead of letting either surface
// as an uncaught error.
try {
stage.requestFullscreen()?.catch(() => {});
} catch { /* no-op — fullscreen simply won't engage this time */ }
} else if (video.webkitEnterFullscreen) {
if (Player.mode === 'audio') {
toast('Fullscreen isnt available in audio-only mode');
return;
}
try {
// Throws InvalidStateError if the element has no loaded media (e.g.
// no video source yet) — nothing to show fullscreen in that case.
video.webkitEnterFullscreen();
} catch { /* no-op — no media loaded to go fullscreen with */ }
} else {
toast('Fullscreen isnt supported on this device');
}
});
els.seek.addEventListener('input', () => {
const dur = Player.master.duration || 0;
if (dur) Player.seek((parseFloat(els.seek.value) / 1000) * dur);
});
els.volume.addEventListener('input', () => {
data.settings.volume = parseFloat(els.volume.value);
Player.applyVolume();
els.muteBtn.textContent = data.settings.volume === 0 ? '🔇' : '🔊';
persist();
});
els.muteBtn.addEventListener('click', () => {
if (data.settings.volume > 0) { els.muteBtn.dataset.prev = data.settings.volume; data.settings.volume = 0; }
else { data.settings.volume = parseFloat(els.muteBtn.dataset.prev || '1'); }
els.volume.value = String(data.settings.volume);
Player.applyVolume();
els.muteBtn.textContent = data.settings.volume === 0 ? '🔇' : '🔊';
persist();
});
els.speed.addEventListener('change', () => Player.applySpeed());
els.quality.addEventListener('change', () => {
data.settings.quality = els.quality.value;
persist();
if (!current) return;
// reload at the new quality, preserving position + play state
const t = Player.master.currentTime;
const wasPlaying = !Player.master.paused;
const q = chooseQuality();
Player.attach(q);
const restore = () => {
Player.seek(t);
if (!wasPlaying) Player.pause();
Player.master.removeEventListener('canplay', restore);
};
Player.master.addEventListener('canplay', restore);
});
els.audioOnlyToggle.addEventListener('change', () => {
data.settings.audioOnly = els.audioOnlyToggle.checked;
persist();
if (!current) return;
const t = Player.master.currentTime;
const q = chooseQuality();
Player.attach(q);
const restore = () => { Player.seek(t); Player.master.removeEventListener('canplay', restore); };
Player.master.addEventListener('canplay', restore);
});
// Keyboard
document.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT') return;
if (e.code === 'Space') { e.preventDefault(); Player.toggle(); }
else if (e.code === 'ArrowRight') Player.seek(Player.master.currentTime + 5);
else if (e.code === 'ArrowLeft') Player.seek(Math.max(0, Player.master.currentTime - 5));
else if (e.key === 'f') els.fsBtn.click();
else if (e.key === 'm') els.muteBtn.click();
else if (e.key === 'l') toggleLoopOne();
else if (e.key === 'r') toggleRepeat();
else if (e.key === 's') toggleShuffle();
else if (e.key === 'q') { if (current && current.meta) addToQueue(current.meta); }
else if (e.key === 'a') { if (current) setAbA(); }
else if (e.key === 'b') { if (current) setAbB(); }
else if (e.key === '?') toggleShortcutHelp();
else if (e.key === 'Escape') {
const app = document.querySelector('.app');
if (app && app.classList.contains('sidebar-open')) closeSidebar();
else if (!$('shortcutHelp').classList.contains('hidden')) $('shortcutHelp').classList.add('hidden');
else if (selectMode) { exitSelectMode(); renderList(); }
}
});
$('modal').addEventListener('click', (e) => { if (e.target.id === 'modal') closeModal(); });
// Mini now-playing bar
$('miniPlayBtn').addEventListener('click', (e) => { e.stopPropagation(); Player.toggle(); });
$('miniBar').addEventListener('click', () => {
hideMiniBar();
// Scroll the player into view if needed. In portrait PWA, scroll only the
// .body pane (the designated scroll container) — scrollIntoView() also
// scrolls overflow:hidden ancestors up to <html> on iOS, leaving the
// document offset and fixed-element hit testing broken.
if (isPortraitPWA()) scrollPlayerIntoViewPortrait();
else els.playerPane.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
$('miniCloseBtn').addEventListener('click', (e) => { e.stopPropagation(); hideMiniBar(); });
// Drag-to-reorder: prevent default on cards container
els.cards.addEventListener('dragover', (e) => {
if (view.type === 'playlist' || view.type === 'queue') e.preventDefault();
});
}
// ============================================================================
// Sleep timer
// ============================================================================
function startSleepTimer(minutes) {
cancelSleepTimer();
if (!minutes) return;
sleepTimerRemaining = minutes * 60;
updateSleepTimerUI();
sleepTimerTick = setInterval(() => {
sleepTimerRemaining--;
if (sleepTimerRemaining <= 0) {
cancelSleepTimer();
Player.pause();
toast('Sleep timer — paused');
} else {
updateSleepTimerUI();
}
}, 1000);
}
function cancelSleepTimer() {
if (sleepTimerTick) clearInterval(sleepTimerTick);
sleepTimerTick = null;
sleepTimerRemaining = 0;
updateSleepTimerUI();
}
function updateSleepTimerUI() {
const active = sleepTimerRemaining > 0;
$('sleepStatus').classList.toggle('hidden', !active);
if (active) {
const m = Math.floor(sleepTimerRemaining / 60);
const s = sleepTimerRemaining % 60;
$('sleepCountdown').textContent = `⏱ Pausing in ${m}:${String(s).padStart(2, '0')}`;
}
const btn = $('sleepTimerBtn');
if (btn) btn.classList.toggle('sleep-active', active);
}
// ============================================================================
// A-B loop markers
// ============================================================================
function setAbA() {
if (!current) return;
abA = Player.master.currentTime;
saveAbMarkers();
updateAbUI();
toast(`A set at ${fmtTime(abA)}`);
}
function setAbB() {
if (!current) return;
abB = Player.master.currentTime;
saveAbMarkers();
updateAbUI();
toast(`B set at ${fmtTime(abB)}`);
}
function clearAb() {
abA = null; abB = null;
if (current && current.meta) {
delete data.abMarkers[current.meta.id];
const entry = currentPlaylistEntry();
if (entry) delete entry.ab;
persist();
}
updateAbUI();
toast('A-B loop cleared');
}
function saveAbMarkers() {
if (!current || !current.meta) return;
if (abA !== null || abB !== null) {
const marker = { a: abA, b: abB };
// When playing from a playlist, store the marker on that playlist's own
// copy of the video so each playlist keeps its own A-B loop and it syncs
// to the database alongside the playlist. Otherwise fall back to the
// global per-video map.
const entry = currentPlaylistEntry();
if (entry) entry.ab = marker;
else data.abMarkers[current.meta.id] = marker;
persist();
}
}
function restoreAbMarkers() {
if (!current || !current.meta) { abA = null; abB = null; updateAbUI(); return; }
// Prefer a marker stored on the current playlist's copy of the video; fall
// back to the global per-video marker.
const entry = currentPlaylistEntry();
const saved = (entry && entry.ab) || data.abMarkers[current.meta.id];
abA = saved ? saved.a : null;
abB = saved ? saved.b : null;
updateAbUI();
}
// Returns the video entry inside the playlist currently being played, if the
// active playback source is a playlist and the playing video belongs to it.
function currentPlaylistEntry() {
if (!current || !current.meta) return null;
if (!queueSource || !queueSource.startsWith('playlist:')) return null;
const plId = queueSource.slice('playlist:'.length);
const pl = data.playlists.find((p) => p.id === plId);
if (!pl || !Array.isArray(pl.videos)) return null;
return pl.videos.find((v) => v.id === current.meta.id) || null;
}
// ---------- Remember-position flag ----------
// Like A-B markers, the flag is per-song-per-playlist: stored on the playlist's
// own copy of the video (`entry.rememberPos`) when playing from that playlist,
// with `data.rememberPos[videoId]` as the fallback for non-playlist playback.
// Resume positions are only saved and restored for songs with the flag on.
function rememberPosEnabled() {
if (!current || !current.meta) return false;
const entry = currentPlaylistEntry();
return entry ? !!entry.rememberPos : !!data.rememberPos[current.meta.id];
}
function toggleRememberPos() {
if (!current || !current.meta) return;
const id = current.meta.id;
const on = !rememberPosEnabled();
const entry = currentPlaylistEntry();
if (entry) {
if (on) entry.rememberPos = true; else delete entry.rememberPos;
} else {
if (on) data.rememberPos[id] = true; else delete data.rememberPos[id];
}
persist();
updateRememberPosUI();
toast(on ? 'Remembering playback position' : 'Not remembering position');
}
function updateRememberPosUI() {
const btn = $('rememberPosBtn');
if (btn) btn.classList.toggle('active', rememberPosEnabled());
}
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; playFullMode = false; 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 || {});
Object.assign(data.rememberPos, imp.rememberPos || {});
for (const [id, cnt] of Object.entries(imp.playCount || {})) data.playCount[id] = Math.max(data.playCount[id] || 0, cnt);
Object.assign(data.abMarkers, imp.abMarkers || {});
persist();
closeModal();
render();
toast('Backup imported');
}},
]);
} catch (err) { toast('⚠ Invalid backup: ' + err.message); }
};
reader.readAsText(file);
e.target.value = '';
}
// ============================================================================
// Build-tag polling — detect server restarts / new deploys (WEB mode only)
//
// The server stamps every response from GET /api/version with a `buildTag`
// that is stable for the lifetime of the process (changes on restart/deploy).
// We baseline the tag on first fetch; any subsequent change means new code is
// live and we surface the existing update banner so the user can reload.
// ============================================================================
let _knownBuildTag = null;
async function checkBuildTag() {
try {
const res = await fetch('/api/version', { cache: 'no-store' });
if (!res.ok) return;
const data = await res.json();
const tag = data.buildTag;
if (!tag) return;
if (!_knownBuildTag) {
_knownBuildTag = tag; // baseline on first successful fetch
return;
}
if (tag !== _knownBuildTag) {
_knownBuildTag = tag; // update so we don't show the banner twice
showUpdateBanner();
}
} catch { /* network error — try again next interval */ }
}
function pollBuildTag() {
checkBuildTag();
setInterval(checkBuildTag, 5 * 60 * 1000); // recheck every 5 minutes
}
// ============================================================================
// PWA — service worker registration and update banner (WEB mode only)
// ============================================================================
// Hard-reload helper: wipes every SW cache and unregisters the SW so the
// next page load fetches fresh assets from the network, not stale cache.
// Called from both the update dialog and the Settings → Force refresh UI button.
async function hardReloadUI() {
try {
// 1. Delete every named cache (app shell, thumbs, fonts, etc.)
if ('caches' in window) {
const keys = await caches.keys();
await Promise.all(keys.map((k) => caches.delete(k)));
}
// 2. Unregister every service worker registration so the browser
// fetches sw.js fresh on the next load rather than serving a
// cached copy that could re-populate the caches.
if ('serviceWorker' in navigator) {
const regs = await navigator.serviceWorker.getRegistrations();
await Promise.all(regs.map((r) => r.unregister()));
}
} catch { /* best effort — reload regardless */ }
// Hard navigation to the root forces the browser to fetch index.html from
// the network (no SW is registered any more to intercept it).
window.location.href = window.location.origin + '/';
}
let _updateBannerShown = false;
function showUpdateBanner() {
// Only show the dialog once per page load
if (_updateBannerShown) return;
_updateBannerShown = true;
// Show a modal dialog instead of a fleeting toast so the user can't miss it.
const body = document.createElement('p');
body.textContent = 'A new version of YT Player is available. Click "Refresh UI" to reload the page with the latest build. All your playlists and history are stored locally and will be preserved.';
showModal('⬆ Update available', body, [
{ label: 'Later', onClick: closeModal },
{ label: 'Refresh UI', primary: true, onClick: async () => {
closeModal();
await applyUpdate();
}},
]);
}
// Tracks the current SW registration so applyUpdate() can reach the
// waiting worker without re-querying getRegistration().
let _swReg = null;
// Applies a pending SW update in place (see sw-update.js for the full
// rationale — this used to call hardReloadUI(), which caused the "Update
// ready" banner to reappear right after being applied).
async function applyUpdate() {
const reg = _swReg || (('serviceWorker' in navigator) ? await navigator.serviceWorker.getRegistration() : null);
await window.SwUpdate.applyUpdate({
reg,
container: navigator.serviceWorker,
reload: () => window.location.reload(),
});
}
async function registerServiceWorker() {
if (!WEB || !('serviceWorker' in navigator)) return;
try {
// updateViaCache:'none' prevents the browser from serving a stale
// cached copy of sw.js — the server always returns it fresh (no-store).
const reg = await navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' });
_swReg = reg;
// A waiting worker only means "update pending" when this page is already
// controlled by a previous SW. On a FIRST install (fresh visit, or right
// after Settings → Force refresh unregisters everything) the brand-new
// worker passes through the `installed`/waiting state for a moment before
// activating — with no controller that is not an update, and showing the
// banner for it is exactly the "Update available keeps coming back after
// Refresh UI" loop.
const hasController = () => !!navigator.serviceWorker.controller;
// If a new SW is already waiting (e.g. user refreshed after an update),
// show the dialog right away.
if (reg.waiting && hasController()) { showUpdateBanner(); return; }
// Listen for a new SW installing after the page is open.
reg.addEventListener('updatefound', () => {
const sw = reg.installing;
if (!sw) return;
sw.addEventListener('statechange', () => {
if (sw.state === 'installed' && reg.waiting && hasController()) showUpdateBanner();
});
});
// The SW can also broadcast SW_UPDATE_AVAILABLE on its own activate.
navigator.serviceWorker.addEventListener('message', (e) => {
if (e.data && e.data.type === 'SW_UPDATE_AVAILABLE') showUpdateBanner();
});
// Check for updates in the background (useful for long-lived sessions)
reg.update().catch(() => {});
} catch (err) {
console.warn('[sw] registration failed:', err.message);
}
}
// ============================================================================
// Boot
// ============================================================================
async function boot() {
wirePlayerEvents();
wireUI();
wireShortcutHelp();
setupPortraitPwaWatcher();
setupViewportAnchorGuard();
try {
const loaded = await API.loadData();
if (loaded && typeof loaded === 'object') {
data = {
playlists: loaded.playlists || [],
history: loaded.history || [],
queue: loaded.queue || [],
customVideos: loaded.customVideos || [],
resumePositions: loaded.resumePositions || {},
rememberPos: loaded.rememberPos || {},
playCount: loaded.playCount || {},
abMarkers: loaded.abMarkers || {},
lastAutoBackup: loaded.lastAutoBackup || 0,
settings: { ...DEFAULT_SETTINGS, ...(loaded.settings || {}) },
profile: loaded.profile || null,
};
}
} catch {
// first run / bridge not ready — start with defaults
}
// Linked online profile: adopt the server copy if another device pushed a
// newer one. Runs before any rendering so no re-render pass is needed.
await pullProfileIfNewer();
applyAppearance();
updateLoopRepeatButtons();
updateQueueBadge();
els.volume.value = String(data.settings.volume ?? 1);
els.quality.value = data.settings.quality || 'auto';
els.audioOnlyToggle.checked = !!data.settings.audioOnly;
// Learn what's already cached, then top up any playlist videos that aren't.
await refreshCachedIds();
data.playlists.forEach(preloadPlaylist);
renderSmartSidebar();
checkAutoBackup();
render();
els.searchInput.focus();
// Register service worker + ping server with fingerprint (WEB mode only)
registerServiceWorker();
if (WEB) pollBuildTag();
if (WEB) {
fetch('/api/user/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fingerprint: window.getFingerprint ? window.getFingerprint() : 'unknown',
appVersion: APP_VERSION,
}),
}).catch(() => {});
}
}
document.addEventListener('DOMContentLoaded', boot);