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

@@ -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) {