Show lyrics in place of the video for watch parties and sing-alongs
This commit is contained in:
117
frontend/app.js
117
frontend/app.js
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user