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.
|
(lastIndex) — `cleanTitle` keeps a stateless copy.
|
||||||
|
|
||||||
## Watch party, sharing, playback extras (all in app.js unless noted)
|
## 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
|
- **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).
|
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
|
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');
|
panel.classList.add('hidden');
|
||||||
renderSeekMarkers();
|
renderSeekMarkers();
|
||||||
renderService(true);
|
renderService(true);
|
||||||
|
renderStage(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const id = meta.id;
|
const id = meta.id;
|
||||||
@@ -2462,6 +2463,7 @@ const Notes = (() => {
|
|||||||
function renderAll() {
|
function renderAll() {
|
||||||
renderLyrics();
|
renderLyrics();
|
||||||
renderService(true);
|
renderService(true);
|
||||||
|
renderStage(true);
|
||||||
renderMarkers();
|
renderMarkers();
|
||||||
renderSeekMarkers();
|
renderSeekMarkers();
|
||||||
renderOffset();
|
renderOffset();
|
||||||
@@ -2576,6 +2578,7 @@ const Notes = (() => {
|
|||||||
|
|
||||||
function tick(cur, force = false) {
|
function tick(cur, force = false) {
|
||||||
tickService(cur, force);
|
tickService(cur, force);
|
||||||
|
tickStage(cur, force);
|
||||||
if (!st.videoId || panel.classList.contains('hidden')) return;
|
if (!st.videoId || panel.classList.contains('hidden')) return;
|
||||||
const dur = (Player.master && Player.master.duration) || 0;
|
const dur = (Player.master && Player.master.duration) || 0;
|
||||||
if (dur && isFinite(dur) && Math.abs(dur - st.lastDur) > 0.5) renderSeekMarkers();
|
if (dur && isFinite(dur) && Math.abs(dur - st.lastDur) > 0.5) renderSeekMarkers();
|
||||||
@@ -3117,6 +3120,101 @@ const Notes = (() => {
|
|||||||
|
|
||||||
function serviceRefresh() { renderService(true); }
|
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', () => {
|
$('serviceLyricsBtn').addEventListener('click', () => {
|
||||||
data.settings.serviceLyrics = !data.settings.serviceLyrics;
|
data.settings.serviceLyrics = !data.settings.serviceLyrics;
|
||||||
// Lyrics fill the screen; the inline video panel would squeeze them.
|
// 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);
|
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 ----------
|
// ---------- 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 setStatus(t) { const el = $('ptStatus'); if (el) el.textContent = t; }
|
||||||
function sysMsg(text) { st.chat.push({ sys: true, text, at: Date.now() }); renderChat(); }
|
function sysMsg(text) { st.chat.push({ sys: true, text, at: Date.now() }); renderChat(); }
|
||||||
function link() { return `${location.origin}/?party=${st.code}`; }
|
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>' : ''}
|
${!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>
|
<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>` : ''}
|
${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">
|
<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>
|
<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>` : ''}
|
${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 === 'resync') resync();
|
||||||
else if (act === 'voice') toggleVoice();
|
else if (act === 'voice') toggleVoice();
|
||||||
else if (act === 'mute') toggleMute();
|
else if (act === 'mute') toggleMute();
|
||||||
|
else if (act === 'lyrics') { Notes.stageLyrics.set(!lyricsOn()); renderLyricsToggle(); }
|
||||||
else if (act === 'leave') leave(true);
|
else if (act === 'leave') leave(true);
|
||||||
});
|
});
|
||||||
panel.addEventListener('change', (e) => {
|
panel.addEventListener('change', (e) => {
|
||||||
@@ -5779,7 +5892,7 @@ const Party = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
boot, guestActive, intercept, afterLoad: () => pushState(true),
|
boot, guestActive, intercept, renderLyricsToggle, afterLoad: () => pushState(true),
|
||||||
state: () => ({
|
state: () => ({
|
||||||
role: st.role, code: st.code, detached: st.detached, members: st.members.length, voice: st.voice.on,
|
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),
|
peers: st.voice.pcs.size, peerStates: [...st.voice.pcs.values()].map((pc) => pc.connectionState),
|
||||||
|
|||||||
@@ -108,6 +108,14 @@
|
|||||||
<div class="player-stage">
|
<div class="player-stage">
|
||||||
<video id="video" playsinline></video>
|
<video id="video" playsinline></video>
|
||||||
<audio id="audio"></audio>
|
<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">
|
<div id="artFallback" class="art-fallback hidden">
|
||||||
<img id="artImg" alt="" />
|
<img id="artImg" alt="" />
|
||||||
</div>
|
</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;
|
position: absolute; left: 4px; bottom: 4px; padding: 1px 6px; border-radius: 6px;
|
||||||
background: rgba(0, 0, 0, 0.6); color: #fff; font-size: 11px;
|
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