diff --git a/Dockerfile b/Dockerfile index f94423a..7f61067 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,10 +14,13 @@ FROM oven/bun:1-debian # ---- System dependencies ---- # python3 is required by yt-dlp for some extraction paths # ca-certificates for HTTPS fetches from yt-dlp +# ffmpeg lets yt-dlp merge bestvideo+bestaudio into a single mp4 for the +# "Save before playing" download path (GET /api/download?mux=1) RUN apt-get update -qq && \ apt-get install -y --no-install-recommends \ curl \ python3 \ + ffmpeg \ ca-certificates && \ rm -rf /var/lib/apt/lists/* diff --git a/frontend/app.js b/frontend/app.js index be83f64..3cf6c2a 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -71,12 +71,19 @@ function saveDataToStorage(jsonStr) { // 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) { +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 url = `/api/download/${encodeURIComponent(videoId)}${fp ? '?fp=' + encodeURIComponent(fp) : ''}`; + 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 @@ -158,8 +165,8 @@ const API = { ? Promise.resolve(saveDataToStorage(typeof d === 'string' ? d : JSON.stringify(d))) : call('store.save', 'store_save', { data: JSON.stringify(d) }), // Offline cache — routes to OPFS (WEB) or native cache (Tauri/Zig) - cacheDownload: (videoId) => WEB - ? opfsDownload(sanitizeId(videoId)) + cacheDownload: (videoId, opts) => WEB + ? opfsDownload(sanitizeId(videoId), opts) : call('cache.download', 'cache_download', { videoId: sanitizeId(videoId) }), cacheStatus: (videoId) => WEB ? opfsStatus(sanitizeId(videoId)) @@ -185,6 +192,7 @@ function toAssetUrl(path) { // ---------- 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 @@ -345,7 +353,7 @@ async function refreshCachedIds() { } // Download a video into the permanent offline cache. Safe to call repeatedly. -async function preload(video, { quiet = false } = {}) { +async function preload(video, { quiet = false, mux = false } = {}) { const id = video.id; if (!id || cachedIds.has(id) || downloading.has(id)) return; downloading.add(id); @@ -359,7 +367,7 @@ async function preload(video, { quiet = false } = {}) { if (current && current.meta && current.meta.id === id) updateNowPlayingActions(); if (!quiet) toast(`Saving “${video.title}” for offline…`); try { - const res = await API.cacheDownload(id); + const res = await API.cacheDownload(id, { mux }); if (res && res.ok && res.cached) { cachedIds.add(id); if (!quiet) toast(`Saved “${video.title}” ✓`); @@ -439,6 +447,10 @@ const Player = { // 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; + // 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 @@ -469,7 +481,31 @@ const Player = { } } + // 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. @@ -1694,6 +1730,13 @@ async function renderSettings() { Audio-only mode +