Add download controls and device storage breakdown
This commit is contained in:
@@ -296,7 +296,12 @@ const SaveSlots = (() => {
|
||||
while (waiters.length && active < limit()) { active++; waiters.shift()(); }
|
||||
}
|
||||
return {
|
||||
acquire() { return new Promise((r) => { waiters.push(r); pump(); }); },
|
||||
acquire(signal) { return new Promise((resolve, reject) => {
|
||||
const abort = () => { const i = waiters.indexOf(ready); if (i >= 0) waiters.splice(i, 1); reject(new DOMException('Paused', 'AbortError')); };
|
||||
const ready = () => { signal?.removeEventListener('abort', abort); resolve(); };
|
||||
if (signal?.aborted) return abort();
|
||||
signal?.addEventListener('abort', abort, { once: true }); waiters.push(ready); pump();
|
||||
}); },
|
||||
release() { active = Math.max(0, active - 1); pump(); },
|
||||
pump,
|
||||
};
|
||||
@@ -307,6 +312,7 @@ const SaveSlots = (() => {
|
||||
// fails for good. A save that only paused (partial kept on the device) stays
|
||||
// listed and is resumed when the app opens, comes back online or returns to
|
||||
// the foreground — and every 2 minutes while the app is visible.
|
||||
const saveControllers = new Map();
|
||||
const SaveQueue = (() => {
|
||||
const KEY = 'ytpSaveQueue';
|
||||
const MAX_AGE = 7 * 24 * 3600 * 1000;
|
||||
@@ -323,6 +329,8 @@ const SaveQueue = (() => {
|
||||
at: (q[v.id] && q[v.id].at) || Date.now() };
|
||||
store(q);
|
||||
}
|
||||
function state(id, status, error = '') { const q = load(); if (q[id]) { Object.assign(q[id], { status, error }); store(q); } }
|
||||
function pause(id) { state(id, 'paused'); saveControllers.get(id)?.abort(); }
|
||||
function remove(id) { const q = load(); if (q[id]) { delete q[id]; store(q); } }
|
||||
const pending = () => Object.values(load());
|
||||
const paused = () => pending().filter((v) => !downloading.has(v.id) && !cachedIds.has(v.id));
|
||||
@@ -361,7 +369,7 @@ const SaveQueue = (() => {
|
||||
const jobs = [];
|
||||
for (const v of pending()) {
|
||||
if (cachedIds.has(v.id)) { remove(v.id); continue; }
|
||||
if (downloading.has(v.id)) continue;
|
||||
if (downloading.has(v.id) || v.status === 'paused' || v.status === 'failed') continue;
|
||||
if (Date.now() - v.at > MAX_AGE) { cancel(v.id); continue; }
|
||||
jobs.push(preload(v, { quiet: true })); // SaveSlots caps how many run at once
|
||||
}
|
||||
@@ -370,6 +378,7 @@ const SaveQueue = (() => {
|
||||
if (view.type === 'downloads') renderList();
|
||||
}
|
||||
function cancel(id) {
|
||||
saveControllers.get(id)?.abort();
|
||||
remove(id);
|
||||
prog.delete(id);
|
||||
if (window.OPFS && window.OPFS.discardPartial) window.OPFS.discardPartial(id);
|
||||
@@ -380,7 +389,7 @@ const SaveQueue = (() => {
|
||||
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') resumeAll(); });
|
||||
setInterval(() => { if (document.visibilityState === 'visible' && paused().length) resumeAll(); }, 120000);
|
||||
}
|
||||
return { add, remove, pending, paused, progress, get, label, resumeAll, cancel, start };
|
||||
return { add, remove, pending, paused, progress, get, label, resumeAll, cancel, pause, state, start };
|
||||
})();
|
||||
|
||||
// Resumable save (docs/resumable-downloads-plan.md): ask the server to get
|
||||
@@ -389,7 +398,7 @@ const SaveQueue = (() => {
|
||||
// single-request save (HEVC copy this device can't play, too long for the
|
||||
// server cache, no worker/OPFS sync access), otherwise the save result —
|
||||
// { ok:false, paused:true } means the partial is kept and will resume.
|
||||
async function resumableOpfsSave(videoId, qs, hevc, onProgress) {
|
||||
async function resumableOpfsSave(videoId, qs, hevc, onProgress, signal) {
|
||||
if (typeof window.OPFS.downloadVideo !== 'function' || typeof Worker === 'undefined') return null;
|
||||
const enc = encodeURIComponent(videoId);
|
||||
const prepUrl = `/api/download/${enc}/prepare${hevc ? '?hevc=1' : ''}`;
|
||||
@@ -399,9 +408,10 @@ async function resumableOpfsSave(videoId, qs, hevc, onProgress) {
|
||||
for (let round = 0; round < 2; round++) {
|
||||
let prep = null;
|
||||
for (;;) {
|
||||
if (signal?.aborted) return { ok: false, paused: true };
|
||||
let j;
|
||||
try {
|
||||
const r = await fetch(prepUrl, { cache: 'no-store' });
|
||||
const r = await fetch(prepUrl, { cache: 'no-store', signal });
|
||||
j = await r.json();
|
||||
} catch (err) {
|
||||
return { ok: false, paused: true, error: 'connection lost' };
|
||||
@@ -414,6 +424,7 @@ async function resumableOpfsSave(videoId, qs, hevc, onProgress) {
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
}
|
||||
const w = await window.OPFS.downloadVideo(videoId, dlUrl, {
|
||||
signal,
|
||||
resumable: { etag: prep.etag, size: prep.size, sha256: prep.sha256 || null, ext: prep.ext || 'mp4' },
|
||||
onProgress: (received, total, m) => report({ phase: m && m.verifying ? 'verifying' : 'downloading', received, total }),
|
||||
});
|
||||
@@ -426,7 +437,7 @@ async function resumableOpfsSave(videoId, qs, hevc, onProgress) {
|
||||
return { ok: false, error: 'the server copy kept changing — try again' };
|
||||
}
|
||||
|
||||
async function opfsDownload(videoId, { mux = false, onProgress = null } = {}) {
|
||||
async function opfsDownload(videoId, { mux = false, onProgress = null, signal } = {}) {
|
||||
if (!window.OPFS || !window.OPFS.isSupported()) {
|
||||
return { ok: false, error: 'OPFS not supported in this browser' };
|
||||
}
|
||||
@@ -441,7 +452,7 @@ async function opfsDownload(videoId, { mux = false, onProgress = null } = {}) {
|
||||
const qs = params.toString();
|
||||
const url = `/api/download/${encodeURIComponent(videoId)}${qs ? '?' + qs : ''}`;
|
||||
|
||||
const resumed = await resumableOpfsSave(videoId, qs, params.get('hevc') === '1', onProgress);
|
||||
const resumed = await resumableOpfsSave(videoId, qs, params.get('hevc') === '1', onProgress, signal);
|
||||
if (resumed) return resumed;
|
||||
|
||||
// Preferred path: a dedicated Web Worker does the fetch AND the OPFS writes,
|
||||
@@ -451,13 +462,14 @@ async function opfsDownload(videoId, { mux = false, onProgress = null } = {}) {
|
||||
// is kept so it can be reported if the fallback fails too.
|
||||
let workerError = null;
|
||||
if (typeof window.OPFS.downloadVideo === 'function' && typeof Worker !== 'undefined') {
|
||||
const w = await window.OPFS.downloadVideo(videoId, url);
|
||||
const w = await window.OPFS.downloadVideo(videoId, url, { signal });
|
||||
if (w.ok) return { ok: true, cached: true, sha256: w.sha256 || null, expectedSha: w.expectedSha || null, size: w.size || 0 };
|
||||
if (w.paused) return w;
|
||||
workerError = w.error || null;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const res = await fetch(url, { signal });
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
return { ok: false, error: j.error || `HTTP ${res.status}` };
|
||||
@@ -468,7 +480,7 @@ async function opfsDownload(videoId, { mux = false, onProgress = null } = {}) {
|
||||
await window.OPFS.writeFromResponse(videoId, ext, res);
|
||||
return { ok: true, cached: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: workerError || err.message };
|
||||
return { ok: false, paused: signal?.aborted || false, error: workerError || err.message };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1796,18 +1808,21 @@ async function preload(video, { quiet = false, mux = false } = {}) {
|
||||
if (current && current.meta && current.meta.id === id) updateNowPlayingActions();
|
||||
if (!quiet) toast(`Saving “${video.title}” for offline…`);
|
||||
SaveQueue.add(video);
|
||||
const controller = new AbortController(); saveControllers.set(id, controller);
|
||||
SaveQueue.progress(id, { phase: 'waiting' });
|
||||
let slot = false;
|
||||
try {
|
||||
await SaveSlots.acquire();
|
||||
await SaveSlots.acquire(controller.signal);
|
||||
slot = true;
|
||||
SaveQueue.progress(id, null);
|
||||
const res = await API.cacheDownload(id, { mux, onProgress: (p) => SaveQueue.progress(id, p) });
|
||||
const res = await API.cacheDownload(id, { mux, signal: controller.signal, onProgress: (p) => SaveQueue.progress(id, p) });
|
||||
if (res && res.paused) {
|
||||
// The partial stays on the device; SaveQueue picks it up again.
|
||||
if (!quiet) toast(`Paused “${video.title}” — it will continue when the connection is back`);
|
||||
} else {
|
||||
} else if (res?.ok) {
|
||||
SaveQueue.remove(id);
|
||||
} else {
|
||||
SaveQueue.state(id, 'failed', res?.error || 'Save failed');
|
||||
}
|
||||
if (res && res.ok && res.cached) {
|
||||
if (typeof EqRender !== 'undefined') EqRender.schedule(id);
|
||||
@@ -1820,9 +1835,11 @@ async function preload(video, { quiet = false, mux = false } = {}) {
|
||||
toast('⚠ ' + ((res && res.error) || 'Could not save video'));
|
||||
}
|
||||
} catch (e) {
|
||||
SaveQueue.remove(id);
|
||||
if (!quiet) toast('⚠ Saving not supported in this build.');
|
||||
if (e.name !== 'AbortError') SaveQueue.state(id, 'failed', e.message || 'Save failed');
|
||||
if (!quiet && e.name !== 'AbortError') toast('⚠ Saving not supported in this build.');
|
||||
} finally {
|
||||
saveControllers.delete(id);
|
||||
if (controller.signal.aborted && !SaveQueue.pending().some(v => v.id === id)) await window.OPFS?.discardPartial?.(id);
|
||||
if (slot) SaveSlots.release();
|
||||
SaveQueue.progress(id, null);
|
||||
downloading.delete(id);
|
||||
@@ -12439,6 +12456,16 @@ async function boot() {
|
||||
data.playlists.forEach(preloadPlaylist);
|
||||
preloadPinnedPlaylists();
|
||||
updateBottomChrome();
|
||||
if (window.Downloads) Downloads.configure({
|
||||
jobs: () => SaveQueue.pending().map(v => ({ ...v, status: downloading.has(v.id) ? (SaveQueue.get(v.id)?.phase === 'waiting' ? 'queued' : 'active') : v.status || 'paused', progress: SaveQueue.get(v.id) })),
|
||||
action: (id, action) => { if (action === 'pause') SaveQueue.pause(id); else if (action === 'cancel') SaveQueue.cancel(id); else { const v = SaveQueue.pending().find(v => v.id === id); if (v) preload(v, { quiet: true }); } },
|
||||
playlists: () => data.playlists, slots: () => data.settings.parallelSaves || 4,
|
||||
files: async () => { const result = []; if (!WEB || !navigator.storage?.getDirectory) return result;
|
||||
const root = await navigator.storage.getDirectory();
|
||||
for (const name of ['videos', 'eq-renders']) { try { const dir = await root.getDirectoryHandle(name); for await (const [filename, handle] of dir.entries()) { if (handle.kind !== 'file' || /\.json$/.test(filename)) continue; const file = await handle.getFile(); result.push({ id: filename.replace(/\.[^.]+(?:\.part)?$/, ''), size: file.size, kind: name === 'eq-renders' ? 'eq' : /\.(mp3|m4a|aac|ogg|opus|wav)(?:\.part)?$/i.test(filename) ? 'audio' : 'video' }); } } catch {} }
|
||||
return result;
|
||||
},
|
||||
});
|
||||
// Continue saves that were interrupted (closed app, dropped connection).
|
||||
if (WEB) SaveQueue.start();
|
||||
// iPhone: bring saved songs' EQ/levelled copies up to date (one at a time).
|
||||
|
||||
Reference in New Issue
Block a user