Add download controls and device storage breakdown

This commit is contained in:
Jonathan Sykes
2026-10-03 14:47:57 +08:00
parent 09ae6a2a85
commit 75dbc468cc
7 changed files with 122 additions and 15 deletions

View File

@@ -296,7 +296,12 @@ const SaveSlots = (() => {
while (waiters.length && active < limit()) { active++; waiters.shift()(); } while (waiters.length && active < limit()) { active++; waiters.shift()(); }
} }
return { 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(); }, release() { active = Math.max(0, active - 1); pump(); },
pump, pump,
}; };
@@ -307,6 +312,7 @@ const SaveSlots = (() => {
// fails for good. A save that only paused (partial kept on the device) stays // 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 // 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. // the foreground — and every 2 minutes while the app is visible.
const saveControllers = new Map();
const SaveQueue = (() => { const SaveQueue = (() => {
const KEY = 'ytpSaveQueue'; const KEY = 'ytpSaveQueue';
const MAX_AGE = 7 * 24 * 3600 * 1000; const MAX_AGE = 7 * 24 * 3600 * 1000;
@@ -323,6 +329,8 @@ const SaveQueue = (() => {
at: (q[v.id] && q[v.id].at) || Date.now() }; at: (q[v.id] && q[v.id].at) || Date.now() };
store(q); 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); } } function remove(id) { const q = load(); if (q[id]) { delete q[id]; store(q); } }
const pending = () => Object.values(load()); const pending = () => Object.values(load());
const paused = () => pending().filter((v) => !downloading.has(v.id) && !cachedIds.has(v.id)); const paused = () => pending().filter((v) => !downloading.has(v.id) && !cachedIds.has(v.id));
@@ -361,7 +369,7 @@ const SaveQueue = (() => {
const jobs = []; const jobs = [];
for (const v of pending()) { for (const v of pending()) {
if (cachedIds.has(v.id)) { remove(v.id); continue; } 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; } if (Date.now() - v.at > MAX_AGE) { cancel(v.id); continue; }
jobs.push(preload(v, { quiet: true })); // SaveSlots caps how many run at once jobs.push(preload(v, { quiet: true })); // SaveSlots caps how many run at once
} }
@@ -370,6 +378,7 @@ const SaveQueue = (() => {
if (view.type === 'downloads') renderList(); if (view.type === 'downloads') renderList();
} }
function cancel(id) { function cancel(id) {
saveControllers.get(id)?.abort();
remove(id); remove(id);
prog.delete(id); prog.delete(id);
if (window.OPFS && window.OPFS.discardPartial) window.OPFS.discardPartial(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(); }); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') resumeAll(); });
setInterval(() => { if (document.visibilityState === 'visible' && paused().length) resumeAll(); }, 120000); 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 // 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 // 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 — // server cache, no worker/OPFS sync access), otherwise the save result —
// { ok:false, paused:true } means the partial is kept and will resume. // { 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; if (typeof window.OPFS.downloadVideo !== 'function' || typeof Worker === 'undefined') return null;
const enc = encodeURIComponent(videoId); const enc = encodeURIComponent(videoId);
const prepUrl = `/api/download/${enc}/prepare${hevc ? '?hevc=1' : ''}`; 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++) { for (let round = 0; round < 2; round++) {
let prep = null; let prep = null;
for (;;) { for (;;) {
if (signal?.aborted) return { ok: false, paused: true };
let j; let j;
try { try {
const r = await fetch(prepUrl, { cache: 'no-store' }); const r = await fetch(prepUrl, { cache: 'no-store', signal });
j = await r.json(); j = await r.json();
} catch (err) { } catch (err) {
return { ok: false, paused: true, error: 'connection lost' }; 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)); await new Promise((r) => setTimeout(r, 3000));
} }
const w = await window.OPFS.downloadVideo(videoId, dlUrl, { const w = await window.OPFS.downloadVideo(videoId, dlUrl, {
signal,
resumable: { etag: prep.etag, size: prep.size, sha256: prep.sha256 || null, ext: prep.ext || 'mp4' }, 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 }), 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' }; 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()) { if (!window.OPFS || !window.OPFS.isSupported()) {
return { ok: false, error: 'OPFS not supported in this browser' }; 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 qs = params.toString();
const url = `/api/download/${encodeURIComponent(videoId)}${qs ? '?' + qs : ''}`; 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; if (resumed) return resumed;
// Preferred path: a dedicated Web Worker does the fetch AND the OPFS writes, // 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. // is kept so it can be reported if the fallback fails too.
let workerError = null; let workerError = null;
if (typeof window.OPFS.downloadVideo === 'function' && typeof Worker !== 'undefined') { 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.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; workerError = w.error || null;
} }
try { try {
const res = await fetch(url); const res = await fetch(url, { signal });
if (!res.ok) { if (!res.ok) {
const j = await res.json().catch(() => ({})); const j = await res.json().catch(() => ({}));
return { ok: false, error: j.error || `HTTP ${res.status}` }; 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); await window.OPFS.writeFromResponse(videoId, ext, res);
return { ok: true, cached: true }; return { ok: true, cached: true };
} catch (err) { } 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 (current && current.meta && current.meta.id === id) updateNowPlayingActions();
if (!quiet) toast(`Saving “${video.title}” for offline…`); if (!quiet) toast(`Saving “${video.title}” for offline…`);
SaveQueue.add(video); SaveQueue.add(video);
const controller = new AbortController(); saveControllers.set(id, controller);
SaveQueue.progress(id, { phase: 'waiting' }); SaveQueue.progress(id, { phase: 'waiting' });
let slot = false; let slot = false;
try { try {
await SaveSlots.acquire(); await SaveSlots.acquire(controller.signal);
slot = true; slot = true;
SaveQueue.progress(id, null); 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) { if (res && res.paused) {
// The partial stays on the device; SaveQueue picks it up again. // 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`); if (!quiet) toast(`Paused “${video.title}” — it will continue when the connection is back`);
} else { } else if (res?.ok) {
SaveQueue.remove(id); SaveQueue.remove(id);
} else {
SaveQueue.state(id, 'failed', res?.error || 'Save failed');
} }
if (res && res.ok && res.cached) { if (res && res.ok && res.cached) {
if (typeof EqRender !== 'undefined') EqRender.schedule(id); 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')); toast('⚠ ' + ((res && res.error) || 'Could not save video'));
} }
} catch (e) { } catch (e) {
SaveQueue.remove(id); if (e.name !== 'AbortError') SaveQueue.state(id, 'failed', e.message || 'Save failed');
if (!quiet) toast('⚠ Saving not supported in this build.'); if (!quiet && e.name !== 'AbortError') toast('⚠ Saving not supported in this build.');
} finally { } finally {
saveControllers.delete(id);
if (controller.signal.aborted && !SaveQueue.pending().some(v => v.id === id)) await window.OPFS?.discardPartial?.(id);
if (slot) SaveSlots.release(); if (slot) SaveSlots.release();
SaveQueue.progress(id, null); SaveQueue.progress(id, null);
downloading.delete(id); downloading.delete(id);
@@ -12439,6 +12456,16 @@ async function boot() {
data.playlists.forEach(preloadPlaylist); data.playlists.forEach(preloadPlaylist);
preloadPinnedPlaylists(); preloadPinnedPlaylists();
updateBottomChrome(); 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). // Continue saves that were interrupted (closed app, dropped connection).
if (WEB) SaveQueue.start(); if (WEB) SaveQueue.start();
// iPhone: bring saved songs' EQ/levelled copies up to date (one at a time). // iPhone: bring saved songs' EQ/levelled copies up to date (one at a time).

49
frontend/downloads.js Normal file
View File

@@ -0,0 +1,49 @@
/* Download controls and device storage accounting. */
(function(root) {
'use strict';
const bytes = n => n >= 1073741824 ? `${(n / 1073741824).toFixed(1)} GB` : `${(n / 1048576).toFixed(1)} MB`;
function breakdown(files, playlists) {
const kinds = { video: 0, audio: 0, eq: 0 }, byId = new Map();
for (const f of files) { const size = Math.max(0, Number(f.size) || 0); kinds[f.kind in kinds ? f.kind : 'video'] += size; byId.set(f.id, (byId.get(f.id) || 0) + size); }
return { total: Object.values(kinds).reduce((a, b) => a + b, 0), kinds, playlists: playlists.map(p => ({ id: p.id, name: p.name || p.title || 'Playlist', size: [...new Set((p.videos || []).map(v => v.id))].reduce((n, id) => n + (byId.get(id) || 0), 0) })) };
}
let adapter, total = 0;
function configure(options) {
adapter = options;
root.SettingsSections.register({ id: 'downloads-storage', title: 'Downloads & storage', cluster: 'Library & storage', description: 'Active queued paused failed saves, disk usage and playlist sizes', icon: '<path d="M12 3v12m-5-5 5 5 5-5M4 17v4h16v-4"/>', summary: () => `${adapter.slots()} at once · ${bytes(total)}`, render(container) {
const doc = container.ownerDocument;
const heading = doc.createElement('h3'); heading.textContent = 'Downloads & storage';
const jobs = doc.createElement('div'); jobs.className = 'download-manager'; jobs.setAttribute('aria-label', 'Download manager');
const storage = doc.createElement('div'); storage.className = 'download-storage';
container.append(heading, jobs, storage);
let alive = true, signature = '';
function paint() {
const items = adapter.jobs(); const next = JSON.stringify(items); if (next === signature) return; signature = next;
jobs.replaceChildren();
if (!items.length) { const p = doc.createElement('p'); p.textContent = 'No pending downloads.'; jobs.append(p); }
for (const job of items) {
const row = doc.createElement('div'); row.className = 'download-job';
const title = doc.createElement('b'); title.textContent = job.title || job.id;
const status = doc.createElement('small'); status.textContent = job.error || job.status;
const progress = doc.createElement('progress'); progress.max = job.progress?.total || 1; if (job.progress?.total) progress.value = job.progress.received || 0; progress.setAttribute('aria-label', `Saving ${job.title || job.id}`);
const actions = doc.createElement('div');
for (const action of [...(['active', 'queued'].includes(job.status) ? ['pause'] : [job.status === 'failed' ? 'retry' : 'resume']), 'cancel']) {
const button = doc.createElement('button'); button.type = 'button'; button.textContent = action[0].toUpperCase() + action.slice(1); button.setAttribute('aria-label', `${button.textContent} ${job.title || job.id}`); button.onclick = () => { adapter.action(job.id, action); signature = ''; paint(); }; actions.append(button);
}
row.append(title, status, progress, actions); jobs.append(row);
}
}
async function disk() {
try {
const [files, estimate] = await Promise.all([adapter.files(), root.navigator.storage?.estimate?.() || {}]); if (!alive) return;
const stats = breakdown(files, adapter.playlists()); total = stats.total; storage.replaceChildren();
for (const text of [`Device media: ${bytes(total)}`, `Video ${bytes(stats.kinds.video)} · Audio ${bytes(stats.kinds.audio)} · EQ renders ${bytes(stats.kinds.eq)}`, estimate.quota ? `Available browser storage: ${bytes(Math.max(0, estimate.quota - (estimate.usage || 0)))}` : 'Available browser storage is not reported.', ...stats.playlists.map(p => `${p.name}: ${bytes(p.size)}`), 'Playlist totals include shared files; device totals count each file once. Browser quota is not total device free space.']) { const p = doc.createElement('p'); p.textContent = text; storage.append(p); }
storage.dispatchEvent(new Event('change', { bubbles: true }));
} catch { storage.textContent = 'Storage details are unavailable on this device.'; }
}
paint(); disk(); const timer = setInterval(paint, 1000), diskTimer = setInterval(disk, 15000);
return () => { alive = false; clearInterval(timer); clearInterval(diskTimer); };
} });
}
const api = { configure, breakdown, bytes }; if (typeof module !== 'undefined') module.exports = api; else root.Downloads = api;
})(typeof window !== 'undefined' ? window : globalThis);

View File

@@ -0,0 +1,17 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { breakdown, bytes } = require('./downloads.js');
test('storage counts media once and shared playlist files within each playlist', () => {
const result = breakdown([{ id: 'a', size: 100, kind: 'video' }, { id: 'a', size: 20, kind: 'eq' }, { id: 'b', size: 30, kind: 'audio' }], [{ id: 'one', videos: [{ id: 'a' }, { id: 'a' }] }, { id: 'two', videos: [{ id: 'a' }, { id: 'b' }] }]);
assert.equal(result.total, 150); assert.deepEqual(result.kinds, { video: 100, audio: 30, eq: 20 }); assert.deepEqual(result.playlists.map(p => p.size), [120, 150]);
});
test('storage ignores invalid and negative sizes', () => { assert.equal(breakdown([{ id: 'a', size: -10 }, { id: 'b', size: 'bad' }], []).total, 0); assert.equal(bytes(1073741824), '1.0 GB'); });
test('pausing a worker terminates it once and ignores later completion', async () => {
const vm = require('node:vm'), fs = require('node:fs'); let worker;
class Worker { constructor() { worker = this; this.terminated = 0; } postMessage() {} terminate() { this.terminated++; } }
const window = {};
vm.runInNewContext(fs.readFileSync(require.resolve('./opfs.js'), 'utf8'), { window, navigator: {}, Worker, URL, AbortController });
const controller = new AbortController(); const result = window.OPFS.downloadVideo('video', '/download', { signal: controller.signal });
controller.abort(); assert.equal((await result).paused, true); assert.equal(worker.terminated, 1);
worker.onmessage({ data: { type: 'done' } }); assert.equal(worker.terminated, 1);
});

View File

@@ -654,6 +654,7 @@
<script src="async-guard.js"></script> <script src="async-guard.js"></script>
<script src="sw-update.js"></script> <script src="sw-update.js"></script>
<script src="settings-sections.js"></script> <script src="settings-sections.js"></script>
<script src="downloads.js"></script>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
</html> </html>

View File

@@ -156,10 +156,16 @@
resolve({ ok: false, fallback: true }); resolve({ ok: false, fallback: true });
return; return;
} }
let settled = false;
const abort = () => finish({ ok: false, paused: true });
const finish = (result) => { const finish = (result) => {
if (settled) return; settled = true;
opts.signal?.removeEventListener('abort', abort);
worker.terminate(); worker.terminate();
resolve(result); resolve(result);
}; };
if (opts.signal?.aborted) { abort(); return; }
opts.signal?.addEventListener('abort', abort, { once: true });
worker.onmessage = (e) => { worker.onmessage = (e) => {
const m = e.data || {}; const m = e.data || {};
if (m.type === 'done') finish({ ok: true, sha256: m.sha256 || null, expectedSha: m.expectedSha || null, size: m.size || 0 }); if (m.type === 'done') finish({ ok: true, sha256: m.sha256 || null, expectedSha: m.expectedSha || null, size: m.size || 0 });

View File

@@ -24,3 +24,9 @@
.settings-back { display: none; } .settings-back { display: none; }
.settings-browser .set-row { flex-wrap: wrap; gap: 12px; } .settings-browser .set-row { flex-wrap: wrap; gap: 12px; }
} }
.download-job { padding: 14px 0; border-bottom: 1px solid var(--line); display: grid; gap: 8px; }
.download-job b { overflow-wrap: anywhere; }
.download-job small, .download-storage p { color: var(--text-2); }
.download-job progress { width: 100%; accent-color: var(--accent); }
.download-job > div { display: flex; gap: 8px; }
.download-job button { min-height: 44px; padding: 8px 16px; color: var(--text); background: var(--bg-2); border: 1px solid var(--line); border-radius: 8px; }

View File

@@ -83,6 +83,7 @@ const SHELL = [
'/p2p-transfer.js', '/p2p-transfer.js',
'/p2p-recv-worker.js', '/p2p-recv-worker.js',
'/settings-sections.js', '/settings-sections.js',
'/downloads.js',
'/settings-sections.css', '/settings-sections.css',
'/app.js', '/app.js',
'/manifest.webmanifest', '/manifest.webmanifest',