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, } 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(() => {}); 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; _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; if(s && S.audio.src && _wasPlayingBeforeHide && !S._userPaused) 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(); initKeyboardShortcuts(); initScrollRows(); // 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', (e) => _navOnPopState(e.state)); // Im installierten Fenster ist "display": "standalone" — es gibt keine Zurueck-/Vorwaerts-Knoepfe, // und die Maustasten werden dort nicht auf den Verlauf umgelenkt: Zurueck war im Programmfenster am // PC also ueberhaupt nicht erreichbar, im Browser-Tab dagegen schon. Deshalb uebernehmen wir X1/X2 // selbst — aber NUR im Standalone-Fenster, im Tab macht das der Browser und wir wuerden doppelt // navigieren. (Alt+Links/Rechts bleibt dem Browser ueberlassen, das kann er auch dort selbst.) const _imAppFenster = () => { try { return window.matchMedia('(display-mode: standalone)').matches || navigator.standalone === true; } catch { return false; } }; window.addEventListener('mouseup', (e) => { if (!_imAppFenster()) return; if (e.button === 3) { e.preventDefault(); _navGoBack(); } else if (e.button === 4) { e.preventDefault(); try { history.forward(); } catch {} } }); // Ohne das zeigt Chrome im Fenster das Kontextmenue der Seitentaste. window.addEventListener('auxclick', (e) => { if (_imAppFenster() && (e.button === 3 || e.button === 4)) e.preventDefault(); }); 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, onHeaderSearch, closeHeaderSearch, onGlobalSearch, s_goArtist, s_goAlbum, playerBarGoArtist, playerBarGoAlbum, searchSimilarArtist, searchAndPreview, loadDeezerAlbum, closeEventsModal, // Lazy: die Zeitleiste zieht nur beim Öffnen ihren Code nach. loadArtistNews: (tage) => import('./views/artistNews.js').then(m => m.loadArtistNews(tage)), // 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, openStats, closeStats, openCompare, closeCompare, closeArtistPin, clearArtistPin, // Views loadAlbums, loadArtists, filterArtists, loadStarred, loadRandom, filterStarred, filterPlaylist, plReorderEnter, plReorderSave, plReorderCancel, toggleAllGenres, loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll, onImporterInput, resolveImporterLink, onGlobalInput, tryImporterResolve, loadSoundCloudImporter, resolveSoundCloudLink, clearSoundCloudPreview, scDlAll, scDlSingle, scDlTrack, executeSpotifyDownload, resolveYouTubeLink, executeYouTubeDownload, openPlModal, closePlModal, addSongsToPlaylist, createAndAddPlaylist, createPlaylistPrompt, showSongInfo, closeInfoModal, toggleInfoEdit, onInfoArtFile, autofillMusicBrainz, autofillDeezer, saveMetadata, deleteSong, openDlModal, closeDlModal, selectDlType, executeDownload, openEditPlModal, closeEditPlModal, saveEditPlaylist, editPlUploadCover, editPlCoverSelected, editPlRemoveCover, deletePlaylist, _confirmOk, _confirmCancel, toggleMobileSearch, closeMobileSearch, switchSetupTab, toggleNotifPanel, deleteNotif, clearNotifs, _notifNavigate, _pickTheme, addRomajiOverrideUI, removeRomajiOverrideUI, openRomajiOverridesModal, closeRomajiOverridesModal, // 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, absToggleConnection, absToggleChapterTrack, absOnSearch: (val) => { clearTimeout(window._absSearchTimer); window._absSearchTimer = setTimeout(() => { if (S.abs.currentLibraryId) loadAbsBooks(S.abs.currentLibraryId, val.trim()); }, 350); }, });