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:
1 parent
93b859bf0e
commit
fbabdf8269
6 files changed
+99
-4
No files matched your search
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export const S = {
|
||||
// Now Playing overlay
|
||||
npOpen: false,
|
||||
npQueueMode: false,
|
||||
npLyricsMode: false,
|
||||
|
||||
// Navigation history
|
||||
_navStack: [],
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user