Compact HE-AAC audio saves: server lane with fdkaac, shrink saved songs from Saved videos
This commit is contained in:
@@ -412,6 +412,19 @@ 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.
|
||||
// Saves that hold the server's compact HE-AAC audio (this device only).
|
||||
const CompactSaves = (() => {
|
||||
const KEY = 'ytpCompactSaves';
|
||||
const read = () => { try { return new Set(JSON.parse(localStorage.getItem(KEY) || '[]')); } catch { return new Set(); } };
|
||||
const write = (set) => { try { localStorage.setItem(KEY, JSON.stringify([...set])); } catch { /* storage blocked */ } };
|
||||
return {
|
||||
has: (id) => read().has(id),
|
||||
mark(id) { const s = read(); if (!s.has(id)) { s.add(id); write(s); } },
|
||||
drop(id) { const s = read(); if (s.delete(id)) write(s); },
|
||||
clear() { write(new Set()); },
|
||||
};
|
||||
})();
|
||||
|
||||
async function resumableOpfsSave(videoId, qs, prepQs, onProgress, signal) {
|
||||
if (typeof window.OPFS.downloadVideo !== 'function' || typeof Worker === 'undefined') return null;
|
||||
const enc = encodeURIComponent(videoId);
|
||||
@@ -442,7 +455,8 @@ async function resumableOpfsSave(videoId, qs, prepQs, onProgress, 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 }),
|
||||
});
|
||||
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,
|
||||
ext: prep.ext || 'mp4', compact: !!prep.compact };
|
||||
if (w.changed) continue; // the server copy was replaced — start over on the new one
|
||||
if (w.paused) return { ok: false, paused: true, error: w.error || 'connection lost', received: w.received || 0 };
|
||||
if (w.fallback) return null;
|
||||
@@ -451,7 +465,7 @@ async function resumableOpfsSave(videoId, qs, prepQs, onProgress, signal) {
|
||||
return { ok: false, error: 'the server copy kept changing — try again' };
|
||||
}
|
||||
|
||||
async function opfsDownload(videoId, { mux = false, retry = false, onProgress = null, signal } = {}) {
|
||||
async function opfsDownload(videoId, { mux = false, retry = false, onProgress = null, signal, compact = false } = {}) {
|
||||
if (!window.OPFS || !window.OPFS.isSupported()) {
|
||||
return { ok: false, error: 'OPFS not supported in this browser' };
|
||||
}
|
||||
@@ -465,6 +479,9 @@ async function opfsDownload(videoId, { mux = false, retry = false, onProgress =
|
||||
if (hevcCapable()) params.set('hevc', '1');
|
||||
// Album-art songs come back as audio + picture instead of a video of one frame.
|
||||
params.set('still', '1');
|
||||
// Compact audio (setting, or Shrink in Saved videos): the server's HE-AAC copy.
|
||||
const he = (compact || data.settings.compactAudio) && !isUploadId(videoId);
|
||||
if (he) params.set('audio', 'he');
|
||||
const qs = params.toString();
|
||||
const url = `/api/download/${encodeURIComponent(videoId)}${qs ? '?' + qs : ''}`;
|
||||
|
||||
@@ -477,7 +494,11 @@ async function opfsDownload(videoId, { mux = false, retry = false, onProgress =
|
||||
return { ok: false, paused: true, error: error.name === 'AbortError' ? 'Save paused' : 'Connection lost' };
|
||||
}
|
||||
}
|
||||
const resumed = await resumableOpfsSave(videoId, qs, params.get('hevc') === '1' ? 'hevc=1&still=1' : 'still=1', onProgress, signal);
|
||||
const prepParams = new URLSearchParams();
|
||||
if (params.get('hevc') === '1') prepParams.set('hevc', '1');
|
||||
prepParams.set('still', '1');
|
||||
if (he) prepParams.set('audio', 'he');
|
||||
const resumed = await resumableOpfsSave(videoId, qs, prepParams.toString(), onProgress, signal);
|
||||
if (resumed) return resumed;
|
||||
|
||||
// Preferred path: a dedicated Web Worker does the fetch AND the OPFS writes,
|
||||
@@ -488,7 +509,7 @@ async function opfsDownload(videoId, { mux = false, retry = false, onProgress =
|
||||
let workerError = null;
|
||||
if (typeof window.OPFS.downloadVideo === 'function' && typeof Worker !== 'undefined') {
|
||||
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, ext: w.ext || null };
|
||||
if (w.paused) return w;
|
||||
workerError = w.error || null;
|
||||
}
|
||||
@@ -570,6 +591,7 @@ async function opfsList() {
|
||||
async function opfsDelete(videoId) {
|
||||
if (!window.OPFS || !window.OPFS.isSupported()) return { ok: true };
|
||||
try { await window.OPFS.deleteVideo(videoId); } catch { /* ignore */ }
|
||||
CompactSaves.drop(videoId);
|
||||
if (window.DeviceDB) { await window.DeviceDB.deleteFile(videoId); if (window.P2PClient) window.P2PClient.changed(); }
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -577,6 +599,7 @@ async function opfsDelete(videoId) {
|
||||
async function opfsClear() {
|
||||
if (!window.OPFS || !window.OPFS.isSupported()) return { ok: true };
|
||||
try { await window.OPFS.clearAll(); } catch { /* ignore */ }
|
||||
CompactSaves.clear();
|
||||
if (window.DeviceDB) { await window.DeviceDB.clear(); if (window.P2PClient) window.P2PClient.changed(); }
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -674,6 +697,7 @@ const DEFAULT_SETTINGS = {
|
||||
p2pReceive: true, // fetch from other devices when YouTube and the server can't — ON by default
|
||||
autoSavePlayed: true, // keep every video you play for offline (background save once playback starts)
|
||||
saveBeforePlay: false, // download (server-muxed single file) before playing instead of streaming
|
||||
compactAudio: false, // new saves are compact HE-AAC audio only (~⅓ of an audio save, no video)
|
||||
repeatMode: 'off', // 'off' | 'all' — repeat the playing list when it ends
|
||||
loopOne: false, // repeat the single current video
|
||||
shuffle: false, // shuffle upcoming tracks when playing a list
|
||||
@@ -1969,6 +1993,58 @@ async function saveStillArt(id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Saved videos: what format a saved file is in.
|
||||
function savedFormatLabel(it) {
|
||||
if (isUploadId(it.id)) return 'Library file';
|
||||
if (!/\.m4a$/i.test(it.name || '')) return 'Video';
|
||||
return CompactSaves.has(it.id) ? 'Compact audio' : 'Audio';
|
||||
}
|
||||
|
||||
function canShrinkSaved(it) {
|
||||
const id = it && it.id;
|
||||
return WEB && !!id && cachedIds.has(id) && !isUploadId(id) && !String(id).startsWith('edit_') && !CompactSaves.has(id);
|
||||
}
|
||||
|
||||
// Swap a saved copy for the server's compact HE-AAC audio. The old file stays
|
||||
// until the new one is complete, so a failed shrink loses nothing.
|
||||
async function shrinkSaved(video) {
|
||||
const id = video && video.id;
|
||||
if (!canShrinkSaved({ id })) return { ok: false, error: 'nothing to shrink' };
|
||||
if (downloading.has(id)) return { ok: false, error: 'it is still saving' };
|
||||
if (current?.meta?.id === id && current.localUrl) return { ok: false, error: 'it is playing from that file — play something else first' };
|
||||
const before = (await window.OPFS.getFileObject(id).catch(() => null))?.size || 0;
|
||||
downloading.add(id);
|
||||
let res;
|
||||
try { res = await opfsDownload(id, { compact: true }); }
|
||||
finally { downloading.delete(id); }
|
||||
if (!res || !res.ok) return { ok: false, error: (res && res.error) || 'save failed' };
|
||||
if (res.ext) await window.OPFS.keepOnly(id, `${id}.${res.ext}`);
|
||||
if (res.compact) CompactSaves.mark(id);
|
||||
await saveStillArt(id);
|
||||
// An audio save is not the server's video file: stop offering it to other devices.
|
||||
if (window.DeviceDB && /^m4a$/i.test(res.ext || '')) { await window.DeviceDB.deleteFile(id).catch(() => {}); window.P2PClient?.changed(); }
|
||||
const after = (await window.OPFS.getFileObject(id).catch(() => null))?.size || 0;
|
||||
return { ok: true, compact: !!res.compact, before, after };
|
||||
}
|
||||
|
||||
// Shrink several saves one after another (the server converts each once).
|
||||
let shrinkRunning = false;
|
||||
async function shrinkSavedAll(ids) {
|
||||
if (shrinkRunning) { toast('Already shrinking saved songs'); return; }
|
||||
shrinkRunning = true;
|
||||
let done = 0, saved = 0, failed = 0;
|
||||
try {
|
||||
for (const id of ids) {
|
||||
if (current?.meta?.id === id) continue; // playing from that file right now
|
||||
toast(`Shrinking saved songs… ${done + failed + 1}/${ids.length}`, { duration: 2500 });
|
||||
const r = await shrinkSaved(videoById(id) || { id });
|
||||
if (r.ok) { done++; saved += Math.max(0, r.before - r.after); } else failed++;
|
||||
}
|
||||
} finally { shrinkRunning = false; }
|
||||
toast(`Shrunk ${done} song${done === 1 ? '' : 's'} · freed ${fmtBytes(saved)}${failed ? ` · ${failed} could not be shrunk` : ''}`, { duration: 6000 });
|
||||
if (view.type === 'saved') renderSaved();
|
||||
}
|
||||
|
||||
// Playback fields for a saved still-picture song: play it as audio and show
|
||||
// its saved picture. {} for anything else.
|
||||
async function localStill(id) {
|
||||
@@ -2027,6 +2103,7 @@ async function preload(video, { quiet = false, mux = false, retry = false, direc
|
||||
cacheMutations++;
|
||||
// A still-picture song saved as audio isn't the server's video file, so
|
||||
// it is never offered to other devices as one.
|
||||
if (res.compact) CompactSaves.mark(id); else CompactSaves.drop(id);
|
||||
saveStillArt(id).then((still) => { if (!still) recordDeviceFile(id, res); });
|
||||
warmThumb(thumbUrlFor(id, video));
|
||||
if (!quiet) toast(`Saved “${video.title}” ✓`);
|
||||
|
||||
@@ -16,7 +16,7 @@ function harness({ autoPreload = true, cached = [], result = { ok: true, cached:
|
||||
SaveSlots: { acquire: async () => {}, release() {} },
|
||||
SaveQueue: { add: v => jobs.set(v.id, { status: 'queued' }), remove: id => jobs.delete(id), progress() {}, state: (id, status, message) => jobs.set(id, { status, message }) },
|
||||
API: { cacheDownload: async id => { calls.push(id); if (error) throw error; return result; } },
|
||||
recordDeviceFile() {}, saveStillArt: async () => false, warmThumb() {}, thumbUrlFor: () => '', cacheMutations: 0,
|
||||
recordDeviceFile() {}, saveStillArt: async () => false, CompactSaves: { mark() {}, drop() {} }, warmThumb() {}, thumbUrlFor: () => '', cacheMutations: 0,
|
||||
});
|
||||
const preload = source.slice(source.indexOf('async function preload(video,'), source.indexOf('// A device copy that failed to play.'));
|
||||
const playlists = source.slice(source.indexOf('function preloadPlaylist(pl)'), source.indexOf('// Update a single card\'s saved badge'));
|
||||
|
||||
@@ -218,7 +218,7 @@
|
||||
opts.signal?.addEventListener('abort', abort, { once: true });
|
||||
worker.onmessage = (e) => {
|
||||
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, ext: m.ext || null });
|
||||
else if (m.type === 'unsupported') finish({ ok: false, fallback: true });
|
||||
else if (m.type === 'error') finish({ ok: false, error: m.error });
|
||||
else if (m.type === 'paused') finish({ ok: false, paused: true, received: m.received || 0, error: m.error });
|
||||
@@ -359,6 +359,21 @@
|
||||
return items.reduce((s, i) => s + i.size, 0);
|
||||
},
|
||||
|
||||
// Remove this video's other saved files (a different format), keeping
|
||||
// `keepName` — after a save that replaced e.g. <id>.mp4 with <id>.m4a.
|
||||
async keepOnly(videoId, keepName) {
|
||||
try {
|
||||
const dir = await getRoot();
|
||||
const drop = [];
|
||||
for await (const [name, handle] of dir.entries()) {
|
||||
if (handle.kind !== 'file' || name === keepName || name.endsWith('.part') || name.endsWith('.part.json')) continue;
|
||||
const dot = name.lastIndexOf('.');
|
||||
if ((dot > -1 ? name.slice(0, dot) : name) === videoId) drop.push(name);
|
||||
}
|
||||
for (const name of drop) await dir.removeEntry(name).catch(() => {});
|
||||
} catch { /* OPFS not available */ }
|
||||
},
|
||||
|
||||
// Delete one video
|
||||
async deleteVideo(videoId) {
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.saved-summary.saved-storage-summary { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:8px 0 16px; }
|
||||
.saved-storage-text { min-width:0; color:var(--text-2); font-size:14px; font-variant-numeric:tabular-nums; }
|
||||
.saved-clear { flex:none; min-height:44px; padding:0 10px; border:0; background:transparent; color:var(--accent); font:inherit; cursor:pointer; }
|
||||
html[data-layout] .card.saved-card { display:grid; grid-template-columns:72px minmax(0,1fr) 96px; align-items:center; gap:12px; min-height:88px; }
|
||||
html[data-layout] .card.saved-card { display:grid; grid-template-columns:72px minmax(0,1fr) auto; align-items:center; gap:12px; min-height:88px; }
|
||||
html[data-layout] #cards .saved-card .thumb { grid-column:1; grid-row:1; width:100%; min-width:0; max-width:100%; flex:none; overflow:hidden; }
|
||||
html[data-layout] #cards .saved-card .thumb img { width:100%; max-width:100%; height:100%; object-fit:cover; }
|
||||
html[data-layout] .saved-card .card-info { grid-column:2; grid-row:1; min-width:0; }
|
||||
@@ -14,4 +14,6 @@ html[data-layout] .saved-file-actions { grid-column:3; grid-row:1; display:flex;
|
||||
.saved-file-action.danger:hover, .saved-clear:hover { color:var(--accent); }
|
||||
.saved-file-action:focus-visible, .saved-clear:focus-visible { outline:2px solid var(--accent); outline-offset:3px; }
|
||||
.saved-file-action:disabled, .saved-clear:disabled { opacity:.5; cursor:wait; }
|
||||
@media(min-width:900px) { html[data-layout] .card.saved-card { grid-template-columns:88px minmax(0,1fr) 96px; } }
|
||||
@media(min-width:900px) { html[data-layout] .card.saved-card { grid-template-columns:88px minmax(0,1fr) auto; } }
|
||||
.saved-storage-summary .saved-shrink-all { margin-left:auto; }
|
||||
@media(max-width:420px) { html[data-layout] .saved-file-actions { gap:4px; } html[data-layout] .saved-file-action { width:40px; min-width:40px; height:40px; padding:8px; } }
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
const matches = (video, query) => !query || `${video.title || video.id} ${video.channel || ''}`.toLowerCase().includes(query.toLowerCase());
|
||||
const paths = {
|
||||
export: '<use href="#media-download"/>',
|
||||
delete: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6"/>'
|
||||
delete: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6"/>',
|
||||
shrink: '<path d="M4 14h6v6M20 10h-6V4M14 10l7-7M3 21l7-7"/>'
|
||||
};
|
||||
function button(doc, kind, label, action) {
|
||||
const b = doc.createElement('button'); b.type = 'button';
|
||||
@@ -13,10 +14,14 @@
|
||||
b.innerHTML = `<svg class="media-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[kind]}</svg>`;
|
||||
b.onclick = e => { e.stopPropagation(); action(); }; return b;
|
||||
}
|
||||
function actions(row, video, exportFile, deleteFile) {
|
||||
function actions(row, video, exportFile, deleteFile, shrinkFile = null) {
|
||||
const doc = row.ownerDocument, group = doc.createElement('div');
|
||||
group.className = 'saved-file-actions'; group.setAttribute('role', 'group');
|
||||
group.setAttribute('aria-label', `File actions for ${video.title || video.id}`);
|
||||
if (shrinkFile) {
|
||||
const b = button(doc, 'shrink', `Shrink to compact audio: ${video.title || video.id}`, async () => { b.disabled = true; try { await shrinkFile(); } finally { b.disabled = false; } });
|
||||
group.append(b);
|
||||
}
|
||||
group.append(button(doc, 'export', `Save to this device: ${video.title || video.id}`, exportFile), button(doc, 'delete', `Delete saved file: ${video.title || video.id}`, deleteFile));
|
||||
row.append(group);
|
||||
}
|
||||
|
||||
@@ -658,7 +658,27 @@ async function renderSaved() {
|
||||
c.innerHTML = '';
|
||||
const summary = document.createElement('div');
|
||||
summary.className = 'saved-summary saved-storage-summary';
|
||||
summary.innerHTML = `<span class="saved-storage-text">${fmtBytes(total)} · ${items.length} video${items.length === 1 ? '' : 's'} stored offline</span>`;
|
||||
const audioCount = items.filter((it) => /\.m4a$/i.test(it.name || '')).length;
|
||||
summary.innerHTML = `<span class="saved-storage-text">${fmtBytes(total)} · ${items.length} saved${audioCount ? ` · ${audioCount} as audio` : ''}</span>`;
|
||||
// Every saved YouTube song not yet in compact audio can be shrunk in place.
|
||||
const shrinkable = items.filter((it) => canShrinkSaved(it));
|
||||
if (shrinkable.length) {
|
||||
const shrinkAll = document.createElement('button');
|
||||
shrinkAll.type = 'button';
|
||||
shrinkAll.className = 'saved-clear saved-shrink-all';
|
||||
shrinkAll.textContent = `Shrink ${shrinkable.length}`;
|
||||
shrinkAll.title = 'Replace these saves with compact audio (HE-AAC): about a third of the space, no video';
|
||||
shrinkAll.setAttribute('aria-label', `Shrink ${shrinkable.length} saved songs to compact audio`);
|
||||
shrinkAll.onclick = () => {
|
||||
const before = shrinkable.reduce((n, it) => n + (it.size || 0), 0);
|
||||
showModal(`Shrink ${shrinkable.length} saved song${shrinkable.length === 1 ? '' : 's'}?`, document.createTextNode(
|
||||
`They are swapped for compact audio (HE-AAC, ~48 kbps): sound only, no video, about a third of an audio save and far less than a video (now ${fmtBytes(before)}). The server converts each song once; a song keeps playing from its current copy until the new one is in place.`), [
|
||||
{ label: 'Cancel', onClick: closeModal },
|
||||
{ label: 'Shrink', onClick: () => { closeModal(); shrinkSavedAll(shrinkable.map((it) => it.id)); } },
|
||||
]);
|
||||
};
|
||||
summary.appendChild(shrinkAll);
|
||||
}
|
||||
summary.appendChild(clearAll);
|
||||
$('listFilterBar').classList.remove('hidden');
|
||||
c.appendChild(summary);
|
||||
@@ -679,13 +699,20 @@ async function renderSaved() {
|
||||
onerror="this.classList.add('no-art')" />${isCustom ? '<span class="edit-badge" title="Edited copy">✂</span>' : ''}${isUploadId(v.id) ? `<span class="upload-badge" title="On this server">${v.kind === 'audio' ? '♪' : '📁'}</span>` : ''}</div>
|
||||
<div class="card-info">
|
||||
<div class="card-title"></div>
|
||||
<div class="card-channel saved-size">${isCustom ? '✂ edited · ' : ''}${fmtBytes(it.size)}</div>
|
||||
<div class="card-channel saved-size">${isCustom ? '✂ edited · ' : ''}${savedFormatLabel(it)} · ${fmtBytes(it.size)}</div>
|
||||
</div>`;
|
||||
row.querySelector('.card-title').textContent = v.title || it.id;
|
||||
row.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.saved-file-actions')) return;
|
||||
playFromList([v], 0, 'saved');
|
||||
});
|
||||
const shrink = canShrinkSaved(it) ? async () => {
|
||||
toast(`Shrinking “${v.title || v.id}”…`);
|
||||
const r = await shrinkSaved(v);
|
||||
toast(r.ok ? (r.compact ? `Shrunk ${fmtBytes(r.before)} → ${fmtBytes(r.after)}` : 'This song has no smaller version')
|
||||
: '⚠ ' + (r.error || 'Could not shrink it'));
|
||||
if (view.type === 'saved') renderSaved();
|
||||
} : null;
|
||||
SavedPage.actions(row, v, () => exportToDevice(v), () => {
|
||||
showModal(`Delete saved file “${v.title || v.id}”?`, document.createTextNode(isCustom
|
||||
? 'This edited copy exists only on this device and will be removed permanently.'
|
||||
@@ -708,7 +735,7 @@ async function renderSaved() {
|
||||
toast('Removed saved file'); renderSaved();
|
||||
} },
|
||||
]);
|
||||
});
|
||||
}, shrink);
|
||||
c.appendChild(row);
|
||||
});
|
||||
if (!c.querySelector('.saved-card')) {
|
||||
@@ -959,6 +986,13 @@ async function renderSettings() {
|
||||
</span>
|
||||
<input id="setAutoPreload" type="checkbox" ${data.settings.autoPreload ? 'checked' : ''} />
|
||||
</label>
|
||||
<label class="set-row">
|
||||
<span>
|
||||
Save songs as compact audio
|
||||
<small>New saves keep only the sound, as HE-AAC (about a third of the space, no video). The server converts each song once. Shrink songs you already saved from Saved videos.</small>
|
||||
</span>
|
||||
<input id="setCompactAudio" type="checkbox" ${data.settings.compactAudio ? 'checked' : ''} />
|
||||
</label>
|
||||
<label class="set-row">
|
||||
<span>
|
||||
Videos saved at once
|
||||
@@ -1355,6 +1389,10 @@ async function renderSettings() {
|
||||
data.settings.autoSavePlayed = e.target.checked;
|
||||
persist();
|
||||
});
|
||||
$('setCompactAudio').addEventListener('change', (e) => {
|
||||
data.settings.compactAudio = e.target.checked;
|
||||
persist();
|
||||
});
|
||||
$('setAutoPreload').addEventListener('change', (e) => {
|
||||
data.settings.autoPreload = e.target.checked;
|
||||
persist();
|
||||
|
||||
Reference in New Issue
Block a user