feat: add opt-in save-before-playing setting backed by server-compiled single-file downloads

This commit is contained in:
Jonathan Sykes
2026-07-03 00:07:50 +08:00
parent ba05f0dafe
commit 76f8ec525d
3 changed files with 106 additions and 7 deletions

View File

@@ -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() {
<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">
@@ -1821,6 +1864,10 @@ async function renderSettings() {
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();