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
+98
-3
No files matched your search
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: [],
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
Reference in new issue
Block a user