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)
|
// 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() {
|
async function refreshCachedIds() {
|
||||||
cachedIds.clear();
|
const next = new Set();
|
||||||
try {
|
try {
|
||||||
const res = await API.cacheList();
|
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 */ }
|
} 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 ----------
|
// ---------- Offline thumbnail warm-up ----------
|
||||||
@@ -1067,6 +1126,7 @@ async function preload(video, { quiet = false, mux = false } = {}) {
|
|||||||
const res = await API.cacheDownload(id, { mux });
|
const res = await API.cacheDownload(id, { mux });
|
||||||
if (res && res.ok && res.cached) {
|
if (res && res.ok && res.cached) {
|
||||||
cachedIds.add(id);
|
cachedIds.add(id);
|
||||||
|
cacheMutations++;
|
||||||
warmThumb(thumbUrlFor(id, video));
|
warmThumb(thumbUrlFor(id, video));
|
||||||
if (!quiet) toast(`Saved “${video.title}” ✓`);
|
if (!quiet) toast(`Saved “${video.title}” ✓`);
|
||||||
} else if (!quiet) {
|
} 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.
|
// Sidebar badge showing how many downloads are in flight.
|
||||||
function updateDownloadBadge() {
|
function updateDownloadBadge() {
|
||||||
const n = downloading.size;
|
const n = downloading.size;
|
||||||
@@ -1179,6 +1269,7 @@ async function downloadEdited(customVideo) {
|
|||||||
const res = await API.cacheDownloadEdited(id, customVideo.sourceId, customVideo.keep);
|
const res = await API.cacheDownloadEdited(id, customVideo.sourceId, customVideo.keep);
|
||||||
if (res && res.ok && res.cached) {
|
if (res && res.ok && res.cached) {
|
||||||
cachedIds.add(id);
|
cachedIds.add(id);
|
||||||
|
cacheMutations++;
|
||||||
// An edited copy has no art of its own — warm its source's thumbnail,
|
// An edited copy has no art of its own — warm its source's thumbnail,
|
||||||
// which is what the Saved list falls back to.
|
// which is what the Saved list falls back to.
|
||||||
warmThumb(thumbUrlFor(customVideo.sourceId, videoById(customVideo.sourceId)));
|
warmThumb(thumbUrlFor(customVideo.sourceId, videoById(customVideo.sourceId)));
|
||||||
@@ -1613,12 +1704,16 @@ const Player = {
|
|||||||
this.fallbackQueue = queue;
|
this.fallbackQueue = queue;
|
||||||
this.fbIndex = 0;
|
this.fbIndex = 0;
|
||||||
},
|
},
|
||||||
onMediaError() {
|
onMediaError(el) {
|
||||||
// A cached file failed to play — fall back to live streaming.
|
// A cached file failed to play — fall back to live streaming.
|
||||||
if (current && current.localUrl) {
|
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;
|
const meta = current.meta;
|
||||||
current.localUrl = null;
|
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.
|
// Preserve the resume intent of the load that just failed.
|
||||||
this.loadVideo(meta, { preferStream: true, resume: this._resumeOnLoad, reveal: this._revealOnLoad });
|
this.loadVideo(meta, { preferStream: true, resume: this._resumeOnLoad, reveal: this._revealOnLoad });
|
||||||
return;
|
return;
|
||||||
@@ -2080,7 +2175,7 @@ function wirePlayerEvents() {
|
|||||||
el.addEventListener('error', () => {
|
el.addEventListener('error', () => {
|
||||||
// A failed master stream, or a failed synced-audio track in dual mode,
|
// A failed master stream, or a failed synced-audio track in dual mode,
|
||||||
// both warrant falling back to the next candidate.
|
// 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);
|
bind(V);
|
||||||
@@ -5400,6 +5495,7 @@ async function boot() {
|
|||||||
|
|
||||||
// Learn what's already cached, then top up any playlist videos that aren't.
|
// Learn what's already cached, then top up any playlist videos that aren't.
|
||||||
await refreshCachedIds();
|
await refreshCachedIds();
|
||||||
|
startCacheResyncWatch();
|
||||||
data.playlists.forEach(preloadPlaylist);
|
data.playlists.forEach(preloadPlaylist);
|
||||||
preloadPinnedPlaylists();
|
preloadPinnedPlaylists();
|
||||||
// Not awaited — artwork backfill must never delay first paint.
|
// Not awaited — artwork backfill must never delay first paint.
|
||||||
|
|||||||
@@ -75,6 +75,12 @@ self.onmessage = async (e) => {
|
|||||||
} finally {
|
} finally {
|
||||||
access.close();
|
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
|
// Finalize: .part → permanent name. Prefer the native rename, but treat
|
||||||
// ANY move() failure as "unavailable" and fall back to a chunked copy —
|
// ANY move() failure as "unavailable" and fall back to a chunked copy —
|
||||||
|
|||||||
@@ -85,6 +85,29 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Revoke an object URL previously returned by getFileUrl().
|
// 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) {
|
revokeUrl(url) {
|
||||||
if (url && _blobUrls.has(url)) {
|
if (url && _blobUrls.has(url)) {
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
@@ -153,6 +176,13 @@
|
|||||||
await writable.abort();
|
await writable.abort();
|
||||||
throw err;
|
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 {
|
} else {
|
||||||
// No createWritable on this browser — main-thread OPFS writes are
|
// No createWritable on this browser — main-thread OPFS writes are
|
||||||
// impossible (sync access handles are worker-only). Surface a real
|
// impossible (sync access handles are worker-only). Surface a real
|
||||||
@@ -187,7 +217,9 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
// List all cached videos — returns [{ id, size, name }]
|
// 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 dir = await getRoot();
|
||||||
const items = [];
|
const items = [];
|
||||||
try {
|
try {
|
||||||
@@ -200,7 +232,7 @@
|
|||||||
const file = await handle.getFile();
|
const file = await handle.getFile();
|
||||||
items.push({ id, name, size: file.size });
|
items.push({ id, name, size: file.size });
|
||||||
}
|
}
|
||||||
} catch { /* OPFS not available */ }
|
} catch (err) { if (strict) throw err; /* OPFS not available */ }
|
||||||
return items;
|
return items;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user