Files
NovaSonic/src/abs.js
T

755 lines
27 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 { esc, toast } from './utils.js';
import { cancelMusicAutoplay } from './player.js';
// ─── HTTP HELPERS ───
// API calls are routed through the NovaSonic backend proxy to avoid CORS restrictions.
// Audio streaming uses <audio src> directly (HTML media elements bypass CORS).
async function absRequest(method, path, body) {
const backend = S.cfg.backendUrl;
let url, headers;
if (backend) {
// Proxy through NovaSonic backend — no CORS issue
url = `${backend}/api/abs-proxy${path}`;
headers = {
'Content-Type': 'application/json',
'X-ABS-URL': S.abs.url,
'X-ABS-Token': S.abs.token || '',
};
} else {
// Direct — requires CORS to be configured on the ABS server
url = `${S.abs.url}${path}`;
headers = { 'Content-Type': 'application/json' };
if (S.abs.token) headers['Authorization'] = `Bearer ${S.abs.token}`;
}
const opts = { method, headers };
if (body !== undefined) opts.body = JSON.stringify(body);
const res = await fetch(url, opts);
if (!res.ok) {
const txt = await res.text().catch(() => '');
throw new Error(`ABS ${method} ${path}: HTTP ${res.status} ${txt.slice(0, 120)}`);
}
const txt = await res.text();
return txt ? JSON.parse(txt) : {};
}
const absGet = p => absRequest('GET', p);
const absPost = (p, b) => absRequest('POST', p, b);
const absPatch = (p, b) => absRequest('PATCH', p, b);
// ─── AUTH ───
export async function absVerifyApiKey(url, apiKey) {
S.abs.url = url.replace(/\/$/, '');
S.abs.token = apiKey;
// Verify by fetching current user — throws if token is invalid
const me = await absGet('/api/me');
S.abs.userId = me.id || null;
return me;
}
// ─── LIBRARIES ───
export async function absGetLibraries() {
const d = await absGet('/api/libraries');
return d.libraries || [];
}
export async function absGetItems(libId, { page = 0, limit = 100, search = '' } = {}) {
if (search) {
const d = await absGet(`/api/libraries/${libId}/search?q=${encodeURIComponent(search)}&limit=40`);
const items = (d.book || d.podcast || []).map(r => r.libraryItem || r);
return { items, total: items.length };
}
const d = await absGet(
`/api/libraries/${libId}/items?expanded=1&limit=${limit}&page=${page}` +
`&sort=media.metadata.title&asc=1&include=progress`
);
return { items: d.results || [], total: d.total || 0 };
}
export async function absGetItem(itemId) {
return absGet(`/api/items/${itemId}?expanded=1`);
}
export function absCoverUrl(itemId) {
if (!S.abs.token || !S.abs.url || !itemId) return null;
return `${S.abs.url}/api/items/${itemId}/cover?token=${encodeURIComponent(S.abs.token)}`;
}
// ─── PROGRESS ───
export async function absGetProgress(itemId) {
try { return await absGet(`/api/me/progress/${itemId}`); } catch { return null; }
}
async function absSaveProgress(itemId, currentTime, duration) {
if (!itemId || !S.abs.token) return;
try {
const progress = duration > 0 ? Math.min(1, currentTime / duration) : 0;
await absPatch(`/api/me/progress/${itemId}`, {
currentTime, duration, progress,
isFinished: progress >= 0.99,
lastUpdate: Date.now(),
});
} catch (e) { console.warn('ABS progress sync failed:', e.message); }
}
// ─── PLAYBACK SESSION ───
async function absStartSession(itemId) {
return absPost(`/api/items/${itemId}/play`, {
deviceInfo: { clientName: 'NovaSonic', clientVersion: '1.0' },
supportedMimeTypes: ['audio/mpeg', 'audio/mp4', 'audio/aac', 'audio/ogg', 'audio/flac', 'audio/m4b'],
mediaPlayer: 'html5',
forceDirectPlay: true,
forceTranscode: false,
});
}
async function absCloseSession(sessionId) {
try { await absPost(`/api/session/${sessionId}/close`, {}); } catch {}
}
// ─── PLAYER INTERNALS ───
let _audio = null;
let _syncTimer = null;
let _absMiniVisible = false; // true = ABS bar covers the music player, only toggled on play start
let _sleepTimer = null;
let _sleepEndTime = null;
let _sleepTick = null;
function _getAudio() {
if (!_audio) {
_audio = document.getElementById('abs-audio');
_audio.addEventListener('timeupdate', _onTimeUpdate);
_audio.addEventListener('ended', _onTrackEnded);
_audio.addEventListener('play', () => { S.absPlayer.playing = true; _absMiniVisible = true; _renderControls(); _startSync(); });
_audio.addEventListener('pause', () => { S.absPlayer.playing = false; _renderControls(); _doSync(); });
}
return _audio;
}
function _globalTime() {
const ap = S.absPlayer;
const track = ap.audioTracks[ap.trackIdx];
return (track?.startOffset ?? 0) + (_audio?.currentTime ?? 0);
}
function _onTimeUpdate() {
const ap = S.absPlayer;
ap.currentTime = _globalTime();
_updateChapter();
_renderProgress();
}
function _onTrackEnded() {
const ap = S.absPlayer;
if (ap.trackIdx < ap.audioTracks.length - 1) {
ap.trackIdx++;
_loadTrack(ap.trackIdx, 0);
_audio.play().catch(() => {});
} else {
ap.playing = false;
_doSync(true);
_renderControls();
toast('📚 Hörbuch beendet');
}
}
function _loadTrack(idx, localOffset = 0) {
const ap = S.absPlayer;
const track = ap.audioTracks[idx];
if (!track) return;
ap.trackIdx = idx;
const sep = track.contentUrl.includes('?') ? '&' : '?';
_audio.src = `${S.abs.url}${track.contentUrl}${sep}token=${encodeURIComponent(S.abs.token)}`;
_audio.playbackRate = ap.speed;
_audio.currentTime = Math.max(0, localOffset);
}
function _findTrack(globalTime) {
const tracks = S.absPlayer.audioTracks;
if (!tracks.length) return { idx: 0, local: globalTime };
let idx = 0;
for (let i = 0; i < tracks.length; i++) {
if (globalTime >= tracks[i].startOffset) idx = i;
}
return { idx, local: Math.max(0, globalTime - (tracks[idx].startOffset ?? 0)) };
}
function _updateChapter() {
const ap = S.absPlayer;
if (!ap.chapters.length) return;
const t = ap.currentTime;
let idx = 0;
for (let i = 0; i < ap.chapters.length; i++) {
if (ap.chapters[i].start <= t) idx = i;
}
if (idx !== ap.chapterIdx) {
ap.chapterIdx = idx;
_renderChapterName();
_renderChapterList();
}
}
function _startSync() {
if (_syncTimer) return;
_syncTimer = setInterval(() => _doSync(), 30_000);
}
function _stopSync() { if (_syncTimer) { clearInterval(_syncTimer); _syncTimer = null; } }
function _doSync(finished = false) {
const ap = S.absPlayer;
if (ap.item) absSaveProgress(ap.item.id, ap.currentTime, ap.duration);
}
// ─── PUBLIC PLAYER ───
export async function absPlayItem(item) {
const ap = S.absPlayer;
const audio = _getAudio();
cancelMusicAutoplay(); // pause music + invalidate any stale canplay callbacks
if (ap._sessionId) { absCloseSession(ap._sessionId); ap._sessionId = null; }
_stopSync();
audio.pause();
toast('⏳ Lade Hörbuch…');
let session;
try {
session = await absStartSession(item.id);
} catch (e) {
toast(`❌ ${e.message}`);
return;
}
const tracks = session.audioTracks || [];
const chapters = session.chapters || item.media?.chapters || [];
const duration = tracks.reduce((s, t) => s + (t.duration ?? 0), 0) || (item.media?.duration ?? 0);
const saved = await absGetProgress(item.id);
const startTime = (saved?.currentTime && saved.currentTime > 10) ? saved.currentTime : 0;
ap.item = item;
ap._sessionId = session.id;
ap.audioTracks = tracks;
ap.chapters = chapters;
ap.duration = duration;
ap.currentTime = startTime;
ap.chapterIdx = -1;
ap.speed = ap.speed || 1.0;
if (tracks.length) {
const { idx, local } = _findTrack(startTime);
_loadTrack(idx, local);
} else if (item.media?.audioFiles?.[0]) {
const f = item.media.audioFiles[0];
_audio.src = `${S.abs.url}/api/items/${item.id}/file/${f.index ?? 0}?token=${encodeURIComponent(S.abs.token)}`;
_audio.playbackRate = ap.speed;
_audio.currentTime = startTime;
ap.duration = item.media.duration || 0;
}
_updateChapter();
absOpenPlayer();
_renderPlayer();
try { await audio.play(); } catch (e) { toast('Wiedergabe: ' + e.message); }
}
export function absTogglePlay() {
const audio = _getAudio();
if (S.absPlayer.playing) {
audio.pause();
} else {
if (S.audio && !S.audio.paused) S.audio.pause();
audio.play().catch(() => {});
}
}
export function absInterrupt() {
// Always hide the ABS bar when music takes over, regardless of ABS play state
if (_absMiniVisible) { _absMiniVisible = false; _renderMiniBar(); }
if (!S.absPlayer.item || !S.absPlayer.playing) return;
_getAudio().pause();
}
export function absSeek(globalTime) {
const ap = S.absPlayer;
globalTime = Math.max(0, Math.min(globalTime, ap.duration || Infinity));
ap.currentTime = globalTime;
if (ap.audioTracks.length) {
const { idx, local } = _findTrack(globalTime);
if (idx !== ap.trackIdx) {
_loadTrack(idx, local);
if (ap.playing) _audio.play().catch(() => {});
} else {
_audio.currentTime = local;
}
} else {
_getAudio().currentTime = globalTime;
}
_renderProgress();
}
export function absSkipSecs(secs) { absSeek(S.absPlayer.currentTime + secs); }
export function absNextChapter() {
const ap = S.absPlayer;
const next = ap.chapters[ap.chapterIdx + 1];
if (next) absSeek(next.start);
}
export function absPrevChapter() {
const ap = S.absPlayer;
const cur = ap.chapters[ap.chapterIdx];
if (cur && ap.currentTime - cur.start > 5) { absSeek(cur.start); return; }
const prev = ap.chapters[ap.chapterIdx - 1];
if (prev) absSeek(prev.start);
}
export function absSetSpeed(speed) {
S.absPlayer.speed = speed;
if (_audio) _audio.playbackRate = speed;
document.querySelectorAll('#abs-speed-picker button').forEach(b =>
b.classList.toggle('active', parseFloat(b.dataset.speed) === speed));
const lbl = document.getElementById('abs-speed-val');
if (lbl) lbl.textContent = `${speed}×`;
document.getElementById('abs-speed-picker')?.classList.remove('open');
}
export function absSleepIn(minutes) {
clearTimeout(_sleepTimer); clearInterval(_sleepTick);
_sleepTimer = null; _sleepTick = null; _sleepEndTime = null;
document.getElementById('abs-sleep-picker')?.classList.remove('open');
document.getElementById('abs-sleep-custom-row')?.classList.remove('active');
if (!minutes) { S.absPlayer.sleepRemaining = null; _renderSleepBtn(); return; }
_sleepEndTime = Date.now() + minutes * 60_000;
_updateSleepTick();
_sleepTimer = setTimeout(() => {
_getAudio().pause();
S.absPlayer.sleepRemaining = null; _sleepEndTime = null;
clearInterval(_sleepTick); _sleepTick = null;
_renderSleepBtn();
toast('😴 Sleep Timer: Pause');
}, minutes * 60_000);
_sleepTick = setInterval(_updateSleepTick, 1000);
_renderSleepBtn();
}
function _updateSleepTick() {
if (!_sleepEndTime) return;
S.absPlayer.sleepRemaining = Math.max(0, Math.round((_sleepEndTime - Date.now()) / 1000));
_renderSleepBtn();
}
// ─── OVERLAY ───
export function absOpenPlayer() {
S.absPlayer.open = true;
document.getElementById('abs-player')?.classList.add('open');
}
export function absClosePlayer() {
S.absPlayer.open = false;
document.getElementById('abs-player')?.classList.remove('open');
}
export function absToggleChapters() {
document.getElementById('abs-chapter-list')?.classList.toggle('open');
_renderChapterList();
}
export function absToggleSpeedPicker() {
document.getElementById('abs-sleep-picker')?.classList.remove('open');
document.getElementById('abs-speed-picker')?.classList.toggle('open');
}
export function absToggleSleepPicker() {
document.getElementById('abs-speed-picker')?.classList.remove('open');
document.getElementById('abs-sleep-picker')?.classList.toggle('open');
}
export function absSeekToChapter(idx) {
const ch = S.absPlayer.chapters[idx];
if (ch) absSeek(ch.start);
}
// ─── CHAPTER TRACK MODE ───
let _chapterTrackMode = localStorage.getItem('ns_abs_chtrack') === '1';
export function absToggleChapterTrack() {
_chapterTrackMode = !_chapterTrackMode;
localStorage.setItem('ns_abs_chtrack', _chapterTrackMode ? '1' : '0');
document.getElementById('abs-chtrack-btn')?.classList.toggle('active', _chapterTrackMode);
_renderProgress();
}
function _chapterWindow() {
const ap = S.absPlayer;
if (!_chapterTrackMode || ap.chapterIdx < 0 || !ap.chapters.length) return null;
const ch = ap.chapters[ap.chapterIdx];
if (!ch || ch.start == null) return null;
const nextCh = ap.chapters[ap.chapterIdx + 1];
const end = nextCh?.start ?? ch.end ?? ap.duration;
if (end <= ch.start) return null;
return { start: ch.start, end };
}
// ─── PROGRESS SCRUB ───
let _scrubbing = false;
export function absProgDown(e) { _scrubbing = true; _scrub(e); }
export function absProgMove(e) { if (_scrubbing) _scrub(e); }
export function absProgUp(e) { if (_scrubbing) { _scrubbing = false; _scrub(e); } }
function _scrub(e) {
const bar = document.getElementById('abs-prog-bar');
if (!bar) return;
const rect = bar.getBoundingClientRect();
const pct = Math.max(0, Math.min(1, ((e.touches?.[0]?.clientX ?? e.clientX) - rect.left) / rect.width));
const win = _chapterWindow();
absSeek(win ? win.start + pct * (win.end - win.start) : pct * S.absPlayer.duration);
}
// ─── RENDER HELPERS ───
function _renderMiniBar() {
const ap = S.absPlayer;
const bar = document.getElementById('abs-mini-bar');
if (!bar) return;
if (!ap.item || !_absMiniVisible) { bar.classList.remove('visible'); return; }
bar.classList.add('visible');
const meta = ap.item?.media?.metadata || {};
const cover = absCoverUrl(ap.item?.id);
const coverEl = document.getElementById('abs-mini-cover');
if (coverEl) {
coverEl.innerHTML = cover
? `<img src="${cover}" alt="" style="width:100%;height:100%;object-fit:cover;border-radius:6px" onerror="this.style.display='none'">`
: '📚';
}
const titleEl = document.getElementById('abs-mini-title');
if (titleEl) titleEl.textContent = meta.title || 'Unbekannt';
const ch = ap.chapters[ap.chapterIdx];
const chEl = document.getElementById('abs-mini-chapter');
if (chEl) chEl.textContent = ch?.title || meta.authorName || '';
const playBtn = document.getElementById('abs-mini-play');
if (playBtn) {
playBtn.innerHTML = ap.playing
? `<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/></svg>`
: `<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>`;
}
}
function _fmt(secs) {
if (!isFinite(secs) || secs < 0) secs = 0;
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
const s = Math.floor(secs % 60);
return h > 0
? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
: `${m}:${String(s).padStart(2, '0')}`;
}
function _renderPlayer() {
const ap = S.absPlayer;
const meta = ap.item?.media?.metadata || {};
const cover = absCoverUrl(ap.item?.id);
const el = document.getElementById('abs-player');
if (!el) return;
const img = el.querySelector('#abs-cover');
if (img) { img.src = cover || ''; img.style.display = cover ? '' : 'none'; }
const pl = el.querySelector('#abs-cover-placeholder');
if (pl) pl.style.display = cover ? 'none' : 'flex';
el.querySelector('#abs-title').textContent = meta.title || 'Unbekannt';
el.querySelector('#abs-author').textContent = meta.authorName || '';
_renderProgress();
_renderControls();
_renderChapterName();
_renderSleepBtn();
_renderMiniBar();
document.getElementById('abs-chtrack-btn')?.classList.toggle('active', _chapterTrackMode);
}
function _renderProgress() {
const ap = S.absPlayer;
const win = _chapterWindow();
const dispCur = win ? Math.max(0, ap.currentTime - win.start) : ap.currentTime;
const dispDur = win ? win.end - win.start : ap.duration;
const pct = dispDur > 0 ? Math.min(100, (dispCur / dispDur) * 100) : 0;
const fill = document.getElementById('abs-prog-fill');
const thumb = document.getElementById('abs-prog-thumb');
if (fill) fill.style.width = `${pct}%`;
if (thumb) thumb.style.left = `${pct}%`;
const cur = document.getElementById('abs-time-cur');
const rem = document.getElementById('abs-time-rem');
if (cur) cur.textContent = _fmt(dispCur);
if (rem) rem.textContent = `-${_fmt(dispDur - dispCur)}`;
// mini bar strip always shows full-book progress
const miniPct = ap.duration > 0 ? Math.min(100, (ap.currentTime / ap.duration) * 100) : 0;
const miniFill = document.getElementById('abs-mini-prog-fill');
if (miniFill) miniFill.style.width = `${miniPct}%`;
}
function _renderControls() {
const ap = S.absPlayer;
const btn = document.getElementById('abs-playpause-btn');
if (!btn) return;
btn.innerHTML = ap.playing
? `<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/></svg>`
: `<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>`;
const sv = document.getElementById('abs-speed-val');
if (sv) sv.textContent = `${ap.speed}×`;
_renderMiniBar();
}
function _renderChapterName() {
const ch = S.absPlayer.chapters[S.absPlayer.chapterIdx];
const el = document.getElementById('abs-chapter-name');
if (el) el.textContent = ch?.title || '';
_renderMiniBar();
}
function _renderChapterList() {
const ap = S.absPlayer;
const el = document.getElementById('abs-chapter-list');
if (!el || !el.classList.contains('open')) return;
el.innerHTML = ap.chapters.map((ch, i) => `
<div class="abs-ch-item${i === ap.chapterIdx ? ' active' : ''}" onclick="absSeekToChapter(${i})">
<span class="abs-ch-num">${i + 1}</span>
<span class="abs-ch-title">${esc(ch.title || `Kapitel ${i + 1}`)}</span>
<span class="abs-ch-start">${_fmt(ch.start)}</span>
</div>`).join('');
}
function _renderSleepBtn() {
const btn = document.getElementById('abs-sleep-btn');
if (!btn) return;
const rem = S.absPlayer.sleepRemaining;
const moonSvg = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>`;
if (rem != null) {
const m = Math.floor(rem / 60), s = String(rem % 60).padStart(2, '0');
btn.innerHTML = `${moonSvg}<span>${m}:${s}</span>`;
btn.classList.add('active');
} else {
btn.innerHTML = moonSvg;
btn.classList.remove('active');
}
}
// ─── BOOKS VIEW ───
export async function loadAbsBooks(libId, search = '') {
const grid = document.getElementById('abs-books-grid');
if (!grid) return;
grid.innerHTML = '<div class="abs-loading">Lade Bücher…</div>';
try {
const { items } = await absGetItems(libId, { search });
if (!items.length) {
grid.innerHTML = '<p class="abs-empty">Keine Bücher gefunden.</p>';
return;
}
grid.innerHTML = items.map(item => {
const meta = item.media?.metadata || {};
const cover = absCoverUrl(item.id);
const prog = item.userMediaProgress;
const pct = prog ? Math.round((prog.progress ?? 0) * 100) : 0;
return `
<div class="abs-book-card" onclick="absOpenBook('${item.id}')">
<div class="abs-book-cover-wrap">
${cover
? `<img src="${cover}" class="abs-book-cover" loading="lazy" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">`
: ''}
<div class="abs-book-cover-ph" style="display:${cover ? 'none' : 'flex'}">📚</div>
${pct > 0 && pct < 99 ? `<div class="abs-book-prog-wrap"><div class="abs-book-prog-fill" style="width:${pct}%"></div></div>` : ''}
${pct >= 99 ? '<div class="abs-book-done">✓</div>' : ''}
</div>
<div class="abs-book-title">${esc(meta.title || 'Unbekannt')}</div>
<div class="abs-book-author">${esc(meta.authorName || '')}</div>
</div>`;
}).join('');
} catch (e) {
grid.innerHTML = `<p class="abs-error">Fehler: ${esc(e.message)}</p>`;
}
}
export function absOpenBook(itemId) {
const { navigate } = window;
navigate('abs-book-detail', { id: itemId });
}
export async function loadAbsBookDetail(itemId) {
const el = document.getElementById('abs-book-detail-content');
if (!el) return;
el.innerHTML = '<div class="abs-loading">Lade Buch…</div>';
try {
const [item, prog] = await Promise.all([absGetItem(itemId), absGetProgress(itemId)]);
window._absCurrentItem = item;
const meta = item.media?.metadata || {};
const cover = absCoverUrl(item.id);
const chapters = item.media?.chapters || [];
const duration = item.media?.duration ?? 0;
const curTime = prog?.currentTime ?? 0;
const pct = duration > 0 ? Math.round((curTime / duration) * 100) : 0;
el.innerHTML = `
<div class="abs-detail-header">
<div class="abs-detail-cover-wrap">
${cover
? `<img src="${cover}" class="abs-detail-cover" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">`
: ''}
<div class="abs-detail-cover-ph" style="display:${cover ? 'none' : 'flex'}">📚</div>
</div>
<div class="abs-detail-meta">
<h2 class="abs-detail-title">${esc(meta.title || 'Unbekannt')}</h2>
<div class="abs-detail-author">${esc(meta.authorName || '')}</div>
${meta.seriesName ? `<div class="abs-detail-series">${esc(meta.seriesName)}</div>` : ''}
${meta.publishedYear ? `<div class="abs-detail-year">${esc(String(meta.publishedYear))}</div>` : ''}
${duration ? `<div class="abs-detail-dur">${_fmt(duration)}</div>` : ''}
${pct > 0 ? `
<div class="abs-detail-progress-wrap">
<div class="abs-detail-progress-bar"><div style="width:${pct}%"></div></div>
<span>${pct}% gehört</span>
</div>` : ''}
<button class="abs-play-btn" onclick="absPlayItem(window._absCurrentItem)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
${curTime > 60 ? 'Weiterhören' : 'Abspielen'}
</button>
</div>
</div>
${meta.description ? `<div class="abs-detail-desc">${esc(meta.description)}</div>` : ''}
${chapters.length ? `
<div class="abs-detail-chapters">
<h3 class="abs-detail-chapters-title">Kapitel <span class="abs-ch-count">${chapters.length}</span></h3>
${chapters.map((ch, i) => `
<div class="abs-chapter-row" onclick="absPlayChapter(${ch.start})">
<span class="abs-ch-num">${i + 1}</span>
<span class="abs-ch-title">${esc(ch.title || `Kapitel ${i + 1}`)}</span>
<span class="abs-ch-start">${_fmt(ch.start)}</span>
</div>`).join('')}
</div>` : ''}`;
} catch (e) {
el.innerHTML = `<p class="abs-error">Fehler: ${esc(e.message)}</p>`;
}
}
export function absPlayChapter(startTime) {
if (!window._absCurrentItem) return;
absPlayItem(window._absCurrentItem).then(() => {
// Give audio time to load then seek
setTimeout(() => absSeek(startTime), 800);
});
}
// ─── SETTINGS ───
export function absInitFromStorage() {
try {
const d = JSON.parse(localStorage.getItem('ns_abs') || 'null');
if (d?.url && d?.token) {
S.abs.url = d.url;
S.abs.token = d.token;
S.abs.userId = d.userId || null;
S.abs.currentLibraryId = d.libId || null;
}
} catch {}
}
function absSaveToStorage() {
localStorage.setItem('ns_abs', JSON.stringify({
url: S.abs.url, token: S.abs.token,
userId: S.abs.userId, libId: S.abs.currentLibraryId,
}));
}
function _showAbsNavItem(show) {
document.getElementById('nav-abs-section')?.style && (document.getElementById('nav-abs-section').style.display = show ? '' : 'none');
const bnav = document.getElementById('bnav-audiobooks');
if (bnav) bnav.style.display = show ? '' : 'none';
}
function _setAbsStatusRow(state, msg) {
// state: 'connected' | 'error' | 'none'
const statusEl = document.getElementById('abs-s-status');
const dotEl = document.getElementById('abs-s-dot');
const btnEl = document.getElementById('abs-s-connect-btn');
if (statusEl) statusEl.textContent = msg || 'Nicht verbunden';
if (dotEl) {
dotEl.style.display = state === 'none' ? 'none' : 'block';
dotEl.className = 'status-dot' + (state === 'connected' ? ' ok' : state === 'error' ? ' err' : '');
}
if (btnEl) {
btnEl.disabled = false;
if (state === 'connected') {
btnEl.textContent = 'Trennen';
btnEl.style.color = 'var(--danger)';
btnEl.style.borderColor = 'rgba(224,85,85,0.3)';
} else {
btnEl.textContent = 'Verbinden';
btnEl.style.color = '';
btnEl.style.borderColor = '';
}
}
}
export function absOpenSettings() {
const urlEl = document.getElementById('abs-s-url');
if (urlEl) urlEl.value = S.abs.url || '';
if (S.abs.token) {
_setAbsStatusRow('connected', 'Verbunden · ' + (S.abs.url || ''));
} else {
_setAbsStatusRow('none', 'Nicht verbunden');
}
}
export function absToggleConnection() {
if (S.abs.token) absDisconnect(); else absConnectSettings();
}
export async function absConnectSettings() {
const url = document.getElementById('abs-s-url')?.value.trim().replace(/\/$/, '');
const apiKey = document.getElementById('abs-s-apikey')?.value.trim();
if (!url || !apiKey) { toast('❌ URL und API Key ausfüllen'); return; }
const btn = document.getElementById('abs-s-connect-btn');
if (btn) { btn.disabled = true; btn.textContent = 'Verbinde…'; }
try {
await absVerifyApiKey(url, apiKey);
const libs = await absGetLibraries();
if (!S.abs.currentLibraryId) S.abs.currentLibraryId = libs[0]?.id || null;
absSaveToStorage();
_renderLibPicker(libs);
_showAbsNavItem(true);
toast('✅ Audiobookshelf verbunden');
_setAbsStatusRow('connected', 'Verbunden · ' + url);
} catch (e) {
S.abs.token = null;
toast(`❌ ABS Verbindung fehlgeschlagen: ${e.message}`);
_setAbsStatusRow('error', 'Fehler: ' + e.message);
}
}
function _renderLibPicker(libs) {
const wrap = document.getElementById('abs-lib-picker');
if (!wrap) return;
wrap.style.display = libs.length > 1 ? '' : 'none';
const sel = wrap.querySelector('select');
if (sel) {
sel.innerHTML = libs.map(l =>
`<option value="${esc(l.id)}"${l.id === S.abs.currentLibraryId ? ' selected' : ''}>${esc(l.name)}</option>`
).join('');
}
}
export function absSetLibrary(libId) {
S.abs.currentLibraryId = libId;
absSaveToStorage();
}
export async function absLoadSettingsLibraries() {
if (!S.abs.token) return;
try {
const libs = await absGetLibraries();
_renderLibPicker(libs);
} catch {}
}
export function absDisconnect() {
S.abs.url = ''; S.abs.token = null;
S.abs.userId = null; S.abs.currentLibraryId = null;
localStorage.removeItem('ns_abs');
_showAbsNavItem(false);
toast('Audiobookshelf getrennt');
_setAbsStatusRow('none', 'Nicht verbunden');
}