import { S } from './state.js'; import { toast } from './utils.js'; import { api, fetchRomajiIndex, loadRomajiOverrides, loadArtistOverrides } from './api.js'; import { injectPWA } from './pwa.js'; import { initPlayLog } from './player/scrobbleLog.js'; import { openStats, closeStats } from './ui/stats.js'; import { openCompare, closeCompare } from './ui/compare.js'; import { closeArtistPin, clearArtistPin } from './ui/artistPin.js'; import { loadSavedTheme } from './themes.js'; import { initScrollRows } from './ui/components.js'; import { playQueue, playCurrent, togglePlay, prevSong, nextSong, onEnded, toggleShuffle, toggleRepeat, toggleQueue, setVolume, seekTo, initProgressScrub, updateProgress, updatePreviewProgress, stopPreview, toggleRadioMode, starCurrent, downloadCurrent, renderQueue, playPreview, toggleStar, dlSongLocal, _applyRadioBtn, _applyShuffleRepeat, _restoreLastPlayed, setPlayIcon, npToggleStar, updateMediaSession, updateNpColorMode, reconcileNativeState, initKeyboardShortcuts, } from './player/player.js'; import { navigate, _navGoBack, _navOnPopState, _navLoad, onHeaderSearch, closeHeaderSearch, onGlobalSearch, s_goArtist, s_goAlbum, playerBarGoArtist, playerBarGoAlbum, searchSimilarArtist, searchAndPreview, loadDeezerAlbum, closeEventsModal, loadAlbums, loadArtists, filterArtists, loadStarred, loadRandom, filterStarred, filterPlaylist, plReorderEnter, plReorderSave, plReorderCancel, toggleAllGenres, } from './views/views.js'; import { loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll, onImporterInput, resolveImporterLink, onGlobalInput, tryImporterResolve } from './views/spotify.js'; import { resolveYouTubeLink, executeYouTubeDownload } from './views/youtube.js'; import { loadSoundCloudImporter, resolveSoundCloudLink, clearSoundCloudPreview, scDlAll, scDlSingle, scDlTrack } from './views/soundcloud.js'; import { absInitFromStorage, absOpenSettings, absConnectSettings, absSetLibrary, absDisconnect, absVerifyApiKey, absToggleConnection, absPlayItem, absTogglePlay, absSeek, absSkipSecs, absNextChapter, absPrevChapter, absSetSpeed, absSleepIn, absOpenPlayer, absClosePlayer, absToggleChapters, absToggleSpeedPicker, absToggleSleepPicker, absSeekToChapter, absProgDown, absProgMove, absProgUp, loadAbsBooks, absOpenBook, loadAbsBookDetail, absPlayChapter, absLoadSettingsLibraries, absToggleChapterTrack, absInterrupt, } from './views/abs.js'; import { openNowPlaying, closeNowPlaying, switchNpTab, npGoArtist, npGoAlbum, openNpMenu, closeNpMenu, toggleLyricsFolgen, } from './player/nowplaying.js'; import { openSettings, closeSettings, saveSettings, confirmDisconnect, checkBackend, openSidebar, closeSidebar, switchNavMode, showCtxMenu, openPlModal, closePlModal, addSongsToPlaylist, createAndAddPlaylist, createPlaylistPrompt, showSongInfo, closeInfoModal, openDlModal, closeDlModal, selectDlType, executeDownload, executeSpotifyDownload, openEditPlModal, closeEditPlModal, saveEditPlaylist, editPlUploadCover, editPlCoverSelected, editPlRemoveCover, deletePlaylist, _confirmOk, _confirmCancel, toggleMobileSearch, closeMobileSearch, switchSetupTab, toggleNotifPanel, deleteNotif, clearNotifs, initNotifBell, _notifNavigate, _pickTheme, toggleInfoEdit, onInfoArtFile, autofillMusicBrainz, autofillDeezer, saveMetadata, deleteSong, addRomajiOverrideUI, removeRomajiOverrideUI, openRomajiOverridesModal, closeRomajiOverridesModal, } from './ui/ui.js'; // ─── CONNECT ─── async function doConnect() { const url = document.getElementById('srv-url').value.trim().replace(/\/$/,''); const user = document.getElementById('srv-user').value.trim(); const pass = document.getElementById('srv-pass').value; const _prev = (() => { try { return JSON.parse(localStorage.getItem('ns_cfg') || '{}'); } catch { return {}; } })(); const lfKey = document.getElementById('lastfm-key')?.value.trim() || _prev.lastfmKey || ''; const backUrl = (document.getElementById('backend-url')?.value.trim() || _prev.backendUrl || window.NS_BACKEND_URL?.trim() || '').replace(/\/$/,''); const scrobUrl = (_prev.scrobbleUrl || window.NS_SCROBBLE_URL?.trim() || '').replace(/\/$/,''); if(!url || !user || !pass) { _showSetupError('Bitte Server-URL, Benutzernamen und Passwort ausfüllen.'); return; } const btn = document.getElementById('btn-connect'); btn.disabled=true; btn.textContent='Verbinde…'; document.getElementById('setup-error').style.display='none'; S.cfg = {url, user, pass, lastfmKey:lfKey, backendUrl:backUrl, scrobbleUrl:scrobUrl}; try { await api('ping'); localStorage.setItem('ns_cfg', JSON.stringify(S.cfg)); startApp(); } catch(e) { S.cfg = {url:'', user:'', pass:'', lastfmKey:'', backendUrl:'', scrobbleUrl:''}; _showSetupError('Verbindung fehlgeschlagen: '+e.message); btn.disabled=false; btn.textContent='Verbinden'; } } function _showSetupError(m) { const el=document.getElementById('setup-error'); el.textContent=m; el.style.display='block'; } function disconnect() { localStorage.removeItem('ns_cfg'); localStorage.removeItem('ns_last_played'); sessionStorage.removeItem('ns_nav'); S.cfg = {url:'', user:'', pass:'', lastfmKey:'', backendUrl:'', scrobbleUrl:''}; S.queue=[]; S.qIdx=-1; S._navStack=[]; S._navCursor=-1; S.audio.pause(); S.audio.src=''; document.getElementById('app').style.display='none'; document.getElementById('setup-screen').style.display='flex'; } async function startApp() { document.getElementById('setup-screen').style.display='none'; document.getElementById('app').style.display='flex'; absInitFromStorage(); if (S.abs.token) { document.getElementById('nav-abs-section').style.display = ''; const bnav = document.getElementById('bnav-audiobooks'); if (bnav) bnav.style.display = ''; absLoadSettingsLibraries(); } if (S.cfg.showRomaji) fetchRomajiIndex().catch(() => {}); if(_navLoad() && S._navCursor >= 0 && S._navStack[S._navCursor]) { const last = S._navStack[S._navCursor]; S._navJumping = true; navigate(last.page, last.data); S._navJumping = false; } else { navigate('home'); } // Geteilter Link (?song=… / ?album=…): erst NACH der Anmeldung aufloesen — vorher gibt es keine // Zugangsdaten, mit denen sich der Inhalt laden liesse. Ueberschreibt bewusst die // wiederhergestellte Ansicht: wer einen Link oeffnet, will dorthin und nicht dahin, wo er zuletzt war. import('./ui/share.js') .then(m => m.handleShareParam(navigate, playQueue)) .catch(() => {}); // Playlists in der Seitenleiste: gleich beim Start zeichnen, damit die Liste nicht erst beim // ersten Besuch der Uebersichtsseite auftaucht. Nutzt denselben 3-Min-Cache. import('./views/playlists.js').then(m => m.renderSidebarPlaylists()).catch(() => {}); initNotifBell(); initPlayLog(); // flush any queued plays to the scrobble service (offline-safe) loadArtistOverrides().catch(() => {}); // pinned same-name artist → Deezer mappings (shared) // Wait for overrides before restoring player bar so disp* functions have the cache ready loadRomajiOverrides().catch(() => {}).then(() => { _restoreLastPlayed(); }); if(localStorage.getItem('ns_radio')==='1') { S.radioMode=true; _applyRadioBtn(); } const sr = localStorage.getItem('ns_repeat'); if(sr === 'all' || sr === 'one') S.repeat = sr; if(localStorage.getItem('ns_shuffle') === '1') S.shuffle = true; // Mitlaufen der Lyrics: Vorgabe AUS, nur ein ausdruecklich gesetztes '1' schaltet es ein. S.lyricsFolgen = localStorage.getItem('ns_lyrics_folgen') === '1'; _applyShuffleRepeat(); } // ─── INIT ─── window.addEventListener('load', () => { loadSavedTheme(); // Wire up audio elements from DOM S.audio = document.getElementById('audio-element'); const _audioB = document.getElementById('audio-element-b'); // Two fixed audio elements for instant skips — S.audio always points at the active one, // the other pre-buffers the next track so a skip can hand off to it without reloading. S._audioEls = _audioB ? [S.audio, _audioB] : [S.audio]; S.previewAudio = document.getElementById('preview-audio'); const saved = localStorage.getItem('ns_cfg'); if(saved) { S.cfg = JSON.parse(saved); if(!S.cfg.backendUrl && window.NS_BACKEND_URL?.trim()) S.cfg.backendUrl = window.NS_BACKEND_URL.trim().replace(/\/$/,''); if(!S.cfg.scrobbleUrl && window.NS_SCROBBLE_URL?.trim()) S.cfg.scrobbleUrl = window.NS_SCROBBLE_URL.trim().replace(/\/$/,''); startApp(); updateNpColorMode(); } if(window.NS_SERVER_URL?.trim()) { const el=document.getElementById('srv-url'); if(el&&!el.value) el.value=window.NS_SERVER_URL.trim(); } document.getElementById('srv-pass').addEventListener('keydown', e => { if(e.key==='Enter') doConnect(); }); // Listeners are bound to BOTH audio elements so a skip can hand off to the pre-buffered // one with no rebinding. Handlers that only apply to the playing element guard with // `el !== S.audio` (the idle standby element must not affect UI/recovery). let _errRetries = 0, _errTimer = null; function bindAudioEl(el) { el.addEventListener('timeupdate', updateProgress); el.addEventListener('ended', onEnded); el.addEventListener('play', () => { S._trackChanging = false; absInterrupt(); if (S.previewCurrent) stopPreview(false); setPlayIcon(true); document.getElementById('player-art').classList.add('spinning'); if(navigator.mediaSession) navigator.mediaSession.playbackState = 'playing'; }); el.addEventListener('pause', () => { if (el !== S.audio) return; // ignore the idle standby element during a handoff setPlayIcon(false); document.getElementById('player-art').classList.remove('spinning'); // Suppress during track change — src swap fires pause before the new track starts if(!S._trackChanging && navigator.mediaSession) navigator.mediaSession.playbackState = 'paused'; }); // stalled = browser buffer ran out briefly; let the browser retry naturally. el.addEventListener('play', () => { _errRetries = 0; clearTimeout(_errTimer); }, { capture: true }); el.addEventListener('error', () => { if (el !== S.audio) return; // standby/preload errors must not disrupt active playback // Skip if playCurrent() is already handling recovery (_trackChanging = true means // we're mid-transition or waiting for canplay — don't conflict with that) if(!S.queue[S.qIdx] || S._trackChanging) return; _errRetries++; const delay = Math.min(1000 * Math.pow(2, _errRetries - 1), 8000); // 1s, 2s, 4s, 8s clearTimeout(_errTimer); _errTimer = setTimeout(() => { if(!S.queue[S.qIdx] || S._trackChanging) return; S._trackChanging = true; S.audio.load(); S.audio.play().catch(() => { S._trackChanging = false; }); }, delay); }); } S._audioEls.forEach(bindAudioEl); S.previewAudio.addEventListener('timeupdate', updatePreviewProgress); S.previewAudio.addEventListener('ended', stopPreview); const _savedVol = parseInt(localStorage.getItem('ns_volume') ?? '', 10); setVolume(isNaN(_savedVol) ? 55 : _savedVol); let _wasPlayingBeforeHide = false; function _onForeground() { reconcileNativeState(); const s = S.queue[S.qIdx]; if(!s) return; updateMediaSession(s); // Don't stomp playbackState during a src-swap — _trackChanging guards the transition. // Skip when native is active: Media3 owns the system media session. if(navigator.mediaSession && !S._trackChanging && !window.NSBridge) navigator.mediaSession.playbackState = S.audio.paused ? 'paused' : 'playing'; // Only auto-resume after an INVOLUNTARY background stop (OS-killed buffer / frozen tab). // Never restart if the user deliberately paused — including via the media notification while // backgrounded (that routes through togglePlay → S._userPaused=true), which used to wrongly // auto-resume on foreground because _wasPlayingBeforeHide was still true from hide-time. if(!S.audio.paused) return; // Am ENDE der Warteschlange nicht fortsetzen. Ein beendetes