Still-picture songs save and play as audio plus one picture

This commit is contained in:
Jonathan Sykes
2026-10-17 07:00:22 +08:00
parent 2e6c79e360
commit eef9fbf05f
10 changed files with 262 additions and 24 deletions

View File

@@ -52,6 +52,12 @@
const _blobUrls = new Set();
// Pictures of still-picture songs saved as audio: art/<videoId>.jpg
async function getArtDir() {
const root = await navigator.storage.getDirectory();
return root.getDirectoryHandle('art', { create: true });
}
window.OPFS = {
// Does a file exist for this videoId?
async hasVideo(videoId) {
@@ -84,6 +90,50 @@
}
},
// The saved file's name (<id>.<ext>), or null — an .m4a for a YouTube id
// is a still-picture song saved as audio + picture.
async fileName(videoId) {
try {
const found = await findHandle(videoId);
return found ? found[1] : null;
} catch {
return null;
}
},
// Store a still-picture song's picture. Main-thread writable first, else
// the worker's sync handle (Safari has no createWritable).
async saveArt(videoId, buffer) {
const dir = await getArtDir();
const handle = await dir.getFileHandle(videoId + '.jpg', { create: true });
if ('createWritable' in handle) {
const w = await handle.createWritable();
await w.write(buffer);
await w.close();
return;
}
await new Promise((resolve, reject) => {
const w = window.Lazy ? window.Lazy.worker('/opfs-worker.js') : new Worker('/opfs-worker.js');
w.onmessage = (e) => { w.terminate(); (e.data && e.data.type === 'written') ? resolve() : reject(new Error((e.data && e.data.error) || 'write failed')); };
w.onerror = () => { w.terminate(); reject(new Error('worker failed')); };
w.postMessage({ op: 'write', dir: 'art', name: videoId + '.jpg', buffer }, [buffer]);
});
},
// blob: URL of a saved picture, or null. Revoke with revokeUrl().
async getArtUrl(videoId) {
try {
const dir = await getArtDir();
const file = await (await dir.getFileHandle(videoId + '.jpg')).getFile();
if (!file.size) return null;
const url = URL.createObjectURL(new Blob([file], { type: 'image/jpeg' }));
_blobUrls.add(url);
return url;
} catch {
return null;
}
},
// Revoke an object URL previously returned by getFileUrl().
// 'hevc' | 'avc' | null for a stored mp4. Server copies are faststart, so
// the sample description (hvc1/hev1 vs avc1) sits in the first megabyte.
@@ -318,6 +368,7 @@
const dir = await getRoot();
await dir.removeEntry(name);
} catch { /* already gone */ }
try { await (await getArtDir()).removeEntry(videoId + '.jpg'); } catch { /* no picture */ }
},
// Delete all cached videos
@@ -327,6 +378,7 @@
const names = [];
for await (const [name] of dir.entries()) names.push(name);
await Promise.all(names.map((n) => dir.removeEntry(n).catch(() => {})));
try { await (await navigator.storage.getDirectory()).removeEntry('art', { recursive: true }); } catch { /* no pictures */ }
// Revoke any outstanding blob URLs
for (const url of _blobUrls) URL.revokeObjectURL(url);
_blobUrls.clear();