Show lyrics in place of the video for watch parties and sing-alongs

This commit is contained in:
Jonathan Sykes
2026-09-21 21:03:46 +08:00
parent 0aa6c95494
commit 2a4526578f
4 changed files with 168 additions and 2 deletions

View File

@@ -167,6 +167,15 @@ JSON shapes mirror the Tauri Rust bridge exactly — don't change one side alone
(lastIndex) — `cleanTitle` keeps a stateless copy.
## Watch party, sharing, playback extras (all in app.js unless noted)
- **Lyrics instead of the video** (`Notes.stageLyrics`, `#stageLyrics` inside
`.player-stage`): a per-device view for watch parties and sing-alongs —
`data.settings.stageLyrics` / `stageLyricsAutoscroll`. It reuses the service-mode
machinery (`fitLyricLines`, `LyricsCore.activeIndex`, the same `.sl-line`
classes) and is driven from the normal playback tick. The `<video>` is only
**hidden** (`visibility`), never unloaded, so playback, audio and party sync
carry on untouched — that is why it is safe for a guest to switch views
mid-party. Toggle from the watch-party panel or the bar inside the stage
(hover on desktop, always visible on touch).
- **Watch party** (`server/party.js`, `Party`): `/ws/party` (Bun has ONE websocket
handler — sockets are tagged `ws.data.hub = 'party'` and routed in server.js).
Host drives; guests extrapolate the host's `state` from the server-stamped

View File

@@ -2418,6 +2418,7 @@ const Notes = (() => {
panel.classList.add('hidden');
renderSeekMarkers();
renderService(true);
renderStage(true);
return;
}
const id = meta.id;
@@ -2462,6 +2463,7 @@ const Notes = (() => {
function renderAll() {
renderLyrics();
renderService(true);
renderStage(true);
renderMarkers();
renderSeekMarkers();
renderOffset();
@@ -2576,6 +2578,7 @@ const Notes = (() => {
function tick(cur, force = false) {
tickService(cur, force);
tickStage(cur, force);
if (!st.videoId || panel.classList.contains('hidden')) return;
const dur = (Player.master && Player.master.duration) || 0;
if (dur && isFinite(dur) && Math.abs(dur - st.lastDur) > 0.5) renderSeekMarkers();
@@ -3117,6 +3120,101 @@ const Notes = (() => {
function serviceRefresh() { renderService(true); }
// ---- Lyrics in place of the video (watch party / sing-along) ------------
// Same machinery as service mode, but inside the player stage, so playback
// and party sync carry on untouched: the <video> is only hidden.
const stg = { key: '', idx: -2, timer: null, refit: null };
const stageOn = () => !!data.settings.stageLyrics;
const stageAuto = () => data.settings.stageLyricsAutoscroll !== false;
function renderStageButtons() {
const a = $('stageLyricsAuto');
if (!a) return;
a.textContent = stageAuto() ? '⇅ Auto-scroll on' : '⇅ Auto-scroll off';
a.classList.toggle('on', stageAuto());
a.setAttribute('aria-pressed', String(stageAuto()));
}
function renderStage(force = false) {
const box = $('stageLyrics');
if (!box) return;
const stage = document.querySelector('.player-stage');
const on = stageOn();
box.classList.toggle('hidden', !on);
if (stage) stage.classList.toggle('lyrics-stage', on);
if (typeof Party !== 'undefined' && Party.renderLyricsToggle) Party.renderLyricsToggle();
if (!on) { stg.key = ''; return; }
renderStageButtons();
const list = $('stageLyricsList');
const items = sungLines();
const key = `${st.videoId}|${st.lyrics ? st.lyrics.rev : 0}|${items.length}|${list.clientWidth}x${list.clientHeight}`;
if (key === stg.key && !force) return;
stg.key = key;
stg.idx = -2;
const pad = Math.round(list.clientHeight / 2);
list.style.paddingTop = pad + 'px';
list.style.paddingBottom = pad + 'px';
if (!items.length) {
const title = (current && current.meta && current.meta.title) || '';
list.innerHTML = `<div class="sl-empty">${st.videoId ? 'No lyrics for this song yet' : 'Nothing playing'}${title ? `<small>${escapeHtml(title)}</small>` : ''}</div>`;
return;
}
list.innerHTML = items.map((l) =>
`<div class="sl-line${l.t === null || l.t === undefined ? ' untimed' : ''}" data-i="${l.i}">${escapeHtml(l.text)}</div>`).join('');
fitLyricLines(list, { maxShare: 0.15, maxPx: 96 });
tickStage(now(), true);
clearTimeout(stg.refit);
stg.refit = setTimeout(() => { if (stageOn()) fitLyricLines(list, { maxShare: 0.15, maxPx: 96 }); }, 900);
}
function tickStage(cur, force = false) {
if (!stageOn()) return;
const list = $('stageLyricsList');
if (!list) return;
const idx = LC.activeIndex(lines(), cur, offset());
if (idx === stg.idx && !force) return;
stg.idx = idx;
const rows = [...list.querySelectorAll('.sl-line')];
const pos = rows.findIndex((el) => Number(el.dataset.i) === idx);
rows.forEach((el, k) => {
const d = pos < 0 ? 99 : Math.abs(k - pos);
el.classList.toggle('active', d === 0);
el.classList.toggle('near', d === 1);
el.classList.toggle('past', pos >= 0 && k < pos);
});
if (stageAuto() && rows.length) {
const target = pos >= 0 ? rows[pos] : rows[0];
const go = () => list.scrollTo({ top: Math.max(0, target.offsetTop - list.clientHeight / 2 + target.offsetHeight / 2), behavior: 'smooth' });
go();
clearTimeout(stg.timer);
stg.timer = setTimeout(go, 380);
}
}
function setStageLyrics(on) {
data.settings.stageLyrics = !!on;
persist();
renderStage(true);
}
if ($('stageLyricsOff')) {
$('stageLyricsOff').addEventListener('click', () => setStageLyrics(false));
$('stageLyricsAuto').addEventListener('click', () => {
data.settings.stageLyricsAutoscroll = !stageAuto();
persist();
renderStageButtons();
if (stageAuto()) tickStage(now(), true);
});
// Tapping a line seeks, exactly as in service mode. A party guest seeking
// is handled by Party (it either drives the host or goes "off sync").
$('stageLyricsList').addEventListener('click', (e) => {
const row = e.target.closest('.sl-line');
const l = row && lines()[Number(row.dataset.i)];
if (l && l.t !== null && l.t !== undefined) Player.seek(Math.max(0, l.t + offset()));
});
window.addEventListener('resize', () => { if (stageOn()) renderStage(true); });
}
$('serviceLyricsBtn').addEventListener('click', () => {
data.settings.serviceLyrics = !data.settings.serviceLyrics;
// Lyrics fill the screen; the inline video panel would squeeze them.
@@ -3288,7 +3386,10 @@ const Notes = (() => {
else if (btn.dataset.act === 'del') deleteMarker(kind, i);
});
return { load, tick, addBookmark, addNote, handleSyncKey, serviceRefresh };
return {
load, tick, addBookmark, addNote, handleSyncKey, serviceRefresh,
stageLyrics: { render: renderStage, set: setStageLyrics, on: stageOn },
};
})();
// ============================================================================
@@ -5661,6 +5762,16 @@ const Party = (() => {
}
// ---------- UI ----------
// Lyrics-instead-of-video is a per-device view: each guest chooses it for
// themselves, and the host's playback is untouched (the video keeps playing
// behind the lyrics, so sync and audio carry on).
const lyricsOn = () => !!(Notes.stageLyrics && Notes.stageLyrics.on());
function renderLyricsToggle() {
const b = document.querySelector('#partyPanel .pt-lyrics');
if (!b) return;
b.textContent = lyricsOn() ? '📺 Show the video' : '🎤 Lyrics instead of video';
b.classList.toggle('on', lyricsOn());
}
function setStatus(t) { const el = $('ptStatus'); if (el) el.textContent = t; }
function sysMsg(text) { st.chat.push({ sys: true, text, at: Date.now() }); renderChat(); }
function link() { return `${location.origin}/?party=${st.code}`; }
@@ -5691,6 +5802,7 @@ const Party = (() => {
${!isHost() && st.detached ? '<button class="rv-primary" type="button" data-act="resync">↻ Re-sync with the host</button>' : ''}
<div class="pt-members">${st.members.map((m) => `<span class="pt-member${m.pid === pid ? ' me' : ''}">${m.host ? '👑 ' : ''}${escapeHtml(m.name)}${m.voice ? ' 🎤' : ''}</span>`).join('')}</div>
${isHost() ? `<label class="pt-toggle"><input type="checkbox" data-act="allow" ${st.allowControl ? 'checked' : ''} /> Guests can control playback</label>` : ''}
<button class="np-btn pt-lyrics${lyricsOn() ? ' on' : ''}" type="button" data-act="lyrics">${lyricsOn() ? '📺 Show the video' : '🎤 Lyrics instead of video'}</button>
<div class="pt-voice">
<button class="np-btn${st.voice.on ? ' on' : ''}" type="button" data-act="voice">${st.voice.on ? '🎤 Leave voice' : '🎤 Join voice'}</button>
${st.voice.on ? `<button class="np-btn" type="button" data-act="mute">${st.voice.muted ? '🔇 Unmute' : '🔈 Mute'}</button>` : ''}
@@ -5740,6 +5852,7 @@ const Party = (() => {
else if (act === 'resync') resync();
else if (act === 'voice') toggleVoice();
else if (act === 'mute') toggleMute();
else if (act === 'lyrics') { Notes.stageLyrics.set(!lyricsOn()); renderLyricsToggle(); }
else if (act === 'leave') leave(true);
});
panel.addEventListener('change', (e) => {
@@ -5779,7 +5892,7 @@ const Party = (() => {
}
return {
boot, guestActive, intercept, afterLoad: () => pushState(true),
boot, guestActive, intercept, renderLyricsToggle, afterLoad: () => pushState(true),
state: () => ({
role: st.role, code: st.code, detached: st.detached, members: st.members.length, voice: st.voice.on,
peers: st.voice.pcs.size, peerStates: [...st.voice.pcs.values()].map((pc) => pc.connectionState),

View File

@@ -108,6 +108,14 @@
<div class="player-stage">
<video id="video" playsinline></video>
<audio id="audio"></audio>
<!-- Lyrics shown in place of the video (watch party / sing-along) -->
<div id="stageLyrics" class="stage-lyrics hidden">
<div id="stageLyricsList" class="sl-list"></div>
<div class="stage-lyrics-bar">
<button id="stageLyricsAuto" class="np-btn" type="button" aria-pressed="true">⇅ Auto-scroll on</button>
<button id="stageLyricsOff" class="np-btn" type="button">📺 Show video</button>
</div>
</div>
<div id="artFallback" class="art-fallback hidden">
<img id="artImg" alt="" />
</div>

View File

@@ -4080,3 +4080,39 @@ body.landscape-fs .player-stage { touch-action: none; } /* fullscreen (real or t
position: absolute; left: 4px; bottom: 4px; padding: 1px 6px; border-radius: 6px;
background: rgba(0, 0, 0, 0.6); color: #fff; font-size: 11px;
}
/* --- Lyrics in place of the video (watch party / sing-along) --------------
The stage keeps its 16/9 box; the lyrics list fills it and the video is
hidden rather than unloaded, so playback (and party sync) never stops. */
.player-stage.lyrics-stage > video,
.player-stage.lyrics-stage > .art-fallback { visibility: hidden; }
.stage-lyrics {
position: absolute;
inset: 0;
background: radial-gradient(circle at 50% 35%, var(--bg-2), #000 78%);
z-index: 3;
}
.stage-lyrics .sl-list { padding: 0 2%; }
.stage-lyrics-bar {
position: absolute;
left: 0; right: 0; bottom: 0;
display: flex;
gap: 8px;
justify-content: center;
padding: 8px;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.72));
opacity: 0;
transition: opacity 0.18s var(--ease);
}
.stage-lyrics:hover .stage-lyrics-bar,
.stage-lyrics:focus-within .stage-lyrics-bar,
.stage-lyrics.show-bar .stage-lyrics-bar { opacity: 1; }
.stage-lyrics-bar .np-btn { backdrop-filter: blur(6px); }
.stage-lyrics .sl-empty {
display: flex; flex-direction: column; align-items: center; justify-content: center;
height: 100%; color: var(--text-dim); font-size: 15px; text-align: center; gap: 6px;
}
/* Touch has no hover, so the bar stays visible on phones. */
@media (hover: none) {
.stage-lyrics-bar { opacity: 1; }
}