3 simultaneous requests were hitting lrclib rate limiter. Now: exact match alone → if no content, 2 parallel searches. Also check response.status === 200 before parsing to avoid treating 404/429 error bodies as valid empty lyrics responses. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
346 lines
16 KiB
JavaScript
346 lines
16 KiB
JavaScript
import { S } from '../state.js';
|
|
import { esc } from '../utils.js';
|
|
import { coverUrl } from '../api.js';
|
|
// Circular imports — safe (only used inside function bodies)
|
|
import { playCurrent, _syncNpDirect, dlSongLocal, npToggleStar, _syncOverlayState, isPlaying, seekToMs, updatePlayerUI } from './player.js';
|
|
import { navigate } from '../views/views.js';
|
|
import { openPlModal, showSongInfo } from '../ui/ui.js';
|
|
|
|
// ─── NOW PLAYING OVERLAY ───
|
|
export function openNowPlaying() {
|
|
// Reconcile S.qIdx with native state before rendering.
|
|
// NSBridge.getState() reads a @Volatile snapshot updated synchronously before every callJs(),
|
|
// so it is always fresh even when evaluateJavascript callbacks are still queued (backgrounded app).
|
|
if (typeof window.NSBridge !== 'undefined') {
|
|
try {
|
|
const live = JSON.parse(window.NSBridge.getState() || '{}');
|
|
const liveId = String(live.currentId || '');
|
|
if (liveId && liveId !== String(S.queue[S.qIdx]?.id || '')) {
|
|
const found = S.queue.findIndex(s => String(s.id) === liveId);
|
|
if (found >= 0) {
|
|
S.qIdx = found;
|
|
const s = S.queue[S.qIdx];
|
|
if (s) { updatePlayerUI(s, true); onNpSongChange(s.id); }
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
S.npOpen = true;
|
|
const overlay = document.getElementById('np-overlay');
|
|
overlay.classList.add('open');
|
|
if(S.npQueueMode) switchNpTab('player');
|
|
syncNowPlaying();
|
|
_syncOverlayState();
|
|
if(!overlay._swipeAttached) {
|
|
overlay._swipeAttached = true;
|
|
let startY=0, startT=0, dragging=false;
|
|
const handle = document.getElementById('np-drag-handle');
|
|
function onStart(clientY) { startY=clientY; startT=Date.now(); dragging=true; overlay.style.transition='none'; }
|
|
function onMove(clientY) { if(!dragging) return; const dy=Math.max(0,clientY-startY); overlay.style.transform=`translateY(${dy}px)`; }
|
|
function onEnd(clientY) {
|
|
if(!dragging) return; dragging=false; overlay.style.transition='';
|
|
const dy=clientY-startY, dt=Date.now()-startT;
|
|
if(dt<250&&dy>50 || dy>window.innerHeight*0.3) closeNowPlaying();
|
|
else overlay.style.transform='';
|
|
}
|
|
overlay.addEventListener('touchstart', e => {
|
|
const rect=overlay.getBoundingClientRect();
|
|
if(e.touches[0].clientY-rect.top < 100) onStart(e.touches[0].clientY);
|
|
}, {passive:true});
|
|
overlay.addEventListener('touchmove', e => onMove(e.touches[0].clientY), {passive:true});
|
|
overlay.addEventListener('touchend', e => onEnd(e.changedTouches[0].clientY), {passive:true});
|
|
handle?.addEventListener('mousedown', e => { onStart(e.clientY); e.preventDefault(); });
|
|
document.addEventListener('mousemove', e => onMove(e.clientY));
|
|
document.addEventListener('mouseup', e => onEnd(e.clientY));
|
|
}
|
|
}
|
|
|
|
export function closeNowPlaying() {
|
|
S.npOpen = false;
|
|
const overlay = document.getElementById('np-overlay');
|
|
overlay.style.transform = '';
|
|
overlay.classList.remove('open');
|
|
_syncOverlayState();
|
|
}
|
|
|
|
export function syncNowPlaying() {
|
|
if(!S.npOpen) return;
|
|
const s = S.queue[S.qIdx];
|
|
if(!s) return;
|
|
_syncNpDirect(s);
|
|
const shuffleBtn = document.getElementById('np-shuffle-btn');
|
|
const repeatBtn = document.getElementById('np-repeat-btn');
|
|
const volSlider = document.getElementById('np-vol-slider');
|
|
if(shuffleBtn) shuffleBtn.classList.toggle('active', S.shuffle);
|
|
if(repeatBtn) repeatBtn.classList.toggle('active', S.repeat !== false);
|
|
if(volSlider) volSlider.value = Math.round(Math.sqrt(S.audio.volume) * 100);
|
|
const playing = isPlaying();
|
|
const npIcon = document.getElementById('np-play-icon');
|
|
if(npIcon) npIcon.innerHTML = playing
|
|
? '<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>'
|
|
: '<polygon points="5 3 19 12 5 21 5 3"/>';
|
|
const cover = document.getElementById('np-cover');
|
|
if(cover) cover.classList.toggle('playing', playing);
|
|
}
|
|
|
|
export function switchNpTab(tab) {
|
|
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;
|
|
let _lyricsLoadToken = 0;
|
|
const _lyricsCache = new Map(); // "artist|title" → result object (null = not found)
|
|
|
|
const LYRICS_LOOKAHEAD_MS = 200;
|
|
|
|
|
|
function _renderSyncedLines(content, lines) {
|
|
const top = document.createElement('div');
|
|
top.style.cssText = 'height:40vh;flex-shrink:0';
|
|
content.appendChild(top);
|
|
_lyricsLines = lines.map(line => {
|
|
const el = document.createElement('div');
|
|
el.className = 'lyrics-line';
|
|
el.textContent = line.value;
|
|
el.onclick = () => { seekToMs(line.start); };
|
|
content.appendChild(el);
|
|
return { start: line.start, el };
|
|
});
|
|
const bot = document.createElement('div');
|
|
bot.style.cssText = 'height:40vh;flex-shrink:0';
|
|
content.appendChild(bot);
|
|
updateLyricsHighlight(S.audio.currentTime * 1000);
|
|
}
|
|
|
|
function _parseLrc(text) {
|
|
let offsetMs = 0;
|
|
const lines = [];
|
|
for(const raw of text.split('\n')) {
|
|
const offM = raw.match(/^\[offset:\s*([+-]?\d+)\s*\]/i);
|
|
if(offM) { offsetMs = parseInt(offM[1]); continue; }
|
|
const m = raw.match(/^\[(\d+):(\d+(?:\.\d+)?)\](.*)/);
|
|
if(m) lines.push({ start: Math.round((parseInt(m[1])*60 + parseFloat(m[2]))*1000), value: m[3].trim() });
|
|
}
|
|
if(offsetMs) lines.forEach(l => l.start = Math.max(0, l.start + offsetMs));
|
|
return lines.sort((a,b) => a.start - b.start).filter(l => l.value);
|
|
}
|
|
|
|
async function _loadNpLyrics(songId) {
|
|
const content = document.getElementById('np-lyrics-content');
|
|
if(!content) return;
|
|
if(_lyricsSongId === songId && (_lyricsLines.length || content.querySelector('.lyrics-plain,.lyrics-none'))) {
|
|
if(_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 token = ++_lyricsLoadToken;
|
|
const s = S.queue[S.qIdx];
|
|
const artist = (s?.artist || '').split(/[/,]/)[0].trim();
|
|
const title = s?.title || '';
|
|
const album = s?.album || '';
|
|
const dur = s?.duration || 0;
|
|
const cacheKey = `${artist}|${title}`;
|
|
|
|
const SIG = () => AbortSignal.timeout(6000);
|
|
const safeJson = async (promise) => { try { const r = await promise; return r.ok ? r.json() : null; } catch { return null; } };
|
|
const bestMatch = (candidates, durSecs) => {
|
|
if(!candidates?.length) return null;
|
|
const synced = candidates.filter(c => c.syncedLyrics);
|
|
const pool = synced.length ? synced : candidates;
|
|
if(!durSecs) return pool[0];
|
|
const pick = [...pool].sort((a, b) => Math.abs(a.duration - durSecs) - Math.abs(b.duration - durSecs))[0];
|
|
return Math.abs(pick.duration - durSecs) <= 5 ? pick : (synced[0] || candidates[0]);
|
|
};
|
|
|
|
// Cache hit — instant
|
|
if(_lyricsCache.has(cacheKey)) {
|
|
const cached = _lyricsCache.get(cacheKey);
|
|
content.innerHTML = '';
|
|
_showLyricsResult(content, cached);
|
|
return;
|
|
}
|
|
|
|
// 1. lrclib exact match — single request, only trust HTTP 200
|
|
const exactP = new URLSearchParams({ artist_name: artist, track_name: title });
|
|
if(album) exactP.set('album_name', album);
|
|
if(dur) exactP.set('duration', dur);
|
|
let result = null;
|
|
try {
|
|
const r = await fetch(`https://lrclib.net/api/get?${exactP}`, { signal: SIG() });
|
|
if(r.status === 200) {
|
|
const d = await r.json();
|
|
if(d?.syncedLyrics || d?.plainLyrics || d?.instrumental) result = d;
|
|
}
|
|
} catch {}
|
|
|
|
if(token !== _lyricsLoadToken) return;
|
|
|
|
// 2+3. lrclib search — artist+title and title-only in parallel (2 requests, not 3)
|
|
if(!result) {
|
|
const artistP = new URLSearchParams({ track_name: title });
|
|
if(artist) artistP.set('artist_name', artist);
|
|
const titleP = new URLSearchParams({ track_name: title });
|
|
|
|
const [artistData, titleData] = await Promise.all([
|
|
safeJson(fetch(`https://lrclib.net/api/search?${artistP}`, { signal: SIG() })),
|
|
safeJson(fetch(`https://lrclib.net/api/search?${titleP}`, { signal: SIG() })),
|
|
]);
|
|
result = bestMatch(artistData, dur) ?? bestMatch(titleData, dur);
|
|
}
|
|
|
|
if(token !== _lyricsLoadToken) return;
|
|
|
|
// NetEase fallback — good for Asian/Japanese music
|
|
if(!result?.syncedLyrics && !result?.plainLyrics) {
|
|
try {
|
|
const q = encodeURIComponent(`${artist} ${title}`.trim());
|
|
const sd = await safeJson(fetch(`https://music.163.com/api/search/get?s=${q}&type=1&limit=5`, {
|
|
headers: { Referer: 'https://music.163.com' }, signal: SIG(),
|
|
}));
|
|
const songs = sd?.result?.songs || [];
|
|
const durMs = dur * 1000;
|
|
const song = dur
|
|
? [...songs].sort((a, b) => Math.abs(a.duration - durMs) - Math.abs(b.duration - durMs))
|
|
.find(s => Math.abs(s.duration - durMs) <= 5000)
|
|
: songs[0];
|
|
if(song && token === _lyricsLoadToken) {
|
|
const ld = await safeJson(fetch(
|
|
`https://music.163.com/api/song/lyric?id=${song.id}&lv=1&kv=1&tv=-1`,
|
|
{ headers: { Referer: 'https://music.163.com' }, signal: SIG() },
|
|
));
|
|
const lrc = ld?.lrc?.lyric;
|
|
if(lrc) result = { syncedLyrics: lrc };
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
if(token !== _lyricsLoadToken) return;
|
|
_lyricsCache.set(cacheKey, result ?? null);
|
|
content.innerHTML = '';
|
|
_showLyricsResult(content, result);
|
|
}
|
|
|
|
function _showLyricsResult(content, result) {
|
|
if(result?.syncedLyrics) {
|
|
const parsed = _parseLrc(result.syncedLyrics);
|
|
if(parsed.length) { _renderSyncedLines(content, parsed); return; }
|
|
}
|
|
if(result?.plainLyrics) {
|
|
const el = document.createElement('div');
|
|
el.className = 'lyrics-plain';
|
|
el.textContent = result.plainLyrics;
|
|
content.appendChild(el);
|
|
return;
|
|
}
|
|
const el = document.createElement('div');
|
|
el.className = 'lyrics-none';
|
|
el.style.cssText = 'text-align:center;padding:60px 0;color:rgba(255,255,255,.3);font-size:14px';
|
|
el.textContent = 'Keine Lyrics verfügbar';
|
|
content.appendChild(el);
|
|
}
|
|
|
|
export function updateLyricsHighlight(currentMs) {
|
|
if(!_lyricsLines.length || !S.npLyricsMode) return;
|
|
const adjusted = currentMs + LYRICS_LOOKAHEAD_MS;
|
|
let activeIdx = -1;
|
|
for(let i = 0; i < _lyricsLines.length; i++) {
|
|
if(_lyricsLines[i].start <= adjusted) 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) {
|
|
const activeEl = _lyricsLines[activeIdx].el;
|
|
const container = document.getElementById('np-lyrics-view');
|
|
if(container) {
|
|
const target = activeEl.offsetTop - container.clientHeight / 2 + activeEl.clientHeight / 2;
|
|
container.scrollTo({ top: Math.max(0, target), behavior: 'smooth' });
|
|
}
|
|
}
|
|
}
|
|
|
|
export function onNpSongChange(songId) {
|
|
_lyricsSongId = null;
|
|
_lyricsLines = [];
|
|
const content = document.getElementById('np-lyrics-content');
|
|
if(content) content.innerHTML = '';
|
|
if(S.npLyricsMode) _loadNpLyrics(songId);
|
|
}
|
|
|
|
// ─── NP QUEUE ───
|
|
export function renderNpQueue() {
|
|
const el = document.getElementById('np-queue-list');
|
|
if(!el) return;
|
|
el.innerHTML = '';
|
|
if(!S.queue.length) {
|
|
el.innerHTML = '<div style="text-align:center;padding:40px;color:rgba(255,255,255,.3)">Warteschlange leer</div>';
|
|
return;
|
|
}
|
|
S.queue.forEach((s, i) => {
|
|
const item = document.createElement('div');
|
|
item.className = 'np-queue-item' + (i===S.qIdx ? ' nq-active' : '');
|
|
const cv = coverUrl(s.coverArt, 128);
|
|
item.innerHTML = `<div class="nqi-num">${i+1}</div><div class="nqi-art">${cv ? `<img src="${cv}">` : '♪'}</div><div class="nqi-info"><div class="nqi-title">${esc(s.title)}</div><div class="nqi-artist">${esc(s.artist||'')}</div></div>`;
|
|
item.onclick = () => { S.qIdx=i; playCurrent(); renderNpQueue(); };
|
|
el.appendChild(item);
|
|
});
|
|
el.querySelector('.nq-active')?.scrollIntoView({block:'nearest'});
|
|
}
|
|
|
|
// ─── NAVIGATION FROM NP ───
|
|
export function npGoArtist() {
|
|
const s = S.queue[S.qIdx]; if(!s) return;
|
|
closeNowPlaying();
|
|
if(s.artistId) navigate('artist-detail', {id:s.artistId, name:s.artist});
|
|
}
|
|
|
|
export function npGoAlbum() {
|
|
const s = S.queue[S.qIdx]; if(!s || !s.albumId) return;
|
|
closeNowPlaying();
|
|
navigate('album-detail', {id:s.albumId, name:s.album||'', artist:s.artist||'', artistId:s.artistId||'', coverArt:s.coverArt||''});
|
|
}
|
|
|
|
// ─── NP ACTION SHEET ───
|
|
export function openNpMenu() {
|
|
const s = S.queue[S.qIdx]; if(!s) return;
|
|
const cv = coverUrl(s.coverArt, 256);
|
|
document.getElementById('np-sheet-song').innerHTML = `
|
|
<div class="thumb">${cv ? `<img src="${cv}">` : '<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:22px">🎵</div>'}</div>
|
|
<div><div class="title">${esc(s.title)}</div><div class="sub">${esc(s.artist||'')}${s.album?' · '+esc(s.album):''}</div></div>`;
|
|
const items = [
|
|
{ icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>', label: 'Song-Info anzeigen', fn: () => { closeNpMenu(); showSongInfo(s); } },
|
|
{ icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>', label: 'Zu Playlist hinzufügen', fn: () => { closeNpMenu(); openPlModal([s]); } },
|
|
{ icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/></svg>', label: 'Zum Künstler', fn: () => { closeNpMenu(); npGoArtist(); } },
|
|
{ icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/></svg>', label: 'Zum Album', fn: () => { closeNpMenu(); closeNowPlaying(); if(s.albumId) navigate('album-detail',{id:s.albumId,name:s.album,artist:s.artist,artistId:s.artistId,coverArt:s.coverArt}); } },
|
|
{ icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>', label: s._starred||s.starred ? 'Aus Favoriten entfernen' : 'Zu Favoriten hinzufügen', fn: () => { closeNpMenu(); npToggleStar(); } },
|
|
{ icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>', label: 'Herunterladen', fn: () => { closeNpMenu(); dlSongLocal(s); } },
|
|
];
|
|
document.getElementById('np-sheet-items').innerHTML = items.map((item,i) =>
|
|
`<div class="np-sheet-item" data-idx="${i}">${item.icon}<span>${item.label}</span></div>`
|
|
).join('');
|
|
document.querySelectorAll('.np-sheet-item').forEach((el,i) => el.addEventListener('click', () => items[i].fn()));
|
|
document.getElementById('np-sheet-overlay').classList.add('open');
|
|
document.getElementById('np-sheet').classList.add('open');
|
|
}
|
|
|
|
export function closeNpMenu() {
|
|
document.getElementById('np-sheet-overlay').classList.remove('open');
|
|
document.getElementById('np-sheet').classList.remove('open');
|
|
}
|