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

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