Add floating synced lyrics with picture-in-picture fallback
This commit is contained in:
@@ -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();
|
||||||
|
|||||||
@@ -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
40
frontend/lyrics-window.js
Normal 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);
|
||||||
4
frontend/lyrics-window.test.js
Normal file
4
frontend/lyrics-window.test.js
Normal 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']); });
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user