import { S } from './state.js'; import { toast } from './utils.js'; import { api, fetchRomajiIndex } from './api.js'; import { injectPWA } from './pwa.js'; import { loadSavedTheme } from './themes.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, } from './player.js'; import { navigate, _navGoBack, _navLoad, onLocalSearch, onMobileSearch, onGlobalSearch, s_goArtist, s_goAlbum, playerBarGoArtist, playerBarGoAlbum, searchSimilarArtist, searchAndPreview, loadItunesAlbum, expandArtistBio, loadAlbums, loadStarred, loadRandom, filterStarred, filterPlaylist, plReorderEnter, plReorderSave, plReorderCancel, toggleAllGenres, } from './views.js'; import { loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll, onImporterInput, resolveImporterLink } from './spotify.js'; import { resolveYouTubeLink, executeYouTubeDownload } from './youtube.js'; import { loadSoundCloudImporter, resolveSoundCloudLink, clearSoundCloudPreview, scDlAll, scDlSingle, scDlTrack } from './soundcloud.js'; import { absInitFromStorage, absOpenSettings, absConnectSettings, absSetLibrary, absDisconnect, absVerifyApiKey, 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 './abs.js'; import { openNowPlaying, closeNowPlaying, switchNpTab, npGoArtist, npGoAlbum, openNpMenu, closeNpMenu, } from './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, autofillItunes, saveMetadata, } from './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(/\/$/,''); 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}; try { await api('ping'); localStorage.setItem('ns_cfg', JSON.stringify(S.cfg)); startApp(); } catch(e) { S.cfg = {url:'', user:'', pass:'', lastfmKey:'', backendUrl:''}; _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:''}; 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) await 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'); } initNotifBell(); _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; _applyShuffleRepeat(); } // ─── INIT ─── window.addEventListener('load', () => { loadSavedTheme(); // Wire up audio elements from DOM S.audio = document.getElementById('audio-element'); 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(/\/$/,''); startApp(); } 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(); }); S.audio.addEventListener('timeupdate', updateProgress); S.audio.addEventListener('ended', onEnded); S.audio.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'; }); S.audio.addEventListener('pause', () => { 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'; }); S.previewAudio.addEventListener('timeupdate', updatePreviewProgress); S.previewAudio.addEventListener('ended', stopPreview); // stalled = browser buffer ran out briefly; let the browser retry naturally. // The watchdog below catches genuine long freezes. let _errRetries = 0, _errTimer = null; S.audio.addEventListener('play', () => { _errRetries = 0; clearTimeout(_errTimer); }, { capture: true }); S.audio.addEventListener('error', () => { // 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); }); const _savedVol = parseInt(localStorage.getItem('ns_volume') ?? '', 10); setVolume(isNaN(_savedVol) ? 55 : _savedVol); let _wasPlayingBeforeHide = false; function _onForeground() { 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 if audio was actually playing when the page hid (OS-killed buffer). // If the user explicitly paused, _wasPlayingBeforeHide is false — don't restart. if(!S.audio.paused) return; if(s && S.audio.src && _wasPlayingBeforeHide) S.audio.play().catch(() => { S._trackChanging = false; }); } document.addEventListener('visibilitychange', () => { if(document.visibilityState === 'hidden') _wasPlayingBeforeHide = !S.audio.paused; else if(document.visibilityState === 'visible') _onForeground(); }); // pageshow fires more reliably than visibilitychange on iOS PWA and Samsung window.addEventListener('pageshow', e => { if(e.persisted) _onForeground(); }); // Watchdog: detect when audio silently stalled in background (ended event not fired, or frozen). // Disabled with Bridge — the foreground service keeps ExoPlayer alive independently. if (typeof window.NSBridge === 'undefined') { let _wdLast = 0, _wdStalledAt = 0; setInterval(() => { if(!S.audio.src || S.audio.paused) { _wdStalledAt = 0; _wdLast = 0; return; } const dur = S.audio.duration; const cur = S.audio.currentTime; // ended but event didn't fire (background throttling) if(dur > 0 && cur >= dur - 0.5) { onEnded(); _wdStalledAt = 0; _wdLast = 0; return; } if(cur !== _wdLast) { _wdStalledAt = 0; _wdLast = cur; return; } if(!_wdStalledAt) { _wdStalledAt = Date.now(); return; } // frozen for >8s → try to resume if(Date.now() - _wdStalledAt > 8000) { _wdStalledAt = 0; S.audio.play().catch(() => {}); } }, 3000); } injectPWA(); initProgressScrub(); // Click player-art to open Now Playing document.getElementById('player-art')?.addEventListener('click', openNowPlaying); // Swipe-up on player bar to open Now Playing (with drag visual feedback) const playerBar = document.querySelector('.player'); const npOverlay = document.getElementById('np-overlay'); if(playerBar) { let _sy=0, _dragging=false; function _resetDrag(open) { playerBar.classList.remove('dragging'); if (!npOverlay) { if(open) openNowPlaying(); _sy=0; _dragging=false; return; } if (open) { // Continue animating from the current drag position instead of // resetting to translateY(100%) first (which caused the stutter). const m = npOverlay.style.transform.match(/translateY\(([^p]+)px\)/); const curY = m ? parseFloat(m[1]) : window.innerHeight; npOverlay.style.transition = 'none'; npOverlay.style.transform = `translateY(${curY}px)`; void npOverlay.offsetHeight; // flush layout so curY is the transition start npOverlay.style.transition = ''; npOverlay.style.transform = 'translateY(0)'; npOverlay.addEventListener('transitionend', () => { npOverlay.style.transform = ''; npOverlay.style.transition = ''; }, { once: true }); openNowPlaying(); } else { npOverlay.style.transition = ''; npOverlay.style.transform = ''; } _sy=0; _dragging=false; } playerBar.addEventListener('touchstart', e => { if(e.target.closest('button') || e.target.closest('.progress-bar') || e.target.closest('.volume-wrap')) return; _sy=e.touches[0].clientY; _dragging=false; }, {passive:true}); playerBar.addEventListener('touchmove', e => { if(!_sy) return; const dy = _sy - e.touches[0].clientY; if(dy < 5) return; _dragging = true; if(npOverlay) { npOverlay.style.transition = 'none'; // Overlay tracks finger with no cap: 2.5× speed so ~360px drag = fully open. npOverlay.style.transform = `translateY(${Math.max(0, window.innerHeight - dy * 1.5)}px)`; } }, {passive:true}); playerBar.addEventListener('touchend', e => { if(!_sy) return; const dy = _sy - e.changedTouches[0].clientY; _resetDrag(_dragging ? dy > 60 : dy > 15); }, {passive:true}); playerBar.addEventListener('touchcancel', () => _resetDrag(false), {passive:true}); } }); window.addEventListener('popstate', () => _navGoBack()); document.addEventListener('keydown', e => { if(e.key !== 'Backspace') return; const t = document.activeElement?.tagName; if(t==='INPUT' || t==='TEXTAREA' || t==='SELECT') return; e.preventDefault(); _navGoBack(); }); try { history.replaceState({ nsIdx: 0 }, '', ''); } catch {} // ─── EXPOSE GLOBALS (for HTML onclick attributes) ─── Object.assign(window, { // Navigation navigate, onLocalSearch, onMobileSearch, onGlobalSearch, s_goArtist, s_goAlbum, playerBarGoArtist, playerBarGoAlbum, searchSimilarArtist, searchAndPreview, loadItunesAlbum, expandArtistBio, // Player playQueue, togglePlay, prevSong, nextSong, toggleShuffle, toggleRepeat, toggleQueue, setVolume, seekTo, starCurrent, downloadCurrent, playPreview, toggleStar, dlSongLocal, toggleRadioMode, stopPreview, npToggleStar, // Now Playing openNowPlaying, closeNowPlaying, switchNpTab, npGoArtist, npGoAlbum, openNpMenu, closeNpMenu, npSeek: () => {}, // UI / Modals openSettings, closeSettings, saveSettings, confirmDisconnect, checkBackend, openSidebar, closeSidebar, switchNavMode, showCtxMenu, // Views loadAlbums, loadStarred, loadRandom, filterStarred, filterPlaylist, plReorderEnter, plReorderSave, plReorderCancel, toggleAllGenres, loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll, onImporterInput, resolveImporterLink, loadSoundCloudImporter, resolveSoundCloudLink, clearSoundCloudPreview, scDlAll, scDlSingle, scDlTrack, executeSpotifyDownload, resolveYouTubeLink, executeYouTubeDownload, openPlModal, closePlModal, addSongsToPlaylist, createAndAddPlaylist, createPlaylistPrompt, showSongInfo, closeInfoModal, toggleInfoEdit, onInfoArtFile, autofillMusicBrainz, autofillItunes, saveMetadata, openDlModal, closeDlModal, selectDlType, executeDownload, openEditPlModal, closeEditPlModal, saveEditPlaylist, editPlUploadCover, editPlCoverSelected, editPlRemoveCover, deletePlaylist, _confirmOk, _confirmCancel, toggleMobileSearch, closeMobileSearch, switchSetupTab, toggleNotifPanel, deleteNotif, clearNotifs, _notifNavigate, _pickTheme, // App doConnect, disconnect, // Audiobookshelf absPlayItem, absTogglePlay, absSeek, absSkipSecs, absNextChapter, absPrevChapter, absSetSpeed, absSleepIn, absOpenPlayer, absClosePlayer, absToggleChapters, absToggleSpeedPicker, absToggleSleepPicker, absSeekToChapter, absProgDown, absProgMove, absProgUp, absOpenBook, absPlayChapter, absConnectSettings, absSetLibrary, absDisconnect, absToggleChapterTrack, absOnSearch: (val) => { clearTimeout(window._absSearchTimer); window._absSearchTimer = setTimeout(() => { if (S.abs.currentLibraryId) loadAbsBooks(S.abs.currentLibraryId, val.trim()); }, 350); }, });