Add floating synced lyrics with picture-in-picture fallback

This commit is contained in:
Jonathan Sykes
2026-10-03 14:54:23 +08:00
parent d7721045fb
commit b72d2e36a6
6 changed files with 59 additions and 1 deletions

View File

@@ -704,7 +704,7 @@ const runExclusive = (window.AsyncGuard && window.AsyncGuard.runExclusive)
}); });
// ---------- DOM ---------- // ---------- DOM ----------
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id) || window.LyricsWindow?.element(id);
const els = { const els = {
video: $('video'), video: $('video'),
audio: $('audio'), audio: $('audio'),
@@ -12445,6 +12445,7 @@ async function boot() {
Remote.boot(); Remote.boot();
Transition.renderButton(); Transition.renderButton();
PiP.render(); PiP.render();
LyricsWindow.configure({ prepare: () => { if (!isServiceModeOpen()) Notes.stageLyrics.set(true); }, view: () => isServiceModeOpen() && data.settings.serviceLyrics ? $('serviceLyrics') : $('stageLyrics'), line: () => $('serviceLyricsList')?.querySelector('.active')?.textContent || $('stageLyricsList')?.querySelector('.active')?.textContent || current?.meta?.title || 'Nothing playing', toggle: () => { Player.toggle(); updatePlayBtn(); }, next: playNext, refresh: () => { Notes.stageLyrics.render(true); Notes.serviceRefresh(); }, toast });
EQ.renderButton(); EQ.renderButton();
EQ.apply(); EQ.apply();
applyAppearance(); applyAppearance();

View File

@@ -656,6 +656,7 @@
<script src="settings-sections.js"></script> <script src="settings-sections.js"></script>
<script src="downloads.js"></script> <script src="downloads.js"></script>
<script src="party-dj.js"></script> <script src="party-dj.js"></script>
<script src="lyrics-window.js"></script>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
</html> </html>

40
frontend/lyrics-window.js Normal file
View File

@@ -0,0 +1,40 @@
/* Floating synced lyrics; opt-in and feature detected. */
(function(root) {
'use strict';
function support(environment) { return environment.documentPiP ? 'document' : environment.canvas && environment.videoPiP ? 'video' : null; }
function wrap(text, measure, width) { const result = []; for (const paragraph of String(text || '').split('\n')) { let line = ''; for (const word of paragraph.split(/\s+/)) { const next = line ? `${line} ${word}` : word; if (line && measure(next) > width) { result.push(line); line = word; } else line = next; } result.push(line); } return result; }
let pipWindow = null, cleanup = () => {}, adapter;
const mode = () => support({ documentPiP: !!root.documentPictureInPicture?.requestWindow, canvas: !!root.HTMLCanvasElement?.prototype.captureStream, videoPiP: root.document?.pictureInPictureEnabled && !!root.HTMLVideoElement?.prototype.requestPictureInPicture });
async function open() {
if (pipWindow || root.document.pictureInPictureElement?.dataset.lyricsPip) { cleanup(); return; }
if (!mode()) return;
try {
adapter.prepare();
if (mode() === 'document') {
pipWindow = await root.documentPictureInPicture.requestWindow({ width: 420, height: 500 });
const doc = pipWindow.document; doc.documentElement.dataset.layout = root.document.documentElement.dataset.layout; doc.documentElement.dataset.theme = root.document.documentElement.dataset.theme;
for (const link of root.document.querySelectorAll('link[rel="stylesheet"]')) { const copy = doc.createElement('link'); copy.rel = 'stylesheet'; copy.href = link.href; doc.head.append(copy); }
const style = doc.createElement('style'); style.textContent = 'body{margin:0;background:var(--bg,#151719);color:var(--text,#fff);display:flex;flex-direction:column;height:100vh;overflow:hidden}.lyrics-floating-view{position:relative!important;inset:auto!important;flex:1;min-height:0;display:flex!important;flex-direction:column!important;width:100%!important}.lyrics-floating-view .sl-list{flex:1;min-height:0;height:auto!important}.lyrics-floating-controls{display:flex;gap:8px;padding:12px}.lyrics-floating-controls button{flex:1;min-height:48px;background:var(--bg-2,#25292d);color:inherit;border:1px solid var(--line,#555);border-radius:8px}'; doc.head.append(style);
const view = adapter.view(), marker = root.document.createComment('floating lyrics'); view.before(marker); view.classList.add('lyrics-floating-view'); doc.body.append(view);
const controls = doc.createElement('div'); controls.className = 'lyrics-floating-controls';
for (const [label, action] of [['Play / Pause', adapter.toggle], ['Next', adapter.next]]) { const b = doc.createElement('button'); b.type = 'button'; b.textContent = label; b.onclick = action; controls.append(b); } doc.body.append(controls);
const win = pipWindow; let restored = false;
cleanup = () => { if (restored) return; restored = true; marker.replaceWith(view); view.classList.remove('lyrics-floating-view'); pipWindow = null; adapter.refresh(); if (!win.closed) win.close(); };
win.addEventListener('pagehide', cleanup, { once: true }); win.addEventListener('resize', adapter.refresh); adapter.refresh();
} else {
const canvas = root.document.createElement('canvas'); canvas.width = 840; canvas.height = 500; const ctx = canvas.getContext('2d');
const draw = () => { ctx.fillStyle = '#141619'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = '#fff'; ctx.font = '32px system-ui'; ctx.textAlign = 'center'; const lines = wrap(adapter.line(), t => ctx.measureText(t).width, 760); lines.slice(0, 8).forEach((line, i) => ctx.fillText(line, 420, 250 + (i - (Math.min(lines.length, 8) - 1) / 2) * 40)); };
draw(); const stream = canvas.captureStream(10), video = root.document.createElement('video'); video.dataset.lyricsPip = '1'; video.muted = true; video.playsInline = true; video.srcObject = stream; video.style.cssText = 'position:fixed;width:1px;height:1px;opacity:0;pointer-events:none'; root.document.body.append(video);
const timer = setInterval(draw, 100); let closed = false;
cleanup = () => { if (closed) return; closed = true; clearInterval(timer); stream.getTracks().forEach(t => t.stop()); video.srcObject = null; video.remove(); if (root.document.pictureInPictureElement === video) root.document.exitPictureInPicture().catch(() => {}); };
video.addEventListener('leavepictureinpicture', cleanup, { once: true }); await video.play(); await video.requestPictureInPicture();
}
} catch (error) { cleanup(); adapter.toast(`Floating lyrics: ${error.message}`); }
}
function configure(options) {
adapter = options; const supported = !!mode();
for (const id of ['stageLyricsAuto', 'serviceAutoscrollBtn', 'pipBtn']) { const anchor = root.document.getElementById(id); if (!anchor || !supported) continue; const button = root.document.createElement('button'); button.type = 'button'; button.className = 'np-btn'; button.textContent = '⧉ Lyrics'; button.title = 'Open floating lyrics'; button.setAttribute('aria-label', 'Open floating lyrics window'); button.onclick = open; anchor.after(button); }
root.SettingsSections.register({ id: 'floating-lyrics', title: 'Floating lyrics', cluster: 'Live & worship', summary: () => mode() === 'document' ? 'Interactive window available' : mode() ? 'Lyric video PiP available' : 'Not supported on this browser', render(container) { const p = container.ownerDocument.createElement('p'); p.textContent = mode() === 'document' ? 'Open synced lyrics with seeking, Play/Pause and Next in an always-on-top window.' : mode() ? 'This browser supports a lyric-video window. Playback controls remain in the player.' : 'Floating lyrics are not supported on this browser.'; container.append(p); if (supported) { const b = container.ownerDocument.createElement('button'); b.type = 'button'; b.className = 'settings-back'; b.textContent = 'Open floating lyrics'; b.onclick = open; container.append(b); } } });
}
const api = { configure, support, wrap, element: id => pipWindow?.document.getElementById(id) || null }; if (typeof module !== 'undefined') module.exports = api; else root.LyricsWindow = api;
})(typeof window !== 'undefined' ? window : globalThis);

View File

@@ -0,0 +1,4 @@
const { test } = require('node:test'), assert = require('node:assert/strict');
const { support, wrap } = require('./lyrics-window');
test('floating lyrics prefer document PiP, fall back only when capture and video PiP work', () => { assert.equal(support({ documentPiP: true }), 'document'); assert.equal(support({ canvas: true, videoPiP: true }), 'video'); assert.equal(support({ canvas: true }), null); });
test('canvas lyric wrapping preserves cue line breaks', () => { assert.deepEqual(wrap('Because You are God\nYou can do anything', t => t.length, 12), ['Because You', 'are God', 'You can do', 'anything']); });

View File

@@ -85,6 +85,7 @@ const SHELL = [
'/settings-sections.js', '/settings-sections.js',
'/downloads.js', '/downloads.js',
'/party-dj.js', '/party-dj.js',
'/lyrics-window.js',
'/settings-sections.css', '/settings-sections.css',
'/app.js', '/app.js',
'/manifest.webmanifest', '/manifest.webmanifest',

View File

@@ -22,3 +22,14 @@ for (const layout of ['classic', 'glass-stage']) for (const width of [390, 1440]
const overflow = await page.locator('.settings-browser').evaluate(el => el.scrollWidth > el.clientWidth + 1); expect(overflow).toBe(false); const overflow = await page.locator('.settings-browser').evaluate(el => el.scrollWidth > el.clientWidth + 1); expect(overflow).toBe(false);
}); });
} }
test('floating lyrics move the real view and restore it on close', async ({ page }) => {
await page.addInitScript(() => { window.documentPictureInPicture = { requestWindow: async () => window.open('about:blank', 'lyrics-test', 'width=420,height=500') }; });
await openClassic(page);
const popupPromise = page.waitForEvent('popup');
await page.locator('#stageLyricsAuto').evaluate(el => el.nextElementSibling.click());
const popup = await popupPromise;
await expect(popup.locator('#stageLyrics')).toBeVisible();
await expect(popup.getByRole('button', { name: 'Play / Pause', exact: true })).toBeVisible();
await expect(page.locator('#stageLyrics')).toHaveCount(0);
await popup.close(); await expect(page.locator('#stageLyrics')).toHaveCount(1);
});