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.js'; import { openPlModal, showSongInfo } from './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; 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 = '
Lade Lyrics…
'; const token = ++_lyricsLoadToken; const s = S.queue[S.qIdx]; const { getLyricsBySongId } = await import('./api.js'); if(token !== _lyricsLoadToken) return; // 1. Navidrome (plugin handles fetching + caching server-side) const lyrics = await getLyricsBySongId(songId); if(token !== _lyricsLoadToken) return; const track = lyrics[0]; if(track?.synced && track.line?.length) { content.innerHTML = ''; _renderSyncedLines(content, track.line.map(l => ({ start: l.start, value: l.value || '' })).filter(l => l.value)); return; } // 2. Fallback: LRCLIB directly (synced LRC format) const firstArtist = (s?.artist||'').split(/[/,]/)[0].trim(); let lrcText = null; let lrclibPlain = null; try { const attempts = [ new URLSearchParams({ artist_name: firstArtist, track_name: s?.title||'', ...(s?.album ? { album_name: s.album } : {}) }), ...(s?.album ? [new URLSearchParams({ artist_name: firstArtist, track_name: s?.title||'' })] : []), ]; for(const p of attempts) { if(token !== _lyricsLoadToken) return; const r = await fetch(`https://lrclib.net/api/get?${p}`, { signal: AbortSignal.timeout(6000) }); if(r.ok) { const d = await r.json(); lrcText = d.syncedLyrics || null; lrclibPlain = d.plainLyrics || null; break; } } } catch {} if(token !== _lyricsLoadToken) return; content.innerHTML = ''; if(lrcText) { const parsed = _parseLrc(lrcText); if(parsed.length) { _renderSyncedLines(content, parsed); return; } } const plain = lrclibPlain || (track?.line?.length ? track.line.map(l => l.value).join('\n') : null); if(plain) { const el = document.createElement('div'); el.className = 'lyrics-plain'; el.textContent = plain; content.appendChild(el); } else { 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 = '
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'); }