feat: synced lyrics view in Now Playing overlay

Adds a Lyrics tab to the NP overlay using Navidrome's getLyricsBySongId
endpoint. Synced lyrics auto-scroll and highlight the current line as
the track plays; tapping a line seeks to that position. Falls back to
plain text for unsynced lyrics.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-04-26 18:42:45 +02:00
1 parent 93b859bf0e
commit fbabdf8269
6 files changed
+99 -4

No files matched your search

+6
View File
@@ -16,6 +16,12 @@ export async function api(method, extra='') {
return r;
}
export function streamUrl(id) { return apiUrl('stream', `&id=${id}&format=raw`); }
export async function getLyricsBySongId(songId) {
try {
const r = await api('getLyricsBySongId', `&id=${songId}`);
return r.lyricsList?.structuredLyrics ?? [];
} catch { return []; }
}
export function downloadUrl(id) { return apiUrl('download', `&id=${id}`); }
export function coverUrl(id, size=300) { return id ? apiUrl('getCoverArt', `&id=${id}&size=${size}`) : null; }
+4 -1
View File
@@ -1,8 +1,9 @@
import { S } from './state.js';
import { esc, formatDur, toast } from './utils.js';
import { api, streamUrl, coverUrl, downloadUrl } from './api.js';
// views.js circular import — safe because only used inside function bodies
// views.js/ui.js circular imports — safe (only used inside function bodies)
import { loadRecommendations } from './views.js';
import { updateLyricsHighlight, onNpSongChange } from './ui.js';
// ─── PLAYBACK ───
export function playQueue(songs, startIdx) {
@@ -19,6 +20,7 @@ export function playCurrent() {
S.audio.src = streamUrl(s.id);
S.audio.play();
updatePlayerUI(s);
onNpSongChange(s.id);
renderQueue();
}
@@ -153,6 +155,7 @@ export function updateProgress() {
if(fill) fill.style.width = p + '%';
if(cur) cur.textContent = formatDur(S.audio.currentTime);
if(tot) tot.textContent = formatDur(S.audio.duration);
if(S.npLyricsMode) updateLyricsHighlight(S.audio.currentTime * 1000);
}
}
+1
View File
@@ -18,6 +18,7 @@ export const S = {
// Now Playing overlay
npOpen: false,
npQueueMode: false,
npLyricsMode: false,
// Navigation history
_navStack: [],
+76 -3
View File
@@ -458,11 +458,84 @@ export function syncNowPlaying() {
}
export function switchNpTab(tab) {
S.npQueueMode = tab==='queue';
document.getElementById('np-overlay').classList.toggle('queue-mode', S.npQueueMode);
document.getElementById('np-tab-player').classList.toggle('active', !S.npQueueMode);
S.npQueueMode = tab === 'queue';
S.npLyricsMode = tab === 'lyrics';
const overlay = document.getElementById('np-overlay');
overlay.classList.toggle('queue-mode', S.npQueueMode);
overlay.classList.toggle('lyrics-mode', S.npLyricsMode);
document.getElementById('np-tab-player').classList.toggle('active', tab === 'player');
document.getElementById('np-tab-lyrics').classList.toggle('active', S.npLyricsMode);
document.getElementById('np-tab-queue').classList.toggle('active', S.npQueueMode);
if(S.npQueueMode) renderNpQueue();
if(S.npLyricsMode) { const s = S.queue[S.qIdx]; if(s) _loadNpLyrics(s.id); }
}
// ─── LYRICS ───
let _lyricsLines = [];
let _lyricsSongId = null;
async function _loadNpLyrics(songId) {
const content = document.getElementById('np-lyrics-content');
if(!content) return;
if(_lyricsSongId === songId && _lyricsLines.length) {
updateLyricsHighlight(S.audio.currentTime * 1000);
return;
}
_lyricsSongId = songId;
_lyricsLines = [];
content.innerHTML = '<div style="text-align:center;padding:60px 0;color:rgba(255,255,255,.3);font-size:14px">Lade Lyrics…</div>';
const { getLyricsBySongId } = await import('./api.js');
const lyrics = await getLyricsBySongId(songId);
if(!lyrics.length) {
content.innerHTML = '<div style="text-align:center;padding:60px 0;color:rgba(255,255,255,.3);font-size:14px">Keine Lyrics verfügbar</div>';
return;
}
const track = lyrics[0];
content.innerHTML = '';
if(track.synced && track.line?.length) {
const spacer = document.createElement('div');
spacer.style.cssText = 'height:40vh;flex-shrink:0';
content.appendChild(spacer);
_lyricsLines = track.line.map(line => {
const el = document.createElement('div');
el.className = 'lyrics-line';
el.textContent = line.value || '';
if(line.value) el.onclick = () => { S.audio.currentTime = line.start / 1000; };
content.appendChild(el);
return { start: line.start, el };
});
const endSpacer = document.createElement('div');
endSpacer.style.cssText = 'height:40vh;flex-shrink:0';
content.appendChild(endSpacer);
updateLyricsHighlight(S.audio.currentTime * 1000);
} else if(track.line?.length) {
const el = document.createElement('div');
el.className = 'lyrics-plain';
el.textContent = track.line.map(l => l.value).join('\n');
content.appendChild(el);
}
}
export function updateLyricsHighlight(currentMs) {
if(!_lyricsLines.length || !S.npLyricsMode) return;
let activeIdx = -1;
for(let i = 0; i < _lyricsLines.length; i++) {
if(_lyricsLines[i].start <= currentMs) activeIdx = i;
else break;
}
_lyricsLines.forEach((line, i) => {
line.el.classList.toggle('active', i === activeIdx);
line.el.classList.toggle('past', i < activeIdx);
});
if(activeIdx >= 0) _lyricsLines[activeIdx].el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
export function onNpSongChange(songId) {
_lyricsSongId = null;
_lyricsLines = [];
const content = document.getElementById('np-lyrics-content');
if(content) content.innerHTML = '';
if(S.npLyricsMode) _loadNpLyrics(songId);
}
export function renderNpQueue() {