Load the whole song into the browser for the lyrics editor so audio no longer stops
This commit is contained in:
@@ -572,6 +572,7 @@
|
|||||||
<nav class="nav" id="tabsBottom" role="tablist"></nav>
|
<nav class="nav" id="tabsBottom" role="tablist"></nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="opfs.js"></script>
|
||||||
<script src="download-actions.js"></script>
|
<script src="download-actions.js"></script>
|
||||||
<script src="lyrics-core.js"></script>
|
<script src="lyrics-core.js"></script>
|
||||||
<script>
|
<script>
|
||||||
@@ -1165,20 +1166,82 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function initAudio(id, streams) {
|
/* The editor plays a COMPLETE local copy, never a live stream (a stream can
|
||||||
const url = (streams.data && streams.data.audioUrl) || ((streams.data && streams.data.qualities || []).find((q) => q.hasAudio) || {}).url || '';
|
stall or expire mid-song). Order: the same saved video the player uses
|
||||||
|
(OPFS) → an audio-only copy kept for admin use (Cache Storage) → fetch the
|
||||||
|
audio-only file from the server once and keep it. */
|
||||||
|
const ADMIN_AUDIO_CACHE = 'ytp-admin-audio';
|
||||||
|
async function adminAudioBlobUrl(id, streams, onProgress) {
|
||||||
|
try {
|
||||||
|
if (window.OPFS && OPFS.isSupported() && await OPFS.hasVideo(id)) {
|
||||||
|
const u = await OPFS.getFileUrl(id);
|
||||||
|
if (u) return u;
|
||||||
|
}
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
const key = `/admin-audio/${encodeURIComponent(id)}`;
|
||||||
|
let cache = null;
|
||||||
|
try { cache = await caches.open(ADMIN_AUDIO_CACHE); } catch { /* no Cache Storage */ }
|
||||||
|
if (cache) {
|
||||||
|
const hit = await cache.match(key).catch(() => null);
|
||||||
|
if (hit) return URL.createObjectURL(await hit.blob());
|
||||||
|
}
|
||||||
|
const d = (streams && streams.data) || {};
|
||||||
|
const sources = [`/api/media/${encodeURIComponent(id)}?a=1`,
|
||||||
|
d.audioUrl || ((d.qualities || []).find((q) => q.hasAudio) || {}).url || ''].filter(Boolean);
|
||||||
|
for (const src of sources) {
|
||||||
|
for (let attempt = 0; attempt < 2; attempt++) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(src, { credentials: 'same-origin' });
|
||||||
|
if (!r.ok) break;
|
||||||
|
const total = Number(r.headers.get('content-length')) || 0;
|
||||||
|
const reader = r.body.getReader(); const parts = []; let got = 0;
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
parts.push(value); got += value.length;
|
||||||
|
if (onProgress) onProgress(got, total);
|
||||||
|
}
|
||||||
|
if (total && got < total) throw new Error('short read');
|
||||||
|
const blob = new Blob(parts, { type: r.headers.get('content-type') || 'audio/mp4' });
|
||||||
|
if (blob.size < 1024) break;
|
||||||
|
if (cache) await cache.put(key, new Response(blob, { headers: { 'content-type': blob.type } })).catch(() => {});
|
||||||
|
return URL.createObjectURL(blob);
|
||||||
|
} catch { await new Promise((ok) => setTimeout(ok, 800)); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio status shares the #msg line with other messages: only write when it
|
||||||
|
// is empty or still showing our own text, and never clobber someone else's.
|
||||||
|
function audioSay(text, cls) {
|
||||||
|
const el = $('msg'), cur = el.textContent;
|
||||||
|
if (cur && cur !== LE.audioMsg) return;
|
||||||
|
LE.audioMsg = text; say(text, cls);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initAudio(id, streams) {
|
||||||
if (LE.audio) { LE.audio.pause(); LE.audio.src = ''; }
|
if (LE.audio) { LE.audio.pause(); LE.audio.src = ''; }
|
||||||
|
if (LE.audioUrl) { try { URL.revokeObjectURL(LE.audioUrl); } catch {} LE.audioUrl = ''; }
|
||||||
LE.audio = null;
|
LE.audio = null;
|
||||||
|
const token = (LE.audioToken = (LE.audioToken || 0) + 1);
|
||||||
|
audioSay('Loading the full audio into the browser…');
|
||||||
|
const url = await adminAudioBlobUrl(id, streams, (got, total) => {
|
||||||
|
if (token === LE.audioToken) audioSay(total ? `Loading audio… ${Math.round(got / total * 100)}%` : `Loading audio… ${(got / 1048576).toFixed(1)} MB`);
|
||||||
|
});
|
||||||
|
if (token !== LE.audioToken || LE.id !== id) { if (url) try { URL.revokeObjectURL(url); } catch {} return; }
|
||||||
|
audioSay('');
|
||||||
if (url) {
|
if (url) {
|
||||||
|
LE.audioUrl = url;
|
||||||
const a = new Audio(url); a.preload = 'auto';
|
const a = new Audio(url); a.preload = 'auto';
|
||||||
a.addEventListener('timeupdate', tick); a.addEventListener('seeked', tick);
|
a.addEventListener('timeupdate', tick); a.addEventListener('seeked', tick);
|
||||||
a.addEventListener('loadedmetadata', () => { if (a.duration) { LE.dur = a.duration; $('dur').textContent = fmtT(a.duration).slice(0, -3); } draw(); });
|
a.addEventListener('loadedmetadata', () => { if (a.duration) { LE.dur = a.duration; $('dur').textContent = fmtT(a.duration).slice(0, -3); } draw(); });
|
||||||
a.addEventListener('play', () => { TransportIcons.set($('bPlay'),'pause'); });
|
a.addEventListener('play', () => { TransportIcons.set($('bPlay'),'pause'); });
|
||||||
a.addEventListener('pause', () => { TransportIcons.set($('bPlay'),'play'); });
|
a.addEventListener('pause', () => { TransportIcons.set($('bPlay'),'play'); });
|
||||||
a.addEventListener('error', () => say('No playable audio for this song on the server — you can still edit the text.', 'err'));
|
a.addEventListener('error', () => audioSay('The saved audio could not be played — reload the song to fetch it again.', 'err'));
|
||||||
a.playbackRate = Number($('rate').value);
|
a.playbackRate = Number($('rate').value);
|
||||||
LE.audio = a;
|
LE.audio = a;
|
||||||
} else say('This song is not cached on the server, so there is no audio to time against.', 'err');
|
} else audioSay('No audio could be saved for this song, so there is nothing to time against.', 'err');
|
||||||
$('dur').textContent = fmtT(LE.dur).slice(0, -3);
|
$('dur').textContent = fmtT(LE.dur).slice(0, -3);
|
||||||
LE.peaks = null;
|
LE.peaks = null;
|
||||||
fetch(`/api/media/${id}/peaks`, { credentials: 'same-origin' }).then((r) => r.json())
|
fetch(`/api/media/${id}/peaks`, { credentials: 'same-origin' }).then((r) => r.json())
|
||||||
|
|||||||
@@ -150,3 +150,18 @@ test('player lyric editor groups Shift+Enter words and stamps their shared cue',
|
|||||||
const document = await text.inputValue(); expect(document).toContain('[0:00.00] Because You are God\n| You can do anything');
|
const document = await text.inputValue(); expect(document).toContain('[0:00.00] Because You are God\n| You can do anything');
|
||||||
expect(document).not.toContain('[0:00.00] |');
|
expect(document).not.toContain('[0:00.00] |');
|
||||||
});
|
});
|
||||||
|
test('editor loads the whole audio into the browser once and reuses it', async ({ page }) => {
|
||||||
|
const wav = Buffer.alloc(44 + 16000); // 1 s of silence, 8 kHz 16-bit mono
|
||||||
|
wav.write('RIFF', 0); wav.writeUInt32LE(36 + 16000, 4); wav.write('WAVEfmt ', 8); wav.writeUInt32LE(16, 16);
|
||||||
|
wav.writeUInt16LE(1, 20); wav.writeUInt16LE(1, 22); wav.writeUInt32LE(8000, 24); wav.writeUInt32LE(16000, 28);
|
||||||
|
wav.writeUInt16LE(2, 32); wav.writeUInt16LE(16, 34); wav.write('data', 36); wav.writeUInt32LE(16000, 40);
|
||||||
|
let hits = 0;
|
||||||
|
await fixture(page);
|
||||||
|
await page.route('**/api/media/' + id + '?a=1', route => { hits++; route.fulfill({ contentType: 'audio/wav', body: wav }); });
|
||||||
|
await page.locator('[data-open="' + id + '"]').first().click();
|
||||||
|
await expect(page.locator('#dur')).toHaveText('0:01');
|
||||||
|
expect(hits).toBe(1);
|
||||||
|
await page.reload(); await page.locator('[data-open="' + id + '"]').first().click();
|
||||||
|
await expect(page.locator('#dur')).toHaveText('0:01');
|
||||||
|
expect(hits).toBe(1);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user