Replace saved copies that fail to play, reject truncated saves, and re-check the device cache when the app returns to the foreground

This commit is contained in:
Jonathan Sykes
2026-09-18 10:11:20 +08:00
parent 64f45a102d
commit a20dd0a3a6
3 changed files with 141 additions and 7 deletions

View File

@@ -999,12 +999,71 @@ function fmtBytes(n) {
// ============================================================================
// Offline cache (preload)
// ============================================================================
// An empty file (a save that never got its bytes) is not a saved video.
function isUsableCacheItem(it) {
return !!(it && it.id) && !(typeof it.size === 'number' && it.size <= 0);
}
async function refreshCachedIds() {
cachedIds.clear();
const next = new Set();
try {
const res = await API.cacheList();
if (res && res.ok) for (const it of res.items || []) cachedIds.add(it.id);
if (res && res.ok) for (const it of res.items || []) if (isUsableCacheItem(it)) next.add(it.id);
} catch { /* shell without cache support — leave empty */ }
cachedIds.clear();
for (const id of next) cachedIds.add(id);
}
// The badges trust cachedIds, which is otherwise only read from storage at
// launch — a PWA resumed from the background would keep showing videos the
// browser has since evicted (iOS does this under storage pressure). Re-read
// the device store when the app returns to the foreground and repaint only
// what changed. Bumped on every add so a save landing mid-listing is never
// undone by the older listing.
let cacheMutations = 0;
let cacheResyncRunning = false;
async function resyncCachedIds() {
if (!WEB || !window.OPFS || !window.OPFS.isSupported() || cacheResyncRunning) return;
cacheResyncRunning = true;
try {
const before = cacheMutations;
let items;
try { items = await window.OPFS.listVideos({ strict: true }); } catch { return; }
if (before !== cacheMutations) return; // a save landed meanwhile — next time
const listed = new Set(items.filter(isUsableCacheItem).map((it) => it.id));
const changed = [];
for (const id of [...cachedIds]) {
if (!listed.has(id)) { cachedIds.delete(id); changed.push(id); }
}
for (const id of listed) {
if (!cachedIds.has(id) && !downloading.has(id)) { cachedIds.add(id); changed.push(id); }
}
if (!changed.length) return;
for (const id of changed) markCardCacheState(id, cachedIds.has(id) ? 'cached' : 'none');
renderSidebar();
if (view.type === 'playlist') {
const pl = data.playlists.find((p) => p.id === view.id);
const offlineBtn = $('plOfflineBtn');
if (offlineBtn && pl) updatePlaylistOfflineButton(offlineBtn, pl);
} else if (view.type === 'settings' || view.type === 'downloads' || view.type === 'saved') {
renderList();
}
} finally {
cacheResyncRunning = false;
}
}
function startCacheResyncWatch() {
if (!WEB) return;
let last = Date.now();
const maybe = () => {
if (document.visibilityState !== 'visible') return;
if (Date.now() - last < 5000) return;
last = Date.now();
resyncCachedIds();
};
document.addEventListener('visibilitychange', maybe);
window.addEventListener('pageshow', maybe);
}
// ---------- Offline thumbnail warm-up ----------
@@ -1067,6 +1126,7 @@ async function preload(video, { quiet = false, mux = false } = {}) {
const res = await API.cacheDownload(id, { mux });
if (res && res.ok && res.cached) {
cachedIds.add(id);
cacheMutations++;
warmThumb(thumbUrlFor(id, video));
if (!quiet) toast(`Saved “${video.title}” ✓`);
} else if (!quiet) {
@@ -1090,6 +1150,36 @@ async function preload(video, { quiet = false, mux = false } = {}) {
}
}
// A device copy that failed to play. Left in place it stays badged "cached"
// and fails on every tap, so drop it and save a fresh one in the background.
// An HEVC copy means canPlayType over-promised: stop asking for HEVC first so
// the re-save comes back as H.264. Edited cuts exist only on the device (no
// source to re-save from), so they are never deleted. One re-save per video
// per session keeps a copy that is bad for another reason from looping.
const resavedAfterFailure = new Set();
async function dropBrokenLocalCopy(meta) {
const id = meta && meta.id;
if (!id || meta.custom || !cachedIds.has(id)) return;
try {
if (WEB && window.OPFS && typeof window.OPFS.sniffCodec === 'function' &&
(await window.OPFS.sniffCodec(id)) === 'hevc') {
try { localStorage.setItem(NO_HEVC_KEY, '1'); } catch { /* storage blocked */ }
}
} catch { /* sniffing is best-effort */ }
try { await API.cacheDelete(id); } catch { /* best-effort */ }
cachedIds.delete(id);
markCardCacheState(id, 'none');
renderSidebar();
if (view.type === 'playlist') {
const pl = data.playlists.find((p) => p.id === view.id);
const offlineBtn = $('plOfflineBtn');
if (offlineBtn && pl) updatePlaylistOfflineButton(offlineBtn, pl);
}
if (resavedAfterFailure.has(id)) return;
resavedAfterFailure.add(id);
preload(meta, { quiet: true });
}
// Sidebar badge showing how many downloads are in flight.
function updateDownloadBadge() {
const n = downloading.size;
@@ -1179,6 +1269,7 @@ async function downloadEdited(customVideo) {
const res = await API.cacheDownloadEdited(id, customVideo.sourceId, customVideo.keep);
if (res && res.ok && res.cached) {
cachedIds.add(id);
cacheMutations++;
// An edited copy has no art of its own — warm its source's thumbnail,
// which is what the Saved list falls back to.
warmThumb(thumbUrlFor(customVideo.sourceId, videoById(customVideo.sourceId)));
@@ -1613,12 +1704,16 @@ const Player = {
this.fallbackQueue = queue;
this.fbIndex = 0;
},
onMediaError() {
onMediaError(el) {
// A cached file failed to play — fall back to live streaming.
if (current && current.localUrl) {
// An error from the PREVIOUS track's blob (revoked by the load that
// replaced it) must not condemn the new track's good copy.
if (el && el.src && el.src !== current.localUrl) return;
const meta = current.meta;
current.localUrl = null;
toast('Cached copy unavailable — streaming instead…');
dropBrokenLocalCopy(meta);
toast('Saved copy won’t play here — streaming and re-saving it…', { duration: 4000 });
// Preserve the resume intent of the load that just failed.
this.loadVideo(meta, { preferStream: true, resume: this._resumeOnLoad, reveal: this._revealOnLoad });
return;
@@ -2080,7 +2175,7 @@ function wirePlayerEvents() {
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();
if (masterIs(el) || el === Player.secondary) Player.onMediaError(el);
});
}
bind(V);
@@ -5400,6 +5495,7 @@ async function boot() {
// Learn what's already cached, then top up any playlist videos that aren't.
await refreshCachedIds();
startCacheResyncWatch();
data.playlists.forEach(preloadPlaylist);
preloadPinnedPlaylists();
// Not awaited — artwork backfill must never delay first paint.

View File

@@ -75,6 +75,12 @@ self.onmessage = async (e) => {
} finally {
access.close();
}
// A connection that ends early can close the body cleanly; never commit
// a truncated file, it would be badged "cached" yet fail to play.
const expected = Number(res.headers.get('content-length'));
if (expected > 0 && offset !== expected) {
throw new Error(`download cut short (${offset} of ${expected} bytes)`);
}
// Finalize: .part → permanent name. Prefer the native rename, but treat
// ANY move() failure as "unavailable" and fall back to a chunked copy —

View File

@@ -85,6 +85,29 @@
},
// Revoke an object URL previously returned by getFileUrl().
// 'hevc' | 'avc' | null for a stored mp4. Server copies are faststart, so
// the sample description (hvc1/hev1 vs avc1) sits in the first megabyte.
async sniffCodec(videoId) {
try {
const found = await findHandle(videoId);
if (!found) return null;
const file = await found[0].getFile();
const head = new Uint8Array(await file.slice(0, 1024 * 1024).arrayBuffer());
const has = (tag) => {
const [a, b, c, d] = [...tag].map((ch) => ch.charCodeAt(0));
for (let i = 0; i + 3 < head.length; i++) {
if (head[i] === a && head[i + 1] === b && head[i + 2] === c && head[i + 3] === d) return true;
}
return false;
};
if (has('hvc1') || has('hev1')) return 'hevc';
if (has('avc1')) return 'avc';
return null;
} catch {
return null;
}
},
revokeUrl(url) {
if (url && _blobUrls.has(url)) {
URL.revokeObjectURL(url);
@@ -153,6 +176,13 @@
await writable.abort();
throw err;
}
// A connection that ends early can close the body cleanly, leaving
// a truncated file that would be badged "cached" yet never play.
const expected = Number(response.headers.get('content-length'));
if (expected > 0) {
const got = (await tmpHandle.getFile()).size;
if (got !== expected) throw new Error(`download cut short (${got} of ${expected} bytes)`);
}
} else {
// No createWritable on this browser — main-thread OPFS writes are
// impossible (sync access handles are worker-only). Surface a real
@@ -187,7 +217,9 @@
},
// List all cached videos — returns [{ id, size, name }]
async listVideos() {
// strict: rethrow instead of returning a partial list, for callers that
// treat a missing entry as "the file is gone".
async listVideos({ strict = false } = {}) {
const dir = await getRoot();
const items = [];
try {
@@ -200,7 +232,7 @@
const file = await handle.getFile();
items.push({ id, name, size: file.size });
}
} catch { /* OPFS not available */ }
} catch (err) { if (strict) throw err; /* OPFS not available */ }
return items;
},