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

@@ -5,7 +5,7 @@ const {createHash}=require('node:crypto');
const {join}=require('node:path');
const app=readFileSync(join(__dirname,'app.js'),'utf8');
test('Phase 4 preserves the exact playback, continuity, tracking and queue bodies',()=>{
{const start=app.indexOf("const Player = {");assert.ok(start>=0,"Player");const body=app.slice(start,start+30343);assert.equal(createHash('sha256').update(body).digest('hex'),"190834ae6f33a168917416e55799f8a467e0d28f966f636a182745a59ffcac41","Player body unchanged");}
{const start=app.indexOf("const Player = {");assert.ok(start>=0,"Player");const body=app.slice(start,start+30964);assert.equal(createHash('sha256').update(body).digest('hex'),"5a63a7bc9ddb2266f9b88ab40730750aa092cd06106ef6a54e9560cd5d4605a0","Player body unchanged");}
{const start=app.indexOf("const Transition = (() => {");assert.ok(start>=0,"Transition");const body=app.slice(start,start+8588);assert.equal(createHash('sha256').update(body).digest('hex'),"71997a537078308a41f0cbf8e29fd1658fb3a199c4f5a25d8af0931f1c2a6d50","Transition body unchanged");}
{const start=app.indexOf("const Wave = (() => {");assert.ok(start>=0,"Wave");const body=app.slice(start,start+5241);assert.equal(createHash('sha256').update(body).digest('hex'),"7252f4e856c59aef8aabfcd394ce4bcba656110cbd01a1a8af662eeb48166a3c","Wave body unchanged");}
{const start=app.indexOf("const StatsTrack = (() => {");assert.ok(start>=0,"StatsTrack");const body=app.slice(start,start+1462);assert.equal(createHash('sha256').update(body).digest('hex'),"9aae2631ba3bfad918319579b7120814af06c84949394d04e52c65345332d514","StatsTrack body unchanged");}

View File

@@ -412,10 +412,10 @@ 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, signal) {
async function resumableOpfsSave(videoId, qs, prepQs, 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' : ''}`;
const prepUrl = `/api/download/${enc}/prepare${prepQs ? '?' + prepQs : ''}`;
const dlUrl = `/api/download/${enc}${qs ? '?' + qs : ''}`;
const report = (info) => { if (onProgress) { try { onProgress(info); } catch { /* UI only */ } } };
const started = Date.now();
@@ -463,6 +463,8 @@ async function opfsDownload(videoId, { mux = false, retry = false, onProgress =
// stream exactly as before.
if (mux) params.set('mux', '1');
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');
const qs = params.toString();
const url = `/api/download/${encodeURIComponent(videoId)}${qs ? '?' + qs : ''}`;
@@ -475,7 +477,7 @@ 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', onProgress, signal);
const resumed = await resumableOpfsSave(videoId, qs, params.get('hevc') === '1' ? 'hevc=1&still=1' : 'still=1', onProgress, signal);
if (resumed) return resumed;
// Preferred path: a dedicated Web Worker does the fetch AND the OPFS writes,
@@ -499,7 +501,7 @@ async function opfsDownload(videoId, { mux = false, retry = false, onProgress =
}
// Determine file extension from Content-Type
const ct = res.headers.get('content-type') || 'video/mp4';
const ext = ct.includes('webm') ? 'webm' : ct.includes('ogg') ? 'ogg' : 'mp4';
const ext = ct.includes('webm') ? 'webm' : ct.includes('ogg') ? 'ogg' : ct.includes('audio/mp4') ? 'm4a' : 'mp4';
await window.OPFS.writeFromResponse(videoId, ext, res);
return { ok: true, cached: true };
} catch (err) {
@@ -581,6 +583,7 @@ async function opfsClear() {
// Blob URL for the currently playing OPFS video — revoked on next video load.
let _currentBlobUrl = null;
let _currentArtUrl = null; // saved picture of a still-picture song (blob:)
const API = {
search: (query, { refresh = false, after = 0 } = {}) => WEB
@@ -1953,6 +1956,30 @@ function recordDeviceFile(id, res) {
.catch(() => {});
}
// Still-picture songs are saved as their audio (<id>.m4a) plus one picture.
// True when this saved file is one; fetches its picture alongside.
async function saveStillArt(id) {
if (!WEB || !window.OPFS || isUploadId(id) || typeof window.OPFS.fileName !== 'function') return false;
const name = await window.OPFS.fileName(id).catch(() => null);
if (!name || !/\.m4a$/i.test(name)) return false;
try {
const r = await fetch(`/api/media/${encodeURIComponent(id)}/art`);
if (r.ok) await window.OPFS.saveArt(id, await r.arrayBuffer());
} catch { /* the YouTube thumbnail stands in */ }
return true;
}
// Playback fields for a saved still-picture song: play it as audio and show
// its saved picture. {} for anything else.
async function localStill(id) {
if (!WEB || !window.OPFS || isUploadId(id) || typeof window.OPFS.fileName !== 'function') return {};
const name = await window.OPFS.fileName(id).catch(() => null);
if (!name || !/\.m4a$/i.test(name)) return {};
const art = await window.OPFS.getArtUrl(id).catch(() => null);
if (art) { if (_currentArtUrl) window.OPFS.revokeUrl(_currentArtUrl); _currentArtUrl = art; }
return { kind: 'audio', still: true, artUrl: art || '' };
}
// Download a video into the permanent offline cache. Safe to call repeatedly.
async function preload(video, { quiet = false, mux = false, retry = false, directFallback = false } = {}) {
const id = video.id;
@@ -1998,7 +2025,9 @@ async function preload(video, { quiet = false, mux = false, retry = false, direc
if (typeof EqRender !== 'undefined') EqRender.schedule(id);
cachedIds.add(id);
cacheMutations++;
recordDeviceFile(id, res);
// A still-picture song saved as audio isn't the server's video file, so
// it is never offered to other devices as one.
saveStillArt(id).then((still) => { if (!still) recordDeviceFile(id, res); });
warmThumb(thumbUrlFor(id, video));
if (!quiet) toast(`Saved “${video.title}” ✓`);
} else if (!quiet && !(res && res.paused)) {
@@ -2463,6 +2492,7 @@ const Player = {
else URL.revokeObjectURL(_currentBlobUrl);
_currentBlobUrl = null;
}
if (_currentArtUrl) { if (window.OPFS) window.OPFS.revokeUrl(_currentArtUrl); _currentArtUrl = null; }
try {
// Party guests follow the host in real time: never hold playback for a
// full device-to-device copy (and its accept prompt) — stream instead.
@@ -2486,7 +2516,9 @@ const Player = {
if (localUrl) {
const eqAudioUrl = await useEqRender(videoObj.id);
if (seq !== this._loadSeq) return;
current = { meta: { ...videoObj }, qualities: [], audioUrl: null, localUrl, eqAudioUrl };
const still = await localStill(videoObj.id);
if (seq !== this._loadSeq) return;
current = { meta: { ...videoObj }, qualities: [], audioUrl: null, localUrl, eqAudioUrl, ...still };
this.afterLoad();
this.fallbackQueue = []; this.fbIndex = 0;
this.attach(null);
@@ -2517,7 +2549,9 @@ const Player = {
if (localUrl) {
_currentBlobUrl = localUrl;
const eqAudioUrl = await useEqRender(videoObj.id);
current = { meta: { ...videoObj }, qualities: [], audioUrl: null, localUrl, eqAudioUrl };
const still = await localStill(videoObj.id);
if (seq !== this._loadSeq) return;
current = { meta: { ...videoObj }, qualities: [], audioUrl: null, localUrl, eqAudioUrl, ...still };
this.afterLoad();
this.fallbackQueue = []; this.fbIndex = 0;
this.attach(null);
@@ -2533,6 +2567,9 @@ const Player = {
if (current.serverCached && current.meta?.id) knownServerCachedIds.add(current.meta.id);
// Merge richer metadata we may already have from the list card.
current.meta = { ...videoObj, ...current.meta };
// Album-art song: its video is one unchanging picture, so play the audio
// and show the picture — a fraction of the data for the same screen.
if (current.still && current.art) { current.kind = 'audio'; current.artUrl = current.art; }
this.afterLoad();
const q = chooseQuality();
@@ -2757,7 +2794,7 @@ const Player = {
A.src = current.eqAudioUrl || current.localUrl;
V.removeAttribute('src'); V.load();
els.art.classList.remove('hidden');
els.artImg.src = current.meta.thumbnail || '';
els.artImg.src = current.artUrl || current.meta.thumbnail || '';
} else {
this.mode = 'progressive';
this.master = V;
@@ -2788,7 +2825,7 @@ const Player = {
A.src = current.audioUrl || (quality && quality.url) || '';
V.removeAttribute('src'); V.load();
els.art.classList.remove('hidden');
els.artImg.src = current.meta.thumbnail || '';
els.artImg.src = current.artUrl || current.meta.thumbnail || '';
} else if (quality && quality.hasAudio) {
// Progressive single stream (video already carries audio).
this.mode = 'progressive';
@@ -7059,7 +7096,10 @@ async function exportToDevice(video) {
say('Looking for the video…');
busy();
let file = await localFile();
if (file) return ready(file);
// A still-picture song saved as audio: a desktop takes the server's video
// file when there is one; otherwise the audio is what gets saved.
const stillAudio = !!file && !isUploadId(id) && /\.m4a$/i.test(file.name || '');
if (file && !stillAudio) return ready(file);
// Is it on the server? (a 1-byte ranged probe; nothing is downloaded)
let serverUrl = '';
@@ -7076,6 +7116,7 @@ async function exportToDevice(video) {
button('Download file', true, () => { DE.download(serverUrl, DE.fileName(title, 'mp4', id)); done('Download started — see your Downloads folder'); });
return;
}
if (stillAudio) return ready(file);
// Other devices, only when the server can't help.
if (!serverUrl && WEB && window.P2PTransfer && window.P2PCore && data.settings.p2pReceive !== false && !video.custom) {

View File

@@ -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() {}, warmThumb() {}, thumbUrlFor: () => '', cacheMutations: 0,
recordDeviceFile() {}, saveStillArt: async () => false, 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'));

View File

@@ -43,7 +43,7 @@ async function getVideosDir() {
function extFromContentType(ct) {
ct = ct || 'video/mp4';
return ct.includes('webm') ? 'webm' : ct.includes('ogg') ? 'ogg' : 'mp4';
return ct.includes('webm') ? 'webm' : ct.includes('ogg') ? 'ogg' : ct.includes('audio/mp4') ? 'm4a' : 'mp4';
}
// .part → permanent name. Prefer the native rename, but treat ANY move()

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();

View File

@@ -101,6 +101,8 @@
}
for (const f of byId.values()) {
if (String(f.id).startsWith('edit_')) continue; // edited cuts are never shared
// A still-picture song saved as audio (<id>.m4a) is not the server's file.
if (/\.m4a$/i.test(f.name) && !String(f.id).startsWith('upl_')) continue;
const r = { videoId: f.id, cid: null, size: f.size, savedAt: Date.now(), lastCheckedAt: 0, state: 'unhashed' };
await window.DeviceDB.putFile(r);
out.push({ ...r, name: f.name });