Files
NovaSonic/src/main.js
T
HoneyandClaude Opus 4.8 46e252b494 perf(web): instant skips via gapless handoff to a pre-buffered element
Reintroduce the two-element handoff, this time as a skip-speed optimization
(not for the OS tile). A second <audio> element pre-buffers the next track;
on a forward skip playCurrent hands playback over to it instead of reloading
the source, so playback starts immediately instead of ~1s. Covers are
pre-warmed in parallel so the new artwork is ready too.

Listeners are bound to both elements once (handlers guard with el !== S.audio).
Safety: handoff only when the standby is buffered (readyState >= 2); otherwise
falls back to the unchanged source-swap path, and a failed handoff play()
recovers with a fresh source load. Web-only; the native bridge path is untouched.

Note: this does not change the OS media-tile flicker (a separate, MSE-only fix).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 11:17:16 +02:00

360 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { S } from './state.js';
import { toast } from './utils.js';
import { api, fetchRomajiIndex, loadRomajiOverrides } from './api.js';
import { injectPWA } from './pwa.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, _navLoad, onHeaderSearch, closeHeaderSearch,
onGlobalSearch, s_goArtist, s_goAlbum, playerBarGoArtist, playerBarGoAlbum,
searchSimilarArtist, searchAndPreview, loadItunesAlbum, closeEventsModal,
loadAlbums, loadStarred, loadRandom, filterStarred, filterPlaylist,
plReorderEnter, plReorderSave, plReorderCancel, toggleAllGenres,
} from './views/views.js';
import { loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll,
onImporterInput, resolveImporterLink } 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, autofillItunes, 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(/\/$/,'');
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) 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();
// 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(/\/$/,'');
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 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();
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', () => _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, onHeaderSearch, closeHeaderSearch, onGlobalSearch, s_goArtist, s_goAlbum,
playerBarGoArtist, playerBarGoAlbum, searchSimilarArtist, searchAndPreview,
loadItunesAlbum, closeEventsModal,
// 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, 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);
},
});