Die Liste, wem man folgt, gehört dorthin, wo man Künstler ohnehin sucht — bisher gab es sie nur als waagerechte Reihe unter den Favoriten. getArtists liefert kein starred mit, die Markierungen kommen deshalb aus getStarred2 und werden 30 s gehalten, damit das Umschalten flott bleibt. Ein Herz-Klick verwirft diesen Zwischenspeicher sofort — sonst zeigte der Filter nach dem Entfolgen noch eine halbe Minute den alten Stand. Beim Testen aufgefallen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
400 lines
20 KiB
JavaScript
400 lines
20 KiB
JavaScript
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);
|
||
},
|
||
});
|