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 ? '' : ''; 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; let _prefetchTimer = null; 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 = []; 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}`; // Cache hit (warmed on play, or from a previous view) — render instantly, no placeholder flash if(_lyricsCache.has(cacheKey)) { content.innerHTML = ''; _showLyricsResult(content, _lyricsCache.get(cacheKey)); return; } content.innerHTML = '
Lade Lyrics…
'; const result = await _fetchLyricsData(artist, title, album, dur); if(token !== _lyricsLoadToken) return; content.innerHTML = ''; _showLyricsResult(content, result); } /** * Pure lrclib fetch cascade — no DOM. Populates _lyricsCache and returns the result. * Shared by the Lyrics tab and by prefetchLyrics() so opening Lyrics feels instant. */ async function _fetchLyricsData(artist, title, album, dur) { const cacheKey = `${artist}|${title}`; if(_lyricsCache.has(cacheKey)) return _lyricsCache.get(cacheKey); 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]); }; let result = null; // 1a. lrclib exact match with duration const exactP = new URLSearchParams({ artist_name: artist, track_name: title }); if(album) exactP.set('album_name', album); if(dur) exactP.set('duration', dur); 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 {} // 1b. exact match without duration — handles version mismatches (e.g. file at 5:58, lrclib has 4:24) if(!result && dur && album) { try { const exactNoDurP = new URLSearchParams({ artist_name: artist, track_name: title, album_name: album }); const r = await fetch(`https://lrclib.net/api/get?${exactNoDurP}`, { signal: SIG() }); if(r.status === 200) { const d = await r.json(); if(d?.syncedLyrics || d?.plainLyrics || d?.instrumental) result = d; } } catch {} } // 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); } _lyricsCache.set(cacheKey, result ?? null); return result ?? null; } /** Warm the lyrics cache when a track starts, so opening the Lyrics tab is instant. */ function prefetchLyrics(song) { if(!song || !song.title) return; const artist = (song.artist || '').split(/[/,]/)[0].trim(); _fetchLyricsData(artist, song.title, song.album || '', song.duration || 0).catch(() => {}); } 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 = ''; // Warm the cache shortly AFTER the track starts — deferred so the lyrics requests // don't compete with the audio stream and delay playback start. clearTimeout(_prefetchTimer); const song = S.queue.find(x => x.id === songId) || S.queue[S.qIdx]; _prefetchTimer = setTimeout(() => prefetchLyrics(song), 1500); 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 = '
Warteschlange leer
'; 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 = `
${i+1}
${cv ? `` : '♪'}
${esc(s.title)}
${esc(s.artist||'')}
`; 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 = `
${cv ? `` : '
🎵
'}
${esc(s.title)}
${esc(s.artist||'')}${s.album?' · '+esc(s.album):''}
`; const items = [ { icon: '', label: 'Song-Info anzeigen', fn: () => { closeNpMenu(); showSongInfo(s); } }, { icon: '', label: 'Zu Playlist hinzufügen', fn: () => { closeNpMenu(); openPlModal([s]); } }, { icon: '', label: 'Zum Künstler', fn: () => { closeNpMenu(); npGoArtist(); } }, { icon: '', 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: '', label: s._starred||s.starred ? 'Aus Favoriten entfernen' : 'Zu Favoriten hinzufügen', fn: () => { closeNpMenu(); npToggleStar(); } }, { icon: '', label: 'Herunterladen', fn: () => { closeNpMenu(); dlSongLocal(s); } }, ]; document.getElementById('np-sheet-items').innerHTML = items.map((item,i) => `
${item.icon}${item.label}
` ).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'); }