/** * diora — radio player * Handles playback, SSE metadata, search, station management, and affiliate links. */ 'use strict'; // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- // Hardcoded for now -- only creamfresh radio gets the vote buttons, since // only its backend (the DJ) actually does anything with them. const CREAMFRESH_RADIO_URL = 'https://diora.creamfresh.xyz/radio/creamfresh-stream/'; let currentStation = null; // { url, name, id } | null let currentTrack = ''; let sseSource = null; let isPlaying = false; let currentPlayId = null; const audio = new Audio(); // Reconnect audio when the output device changes (e.g. Bluetooth, USB DAC) if (navigator.mediaDevices) { let _deviceChangeDebounce = null; navigator.mediaDevices.addEventListener('devicechange', () => { clearTimeout(_deviceChangeDebounce); _deviceChangeDebounce = setTimeout(() => { if (!isPlaying || audio.src === '') return; const savedTime = audio.currentTime; const savedSrc = audio.src; audio.src = savedSrc; audio.load(); if (savedTime > 0) { audio.addEventListener('loadedmetadata', function onMeta() { audio.currentTime = savedTime; audio.removeEventListener('loadedmetadata', onMeta); }); } audio.play().catch(() => {}); }, 500); }); } // --------------------------------------------------------------------------- // DOM helpers // --------------------------------------------------------------------------- function $(id) { return document.getElementById(id); } function getCsrfToken() { const cookie = document.cookie.split('; ').find(r => r.startsWith('csrftoken=')); return cookie ? cookie.split('=')[1] : ''; } function formatDateTime(iso) { if (!iso) return ''; const d = new Date(iso); const pad = n => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ` + `${pad(d.getHours())}:${pad(d.getMinutes())}`; } function escapeHtml(str) { return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } // --------------------------------------------------------------------------- // Modal dialogs — styled replacements for native alert()/confirm()/prompt(), // which look jarring against the rest of the custom dark UI and can't be // styled or (in confirm/prompt's case) awaited without blocking the thread. // All three return a Promise; callers must be async and `await` them. // --------------------------------------------------------------------------- function _showModal({ message, showInput = false, inputValue = '', showCancel = false, okLabel = 'OK', danger = false }) { return new Promise((resolve) => { const overlay = $('modal-overlay'); const dialog = $('modal-dialog'); const msgEl = $('modal-message'); const inputEl = $('modal-input'); const okBtn = $('modal-ok-btn'); const cancelBtn = $('modal-cancel-btn'); if (!overlay || !dialog) { resolve(null); return; } msgEl.textContent = message; inputEl.style.display = showInput ? '' : 'none'; inputEl.value = inputValue; cancelBtn.style.display = showCancel ? '' : 'none'; okBtn.textContent = okLabel; okBtn.className = danger ? 'btn btn-danger' : 'btn btn-primary'; overlay.style.display = ''; dialog.style.display = ''; (showInput ? inputEl : okBtn).focus(); if (showInput) inputEl.select(); function cleanup(result) { overlay.style.display = 'none'; dialog.style.display = 'none'; overlay.removeEventListener('click', onCancel); okBtn.removeEventListener('click', onOk); cancelBtn.removeEventListener('click', onCancel); document.removeEventListener('keydown', onKeydown); resolve(result); } function onOk() { cleanup(showInput ? inputEl.value : true); } function onCancel() { cleanup(showInput ? null : false); } function onKeydown(e) { if (e.key === 'Escape') onCancel(); if (e.key === 'Enter' && showInput) onOk(); } okBtn.addEventListener('click', onOk); cancelBtn.addEventListener('click', onCancel); overlay.addEventListener('click', onCancel); document.addEventListener('keydown', onKeydown); }); } function customAlert(message) { return _showModal({ message }); } function customConfirm(message, { danger = false } = {}) { return _showModal({ message, showCancel: true, danger }); } // The server answers with short English error codes (an API contract, tests and // other clients rely on them); what the page shows of them is German. const _GERMAN_ERRORS = { 'no stations found in file': 'In der Datei wurden keine Sender gefunden.', 'file must be .m3u or .m3u8': 'Die Datei muss eine .m3u- oder .m3u8-Datei sein.', 'no file uploaded': 'Keine Datei hochgeladen.', 'only jpeg, png, or webp allowed': 'Nur JPEG, PNG oder WebP erlaubt.', 'file too large (max 50 MB)': 'Datei zu groß (höchstens 50 MB).', 'file too large (max 5 MB)': 'Datei zu groß (höchstens 5 MB).', 'authentication required': 'Bitte anmelden.', 'not found': 'Nicht gefunden.', 'invalid JSON': 'Ungültige Anfrage.', 'upstream unreachable': 'Der Server dahinter ist nicht erreichbar.', }; function _germanError(code) { return _GERMAN_ERRORS[code] || code; } function customPrompt(message, defaultValue = '') { return _showModal({ message, showInput: true, inputValue: defaultValue, showCancel: true }); } // --------------------------------------------------------------------------- // Play / Stop // --------------------------------------------------------------------------- function playStation(url, name, stationId) { stopPlayback(false); if (window.__dioraAmbientStop) window.__dioraAmbientStop(); // A secure page can't load plain-http streams (mixed content). Native shells // that play the stream themselves set window.DIORA_PLAYS_HTTP_STREAMS. if (location.protocol === 'https:' && url.startsWith('http://') && !window.DIORA_PLAYS_HTTP_STREAMS) { window.open(url, '_blank'); return; } currentStation = { url, name, id: stationId || null }; isPlaying = true; audio.src = url; const volSlider = document.getElementById('volume'); if (volSlider) audio.volume = volSlider.value / 255; audio.play().catch(() => { // Browser may block autoplay; the user needs to interact first console.warn('Audio play blocked by browser policy.'); }); $('now-playing-station').textContent = name; $('now-playing-track').textContent = ''; $('play-stop-btn').style.display = ''; $('play-stop-btn').textContent = '⏹ Stopp'; $('play-stop-btn').classList.add('playing'); $('save-station-btn').style.display = ''; const isCreamfresh = url === CREAMFRESH_RADIO_URL; $('creamfresh-vote-up-btn').style.display = isCreamfresh ? '' : 'none'; $('creamfresh-vote-down-btn').style.display = isCreamfresh ? '' : 'none'; $('creamfresh-vote-up-btn').classList.remove('active'); $('creamfresh-vote-down-btn').classList.remove('active'); startMetadataSSE(url); startPlaySession(name, url); maybeShowDonationHint(url, name); if ('mediaSession' in navigator) { navigator.mediaSession.metadata = new MediaMetadata({title: name, artist: 'Radio'}); navigator.mediaSession.setActionHandler('play', () => { audio.play(); isPlaying = true; }); navigator.mediaSession.setActionHandler('pause', () => { audio.pause(); isPlaying = false; }); navigator.mediaSession.setActionHandler('stop', () => stopPlayback(true)); try { navigator.mediaSession.setActionHandler('seekbackward', null); } catch (_) {} try { navigator.mediaSession.setActionHandler('seekforward', null); } catch (_) {} try { navigator.mediaSession.setActionHandler('nexttrack', null); } catch (_) {} try { navigator.mediaSession.setActionHandler('previoustrack',null); } catch (_) {} navigator.mediaSession.playbackState = 'playing'; } } function stopPlayback(clearStation = true) { audio.pause(); audio.src = ''; isPlaying = false; if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'none'; if (sseSource) { sseSource.close(); sseSource = null; } $('play-stop-btn').textContent = '▶ Abspielen'; $('play-stop-btn').classList.remove('playing'); $('save-station-btn').style.display = 'none'; $('creamfresh-vote-up-btn').style.display = 'none'; $('creamfresh-vote-down-btn').style.display = 'none'; $('affiliate-section').style.display = 'none'; stopPlaySession(); const stationEl = $('now-playing-station'); const trackEl = $('now-playing-track'); if (clearStation) { currentStation = null; currentTrack = ''; stationEl.textContent = '— kein Sender —'; trackEl.textContent = ''; $('play-stop-btn').style.display = 'none'; } } function togglePlayStop() { if (isPlaying) { stopPlayback(true); } else if (currentStation) { playStation(currentStation.url, currentStation.name, currentStation.id); } } // --------------------------------------------------------------------------- // Play session tracking // --------------------------------------------------------------------------- async function startPlaySession(stationName, stationUrl) { try { const res = await fetch('/radio/play/start/', { method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, body: JSON.stringify({station_name: stationName, station_url: stationUrl}) }); if (res.ok) { const data = await res.json(); currentPlayId = data.play_id; } } catch (e) {} } async function stopPlaySession() { // Take the id before awaiting: playStation() calls this without waiting and // starts the next session right away, whose id must not be cleared here. const playId = currentPlayId; if (!playId) return; currentPlayId = null; try { await fetch('/radio/play/stop/', { method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, body: JSON.stringify({play_id: playId}) }); } catch (e) {} } window.addEventListener('beforeunload', () => { if (currentPlayId) { navigator.sendBeacon('/radio/play/stop/', JSON.stringify({play_id: currentPlayId})); } // Flush cached encrypted payloads for reader data (encryption is async so we // use pre-computed blobs stored by the debounced savers) if (_lastProgressBeacon) navigator.sendBeacon(_lastProgressBeacon.url, _lastProgressBeacon.body); if (_lastBookmarkBeacon) navigator.sendBeacon(_lastBookmarkBeacon.url, _lastBookmarkBeacon.body); if (_lastHighlightBeacon) navigator.sendBeacon(_lastHighlightBeacon.url, _lastHighlightBeacon.body); }); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden' && currentBookId) { if (bookmarksDirty) saveBookmarks(); if (highlightsDirty) saveHighlights(); saveReaderProgress(); } }); // Cached beacon payloads — updated after each successful encrypt in save functions let _lastBookmarkBeacon = null; let _lastHighlightBeacon = null; let _lastProgressBeacon = null; // --------------------------------------------------------------------------- // Recommendations // --------------------------------------------------------------------------- async function loadRecommendations() { const container = document.getElementById('recommendations'); if (!container) return; try { const res = await fetch('/radio/recommendations/'); const data = await res.json(); if (!data.recommendations.length) { container.innerHTML = '

Hör mehr Sender, dann gibt es hier Empfehlungen.

'; return; } const label = data.context; let html = `

Was du ${label} oft hörst:

'; container.innerHTML = html; } catch (e) {} } function escapeAttr(s) { return String(s).replace(/'/g, "\\'").replace(/"/g, '"'); } // --------------------------------------------------------------------------- // Volume // --------------------------------------------------------------------------- function setVolume(val) { val = Math.max(0, Math.min(255, Math.round(val))); audio.volume = val / 255; localStorage.setItem('diora_volume', val); const slider = $('volume'); const numInput = $('volume-num'); if (slider) slider.value = val; if (numInput) numInput.value = val; _ambientVolume(val); } const volSliderEl = document.getElementById('volume'); if (volSliderEl) { ['input', 'change', 'mousemove', 'touchmove'].forEach(evt => volSliderEl.addEventListener(evt, function () { setVolume(this.value); }) ); } const volNumEl = document.getElementById('volume-num'); if (volNumEl) { volNumEl.addEventListener('change', function () { setVolume(this.value); }); volNumEl.addEventListener('input', function () { setVolume(this.value); }); volNumEl.addEventListener('click', function () { this.select(); }); } const volSliderEl2 = document.getElementById('volume'); const volWheelTarget = volSliderEl2 || volNumEl; if (volWheelTarget) { volWheelTarget.addEventListener('wheel', function (e) { e.preventDefault(); const current = parseInt(document.getElementById('volume').value, 10); setVolume(current + (e.deltaY < 0 ? 4 : -4)); }, { passive: false }); } // --------------------------------------------------------------------------- // SSE metadata // --------------------------------------------------------------------------- function startMetadataSSE(streamUrl) { if (sseSource) { sseSource.close(); sseSource = null; } const endpoint = '/radio/sse/?url=' + encodeURIComponent(streamUrl); sseSource = new EventSource(endpoint); sseSource.onmessage = function (e) { let data; try { data = JSON.parse(e.data); } catch (_) { return; } if (data.error) { console.warn('SSE stream ended:', data.error); return; } if (data.track && data.track !== currentTrack) { currentTrack = data.track; updateNowPlayingUI(data.track); recordTrack(currentStation ? currentStation.name : '', data.track); fetchAffiliateLinks(data.track); } }; sseSource.onerror = function () { // Connection dropped; the browser will attempt to reconnect automatically console.warn('SSE connection error, browser will retry.'); }; } function updateNowPlayingUI(track) { $('now-playing-track').textContent = track; } // --------------------------------------------------------------------------- // Record track // --------------------------------------------------------------------------- async function recordTrack(stationName, track) { try { const res = await fetch('/radio/record/', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken(), }, body: JSON.stringify({ station_name: stationName, track, scrobble: true }), }); if (res.ok) { addHistoryRow(stationName, track); } } catch (err) { console.error('recordTrack error:', err); } } function addHistoryRow(stationName, track) { const tbody = $('history-tbody'); if (!tbody) return; // Remove the "no history" placeholder row if present const emptyRow = $('history-empty-row'); if (emptyRow) emptyRow.remove(); const tr = document.createElement('tr'); const now = new Date().toISOString(); tr.innerHTML = ` ${escapeHtml(formatDateTime(now))} ${escapeHtml(stationName)} ${escapeHtml(track)} `; tbody.insertBefore(tr, tbody.firstChild); } async function deleteHistoryEntry(id, btn) { const tr = btn.closest('tr'); if (!id) { tr.remove(); return; } try { const res = await fetch(`/radio/history/${id}/delete/`, { method: 'POST', headers: {'X-CSRFToken': getCsrfToken()}, }); if (res.ok) tr.remove(); } catch (e) {} } // --------------------------------------------------------------------------- // Affiliate links // --------------------------------------------------------------------------- async function fetchAffiliateLinks(track) { const section = $('affiliate-section'); if (section && section.dataset.disabled) return; try { const res = await fetch('/radio/affiliate/?track=' + encodeURIComponent(track)); if (!res.ok) return; const data = await res.json(); const itunes = data.itunes_data || {}; $('affiliate-track-name').textContent = itunes.name || track; $('affiliate-artist-name').textContent = itunes.artist || ''; $('affiliate-album-name').textContent = itunes.album || ''; const artEl = $('affiliate-artwork'); if (itunes.artwork) { artEl.src = itunes.artwork; artEl.style.display = ''; } else { artEl.style.display = 'none'; } const amzLink = $('affiliate-amazon-link'); if (data.amazon_url) { amzLink.href = data.amazon_url; amzLink.style.display = ''; } else { amzLink.style.display = 'none'; } section.style.display = 'flex'; } catch (err) { console.error('fetchAffiliateLinks error:', err); section.style.display = 'none'; } } // --------------------------------------------------------------------------- // Search (radio-browser.info) // --------------------------------------------------------------------------- async function doSearch() { const query = $('search-input').value.trim(); if (!query) return; const statusEl = $('search-status'); const tableEl = $('search-results-table'); const tbody = $('search-results-body'); tableEl.style.display = 'none'; tbody.innerHTML = ''; // A stream address instead of a name: offer to play or save it directly. if (/^https?:\/\/\S+$/i.test(query)) { showStreamAddressResult(query); return; } statusEl.textContent = 'Suche …'; try { const url = `https://de1.api.radio-browser.info/json/stations/search?name=${encodeURIComponent(query)}&limit=50&hidebroken=true&order=clickcount&reverse=true`; const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); const stations = await res.json(); if (!stations.length) { statusEl.textContent = 'Keine Sender gefunden.'; return; } statusEl.textContent = stations.length === 1 ? '1 Treffer' : `${stations.length} Treffer`; tableEl.style.display = ''; const curated = document.getElementById('curated-lists'); if (curated) curated.style.display = 'none'; stations.forEach(st => { const tr = document.createElement('tr'); const safeName = escapeHtml(st.name || ''); const safeUrl = escapeHtml(st.url_resolved || st.url || ''); const safeBr = escapeHtml(st.bitrate ? st.bitrate + ' kbps' : ''); const safeCC = escapeHtml(st.countrycode || st.country || ''); const safeTags = escapeHtml((st.tags || '').split(',').slice(0, 3).join(', ')); tr.innerHTML = ` ${safeName} ${safeBr} ${safeCC} ${safeTags} `; tbody.appendChild(tr); }); } catch (err) { statusEl.textContent = 'Suche fehlgeschlagen: ' + err.message; } } // A name to start from for a pasted address: "klassik.mp3" on radio.example.org // -> "Klassik (radio.example.org)". The query string is left out on purpose — // it often carries an access token, which shouldn't end up on screen. function _streamAddressName(url) { try { const u = new URL(url); const file = decodeURIComponent(u.pathname.split('/').filter(Boolean).pop() || ''); const base = file.replace(/\.(mp3|aac|aacp|ogg|opus|m3u8?|pls|flac)$/i, '').replace(/[-_]+/g, ' ').trim(); const nice = base ? base.charAt(0).toUpperCase() + base.slice(1) : ''; return nice ? `${nice} (${u.host})` : u.host; } catch (e) { return url; } } function showStreamAddressResult(url) { const statusEl = $('search-status'); const tableEl = $('search-results-table'); const tbody = $('search-results-body'); const name = _streamAddressName(url); const shown = url.split('?')[0]; const data = {name, url, bitrate: '', country: '', tags: '', favicon_url: ''}; statusEl.textContent = 'Stream-Adresse'; const curated = document.getElementById('curated-lists'); if (curated) curated.style.display = 'none'; // Same five columns as a radio-browser result: name, bitrate, country, tags, actions. const tr = document.createElement('tr'); tr.className = 'stream-address-row'; for (let i = 0; i < 4; i++) tr.appendChild(document.createElement('td')); tr.children[0].textContent = name; tr.children[0].title = shown; tr.children[3].textContent = shown; tr.children[3].title = shown; const actions = document.createElement('td'); const play = document.createElement('button'); play.className = 'btn btn-sm btn-play'; play.innerHTML = '▶ Abspielen'; play.addEventListener('click', () => searchPlay(url, name, data)); const save = document.createElement('button'); save.className = 'btn btn-sm'; save.innerHTML = '★ Speichern'; save.style.marginLeft = '4px'; save.addEventListener('click', async e => { e.stopPropagation(); const chosen = await customPrompt('Name für diesen Sender', name); if (chosen == null) return; const finalName = chosen.trim() || name; await saveStation({...data, name: finalName}); statusEl.textContent = `Gespeichert als „${finalName}“.`; }); actions.append(play, save); tr.appendChild(actions); tbody.appendChild(tr); tableEl.style.display = ''; } function searchPlay(url, name, stationData) { // Store station data on the window so saveCurrentStation() can use it window._pendingStationData = stationData; playStation(url, name, null); } // --------------------------------------------------------------------------- // Save current station // --------------------------------------------------------------------------- async function saveCurrentStation() { if (!currentStation) return; // Use the rich data from search results if available, otherwise minimal data const data = window._pendingStationData || { name: currentStation.name, url: currentStation.url, bitrate: '', country: '', tags: '', favicon_url: '', }; await saveStation(data); } // --------------------------------------------------------------------------- // creamfresh radio: DJ feedback (hardcoded to this one station, see // CREAMFRESH_RADIO_URL above) // --------------------------------------------------------------------------- async function creamfreshVote(direction) { const upBtn = $('creamfresh-vote-up-btn'); const downBtn = $('creamfresh-vote-down-btn'); upBtn.disabled = true; downBtn.disabled = true; try { const res = await fetch('/radio/creamfresh-feedback/', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken(), }, body: JSON.stringify({ vote: direction }), }); if (!res.ok) throw new Error(`feedback returned ${res.status}`); upBtn.classList.toggle('active', direction === 'up'); downBtn.classList.toggle('active', direction === 'down'); } catch (err) { console.warn('creamfresh vote failed', err); } finally { upBtn.disabled = false; downBtn.disabled = false; } } async function saveStation(station) { try { const res = await fetch('/radio/save/', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken(), }, body: JSON.stringify(station), }); if (res.status === 401) { await customAlert('Bitte anmelden, um Sender zu speichern.'); return; } const data = await res.json(); if (data.ok) { if (data.created) { addSavedRow({ id: data.id, ...station, is_favorite: false }); } } } catch (err) { console.error('saveStation error:', err); } } function addSavedRow(station) { const tbody = $('saved-tbody'); if (!tbody) return; const emptyRow = $('saved-empty-row'); if (emptyRow) emptyRow.remove(); // Check for duplicate if (document.getElementById(`saved-row-${station.id}`)) return; const tr = document.createElement('tr'); tr.id = `saved-row-${station.id}`; tr.dataset.id = station.id; tr.dataset.url = station.url; tr.dataset.name = station.name; const safeName = escapeHtml(station.name || ''); const safeBr = escapeHtml(station.bitrate || ''); const safeCC = escapeHtml(station.country || ''); const safeUrl = escapeHtml(station.url || ''); tr.innerHTML = ` ${safeName} ${safeBr} ${safeCC} `; tbody.appendChild(tr); } // --------------------------------------------------------------------------- // Remove station // --------------------------------------------------------------------------- // The only toggleFav: a second definition further down (expecting the button // as an argument) used to shadow this one, so the server-rendered rows — which // pass no button — toggled the favourite on the server without the star ever // changing, and a second click silently took it back. async function toggleFav(pk, btnEl) { try { const res = await fetch(`/radio/favorite/${pk}/`, { method: 'POST', headers: { 'X-CSRFToken': getCsrfToken() }, }); if (!res.ok) return; const data = await res.json(); // Flip the star button state const btn = btnEl || document.querySelector(`#saved-row-${pk} .fav-btn`); if (btn) btn.classList.toggle('active', !!data.is_favorite); // Re-sort rows in the DOM: favorites first, then alphabetically const tbody = $('saved-tbody'); if (!tbody) return; const rows = Array.from(tbody.querySelectorAll('tr[data-id]')); rows.sort((a, b) => { const aFav = a.querySelector('.fav-btn')?.classList.contains('active') ? 0 : 1; const bFav = b.querySelector('.fav-btn')?.classList.contains('active') ? 0 : 1; if (aFav !== bFav) return aFav - bFav; return a.dataset.name.localeCompare(b.dataset.name); }); rows.forEach(row => tbody.appendChild(row)); } catch (err) { console.error('toggleFav error', err); } } async function removeStation(pk) { try { const res = await fetch(`/radio/remove/${pk}/`, { method: 'POST', headers: { 'X-CSRFToken': getCsrfToken() }, }); if (res.ok) { const row = $(`saved-row-${pk}`); if (row) row.remove(); const tbody = $('saved-tbody'); if (tbody && tbody.querySelectorAll('tr').length === 0) { const tr = document.createElement('tr'); tr.id = 'saved-empty-row'; tr.innerHTML = 'Noch keine gespeicherten Sender.'; tbody.appendChild(tr); } } } catch (err) { console.error('removeStation error:', err); } } // --------------------------------------------------------------------------- // Focus Timer // --------------------------------------------------------------------------- const TIMER_WORK = 25 * 60; const TIMER_BREAK = 5 * 60; let timerSeconds = TIMER_WORK; let timerRunning = false; let timerIsBreak = false; let timerInterval = null; function timerTick() { timerSeconds--; renderTimer(); if (timerSeconds <= 0) { clearInterval(timerInterval); timerInterval = null; timerRunning = false; if (!timerIsBreak) { // work session ended → start break timerIsBreak = true; recordFocusSession(); timerSeconds = TIMER_BREAK; showTimerNotification('Pause! 5 Minuten.'); // auto-pause playback during break if (audio.src && !audio.paused) audio.pause(); } else { // break ended → reset to work timerIsBreak = false; timerSeconds = TIMER_WORK; showTimerNotification('Pause vorbei. Weiter geht’s.'); } renderTimer(); } } function toggleTimer() { if (timerRunning) { clearInterval(timerInterval); timerInterval = null; timerRunning = false; } else { if ('Notification' in window && Notification.permission === 'default') { Notification.requestPermission(); } timerRunning = true; timerInterval = setInterval(timerTick, 1000); } renderTimer(); } function resetTimer() { clearInterval(timerInterval); timerInterval = null; timerRunning = false; timerIsBreak = false; timerSeconds = TIMER_WORK; renderTimer(); } function renderTimer() { const m = String(Math.floor(timerSeconds / 60)).padStart(2, '0'); const s = String(timerSeconds % 60).padStart(2, '0'); const display = $('timer-display'); const btn = $('timer-toggle-btn'); const label = $('timer-phase-label'); if (display) display.textContent = `${m}:${s}`; if (btn) btn.textContent = timerRunning ? '⏸' : '▶'; if (label) label.textContent = timerIsBreak ? 'Pause' : 'Fokus'; // colour the display red when break if (display) display.style.color = timerIsBreak ? '#e63946' : ''; } function showTimerNotification(msg) { if ('Notification' in window && Notification.permission === 'granted') { new Notification('diora', { body: msg }); } // also flash in the timer label const label = $('timer-phase-label'); if (label) { label.textContent = msg; setTimeout(() => renderTimer(), 3000); } } // --------------------------------------------------------------------------- // Focus session recording // --------------------------------------------------------------------------- async function recordFocusSession() { try { await fetch('/radio/focus/record/', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken() }, body: JSON.stringify({ station_name: currentStation ? currentStation.name : '', duration_minutes: 25, }), }); loadFocusStats(); } catch (e) {} } async function loadFocusStats() { try { const res = await fetch('/radio/focus/stats/'); const data = await res.json(); const widget = document.getElementById('focus-today-widget'); if (widget) { if (data.today_sessions > 0) { widget.textContent = `Heute: ${data.today_sessions} ${data.today_sessions !== 1 ? 'Sitzungen' : 'Sitzung'} · ${data.today_minutes} min`; widget.style.display = ''; } else { widget.style.display = 'none'; } } // populate focus tab const tbody = document.getElementById('focus-tbody'); if (!tbody) return; tbody.innerHTML = ''; if (!data.sessions.length) { tbody.innerHTML = 'Noch keine Fokus-Sitzungen. Starte den Timer!'; return; } data.sessions.forEach(s => { const tr = document.createElement('tr'); const dt = new Date(s.completed_at).toLocaleString([], {dateStyle: 'short', timeStyle: 'short'}); tr.innerHTML = `${dt}${escapeHtml(s.station_name || '—')}${s.duration_minutes} min`; tbody.appendChild(tr); }); } catch (e) {} } // --------------------------------------------------------------------------- // Do Not Disturb / focus mode // --------------------------------------------------------------------------- let dndActive = false; function toggleDNDLight() { document.body.classList.toggle('dnd-dark'); const btn = $('dnd-light-btn'); if (btn) btn.style.opacity = document.body.classList.contains('dnd-dark') ? '0.4' : '1'; } function toggleDND() { dndActive = !dndActive; if (!dndActive) document.body.classList.remove('dnd-dark'); document.body.classList.toggle('dnd-mode', dndActive); const btn = $('dnd-btn'); if (btn) btn.classList.toggle('active', dndActive); if (dndActive) { const el = document.documentElement; if (el.requestFullscreen) el.requestFullscreen(); else if (el.webkitRequestFullscreen) el.webkitRequestFullscreen(); } else { if (document.fullscreenElement && document.exitFullscreen) document.exitFullscreen(); else if (document.webkitFullscreenElement && document.webkitExitFullscreen) document.webkitExitFullscreen(); } } // Exit DND on Escape (browser also exits fullscreen on Escape, so sync state) document.addEventListener('fullscreenchange', () => { if (!document.fullscreenElement && dndActive) { dndActive = false; document.body.classList.remove('dnd-mode'); const btn = $('dnd-btn'); if (btn) btn.classList.remove('active'); } }); document.addEventListener('keydown', e => { if (e.key === 'Escape' && dndActive) toggleDND(); }); // --------------------------------------------------------------------------- // Mood / genre tag filter // --------------------------------------------------------------------------- const MOOD_TAGS = [ { label: 'Fokus', tag: 'ambient' }, { label: 'Lo-fi', tag: 'lofi' }, { label: 'Jazz', tag: 'jazz' }, { label: 'Klassik', tag: 'classical' }, { label: 'Ambient', tag: 'ambient' }, { label: 'Metal', tag: 'metal' }, { label: 'Elektronisch', tag: 'electronic' }, { label: 'Wort', tag: 'talk' }, ]; function initMoodChips() { const container = $('mood-chips'); if (!container) return; MOOD_TAGS.forEach(({ label, tag }) => { const btn = document.createElement('button'); btn.className = 'mood-chip'; btn.textContent = label; btn.onclick = () => { const input = $('search-input'); if (input) input.value = tag; doSearch(); }; container.appendChild(btn); }); } // --------------------------------------------------------------------------- // Curated station lists // --------------------------------------------------------------------------- const CURATED_LISTS = [ { id: 'focus', label: 'Fokus', stations: [ { name: 'SomaFM Drone Zone', url: 'https://ice6.somafm.com/dronezone-256-mp3' }, { name: 'SomaFM Groove Salad', url: 'https://ice5.somafm.com/groovesalad-128-aac' }, { name: 'Nightride FM', url: 'https://stream.nightride.fm/nightride.mp3' }, { name: 'Nightride FM Chillsynth', url: 'https://stream.nightride.fm/chillsynth.mp3' }, ], }, { id: 'lofi', label: 'Lo-fi / Chill', stations: [ { name: 'SomaFM Groove Salad Classic', url: 'https://ice6.somafm.com/gsclassic-128-mp3' }, { name: 'SomaFM Secret Agent', url: 'https://ice4.somafm.com/secretagent-128-mp3' }, { name: 'dublab DE', url: 'https://dublabde.out.airtime.pro/dublabde_a' }, ], }, { id: 'dark', label: 'Dark / Industrial', stations: [ { name: 'SomaFM Doomed', url: 'https://ice2.somafm.com/doomed-256-mp3' }, { name: 'Nightride FM Darksynth', url: 'https://stream.nightride.fm/darksynth.mp3' }, { name: 'Radio Caprice Industrial', url: 'http://79.120.39.202:9095/' }, ], }, { id: 'classical', label: 'Klassik', stations: [ { name: 'BR Klassik', url: 'https://dispatcher.rndfnk.com/br/brklassik/live/mp3/high' }, { name: 'SWR Kultur', url: 'https://f111.rndfnk.com/ard/swr/swr2/live/mp3/256/stream.mp3?aggregator=web' }, { name: 'Deutschlandfunk Kultur', url: 'https://st02.sslstream.dlf.de/dlf/02/high/aac/stream.aac?aggregator=web' }, ], }, ]; function initCuratedLists() { const container = document.getElementById('curated-lists'); if (!container) return; if (INITIAL_FEATURED && INITIAL_FEATURED.length) { const section = document.createElement('div'); section.className = 'curated-section'; section.innerHTML = `
★ Empfohlen
`; const ul = document.createElement('ul'); ul.className = 'curated-stations'; INITIAL_FEATURED.forEach(s => { const li = document.createElement('li'); li.innerHTML = ` ${escapeHtml(s.name)} ${s.description ? `${escapeHtml(s.description)}` : ''}`; ul.appendChild(li); }); section.appendChild(ul); container.appendChild(section); } CURATED_LISTS.forEach(list => { const section = document.createElement('div'); section.className = 'curated-section'; section.innerHTML = `
${list.label}
`; const ul = document.createElement('ul'); ul.className = 'curated-stations'; list.stations.forEach(s => { const li = document.createElement('li'); li.innerHTML = ` ${escapeHtml(s.name)}`; ul.appendChild(li); }); section.appendChild(ul); container.appendChild(section); }); } // --------------------------------------------------------------------------- // Donation hint // --------------------------------------------------------------------------- const DONATION_HINT_THRESHOLD = 10; const DONATION_HINT_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000; // 7 days function maybeShowDonationHint(stationUrl, stationName) { const station = INITIAL_SAVED.find(s => s.url === stationUrl); if (!station || station.play_count < DONATION_HINT_THRESHOLD) return; const key = `diora_donation_hint_${stationUrl}`; const last = parseInt(localStorage.getItem(key) || '0', 10); if (Date.now() - last < DONATION_HINT_COOLDOWN_MS) return; // Past the threshold just means "this station is a regular" — from then on // every single play would otherwise qualify, so only actually show the hint // on roughly 1 in 10 of those plays instead of every time. if (Math.random() >= 0.1) return; const existing = document.getElementById('donation-hint'); if (existing) existing.remove(); const el = document.createElement('div'); el.id = 'donation-hint'; el.innerHTML = ` Du hörst ${escapeHtml(stationName)} oft – vielleicht magst du den Sender unterstützen ♥ `; document.body.appendChild(el); setTimeout(() => dismissDonationHint(stationUrl), 12000); } function dismissDonationHint(stationUrl) { localStorage.setItem(`diora_donation_hint_${stationUrl}`, Date.now()); const el = document.getElementById('donation-hint'); if (el) { el.classList.add('hiding'); setTimeout(() => el.remove(), 400); } } // --------------------------------------------------------------------------- // Station notes // --------------------------------------------------------------------------- async function editNotes(pk, current) { const note = await customPrompt('Notiz zum Sender:', current || ''); if (note === null) return; // cancelled fetch(`/radio/notes/${pk}/`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken() }, body: JSON.stringify({ notes: note }), }).then(r => { if (r.ok) { const cell = document.querySelector(`#saved-row-${pk} .notes-cell`); if (cell) cell.textContent = note; } }); } // --------------------------------------------------------------------------- // Tabs // --------------------------------------------------------------------------- const TOP_TABS = ['radio', 'focus', 'podcasts', 'books', 'ambient']; const RADIO_SUB_TABS = ['search', 'saved', 'history']; // --------------------------------------------------------------------------- // URL hash routing — makes tabs deep-linkable/shareable and lets the browser // back/forward buttons move between them. Deliberately hash-based rather // than real paths: /podcasts/ and /books/ etc. are already taken by the // JSON API, so this avoids touching Django's urls.py at all. // --------------------------------------------------------------------------- let _routingFromHash = false; let _currentTopTab = 'radio'; function _syncHash() { if (_routingFromHash) return; let hash = _currentTopTab; if (_currentTopTab === 'radio') { hash += '/' + (localStorage.getItem('diora_active_radio_tab') || 'saved'); } if (location.hash.slice(1) !== hash) history.pushState(null, '', '#' + hash); } function _routeFromHash() { const [tab, sub] = (location.hash || '').replace(/^#/, '').split('/').filter(Boolean); if (!TOP_TABS.includes(tab)) return; _routingFromHash = true; try { showTab(tab); if (tab === 'radio' && RADIO_SUB_TABS.includes(sub)) showRadioTab(sub); } finally { _routingFromHash = false; } } window.addEventListener('popstate', _routeFromHash); window.addEventListener('hashchange', _routeFromHash); function showOfflineBanner(offline) { document.body.classList.toggle('app-offline', offline); const uploadArea = $('book-upload-area'); if (uploadArea) uploadArea.style.display = offline ? 'none' : ''; } function showTab(name) { TOP_TABS.forEach(p => { const panel = $(`tab-${p}`); if (panel) panel.style.display = (p === name) ? '' : 'none'; }); document.querySelectorAll('#tabs .tab-btn').forEach((btn, i) => { btn.classList.toggle('active', TOP_TABS[i] === name); }); localStorage.setItem('diora_active_tab', name); _currentTopTab = name; _syncHash(); if (name === 'books') loadBookList(); if (name === 'ambient') _ambientLoad(); } // --------------------------------------------------------------------------- // Ambient: "Drift" (static/ambient/), a generative synthesizer in a frame of its // own. It and the radio / podcast player take turns — one starting stops the // other — by postMessage: {type: 'ambient', cmd: 'start'|'stop'} to the frame, // {type: 'ambient', state: 'playing'|'stopped'} from it. // --------------------------------------------------------------------------- let _ambientPlaying = false; function _ambientFrame() { return $('ambient-frame'); } function _ambientLoad() { const f = _ambientFrame(); if (!f) return; if (!f.getAttribute('src')) { // Drift takes the page's volume before its first sound. f.addEventListener('load', () => _ambientVolume()); f.src = f.dataset.src; } requestAnimationFrame(_ambientSize); } // The frame fills the room between the tabs and the player bar at the bottom. function _ambientSize() { const f = _ambientFrame(); if (!f || _currentTopTab !== 'ambient') return; const root = getComputedStyle(document.documentElement); const bar = parseFloat(root.getPropertyValue('--nui-dock-h')) || parseFloat(root.getPropertyValue('--dpw-bar-h')) || 0; const top = f.getBoundingClientRect().top + window.scrollY; f.style.height = Math.max(320, Math.floor(window.innerHeight - top - bar)) + 'px'; } window.addEventListener('resize', () => requestAnimationFrame(_ambientSize)); // A command for Drift ('start' | 'stop'): the app's notification, the sleep timer. window.__dioraAmbientCmd = function (cmd, value) { const f = _ambientFrame(); if (f && f.contentWindow && f.getAttribute('src')) f.contentWindow.postMessage({type: 'ambient', cmd, value}, '*'); }; // The radio or a podcast starts: Ambient stops. window.__dioraAmbientStop = function () { if (_ambientPlaying) window.__dioraAmbientCmd('stop'); }; window.__dioraAmbientPlaying = () => _ambientPlaying; // The page's volume (0–255, the bar's slider) is Drift's too; its own slider hides when // embedded. Not in the Android app: there the volume keys set it, as for everything. function _ambientVolume(val) { if (window.DioraNative && DioraNative.ambientState) return; if (val == null) { const s = $('volume'); val = s ? +s.value : +(localStorage.getItem('diora_volume') || 102); } window.__dioraAmbientCmd && window.__dioraAmbientCmd('volume', val / 255); } window.addEventListener('message', e => { const f = _ambientFrame(); const d = e.data; if (!f || e.source !== f.contentWindow || !d || d.type !== 'ambient' || !d.state) return; _ambientPlaying = d.state === 'playing'; // The app keeps it going with the screen off (AmbientService); the browser's sleep timer counts it. if (window.DioraNative && DioraNative.ambientState) DioraNative.ambientState(_ambientPlaying); if (window.__dioraOnAmbient) window.__dioraOnAmbient(_ambientPlaying); if (!_ambientPlaying) return; // Ambient starts: the radio and a podcast here stop. if (typeof isPlaying !== 'undefined' && isPlaying) stopPlayback(false); if (window.__dioraPausePlayer) window.__dioraPausePlayer(); }); function showRadioTab(name) { RADIO_SUB_TABS.forEach(p => { const panel = $(`tab-${p}`); if (panel) panel.style.display = (p === name) ? '' : 'none'; }); document.querySelectorAll('#radio-sub-tabs .tab-btn').forEach((btn, i) => { btn.classList.toggle('active', RADIO_SUB_TABS[i] === name); }); localStorage.setItem('diora_active_radio_tab', name); _syncHash(); if (name === 'saved') loadRecommendations(); } // --------------------------------------------------------------------------- // Sidebar // --------------------------------------------------------------------------- function openSidebar(title, htmlContent) { $('sidebar-title').textContent = title; $('sidebar-body').innerHTML = sanitizeSidebarHtml(htmlContent); $('sidebar-overlay').style.display = ''; $('sidebar').classList.add('open'); } function closeSidebar() { $('sidebar').classList.remove('open'); $('sidebar-overlay').style.display = 'none'; } document.addEventListener('keydown', e => { const overlay = $('reader-overlay'); const readerOpen = overlay && overlay.style.display !== 'none'; if (e.key === 'Escape') { if (readerOpen) { closeReader(); } else { closeSidebar(); } } if (readerOpen) { if ((e.ctrlKey && e.key === 'f') || e.key === 'F3') { e.preventDefault(); toggleReaderSearch(); } if (readerSearchOpen) { if (e.key === 'ArrowDown') { e.preventDefault(); readerSearchNext(); } if (e.key === 'ArrowUp') { e.preventDefault(); readerSearchPrev(); } } if (readerSettings.pdfPaginated && currentPdfDoc) { if (e.key === 'ArrowRight') { e.preventDefault(); pdfGoToPage(pdfCurrentPage + 1); } if (e.key === 'ArrowLeft') { e.preventDefault(); pdfGoToPage(pdfCurrentPage - 1); } } // Vim-style scroll — ignore when typing in an input const tag = document.activeElement?.tagName; if (tag !== 'INPUT' && tag !== 'TEXTAREA') { const contentEl = $('reader-content'); if (contentEl) { const large = Math.round(contentEl.clientHeight * 0.85); const continuousKeys = {'j': 1, 'k': -1}; if (continuousKeys[e.key] !== undefined && !e.repeat) { e.preventDefault(); _readerScrollDir = continuousKeys[e.key]; if (!_readerScrollInterval) { _readerScrollLastTs = performance.now(); _readerScrollInterval = setInterval(_readerScrollTick, READER_SCROLL_TICK_MS); } } if (e.key === 'd') { e.preventDefault(); contentEl.scrollBy({top: large / 2, behavior: 'smooth'}); } if (e.key === 'u') { e.preventDefault(); contentEl.scrollBy({top: -large / 2, behavior: 'smooth'}); } if (e.key === 'f') { e.preventDefault(); contentEl.scrollBy({top: large, behavior: 'smooth'}); } if (e.key === 'b') { e.preventDefault(); contentEl.scrollBy({top: -large, behavior: 'smooth'}); } if (e.key === 'g') { e.preventDefault(); contentEl.scrollTop = 0; } if (e.key === 'G') { e.preventDefault(); contentEl.scrollTop = contentEl.scrollHeight; } if (currentPdfDoc && !readerSettings.pdfPaginated) { if (e.key === 'n') { e.preventDefault(); pdfGoToPage(pdfCurrentPage + 1); } if (e.key === 'p') { e.preventDefault(); pdfGoToPage(pdfCurrentPage - 1); } } } } } }); document.addEventListener('keyup', e => { if (e.key === 'j' || e.key === 'k') { clearInterval(_readerScrollInterval); _readerScrollInterval = null; _readerScrollDir = 0; } }); function sanitizeSidebarHtml(html) { if (!html) return '

Keine Shownotes vorhanden.

'; const div = document.createElement('div'); div.innerHTML = html; div.querySelectorAll('script, iframe, object, embed, style').forEach(el => el.remove()); div.querySelectorAll('*').forEach(el => { Array.from(el.attributes).forEach(attr => { if (attr.name.startsWith('on')) el.removeAttribute(attr.name); }); if (el.tagName === 'A') { el.setAttribute('target', '_blank'); el.setAttribute('rel', 'noopener noreferrer'); } }); return div.innerHTML; } // --------------------------------------------------------------------------- // Service Worker // --------------------------------------------------------------------------- if ('serviceWorker' in navigator) { window.addEventListener('load', () => { // Registered from '/sw.js' (root), not '/static/js/sw.js': a service // worker's default scope is its own script's directory, so registering // it from under /static/js/ would only ever let it control requests // under that path — page navigations to '/', '/books/' etc. would never // be intercepted, breaking offline entirely. navigator.serviceWorker.register('/sw.js', {scope: '/'}).catch(err => { console.warn('Service worker registration failed:', err); }); // Drop any leftover registration from the old, wrongly-scoped '/static/js/sw.js' // path so it doesn't keep running or fight the new root-scoped worker. navigator.serviceWorker.getRegistrations().then(regs => { regs.forEach(r => { if (r.scope.endsWith('/static/js/')) r.unregister(); }); }); }); // When a new SW activates it sends SW_ACTIVATED — reload to get fresh assets, // but never in the middle of something: not while a book is open, radio or a // podcast plays, or a text field has the focus — then as soon as that's over. let reloadPending = false; const busy = () => { if (currentBookId || isPlaying) return true; const host = window.DioraPodcastHost; if (host && host.playerState) { try { if (JSON.parse(host.playerState()).playing) return true; } catch (_) {} } const el = document.activeElement; return !!el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName)); }; const reloadWhenIdle = () => { if (busy()) setTimeout(reloadWhenIdle, 5000); else window.location.reload(); }; navigator.serviceWorker.addEventListener('message', e => { if (e.data?.type === 'SW_ACTIVATED' && !reloadPending) { reloadPending = true; reloadWhenIdle(); } }); } // --------------------------------------------------------------------------- // M3U import // --------------------------------------------------------------------------- async function importM3U(input) { const file = input.files[0]; if (!file) return; const status = document.getElementById('import-status'); status.textContent = 'Importiere …'; const form = new FormData(); form.append('file', file); form.append('csrfmiddlewaretoken', getCsrfToken()); try { const res = await fetch('/radio/import/', { method: 'POST', body: form }); const data = await res.json(); if (data.ok) { status.textContent = `✓ ${data.added} hinzugefügt, ${data.skipped} schon gespeichert`; if (data.added > 0) location.reload(); } else { status.textContent = `Fehler: ${_germanError(data.error)}`; } } catch (e) { status.textContent = 'Hochladen fehlgeschlagen'; } input.value = ''; } // --------------------------------------------------------------------------- // Contrast scheme // --------------------------------------------------------------------------- // Accent palette — ordered by preference. Algorithm picks the one with the // highest WCAG contrast ratio against the detected background luminance. const ACCENT_PALETTE = [ { base: '#e63946', hover: '#ff4d58' }, // red { base: '#ff9500', hover: '#ffaa33' }, // orange { base: '#f1c40f', hover: '#f9d439' }, // yellow { base: '#2ecc71', hover: '#4ee88a' }, // green { base: '#00b4d8', hover: '#33c7e5' }, // cyan { base: '#4361ee', hover: '#6d84f4' }, // blue { base: '#c77dff', hover: '#d89fff' }, // purple { base: '#ff6b9d', hover: '#ff8fb5' }, // pink { base: '#ffffff', hover: '#cccccc' }, // white (last resort) ]; function _linearise(c) { c /= 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); } function _luminance(r, g, b) { return 0.2126 * _linearise(r) + 0.7152 * _linearise(g) + 0.0722 * _linearise(b); } function _contrast(l1, l2) { return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); } function _hexRgb(hex) { return [parseInt(hex.slice(1,3),16), parseInt(hex.slice(3,5),16), parseInt(hex.slice(5,7),16)]; } function analyzeBackground(url) { return new Promise(resolve => { const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => { const canvas = document.createElement('canvas'); canvas.width = 64; canvas.height = 64; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0, 64, 64); const data = ctx.getImageData(0, 0, 64, 64).data; let tR = 0, tG = 0, tB = 0, tBt601 = 0; const n = data.length / 4; for (let i = 0; i < data.length; i += 4) { tR += data[i]; tG += data[i+1]; tB += data[i+2]; tBt601 += 0.299*data[i] + 0.587*data[i+1] + 0.114*data[i+2]; } resolve({ bright: (tBt601 / n) > 127, bgLuminance: _luminance(tR/n, tG/n, tB/n), }); }; img.onerror = () => resolve({ bright: false, bgLuminance: 0 }); img.src = url; }); } function pickBestAccent(bgLuminance) { let best = ACCENT_PALETTE[0], bestRatio = 0; for (const entry of ACCENT_PALETTE) { const [r, g, b] = _hexRgb(entry.base); const ratio = _contrast(bgLuminance, _luminance(r, g, b)); if (ratio > bestRatio) { bestRatio = ratio; best = entry; } } return best; } function applyAccent(entry) { const root = document.documentElement; root.style.setProperty('--accent', entry.base); root.style.setProperty('--accent-hover', entry.hover); } function setScheme(bright) { document.body.classList.toggle('bright-bg', bright); const btn = document.getElementById('contrast-toggle'); if (btn) btn.style.opacity = bright ? '1' : '0.5'; } function toggleContrast() { setScheme(!document.body.classList.contains('bright-bg')); } // --------------------------------------------------------------------------- // E2E Encryption utilities (Web Crypto API) // --------------------------------------------------------------------------- let _encKey = null; function bytesToBase64(buf) { const bytes = new Uint8Array(buf); let str = ''; for (const b of bytes) str += String.fromCharCode(b); return btoa(str); } function base64ToBytes(b64) { const str = atob(b64); const buf = new Uint8Array(str.length); for (let i = 0; i < str.length; i++) buf[i] = str.charCodeAt(i); return buf; } function bytesToHex(buf) { return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join(''); } function hexToBytes(hex) { const arr = new Uint8Array(hex.length / 2); for (let i = 0; i < arr.length; i++) arr[i] = parseInt(hex.slice(i*2, i*2+2), 16); return arr; } async function getOrCreateEncKey() { if (_encKey) return _encKey; const storageKey = `diora_enc_key_${window.USER_ID || 0}`; const stored = localStorage.getItem(storageKey); if (stored) { try { const raw = base64ToBytes(stored); _encKey = await crypto.subtle.importKey('raw', raw, {name: 'AES-GCM'}, false, ['encrypt', 'decrypt']); return _encKey; } catch (e) { /* fall through, generate new */ } } // No key found — generate one and store it _encKey = await crypto.subtle.generateKey({name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']); const raw = await crypto.subtle.exportKey('raw', _encKey); localStorage.setItem(storageKey, bytesToBase64(new Uint8Array(raw))); return _encKey; } async function encryptBytes(key, plainBytes) { const iv = crypto.getRandomValues(new Uint8Array(12)); const ct = await crypto.subtle.encrypt({name: 'AES-GCM', iv}, key, plainBytes); return {iv: bytesToHex(iv), ciphertext: bytesToBase64(ct)}; } async function decryptBytes(key, ivHex, ctB64) { const iv = hexToBytes(ivHex); const ct = base64ToBytes(ctB64); return crypto.subtle.decrypt({name: 'AES-GCM', iv}, key, ct); } // --------------------------------------------------------------------------- // Encrypted wallpaper // --------------------------------------------------------------------------- async function uploadBackground(file) { if (!file) return; const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']; if (!allowedTypes.includes(file.type)) { await customAlert('Nur JPEG-, PNG- oder WebP-Bilder sind erlaubt.'); return; } if (file.size > DIORA_CONFIG.bgMaxBytes) { await customAlert(`Das Bild darf höchstens ${DIORA_CONFIG.bgMaxBytes / 1024 / 1024} MB groß sein.`); return; } const key = await getOrCreateEncKey(); const buf = await file.arrayBuffer(); const {iv, ciphertext} = await encryptBytes(key, buf); const res = await fetch('/accounts/background/upload/', { method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, body: JSON.stringify({iv, ciphertext, mime_type: file.type, file_size: file.size}), }); const data = await res.json(); if (!data.ok) throw new Error(data.error || 'upload failed'); } async function applyEncryptedBackground() { if (typeof ENCRYPTED_BG === 'undefined' || !ENCRYPTED_BG.ciphertext) return; try { const key = await getOrCreateEncKey(); const plain = await decryptBytes(key, ENCRYPTED_BG.iv, ENCRYPTED_BG.ciphertext); const blob = new Blob([plain], {type: ENCRYPTED_BG.mime || 'image/jpeg'}); const url = URL.createObjectURL(blob); document.body.style.backgroundImage = `url('${url}')`; document.body.style.backgroundSize = 'cover'; document.body.style.backgroundPosition = 'center'; document.body.style.backgroundAttachment = 'fixed'; // Analyze brightness for accent/scheme analyzeBackground(url).then(({bright, bgLuminance}) => { setScheme(bright); applyAccent(pickBestAccent(bgLuminance)); }); } catch (e) { console.warn('Could not decrypt background:', e); } } // --------------------------------------------------------------------------- // EPUB parser (requires JSZip) // --------------------------------------------------------------------------- function resolveEpubPath(base, relative) { if (!relative) return ''; if (relative.startsWith('/')) return relative.slice(1); const hashIdx = relative.indexOf('#'); const frag = hashIdx >= 0 ? relative.slice(hashIdx) : ''; const rel = hashIdx >= 0 ? relative.slice(0, hashIdx) : relative; const parts = (base + rel).split('/'); const resolved = []; for (const p of parts) { if (p === '..') resolved.pop(); else if (p !== '.') resolved.push(p); } return resolved.join('/') + frag; } async function parseEpub(arrayBuffer) { const zip = await JSZip.loadAsync(arrayBuffer); // 1. Find OPF via container.xml const containerXml = await zip.file('META-INF/container.xml').async('text'); const containerDoc = new DOMParser().parseFromString(containerXml, 'application/xml'); const rootfileEl = containerDoc.querySelector('rootfile'); if (!rootfileEl) throw new Error('Ungültige EPUB-Datei (container.xml ohne rootfile)'); const opfPath = rootfileEl.getAttribute('full-path'); const opfDir = opfPath.includes('/') ? opfPath.substring(0, opfPath.lastIndexOf('/') + 1) : ''; // 2. Parse OPF const opfText = await zip.file(opfPath).async('text'); const opfDoc = new DOMParser().parseFromString(opfText, 'application/xml'); const title = opfDoc.querySelector('metadata > title, metadata > *|title')?.textContent?.trim() || 'Unbekannter Titel'; const author = opfDoc.querySelector('metadata > creator, metadata > *|creator')?.textContent?.trim() || 'Unbekannter Autor'; // 3. Build manifest: id → {href, mediaType, properties} const manifest = {}; opfDoc.querySelectorAll('manifest > item').forEach(item => { manifest[item.getAttribute('id')] = { href: opfDir + item.getAttribute('href'), mediaType: item.getAttribute('media-type') || '', properties: item.getAttribute('properties') || '', }; }); // 4. Build image map: abs zip path → blob URL (parallel) const imageMap = {}; await Promise.all( Object.values(manifest) .filter(({mediaType}) => mediaType.startsWith('image/')) .map(async ({href, mediaType}) => { try { const buf = await zip.file(href).async('arraybuffer'); imageMap[href] = URL.createObjectURL(new Blob([buf], {type: mediaType})); } catch (e) { /* missing asset */ } }) ); // 5. Parse TOC const toc = await _parseEpubToc(zip, opfDoc, manifest); // 6. Get spine items; chapters are returned raw for progressive DOM injection const spineItems = Array.from(opfDoc.querySelectorAll('spine > itemref')) .map(ref => manifest[ref.getAttribute('idref')]?.href) .filter(Boolean); const chapters = []; for (let i = 0; i < spineItems.length; i++) { const href = spineItems[i]; try { let chapterText = await zip.file(href).async('text'); // Strip style/script blocks early — publisher EPUBs often embed large base64 fonts in CSS. // This shrinks the string before regex and DOMParser work on it. chapterText = chapterText.replace(/]*>[\s\S]*?<\/style>/gi, ''); chapterText = chapterText.replace(/]*>[\s\S]*?<\/script>/gi, ''); const chapterDir = href.includes('/') ? href.substring(0, href.lastIndexOf('/') + 1) : ''; const withBlobs = _injectImageBlobs(chapterText, chapterDir, imageMap); const sanitized = sanitizeEpubHtml(withBlobs); chapters.push(`
${sanitized}
`); if (i % 8 === 0) await new Promise(r => requestAnimationFrame(r)); } catch (e) { /* skip missing */ } } return {title, author, chapters, toc, imageMap}; } async function _parseEpubToc(zip, opfDoc, manifest) { // Try EPUB3 nav document const navItem = Object.values(manifest).find(m => m.properties.includes('nav')); if (navItem) { try { const navText = await zip.file(navItem.href).async('text'); const navDoc = new DOMParser().parseFromString(navText, 'application/xhtml+xml'); const tocNav = navDoc.querySelector('nav[epub\\:type="toc"]') || navDoc.querySelector('nav'); if (tocNav) { const ol = tocNav.querySelector('ol'); if (ol) return _parseTocOl(ol, navItem.href, 0); } } catch (e) {} } // Fall back to EPUB2 NCX const ncxItem = Object.values(manifest).find(m => m.mediaType === 'application/x-dtbncx+xml'); if (ncxItem) { try { const ncxText = await zip.file(ncxItem.href).async('text'); const ncxDoc = new DOMParser().parseFromString(ncxText, 'application/xml'); const ncxDir = ncxItem.href.includes('/') ? ncxItem.href.substring(0, ncxItem.href.lastIndexOf('/') + 1) : ''; return _parseNcxNavMap(ncxDoc.querySelector('navMap'), ncxDir, 0); } catch (e) {} } return []; } function _parseTocOl(ol, navHref, depth) { if (!ol || depth > 5) return []; const navDir = navHref.includes('/') ? navHref.substring(0, navHref.lastIndexOf('/') + 1) : ''; const items = []; for (const li of Array.from(ol.children)) { const a = li.querySelector(':scope > a') || li.querySelector(':scope > span'); if (a) { const rawHref = a.getAttribute('href') || ''; items.push({ label: a.textContent.trim(), href: rawHref ? resolveEpubPath(navDir, rawHref) : '', depth, }); } const childOl = li.querySelector(':scope > ol'); if (childOl) items.push(..._parseTocOl(childOl, navHref, depth + 1)); } return items; } function _parseNcxNavMap(navMap, ncxDir, depth) { if (!navMap || depth > 5) return []; const items = []; for (const navPoint of Array.from(navMap.children)) { if (navPoint.tagName !== 'navPoint') continue; const label = navPoint.querySelector('navLabel > text')?.textContent?.trim() || ''; const src = navPoint.querySelector('content')?.getAttribute('src') || ''; items.push({label, href: src ? resolveEpubPath(ncxDir, src) : '', depth}); items.push(..._parseNcxNavMap(navPoint, ncxDir, depth + 1)); } return items; } function _resolveImageBlob(imageMap, absPath) { if (imageMap[absPath]) return imageMap[absPath]; // Fallback: match by decoded filename only const name = decodeURIComponent(absPath.split('/').pop()).toLowerCase(); for (const [k, v] of Object.entries(imageMap)) { if (decodeURIComponent(k.split('/').pop()).toLowerCase() === name) return v; } return null; } // Replace image src/href in raw chapter HTML text with blob URLs before DOMParser sees it. // This avoids relying on innerHTML serialisation preserving attributes we set on DOM nodes. function _injectImageBlobs(html, chapterDir, imageMap) { function subst(src) { if (!src || src.startsWith('blob:') || src.startsWith('data:') || src.startsWith('http')) return src; return _resolveImageBlob(imageMap, resolveEpubPath(chapterDir, src)) || ''; } // html = html.replace(/(]*?)\bsrc="([^"]*)"/gi, (_, pre, src) => { const b = subst(src); return b ? `${pre}src="${b}"` : pre; }); // SVG html = html.replace(/(]*?)\bxlink:href="([^"]*)"/gi, (_, pre, src) => { const b = subst(src); return b ? `${pre}xlink:href="${b}"` : pre; }); // SVG html = html.replace(/(]*?)\bhref="([^"]*)"/gi, (_, pre, src) => { const b = subst(src); return b ? `${pre}href="${b}"` : pre; }); return html; } // EPUBs mark footnote/endnote reference links very inconsistently across // editions/tools — some use class="footnote" (same-file '#frag' href), some // use the EPUB3 epub:type="noteref" attribute, and some (e.g. older // Calibre/zeno.org conversions) use neither and just wrap a plain link to // another chapter file's fragment in a — in either direction // (… or …). Recognize all of these // rather than only the single hardcoded class name. function _looksLikeFootnoteLink(el, href) { if (!href.includes('#')) return false; const cls = (el.getAttribute('class') || '').toLowerCase(); if (/\bnote|\bfootnote|\bfn\b/.test(cls)) return true; const epubType = (el.getAttribute('epub:type') || '').toLowerCase(); if (epubType.includes('noteref')) return true; return !!(el.closest('sup') || el.querySelector('sup')); } function sanitizeEpubHtml(html) { const doc = new DOMParser().parseFromString(html, 'text/html'); doc.querySelectorAll('script, iframe, object, embed, style, head, meta, link').forEach(el => el.remove()); doc.querySelectorAll('*').forEach(el => { Array.from(el.attributes).forEach(attr => { if (attr.name.startsWith('on') || attr.name === 'title') el.removeAttribute(attr.name); }); const tag = el.tagName.toLowerCase(); if (tag === 'img') { const src = el.getAttribute('src') || ''; // Remove any non-blob, non-data src that slipped through (broken relative paths) if (src && !src.startsWith('blob:') && !src.startsWith('data:')) el.removeAttribute('src'); } if (el.tagName === 'A') { const href = el.getAttribute('href') || ''; if (href.startsWith('http://') || href.startsWith('https://')) { el.setAttribute('target', '_blank'); el.setAttribute('rel', 'noopener noreferrer'); } else if (_looksLikeFootnoteLink(el, href)) { // The whole book is one concatenated DOM, so a fragment id resolves // via getElementById regardless of which chapter file it originally // pointed into — strip any "../Text/other.html" path prefix and keep // just the id after the last '#'. el.dataset.footnoteRef = href.slice(href.indexOf('#') + 1); el.removeAttribute('href'); el.style.cursor = 'pointer'; } else { el.removeAttribute('href'); el.style.cursor = 'default'; } } }); return doc.body ? doc.body.innerHTML : doc.documentElement.innerHTML; } // --------------------------------------------------------------------------- // Books // --------------------------------------------------------------------------- let currentBookId = null; let currentBookToc = []; let currentImageMap = {}; let readerScrollSaveTimer = null; let _resizeObserver = null; let _currentPositionAnchor = ''; let _currentBookCharCount = 0; const bookMetaCache = {}; // id → {title, author, type} // Reading-position percentages (the progress readout, annotation "% im Buch" // coordinates): a whole percent is fine for a normal book, but on a long one // (~600-700 physical pages and up) it barely moves during a whole scrolling // session, which reads as broken. Past that length, show one decimal — // formatted with the browser locale's own decimal separator. const LONG_BOOK_CHAR_THRESHOLD = 1000000; function _formatBookPercent(fraction) { const pct = Math.max(0, Math.min(100, fraction * 100)); const decimals = _currentBookCharCount > LONG_BOOK_CHAR_THRESHOLD ? 1 : 0; return pct.toLocaleString(undefined, {minimumFractionDigits: decimals, maximumFractionDigits: decimals}); } // Accepts both '45.3' and '45,3' (locale-typed input) function _parseLocalePercent(str) { const v = parseFloat(String(str).trim().replace(',', '.')); return isNaN(v) ? 0 : v; } // --------------------------------------------------------------------------- // Reader "scrolled too far" safety net — offers a toast to jump back after a // sudden, large scroll (accidental fling, fat-fingered keybind, etc.). Only // watches organic scrolling; explicit destination jumps (TOC, bookmarks, the // progress input, search) call _suppressScrollJumpDetect() first so they // don't trigger it. // --------------------------------------------------------------------------- let _scrollRestTop = null; // scrollTop last "settled" at, before the current burst let _scrollRestDebounce = null; let _scrollJumpSuppressed = false; // true while an explicit/programmatic jump is in flight let _scrollJumpToastEl = null; // Marks the current (and any immediately-following, possibly animated) scroll // as deliberate, so _checkScrollJump ignores it. Suppression lifts itself // once scrolling actually settles (see _armScrollJumpSettle) — not after a // fixed delay — so it stays active for however long a `smooth` scroll // animation to a far-away target actually takes, and calling this again // (e.g. from repeated resize-triggered corrections while a book settles) // simply pushes that settle point further out instead of racing it. function _suppressScrollJumpDetect() { _scrollJumpSuppressed = true; _armScrollJumpSettle(); } function _armScrollJumpSettle() { clearTimeout(_scrollRestDebounce); _scrollRestDebounce = setTimeout(() => { const contentEl = $('reader-content'); if (contentEl) _scrollRestTop = contentEl.scrollTop; _scrollJumpSuppressed = false; }, 500); } function _dismissScrollJumpToast() { if (_scrollJumpToastEl) { _scrollJumpToastEl.remove(); _scrollJumpToastEl = null; } } function _showScrollJumpToast(backTop) { _dismissScrollJumpToast(); const toast = document.createElement('div'); toast.className = 'reader-toast reader-toast-action'; toast.innerHTML = `Weit gescrollt`; document.body.appendChild(toast); _scrollJumpToastEl = toast; toast.querySelector('.reader-toast-btn').addEventListener('click', () => { const contentEl = $('reader-content'); if (contentEl) { _suppressScrollJumpDetect(); contentEl.scrollTo({top: backTop, behavior: 'smooth'}); } _dismissScrollJumpToast(); }); setTimeout(_dismissScrollJumpToast, 6000); } function _checkScrollJump(contentEl) { const top = contentEl.scrollTop; if (_scrollRestTop === null) _scrollRestTop = top; // first sample this session if (!_scrollJumpSuppressed && !_scrollJumpToastEl) { const screen = contentEl.clientHeight || 1; if (Math.abs(top - _scrollRestTop) > screen * 3.5) { _showScrollJumpToast(_scrollRestTop); } } // Only adopt the new position as the "settled" baseline — and lift any // suppression — once scrolling has paused for a bit. That's what lets a // fast, still-in-progress fling (or a long smooth-scroll animation) be // compared against/protected until it actually finishes, not mid-flight. _armScrollJumpSettle(); } const EPUB_BLOCK_SELECTOR = 'p, h1, h2, h3, h4, h5, h6, li, blockquote, dt, dd, figcaption, div:not(:has(*))'; function getPositionAnchor(contentEl) { const blocks = Array.from(contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR)); if (!blocks.length) return ''; const containerTop = contentEl.getBoundingClientRect().top; const containerBottom = containerTop + contentEl.clientHeight; let bestIndex = 0; let bestDelta = Infinity; for (let i = 0; i < blocks.length; i++) { const rect = blocks[i].getBoundingClientRect(); if (rect.height < 1) continue; if (rect.top > containerBottom) break; const delta = rect.top - containerTop; if (delta <= 0 && Math.abs(delta) < Math.abs(bestDelta)) { bestIndex = i; bestDelta = delta; } else if (delta > 0 && bestDelta === Infinity) { bestIndex = i; bestDelta = delta; } } const rect = blocks[bestIndex].getBoundingClientRect(); const innerFraction = Math.max(0, Math.min(1, (containerTop - rect.top) / (rect.height || 1))); return `${bestIndex}:${innerFraction.toFixed(6)}`; } // Reading progress as a share of the book's text (characters), from a // position anchor. Independent of layout — font size, screen width, which // chapters are rendered (the Android app's e-reader mode lays out one chapter // at a time) — so every client shows the same percentage for the same place. // The pixel share (scrollTop / scrollHeight) used before differed between // layouts and counted margins, headings and images as reading. let _charIndex = null; // {content, starts[], lens[], total} for the open book function _bookCharIndex(contentEl) { if (_charIndex && _charIndex.content === contentEl) return _charIndex; const offsets = new Map(); let pos = 0; const walker = document.createTreeWalker(contentEl, NodeFilter.SHOW_TEXT); let node; while ((node = walker.nextNode())) { offsets.set(node, pos); pos += node.nodeValue.length; } const blocks = contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR); const starts = new Array(blocks.length); const lens = new Array(blocks.length); for (let i = 0; i < blocks.length; i++) { const first = document.createTreeWalker(blocks[i], NodeFilter.SHOW_TEXT).nextNode(); starts[i] = first ? offsets.get(first) : (i ? starts[i - 1] + lens[i - 1] : 0); lens[i] = blocks[i].textContent.length; } _charIndex = {content: contentEl, starts, lens, total: pos || 1}; return _charIndex; } // Share of the text before the anchored place (0..1), or null. function _fractionFromAnchor(contentEl, anchor) { const [block, inner] = _anchorParts(anchor); if (block < 0) return null; const idx = _bookCharIndex(contentEl); if (block >= idx.starts.length) return null; return Math.max(0, Math.min(1, (idx.starts[block] + inner * idx.lens[block]) / idx.total)); } // Anchor of the place where `fraction` of the text has been read. function _anchorForFraction(contentEl, fraction) { const idx = _bookCharIndex(contentEl); if (!idx.starts.length) return ''; const target = Math.max(0, Math.min(1, fraction)) * idx.total; let lo = 0, hi = idx.starts.length - 1; while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (idx.starts[mid] <= target) lo = mid; else hi = mid - 1; } const inner = idx.lens[lo] ? Math.max(0, Math.min(1, (target - idx.starts[lo]) / idx.lens[lo])) : 0; return `${lo}:${inner.toFixed(6)}`; } function restoreFromAnchor(contentEl, anchor) { if (!anchor) return false; const parts = anchor.split(':'); if (parts.length !== 2) return false; const idx = parseInt(parts[0], 10); const innerFraction = parseFloat(parts[1]); if (isNaN(idx) || isNaN(innerFraction)) return false; const blocks = Array.from(contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR)); if (idx >= blocks.length) return false; const el = blocks[idx]; contentEl.scrollTop = el.offsetTop + Math.round(innerFraction * el.offsetHeight); return true; } // Split 'blockIndex:innerFraction' into [block, inner]; [-1, 0] if invalid/empty. function _anchorParts(anchor) { if (typeof anchor !== 'string') return [-1, 0]; const c = anchor.indexOf(':'); if (c < 0) return [-1, 0]; const b = parseInt(anchor.slice(0, c), 10); const f = parseFloat(anchor.slice(c + 1)); return [Number.isFinite(b) ? b : -1, Number.isFinite(f) ? f : 0]; } // >0 if position A is further into the book than B, <0 if behind, 0 if equal. // Compares by anchor block index (precise); falls back to scroll_fraction only // when an anchor is missing (e.g. PDFs), matching the server's _progress_is_further. function _cmpProgress(anchorA, fracA, anchorB, fracB) { const [ba, ia] = _anchorParts(anchorA); const [bb, ib] = _anchorParts(anchorB); if (ba >= 0 && bb >= 0) return ba !== bb ? ba - bb : ia - ib; return (fracA || 0) - (fracB || 0); } // --------------------------------------------------------------------------- // Book cache — IndexedDB, evict after 4 weeks of inactivity // --------------------------------------------------------------------------- const _BOOK_CACHE_STORE = 'books'; const _BOOK_META_STORE = 'book_meta'; const _BOOK_ANNOTATIONS_STORE = 'book_annotations'; const _BOOK_CACHE_EVICT_MS = 4 * 7 * 24 * 60 * 60 * 1000; function _openBookCacheDb() { return new Promise((resolve, reject) => { const req = indexedDB.open('diora_book_cache', 2); req.onupgradeneeded = e => { const db = e.target.result; if (!db.objectStoreNames.contains(_BOOK_CACHE_STORE)) db.createObjectStore(_BOOK_CACHE_STORE, {keyPath: 'id'}); if (!db.objectStoreNames.contains(_BOOK_META_STORE)) db.createObjectStore(_BOOK_META_STORE, {keyPath: 'id'}); if (!db.objectStoreNames.contains(_BOOK_ANNOTATIONS_STORE)) db.createObjectStore(_BOOK_ANNOTATIONS_STORE, {keyPath: 'id'}); }; req.onsuccess = e => resolve(e.target.result); req.onerror = () => reject(); }); } async function _getCachedBook(bookId) { try { const db = await _openBookCacheDb(); return await new Promise((resolve) => { const req = db.transaction(_BOOK_CACHE_STORE).objectStore(_BOOK_CACHE_STORE).get(bookId); req.onsuccess = e => resolve(e.target.result || null); req.onerror = () => resolve(null); }); } catch { return null; } } async function _setCachedBook(bookId, data_ct, data_iv) { try { const db = await _openBookCacheDb(); await new Promise((resolve) => { const tx = db.transaction(_BOOK_CACHE_STORE, 'readwrite'); tx.objectStore(_BOOK_CACHE_STORE).put({id: bookId, data_ct, data_iv, cached_at: Date.now()}); tx.oncomplete = resolve; tx.onerror = resolve; }); // Request persistent storage so the browser doesn't evict cached books // under storage pressure. Chrome grants this silently for installed PWAs; // Firefox may show a permission prompt. if (navigator.storage?.persist) navigator.storage.persist(); } catch {} } async function _evictCachedBook(bookId) { try { const db = await _openBookCacheDb(); await new Promise((resolve) => { const tx = db.transaction(_BOOK_CACHE_STORE, 'readwrite'); tx.objectStore(_BOOK_CACHE_STORE).delete(bookId); tx.oncomplete = resolve; tx.onerror = resolve; }); } catch {} } async function _evictBookCache(bookList) { try { const db = await _openBookCacheDb(); const now = Date.now(); const metaById = Object.fromEntries(bookList.map(b => [b.id, b])); await new Promise((resolve) => { const tx = db.transaction(_BOOK_CACHE_STORE, 'readwrite'); const store = tx.objectStore(_BOOK_CACHE_STORE); store.openCursor().onsuccess = e => { const cursor = e.target.result; if (!cursor) { resolve(); return; } const meta = metaById[cursor.value.id]; const lastRead = meta?.last_read ? new Date(meta.last_read).getTime() : 0; if (!meta || (now - lastRead) > _BOOK_CACHE_EVICT_MS) cursor.delete(); cursor.continue(); }; tx.onerror = resolve; }); } catch {} } async function _saveBookMeta(books) { try { const db = await _openBookCacheDb(); const ids = new Set(books.map(b => String(b.id))); await new Promise(resolve => { const tx = db.transaction(_BOOK_META_STORE, 'readwrite'); const store = tx.objectStore(_BOOK_META_STORE); for (const b of books) store.put(b); store.openCursor().onsuccess = e => { const cursor = e.target.result; if (!cursor) return; if (!ids.has(String(cursor.value.id))) cursor.delete(); cursor.continue(); }; tx.oncomplete = resolve; tx.onerror = resolve; }); } catch {} } async function _loadCachedBookMeta() { try { const db = await _openBookCacheDb(); return await new Promise(resolve => { const req = db.transaction(_BOOK_META_STORE).objectStore(_BOOK_META_STORE).getAll(); req.onsuccess = e => resolve(e.target.result || []); req.onerror = () => resolve([]); }); } catch { return []; } } async function _updateCachedBookProgress(bookId, fraction, anchor, force = false) { try { const db = await _openBookCacheDb(); await new Promise(resolve => { const tx = db.transaction(_BOOK_META_STORE, 'readwrite'); const store = tx.objectStore(_BOOK_META_STORE); const req = store.get(bookId); req.onsuccess = e => { const entry = e.target.result; // Only advance (or on force), so a transiently wrong save can't lose progress. if (entry && (force || _cmpProgress(anchor, fraction, entry.position_anchor, entry.scroll_fraction) >= 0)) { entry.scroll_fraction = fraction; entry.position_anchor = anchor; entry.last_read = new Date().toISOString(); store.put(entry); } }; tx.oncomplete = resolve; tx.onerror = resolve; }); } catch {} } async function _saveAnnotationsCache(bookId, type, ct, iv) { try { const db = await _openBookCacheDb(); await new Promise(resolve => { const tx = db.transaction(_BOOK_ANNOTATIONS_STORE, 'readwrite'); tx.objectStore(_BOOK_ANNOTATIONS_STORE).put({id: `${bookId}_${type}`, ct, iv}); tx.oncomplete = resolve; tx.onerror = resolve; }); } catch {} } async function _loadAnnotationsCache(bookId, type) { try { const db = await _openBookCacheDb(); return await new Promise(resolve => { const req = db.transaction(_BOOK_ANNOTATIONS_STORE).objectStore(_BOOK_ANNOTATIONS_STORE).get(`${bookId}_${type}`); req.onsuccess = e => resolve(e.target.result || null); req.onerror = () => resolve(null); }); } catch { return null; } } const _PENDING_SYNC_KEY = 'diora_pending_sync'; function _markPendingSync(bookId, type) { try { const pending = JSON.parse(localStorage.getItem(_PENDING_SYNC_KEY) || '[]'); const key = `${bookId}:${type}`; if (!pending.includes(key)) pending.push(key); localStorage.setItem(_PENDING_SYNC_KEY, JSON.stringify(pending)); } catch {} } async function _syncPendingAnnotations() { try { const pending = JSON.parse(localStorage.getItem(_PENDING_SYNC_KEY) || '[]'); if (!pending.length) return; const remaining = []; for (const key of pending) { const sep = key.indexOf(':'); const bookId = key.slice(0, sep); const type = key.slice(sep + 1); const cached = await _loadAnnotationsCache(bookId, type); if (!cached?.ct) continue; const url = type === 'bookmarks' ? `/books/${bookId}/bookmarks/` : `/books/${bookId}/highlights/`; try { await fetch(url, { method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, body: JSON.stringify({ct: cached.ct, iv: cached.iv}), }); } catch { remaining.push(key); } } localStorage.setItem(_PENDING_SYNC_KEY, JSON.stringify(remaining)); } catch {} } // Reader settings let readerSettings = { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark', fontFamily: 'serif', noBold: false, fontWeight: null, pdfZoom: 100, pdfInverted: false, pdfPaginated: false, pdfSpread: false }; let readerSettingsPanelOpen = false; let currentPdfDoc = null; let currentPdfBuffer = null; // Bookmarks let currentBookmarks = []; let bookmarksDirty = false; // Highlights let currentHighlights = []; let highlightsDirty = false; let currentHighlightPopover = null; // Search let searchMatches = []; let searchMatchIndex = -1; let searchOriginalContent = null; let readerSearchOpen = false; // PDF paginated let pdfCurrentPage = 1; let pdfTotalPages = 0; let _pdfPageTextBoxCache = {}; let _pdfRenderGen = 0; let _touchStartX = 0; let _readerScrollInterval = null; let _readerScrollDir = 0; let _readerScrollLastTs = 0; const READER_SCROLL_SPEED = 1200; // constant px/s, no ramp-up const READER_SCROLL_TICK_MS = 20; // fixed cadence, independent of the // display's refresh rate — rAF runs at // that rate (120/144/165Hz on plenty of // laptops), which was driving 2-3x more // scrollBy + scroll-listener work per // second than intended and read as lag. function _readerScrollTick() { const now = performance.now(); const dt = now - _readerScrollLastTs; _readerScrollLastTs = now; $('reader-content')?.scrollBy({top: Math.round(_readerScrollDir * READER_SCROLL_SPEED * dt / 1000)}); } let _pinchStartDist = 0; let _pinchStartZoom = 100; let _isPinching = false; if (typeof pdfjsLib !== 'undefined') { pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/js/pdf.worker.min.js'; } async function loadBookList() { if (!IS_AUTHENTICATED) return; const listEl = $('book-list'); if (!listEl) return; listEl.innerHTML = '

Bücher werden geladen …

'; try { listEl.innerHTML = '

Bücherliste wird vom Server geholt …

'; const res = await fetch('/books/', {cache: 'no-store'}); if (!res.ok) { listEl.innerHTML = `

Serverfehler ${res.status} beim Laden der Bücher.

`; return; } const books = await res.json(); _evictBookCache(books); // fire-and-forget if (!Array.isArray(books)) { listEl.innerHTML = `

Unerwartete Antwort vom Server.

`; return; } if (!books.length) { listEl.innerHTML = '

Noch keine Bücher. Zieh oben eine .epub- oder .pdf-Datei hinein.

'; return; } listEl.innerHTML = `

${books.length === 1 ? '1 Buch' : books.length + ' Bücher'} auf dem Server. Entschlüssele …

`; let key; try { key = await getOrCreateEncKey(); } catch (e) { listEl.innerHTML = `

Verschlüsselung nicht verfügbar: ${e.message}. Die Seite muss über HTTPS laufen.

`; return; } const decrypted = []; for (const b of books) { try { const metaBuf = await decryptBytes(key, b.meta_iv, b.meta_ct); const meta = JSON.parse(new TextDecoder().decode(metaBuf)); bookMetaCache[b.id] = {title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', filename: meta.filename || '', folder: meta.folder || '', isbn: meta.isbn || '', shelfTag: meta.shelfTag || ''}; decrypted.push({id: b.id, title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', folder: meta.folder || '', isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: true, is_read: !!b.is_read, has_highlights: !!b.has_highlights}); } catch (e) { bookMetaCache[b.id] = {title: `Buch #${b.id}`, author: '', type: 'epub', filename: '', folder: '', isbn: '', shelfTag: ''}; decrypted.push({id: b.id, title: `Buch #${b.id}`, author: '', type: 'epub', folder: '', isbn: '', shelfTag: '', scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: false, is_read: !!b.is_read, has_highlights: !!b.has_highlights}); } } // If local cache is further ahead than the server, push it and use it for display. // Server uses max-semantics so this is always safe. const _localMeta = await _loadCachedBookMeta(); const _localById = Object.fromEntries(_localMeta.map(b => [b.id, b])); for (const b of decrypted) { const loc = _localById[b.id]; if (loc && _cmpProgress(loc.position_anchor, loc.scroll_fraction, b.position_anchor, b.scroll_fraction) > 0) { fetch(`/books/${b.id}/progress/`, { method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, body: JSON.stringify({scroll_fraction: loc.scroll_fraction, position_anchor: loc.position_anchor || ''}), }).catch(() => {}); b.scroll_fraction = loc.scroll_fraction; b.position_anchor = loc.position_anchor; b.last_read = loc.last_read; } } decrypted.sort((a, b) => { if (a.last_read && b.last_read) return b.last_read.localeCompare(a.last_read); if (a.last_read) return -1; if (b.last_read) return 1; return b.uploaded_at.localeCompare(a.uploaded_at); }); _saveBookMeta(decrypted); // persist for offline renderBookList(decrypted); } catch (e) { // Network error — try offline fallback const cachedBooks = await _loadCachedBookMeta(); if (cachedBooks.length) { cachedBooks.sort((a, b) => { if (a.last_read && b.last_read) return b.last_read.localeCompare(a.last_read); if (a.last_read) return -1; if (b.last_read) return 1; return (b.uploaded_at || '').localeCompare(a.uploaded_at || ''); }); for (const b of cachedBooks) bookMetaCache[b.id] = {title: b.title, author: b.author, type: b.type || 'epub', filename: b.filename || '', folder: b.folder || '', isbn: b.isbn || '', shelfTag: b.shelfTag || ''}; const uploadArea = $('book-upload-area'); if (uploadArea) uploadArea.style.display = 'none'; renderBookList(cachedBooks); const notice = document.createElement('p'); notice.className = 'muted offline-notice'; notice.textContent = 'Offline — gespeicherte Bücher. Fortschritt wird beim nächsten Öffnen synchronisiert.'; listEl.prepend(notice); } else { if (listEl) listEl.innerHTML = `

Fehler beim Laden der Bücher: ${e.message}

`; } } } const _brokenBooks = new Set(); function markBookBroken(bookId) { _brokenBooks.add(bookId); const item = document.querySelector(`.book-item[data-book-id="${bookId}"]`); if (!item) return; if (!item.querySelector('.book-broken-btn')) { const btn = document.createElement('button'); btn.className = 'btn btn-sm btn-danger book-broken-btn'; btn.title = 'Buch konnte nicht geöffnet werden — Datei erneut hochladen'; btn.textContent = '!'; btn.onclick = () => repairBook(bookId); item.querySelector('.book-item-actions').prepend(btn); } } function _extractIsbnFromOpf(opfDoc) { const identifiers = opfDoc.querySelectorAll('metadata > identifier, metadata > *|identifier'); for (const el of identifiers) { const digits = (el.textContent || '').replace(/[^0-9Xx]/g, ''); if (digits.length === 10 || digits.length === 13) return digits; } return ''; } function repairBook(bookId) { const input = document.createElement('input'); input.type = 'file'; input.accept = '.epub,.pdf'; input.onchange = async () => { const file = input.files[0]; if (!file) return; const item = document.querySelector(`.book-item[data-book-id="${bookId}"]`); const btn = item?.querySelector('.book-broken-btn'); if (btn) btn.textContent = '…'; try { const key = await getOrCreateEncKey(); const buf = await file.arrayBuffer(); const isPdf = /\.pdf$/i.test(file.name); const type = isPdf ? 'pdf' : 'epub'; let title = file.name.replace(/\.(epub|pdf)$/i, ''), author = '', isbn = ''; try { if (isPdf) { const pdfDoc = await pdfjsLib.getDocument({data: new Uint8Array(buf.slice(0))}).promise; const meta = await pdfDoc.getMetadata(); title = meta.info?.Title?.trim() || title; author = meta.info?.Author?.trim() || ''; } else { const zip = await JSZip.loadAsync(buf.slice(0)); const containerXml = await zip.file('META-INF/container.xml').async('text'); const opfPath = new DOMParser() .parseFromString(containerXml, 'application/xml') .querySelector('rootfile')?.getAttribute('full-path'); if (opfPath) { const opfDoc = new DOMParser().parseFromString( await zip.file(opfPath).async('text'), 'application/xml'); title = opfDoc.querySelector('metadata > title, metadata > *|title')?.textContent?.trim() || title; author = opfDoc.querySelector('metadata > creator, metadata > *|creator')?.textContent?.trim() || ''; isbn = _extractIsbnFromOpf(opfDoc); } } } catch (e) { /* keep filename as title */ } // Preserve folder/shelfTag assigned before this repair — replacing the book's // data shouldn't silently wipe organization the user already set up. const prevMeta = bookMetaCache[bookId] || {}; const metaJson = new TextEncoder().encode(JSON.stringify({ title, author, filename: file.name, type, isbn: isbn || prevMeta.isbn || '', folder: prevMeta.folder || '', shelfTag: prevMeta.shelfTag || '', })); const [metaEnc, dataEnc] = await Promise.all([ encryptBytes(key, metaJson), encryptBytes(key, buf), ]); const res = await fetch(`/books/${bookId}/replace-data/`, { method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, body: JSON.stringify({ data_ct: dataEnc.ciphertext, data_iv: dataEnc.iv, meta_ct: metaEnc.ciphertext, meta_iv: metaEnc.iv, }), }); if (!(await res.json()).ok) throw new Error('Serverfehler'); await _evictCachedBook(bookId); _brokenBooks.delete(bookId); if (btn) btn.remove(); openBook(bookId); } catch (e) { if (btn) btn.textContent = '!'; await customAlert('Reparatur fehlgeschlagen: ' + e.message); } }; input.click(); } let _lastBookListData = []; const _bookShowReadKey = 'diora_book_show_read'; let _bookShowRead = localStorage.getItem(_bookShowReadKey) === '1'; function _onBookShowReadToggle(checked) { _bookShowRead = checked; localStorage.setItem(_bookShowReadKey, checked ? '1' : '0'); renderBookList(_lastBookListData); } async function toggleBookRead(bookId, currentlyRead) { const verb = currentlyRead ? 'als ungelesen markieren' : 'als gelesen markieren'; if (!await customConfirm(`Dieses Buch ${verb}?`)) return; try { const res = await fetch(`/books/${bookId}/read/`, { method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, body: JSON.stringify({is_read: !currentlyRead}), }); const data = await res.json(); if (!data.ok) throw new Error('Serverfehler'); const book = _lastBookListData.find(b => b.id === bookId); if (book) book.is_read = data.is_read; renderBookList(_lastBookListData); } catch (e) { await customAlert('Konnte Lesestatus nicht ändern: ' + e.message); } } // Folder assigned to a book lives inside its encrypted meta blob (like title/author), so the // server never sees plaintext folder names — no schema change needed, and it rides along for // free in /api/sync/. One level only: folders don't nest. let _currentBookFolder = null; // null = root view (folder tiles + unfiled books) function _recentlyReadBooks(books) { return books .filter(b => b.last_read) .sort((a, b) => b.last_read.localeCompare(a.last_read)) .slice(0, 7); } function _renderBookItemHtml(b) { const pct = Math.round((b.scroll_fraction || 0) * 100); const keyWarning = b.keyOk === false ? '⚠ falscher Schlüssel' : ''; const broken = _brokenBooks.has(b.id); return `
${escapeHtml(b.title)}${keyWarning}${b.is_read ? ' ✓ gelesen' : ''} ${escapeHtml(b.author)} ${pct > 0 ? `${pct} % gelesen` : ''} ${b.shelfTag ? `${escapeHtml(b.shelfTag)}` : ''}
${broken ? `` : ''} ${b.has_highlights ? `` : ''}
`; } function toggleBookMenu(toggleBtn) { // Looked up relative to the clicked button (not by id) because the same book — and // thus the same rendered menu — can appear twice at once: once in the "Zuletzt // gelesen" section and again further down in the folder/unfiled list. const menu = toggleBtn.closest('.book-item-menu')?.querySelector('.book-item-menu-list'); if (!menu) return; const willOpen = !menu.classList.contains('open'); document.querySelectorAll('.book-item-menu-list.open').forEach(m => m.classList.remove('open')); if (willOpen) menu.classList.add('open'); } // Close any open book menu on an outside click, or right after an item inside it is // clicked (the toggle button manages its own open/close state above, so it's excluded). document.addEventListener('click', (e) => { if (e.target.closest('.book-menu-toggle')) return; document.querySelectorAll('.book-item-menu-list.open').forEach(m => m.classList.remove('open')); }); function _openBookFolder(name) { _currentBookFolder = name; renderBookList(_lastBookListData); } function renderBookList(books) { const listEl = $('book-list'); if (!listEl) return; _lastBookListData = books; if (!books.length) { listEl.innerHTML = ''; return; } let html = ''; // Always-visible "recently read" section, independent of folder navigation and the // read/unread filter below — it's a shortcut back into whatever was open last. Same // vertical list-item look as the rest of the list (not a card shelf). const recent = _recentlyReadBooks(books); if (recent.length) { html += '
' + '

Zuletzt gelesen

' + recent.map(_renderBookItemHtml).join('') + '
'; } const visible = _bookShowRead ? books : books.filter(b => !b.is_read); if (!visible.length) { html += books.length ? '

Keine ungelesenen Bücher. „Gelesene Bücher anzeigen“ aktivieren, um alle zu sehen.

' : ''; listEl.innerHTML = html; return; } const grouped = new Map(); for (const b of visible) { const key = b.folder || ''; if (!grouped.has(key)) grouped.set(key, []); grouped.get(key).push(b); } // Folder emptied out (last book moved/deleted, or read-filter hid it) — fall back to root. if (_currentBookFolder !== null && !grouped.has(_currentBookFolder)) { _currentBookFolder = null; } if (_currentBookFolder === null) { const folderNames = [...grouped.keys()].filter(k => k !== '').sort((a, b) => a.localeCompare(b, 'de')); if (folderNames.length) { html += '
' + folderNames.map(name => ``).join('') + '
'; } html += (grouped.get('') || []).map(_renderBookItemHtml).join(''); } else { html += `
${escapeHtml(_currentBookFolder)}
`; html += grouped.get(_currentBookFolder).map(_renderBookItemHtml).join(''); } listEl.innerHTML = html; listEl.querySelectorAll('.book-folder-tile').forEach(tile => { tile.addEventListener('click', () => _openBookFolder(tile.dataset.folder)); }); } // Shared by assignBookFolder/lookupBookMetadata: re-encrypts the meta blob with the given // field overrides applied on top of whatever is already cached, and pushes it to the server. async function _updateBookMeta(bookId, overrides) { const book = _lastBookListData.find(b => b.id === bookId); if (!book) return false; const cached = bookMetaCache[bookId] || {}; const merged = { title: cached.title || book.title, author: cached.author || book.author, filename: cached.filename || '', type: cached.type || book.type || 'epub', isbn: cached.isbn || book.isbn || '', folder: cached.folder || book.folder || '', shelfTag: cached.shelfTag || book.shelfTag || '', ...overrides, }; const key = await getOrCreateEncKey(); const metaJson = new TextEncoder().encode(JSON.stringify(merged)); const metaEnc = await encryptBytes(key, metaJson); const res = await fetch(`/books/${bookId}/meta/`, { method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, body: JSON.stringify({meta_ct: metaEnc.ciphertext, meta_iv: metaEnc.iv}), }); const data = await res.json(); if (!data.ok) throw new Error('Serverfehler'); Object.assign(book, overrides); bookMetaCache[bookId] = merged; _saveBookMeta(_lastBookListData); return true; } async function assignBookFolder(bookId) { const book = _lastBookListData.find(b => b.id === bookId); if (!book) return; const folders = [...new Set(_lastBookListData.map(b => b.folder).filter(Boolean))].sort((a, b) => a.localeCompare(b, 'de')); const hint = folders.length ? ` Vorhandene Ordner: ${folders.join(', ')}.` : ''; const result = await customPrompt(`Ordner für „${book.title}“ (leer = kein Ordner).${hint}`, book.folder || ''); if (result === null) return; const folder = result.trim(); if (folder === (book.folder || '')) return; try { await _updateBookMeta(bookId, {folder}); if (folder) _currentBookFolder = folder; renderBookList(_lastBookListData); } catch (e) { await customAlert('Konnte Ordner nicht ändern: ' + e.message); } } // Fallback for books uploaded before ISBN extraction existed (or repaired/re-encrypted // without it): pulls the already-accessible encrypted book bytes, decrypts and unzips just // far enough to read the OPF identifiers — same source openBook() itself parses — without // doing a full reader render. EPUB only; PDFs never carried a reliable ISBN via pdf.js. async function _extractIsbnFromBookFile(bookId) { try { const key = await getOrCreateEncKey(); let data_ct, data_iv; const cached = await _getCachedBook(bookId); if (cached) { ({data_ct, data_iv} = cached); } else { const res = await fetch(`/books/${bookId}/data/`); ({data_ct, data_iv} = await res.json()); } const plain = await decryptBytes(key, data_iv, data_ct); const zip = await JSZip.loadAsync(plain); const containerXml = await zip.file('META-INF/container.xml').async('text'); const opfPath = new DOMParser() .parseFromString(containerXml, 'application/xml') .querySelector('rootfile')?.getAttribute('full-path'); if (!opfPath) return ''; const opfDoc = new DOMParser().parseFromString(await zip.file(opfPath).async('text'), 'application/xml'); return _extractIsbnFromOpf(opfDoc); } catch (e) { return ''; } } async function _fetchShelfLookup(isbn, title, author) { const params = new URLSearchParams(); if (isbn) params.set('isbn', isbn); if (title) params.set('title', title); if (author) params.set('author', author); const res = await fetch(`/books/metadata-lookup/?${params.toString()}`); return res.json(); } // Manually triggered (never automatic) — looks up which library shelf/DDC category this // book falls under via the server-side DNB→Open Library proxy, purely as an organizational // hint (shown as a badge). See books/views.py:lookup_book_metadata for why this one call is // allowed to briefly touch the server with a plaintext ISBN/title/author. // // Fallback chain when there's no ISBN (older upload, or a book that never had one — e.g. // every PDF, since pdf.js doesn't expose one): (1) extract ISBN from the file itself, for // EPUBs, (2) server-side title/author text search (less precise — could match the wrong // edition/translation), (3) if that also comes up empty, ask the user to type in an ISBN // by hand (from the book's cover/an online store) and retry once. async function lookupBookMetadata(bookId) { const book = _lastBookListData.find(b => b.id === bookId); if (!book) return; const cached = bookMetaCache[bookId] || {}; let isbn = cached.isbn || book.isbn || ''; const isPdf = (cached.type || book.type) === 'pdf'; const title = cached.title || book.title || ''; const author = cached.author || book.author || ''; if (!isbn && !isPdf) { // Older upload without a stored ISBN — extract it from the book file itself (may take // a moment for large books, since it downloads/decrypts the full file if not cached). isbn = await _extractIsbnFromBookFile(bookId); } try { let data = await _fetchShelfLookup(isbn, title, author); if (!data.label) { const manual = await customPrompt( 'Keine Regal-Kategorie per ISBN/Titel/Autor gefunden. ISBN manuell eingeben (z. B. vom Buchrücken oder Online-Shop), oder leer lassen zum Abbrechen:', '' ); const manualIsbn = (manual || '').replace(/[^0-9Xx]/g, ''); if (manualIsbn) { isbn = manualIsbn; data = await _fetchShelfLookup(isbn, title, author); } } // Persist whatever we ended up with either way, so a repeat lookup never needs to // re-extract/re-search from scratch. const overrides = {}; if (isbn) overrides.isbn = isbn; if (data.label) overrides.shelfTag = data.label; if (Object.keys(overrides).length) { await _updateBookMeta(bookId, overrides); renderBookList(_lastBookListData); } if (!data.label) { await customAlert('Keine Regal-Kategorie gefunden (weder DNB noch Open Library, per ISBN oder Titel/Autor).'); } } catch (e) { await customAlert('Metadaten-Abruf fehlgeschlagen: ' + e.message); } } function bookFileSelected(input) { const file = input.files[0]; if (!file) return; uploadEbook(file); input.value = ''; } function initBookDropZone() { // Prevent Firefox from opening dragged files when dropped outside the zone document.addEventListener('dragover', e => e.preventDefault()); document.addEventListener('drop', e => { const zone = $('book-drop-zone'); if (!zone || !zone.contains(e.target)) e.preventDefault(); }); const zone = $('book-drop-zone'); if (!zone) return; zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('drag-over'); }); zone.addEventListener('dragleave', () => zone.classList.remove('drag-over')); zone.addEventListener('drop', e => { e.preventDefault(); zone.classList.remove('drag-over'); const file = e.dataTransfer.files[0]; if (file) uploadEbook(file); }); } async function deriveAndStoreKey() { const pwInput = document.getElementById('enc-key-password'); const statusEl = $('enc-key-status'); const pw = pwInput ? pwInput.value : ''; if (!pw) { if (statusEl) statusEl.textContent = 'Bitte gib dein Passwort ein.'; return; } if (statusEl) statusEl.textContent = 'Schlüssel wird abgeleitet …'; try { const enc = new TextEncoder(); const username = document.querySelector('meta[name="username"]')?.content || ''; const mat = await crypto.subtle.importKey('raw', enc.encode(pw), 'PBKDF2', false, ['deriveKey']); const key = await crypto.subtle.deriveKey( {name: 'PBKDF2', salt: enc.encode('diora:' + username), iterations: 200000, hash: 'SHA-256'}, mat, {name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt'] ); const raw = await crypto.subtle.exportKey('raw', key); const storageKey = `diora_enc_key_${window.USER_ID || 0}`; localStorage.setItem(storageKey, bytesToBase64(new Uint8Array(raw))); _encKey = null; // reset cached key if (statusEl) statusEl.textContent = '✓ Entsperrt'; const prompt = $('enc-key-prompt'); const uploadArea = $('book-upload-area'); if (prompt) prompt.style.display = 'none'; if (uploadArea) uploadArea.style.display = ''; loadBookList(); } catch (err) { if (statusEl) statusEl.textContent = 'Fehler: ' + err.message; } } async function uploadEbook(file) { const statusEl = $('book-upload-status'); if (file.size > DIORA_CONFIG.ebookMaxBytes) { if (statusEl) statusEl.textContent = `Datei zu groß (höchstens ${DIORA_CONFIG.ebookMaxBytes / 1024 / 1024} MB).`; return false; } return _importEbookBuffer(await file.arrayBuffer(), file.name, statusEl); } // Shared tail of every import path (local file, cloud): parse metadata, encrypt // with the user's key and POST ciphertext. Plaintext never leaves the browser. async function _importEbookBuffer(buf, filename, statusEl) { const isPdf = /\.pdf$/i.test(filename); const isEpub = /\.epub$/i.test(filename); if (!isPdf && !isEpub) { if (statusEl) statusEl.textContent = 'Nur .epub- und .pdf-Dateien werden unterstützt.'; return false; } if (buf.byteLength > DIORA_CONFIG.ebookMaxBytes) { if (statusEl) statusEl.textContent = `Datei zu groß (höchstens ${DIORA_CONFIG.ebookMaxBytes / 1024 / 1024} MB).`; return false; } if (statusEl) statusEl.textContent = 'Verschlüssele …'; try { let title = filename.replace(/\.(epub|pdf)$/i, ''); let author = ''; let isbn = ''; const type = isPdf ? 'pdf' : 'epub'; if (isPdf) { try { const pdfDoc = await pdfjsLib.getDocument({data: new Uint8Array(buf.slice(0))}).promise; const meta = await pdfDoc.getMetadata(); title = meta.info?.Title?.trim() || title; author = meta.info?.Author?.trim() || ''; } catch (e) { /* use filename as title */ } } else { try { const zip = await JSZip.loadAsync(buf.slice(0)); const containerXml = await zip.file('META-INF/container.xml').async('text'); const containerDoc = new DOMParser().parseFromString(containerXml, 'application/xml'); const opfPath = containerDoc.querySelector('rootfile')?.getAttribute('full-path'); if (opfPath) { const opfText = await zip.file(opfPath).async('text'); const opfDoc = new DOMParser().parseFromString(opfText, 'application/xml'); title = opfDoc.querySelector('metadata > title, metadata > *|title')?.textContent?.trim() || title; author = opfDoc.querySelector('metadata > creator, metadata > *|creator')?.textContent?.trim() || ''; isbn = _extractIsbnFromOpf(opfDoc); } } catch (e) { /* use filename as title */ } } const key = await getOrCreateEncKey(); const metaJson = new TextEncoder().encode(JSON.stringify({title, author, filename, type, isbn, folder: '', shelfTag: ''})); const [metaEnc, dataEnc] = await Promise.all([ encryptBytes(key, metaJson), encryptBytes(key, buf), ]); if (statusEl) statusEl.textContent = 'Lade hoch …'; const res = await fetch('/books/upload/', { method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, body: JSON.stringify({ meta_ct: metaEnc.ciphertext, meta_iv: metaEnc.iv, data_ct: dataEnc.ciphertext, data_iv: dataEnc.iv, }), }); const data = await res.json(); if (data.ok) { if (statusEl) statusEl.textContent = `✓ „${title}“ hochgeladen`; loadBookList(); return true; } if (statusEl) statusEl.textContent = 'Fehler: ' + (data.error ? _germanError(data.error) : 'Hochladen fehlgeschlagen'); return false; } catch (e) { if (statusEl) statusEl.textContent = 'Hochladen fehlgeschlagen: ' + e.message; return false; } } // --------------------------------------------------------------------------- // Cloud import — browse a user-configured WebDAV server and pull a book in. // // The download is proxied by the server because the remote host is a different // origin and sends no CORS headers. It still lands here as raw bytes and goes // through _importEbookBuffer like any local file, so what gets stored is // ciphertext the server cannot read. // --------------------------------------------------------------------------- let _cloudSources = []; let _cloudSourceId = null; let _cloudPath = ''; async function toggleCloudImport() { const panel = $('cloud-browser'); if (!panel) return; if (panel.style.display !== 'none') { closeCloudImport(); return; } try { const res = await fetch('/books/cloud/sources/'); _cloudSources = (await res.json()).sources || []; } catch (e) { await customAlert('Cloud-Verbindungen konnten nicht geladen werden: ' + e.message); return; } if (!_cloudSources.length) { await customAlert( 'Noch keine Cloud-Verbindung eingerichtet. Unter Einstellungen → ' + '„Cloud-Verbindungen für Bücher“ kannst du eine hinzufügen.' ); return; } const select = $('cloud-source-select'); if (select) { select.innerHTML = ''; for (const source of _cloudSources) { const option = document.createElement('option'); option.value = source.id; option.textContent = source.label; select.appendChild(option); } select.style.display = _cloudSources.length > 1 ? '' : 'none'; } panel.style.display = ''; _cloudSourceId = _cloudSources[0].id; _cloudBrowse(''); } function closeCloudImport() { const panel = $('cloud-browser'); if (panel) panel.style.display = 'none'; } function _onCloudSourceChange(value) { _cloudSourceId = parseInt(value, 10); _cloudBrowse(''); } function _cloudParentPath(path) { const parts = path.split('/').filter(Boolean); parts.pop(); return parts.join('/'); } function _formatBytes(n) { if (!n) return ''; if (n < 1024) return n + ' B'; if (n < 1024 * 1024) return Math.round(n / 1024) + ' KB'; return (n / 1024 / 1024).toFixed(1) + ' MB'; } function _cloudEntryRow(label, onClick, sizeText) { const li = document.createElement('li'); li.className = 'cloud-entry'; const button = document.createElement('button'); button.type = 'button'; button.className = 'cloud-entry-name'; button.textContent = label; button.addEventListener('click', onClick); li.appendChild(button); if (sizeText) { const size = document.createElement('span'); size.className = 'cloud-entry-size'; size.textContent = sizeText; li.appendChild(size); } return li; } async function _cloudBrowse(path) { const listEl = $('cloud-entries'); const pathEl = $('cloud-path'); const statusEl = $('cloud-status'); if (!listEl) return; if (statusEl) statusEl.textContent = 'Lade…'; listEl.innerHTML = ''; let data; try { const res = await fetch(`/books/cloud/${_cloudSourceId}/browse/?path=${encodeURIComponent(path)}`); data = await res.json(); if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); } catch (e) { if (statusEl) statusEl.textContent = 'Fehler: ' + e.message; return; } _cloudPath = data.path || ''; if (pathEl) pathEl.textContent = '/' + _cloudPath; if (statusEl) statusEl.textContent = ''; if (_cloudPath) { listEl.appendChild(_cloudEntryRow('⬑ ..', () => _cloudBrowse(_cloudParentPath(_cloudPath)))); } const entries = (data.entries || []).filter(e => e.is_dir || e.is_book); if (!entries.length && statusEl) { statusEl.textContent = 'Keine Bücher oder Ordner in diesem Verzeichnis.'; } for (const entry of entries) { listEl.appendChild(entry.is_dir ? _cloudEntryRow('📁 ' + entry.name, () => _cloudBrowse(entry.path)) : _cloudEntryRow('📖 ' + entry.name, () => _cloudImport(entry), _formatBytes(entry.size))); } } async function _cloudImport(entry) { const statusEl = $('cloud-status'); if (statusEl) statusEl.textContent = `Lade „${entry.name}“…`; try { const res = await fetch(`/books/cloud/${_cloudSourceId}/fetch/?path=${encodeURIComponent(entry.path)}`); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || `HTTP ${res.status}`); } const buf = await res.arrayBuffer(); if (await _importEbookBuffer(buf, entry.name, statusEl) && statusEl) { statusEl.textContent = `✓ „${entry.name}“ importiert`; } } catch (e) { if (statusEl) statusEl.textContent = 'Import fehlgeschlagen: ' + e.message; } } async function _parsePdfOutline(pdf, items, depth) { depth = depth || 0; const result = []; for (const item of items) { let href = ''; if (item.dest) { try { const dest = typeof item.dest === 'string' ? await pdf.getDestination(item.dest) : item.dest; if (dest) { const pageIndex = await pdf.getPageIndex(dest[0]); href = `#pdf-page-${pageIndex + 1}`; } } catch (e) {} } result.push({label: item.title || '(untitled)', href, depth}); if (item.items && item.items.length) { result.push(...await _parsePdfOutline(pdf, item.items, depth + 1)); } } return result; } async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) { const myGen = ++_pdfRenderGen; const pdf = currentPdfDoc || await pdfjsLib.getDocument({data: new Uint8Array(arrayBuffer.slice(0))}).promise; if (_pdfRenderGen !== myGen) return null; currentPdfDoc = pdf; let pdfTitle = '', pdfAuthor = ''; try { const meta = await pdf.getMetadata(); pdfTitle = meta.info?.Title?.trim() || ''; pdfAuthor = meta.info?.Author?.trim() || ''; } catch (e) {} let toc = []; try { const outline = await pdf.getOutline(); if (outline && outline.length) toc = await _parsePdfOutline(pdf, outline); } catch (e) {} contentEl.innerHTML = ''; const pdfVp = document.createElement('div'); pdfVp.id = 'pdf-viewport'; contentEl.appendChild(pdfVp); const containerWidth = readerSettings.pdfSpread ? contentEl.clientWidth - 32 : Math.min(contentEl.clientWidth - 32, 900); const pageWidth = readerSettings.pdfSpread ? (containerWidth - 8) / 2 : containerWidth; const dpr = window.devicePixelRatio || 1; // Pass 1: build DOM structure and size all canvases (no pixel rendering yet) const pageEntries = []; let spreadContainer = null; for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) { if (_pdfRenderGen !== myGen) { contentEl.innerHTML = ''; return null; } const page = await pdf.getPage(pageNum); const naturalVp = page.getViewport({scale: 1}); const scale = scaleOverride != null ? scaleOverride : Math.max(0.5, pageWidth / naturalVp.width) * (readerSettings.pdfZoom / 100); const viewport = page.getViewport({scale}); const wrapper = document.createElement('div'); wrapper.className = 'pdf-page-wrapper'; wrapper.id = `pdf-page-${pageNum}`; const inner = document.createElement('div'); inner.className = 'pdf-page-inner'; const canvas = document.createElement('canvas'); canvas.className = 'pdf-page'; canvas.width = Math.round(viewport.width * dpr); canvas.height = Math.round(viewport.height * dpr); canvas.style.width = viewport.width + 'px'; canvas.style.height = viewport.height + 'px'; inner.appendChild(canvas); wrapper.appendChild(inner); if (!readerSettings.pdfSpread) { pdfVp.appendChild(wrapper); } else if (pageNum === 1) { wrapper.classList.add('pdf-spread-cover'); pdfVp.appendChild(wrapper); spreadContainer = null; } else { if (pageNum % 2 === 0) { spreadContainer = document.createElement('div'); spreadContainer.className = 'pdf-spread-wrapper'; pdfVp.appendChild(spreadContainer); } if (spreadContainer) spreadContainer.appendChild(wrapper); else pdfVp.appendChild(wrapper); } pageEntries.push({page, canvas, viewport}); } pdfTotalPages = pdf.numPages; // Scroll to pivot page immediately — DOM and canvas sizes are set const pivot = Math.max(1, Math.min(pdf.numPages, pivotPage || 1)); const pivotEl = document.getElementById(`pdf-page-${pivot}`); if (pivotEl && pivot > 1) pivotEl.scrollIntoView({block: 'start'}); // Pass 2: render pixels from pivot outward (pivot→end, then pivot-1→start) const order = []; for (let i = pivot - 1; i < pdf.numPages; i++) order.push(i); for (let i = pivot - 2; i >= 0; i--) order.push(i); for (const idx of order) { if (_pdfRenderGen !== myGen) { contentEl.innerHTML = ''; return null; } const {page, canvas, viewport} = pageEntries[idx]; const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr); await page.render({canvasContext: ctx, viewport}).promise; } return {title: pdfTitle, author: pdfAuthor, toc, numPages: pdf.numPages}; } // --------------------------------------------------------------------------- // Read-aloud (TTS) — server-side Piper synthesis, one sentence per request. // EPUB only (no block model for PDFs). Highlights the sentence currently // playing and scrolls it into view; playback advances sentence-by-sentence, // then block-by-block, until stopped or the book ends. // --------------------------------------------------------------------------- const TTS_MAX_CHARS = 480; // stay under the server's 500-char cap with margin let ttsActive = false; let ttsPaused = false; let ttsRunToken = 0; let ttsAudio = null; let ttsCurrentMark = null; let ttsBarEl = null; let ttsLang = localStorage.getItem('diora_tts_lang') || 'de'; function setTtsLang(lang) { ttsLang = lang; localStorage.setItem('diora_tts_lang', lang); } function _ttsSplitSentences(text) { const raw = text.split(/(?<=[.!?])\s+/).map(s => s.trim()).filter(Boolean); const pieces = raw.length ? raw : [text]; const out = []; for (const piece of pieces) { let rest = piece; while (rest.length > TTS_MAX_CHARS) { let cut = rest.lastIndexOf(' ', TTS_MAX_CHARS); if (cut <= 0) cut = TTS_MAX_CHARS; out.push(rest.slice(0, cut).trim()); rest = rest.slice(cut).trim(); } if (rest) out.push(rest); } return out; } async function _ttsFetchAudio(sentence) { const resp = await fetch('/tts/synthesize/', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({text: sentence, lang: ttsLang}), }); if (!resp.ok) throw new Error('tts request failed'); return await resp.blob(); } function _ttsClearHighlight() { if (ttsCurrentMark && ttsCurrentMark.parentNode) { const parent = ttsCurrentMark.parentNode; while (ttsCurrentMark.firstChild) parent.insertBefore(ttsCurrentMark.firstChild, ttsCurrentMark); parent.removeChild(ttsCurrentMark); parent.normalize(); } ttsCurrentMark = null; } // Finds `sentence` as a substring of block.textContent starting at fromOffset // (so repeated sentence text earlier in the block isn't matched again), turns // it into a DOM Range via the same char-offset addressing highlights use, and // wraps it in a . Returns the offset to resume searching from. function _ttsHighlightSentence(block, sentence, fromOffset) { _ttsClearHighlight(); const full = block.textContent; const idx = full.indexOf(sentence, fromOffset); if (idx === -1) return fromOffset; const start = _nodeAtCharOffset(block, idx); const end = _nodeAtCharOffset(block, idx + sentence.length); if (!start || !end) return idx + sentence.length; try { const range = document.createRange(); range.setStart(start.node, start.offset); range.setEnd(end.node, end.offset); const mark = document.createElement('mark'); mark.className = 'tts-current'; range.surroundContents(mark); ttsCurrentMark = mark; _ttsScrollIntoView(mark); } catch (e) {} return idx + sentence.length; } function _ttsScrollIntoView(el) { const contentEl = $('reader-content'); if (!contentEl) return; const top = el.getBoundingClientRect().top - contentEl.getBoundingClientRect().top; if (top < 40 || top > contentEl.clientHeight - 80) { _suppressScrollJumpDetect(); contentEl.scrollBy({top: top - contentEl.clientHeight * 0.3, behavior: 'smooth'}); } } function _ttsPlayBlob(blob) { return new Promise((resolve) => { const url = URL.createObjectURL(blob); const audio = new Audio(url); ttsAudio = audio; const done = () => { URL.revokeObjectURL(url); resolve(); }; audio.addEventListener('ended', done); audio.addEventListener('error', done); if (!ttsPaused) audio.play().catch(done); }); } // Walks blocks/sentences from startBlockIndex onward, fetching one sentence // ahead while the current one plays so there's no gap between them. async function _ttsPlayLoop(blocks, startBlockIndex, runToken) { function* sentenceStream() { for (let bi = startBlockIndex; bi < blocks.length; bi++) { const block = blocks[bi]; const text = (block.textContent || '').trim(); if (!text) continue; for (const sentence of _ttsSplitSentences(text)) yield {block, sentence}; } } const iter = sentenceStream(); let cur = iter.next(); if (cur.done) { stopReadAloud(); return; } let curFetch = _ttsFetchAudio(cur.value.sentence); let lastBlock = null; let blockSearchOffset = 0; while (!cur.done) { if (runToken !== ttsRunToken) return; const {block, sentence} = cur.value; const next = iter.next(); const nextFetch = next.done ? null : _ttsFetchAudio(next.value.sentence).catch(() => null); let audioBlob; try { audioBlob = await curFetch; } catch (e) { stopReadAloud(); return; } if (runToken !== ttsRunToken) return; if (block !== lastBlock) { lastBlock = block; blockSearchOffset = 0; } blockSearchOffset = _ttsHighlightSentence(block, sentence, blockSearchOffset); await _ttsPlayBlob(audioBlob); if (runToken !== ttsRunToken) return; cur = next; curFetch = nextFetch; } if (runToken === ttsRunToken) stopReadAloud(); } function _ttsTogglePause() { ttsPaused = !ttsPaused; if (ttsAudio) { if (ttsPaused) ttsAudio.pause(); else ttsAudio.play().catch(() => {}); } _ttsUpdateBar(); } function _ttsUpdateBar() { if (!ttsBarEl) return; const btn = ttsBarEl.querySelector('.tts-pause-btn'); if (btn) btn.textContent = ttsPaused ? '▶' : '⏸'; } function _ttsShowBar() { _ttsRemoveBar(); const bar = document.createElement('div'); bar.className = 'tts-bar'; bar.innerHTML = ` `; document.body.appendChild(bar); ttsBarEl = bar; bar.querySelector('.tts-pause-btn').addEventListener('click', _ttsTogglePause); bar.querySelector('.tts-stop-btn').addEventListener('click', stopReadAloud); } function _ttsRemoveBar() { if (ttsBarEl) { ttsBarEl.remove(); ttsBarEl = null; } } function toggleReadAloud() { if (ttsActive) stopReadAloud(); else startReadAloud(); } function startReadAloud() { if (ttsActive) return; if (currentPdfDoc) { const toast = document.createElement('div'); toast.className = 'reader-toast'; toast.textContent = 'Vorlesen ist aktuell nur für EPUB-Bücher verfügbar.'; document.body.appendChild(toast); setTimeout(() => toast.remove(), 2200); return; } const contentEl = $('reader-content'); if (!contentEl) return; const blocks = Array.from(contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR)); if (!blocks.length) return; const [anchorBlock] = _anchorParts(getPositionAnchor(contentEl)); const startIndex = (anchorBlock >= 0 && anchorBlock < blocks.length) ? anchorBlock : 0; ttsActive = true; ttsPaused = false; ttsRunToken++; const runToken = ttsRunToken; const btn = $('reader-tts-btn'); if (btn) { btn.classList.add('active'); btn.title = 'Vorlesen beenden'; } _ttsShowBar(); _ttsPlayLoop(blocks, startIndex, runToken); } function stopReadAloud() { ttsRunToken++; ttsActive = false; ttsPaused = false; if (ttsAudio) { try { ttsAudio.pause(); ttsAudio.src = ''; } catch (e) {} ttsAudio = null; } _ttsClearHighlight(); _ttsRemoveBar(); const btn = $('reader-tts-btn'); if (btn) { btn.classList.remove('active'); btn.title = 'Vorlesen'; } } // --------------------------------------------------------------------------- // Immersive reader mode — tap centre of screen to toggle bars // --------------------------------------------------------------------------- let _immBarsVisible = true; function _immHandleTap(e) { // Ignore taps on interactive elements (buttons, links, inputs, settings panel, footnote popover) if (e.target.closest('button, a, input, select, label, #reader-settings-panel, .reader-header, .footnote-popover, #reader-margin, #highlight-popover, .note-bottom-sheet, .tts-bar')) return; // In marker mode, taps have a dedicated meaning (highlight/create a note) — // don't also toggle the immersive bars underneath. if (markerModeActive) return; // Don't toggle bars while the user has just finished selecting text (e.g. to highlight it) const sel = window.getSelection(); if (sel && !sel.isCollapsed) return; // If a footnote popover is open, dismiss it instead of toggling bars if (currentFootnotePopover && !currentFootnotePopover.contains(e.target)) { dismissFootnotePopover(); return; } _immBarsVisible = !_immBarsVisible; document.body.classList.toggle('reader-immersive', !_immBarsVisible); // Close settings panel when bars disappear if (!_immBarsVisible && readerSettingsPanelOpen) { const sp = document.getElementById('reader-settings-panel'); if (sp) sp.remove(); readerSettingsPanelOpen = false; } } function enterReaderImmersiveMode() { _immBarsVisible = true; document.body.classList.remove('reader-immersive'); document.addEventListener('click', _immHandleTap); } function exitReaderImmersiveMode() { _immBarsVisible = true; document.body.classList.remove('reader-immersive'); document.removeEventListener('click', _immHandleTap); } // Serializes openBook: it empties #reader-content, then appends the chapters // across several awaits. A second call in between (a second tap while a slow // e-reader is still decrypting) appended the whole book a second time — and // progress saved from that doubled DOM had block indices beyond the real book, // which then out-ranked every real position on the server. let _openBookInFlight = null; let _repairProgressAnchor = false; // stored anchor didn't fit this book async function openBook(bookId) { if (_openBookInFlight) { if (_openBookInFlight.bookId === bookId) return _openBookInFlight.promise; await _openBookInFlight.promise.catch(() => {}); } const promise = _openBookOnce(bookId); _openBookInFlight = {bookId, promise}; try { return await promise; } finally { if (_openBookInFlight && _openBookInFlight.promise === promise) _openBookInFlight = null; } } async function _openBookOnce(bookId) { _repairProgressAnchor = false; const overlay = $('reader-overlay'); const contentEl = $('reader-content'); const titleEl = $('reader-title'); if (!overlay || !contentEl) return; titleEl.textContent = 'Lade …'; contentEl.innerHTML = ''; overlay.style.display = ''; try { loadReaderSettings(bookId); _charIndex = null; const key = await getOrCreateEncKey(); let data_ct, data_iv; const cached = await _getCachedBook(bookId); if (cached) { ({data_ct, data_iv} = cached); } else { let res; try { res = await fetch(`/books/${bookId}/data/`); } catch { // offline and book not cached yet overlay.style.display = 'none'; const toast = document.createElement('div'); toast.className = 'reader-toast'; toast.textContent = 'Buch nicht offline verfügbar — einmal online öffnen zum Cachen.'; document.body.appendChild(toast); setTimeout(() => toast.remove(), 3500); return; } ({data_ct, data_iv} = await res.json()); _setCachedBook(bookId, data_ct, data_iv); // fire-and-forget } const plain = await decryptBytes(key, data_iv, data_ct); // Revoke any previous image blob URLs for (const url of Object.values(currentImageMap)) URL.revokeObjectURL(url); currentImageMap = {}; const cachedMeta = bookMetaCache[bookId] || {}; let title = cachedMeta.title || ''; let author = cachedMeta.author || ''; let toc = []; let numPages = 0; const isPdfBook = cachedMeta.type === 'pdf'; if (isPdfBook) { currentPdfDoc = null; // reset so renderPdf creates fresh doc const loadingEl = document.createElement('div'); loadingEl.className = 'pdf-loading-overlay'; loadingEl.innerHTML = ''; overlay.appendChild(loadingEl); const result = await renderPdf(plain, contentEl); loadingEl.remove(); title = result.title || title; author = result.author || author; toc = result.toc; numPages = result.numPages; currentPdfBuffer = plain; } else { currentPdfBuffer = null; const result = await parseEpub(plain); title = result.title || title; author = result.author || author; toc = result.toc; currentImageMap = result.imageMap; // Inject chapters progressively so the browser stays responsive on large books const BATCH = 8; for (let i = 0; i < result.chapters.length; i += BATCH) { const frag = document.createDocumentFragment(); const tmp = document.createElement('div'); for (let j = i; j < Math.min(i + BATCH, result.chapters.length); j++) { tmp.innerHTML = result.chapters[j]; while (tmp.firstChild) frag.appendChild(tmp.firstChild); } contentEl.appendChild(frag); await new Promise(r => requestAnimationFrame(r)); } // Used to decide whole-percent vs one-decimal display further down — // see _formatBookPercent(). _currentBookCharCount = contentEl.textContent.length; } currentBookToc = toc; titleEl.textContent = title + (author ? ` — ${author}` : ''); currentBookId = bookId; // Load bookmarks and highlights await Promise.all([ loadBookmarks(bookId), loadHighlights(bookId), ]); // Apply reader settings (theme, font size, etc.) applyReaderSettings(isPdfBook); // If this book already has notes/highlights, surface them right away // instead of making the reader hide them behind a manual toggle. // Not on phones: there the margin covers the text; it stays one tap away (✎). if (!isPdfBook && currentHighlights.length && !readerAnnotationsMarginOpen && !_isMobileReader()) { toggleAnnotationsMargin(); } // Touch: swipe (paginated) + pinch-to-zoom contentEl.addEventListener('touchstart', _pdfTouchStart, {passive: true}); contentEl.addEventListener('touchmove', _pdfTouchMove, {passive: false}); contentEl.addEventListener('touchend', _pdfTouchEnd, {passive: true}); contentEl.addEventListener('touchcancel', _pdfTouchEnd, {passive: true}); // Set up progress input const progressInput = $('reader-progress-input'); const progressSuffix = $('reader-progress-suffix'); const isPdf = isPdfBook; if (progressInput) { progressInput.style.display = ''; if (isPdf) { progressInput.value = 1; if (progressSuffix) progressSuffix.textContent = `/ ${numPages}`; } else { progressInput.value = _formatBookPercent(0); if (progressSuffix) progressSuffix.textContent = '%'; } progressInput.addEventListener('change', function () { _suppressScrollJumpDetect(); if (isPdf) { const page = Math.min(numPages, Math.max(1, parseInt(this.value, 10) || 1)); this.value = page; const target = contentEl.querySelector(`#pdf-page-${page}`); if (target) { const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top; contentEl.scrollBy({top: top - 8, behavior: 'smooth'}); } } else { const pct = Math.min(100, Math.max(0, _parseLocalePercent(this.value))); this.value = _formatBookPercent(pct / 100); restoreFromAnchor(contentEl, _anchorForFraction(contentEl, pct / 100)); } }); progressInput.addEventListener('click', function () { this.select(); }); } // Restore scroll position — must happen BEFORE auto-save timer is started _suppressScrollJumpDetect(); try { let fraction = 0, anchor = ''; try { const progressRes = await fetch('/books/'); const allBooks = await progressRes.json(); const bookData = allBooks.find(b => b.id === bookId); fraction = bookData ? (bookData.scroll_fraction || 0) : 0; anchor = bookData ? (bookData.position_anchor || '') : ''; } catch { // offline — use cached meta const cached = await _loadCachedBookMeta(); const bookData = cached.find(b => b.id === bookId); fraction = bookData ? (bookData.scroll_fraction || 0) : 0; anchor = bookData ? (bookData.position_anchor || '') : ''; } _currentPositionAnchor = anchor; if (isPdf && readerSettings.pdfPaginated) { if (fraction > 0 && pdfTotalPages > 1) pdfCurrentPage = Math.max(1, Math.round(fraction * (pdfTotalPages - 1)) + 1); enterPdfPaginatedMode(); } else if (isPdf) { // PDF scroll mode await new Promise(r => requestAnimationFrame(r)); if (fraction > 0) contentEl.scrollTop = fraction * (contentEl.scrollHeight - contentEl.clientHeight); } else { // EPUB — wait for images so scrollHeight is final const imgs = Array.from(contentEl.querySelectorAll('img')); if (imgs.length) { await Promise.all(imgs.map(img => img.complete ? Promise.resolve() : new Promise(r => { img.onload = r; img.onerror = r; }) )); } await new Promise(r => requestAnimationFrame(r)); if (!restoreFromAnchor(contentEl, anchor) && fraction > 0) { restoreFromAnchor(contentEl, _anchorForFraction(contentEl, fraction)); } // An anchor past the end of this book can only come from a broken // render. The server keeps the furthest block index, so it would block // every real position from now on: overwrite it with the next save. const [storedBlock] = _anchorParts(anchor); if (storedBlock >= contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR).length) { _repairProgressAnchor = true; } } } catch (e) {} // Prime the "settled" baseline to wherever we just restored to, rather // than waiting to lazily pick up whatever scroll event happens to fire // first — that could otherwise be a later, unrelated layout correction. _scrollRestTop = contentEl.scrollTop; // Update progress input on scroll contentEl.addEventListener('scroll', () => { if (!progressInput) return; if (isPdf) { const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper'); const cTop = contentEl.getBoundingClientRect().top; let currentPage = 1; for (const w of wrappers) { if (w.getBoundingClientRect().bottom > cTop + 20) { currentPage = parseInt(w.id.replace('pdf-page-', ''), 10) || 1; break; } } progressInput.value = currentPage; } // EPUB: shown from the position anchor, see the anchor tracking below. }); // Auto-save progress every 10s and on scroll (debounced 2s) // Started AFTER restore so an early visibilitychange can't overwrite with position 0 readerScrollSaveTimer = setInterval(saveReaderProgress, 10000); let _scrollDebounce = null; contentEl.addEventListener('scroll', () => { clearTimeout(_scrollDebounce); _scrollDebounce = setTimeout(saveReaderProgress, 2000); }, {passive: true}); // Keep _currentPositionAnchor fresh independent of the slower save debounce // above: the resize observer below (or an immersive-bars toggle right after // scrolling) would otherwise restore to a stale, pre-scroll anchor. if (!isPdf) { let _anchorTrackDebounce = null; contentEl.addEventListener('scroll', () => { clearTimeout(_anchorTrackDebounce); _anchorTrackDebounce = setTimeout(() => { _currentPositionAnchor = getPositionAnchor(contentEl); const f = _fractionFromAnchor(contentEl, _currentPositionAnchor); if (progressInput && f !== null && document.activeElement !== progressInput) { progressInput.value = _formatBookPercent(f); } }, 150); }, {passive: true}); } // Restore anchor on viewport resize (e.g. screen rotation, font zoom) if (!isPdf) { _resizeObserver = new ResizeObserver(() => { if (_currentPositionAnchor) { _suppressScrollJumpDetect(); requestAnimationFrame(() => restoreFromAnchor(contentEl, _currentPositionAnchor)); } _repositionMarginMarkers(); }); _resizeObserver.observe(contentEl); } // Offer to jump back after a sudden, large scroll (accidental fling, // fat-fingered 'g'/'G' keybind, etc.) — see _checkScrollJump above. _scrollRestTop = null; let _scrollJumpRaf = null; contentEl.addEventListener('scroll', () => { if (_scrollJumpRaf) return; _scrollJumpRaf = requestAnimationFrame(() => { _scrollJumpRaf = null; _checkScrollJump(contentEl); _repositionMarginMarkers(); }); }, {passive: true}); enterReaderImmersiveMode(); } catch (e) { console.error('openBook failed:', e); overlay.style.display = 'none'; markBookBroken(bookId); } } async function exportEncKey() { const statusEl = $('book-key-status'); try { const key = await getOrCreateEncKey(); const raw = await crypto.subtle.exportKey('raw', key); const b64 = bytesToBase64(raw); await navigator.clipboard.writeText(b64); if (statusEl) statusEl.textContent = '✓ Schlüssel in die Zwischenablage kopiert'; setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 3000); } catch (e) { if (statusEl) statusEl.textContent = 'Export fehlgeschlagen: ' + e.message; } } function showImportKey() { const body = $('sidebar-body'); openSidebar('Schlüssel importieren', `

Füge den Schlüssel ein, den du im anderen Browser exportiert hast:

Das ersetzt den Schlüssel in diesem Browser. Hier hochgeladene Bücher sind erst wieder lesbar, wenn du den Schlüssel zurück abgleichst.

`); body.addEventListener('click', async function _importClick(e) { if (!e.target.closest('[data-import-key-apply]')) return; body.removeEventListener('click', _importClick); const b64 = (body.querySelector('#import-key-input')?.value || '').trim(); const statusEl = $('book-key-status'); try { const raw = base64ToBytes(b64); const importedKey = await crypto.subtle.importKey('raw', raw, {name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']); const re_exported = await crypto.subtle.exportKey('raw', importedKey); localStorage.setItem(`diora_enc_key_${window.USER_ID}`, bytesToBase64(re_exported)); closeSidebar(); if (statusEl) statusEl.textContent = '✓ Schlüssel importiert – Bücher werden neu geladen …'; await loadBookList(); if (statusEl) setTimeout(() => { statusEl.textContent = ''; }, 3000); } catch (e) { if ($('book-key-status')) $('book-key-status').textContent = 'Import fehlgeschlagen: ungültiger Schlüssel'; } }); } async function deleteBook(bookId) { if (!await customConfirm('Dieses Buch löschen? Das lässt sich nicht rückgängig machen.', { danger: true })) return; try { const res = await fetch(`/books/${bookId}/delete/`, { method: 'POST', headers: {'X-CSRFToken': getCsrfToken()}, }); const data = await res.json(); if (data.ok) loadBookList(); } catch (e) {} } async function saveReaderProgress(force = false) { if (!currentBookId) return; const contentEl = $('reader-content'); if (!contentEl) return; let fraction; if (readerSettings.pdfPaginated && currentPdfDoc && pdfTotalPages > 1) { fraction = (pdfCurrentPage - 1) / (pdfTotalPages - 1); } else { fraction = contentEl.scrollTop / (contentEl.scrollHeight - contentEl.clientHeight || 1); } // Chapter wrappers carry unique ids; seeing one twice means the book was // rendered twice. Positions from that DOM are wrong — don't store them. if (!currentPdfDoc && contentEl.querySelectorAll('#epub-chapter-0').length > 1) { console.warn('Book rendered twice, progress not saved'); return; } let anchor = ''; if (!currentPdfDoc) { anchor = getPositionAnchor(contentEl); _currentPositionAnchor = anchor; // EPUB: share of the text, the same on every device and layout. const textFraction = _fractionFromAnchor(contentEl, anchor); if (textFraction !== null) fraction = textFraction; } fraction = Math.min(1.0, Math.max(0.0, fraction)); _updateCachedBookProgress(currentBookId, fraction, anchor, force || (_repairProgressAnchor && !!anchor)); const repair = _repairProgressAnchor && !!anchor; if (repair) _repairProgressAnchor = false; const body = JSON.stringify({scroll_fraction: fraction, position_anchor: anchor, ...((force || repair) && {force: true})}); _lastProgressBeacon = {url: `/books/${currentBookId}/progress/`, body}; try { await fetch(`/books/${currentBookId}/progress/`, { method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, body, }); if (force) { const toast = document.createElement('div'); toast.className = 'reader-toast'; toast.textContent = 'Position gesetzt'; document.body.appendChild(toast); setTimeout(() => toast.remove(), 1800); } } catch (e) {} } function closeReader() { exitReaderImmersiveMode(); stopReadAloud(); // Save progress BEFORE hiding — scrollHeight/clientHeight return 0 once display:none saveReaderProgress(); if (bookmarksDirty) saveBookmarks(); if (highlightsDirty) saveHighlights(); const overlay = $('reader-overlay'); if (overlay) overlay.style.display = 'none'; if (readerScrollSaveTimer) { clearInterval(readerScrollSaveTimer); readerScrollSaveTimer = null; } if (_resizeObserver) { _resizeObserver.disconnect(); _resizeObserver = null; } _currentPositionAnchor = ''; // Close the margin panel readerAnnotationsMarginOpen = false; const marginEl = $('reader-margin'); if (marginEl) marginEl.classList.remove('open'); const marginMarkersEl = $('reader-margin-markers'); if (marginMarkersEl) marginMarkersEl.innerHTML = ''; // Disarm marker mode markerModeActive = false; document.querySelectorAll('.marker-mode-btn').forEach(b => b.classList.remove('active')); dismissNoteBottomSheet(); // Clear search before wiping content clearReaderSearch(); // Close settings panel if open readerSettingsPanelOpen = false; const sp = document.getElementById('reader-settings-panel'); if (sp) sp.remove(); // Reset progress input const progressInput = $('reader-progress-input'); if (progressInput) { progressInput.style.display = 'none'; progressInput.value = 0; } const progressSuffix = $('reader-progress-suffix'); if (progressSuffix) progressSuffix.textContent = ''; // Remove touch handlers and free image blob URLs const contentEl = $('reader-content'); if (contentEl) { contentEl.removeEventListener('touchstart', _pdfTouchStart); contentEl.removeEventListener('touchmove', _pdfTouchMove); contentEl.removeEventListener('touchend', _pdfTouchEnd); contentEl.removeEventListener('touchcancel', _pdfTouchEnd); contentEl.classList.remove('pinch-active'); contentEl.innerHTML = ''; } for (const url of Object.values(currentImageMap)) URL.revokeObjectURL(url); currentImageMap = {}; // Reset all state currentBookId = null; currentBookToc = []; _currentBookCharCount = 0; currentPdfDoc = null; currentPdfBuffer = null; currentBookmarks = []; bookmarksDirty = false; currentHighlights = []; highlightsDirty = false; _lastProgressBeacon = null; _lastBookmarkBeacon = null; _lastHighlightBeacon = null; dismissHighlightPopover(); pdfCurrentPage = 1; pdfTotalPages = 0; _pdfPageTextBoxCache = {}; // Remove PDF invert class if (overlay) overlay.classList.remove('pdf-inverted'); } function openTocSidebar() { if (!currentBookToc.length) { openSidebar('Inhaltsverzeichnis', '

Dieses Buch hat kein Inhaltsverzeichnis.

'); return; } let html = '
    '; for (const entry of currentBookToc) { const indent = entry.depth * 14; // Use data-toc-href — onclick would be stripped by sanitizeSidebarHtml html += `
  • `; } html += '
'; openSidebar('Inhaltsverzeichnis', html); // Attach delegated listener after sidebar body is populated const body = $('sidebar-body'); body.addEventListener('click', function _tocClick(e) { const btn = e.target.closest('.toc-entry'); if (btn) { body.removeEventListener('click', _tocClick); jumpToTocEntry(btn.getAttribute('data-toc-href') || ''); } }); } function jumpToTocEntry(href) { closeSidebar(); _suppressScrollJumpDetect(); setTimeout(() => { const contentEl = $('reader-content'); if (!contentEl) return; // PDF page jump if (href.startsWith('#pdf-page-')) { const target = contentEl.querySelector(href); if (target) { const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top; contentEl.scrollBy({top: top - 16, behavior: 'smooth'}); } return; } const hashIdx = href.indexOf('#'); const fragment = hashIdx >= 0 ? href.slice(hashIdx + 1) : ''; const filePath = hashIdx >= 0 ? href.slice(0, hashIdx) : href; let target = null; if (fragment) { target = contentEl.querySelector(`#${CSS.escape(fragment)}`); } if (!target && filePath) { target = Array.from(contentEl.querySelectorAll('[data-epub-src]')) .find(el => el.getAttribute('data-epub-src') === filePath) || null; } if (target) { const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top; contentEl.scrollBy({top: top - 16, behavior: 'smooth'}); } }, 50); } // --------------------------------------------------------------------------- // Reader Settings // --------------------------------------------------------------------------- // Typography defaults. Phones keep the long-standing ones; a wide screen // (desktop browser, diora desktop) starts bigger and wider. const _READER_TYPE_DEFAULTS_NARROW = { fontSize: 16, lineHeight: 1.8, maxWidth: 65, fontWeight: null }; const _READER_TYPE_DEFAULTS_WIDE = { fontSize: 20, lineHeight: 1.6, maxWidth: 90, fontWeight: 375 }; const _READER_WIDTH_MAX = 140; // ch, the width slider's upper end ("Full" stays separate) function _readerTypeDefaults() { return window.matchMedia('(min-width: 900px)').matches ? _READER_TYPE_DEFAULTS_WIDE : _READER_TYPE_DEFAULTS_NARROW; } function loadReaderSettings(bookId) { // Reset to defaults, then apply per-book overrides Object.assign(readerSettings, { theme: 'dark', fontFamily: 'serif', noBold: false, pdfZoom: 100, pdfInverted: false, pdfPaginated: false, pdfSpread: false }, _readerTypeDefaults()); try { const saved = JSON.parse(localStorage.getItem(`diora_reader_settings_${bookId}`) || '{}'); // Settings saved before v2 hold every value, changed or not. A value equal // to the old default was never chosen, so it gives way to today's default. if (!saved.v) { for (const [key, old] of Object.entries(_READER_TYPE_DEFAULTS_NARROW)) { if (saved[key] === old) delete saved[key]; } } delete saved.v; Object.assign(readerSettings, saved); if (saved.pdfPaginated === undefined) { readerSettings.pdfPaginated = window.innerWidth < 768; } } catch (e) {} } function saveReaderSettings() { if (currentBookId) { localStorage.setItem(`diora_reader_settings_${currentBookId}`, JSON.stringify({ ...readerSettings, v: 2 })); } } function applyReaderSettings(isPdf) { const overlay = $('reader-overlay'); const contentEl = $('reader-content'); if (!overlay || !contentEl) return; if (!isPdf) { contentEl.style.fontSize = readerSettings.fontSize + 'px'; contentEl.style.lineHeight = readerSettings.lineHeight; contentEl.style.setProperty('--reader-max-width', readerSettings.maxWidth + 'ch'); const fontMap = { serif: "'Source Serif 4', Georgia, 'Times New Roman', serif", sans: 'system-ui, -apple-system, sans-serif', humanist: 'Verdana, Geneva, sans-serif', mono: "'Courier New', monospace", }; contentEl.style.fontFamily = fontMap[readerSettings.fontFamily] || fontMap.serif; contentEl.classList.toggle('reader-no-bold', !!readerSettings.noBold); // Weight: null keeps the stylesheet's. --reader-weight lets old WebViews // without variable-font support (which drop weights like 425) pick a // static cut instead. contentEl.style.fontWeight = ''; if (readerSettings.fontWeight) { contentEl.style.fontWeight = String(readerSettings.fontWeight); contentEl.style.setProperty('--reader-weight', String(readerSettings.fontWeight)); } else { contentEl.style.removeProperty('--reader-weight'); } if (_currentPositionAnchor && currentBookId) { _suppressScrollJumpDetect(); requestAnimationFrame(() => restoreFromAnchor($('reader-content'), _currentPositionAnchor)); } } // Theme overlay.classList.remove('reader-theme-sepia', 'reader-theme-bright'); if (readerSettings.theme === 'sepia') overlay.classList.add('reader-theme-sepia'); else if (readerSettings.theme === 'bright') overlay.classList.add('reader-theme-bright'); // PDF invert if (isPdf && readerSettings.pdfInverted) overlay.classList.add('pdf-inverted'); else overlay.classList.remove('pdf-inverted'); // Annotations (marker mode + margin panel) are EPUB-only for now — PDFs have no text layer yet const marginBtn = $('reader-margin-btn'); if (marginBtn) marginBtn.style.display = isPdf ? 'none' : ''; if (isPdf && readerAnnotationsMarginOpen) toggleAnnotationsMargin(); document.querySelectorAll('.marker-mode-btn').forEach(b => { b.style.display = isPdf ? 'none' : ''; }); if (isPdf && markerModeActive) toggleMarkerMode(); } // Font weight slider: the variable Source Serif 4 takes any weight, the // system fonts only their fixed cuts (multiples of 100). function _readerWeightStep() { return readerSettings.fontFamily === 'serif' ? 25 : 100; } function _readerWeight() { const step = _readerWeightStep(); const w = readerSettings.fontWeight || parseInt(getComputedStyle($('reader-content')).fontWeight, 10) || 400; return Math.round(w / step) * step; } function toggleSettingsPanel() { const overlay = $('reader-overlay'); const contentEl = $('reader-content'); if (!overlay || !contentEl) return; const existing = document.getElementById('reader-settings-panel'); if (existing) { existing.remove(); readerSettingsPanelOpen = false; return; } readerSettingsPanelOpen = true; const isPdf = !!currentPdfDoc; const panel = document.createElement('div'); panel.id = 'reader-settings-panel'; panel.className = 'reader-settings-panel'; if (!isPdf) { panel.innerHTML = ` `; } else { panel.innerHTML = ` `; } overlay.insertBefore(panel, $('reader-body-row')); if (!isPdf) { const fontRange = panel.querySelector('#rs-font'); const fontVal = panel.querySelector('#rs-font-val'); fontRange.addEventListener('input', () => { readerSettings.fontSize = parseInt(fontRange.value, 10); fontVal.textContent = readerSettings.fontSize + 'px'; applyReaderSettings(false); saveReaderSettings(); }); panel.querySelector('#rs-font-minus').addEventListener('click', () => { fontRange.value = Math.max(+fontRange.min, parseInt(fontRange.value, 10) - 1); fontRange.dispatchEvent(new Event('input')); }); panel.querySelector('#rs-font-plus').addEventListener('click', () => { fontRange.value = Math.min(+fontRange.max, parseInt(fontRange.value, 10) + 1); fontRange.dispatchEvent(new Event('input')); }); const lineRange = panel.querySelector('#rs-line'); const lineVal = panel.querySelector('#rs-line-val'); lineRange.addEventListener('input', () => { readerSettings.lineHeight = (parseInt(lineRange.value, 10) / 10).toFixed(1); lineVal.textContent = readerSettings.lineHeight; applyReaderSettings(false); saveReaderSettings(); }); panel.querySelector('#rs-line-minus').addEventListener('click', () => { lineRange.value = Math.max(+lineRange.min, parseInt(lineRange.value, 10) - 1); lineRange.dispatchEvent(new Event('input')); }); panel.querySelector('#rs-line-plus').addEventListener('click', () => { lineRange.value = Math.min(+lineRange.max, parseInt(lineRange.value, 10) + 1); lineRange.dispatchEvent(new Event('input')); }); const widthRange = panel.querySelector('#rs-width'); const widthVal = panel.querySelector('#rs-width-val'); widthRange.addEventListener('input', () => { readerSettings.maxWidth = parseInt(widthRange.value, 10); widthVal.textContent = readerSettings.maxWidth + 'ch'; applyReaderSettings(false); saveReaderSettings(); }); panel.querySelector('#rs-width-minus').addEventListener('click', () => { widthRange.value = Math.max(+widthRange.min, parseInt(widthRange.value, 10) - 5); widthRange.dispatchEvent(new Event('input')); }); panel.querySelector('#rs-width-plus').addEventListener('click', () => { widthRange.value = Math.min(+widthRange.max, parseInt(widthRange.value, 10) + 5); widthRange.dispatchEvent(new Event('input')); }); panel.querySelector('#rs-width-full').addEventListener('click', () => { readerSettings.maxWidth = 999; widthRange.value = 90; widthVal.textContent = 'full'; applyReaderSettings(false); saveReaderSettings(); }); const weightRange = panel.querySelector('#rs-weight'); const weightVal = panel.querySelector('#rs-weight-val'); weightRange.addEventListener('input', () => { readerSettings.fontWeight = parseInt(weightRange.value, 10); weightVal.textContent = readerSettings.fontWeight; applyReaderSettings(false); saveReaderSettings(); }); panel.querySelector('#rs-weight-minus').addEventListener('click', () => { weightRange.value = Math.max(+weightRange.min, parseInt(weightRange.value, 10) - +weightRange.step); weightRange.dispatchEvent(new Event('input')); }); panel.querySelector('#rs-weight-plus').addEventListener('click', () => { weightRange.value = Math.min(+weightRange.max, parseInt(weightRange.value, 10) + +weightRange.step); weightRange.dispatchEvent(new Event('input')); }); panel.querySelectorAll('[data-rs-font]').forEach(btn => { btn.addEventListener('click', () => { readerSettings.fontFamily = btn.dataset.rsFont; panel.querySelectorAll('[data-rs-font]').forEach(b => b.classList.toggle('active', b === btn)); // The system fonts only have cuts at multiples of 100. weightRange.step = _readerWeightStep(); if (readerSettings.fontWeight) { readerSettings.fontWeight = Math.round(readerSettings.fontWeight / weightRange.step) * weightRange.step; } applyReaderSettings(false); saveReaderSettings(); weightRange.value = _readerWeight(); weightVal.textContent = _readerWeight(); }); }); panel.querySelector('#rs-no-bold').addEventListener('click', function () { readerSettings.noBold = !readerSettings.noBold; this.classList.toggle('active', readerSettings.noBold); applyReaderSettings(false); saveReaderSettings(); }); panel.querySelectorAll('[data-rs-theme]').forEach(btn => { btn.addEventListener('click', () => { readerSettings.theme = btn.dataset.rsTheme; panel.querySelectorAll('[data-rs-theme]').forEach(b => b.classList.toggle('active', b === btn)); applyReaderSettings(false); saveReaderSettings(); }); }); } else { const zoomRange = panel.querySelector('#rs-zoom'); zoomRange.addEventListener('input', () => applyPdfZoom(parseInt(zoomRange.value, 10))); panel.querySelector('#rs-zoom-minus').addEventListener('click', () => applyPdfZoom(Math.max(50, readerSettings.pdfZoom - 10))); panel.querySelector('#rs-zoom-plus').addEventListener('click', () => applyPdfZoom(Math.min(200, readerSettings.pdfZoom + 10))); panel.querySelector('#rs-invert').addEventListener('click', function () { readerSettings.pdfInverted = !readerSettings.pdfInverted; this.classList.toggle('active', readerSettings.pdfInverted); applyReaderSettings(true); saveReaderSettings(); }); panel.querySelector('#rs-spread').addEventListener('click', function () { readerSettings.pdfSpread = !readerSettings.pdfSpread; this.classList.toggle('active', readerSettings.pdfSpread); saveReaderSettings(); reRenderPdf(); }); } } async function applyPdfZoom(newZoom) { readerSettings.pdfZoom = newZoom; const zoomRange = document.getElementById('rs-zoom'); const zoomVal = document.getElementById('rs-zoom-val'); if (zoomRange) zoomRange.value = newZoom; if (zoomVal) zoomVal.textContent = newZoom + '%'; saveReaderSettings(); if (readerSettings.pdfPaginated) { pdfSmartZoomPage(pdfCurrentPage); } else { await reRenderPdf(); } } function _currentScrollPage(contentEl) { const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper'); const viewTop = contentEl.scrollTop; for (const w of wrappers) { if (w.offsetTop + w.offsetHeight > viewTop) { const n = parseInt(w.id.replace('pdf-page-', ''), 10); if (n) return n; } } return pdfCurrentPage || 1; } async function reRenderPdf() { if (!currentPdfBuffer) return; const contentEl = $('reader-content'); if (!contentEl) return; const pivot = readerSettings.pdfPaginated ? pdfCurrentPage : _currentScrollPage(contentEl); await renderPdf(currentPdfBuffer, contentEl, undefined, pivot); if (readerSettings.pdfPaginated) enterPdfPaginatedMode(); } // --------------------------------------------------------------------------- // PDF Paginated Mode // --------------------------------------------------------------------------- function enterPdfPaginatedMode() { const contentEl = $('reader-content'); if (!contentEl) return; contentEl.classList.add('pdf-paginated'); contentEl.style.overflow = 'hidden'; const pdfVp = document.getElementById('pdf-viewport'); if (pdfVp) pdfVp.style.zoom = 1; const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper'); wrappers.forEach((w, i) => { w.style.display = (i + 1 === pdfCurrentPage) ? '' : 'none'; }); pdfSmartZoomPage(pdfCurrentPage); // Tap left/right to navigate contentEl.addEventListener('click', _pdfPaginatedClick); } function exitPdfPaginatedMode() { const contentEl = $('reader-content'); if (!contentEl) return; contentEl.classList.remove('pdf-paginated'); contentEl.style.overflow = ''; contentEl.removeEventListener('click', _pdfPaginatedClick); reRenderPdf(); } function _pdfPaginatedClick(e) { const w = e.currentTarget.clientWidth; if (e.clientX < w * 0.4) pdfGoToPage(pdfCurrentPage - 1); else if (e.clientX > w * 0.6) pdfGoToPage(pdfCurrentPage + 1); } function _pdfTouchStart(e) { if (e.touches.length === 2) { const dx = e.touches[0].clientX - e.touches[1].clientX; const dy = e.touches[0].clientY - e.touches[1].clientY; _pinchStartDist = Math.hypot(dx, dy); _pinchStartZoom = readerSettings.pdfZoom; _isPinching = true; const ce = $('reader-content'); if (ce) ce.classList.add('pinch-active'); } else { _touchStartX = e.touches[0].clientX; _isPinching = false; } } function _pdfTouchMove(e) { if (e.touches.length !== 2 || !_isPinching) return; e.preventDefault(); const dx = e.touches[0].clientX - e.touches[1].clientX; const dy = e.touches[0].clientY - e.touches[1].clientY; const dist = Math.hypot(dx, dy); if (_pinchStartDist === 0) return; const liveZoom = Math.max(50, Math.min(200, _pinchStartZoom * (dist / _pinchStartDist))); const vp = document.getElementById('pdf-viewport'); if (vp) vp.style.zoom = liveZoom / 100; } function _pdfTouchEnd(e) { if (_isPinching) { _isPinching = false; const ce = $('reader-content'); if (ce) ce.classList.remove('pinch-active'); const vp = document.getElementById('pdf-viewport'); const liveZoom = vp ? parseFloat(vp.style.zoom) * 100 : readerSettings.pdfZoom; const snapped = Math.max(50, Math.min(200, Math.round(liveZoom / 10) * 10)); applyPdfZoom(snapped); return; } if (!readerSettings.pdfPaginated) return; const delta = e.changedTouches[0].clientX - _touchStartX; if (delta > 50) pdfGoToPage(pdfCurrentPage - 1); else if (delta < -50) pdfGoToPage(pdfCurrentPage + 1); } function pdfGoToPage(n) { if (!currentPdfDoc) return; n = Math.max(1, Math.min(pdfTotalPages, n)); if (n === pdfCurrentPage) return; const contentEl = $('reader-content'); if (!contentEl) return; const oldWrapper = contentEl.querySelector(`#pdf-page-${pdfCurrentPage}`); if (oldWrapper) oldWrapper.style.display = 'none'; pdfCurrentPage = n; const newWrapper = contentEl.querySelector(`#pdf-page-${pdfCurrentPage}`); if (newWrapper) newWrapper.style.display = ''; pdfSmartZoomPage(pdfCurrentPage); const progressInput = $('reader-progress-input'); if (progressInput) progressInput.value = pdfCurrentPage; } async function pdfSmartZoomPage(pageNum) { if (!currentPdfDoc) return; const contentEl = $('reader-content'); if (!contentEl) return; const wrapper = contentEl.querySelector(`#pdf-page-${pageNum}`); if (!wrapper) return; const canvas = wrapper.querySelector('canvas'); if (!canvas) return; const page = await currentPdfDoc.getPage(pageNum); const naturalVp = page.getViewport({scale: 1}); const pageW = naturalVp.width; const pageH = naturalVp.height; let bbox = _pdfPageTextBoxCache[pageNum]; if (!bbox) { bbox = await _computePdfTextBox(page, pageW, pageH); _pdfPageTextBoxCache[pageNum] = bbox; } const containerW = contentEl.clientWidth; const containerH = contentEl.clientHeight; const contentW = bbox.x2 - bbox.x1; const contentH = bbox.y2 - bbox.y1; const pad = 12; const scale = Math.min( (containerW - pad * 2) / contentW, (containerH - pad * 2) / contentH ) * (readerSettings.pdfZoom / 100); // Re-render canvas at new scale if significantly different const currentScale = canvas.width / naturalVp.width; if (Math.abs(scale - currentScale) / currentScale > 0.05) { const vp = page.getViewport({scale}); canvas.width = vp.width; canvas.height = vp.height; await page.render({canvasContext: canvas.getContext('2d'), viewport: vp}).promise; } // Position canvas to center the text bounding box const renderedScale = canvas.width / naturalVp.width; const offsetX = -renderedScale * (bbox.x1 - pad) + (containerW - renderedScale * contentW - pad * 2) / 2; // PDF y-axis is bottom-up; canvas is top-down const offsetY = -renderedScale * (pageH - bbox.y2 - pad) + (containerH - renderedScale * contentH - pad * 2) / 2; canvas.style.transform = `translate(${offsetX}px, ${offsetY}px)`; wrapper.style.overflow = 'hidden'; wrapper.style.width = containerW + 'px'; wrapper.style.height = containerH + 'px'; } async function _computePdfTextBox(page, pageW, pageH) { // Tier 1: text-based try { const tc = await page.getTextContent(); if (tc.items && tc.items.length) { let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity; for (const item of tc.items) { if (!item.transform) continue; const tx = item.transform[4], ty = item.transform[5]; const iw = item.width || 0, ih = item.height || 0; if (tx < x1) x1 = tx; if (ty < y1) y1 = ty; if (tx + iw > x2) x2 = tx + iw; if (ty + ih > y2) y2 = ty + ih; } const area = (x2 - x1) * (y2 - y1); if (isFinite(x1) && area > pageW * pageH * 0.25) { return {x1, y1, x2, y2}; } } } catch (e) {} // Tier 2: pixel analysis at scale 0.3 try { const lowScale = 0.3; const vp = page.getViewport({scale: lowScale}); const offCanvas = document.createElement('canvas'); offCanvas.width = vp.width; offCanvas.height = vp.height; const ctx = offCanvas.getContext('2d'); await page.render({canvasContext: ctx, viewport: vp}).promise; const {data, width, height} = ctx.getImageData(0, 0, vp.width, vp.height); let rMin = height, rMax = 0, cMin = width, cMax = 0; for (let r = 0; r < height; r++) { for (let c = 0; c < width; c++) { const idx = (r * width + c) * 4; if (data[idx] + data[idx+1] + data[idx+2] < 720) { if (r < rMin) rMin = r; if (r > rMax) rMax = r; if (c < cMin) cMin = c; if (c > cMax) cMax = c; } } } if (rMin < rMax && cMin < cMax) { return { x1: cMin / lowScale, y1: (height - rMax) / lowScale, x2: cMax / lowScale, y2: (height - rMin) / lowScale, }; } } catch (e) {} // Fallback: full page return {x1: 0, y1: 0, x2: pageW, y2: pageH}; } // --------------------------------------------------------------------------- // Bookmarks // --------------------------------------------------------------------------- async function loadBookmarks(bookId) { try { const res = await fetch(`/books/${bookId}/bookmarks/`); const {ct, iv} = await res.json(); if (ct) { _saveAnnotationsCache(bookId, 'bookmarks', ct, iv); // persist for offline const key = await getOrCreateEncKey(); const plain = await decryptBytes(key, iv, ct); currentBookmarks = JSON.parse(new TextDecoder().decode(plain)); } else { currentBookmarks = []; } } catch (e) { // offline fallback const cached = await _loadAnnotationsCache(bookId, 'bookmarks'); if (cached?.ct) { try { const key = await getOrCreateEncKey(); const plain = await decryptBytes(key, cached.iv, cached.ct); currentBookmarks = JSON.parse(new TextDecoder().decode(plain)); } catch { currentBookmarks = []; } } else { currentBookmarks = []; } } } async function saveBookmarks() { if (!currentBookId) return; try { const key = await getOrCreateEncKey(); const plain = new TextEncoder().encode(JSON.stringify(currentBookmarks)); const {iv, ciphertext} = await encryptBytes(key, plain); const body = JSON.stringify({ct: ciphertext, iv}); const url = `/books/${currentBookId}/bookmarks/`; _lastBookmarkBeacon = {url, body}; _saveAnnotationsCache(currentBookId, 'bookmarks', ciphertext, iv); // keep local cache current await fetch(url, { method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, body, }); bookmarksDirty = false; } catch (e) { _markPendingSync(currentBookId, 'bookmarks'); } } function addBookmark() { const contentEl = $('reader-content'); if (!contentEl || !currentBookId) return; let label, anchor, scrollFraction; if (currentPdfDoc) { const page = pdfCurrentPage || parseInt($('reader-progress-input')?.value, 10) || 1; label = `Seite ${page}`; anchor = `pdf-page-${page}`; scrollFraction = (page - 1) / Math.max(1, pdfTotalPages - 1); } else { // Find first visible chapter div const chapters = contentEl.querySelectorAll('[data-epub-src]'); let visibleChapter = null; for (const ch of chapters) { const rect = ch.getBoundingClientRect(); if (rect.bottom > 0 && rect.top < window.innerHeight) { visibleChapter = ch; break; } } const src = visibleChapter?.getAttribute('data-epub-src') || ''; label = src.split('/').pop().replace(/\.x?html?$/i, '') || 'Bookmark'; anchor = src; scrollFraction = contentEl.scrollTop / (contentEl.scrollHeight - contentEl.clientHeight || 1); } const bm = { id: crypto.randomUUID(), label, anchor, scrollFraction, createdAt: new Date().toISOString(), }; currentBookmarks.unshift(bm); bookmarksDirty = true; saveBookmarks(); // Toast const toast = document.createElement('div'); toast.className = 'reader-toast'; toast.textContent = `★ Lesezeichen: ${label}`; document.body.appendChild(toast); setTimeout(() => toast.remove(), 2200); } function openBookmarksSidebar() { if (!currentBookmarks.length) { openSidebar('Lesezeichen', '

Noch keine Lesezeichen. Tippe beim Lesen auf ★.

'); return; } let html = '
    '; for (const bm of currentBookmarks) { html += `
  • `; } html += '
'; openSidebar('Lesezeichen', html); const body = $('sidebar-body'); body.addEventListener('click', function _bmClick(e) { const jumpBtn = e.target.closest('[data-jump-bookmark]'); const delBtn = e.target.closest('[data-delete-bookmark]'); if (jumpBtn) { body.removeEventListener('click', _bmClick); jumpToBookmark(jumpBtn.dataset.jumpBookmark); } if (delBtn) { const id = delBtn.dataset.deleteBookmark; currentBookmarks = currentBookmarks.filter(b => b.id !== id); bookmarksDirty = true; saveBookmarks(); openBookmarksSidebar(); // re-render } }); } function jumpToBookmark(id) { const bm = currentBookmarks.find(b => b.id === id); if (!bm) return; closeSidebar(); _suppressScrollJumpDetect(); setTimeout(() => { const contentEl = $('reader-content'); if (!contentEl) return; if (bm.anchor.startsWith('pdf-page-')) { if (readerSettings.pdfPaginated) { pdfGoToPage(parseInt(bm.anchor.replace('pdf-page-', ''), 10) || 1); } else { const target = contentEl.querySelector('#' + bm.anchor); if (target) { const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top; contentEl.scrollBy({top: top - 16, behavior: 'smooth'}); } } } else { const target = Array.from(contentEl.querySelectorAll('[data-epub-src]')) .find(el => el.getAttribute('data-epub-src') === bm.anchor); if (target) { const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top; contentEl.scrollBy({top: top - 16, behavior: 'smooth'}); } else { contentEl.scrollTop = bm.scrollFraction * (contentEl.scrollHeight - contentEl.clientHeight); } } }, 50); } // --------------------------------------------------------------------------- // Reader Search // --------------------------------------------------------------------------- let _readerSearchDebounce = null; function toggleReaderSearch() { const overlay = $('reader-overlay'); const contentEl = $('reader-content'); if (!overlay || !contentEl) return; const existing = document.getElementById('reader-search-bar'); if (existing) { existing.remove(); readerSearchOpen = false; clearReaderSearch(); return; } readerSearchOpen = true; const bar = document.createElement('div'); bar.id = 'reader-search-bar'; bar.className = 'reader-search-bar'; bar.innerHTML = ` `; overlay.insertBefore(bar, $('reader-body-row')); const input = bar.querySelector('#reader-search-input'); input.focus(); input.addEventListener('input', () => { clearTimeout(_readerSearchDebounce); _readerSearchDebounce = setTimeout(() => doReaderSearch(input.value.trim()), 300); }); input.addEventListener('keydown', e => { if (e.key === 'Enter') { e.shiftKey ? readerSearchPrev() : readerSearchNext(); } if (e.key === 'Escape') { toggleReaderSearch(); } }); bar.querySelector('#rs-search-prev').addEventListener('click', readerSearchPrev); bar.querySelector('#rs-search-next').addEventListener('click', readerSearchNext); bar.querySelector('#rs-search-clear').addEventListener('click', toggleReaderSearch); } async function doReaderSearch(query) { const contentEl = $('reader-content'); if (!contentEl) return; const countEl = document.getElementById('rs-search-count'); clearReaderSearchHighlights(); searchMatches = []; searchMatchIndex = -1; if (!query) { if (countEl) countEl.textContent = ''; return; } if (!currentPdfDoc) { // EPUB: snapshot original content if (!searchOriginalContent) { searchOriginalContent = contentEl.innerHTML; } else { contentEl.innerHTML = searchOriginalContent; applyHighlightsToContent(); } const walker = document.createTreeWalker(contentEl, NodeFilter.SHOW_TEXT); const lq = query.toLowerCase(); const ranges = []; let node; while ((node = walker.nextNode())) { const text = node.textContent; const lt = text.toLowerCase(); let idx = 0; while ((idx = lt.indexOf(lq, idx)) !== -1) { const range = document.createRange(); range.setStart(node, idx); range.setEnd(node, idx + query.length); ranges.push(range); idx += query.length; } } // Insert marks in reverse to preserve range validity for (let i = ranges.length - 1; i >= 0; i--) { try { const mark = document.createElement('mark'); mark.className = 'reader-search-match'; ranges[i].surroundContents(mark); searchMatches.unshift(mark); } catch (e) {} } } else { // PDF: collect text layer spans const spans = contentEl.querySelectorAll('.pdf-text-layer > span'); const lq = query.toLowerCase(); for (const span of spans) { if (span.textContent.toLowerCase().includes(lq)) { span.classList.add('reader-search-match'); searchMatches.push(span); } } } if (countEl) countEl.textContent = searchMatches.length ? `1 / ${searchMatches.length}` : '0'; if (searchMatches.length) { searchMatchIndex = 0; scrollToSearchMatch(0); } } function clearReaderSearchHighlights() { if (!currentPdfDoc) { // EPUB: restore from snapshot if (searchOriginalContent !== null) { const contentEl = $('reader-content'); if (contentEl) { contentEl.innerHTML = searchOriginalContent; applyHighlightsToContent(); } searchOriginalContent = null; } else { // Just remove marks without full restore document.querySelectorAll('mark.reader-search-match').forEach(m => { const parent = m.parentNode; while (m.firstChild) parent.insertBefore(m.firstChild, m); parent.removeChild(m); }); } } else { // PDF: remove highlight class from spans document.querySelectorAll('.reader-search-match').forEach(el => { el.classList.remove('reader-search-match', 'active'); }); } searchMatches = []; searchMatchIndex = -1; } function clearReaderSearch() { clearTimeout(_readerSearchDebounce); clearReaderSearchHighlights(); readerSearchOpen = false; const countEl = document.getElementById('rs-search-count'); if (countEl) countEl.textContent = ''; } function scrollToSearchMatch(idx) { if (!searchMatches.length) return; _suppressScrollJumpDetect(); searchMatches.forEach((m, i) => m.classList.toggle('active', i === idx)); searchMatches[idx].scrollIntoView({behavior: 'smooth', block: 'center'}); const countEl = document.getElementById('rs-search-count'); if (countEl) countEl.textContent = `${idx + 1} / ${searchMatches.length}`; } function readerSearchNext() { if (!searchMatches.length) return; searchMatchIndex = (searchMatchIndex + 1) % searchMatches.length; scrollToSearchMatch(searchMatchIndex); } function readerSearchPrev() { if (!searchMatches.length) return; searchMatchIndex = (searchMatchIndex - 1 + searchMatches.length) % searchMatches.length; scrollToSearchMatch(searchMatchIndex); } // --------------------------------------------------------------------------- // Highlights // --------------------------------------------------------------------------- async function loadHighlights(bookId) { try { const res = await fetch(`/books/${bookId}/highlights/`); const {ct, iv} = await res.json(); if (ct) { _saveAnnotationsCache(bookId, 'highlights', ct, iv); // persist for offline const key = await getOrCreateEncKey(); const plain = await decryptBytes(key, iv, ct); currentHighlights = JSON.parse(new TextDecoder().decode(plain)); } else { currentHighlights = []; } applyHighlightsToContent(); } catch (e) { // offline fallback const cached = await _loadAnnotationsCache(bookId, 'highlights'); if (cached?.ct) { try { const key = await getOrCreateEncKey(); const plain = await decryptBytes(key, cached.iv, cached.ct); currentHighlights = JSON.parse(new TextDecoder().decode(plain)); } catch { currentHighlights = []; } } else { currentHighlights = []; } applyHighlightsToContent(); } } async function saveHighlights() { if (!currentBookId) return; try { const key = await getOrCreateEncKey(); const plain = new TextEncoder().encode(JSON.stringify(currentHighlights)); const {iv, ciphertext} = await encryptBytes(key, plain); const body = JSON.stringify({ct: ciphertext, iv}); const url = `/books/${currentBookId}/highlights/`; _lastHighlightBeacon = {url, body}; _saveAnnotationsCache(currentBookId, 'highlights', ciphertext, iv); // keep local cache current await fetch(url, { method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, body, }); highlightsDirty = false; } catch (e) { _markPendingSync(currentBookId, 'highlights'); } } let _highlightSaveDebounce = null; function debounceSaveHighlights() { clearTimeout(_highlightSaveDebounce); _highlightSaveDebounce = setTimeout(saveHighlights, 2000); } function applyHighlightsToContent() { const contentEl = $('reader-content'); if (!contentEl || currentPdfDoc) return; for (const h of currentHighlights) { try { renderHighlight(h); } catch (e) {} } _repositionMarginMarkers(); } // Resolves a highlight/note's stored anchor back to a live DOM Range. Shared by // rendering, margin-marker positioning and export ordering — all three need the // same "where in the current DOM is this annotation" answer. function resolveAnchorRange(h) { const contentEl = $('reader-content'); if (!contentEl || !h.anchor) return null; let range = null; try { const blocks = Array.from(contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR)); const startBlock = blocks[h.anchor.startBlockIndex]; const endBlock = blocks[h.anchor.endBlockIndex] ?? startBlock; if (startBlock && endBlock) { const start = _nodeAtCharOffset(startBlock, h.anchor.startChar); const end = _nodeAtCharOffset(endBlock, h.anchor.endChar); if (start && end) { range = document.createRange(); range.setStart(start.node, start.offset); range.setEnd(end.node, end.offset); } } } catch (e) {} // Fallback: quote substring search within the chapter if (!range && h.anchor.quote) { const chapterEl = contentEl.querySelector(`[data-epub-src="${CSS.escape(h.anchor.chapterSrc || '')}"]`) || contentEl; const walker = document.createTreeWalker(chapterEl, NodeFilter.SHOW_TEXT); let node; while ((node = walker.nextNode())) { const idx = node.textContent.indexOf(h.anchor.quote); if (idx !== -1) { range = document.createRange(); range.setStart(node, idx); range.setEnd(node, idx + h.anchor.quote.length); break; } } } return range; } function renderHighlight(h) { // Freeform notes carry no in-text mark — their anchor exists purely to // position the note card in the margin (resolveAnchorRange handles that // directly from the stored xpath, no DOM wrapper needed). if (h.type === 'note') return; const range = resolveAnchorRange(h); if (!range) return; try { const mark = document.createElement('mark'); mark.dataset.highlightId = h.id; mark.className = 'epub-highlight'; mark.dataset.color = h.color || 'yellow'; range.surroundContents(mark); } catch (e) {} } // Character-offset addressing within a block element (p, li, ...), rather // than XPath-to-a-specific-text-node: XPath sibling indices shift whenever // ANY highlight in the same block gets wrapped/unwrapped in a (that // splits/merges text nodes), silently corrupting OTHER highlights' anchors // in the same block. A block's overall textContent length/order is never // touched by mark-wrapping, so a character offset into it stays valid no // matter how many other highlights come and go around it. function _charOffsetInBlock(block, node, nodeOffset) { const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT); let count = 0, n; while ((n = walker.nextNode())) { if (n === node) return count + nodeOffset; count += n.textContent.length; } return count; } function _nodeAtCharOffset(block, targetOffset) { const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT); let count = 0, lastNode = null, n; while ((n = walker.nextNode())) { lastNode = n; const len = n.textContent.length; if (targetOffset <= count + len) { return {node: n, offset: Math.max(0, targetOffset - count)}; } count += len; } return lastNode ? {node: lastNode, offset: lastNode.textContent.length} : null; } function _blockAncestor(node) { const el = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement; return el ? el.closest(EPUB_BLOCK_SELECTOR) : null; } function buildEpubAnchor(range) { const contentEl = $('reader-content'); const chapterEl = range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE ? range.commonAncestorContainer.closest('[data-epub-src]') : range.commonAncestorContainer.parentElement?.closest('[data-epub-src]'); const startBlock = _blockAncestor(range.startContainer); const endBlock = _blockAncestor(range.endContainer) || startBlock; const blocks = Array.from(contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR)); return { type: 'epub', chapterSrc: chapterEl?.getAttribute('data-epub-src') || '', startBlockIndex: startBlock ? blocks.indexOf(startBlock) : -1, startChar: startBlock ? _charOffsetInBlock(startBlock, range.startContainer, range.startOffset) : 0, endBlockIndex: endBlock ? blocks.indexOf(endBlock) : -1, endChar: endBlock ? _charOffsetInBlock(endBlock, range.endContainer, range.endOffset) : 0, quote: range.toString().slice(0, 200), }; } // Marker mode — armed via the pen button in the reader header. Text selection // only opens the highlight popover while this is active, so normal selection // (e.g. to copy text) isn't hijacked, and the feature has a discoverable // on/off switch instead of being an invisible "just select text" gesture. let markerModeActive = false; function toggleMarkerMode() { markerModeActive = !markerModeActive; document.querySelectorAll('.marker-mode-btn').forEach(b => b.classList.toggle('active', markerModeActive)); } // Note editing/creation happens in the margin column on desktop, but a note // bottom sheet (see showNoteBottomSheet) on narrow viewports — typing into a // 150px margin strip on a phone keyboard is unpleasant, and opening that // column at all is exactly the friction we're trying to remove for mobile // note-taking. Reviewing existing notes in depth is expected to stay a // desktop activity; this only affects where the editor for a note appears. function _isMobileReader() { return window.innerWidth <= 600; } function handleReaderSelection(e) { // Clicking an existing highlight always shows its tooltip, regardless of marker mode const hlMark = e.target.closest('.epub-highlight'); if (hlMark) { dismissHighlightPopover(); const id = hlMark.dataset.highlightId; const h = currentHighlights.find(x => x.id === id); showHighlightTooltip(hlMark, h); return; } if (!markerModeActive) return; // Don't hijack taps on real interactive content (footnote refs, external // links) — but plenty of EPUBs wrap plain reading text in a bare, hrefless // used only as an internal jump target (sanitizeEpubHtml // strips its href but the tag itself stays); those aren't actually // clickable and must not block note-taking, so check for a surviving // href/footnote-ref rather than just "is inside some ". if (e.target.closest('button, [data-footnote-ref], a[href]')) return; dismissHighlightPopover(); const sel = window.getSelection(); if (sel && !sel.isCollapsed && sel.rangeCount) { const range = sel.getRangeAt(0); const contentEl = $('reader-content'); if (!contentEl || !contentEl.contains(range.commonAncestorContainer)) return; if (range.toString().trim().length === 0) return; showHighlightPopover(range); return; } // A plain tap with nothing selected — in marker mode this means "drop a // freeform note here" instead of doing nothing, so a note-only annotation // never depends on finding the margin's precise click target (especially // hard on a narrow phone screen — see createFreeformNote/_openNoteEditor). if (typeof e.clientX === 'number' && typeof e.clientY === 'number') { const range = _wordRangeAtPoint(e.clientX, e.clientY); if (range) createFreeformNote(range); } } function showHighlightPopover(range) { const rect = range.getBoundingClientRect(); const popover = document.createElement('div'); popover.id = 'highlight-popover'; popover.className = 'highlight-popover'; popover.innerHTML = ` `; popover.style.visibility = 'hidden'; // measure before showing, to avoid a flash at the wrong spot document.body.appendChild(popover); currentHighlightPopover = popover; // Clamp within the viewport — a selection near the top/edge of the page must // not produce an invisible, off-screen popover (flip below when there's no // room above; keep it horizontally on-screen). const pRect = popover.getBoundingClientRect(); const minTop = window.scrollY + 8; const maxTop = window.scrollY + window.innerHeight - pRect.height - 8; let top = rect.top + window.scrollY - 44; if (top < minTop) top = rect.bottom + window.scrollY + 8; // no room above -> try below top = Math.max(minTop, Math.min(top, maxTop)); let left = rect.left + window.scrollX + rect.width / 2 - pRect.width / 2; left = Math.max(window.scrollX + 8, Math.min(left, window.scrollX + window.innerWidth - pRect.width - 8)); popover.style.top = top + 'px'; popover.style.left = left + 'px'; popover.style.visibility = ''; // Store range info before selection is cleared const savedRange = range.cloneRange(); popover.addEventListener('click', e => { const colorBtn = e.target.closest('.hl-color-btn'); const noteBtn = e.target.closest('.hl-note-btn'); if (colorBtn) { createHighlight(colorBtn.dataset.hlColor, savedRange); } else if (noteBtn) { createHighlightWithNote(savedRange); } }); } function showHighlightTooltip(markEl, h) { const rect = markEl.getBoundingClientRect(); const popover = document.createElement('div'); popover.id = 'highlight-popover'; popover.className = 'highlight-popover'; popover.style.flexDirection = 'column'; popover.style.maxWidth = '220px'; const noteText = h?.note ? escapeHtml(h.note) : 'Keine Notiz'; popover.innerHTML = `
${noteText}
`; popover.style.visibility = 'hidden'; document.body.appendChild(popover); currentHighlightPopover = popover; const pRect = popover.getBoundingClientRect(); const minTop = window.scrollY + 8; const maxTop = window.scrollY + window.innerHeight - pRect.height - 8; let top = rect.bottom + window.scrollY + 4; if (top > maxTop) top = rect.top + window.scrollY - pRect.height - 4; // flip above if no room below top = Math.max(minTop, Math.min(top, maxTop)); let left = rect.left + window.scrollX; left = Math.max(window.scrollX + 8, Math.min(left, window.scrollX + window.innerWidth - pRect.width - 8)); popover.style.top = top + 'px'; popover.style.left = left + 'px'; popover.style.visibility = ''; popover.addEventListener('click', ev => { const editBtn = ev.target.closest('[data-hl-edit-note]'); const delBtn = ev.target.closest('[data-hl-delete]'); if (editBtn && h) { dismissHighlightPopover(); _openNoteEditor(h); } if (delBtn && h) { dismissHighlightPopover(); deleteHighlight(h.id); } }); // Close on outside click setTimeout(() => { document.addEventListener('click', dismissHighlightPopover, {once: true}); }, 0); } function createHighlight(color, range) { const anchor = buildEpubAnchor(range); const h = { id: crypto.randomUUID(), type: 'highlight', anchor, color, note: '', createdAt: new Date().toISOString(), }; currentHighlights.push(h); highlightsDirty = true; window.getSelection()?.removeAllRanges(); dismissHighlightPopover(); renderHighlight(h); _repositionMarginMarkers(); debounceSaveHighlights(); } function createHighlightWithNote(range) { const anchor = buildEpubAnchor(range); const h = { id: crypto.randomUUID(), type: 'highlight', anchor, color: 'yellow', note: '', createdAt: new Date().toISOString(), }; currentHighlights.push(h); highlightsDirty = true; window.getSelection()?.removeAllRanges(); dismissHighlightPopover(); renderHighlight(h); _repositionMarginMarkers(); _openNoteEditor(h); } // Every "add/edit a note" entry point routes here — notes are always edited // right where they conceptually live (the margin), never in the separate // right-hand sidebar, EXCEPT on narrow viewports, where the margin itself is // too cramped to type into and a full-width bottom sheet takes over instead. // `pending` is only set for a not-yet-created freeform note (see // createFreeformNote); h is null in that case until the user actually saves // text, so an empty tap doesn't leave a permanent ghost entry behind. function _openNoteEditor(h, pending) { if (_isMobileReader()) { showNoteBottomSheet(h, pending); } else { if (!readerAnnotationsMarginOpen) toggleAnnotationsMargin(); editNoteInlineInMargin(h, pending); } } function deleteHighlight(id) { currentHighlights = currentHighlights.filter(h => h.id !== id); highlightsDirty = true; // Re-apply all highlights after removing the deleted one const contentEl = $('reader-content'); if (contentEl && !currentPdfDoc) { // Snapshot restore not available mid-session, so remove the mark manually const mark = contentEl.querySelector(`mark[data-highlight-id="${id}"]`); if (mark) { const parent = mark.parentNode; while (mark.firstChild) parent.insertBefore(mark.firstChild, mark); parent.removeChild(mark); } } _repositionMarginMarkers(); debounceSaveHighlights(); } function dismissHighlightPopover() { if (currentHighlightPopover) { currentHighlightPopover.remove(); currentHighlightPopover = null; } } // --------------------------------------------------------------------------- // Margin panel — a "page margin" strip left of the reader content. Shows small // markers next to the on-screen highlights/notes, and lets you click empty // space to anchor a freeform note at that line's height (EPUB only). // --------------------------------------------------------------------------- let readerAnnotationsMarginOpen = false; function toggleAnnotationsMargin() { const el = $('reader-margin'); const markersEl = $('reader-margin-markers'); if (!el || !markersEl || currentPdfDoc) return; readerAnnotationsMarginOpen = !readerAnnotationsMarginOpen; el.classList.toggle('open', readerAnnotationsMarginOpen); if (readerAnnotationsMarginOpen) { // Reposition once now (pre-transition) and once more after the width // transition settles, since content reflow can shift line heights. _repositionMarginMarkers(); el.addEventListener('transitionend', _repositionMarginMarkers, {once: true}); } else { markersEl.innerHTML = ''; } } // Renders each highlight/note at its text position in the margin. Entries // with note text become a readable "post-it" card (the whole point of the // margin — you can read notes there, not just see that one exists); a plain // highlight with no note is just a small color dot. Cards are stacked // top-to-bottom so overlapping anchors don't overlap visually. function _repositionMarginMarkers() { const markersEl = $('reader-margin-markers'); if (!markersEl) return; if (markersEl.querySelector('.margin-note-editor')) return; // don't blow away an active inline edit markersEl.innerHTML = ''; if (!readerAnnotationsMarginOpen || currentPdfDoc) return; const areaRect = markersEl.getBoundingClientRect(); // A generous pre-filter band: a note card can be considerably taller than // its own anchor point, so a card whose anchor is somewhat outside the // strictly-visible area can still be partially (or fully) on-screen once // rendered. A tight band here excluded it before it ever got a chance to // be measured — the exact bug reported ("tall notes stay hidden until // scrolled much further than expected"). const band = Math.max(areaRect.height, 400); const entries = []; for (const h of currentHighlights) { let range; try { range = resolveAnchorRange(h); } catch (e) { continue; } if (!range) continue; const rect = range.getBoundingClientRect(); if (!rect.width && !rect.height) continue; // detached/invalid const y = rect.top + rect.height / 2 - areaRect.top; if (y < -band || y > areaRect.height + band) continue; entries.push({h, y}); } entries.sort((a, b) => a.y - b.y); let nextTop = -Infinity; const GAP = 4; const rendered = []; for (const {h, y} of entries) { const hasText = !!h.note; const el = document.createElement('div'); el.className = 'margin-note ' + (hasText ? 'margin-note-text' : 'margin-note-dot'); el.dataset.highlightId = h.id; if (h.type === 'note') { if (hasText) el.classList.add('margin-note-freeform'); } else { el.dataset.color = h.color || 'yellow'; } if (hasText) el.textContent = h.note; const desiredTop = hasText ? y - 8 : y - 6; const top = Math.max(desiredTop, nextTop); el.style.top = top + 'px'; markersEl.appendChild(el); const measured = el.getBoundingClientRect().height || (hasText ? 20 : 12); nextTop = top + measured + GAP; rendered.push({el, top, measured}); } // Now that actual card heights are known, do the real cull: drop anything // that ended up entirely outside the visible band after stacking (e.g. // pushed down by a very tall card above it). for (const {el, top, measured} of rendered) { if (top + measured < -40 || top > areaRect.height + 40) el.remove(); } } function handleMarginClick(e) { const markerBtn = e.target.closest('.margin-note'); if (markerBtn) { const h = currentHighlights.find(x => x.id === markerBtn.dataset.highlightId); if (h) _openNoteEditor(h); return; } // Empty space in the markers area → place a new freeform note anchored to // the first word of whatever line sits at this click height. if (!readerAnnotationsMarginOpen || currentPdfDoc) return; const markersEl = $('reader-margin-markers'); if (!markersEl || !markersEl.contains(e.target)) return; // If an editor is already open, this click just blurs/commits it (see its // own blur handler) — it shouldn't also create a second, unrelated note. if (markersEl.querySelector('.margin-note-editor')) return; const contentEl = $('reader-content'); if (!contentEl) return; const x = contentEl.getBoundingClientRect().left + 4; const range = _wordRangeAtPoint(x, e.clientY); if (range) createFreeformNote(range); } // Finds the word starting at (or just after) the given viewport point, by // probing near the left edge of the reader content at that height — since // text is left-aligned, that lands at (or very near) the start of the line. function _wordRangeAtPoint(x, y) { let caret = null; if (document.caretRangeFromPoint) { caret = document.caretRangeFromPoint(x, y); } else if (document.caretPositionFromPoint) { const pos = document.caretPositionFromPoint(x, y); if (pos) { caret = document.createRange(); caret.setStart(pos.offsetNode, pos.offset); } } if (!caret) return null; const contentEl = $('reader-content'); if (!contentEl || !contentEl.contains(caret.startContainer)) return null; let node = caret.startContainer; let offset = caret.startOffset; if (node.nodeType !== Node.TEXT_NODE) { const walker = document.createTreeWalker(contentEl, NodeFilter.SHOW_TEXT); walker.currentNode = node; node = walker.nextNode(); if (!node) return null; offset = 0; } const text = node.textContent; let start = offset; while (start < text.length && /\s/.test(text[start])) start++; if (start >= text.length) return null; let wordStart = start; while (wordStart > 0 && !/\s/.test(text[wordStart - 1])) wordStart--; let wordEnd = start; while (wordEnd < text.length && !/\s/.test(text[wordEnd])) wordEnd++; if (wordEnd <= wordStart) return null; const range = document.createRange(); range.setStart(node, wordStart); range.setEnd(node, wordEnd); return range; } function createFreeformNote(range) { const anchor = buildEpubAnchor(range); // Where the note's card/dot would sit in the margin, computed from the live // range right now (before it goes stale) — used to position the editor, // whether or not the margin is actually open. let top = 0; const markersEl = $('reader-margin-markers'); if (markersEl) { const areaRect = markersEl.getBoundingClientRect(); const rangeRect = range.getBoundingClientRect(); top = Math.max(0, rangeRect.top + rangeRect.height / 2 - areaRect.top - 8); } // The highlight object itself isn't created yet — see _openNoteEditor's // `pending` param — so a tap that's immediately cancelled or left empty // never leaves a ghost entry in currentHighlights. _openNoteEditor(null, {anchor, top}); } // Commits an edit for both note-editor UIs (inline margin + mobile bottom // sheet): updates an existing note in place, or — only if there's actual // text — creates and persists a not-yet-existing freeform note. An empty // tap/cancel on a pending note must never leave a permanent ghost entry. function _commitNoteEdit(h, pending, text) { if (h) { if (h.note !== text) { h.note = text; highlightsDirty = true; debounceSaveHighlights(); } return h; } if (!text) return null; const newH = { id: crypto.randomUUID(), type: 'note', anchor: pending.anchor, color: null, note: text, createdAt: new Date().toISOString(), }; currentHighlights.push(newH); highlightsDirty = true; debounceSaveHighlights(); return newH; } // Edit a note's text right where it lives in the margin, instead of jumping // to the separate right-hand sidebar — the whole point of setting a note in // the margin is that you shouldn't have to look away from it to write it. // `h` is null for a not-yet-created freeform note (see createFreeformNote); // `pending` then carries its anchor + intended margin position instead. function editNoteInlineInMargin(h, pending) { const markersEl = $('reader-margin-markers'); if (!markersEl) return; markersEl.querySelector('.margin-note-editor')?.remove(); let top = pending?.top || 0; if (h) { const existingCard = markersEl.querySelector(`[data-highlight-id="${h.id}"]`); top = existingCard ? (parseFloat(existingCard.style.top) || 0) : 0; if (existingCard) existingCard.style.display = 'none'; } const editor = document.createElement('div'); editor.className = 'margin-note-editor'; editor.style.top = top + 'px'; editor.innerHTML = ''; markersEl.appendChild(editor); const textarea = editor.querySelector('textarea'); textarea.value = h?.note || ''; function commit() { _commitNoteEdit(h, pending, textarea.value.trim()); editor.remove(); _repositionMarginMarkers(); } textarea.addEventListener('blur', commit); textarea.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); e.stopPropagation(); textarea.blur(); // triggers commit() } else if (e.key === 'Escape') { // Stop this from bubbling to the document-level Escape handler, which // would otherwise close the whole reader (it doesn't know we just want // to cancel this one edit) — see the global keydown listener. e.stopPropagation(); textarea.removeEventListener('blur', commit); // discard, don't save editor.remove(); _repositionMarginMarkers(); } }); textarea.focus(); textarea.select(); } // Mobile alternative to editNoteInlineInMargin — a full-width sheet instead // of squeezing a textarea into the 150px margin column (see _openNoteEditor). let currentNoteBottomSheet = null; function dismissNoteBottomSheet() { currentNoteBottomSheet?.remove(); currentNoteBottomSheet = null; } function showNoteBottomSheet(h, pending) { dismissNoteBottomSheet(); const sheet = document.createElement('div'); sheet.className = 'note-bottom-sheet'; sheet.innerHTML = `
`; document.body.appendChild(sheet); currentNoteBottomSheet = sheet; const textarea = sheet.querySelector('.note-bottom-sheet-textarea'); textarea.value = h?.note || ''; function commit() { _commitNoteEdit(h, pending, textarea.value.trim()); dismissNoteBottomSheet(); if (readerAnnotationsMarginOpen) _repositionMarginMarkers(); } function cancel() { dismissNoteBottomSheet(); } sheet.querySelector('[data-nbs-save]').addEventListener('click', commit); sheet.querySelector('[data-nbs-cancel]').addEventListener('click', cancel); sheet.querySelector('.note-bottom-sheet-backdrop').addEventListener('click', cancel); textarea.addEventListener('keydown', e => { if (e.key === 'Escape') { e.stopPropagation(); cancel(); } }); requestAnimationFrame(() => { sheet.classList.add('open'); textarea.focus(); }); } // Same plain-text export as exportAnnotations(), but callable straight from the // book list (no open reader, so no live DOM/TOC to resolve percent position or // chapter titles against — falls back to the raw chapterSrc path and orders by // the anchor's block index, which is already a global reading-order index). async function downloadBookAnnotationsFromList(bookId, btnEl) { if (btnEl) btnEl.disabled = true; try { const res = await fetch(`/books/${bookId}/highlights/`); const {ct, iv} = await res.json(); if (!ct) { await customAlert('In diesem Buch gibt es noch keine Markierungen oder Notizen.'); return; } const key = await getOrCreateEncKey(); const plain = await decryptBytes(key, iv, ct); const highlights = JSON.parse(new TextDecoder().decode(plain)); if (!highlights.length) { await customAlert('In diesem Buch gibt es noch keine Markierungen oder Notizen.'); return; } highlights.sort((a, b) => { const ab = a.anchor?.startBlockIndex ?? 0, bb = b.anchor?.startBlockIndex ?? 0; if (ab !== bb) return ab - bb; return (a.anchor?.startChar ?? 0) - (b.anchor?.startChar ?? 0); }); const bookTitle = bookMetaCache[bookId]?.title || `Buch #${bookId}`; const lines = [`${bookTitle} — Markierungen & Notizen`, `Exportiert: ${new Date().toISOString().slice(0, 10)}`, '']; let lastChapter = null; for (const h of highlights) { const chapterTitle = h.anchor?.chapterSrc || ''; if (chapterTitle !== lastChapter) { lines.push(`── ${chapterTitle || 'Abschnitt ohne Titel'} ──`); lastChapter = chapterTitle; } if (h.type === 'note') { lines.push(`[Notiz] bei: „${(h.anchor?.quote || '').trim()}“`); if (h.note) lines.push(` ${h.note}`); } else { lines.push(`[Markierung · ${_hlColorName(h.color || 'yellow')}] „${(h.anchor?.quote || '').trim()}“`); if (h.note) lines.push(` Notiz: ${h.note}`); } lines.push(''); } const blob = new Blob([lines.join('\n')], {type: 'text/plain;charset=utf-8'}); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${bookTitle.replace(/[^\w\-]+/g, '_')}-notizen.txt`; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } catch (e) { await customAlert('Export fehlgeschlagen: ' + e.message); } finally { if (btnEl) btnEl.disabled = false; } } // --------------------------------------------------------------------------- // Export annotations — walks all highlights/notes of the current book in // reading order and offers them as a downloadable plain-text file. // --------------------------------------------------------------------------- function _tocTitleForChapterSrc(chapterSrc) { if (!chapterSrc) return ''; const entry = currentBookToc.find(t => (t.href || '').split('#')[0] === chapterSrc); return entry ? entry.label : ''; } // Rough "how far through the book" position for a resolved range — there's no // real page concept for reflowable EPUB text, so this is a coordinate of last // resort: percent of total scrollable content height, good enough to roughly // relocate a highlight/note later. function _percentFromRange(range) { const contentEl = $('reader-content'); if (!contentEl || !range) return null; // Share of the text before the range, like the reading progress. const block = _blockAncestor(range.startContainer); if (block && contentEl.contains(block)) { const blocks = Array.from(contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR)); const i = blocks.indexOf(block); if (i >= 0) { const idx = _bookCharIndex(contentEl); const at = idx.starts[i] + _charOffsetInBlock(block, range.startContainer, range.startOffset); return Math.max(0, Math.min(100, (at / idx.total) * 100)); } } if (!contentEl.scrollHeight) return null; const contentRect = contentEl.getBoundingClientRect(); const rangeRect = range.getBoundingClientRect(); if (!rangeRect.width && !rangeRect.height) return null; const offsetWithinScroll = (rangeRect.top - contentRect.top) + contentEl.scrollTop; return Math.max(0, Math.min(100, (offsetWithinScroll / contentEl.scrollHeight) * 100)); } function _sortedAnnotations() { const resolved = currentHighlights.map(h => { let range; try { range = resolveAnchorRange(h); } catch (e) { range = null; } return {h, range}; }); resolved.sort((a, b) => { if (!a.range || !b.range) return 0; return a.range.compareBoundaryPoints(Range.START_TO_START, b.range); }); return resolved; } // Highlight colours as the export and the list name them. function _hlColorName(color) { return ({yellow: 'gelb', green: 'grün', blue: 'blau', red: 'rot'})[color] || color; } function exportAnnotations() { if (!currentHighlights.length) { customAlert('In diesem Buch gibt es noch keine Markierungen oder Notizen.'); return; } const resolved = _sortedAnnotations(); const titleEl = $('reader-title'); const bookTitle = (titleEl?.textContent || 'Buch').split(' — ')[0]; const lines = [`${bookTitle} — Markierungen & Notizen`, `Exportiert: ${new Date().toISOString().slice(0, 10)}`, '']; let lastChapter = null; for (const {h, range} of resolved) { const chapterTitle = _tocTitleForChapterSrc(h.anchor?.chapterSrc) || h.anchor?.chapterSrc || ''; if (chapterTitle !== lastChapter) { lines.push(`── ${chapterTitle || 'Abschnitt ohne Titel'} ──`); lastChapter = chapterTitle; } const pct = _percentFromRange(range); const coord = pct !== null ? ` (≈${_formatBookPercent(pct / 100)}% im Buch)` : ''; if (h.type === 'note') { lines.push(`[Notiz]${coord} bei: „${(h.anchor?.quote || '').trim()}“`); if (h.note) lines.push(` ${h.note}`); } else { lines.push(`[Markierung · ${_hlColorName(h.color || 'yellow')}]${coord} „${(h.anchor?.quote || '').trim()}“`); if (h.note) lines.push(` Notiz: ${h.note}`); } lines.push(''); } const blob = new Blob([lines.join('\n')], {type: 'text/plain;charset=utf-8'}); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${bookTitle.replace(/[^\w\-]+/g, '_')}-notizen.txt`; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } // List of every highlight/note in the book, in reading order, each jumpable — // the same pattern as the bookmarks sidebar, but for annotations. Unlike the // margin panel (which only shows what's currently on-screen), this is a full // overview, useful once a book has annotations scattered throughout. function openAnnotationsSidebar() { if (!currentHighlights.length) { openSidebar('Markierungen & Notizen', '

Noch keine Markierungen oder Notizen.

'); return; } const resolved = _sortedAnnotations(); let html = '
    '; for (const {h, range} of resolved) { const pct = _percentFromRange(range); const pctLabel = pct !== null ? `≈${_formatBookPercent(pct / 100)}%` : ''; const kind = h.type === 'note' ? 'Notiz' : `Markierung${h.color ? ' · ' + _hlColorName(h.color) : ''}`; const preview = h.note || h.anchor?.quote || '(kein Text)'; html += `
  • `; } html += '
'; openSidebar('Markierungen & Notizen', html); const body = $('sidebar-body'); body.addEventListener('click', function _annClick(e) { const jumpBtn = e.target.closest('[data-jump-annotation]'); const delBtn = e.target.closest('[data-delete-annotation]'); if (jumpBtn) { body.removeEventListener('click', _annClick); jumpToAnnotation(jumpBtn.dataset.jumpAnnotation); } if (delBtn) { deleteHighlight(delBtn.dataset.deleteAnnotation); openAnnotationsSidebar(); // re-render } }); } function jumpToAnnotation(id) { const h = currentHighlights.find(x => x.id === id); if (!h) return; closeSidebar(); _suppressScrollJumpDetect(); setTimeout(() => { const contentEl = $('reader-content'); if (!contentEl) return; let range; try { range = resolveAnchorRange(h); } catch (e) { range = null; } if (range) { const top = range.getBoundingClientRect().top - contentEl.getBoundingClientRect().top; contentEl.scrollBy({top: top - 16, behavior: 'smooth'}); } }, 50); } // --------------------------------------------------------------------------- // Footnote popover // --------------------------------------------------------------------------- let currentFootnotePopover = null; let _footnotePopoverTimer = null; function showFootnotePopover(link) { dismissFootnotePopover(); const targetEl = document.getElementById(link.dataset.footnoteRef); if (!targetEl) return; const container = targetEl.closest('.footnote') || targetEl.parentElement; if (!container) return; const clone = container.cloneNode(true); clone.querySelector('sup')?.remove(); const popover = document.createElement('div'); popover.className = 'footnote-popover'; const _overlay = document.getElementById('reader-overlay'); ['reader-theme-sepia', 'reader-theme-bright'].forEach(cls => { if (_overlay?.classList.contains(cls)) popover.classList.add(cls); }); popover.innerHTML = clone.innerHTML; const closeBtn = document.createElement('button'); closeBtn.className = 'footnote-popover-close'; closeBtn.textContent = '✕'; closeBtn.addEventListener('click', dismissFootnotePopover); popover.appendChild(closeBtn); const rect = link.getBoundingClientRect(); popover.style.top = (rect.bottom + 8) + 'px'; popover.style.left = Math.max(8, rect.left - 8) + 'px'; popover.addEventListener('mouseenter', () => clearTimeout(_footnotePopoverTimer)); popover.addEventListener('mouseleave', () => { _footnotePopoverTimer = setTimeout(dismissFootnotePopover, 250); }); document.body.appendChild(popover); currentFootnotePopover = popover; currentFootnotePopover._refLink = link; const pr = popover.getBoundingClientRect(); if (pr.right > window.innerWidth - 8) popover.style.left = (window.innerWidth - pr.width - 8) + 'px'; if (pr.bottom > window.innerHeight - 8) popover.style.top = (rect.top - pr.height - 8) + 'px'; } function dismissFootnotePopover() { clearTimeout(_footnotePopoverTimer); currentFootnotePopover?.remove(); currentFootnotePopover = null; } // --------------------------------------------------------------------------- // Radio sidebar (compact player) // --------------------------------------------------------------------------- function openRadioSidebar() { const stationName = currentStation ? escapeHtml(currentStation.name) : '— kein Sender —'; const track = currentTrack ? escapeHtml(currentTrack) : ''; const vol = document.getElementById('volume')?.value ?? 204; const rows = [...document.querySelectorAll('#saved-tbody tr[data-id]')]; const stationsHtml = rows.length ? rows.map(r => `
  • `).join('') : `
  • Keine gespeicherten Sender.
  • `; const html = `
    ${stationName}
    ${track ? `
    ${track}
    ` : ''}
      ${stationsHtml}
    `; openSidebar('Radio', html); const body = $('sidebar-body'); body.querySelector('#rsb-playstop').addEventListener('click', togglePlayStop); body.querySelector('#rsb-volume').addEventListener('input', function () { const v = this.value; const mainSlider = document.getElementById('volume'); const mainNum = document.getElementById('volume-num'); if (mainSlider) mainSlider.value = v; if (mainNum) mainNum.value = v; audio.volume = v / 255; }); body.querySelectorAll('.rsb-play-btn').forEach(btn => { btn.addEventListener('click', () => playStation(btn.dataset.url, btn.dataset.name, null)); }); } // --------------------------------------------------------------------------- // Init // --------------------------------------------------------------------------- (function init() { // Migrate PBKDF2-derived key stored by login/register form if (window.USER_ID) { const pending = localStorage.getItem('diora_pending_enc_key'); if (pending) { localStorage.setItem(`diora_enc_key_${window.USER_ID}`, pending); localStorage.removeItem('diora_pending_enc_key'); } } // Populate saved stations from server-side context if available if (typeof INITIAL_SAVED !== 'undefined' && Array.isArray(INITIAL_SAVED)) { // The server already renders saved stations in the template; nothing extra needed. // But if JS-rendered saved tab were needed we'd call addSavedRow here. } // Restore persisted volume, fall back to slider default const volSlider = $('volume'); if (volSlider) { const saved = localStorage.getItem('diora_volume'); const vol = saved !== null ? parseInt(saved, 10) : parseInt(volSlider.value, 10); setVolume(vol); } // Restore persisted read-aloud language const ttsLangSelect = $('reader-tts-lang'); if (ttsLangSelect) ttsLangSelect.value = ttsLang; // Load recommendations on page load loadRecommendations(); // Initialise focus timer display renderTimer(); // Initialise mood/genre chips initMoodChips(); // Initialise curated station lists initCuratedLists(); // Show curated lists again when search input is cleared const searchInput = document.getElementById('search-input'); if (searchInput) { searchInput.addEventListener('input', function () { if (this.value === '') { const curated = document.getElementById('curated-lists'); if (curated) curated.style.display = ''; } }); } // Load focus session stats loadFocusStats(); // Apply encrypted wallpaper (if set) applyEncryptedBackground(); // Init book drop zone initBookDropZone(); const showReadToggle = $('book-show-read-toggle'); if (showReadToggle) showReadToggle.checked = _bookShowRead; // Restore active tab: a URL hash (shared/bookmarked link, or browser // back/forward) wins over the last-used tab remembered in localStorage — // except when offline, where books (usable offline) always takes over. const offlineOverride = !navigator.onLine && IS_AUTHENTICATED; const hashTab = (location.hash || '').replace(/^#/, '').split('/')[0]; if (!offlineOverride && TOP_TABS.includes(hashTab)) { _routeFromHash(); } else { const savedTab = localStorage.getItem('diora_active_tab') || 'radio'; const savedRadioTab = localStorage.getItem('diora_active_radio_tab') || 'saved'; showTab(offlineOverride ? 'books' : savedTab); showRadioTab(savedRadioTab); } // React to connectivity changes window.addEventListener('offline', () => { showOfflineBanner(true); if (IS_AUTHENTICATED) showTab('books'); }); window.addEventListener('online', async () => { showOfflineBanner(false); await _syncPendingAnnotations(); loadBookList(); // loadBookList pushes any locally-advanced progress automatically }); // Show banner immediately if already offline at startup if (!navigator.onLine) showOfflineBanner(true); // Footnote hover (desktop) + tap (mobile) via event delegation const _readerContentEl = $('reader-content'); if (_readerContentEl) { _readerContentEl.addEventListener('mouseover', e => { const link = e.target.closest('[data-footnote-ref]'); if (!link) return; clearTimeout(_footnotePopoverTimer); _footnotePopoverTimer = setTimeout(() => showFootnotePopover(link), 120); }); _readerContentEl.addEventListener('mouseout', e => { if (!e.target.closest('[data-footnote-ref]')) return; _footnotePopoverTimer = setTimeout(dismissFootnotePopover, 250); }); _readerContentEl.addEventListener('click', e => { const link = e.target.closest('[data-footnote-ref]'); if (!link) return; e.preventDefault(); if (currentFootnotePopover?._refLink === link) { dismissFootnotePopover(); } else { showFootnotePopover(link); } }); // Text selection → highlight color/note popover _readerContentEl.addEventListener('mouseup', handleReaderSelection); _readerContentEl.addEventListener('touchend', e => { const target = e.target; const touch = e.changedTouches && e.changedTouches[0]; const clientX = touch ? touch.clientX : undefined; const clientY = touch ? touch.clientY : undefined; setTimeout(() => handleReaderSelection({target, clientX, clientY}), 50); }); } // Margin ("page margin") panel — click a marker to view/edit, click empty // space to anchor a new freeform note at that line's height const _readerMarginEl = $('reader-margin'); if (_readerMarginEl) { _readerMarginEl.addEventListener('click', handleMarginClick); } })();