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