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:
106
frontend/app.js
106
frontend/app.js
@@ -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.
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user