From b72d2e36a6abcefa319ff52e7dae362d2a2de3e9 Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Sat, 3 Oct 2026 14:54:23 +0800 Subject: [PATCH] Add floating synced lyrics with picture-in-picture fallback --- frontend/app.js | 3 ++- frontend/index.html | 1 + frontend/lyrics-window.js | 40 +++++++++++++++++++++++++++++++++ frontend/lyrics-window.test.js | 4 ++++ frontend/sw.js | 1 + tests/settings-sections.spec.js | 11 +++++++++ 6 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 frontend/lyrics-window.js create mode 100644 frontend/lyrics-window.test.js diff --git a/frontend/app.js b/frontend/app.js index 7d748b2..38e8e46 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -704,7 +704,7 @@ const runExclusive = (window.AsyncGuard && window.AsyncGuard.runExclusive) }); // ---------- DOM ---------- -const $ = (id) => document.getElementById(id); +const $ = (id) => document.getElementById(id) || window.LyricsWindow?.element(id); const els = { video: $('video'), audio: $('audio'), @@ -12445,6 +12445,7 @@ async function boot() { Remote.boot(); Transition.renderButton(); 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.apply(); applyAppearance(); diff --git a/frontend/index.html b/frontend/index.html index 980bbf3..41e26fc 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -656,6 +656,7 @@ + diff --git a/frontend/lyrics-window.js b/frontend/lyrics-window.js new file mode 100644 index 0000000..7bc2c02 --- /dev/null +++ b/frontend/lyrics-window.js @@ -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); diff --git a/frontend/lyrics-window.test.js b/frontend/lyrics-window.test.js new file mode 100644 index 0000000..887a63c --- /dev/null +++ b/frontend/lyrics-window.test.js @@ -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']); }); diff --git a/frontend/sw.js b/frontend/sw.js index 2cecaf4..3c7a481 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -85,6 +85,7 @@ const SHELL = [ '/settings-sections.js', '/downloads.js', '/party-dj.js', + '/lyrics-window.js', '/settings-sections.css', '/app.js', '/manifest.webmanifest', diff --git a/tests/settings-sections.spec.js b/tests/settings-sections.spec.js index 5cef28d..4242ee8 100644 --- a/tests/settings-sections.spec.js +++ b/tests/settings-sections.spec.js @@ -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); }); } +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); +});