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}” ✓`);
|
||||
|
||||
Reference in New Issue
Block a user