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.
|
||||
|
||||
Reference in New Issue
Block a user