Still-picture songs save and play as audio plus one picture
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user