12 Commits

Author SHA1 Message Date
Jonathan Sykes
0f3f055835 Merge main (admin home link, stamp fix) into beta 2026-10-11 04:08:45 +08:00
Jonathan Sykes
955dd54e5a Lyrics Studio logo returns to the admin home; Stamp now works on any line 2026-10-11 04:08:32 +08:00
Jonathan Sykes
e3283c53a0 Merge main (auto-save played videos, lyrics editor audio) into beta 2026-10-11 03:57:27 +08:00
Jonathan Sykes
785e903e1e Load the whole song into the browser for the lyrics editor so audio no longer stops 2026-10-11 03:57:02 +08:00
Jonathan Sykes
f84ecb5ae9 Auto-save every video you play for offline, with a Settings switch 2026-10-11 03:26:04 +08:00
Jonathan Sykes
15f554430b Merge the integrated JamesDSP, settings layout and retry fixes into beta 2026-10-10 23:03:39 +08:00
Jonathan Sykes
7950e83c08 Restore the beta compose stack 2026-10-10 21:04:49 +08:00
Jonathan Sykes
295ac6fb30 Replace the beta branch with the integrated JamesDSP engine 2026-10-10 20:53:23 +08:00
Jonathan Sykes
459a27b6eb Merge main into jamesdsp and register its assets 2026-10-09 18:37:35 +08:00
Jonathan Sykes
04421a2145 Add an isolated beta compose stack 2026-10-07 23:30:29 +08:00
Jonathan Sykes
491ec407a1 Expose more JamesDSP EQ bands 2026-10-07 19:24:36 +08:00
Jonathan Sykes
dce186c187 Add optional JamesDSP audio effects 2026-10-07 19:23:35 +08:00
6 changed files with 156 additions and 9 deletions

34
docker-compose.beta.yml Normal file
View File

@@ -0,0 +1,34 @@
# Beta stack (beta.worship.hesed.sbs). Separate from prod on purpose: own named
# volumes (no shared DB or media drive), no hardcoded Traefik labels (Dokploy
# injects the domain routing), no lyrics/piano workers.
services:
ytplayer:
build: .
restart: unless-stopped
volumes:
- ytplayer-beta-data:/app/data
environment:
PORT: "3000"
DB_PATH: "/app/data/ytplayer.db"
MEDIA_DIR: "/app/data/media"
UPLOAD_DIR: "/app/data/uploads"
MEDIA_CACHE_MAX_BYTES: "10737418240"
APP_VERSION: "1.0.0-beta"
ADMIN_PASSWORD: "${ADMIN_PASSWORD:-}"
P2P_ENABLED: "${P2P_ENABLED:-0}"
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:3000/api/version"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
networks:
- dokploy-network
volumes:
ytplayer-beta-data:
driver: local
networks:
dokploy-network:
external: true

View File

@@ -321,7 +321,7 @@
<!-- ============ sign-in ============ --> <!-- ============ sign-in ============ -->
<div id="loginBox" class="gate hidden"> <div id="loginBox" class="gate hidden">
<div class="brand"><i><svg viewBox="0 0 24 24"><path d="M9 18V6l10-2v12"/><circle cx="6.5" cy="18" r="2.5"/><circle cx="16.5" cy="16" r="2.5"/></svg></i>Lyrics Studio</div> <a class="brand" href="/admin#editor" data-home style="text-decoration:none;color:inherit"><i><svg viewBox="0 0 24 24"><path d="M9 18V6l10-2v12"/><circle cx="6.5" cy="18" r="2.5"/><circle cx="16.5" cy="16" r="2.5"/></svg></i>Lyrics Studio</a>
<div class="card"> <div class="card">
<h2>Sign in</h2> <h2>Sign in</h2>
<p class="muted">Admin password</p> <p class="muted">Admin password</p>
@@ -340,7 +340,7 @@
<div id="app" class="hidden"> <div id="app" class="hidden">
<header class="top"> <header class="top">
<div class="brand"><i><svg viewBox="0 0 24 24"><path d="M9 18V6l10-2v12"/><circle cx="6.5" cy="18" r="2.5"/><circle cx="16.5" cy="16" r="2.5"/></svg></i>Lyrics Studio</div> <a class="brand" href="/admin#editor" data-home style="text-decoration:none;color:inherit"><i><svg viewBox="0 0 24 24"><path d="M9 18V6l10-2v12"/><circle cx="6.5" cy="18" r="2.5"/><circle cx="16.5" cy="16" r="2.5"/></svg></i>Lyrics Studio</a>
<nav class="tabs" role="tablist" id="tabsTop"></nav> <nav class="tabs" role="tablist" id="tabsTop"></nav>
<span class="grow"></span> <span class="grow"></span>
<a class="ghost desk" href="/">← Back to app</a> <a class="ghost desk" href="/">← Back to app</a>
@@ -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())
@@ -1295,7 +1358,9 @@
} }
function seek(t) { if (!LE.audio) return; LE.audio.currentTime = Math.max(0, Math.min(LE.dur || 1e9, t)); tick(); } function seek(t) { if (!LE.audio) return; LE.audio.currentTime = Math.max(0, Math.min(LE.dur || 1e9, t)); tick(); }
function stamp(i) { function stamp(i) {
if (!LE.audio || i < 0 || i >= LE.lines.length) return; if (i < 0 || i >= LE.lines.length) return;
if (!LE.audio) { say('The audio is still loading — try again in a moment.', 'err'); return; }
if (LE.sel !== i) select(i);
edit(() => { LE.lines[i].t = r2(cur()); }); edit(() => { LE.lines[i].t = r2(cur()); });
paintRow(i); progress(); lastIdx = -2; tick(); tapLabel(); paintRow(i); progress(); lastIdx = -2; tick(); tapLabel();
} }
@@ -1304,7 +1369,7 @@
$('lines').addEventListener('click', async (e) => { $('lines').addEventListener('click', async (e) => {
const row = e.target.closest('.ln'); if (!row) return; const row = e.target.closest('.ln'); if (!row) return;
const i = Number(row.dataset.i); const i = Number(row.dataset.i);
if (LE.sel !== i) select(i); if (LE.sel !== i || !row.classList.contains('sel')) select(i);
const btn = e.target.closest('button[data-act]'); if (!btn) return; const btn = e.target.closest('button[data-act]'); if (!btn) return;
const act = btn.dataset.act; const act = btn.dataset.act;
if (act === 'chip') { if (act === 'chip') {
@@ -1357,6 +1422,13 @@
$('bUndo').addEventListener('click', doUndo); $('bUndo').addEventListener('click', doUndo);
$('bRedo').addEventListener('click', doRedo); $('bRedo').addEventListener('click', doRedo);
$('bTools').addEventListener('click', () => { const on = $('drawer').classList.toggle('hidden') === false; $('bTools').setAttribute('aria-pressed', String(on)); }); $('bTools').addEventListener('click', () => { const on = $('drawer').classList.toggle('hidden') === false; $('bTools').setAttribute('aria-pressed', String(on)); });
document.querySelectorAll('[data-home]').forEach((a) => a.addEventListener('click', (e) => {
e.preventDefault();
if (!$('app').classList.contains('hidden')) {
if (LE.loaded && !$('studio').classList.contains('hidden')) $('closeSong').click();
showTab('editor');
} else location.href = '/admin#editor';
}));
$('closeSong').addEventListener('click', () => { $('closeSong').addEventListener('click', () => {
if (LE.dirty && !confirm('Discard the unsaved changes to this song?')) return; if (LE.dirty && !confirm('Discard the unsaved changes to this song?')) return;
if (LE.audio) { LE.audio.pause(); LE.audio = null; } if (LE.audio) { LE.audio.pause(); LE.audio = null; }

View File

@@ -657,6 +657,7 @@ const DEFAULT_SETTINGS = {
quality: 'auto', volume: 1, audioOnly: false, autoPreload: true, quality: 'auto', volume: 1, audioOnly: false, autoPreload: true,
p2pShare: true, // P2P (docs/p2p-architecture.md): share my saved videos with other devices — ON by default p2pShare: true, // P2P (docs/p2p-architecture.md): share my saved videos with other devices — ON by default
p2pReceive: true, // fetch from other devices when YouTube and the server can't — ON by default p2pReceive: true, // fetch from other devices when YouTube and the server can't — ON by default
autoSavePlayed: true, // keep every video you play for offline (background save once playback starts)
saveBeforePlay: false, // download (server-muxed single file) before playing instead of streaming saveBeforePlay: false, // download (server-muxed single file) before playing instead of streaming
repeatMode: 'off', // 'off' | 'all' — repeat the playing list when it ends repeatMode: 'off', // 'off' | 'all' — repeat the playing list when it ends
loopOne: false, // repeat the single current video loopOne: false, // repeat the single current video
@@ -1999,6 +2000,20 @@ function markCardCacheState(id, state) {
}); });
} }
// Auto-save what you play: once a video starts playing, save it in the
// background (never delays playback). One attempt per video per page session.
const autoSaveTried = new Set();
document.addEventListener('play', () => {
const m = current && current.meta;
if (!m || !m.id || m.custom || current.localUrl) return;
if (data.settings.autoSavePlayed === false) return;
if (!WEB) return;
if (cachedIds.has(m.id) || downloading.has(m.id) || autoSaveTried.has(m.id)) return;
autoSaveTried.add(m.id);
preload(m, { quiet: true });
}, true);
// ============================================================================ // ============================================================================
// Video editor — cut parts out of a video and save a custom offline copy // Video editor — cut parts out of a video and save a custom offline copy
// //

View File

@@ -911,6 +911,13 @@ async function renderSettings() {
<div class="set-group"> <div class="set-group">
<div class="set-group-title">Offline cache</div> <div class="set-group-title">Offline cache</div>
<div class="set-row"><span>App &amp; features</span><small id="offlineAssetStatus" role="status">Checking offline readiness…</small></div> <div class="set-row"><span>App &amp; features</span><small id="offlineAssetStatus" role="status">Checking offline readiness…</small></div>
<label class="set-row">
<span>
Auto-save videos I play
<small>Every video you open is kept on this device in the background, so it plays offline later.</small>
</span>
<input id="setAutoSavePlayed" type="checkbox" ${data.settings.autoSavePlayed !== false ? 'checked' : ''} />
</label>
<label class="set-row"> <label class="set-row">
<span> <span>
Auto-save playlist videos Auto-save playlist videos
@@ -1301,6 +1308,10 @@ async function renderSettings() {
persist(); persist();
Notes.refreshFlags(); Notes.refreshFlags();
}); });
$('setAutoSavePlayed').addEventListener('change', (e) => {
data.settings.autoSavePlayed = e.target.checked;
persist();
});
$('setAutoPreload').addEventListener('change', (e) => { $('setAutoPreload').addEventListener('change', (e) => {
data.settings.autoPreload = e.target.checked; data.settings.autoPreload = e.target.checked;
persist(); persist();

View File

@@ -94,7 +94,7 @@ execFileSync('ffmpeg', ['-y', '-i', mp4, '-vn', '-c:a', 'copy', path.join(media,
await page.waitForFunction(key => JSON.parse(localStorage.getItem('_ytpdata')).settings[key] === true, key); await page.waitForFunction(key => JSON.parse(localStorage.getItem('_ytpdata')).settings[key] === true, key);
if (scenario === 'settings-save-first') await page.evaluate(clip => { Player.loadVideo(clip); }, clip); if (scenario === 'settings-save-first') await page.evaluate(clip => { Player.loadVideo(clip); }, clip);
} }
const expected = !['open-default', 'open-audio-only', 'open-prefer-stream', 'playlist-boot-off'].includes(scenario); const expected = !['playlist-boot-off'].includes(scenario);
if (supported && expected) await page.waitForFunction(id => cachedIds.has(id), clip.id, { timeout: 15000 }); if (supported && expected) await page.waitForFunction(id => cachedIds.has(id), clip.id, { timeout: 15000 });
else await page.waitForTimeout(1000); else await page.waitForTimeout(1000);
const state = await page.evaluate(async id => ({ cached: cachedIds.has(id), files: OPFS.isSupported() ? await OPFS.listVideos() : [], queue: SaveQueue.pending(), calls: window.__saveCalls }), clip.id); const state = await page.evaluate(async id => ({ cached: cachedIds.has(id), files: OPFS.isSupported() ? await OPFS.listVideos() : [], queue: SaveQueue.pending(), calls: window.__saveCalls }), clip.id);

View File

@@ -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);
});