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
+98 -3

No files matched your search

+5
View File
@@ -557,6 +557,7 @@
</button> </button>
<div class="np-tabs"> <div class="np-tabs">
<button class="np-tab active" id="np-tab-player" onclick="switchNpTab('player')">Jetzt läuft</button> <button class="np-tab active" id="np-tab-player" onclick="switchNpTab('player')">Jetzt läuft</button>
<button class="np-tab" id="np-tab-lyrics" onclick="switchNpTab('lyrics')">Lyrics</button>
<button class="np-tab" id="np-tab-queue" onclick="switchNpTab('queue')">Warteschlange</button> <button class="np-tab" id="np-tab-queue" onclick="switchNpTab('queue')">Warteschlange</button>
</div> </div>
<button class="np-menu-btn" onclick="openNpMenu()" title="Optionen"> <button class="np-menu-btn" onclick="openNpMenu()" title="Optionen">
@@ -624,6 +625,10 @@
</div> </div>
</div><!-- /np-player-view --> </div><!-- /np-player-view -->
<div id="np-lyrics-view">
<div id="np-lyrics-content"></div>
</div>
<div id="np-queue-view"> <div id="np-queue-view">
<div id="np-queue-list"></div> <div id="np-queue-list"></div>
</div> </div>
+6
View File
@@ -16,6 +16,12 @@ export async function api(method, extra='') {
return r; return r;
} }
export function streamUrl(id) { return apiUrl('stream', `&id=${id}&format=raw`); } 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 downloadUrl(id) { return apiUrl('download', `&id=${id}`); }
export function coverUrl(id, size=300) { return id ? apiUrl('getCoverArt', `&id=${id}&size=${size}`) : null; } 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 { S } from './state.js';
import { esc, formatDur, toast } from './utils.js'; import { esc, formatDur, toast } from './utils.js';
import { api, streamUrl, coverUrl, downloadUrl } from './api.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 { loadRecommendations } from './views.js';
import { updateLyricsHighlight, onNpSongChange } from './ui.js';
// ─── PLAYBACK ─── // ─── PLAYBACK ───
export function playQueue(songs, startIdx) { export function playQueue(songs, startIdx) {
@@ -19,6 +20,7 @@ export function playCurrent() {
S.audio.src = streamUrl(s.id); S.audio.src = streamUrl(s.id);
S.audio.play(); S.audio.play();
updatePlayerUI(s); updatePlayerUI(s);
onNpSongChange(s.id);
renderQueue(); renderQueue();
} }
@@ -153,6 +155,7 @@ export function updateProgress() {
if(fill) fill.style.width = p + '%'; if(fill) fill.style.width = p + '%';
if(cur) cur.textContent = formatDur(S.audio.currentTime); if(cur) cur.textContent = formatDur(S.audio.currentTime);
if(tot) tot.textContent = formatDur(S.audio.duration); 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 // Now Playing overlay
npOpen: false, npOpen: false,
npQueueMode: false, npQueueMode: false,
npLyricsMode: false,
// Navigation history // Navigation history
_navStack: [], _navStack: [],
+75 -2
View File
@@ -459,10 +459,83 @@ export function syncNowPlaying() {
export function switchNpTab(tab) { export function switchNpTab(tab) {
S.npQueueMode = tab === 'queue'; S.npQueueMode = tab === 'queue';
document.getElementById('np-overlay').classList.toggle('queue-mode', S.npQueueMode); S.npLyricsMode = tab === 'lyrics';
document.getElementById('np-tab-player').classList.toggle('active', !S.npQueueMode); 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); document.getElementById('np-tab-queue').classList.toggle('active', S.npQueueMode);
if(S.npQueueMode) renderNpQueue(); 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() { export function renderNpQueue() {
+7
View File
@@ -615,6 +615,13 @@ nav{flex:1;overflow-y:auto;padding:4px 10px}
#np-queue-view{display:none;flex-direction:column;width:100%;flex:1;overflow-y:auto;padding-top:8px} #np-queue-view{display:none;flex-direction:column;width:100%;flex:1;overflow-y:auto;padding-top:8px}
.np-overlay.queue-mode #np-player-view{display:none} .np-overlay.queue-mode #np-player-view{display:none}
.np-overlay.queue-mode #np-queue-view{display:flex} .np-overlay.queue-mode #np-queue-view{display:flex}
#np-lyrics-view{display:none;flex-direction:column;width:100%;flex:1;overflow-y:auto;padding:16px 28px 48px;scroll-behavior:smooth}
.np-overlay.lyrics-mode #np-player-view{display:none}
.np-overlay.lyrics-mode #np-lyrics-view{display:flex}
.lyrics-line{font-size:16px;line-height:1.75;color:rgba(255,255,255,.22);transition:color .35s,font-size .35s,font-weight .2s;padding:2px 0;cursor:pointer;text-align:center}
.lyrics-line.past{color:rgba(255,255,255,.45)}
.lyrics-line.active{color:#fff;font-size:21px;font-weight:600}
.lyrics-plain{font-size:15px;line-height:1.9;color:rgba(255,255,255,.65);white-space:pre-wrap;text-align:center}
.np-queue-item{display:flex;align-items:center;gap:10px;padding:9px 6px;border-radius:10px;cursor:pointer;transition:background .15s} .np-queue-item{display:flex;align-items:center;gap:10px;padding:9px 6px;border-radius:10px;cursor:pointer;transition:background .15s}
.np-queue-item:hover{background:rgba(255,255,255,.06)} .np-queue-item:hover{background:rgba(255,255,255,.06)}
.np-queue-item.nq-active .nqi-title{color:var(--accent)} .np-queue-item.nq-active .nqi-title{color:var(--accent)}