/* ============================================================================ * 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__). 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:',…) 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 = `

Pick a name (3–40 characters: letters, digits, - or _). The name is the key — anyone who knows it can load this profile on their device, so use something hard to guess or go random.

`; 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 3–40 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 = `

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.

`; 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 = '
'; 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 doesn’t 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 const body = document.createElement('div'); body.className = 'video-editor'; body.innerHTML = `

Mark the parts to remove. Everything else is kept and saved as a new offline video.

`; const fromEl = body.querySelector('.ve-from'); const toEl = body.querySelector('.ve-to'); const addBtn = body.querySelector('.ve-add'); 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) fromEl.value = VideoEdit.fmtTime(time); else if (!toEl.value) toEl.value = VideoEdit.fmtTime(time); updateScrubber(); }; 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 = `✂ ${VideoEdit.fmtTime(cut.start)} – ${VideoEdit.fmtTime(cut.end)}`; 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: ${VideoEdit.fmtTime(finalLen)} 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