diora-web/static/js/app.js
marwin 3e301b6f61
All checks were successful
Build and push Docker image / build (push) Successful in 20s
Test / test (push) Successful in 34s
Bücher: Regal-Metadaten-Lookup (DNB → Open Library) + Drei-Punkte-Menü (SW v37)
Manuell per neuem "Metadaten"-Menüpunkt ausgelöst (nie automatisch): der Server
schlägt die aus dem EPUB extrahierte ISBN zuerst bei der DNB (SRU, DDC-Sachgruppe),
dann bei Open Library nach und gibt nur ein kurzes Label zurück, ohne etwas zu
speichern — das Label landet als Badge nur im ohnehin verschlüsselten Meta-Blob des
Clients. Das ist eine bewusste, eng begrenzte Ausnahme vom "Server sieht nie
Buchinhalte"-Prinzip: die Verschlüsselung dient vor allem der Absicherung gegen
Piraterie-Vorwürfe, nicht striktem Zero-Knowledge gegenüber dem eigenen Server.

Da pro Buch jetzt Reparieren/Herunterladen/Ordner/Metadaten/Lesestatus/Löschen
zusammenkommen, wandern alle Aktionen außer "Open" in ein Drei-Punkte-Menü.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011j4LcoeddwFt6Rw8Wyaknj
2026-08-17 11:27:16 +02:00

6421 lines
237 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* diora — radio player
* Handles playback, SSE metadata, search, station management, and affiliate links.
*/
'use strict';
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let currentStation = null; // { url, name, id } | null
let currentTrack = '';
let sseSource = null;
let isPlaying = false;
let currentPlayId = null;
// Podcast state
let podcastMode = false;
let currentEpisode = null; // {id, title, audioUrl, durationSeconds, feedId}
let seekSaveTimer = null;
let podcastFeeds = [];
let podcastQueue = [];
let podcastCurrentView = 'feeds';
let podcastCurrentFeedId = null;
const podcastEpCache = {}; // id → episode data, avoids encoding strings in onclick attrs
let sleepTimerInterval = null;
let sleepTimerEndSecs = 0;
let sleepTimerEndOfEp = false;
let _dragSrcEl = null;
let feedSortOrder = 'alpha';
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ---------------------------------------------------------------------------
// 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 });
}
function customPrompt(message, defaultValue = '') {
return _showModal({ message, showInput: true, inputValue: defaultValue, showCancel: true });
}
// ---------------------------------------------------------------------------
// Play / Stop
// ---------------------------------------------------------------------------
function playStation(url, name, stationId) {
stopPlayback(false);
if (location.protocol === 'https:' && url.startsWith('http://')) {
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 = '⏹ Stop';
$('play-stop-btn').classList.add('playing');
$('save-station-btn').style.display = '';
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) {
// Save podcast progress before stopping
if (podcastMode && currentEpisode) {
savePodcastProgress();
}
if (seekSaveTimer) { clearInterval(seekSaveTimer); seekSaveTimer = null; }
audio.pause();
audio.src = '';
audio.ontimeupdate = null;
audio.onended = null;
audio.onerror = null;
isPlaying = false;
podcastMode = false;
if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'none';
const seekBar = $('podcast-seek-bar');
if (seekBar) seekBar.style.display = 'none';
if (sseSource) {
sseSource.close();
sseSource = null;
}
$('play-stop-btn').textContent = '▶ Play';
$('play-stop-btn').classList.remove('playing');
$('save-station-btn').style.display = 'none';
$('affiliate-section').style.display = 'none';
stopPlaySession();
const stationEl = $('now-playing-station');
stationEl.classList.remove('podcast-station-link');
stationEl.onclick = null;
const trackEl = $('now-playing-track');
trackEl.classList.remove('podcast-track-link');
trackEl.onclick = null;
if (clearStation) {
currentStation = null;
currentTrack = '';
stationEl.textContent = '— no station —';
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() {
if (!currentPlayId) return;
try {
await fetch('/radio/play/stop/', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify({play_id: currentPlayId})
});
} catch (e) {}
currentPlayId = null;
}
window.addEventListener('beforeunload', () => {
if (currentPlayId) {
navigator.sendBeacon('/radio/play/stop/', JSON.stringify({play_id: currentPlayId}));
}
if (podcastMode && currentEpisode) {
navigator.sendBeacon('/podcasts/progress/save/', JSON.stringify({
episode_id: currentEpisode.id,
position_seconds: Math.floor(audio.currentTime),
}));
}
// 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 = '<p class="muted">Play more stations to get recommendations.</p>';
return;
}
const label = data.context;
let html = `<p class="recommendations-context">Based on your ${label} listening:</p><ul class="recommendations-list">`;
for (const r of data.recommendations) {
html += `<li>
<button class="btn btn-sm" onclick="playStation('${escapeAttr(r.station_url)}', '${escapeAttr(r.station_name)}', ${r.saved_id || 'null'})">
&#9654; ${escapeHtml(r.station_name)}
</button>
<span class="muted">${r.play_count}&times;</span>
</li>`;
}
html += '</ul>';
container.innerHTML = html;
} catch (e) {}
}
function escapeAttr(s) {
return String(s).replace(/'/g, "\\'").replace(/"/g, '&quot;');
}
// ---------------------------------------------------------------------------
// 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;
}
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 = `
<td class="history-time">${escapeHtml(formatDateTime(now))}</td>
<td>${escapeHtml(stationName)}</td>
<td>${escapeHtml(track)}</td>
<td></td>
<td><button class="btn-delete-history" onclick="deleteHistoryEntry(null, this)" title="Remove">✕</button></td>
`;
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');
statusEl.textContent = 'Searching…';
tableEl.style.display = 'none';
tbody.innerHTML = '';
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 = 'No stations found.';
return;
}
statusEl.textContent = `${stations.length} result(s)`;
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 = `
<td title="${safeName}">${safeName}</td>
<td>${safeBr}</td>
<td>${safeCC}</td>
<td title="${escapeHtml(st.tags || '')}">${safeTags}</td>
<td>
<button class="btn btn-sm btn-play"
onclick='searchPlay(${JSON.stringify(safeUrl)}, ${JSON.stringify(safeName)}, ${JSON.stringify({
name: st.name,
url: st.url_resolved || st.url,
bitrate: st.bitrate ? String(st.bitrate) : '',
country: st.country || '',
tags: st.tags || '',
favicon_url: st.favicon || '',
})})'>
&#9654; Play
</button>
</td>
`;
tbody.appendChild(tr);
});
} catch (err) {
statusEl.textContent = 'Search failed: ' + err.message;
}
}
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);
}
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('Please log in to save stations.');
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 = `
<td>
<button class="btn-icon fav-btn${station.is_favorite ? ' active' : ''}"
onclick="toggleFav(${station.id}, this)"
title="Toggle favorite">&#9733;</button>
</td>
<td class="station-name-cell">${safeName}</td>
<td>${safeBr}</td>
<td>${safeCC}</td>
<td class="notes-cell" onclick="editNotes(${station.id}, this.textContent.trim())" title="" style="cursor:pointer; color:#666; max-width:120px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;"></td>
<td>
<button class="btn btn-sm"
onclick="playStation('${safeUrl}', '${safeName}', ${station.id})">
&#9654; Play
</button>
</td>
<td>
<button class="btn btn-sm btn-danger"
onclick="removeStation(${station.id})">
Remove
</button>
</td>
`;
tbody.appendChild(tr);
}
// ---------------------------------------------------------------------------
// Remove station
// ---------------------------------------------------------------------------
async function toggleFav(pk) {
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 = 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 = '<td colspan="7" class="empty-msg">No saved stations yet.</td>';
tbody.appendChild(tr);
}
}
} catch (err) {
console.error('removeStation error:', err);
}
}
// ---------------------------------------------------------------------------
// Toggle favorite
// ---------------------------------------------------------------------------
async function toggleFav(pk, btnEl) {
try {
const res = await fetch(`/radio/favorite/${pk}/`, {
method: 'POST',
headers: { 'X-CSRFToken': getCsrfToken() },
});
if (res.ok) {
const data = await res.json();
if (data.is_favorite) {
btnEl.classList.add('active');
} else {
btnEl.classList.remove('active');
}
}
} catch (err) {
console.error('toggleFav 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('Break time! 5 minutes.');
// auto-pause playback during break
if (audio.src && !audio.paused) audio.pause();
} else {
// break ended → reset to work
timerIsBreak = false;
timerSeconds = TIMER_WORK;
showTimerNotification('Break over. Back to work.');
}
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 ? 'break' : 'focus';
// 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 = `Today: ${data.today_sessions} session${data.today_sessions !== 1 ? 's' : ''} · ${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 = '<tr><td colspan="3" class="empty-msg">No focus sessions yet. Start the timer!</td></tr>';
return;
}
data.sessions.forEach(s => {
const tr = document.createElement('tr');
const dt = new Date(s.completed_at).toLocaleString([], {dateStyle: 'short', timeStyle: 'short'});
tr.innerHTML = `<td>${dt}</td><td>${escapeHtml(s.station_name || '—')}</td><td>${s.duration_minutes} min</td>`;
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: 'Focus', tag: 'ambient' },
{ label: 'Lo-fi', tag: 'lofi' },
{ label: 'Jazz', tag: 'jazz' },
{ label: 'Classical', tag: 'classical' },
{ label: 'Ambient', tag: 'ambient' },
{ label: 'Metal', tag: 'metal' },
{ label: 'Electronic', tag: 'electronic' },
{ label: 'Talk', 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: 'Focus',
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: 'Classical',
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 = `<div class="curated-label">&#9733; Featured</div>`;
const ul = document.createElement('ul');
ul.className = 'curated-stations';
INITIAL_FEATURED.forEach(s => {
const li = document.createElement('li');
li.innerHTML = `<button class="btn btn-sm" onclick="playStation('${escapeAttr(s.url)}', '${escapeAttr(s.name)}', null)">▶</button>
<span class="curated-name">${escapeHtml(s.name)}</span>
${s.description ? `<span class="muted" style="font-size:0.78rem">${escapeHtml(s.description)}</span>` : ''}`;
ul.appendChild(li);
});
section.appendChild(ul);
container.appendChild(section);
}
CURATED_LISTS.forEach(list => {
const section = document.createElement('div');
section.className = 'curated-section';
section.innerHTML = `<div class="curated-label">${list.label}</div>`;
const ul = document.createElement('ul');
ul.className = 'curated-stations';
list.stations.forEach(s => {
const li = document.createElement('li');
li.innerHTML = `<button class="btn btn-sm" onclick="playStation('${escapeAttr(s.url)}', '${escapeAttr(s.name)}', null)">▶</button>
<span class="curated-name">${escapeHtml(s.name)}</span>`;
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 = `
<span>You listen to <strong>${escapeHtml(stationName)}</strong> a lot — consider supporting them ♥</span>
<button onclick="dismissDonationHint('${escapeAttr(stationUrl)}')" title="Dismiss">✕</button>
`;
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('Station note:', 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'];
const RADIO_SUB_TABS = ['search', 'saved', 'history'];
const PODCAST_ROUTABLE_VIEWS = ['search', 'feeds', 'inbox', 'queue'];
// ---------------------------------------------------------------------------
// 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');
} else if (_currentTopTab === 'podcasts') {
hash += '/' + (podcastCurrentView || 'feeds');
}
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 {
if (tab === 'podcasts' && PODCAST_ROUTABLE_VIEWS.includes(sub)) {
podcastCurrentView = sub;
}
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 === 'podcasts') loadPodcastTab();
if (name === 'books') loadBookList();
}
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();
}
// ---------------------------------------------------------------------------
// Podcasts
// ---------------------------------------------------------------------------
function loadPodcastTab() {
loadFeedList().then(() => {
showPodcastView(podcastCurrentView);
});
}
function showPodcastView(view) {
podcastCurrentView = view;
const panes = ['search', 'feeds', 'inbox', 'episodes', 'queue'];
panes.forEach(p => {
const el = document.getElementById(`podcast-${p}-pane`);
if (el) el.style.display = (p === view) ? '' : 'none';
});
if (PODCAST_ROUTABLE_VIEWS.includes(view)) _syncHash();
if (view === 'feeds') renderFeedList();
if (view === 'inbox') loadAndRenderInbox();
if (view === 'queue') loadAndRenderQueue();
}
async function doPodcastSearch() {
const q = $('podcast-search-input').value.trim();
if (!q) return;
const statusEl = $('podcast-search-status');
const listEl = $('podcast-search-list');
statusEl.textContent = 'Searching…';
listEl.innerHTML = '';
try {
const res = await fetch('/podcasts/search/?q=' + encodeURIComponent(q));
const data = await res.json();
if (data.error) { statusEl.textContent = 'Error: ' + data.error; return; }
const results = data.results || [];
statusEl.textContent = results.length ? `${results.length} result(s)` : 'No results.';
results.forEach(r => {
const div = document.createElement('div');
div.className = 'podcast-search-item';
div.innerHTML = `
${r.artwork_url ? `<img class="podcast-thumb" src="${escapeHtml(r.artwork_url)}" alt="">` : '<div class="podcast-thumb-placeholder"></div>'}
<div class="podcast-search-info">
<div class="podcast-feed-title">${escapeHtml(r.title)}</div>
<div class="muted">${escapeHtml(r.author)}</div>
</div>
<button class="btn btn-sm podcast-subscribe-btn">Subscribe</button>
`;
// Attach via addEventListener to avoid encoding strings in onclick attribute
div.querySelector('.podcast-subscribe-btn').addEventListener('click', () => {
subscribeFeed(r.rss_url, r.title);
});
listEl.appendChild(div);
});
} catch (e) {
statusEl.textContent = 'Search failed.';
}
}
function podcastSearchOpen() {
showPodcastView('search');
}
async function addFeedByUrl() {
const url = await customPrompt('RSS feed URL:');
if (url) subscribeFeed(url.trim(), '');
}
async function subscribeFeed(rssUrl, title) {
if (!rssUrl) return;
const statusEl = $('podcast-search-status') || $('opml-status');
try {
const res = await fetch('/podcasts/feeds/add/', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify({rss_url: rssUrl, title: title || rssUrl}),
});
const data = await res.json();
if (data.ok) {
await loadFeedList();
showPodcastView('feeds');
} else if (statusEl) {
statusEl.textContent = 'Error: ' + (data.error || 'unknown');
}
} catch (e) {
if (statusEl) statusEl.textContent = 'Failed to subscribe.';
}
}
async function loadFeedList() {
try {
const res = await fetch('/podcasts/feeds/');
const data = await res.json();
podcastFeeds = data.feeds || [];
} catch (e) {
podcastFeeds = [];
}
}
function renderFeedList() {
const container = $('podcast-feed-list');
if (!container) return;
if (!podcastFeeds.length) {
container.innerHTML = '<p class="muted">No subscriptions yet. Search or import OPML to add feeds.</p>';
return;
}
container.innerHTML = '';
podcastFeeds.forEach(feed => {
const div = document.createElement('div');
div.className = 'podcast-feed-item';
div.innerHTML = `
${feed.artwork_url
? `<img class="podcast-thumb" src="${escapeHtml(feed.artwork_url)}" alt="">`
: '<div class="podcast-thumb-placeholder"></div>'}
<div class="podcast-feed-info">
<div class="podcast-feed-title">${escapeHtml(feed.title)}</div>
${feed.author ? `<div class="muted">${escapeHtml(feed.author)}</div>` : ''}
</div>
<div class="podcast-feed-actions">
<button class="btn btn-sm" onclick="openFeed(${feed.id})">Episodes</button>
<button class="btn btn-sm" onclick="refreshFeed(${feed.id})" title="Refresh feed">↻</button>
<button class="btn btn-sm ${feed.auto_queue ? 'active' : ''}" onclick="toggleFeedAutoQueue(${feed.id}, this)" title="${feed.auto_queue ? 'Auto-queue ON' : 'Auto-queue new episodes'}">Auto</button>
<button class="btn btn-sm btn-danger" onclick="removeFeed(${feed.id})">Remove</button>
</div>
`;
container.appendChild(div);
});
const filterVal = ($('feed-filter-input') || {}).value || '';
if (filterVal) filterFeeds(filterVal);
}
function filterFeeds(query) {
const container = $('podcast-feed-list');
if (!container) return;
const q = query.toLowerCase().trim();
container.querySelectorAll('.podcast-feed-item').forEach(item => {
const title = (item.querySelector('.podcast-feed-title') || {}).textContent?.toLowerCase() || '';
const author = (item.querySelector('.muted') || {}).textContent?.toLowerCase() || '';
item.style.display = (!q || title.includes(q) || author.includes(q)) ? '' : 'none';
});
}
function sortFeeds(order) {
feedSortOrder = order;
if (order === 'alpha') podcastFeeds.sort((a, b) => a.title.localeCompare(b.title));
if (order === 'alpha-desc') podcastFeeds.sort((a, b) => b.title.localeCompare(a.title));
if (order === 'added') podcastFeeds.sort((a, b) => (b.added_at || '').localeCompare(a.added_at || ''));
if (order === 'latest_episode') podcastFeeds.sort((a, b) => (b.latest_episode_at || '').localeCompare(a.latest_episode_at || ''));
renderFeedList();
}
async function toggleFeedAutoQueue(feedId, btn) {
try {
const res = await fetch(`/podcasts/feeds/${feedId}/set-auto-queue/`, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify({}),
});
const data = await res.json();
if (data.ok) {
const feed = podcastFeeds.find(f => f.id === feedId);
if (feed) feed.auto_queue = data.auto_queue;
btn.classList.toggle('active', data.auto_queue);
btn.title = data.auto_queue ? 'Auto-queue ON' : 'Auto-queue new episodes';
}
} catch (e) {}
}
async function openFeed(feedId) {
podcastCurrentFeedId = feedId;
showPodcastView('episodes');
const headerEl = $('podcast-feed-header');
const listEl = $('podcast-episode-list');
if (headerEl) headerEl.innerHTML = '<p class="muted">Loading…</p>';
if (listEl) listEl.innerHTML = '';
try {
const res = await fetch(`/podcasts/feeds/${feedId}/episodes/`);
const data = await res.json();
const feed = data.feed;
const episodes = data.episodes || [];
if (headerEl) {
headerEl.innerHTML = `
<div class="podcast-feed-header-inner">
${feed.artwork_url ? `<img class="podcast-thumb-lg" src="${escapeHtml(feed.artwork_url)}" alt="">` : ''}
<div>
<div class="podcast-feed-title">${escapeHtml(feed.title)}</div>
${feed.author ? `<div class="muted">${escapeHtml(feed.author)}</div>` : ''}
</div>
<button class="btn btn-sm feed-refresh-btn" id="feed-refresh-btn" onclick="refreshOpenFeed(this)" title="Refresh feed">↻ Refresh</button>
</div>
`;
}
renderEpisodeList(episodes, feedId, listEl);
const filterBar = $('episode-search-bar');
if (filterBar) { filterBar.style.display = ''; $('episode-filter-input').value = ''; }
} catch (e) {
if (headerEl) headerEl.innerHTML = '<p class="muted">Failed to load episodes.</p>';
}
}
function filterEpisodes(query) {
const listEl = $('podcast-episode-list');
if (!listEl) return;
const q = query.toLowerCase().trim();
listEl.querySelectorAll('.episode-item').forEach(item => {
const text = (item.querySelector('.episode-title') || {}).textContent?.toLowerCase() || '';
item.style.display = (!q || text.includes(q)) ? '' : 'none';
});
}
function renderEpisodeList(episodes, feedId, container) {
if (!container) return;
if (!episodes.length) {
container.innerHTML = '<p class="muted">No episodes found.</p>';
return;
}
container.innerHTML = '';
episodes.forEach(ep => {
// Cache episode data by id so onclick attrs only need the id (avoids encoding
// strings with quotes inside HTML attributes which breaks the attribute parser)
podcastEpCache[ep.id] = {
id: ep.id,
title: ep.title,
description: ep.description || '',
audioUrl: ep.audio_url,
durationSeconds: ep.duration_seconds,
positionSeconds: ep.position_seconds || 0,
feedId: feedId || 0,
played: ep.played,
};
const div = document.createElement('div');
div.className = 'episode-item' + (ep.played ? ' episode-played' : '');
div.id = `episode-item-${ep.id}`;
const artSrc = ep.artwork_url || (feedId ? (podcastFeeds.find(f => f.id === feedId) || {}).artwork_url || '' : '');
const dur = formatDuration(ep.duration_seconds);
const dateStr = ep.pub_date ? ep.pub_date.slice(0, 10) : '';
const posStr = ep.position_seconds > 0 ? formatDuration(ep.position_seconds) + ' played' : '';
const progressPct = (ep.duration_seconds > 0 && ep.position_seconds > 0)
? Math.min(100, (ep.position_seconds / ep.duration_seconds) * 100) : 0;
div.innerHTML = `
${artSrc ? `<img class="podcast-thumb" src="${escapeHtml(artSrc)}" alt="">` : '<div class="podcast-thumb-placeholder"></div>'}
<div class="episode-info">
<div class="episode-title ep-clickable" onclick="openEpisodeSidebar(${ep.id})" title="Show notes">${escapeHtml(ep.title)}</div>
<div class="episode-meta">
${dateStr ? `<span class="episode-date">${escapeHtml(dateStr)}</span>` : ''}
${dur !== '0:00' ? `<span class="episode-dur">${escapeHtml(dur)}</span>` : ''}
${posStr ? `<span class="episode-pos muted">${escapeHtml(posStr)}</span>` : ''}
</div>
${progressPct > 0 ? `<div class="episode-progress-bar"><div class="episode-progress-fill" style="width:${progressPct.toFixed(1)}%"></div></div>` : ''}
</div>
<div class="episode-actions">
<button class="btn btn-sm btn-play" onclick="playEpisodeById(${ep.id})">▶</button>
<button class="btn btn-sm" onclick="openEpisodeSidebar(${ep.id})" title="Show notes">Notes</button>
<button class="btn btn-sm" onclick="queueAddEpisode(${ep.id})" title="${ep.in_queue ? 'In queue' : 'Add to queue'}">${ep.in_queue ? '✓Q' : '+Q'}</button>
<button class="btn btn-sm" onclick="toggleMarkPlayed(${ep.id}, this)" title="Mark played">${ep.played ? '✓' : '○'}</button>
<button class="btn btn-sm" onclick="downloadEpisodeById(${ep.id}, this)" title="Download">⬇</button>
</div>
`;
container.appendChild(div);
});
}
function playEpisodeById(id) {
const ep = podcastEpCache[id];
if (!ep) return;
playEpisode(ep.id, ep.title, ep.audioUrl, ep.durationSeconds, ep.positionSeconds, ep.feedId);
}
function downloadEpisodeById(id, btn) {
const ep = podcastEpCache[id];
if (!ep) return;
downloadEpisode(ep.audioUrl, ep.title, btn);
}
function playEpisode(id, title, url, durationSeconds, positionSeconds, feedId) {
// Auto-enqueue if not already in queue
const inQueue = podcastQueue.some(q => q['episode__id'] === id);
if (!inQueue) {
fetch('/podcasts/queue/add/', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify({episode_id: id}),
}).then(() => {
// update local queue state and any visible +Q buttons
if (podcastCurrentView === 'queue') loadAndRenderQueue();
const qBtn = document.querySelector(`#episode-item-${id} .episode-actions .btn-sm:nth-child(3)`);
if (qBtn && (qBtn.textContent === '+Q' || qBtn.textContent.includes('Q'))) {
qBtn.textContent = '✓Q'; qBtn.title = 'In queue';
}
}).catch(() => {});
}
stopPlayback(false);
podcastMode = true;
isPlaying = true; // fix: was missing, causing stop button to do nothing
currentEpisode = {id, title, audioUrl: url, durationSeconds, feedId};
audio.src = url;
const volSlider = $('volume');
if (volSlider) audio.volume = volSlider.value / 255;
if (positionSeconds > 0) {
audio.addEventListener('loadedmetadata', function onMeta() {
audio.currentTime = positionSeconds;
audio.removeEventListener('loadedmetadata', onMeta);
});
}
audio.play().catch(e => console.warn('Podcast play blocked:', e));
const feedTitle = (podcastFeeds.find(f => f.id === feedId) || {}).title || 'Podcast';
const stationEl = $('now-playing-station');
stationEl.textContent = feedTitle;
stationEl.classList.add('podcast-station-link');
stationEl.onclick = () => { showTab('podcasts'); openFeed(feedId); };
const trackEl = $('now-playing-track');
trackEl.textContent = title;
trackEl.classList.add('podcast-track-link');
trackEl.onclick = () => openEpisodeSidebar(id);
$('play-stop-btn').style.display = '';
$('play-stop-btn').textContent = '⏹ Stop';
$('play-stop-btn').classList.add('playing');
const seekBar = $('podcast-seek-bar');
if (seekBar) seekBar.style.display = '';
const slider = $('seek-slider');
if (slider && durationSeconds > 0) slider.max = durationSeconds;
// Reset speed to 1× for each new episode
setPlaybackRate(1);
audio.ontimeupdate = podcastTimeUpdate;
audio.onended = podcastOnEnded;
// Media Session API — maps hardware media keys, lock-screen controls, and
// Windows taskbar thumbnail buttons (play/pause, previous, next)
if ('mediaSession' in navigator) {
const feedTitle = (podcastFeeds.find(f => f.id === feedId) || {}).title || '';
const artSrc = (podcastFeeds.find(f => f.id === feedId) || {}).artwork_url || '';
navigator.mediaSession.metadata = new MediaMetadata({
title,
artist: feedTitle,
artwork: artSrc ? [{src: artSrc, sizes: '512x512', type: 'image/jpeg'}] : [],
});
navigator.mediaSession.setActionHandler('play', () => { audio.play(); isPlaying = true; });
navigator.mediaSession.setActionHandler('pause', () => { audio.pause(); isPlaying = false; });
navigator.mediaSession.setActionHandler('stop', () => stopPlayback(true));
navigator.mediaSession.setActionHandler('seekbackward', () => skipBack());
navigator.mediaSession.setActionHandler('seekforward', () => skipForward());
navigator.mediaSession.setActionHandler('nexttrack', () => podcastOnEnded());
try { navigator.mediaSession.setActionHandler('previoustrack', () => skipBack()); } catch (_) {}
navigator.mediaSession.playbackState = 'playing';
}
if (seekSaveTimer) clearInterval(seekSaveTimer);
seekSaveTimer = setInterval(savePodcastProgress, 15000);
}
function podcastTimeUpdate() {
const pos = Math.floor(audio.currentTime);
const dur = currentEpisode ? currentEpisode.durationSeconds : 0;
const curEl = $('seek-current');
if (curEl) curEl.textContent = formatDuration(pos);
const durEl = $('seek-duration');
if (durEl) durEl.textContent = formatDuration(dur || Math.floor(audio.duration) || 0);
const slider = $('seek-slider');
if (slider) {
if (dur > 0) {
slider.max = dur;
} else if (audio.duration && isFinite(audio.duration)) {
slider.max = Math.floor(audio.duration);
}
slider.value = pos;
}
}
function skipBack() {
audio.currentTime = Math.max(0, audio.currentTime - 15);
}
function skipForward() {
const dur = audio.duration;
audio.currentTime = dur && isFinite(dur)
? Math.min(dur, audio.currentTime + 30)
: audio.currentTime + 30;
}
function setPlaybackRate(rate) {
audio.playbackRate = rate;
// Keep pitch natural at non-1× speeds (supported in all modern browsers)
audio.preservesPitch = true;
document.querySelectorAll('.speed-btn').forEach(btn => {
btn.classList.toggle('active', parseFloat(btn.textContent) === rate
|| (rate === 0.75 && btn.textContent.startsWith('¾'))
|| (rate === 1.25 && btn.textContent.startsWith('1¼'))
|| (rate === 1.5 && btn.textContent.startsWith('1½'))
|| (rate === 1.75 && btn.textContent.startsWith('1¾'))
|| (rate === 2.5 && btn.textContent.startsWith('2½')));
});
}
async function podcastOnEnded() {
if (!podcastMode || !currentEpisode) return;
await fetch('/podcasts/progress/mark-played/', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify({episode_id: currentEpisode.id, played: true}),
}).catch(() => {});
if (sleepTimerEndOfEp) { clearSleepTimer(); audio.pause(); return; }
const finishedId = currentEpisode.id;
try {
const res = await fetch('/podcasts/queue/');
const data = await res.json();
const items = data.queue || [];
const currentIdx = items.findIndex(item => item['episode__id'] === finishedId);
const nextItem = currentIdx >= 0 ? items[currentIdx + 1] : null;
await fetch('/podcasts/queue/remove/', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify({episode_id: finishedId}),
}).catch(() => {});
if (nextItem) {
const nextEpId = nextItem['episode__id'];
const cached = podcastEpCache[nextEpId] || {};
playEpisode(nextEpId, nextItem['episode__title'], nextItem['episode__audio_url'],
nextItem['episode__duration_seconds'], cached.positionSeconds || 0, nextItem['episode__feed__id']);
} else {
stopPlayback(false);
}
if (podcastCurrentView === 'queue') loadAndRenderQueue();
} catch (e) {
stopPlayback(false);
}
}
function openSleepTimerMenu() {
const existing = document.getElementById('sleep-timer-menu');
if (existing) { existing.remove(); return; }
const options = [
{label: 'Off', value: 0}, {label: '5m', value: 5}, {label: '10m', value: 10},
{label: '15m', value: 15}, {label: '30m', value: 30}, {label: '45m', value: 45},
{label: '60m', value: 60}, {label: 'End of episode', value: -1},
];
const menu = document.createElement('div');
menu.id = 'sleep-timer-menu';
menu.className = 'sleep-timer-menu';
options.forEach(opt => {
const btn = document.createElement('button');
btn.className = 'sleep-timer-option';
btn.textContent = opt.label;
btn.onclick = () => { setSleepTimer(opt.value); menu.remove(); };
menu.appendChild(btn);
});
document.getElementById('sleep-timer-btn').insertAdjacentElement('afterend', menu);
}
function setSleepTimer(minutes) {
clearSleepTimer();
const btn = document.getElementById('sleep-timer-btn');
if (minutes === 0) { if (btn) btn.textContent = 'Sleep'; return; }
if (minutes === -1) {
sleepTimerEndOfEp = true;
if (btn) btn.textContent = 'Sleep:EoE';
return;
}
sleepTimerEndOfEp = false;
sleepTimerEndSecs = Math.floor(Date.now() / 1000) + minutes * 60;
sleepTimerInterval = setInterval(() => {
const remaining = sleepTimerEndSecs - Math.floor(Date.now() / 1000);
if (remaining <= 0) { clearSleepTimer(); audio.pause(); isPlaying = false; return; }
const m = Math.floor(remaining / 60);
const s = remaining % 60;
if (btn) btn.textContent = `${m}:${String(s).padStart(2, '0')}`;
}, 1000);
}
function clearSleepTimer() {
if (sleepTimerInterval) { clearInterval(sleepTimerInterval); sleepTimerInterval = null; }
sleepTimerEndOfEp = false;
sleepTimerEndSecs = 0;
const btn = document.getElementById('sleep-timer-btn');
if (btn) btn.textContent = 'Sleep';
}
async function savePodcastProgress() {
if (!currentEpisode) return;
const pos = Math.floor(audio.currentTime);
try {
await fetch('/podcasts/progress/save/', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify({episode_id: currentEpisode.id, position_seconds: pos}),
});
} catch (e) {}
}
let _inboxOffset = 0;
const _inboxPageSize = DIORA_CONFIG.podcastInboxPageSize;
async function loadAndRenderInbox(append = false) {
const listEl = $('podcast-inbox-list');
if (!listEl) return;
if (!append) {
_inboxOffset = 0;
listEl.innerHTML = '<p class="muted">Loading…</p>';
inboxUpdateBulkBar();
}
try {
const res = await fetch(`/podcasts/inbox/?limit=${_inboxPageSize}&offset=${_inboxOffset}`);
const data = await res.json();
const episodes = data.episodes || [];
if (!append) listEl.innerHTML = '';
if (!episodes.length && !append) {
listEl.innerHTML = '<p class="muted">Inbox empty — all caught up!</p>';
$('inbox-load-more-bar').style.display = 'none';
return;
}
episodes.forEach(ep => {
podcastEpCache[ep.id] = {
id: ep.id,
title: ep.title,
description: ep.description || '',
audioUrl: ep.audio_url,
durationSeconds: ep.duration_seconds,
positionSeconds: ep.position_seconds || 0,
feedId: ep['feed__id'],
played: false,
};
const progressPct = (ep.duration_seconds > 0 && ep.position_seconds > 0)
? Math.min(100, (ep.position_seconds / ep.duration_seconds) * 100) : 0;
const dur = formatDuration(ep.duration_seconds);
const dateStr = ep.pub_date ? ep.pub_date.slice(0, 10) : '';
const inQueue = ep.in_queue;
const div = document.createElement('div');
div.className = 'episode-item';
div.dataset.epId = ep.id;
div.innerHTML = `
<label class="inbox-checkbox-label">
<input type="checkbox" class="inbox-cb" data-ep-id="${ep.id}" onchange="inboxOnCheck()">
</label>
${ep['feed__artwork_url'] ? `<img class="podcast-thumb" src="${escapeHtml(ep['feed__artwork_url'])}" alt="">` : '<div class="podcast-thumb-placeholder"></div>'}
<div class="episode-info">
<div class="episode-title ep-clickable" onclick="openEpisodeSidebar(${ep.id})" title="Show notes">${escapeHtml(ep.title)}</div>
<div class="episode-meta">
<span class="episode-date episode-feed-link" onclick="openFeed(${ep['feed__id']})" title="Open feed">${escapeHtml(ep['feed__title'])}</span>
${dateStr ? `<span class="episode-dur">${escapeHtml(dateStr)}</span>` : ''}
${dur !== '0:00' ? `<span class="episode-dur">${escapeHtml(dur)}</span>` : ''}
</div>
${progressPct > 0 ? `<div class="episode-progress-bar"><div class="episode-progress-fill" style="width:${progressPct.toFixed(1)}%"></div></div>` : ''}
</div>
<div class="episode-actions">
<button class="btn btn-sm btn-play" onclick="playEpisodeById(${ep.id})">▶</button>
<button class="btn btn-sm" onclick="queueAddEpisode(${ep.id})" title="${inQueue ? 'In queue' : 'Add to queue'}">${inQueue ? '✓Q' : '+Q'}</button>
<button class="btn btn-sm" onclick="downloadEpisodeById(${ep.id}, this)" title="Download">⬇</button>
<button class="btn btn-sm btn-danger" onclick="inboxDismissOne(${ep.id}, this)" title="Dismiss">✕</button>
</div>
`;
listEl.appendChild(div);
});
_inboxOffset += episodes.length;
const moreBar = $('inbox-load-more-bar');
const countLabel = $('inbox-count-label');
if (episodes.length === _inboxPageSize) {
moreBar.style.display = '';
if (countLabel) countLabel.textContent = `${_inboxOffset} loaded`;
} else {
moreBar.style.display = 'none';
if (countLabel) countLabel.textContent = '';
}
} catch (e) {
if (!append) listEl.innerHTML = '<p class="muted">Failed to load inbox.</p>';
}
}
function inboxLoadMore() {
loadAndRenderInbox(true);
}
function inboxGetSelectedIds() {
return Array.from(document.querySelectorAll('.inbox-cb:checked'))
.map(cb => parseInt(cb.dataset.epId, 10));
}
function inboxOnCheck() {
inboxUpdateBulkBar();
// sync select-all state
const all = document.querySelectorAll('.inbox-cb');
const checked = document.querySelectorAll('.inbox-cb:checked');
const selectAll = $('inbox-select-all');
if (selectAll) {
selectAll.indeterminate = checked.length > 0 && checked.length < all.length;
selectAll.checked = all.length > 0 && checked.length === all.length;
}
}
function inboxSelectAll(checked) {
document.querySelectorAll('.inbox-cb').forEach(cb => { cb.checked = checked; });
inboxUpdateBulkBar();
}
function inboxUpdateBulkBar() {
const ids = inboxGetSelectedIds();
const bar = $('inbox-bulk-actions');
const countEl = $('inbox-selection-count');
if (!bar) return;
if (ids.length > 0) {
bar.style.display = '';
if (countEl) countEl.textContent = `${ids.length} selected`;
} else {
bar.style.display = 'none';
}
}
async function inboxBulkDismiss() {
const ids = inboxGetSelectedIds();
if (!ids.length) return;
try {
await fetch('/podcasts/progress/dismiss/', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify({episode_ids: ids, dismissed: true}),
});
ids.forEach(id => {
const div = document.querySelector(`.episode-item[data-ep-id="${id}"]`);
if (div) div.remove();
});
inboxUpdateBulkBar();
const selectAll = $('inbox-select-all');
if (selectAll) { selectAll.checked = false; selectAll.indeterminate = false; }
if (!document.querySelector('.inbox-cb')) {
const listEl = $('podcast-inbox-list');
if (listEl && !listEl.querySelector('.episode-item')) {
listEl.innerHTML = '<p class="muted">Inbox empty — all caught up!</p>';
}
}
} catch (e) {}
}
async function inboxDismissOne(epId, btn) {
try {
await fetch('/podcasts/progress/dismiss/', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify({episode_ids: [epId], dismissed: true}),
});
const div = document.querySelector(`.episode-item[data-ep-id="${epId}"]`);
if (div) div.remove();
if (!document.querySelector('.inbox-cb')) {
const listEl = $('podcast-inbox-list');
if (listEl && !listEl.querySelector('.episode-item')) {
listEl.innerHTML = '<p class="muted">Inbox empty — all caught up!</p>';
}
}
} catch (e) {}
}
async function inboxBulkQueueAdd() {
const ids = inboxGetSelectedIds();
for (const id of ids) {
await queueAddEpisode(id);
}
// Update queue button states
ids.forEach(id => {
const div = document.querySelector(`.episode-item[data-ep-id="${id}"]`);
if (div) {
const qBtn = div.querySelector('.episode-actions .btn-sm:nth-child(2)');
if (qBtn) { qBtn.textContent = '✓Q'; qBtn.title = 'In queue'; }
}
});
}
async function inboxBulkMarkPlayed() {
const ids = inboxGetSelectedIds();
try {
for (const id of ids) {
await fetch('/podcasts/progress/mark-played/', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify({episode_id: id, played: true}),
});
}
ids.forEach(id => {
const div = document.querySelector(`.episode-item[data-ep-id="${id}"]`);
if (div) div.remove();
});
inboxUpdateBulkBar();
const selectAll = $('inbox-select-all');
if (selectAll) { selectAll.checked = false; selectAll.indeterminate = false; }
} catch (e) {}
}
async function inboxBulkDownload() {
const ids = inboxGetSelectedIds();
for (const id of ids) {
const ep = podcastEpCache[id];
if (ep) await downloadEpisode(ep.audioUrl, ep.title, null);
}
}
async function loadAndRenderQueue() {
const ol = $('podcast-queue-ol');
if (!ol) return;
ol.innerHTML = '<li class="muted">Loading…</li>';
try {
const res = await fetch('/podcasts/queue/');
const data = await res.json();
const items = data.queue || [];
podcastQueue = items;
ol.innerHTML = '';
if (!items.length) {
ol.innerHTML = '<li class="muted">Queue is empty.</li>';
return;
}
items.forEach(item => {
const epId = item['episode__id'];
podcastEpCache[epId] = {
id: epId,
title: item['episode__title'],
audioUrl: item['episode__audio_url'],
durationSeconds: item['episode__duration_seconds'],
positionSeconds: item['position_seconds'] || 0,
feedId: item['episode__feed__id'],
played: false,
};
const progressPct = (item['episode__duration_seconds'] > 0 && item['position_seconds'] > 0)
? Math.min(100, (item['position_seconds'] / item['episode__duration_seconds']) * 100) : 0;
const dur = formatDuration(item['episode__duration_seconds']);
const li = document.createElement('li');
li.className = 'episode-item';
li.draggable = true;
li.dataset.epId = epId;
li.innerHTML = `
<span class="drag-handle">⠿</span>
<div class="episode-info">
<div class="episode-title">${escapeHtml(item['episode__title'])}</div>
<div class="episode-meta">
<span class="episode-date episode-feed-link" onclick="openFeed(${item['episode__feed__id']})" title="Open feed">${escapeHtml(item['episode__feed__title'])}</span>
${dur !== '0:00' ? `<span class="episode-dur">${escapeHtml(dur)}</span>` : ''}
</div>
${progressPct > 0 ? `<div class="episode-progress-bar"><div class="episode-progress-fill" style="width:${progressPct.toFixed(1)}%"></div></div>` : ''}
</div>
<div class="episode-actions">
<button class="btn btn-sm btn-play" onclick="playEpisodeById(${epId})">▶</button>
<button class="btn btn-sm" onclick="downloadEpisodeById(${epId}, this)" title="Download">⬇</button>
<button class="btn btn-sm btn-danger" onclick="queueRemoveEpisode(${epId})">✕</button>
</div>
`;
li.addEventListener('dragstart', queueDragStart);
li.addEventListener('dragover', queueDragOver);
li.addEventListener('drop', queueDrop);
li.addEventListener('dragend', queueDragEnd);
ol.appendChild(li);
});
} catch (e) {
ol.innerHTML = '<li class="muted">Failed to load queue.</li>';
}
}
function queueDragStart(e) {
_dragSrcEl = this;
e.dataTransfer.effectAllowed = 'move';
this.classList.add('dragging');
}
function queueDragOver(e) {
e.preventDefault();
const ol = document.getElementById('podcast-queue-ol');
const dragging = ol.querySelector('.dragging');
if (!dragging || dragging === this) return;
const rect = this.getBoundingClientRect();
ol.insertBefore(dragging, e.clientY < rect.top + rect.height / 2 ? this : this.nextSibling);
}
function queueDrop(e) { e.preventDefault(); }
function queueDragEnd() {
this.classList.remove('dragging');
_dragSrcEl = null;
const ol = document.getElementById('podcast-queue-ol');
const newOrder = Array.from(ol.querySelectorAll('li[data-ep-id]'))
.map(li => parseInt(li.dataset.epId, 10));
fetch('/podcasts/queue/reorder/', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify({order: newOrder}),
}).catch(() => {});
}
async function queueAddEpisode(id) {
try {
await fetch('/podcasts/queue/add/', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify({episode_id: id}),
});
} catch (e) {}
}
async function queueRemoveEpisode(id) {
try {
await fetch('/podcasts/queue/remove/', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify({episode_id: id}),
});
if (podcastCurrentView === 'queue') loadAndRenderQueue();
} catch (e) {}
}
async function toggleMarkPlayed(id, btn) {
const ep = podcastEpCache[id];
const current = ep ? ep.played : btn.textContent === '✓';
const newPlayed = !current;
try {
const res = await fetch('/podcasts/progress/mark-played/', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify({episode_id: id, played: newPlayed}),
});
if (res.ok) {
if (ep) ep.played = newPlayed;
btn.textContent = newPlayed ? '✓' : '○';
const item = document.getElementById(`episode-item-${id}`);
if (item) item.classList.toggle('episode-played', newPlayed);
}
} catch (e) {}
}
async function refreshFeed(feedId) {
try {
const res = await fetch('/podcasts/feeds/refresh/', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify({feed_id: feedId}),
});
const data = await res.json();
if (data.ok && podcastCurrentFeedId === feedId) {
openFeed(feedId);
}
} catch (e) {}
}
async function refreshOpenFeed(btn) {
if (!podcastCurrentFeedId) return;
if (btn) { btn.disabled = true; btn.textContent = '↻ …'; }
try {
const res = await fetch('/podcasts/feeds/refresh/', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify({feed_id: podcastCurrentFeedId}),
});
const data = await res.json();
if (data.ok) {
await openFeed(podcastCurrentFeedId);
// openFeed re-renders the header, btn reference is stale — nothing to restore
return;
}
} catch (e) {}
if (btn) { btn.disabled = false; btn.textContent = '↻ Refresh'; }
}
async function refreshAllFeedsBtn(btn) {
if (btn) { btn.disabled = true; btn.textContent = '↻ 0/' + podcastFeeds.length; }
let done = 0;
for (const feed of podcastFeeds) {
try {
await fetch('/podcasts/feeds/refresh/', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify({feed_id: feed.id}),
});
} catch (e) {}
done++;
if (btn) btn.textContent = `${done}/${podcastFeeds.length}`;
}
await loadFeedList();
if (podcastCurrentView === 'feeds') renderFeedList();
if (podcastCurrentView === 'inbox') loadAndRenderInbox();
if (podcastCurrentView === 'episodes' && podcastCurrentFeedId) openFeed(podcastCurrentFeedId);
if (btn) { btn.disabled = false; btn.textContent = '↻ All'; }
}
async function refreshAllFeeds() {
if (!IS_AUTHENTICATED || !podcastFeeds.length) return;
for (const feed of podcastFeeds) {
try {
await fetch('/podcasts/feeds/refresh/', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify({feed_id: feed.id}),
});
} catch (e) {}
}
// Reload feed metadata and refresh the currently open view
await loadFeedList();
if (podcastCurrentView === 'feeds') renderFeedList();
if (podcastCurrentView === 'inbox') loadAndRenderInbox();
if (podcastCurrentView === 'episodes' && podcastCurrentFeedId) openFeed(podcastCurrentFeedId);
}
async function removeFeed(feedId) {
if (!await customConfirm('Remove this podcast?', { danger: true })) return;
try {
await fetch(`/podcasts/feeds/${feedId}/remove/`, {
method: 'POST',
headers: {'X-CSRFToken': getCsrfToken()},
});
podcastFeeds = podcastFeeds.filter(f => f.id !== feedId);
renderFeedList();
} catch (e) {}
}
async function importOPML(input) {
const file = input.files[0];
if (!file) return;
const statusEl = $('opml-status');
if (statusEl) statusEl.textContent = 'Importing…';
const form = new FormData();
form.append('file', file);
form.append('csrfmiddlewaretoken', getCsrfToken());
try {
const res = await fetch('/podcasts/feeds/import/', {method: 'POST', body: form});
const data = await res.json();
if (data.ok) {
if (statusEl) statusEl.textContent = `${data.added} added, ${data.skipped} already subscribed`;
await loadFeedList();
renderFeedList();
} else {
if (statusEl) statusEl.textContent = 'Error: ' + (data.error || 'unknown');
}
} catch (e) {
if (statusEl) statusEl.textContent = 'Import failed.';
}
input.value = '';
}
async function downloadEpisode(url, title, btn) {
if (!('caches' in window)) {
await customAlert('Cache API not supported in this browser.');
return;
}
const origText = btn ? btn.textContent : '⬇';
if (btn) { btn.textContent = '…'; btn.disabled = true; }
try {
const cache = await caches.open('diora-podcast-v1');
const existing = await cache.match(url);
if (existing) {
if (btn) { btn.textContent = '✓'; btn.disabled = false; }
return;
}
// Use no-cors so cross-origin audio URLs don't block the fetch
const resp = await fetch(url, {mode: 'no-cors'});
await cache.put(url, resp);
if (btn) { btn.textContent = '✓'; btn.disabled = false; }
} catch (e) {
if (btn) { btn.textContent = origText; btn.disabled = false; }
await customAlert('Download failed: ' + e.message);
}
}
// ---------------------------------------------------------------------------
// 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 '<p class="muted">No show notes available.</p>';
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;
}
function openEpisodeSidebar(id) {
const ep = podcastEpCache[id];
if (!ep) return;
openSidebar(ep.title, ep.description || '');
}
function formatDuration(seconds) {
if (!seconds || seconds <= 0) return '0:00';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) {
return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
return `${m}:${String(s).padStart(2, '0')}`;
}
// ---------------------------------------------------------------------------
// 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 only if the reader isn't open (would interrupt reading).
navigator.serviceWorker.addEventListener('message', e => {
if (e.data?.type === 'SW_ACTIVATED' && !currentBookId) {
window.location.reload();
}
});
}
// ---------------------------------------------------------------------------
// M3U import
// ---------------------------------------------------------------------------
async function importM3U(input) {
const file = input.files[0];
if (!file) return;
const status = document.getElementById('import-status');
status.textContent = 'Importing…';
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} added, ${data.skipped} already saved`;
if (data.added > 0) location.reload();
} else {
status.textContent = `Error: ${data.error}`;
}
} catch (e) {
status.textContent = 'Upload failed';
}
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('Only JPEG, PNG, or WebP images are allowed.');
return;
}
if (file.size > DIORA_CONFIG.bgMaxBytes) {
await customAlert(`Image must be ${DIORA_CONFIG.bgMaxBytes / 1024 / 1024} MB or smaller.`);
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('No rootfile in container.xml');
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() || 'Unknown Title';
const author = opfDoc.querySelector('metadata > creator, metadata > *|creator')?.textContent?.trim() || 'Unknown Author';
// 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(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '');
chapterText = chapterText.replace(/<script\b[^>]*>[\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(`<div id="epub-chapter-${i}" data-epub-src="${href}">${sanitized}</div>`);
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)) || '';
}
// <img src="...">
html = html.replace(/(<img\b[^>]*?)\bsrc="([^"]*)"/gi,
(_, pre, src) => { const b = subst(src); return b ? `${pre}src="${b}"` : pre; });
// SVG <image xlink:href="...">
html = html.replace(/(<image\b[^>]*?)\bxlink:href="([^"]*)"/gi,
(_, pre, src) => { const b = subst(src); return b ? `${pre}xlink:href="${b}"` : pre; });
// SVG <image href="...">
html = html.replace(/(<image\b[^>]*?)\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 <sup> — in either direction
// (<sup><a>…</a></sup> or <a><sup>…</sup></a>). 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 = `<span>Weit gescrollt</span><button type="button" class="reader-toast-btn">Zurückspringen</button>`;
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)}`;
}
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,
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 = '<p class="muted">Loading books…</p>';
try {
listEl.innerHTML = '<p class="muted">Fetching book list from server…</p>';
const res = await fetch('/books/', {cache: 'no-store'});
if (!res.ok) {
listEl.innerHTML = `<p class="muted">Server error ${res.status} loading books.</p>`;
return;
}
const books = await res.json();
_evictBookCache(books); // fire-and-forget
if (!Array.isArray(books)) {
listEl.innerHTML = `<p class="muted">Unexpected response from server (not an array).</p>`;
return;
}
if (!books.length) {
listEl.innerHTML = '<p class="muted">No books yet. Drop an .epub or .pdf above.</p>';
return;
}
listEl.innerHTML = `<p class="muted">Found ${books.length} book(s) on server. Decrypting…</p>`;
let key;
try {
key = await getOrCreateEncKey();
} catch (e) {
listEl.innerHTML = `<p class="muted">Encryption not available: ${e.message}. Make sure you are on HTTPS.</p>`;
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: `Book #${b.id}`, author: '', type: 'epub', filename: '', folder: '', isbn: '', shelfTag: ''};
decrypted.push({id: b.id, title: `Book #${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 = `<p class="muted">Error loading books: ${e.message}</p>`;
}
}
}
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('Server error');
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('Server error');
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 ? '<span title="Wrong encryption key — import the correct key to open this book" style="color:var(--accent,#e63946);margin-left:4px;">⚠ wrong key</span>' : '';
const broken = _brokenBooks.has(b.id);
return `<div class="book-item" data-book-id="${b.id}">
<div class="book-item-info">
<strong class="book-title">${escapeHtml(b.title)}${keyWarning}${b.is_read ? ' <span class="muted book-read-badge">✓ gelesen</span>' : ''}</strong>
<span class="muted book-author">${escapeHtml(b.author)}</span>
<span class="book-item-meta-row">
${pct > 0 ? `<span class="muted book-progress">${pct}% read</span>` : ''}
${b.shelfTag ? `<span class="book-shelf-badge" title="Regal-Kategorie">${escapeHtml(b.shelfTag)}</span>` : ''}
</span>
</div>
<div class="book-item-actions">
<button class="btn btn-sm" onclick="openBook(${b.id})"${b.keyOk === false ? ' disabled title="Import the correct encryption key first"' : ''}>Open</button>
<div class="book-item-menu">
<button class="btn btn-sm book-menu-toggle" title="Weitere Optionen" onclick="toggleBookMenu(${b.id})">⋮</button>
<div class="book-item-menu-list" id="book-menu-${b.id}">
${broken ? `<button class="book-menu-item book-menu-item--danger book-broken-btn" onclick="repairBook(${b.id})">🔧 Reparieren (Datei erneut hochladen)</button>` : ''}
${b.has_highlights ? `<button class="book-menu-item" onclick="downloadBookAnnotationsFromList(${b.id}, this)">⭳ Markierungen &amp; Notizen herunterladen</button>` : ''}
<button class="book-menu-item" onclick="assignBookFolder(${b.id})">📁 Ordner zuweisen</button>
<button class="book-menu-item" onclick="lookupBookMetadata(${b.id})">🏷️ Metadaten abrufen (Regal)</button>
<button class="book-menu-item" onclick="toggleBookRead(${b.id}, ${!!b.is_read})">${b.is_read ? '↺ Als ungelesen markieren' : '✓ Als gelesen markieren'}</button>
<button class="book-menu-item book-menu-item--danger" onclick="deleteBook(${b.id})">🗑 Löschen</button>
</div>
</div>
</div>
</div>`;
}
function toggleBookMenu(bookId) {
const menu = document.getElementById(`book-menu-${bookId}`);
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" shelf, independent of folder navigation and the
// read/unread filter below — it's a shortcut back into whatever was open last.
const recent = _recentlyReadBooks(books);
if (recent.length) {
html += '<div class="book-recent-shelf">'
+ '<h3 class="book-recent-title">Zuletzt gelesen</h3>'
+ '<div class="book-recent-row">'
+ recent.map(b => `<button class="book-recent-item" onclick="openBook(${b.id})" title="${escapeHtml(b.title)}">
<span class="book-recent-item-title">${escapeHtml(b.title)}</span>
${b.author ? `<span class="book-recent-item-author">${escapeHtml(b.author)}</span>` : ''}
</button>`).join('')
+ '</div></div>';
}
const visible = _bookShowRead ? books : books.filter(b => !b.is_read);
if (!visible.length) {
html += books.length
? '<p class="muted">Keine ungelesenen Bücher. „Gelesene Bücher anzeigen“ aktivieren, um alle zu sehen.</p>'
: '';
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 += '<div class="book-folder-grid">'
+ folderNames.map(name => `<button class="book-folder-tile" data-folder="${escapeHtml(name)}">
<span class="book-folder-tile-icon">📁</span>
<span class="book-folder-tile-name">${escapeHtml(name)}</span>
<span class="book-folder-tile-count">${grouped.get(name).length}</span>
</button>`).join('')
+ '</div>';
}
html += (grouped.get('') || []).map(_renderBookItemHtml).join('');
} else {
html += `<div class="book-folder-header">
<button class="btn btn-sm" onclick="_openBookFolder(null)">← Alle Ordner</button>
<strong>${escapeHtml(_currentBookFolder)}</strong>
</div>`;
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('Server error');
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);
}
}
// 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.
async function lookupBookMetadata(bookId) {
const book = _lastBookListData.find(b => b.id === bookId);
if (!book) return;
const isbn = (bookMetaCache[bookId] || {}).isbn || book.isbn || '';
if (!isbn) {
await customAlert('Keine ISBN in den Metadaten dieses Buchs gefunden (nur bei neueren Uploads automatisch erkannt).');
return;
}
try {
const res = await fetch(`/books/metadata-lookup/?isbn=${encodeURIComponent(isbn)}`);
const data = await res.json();
if (!data.label) {
await customAlert('Keine Regal-Kategorie gefunden (weder bei der DNB noch bei Open Library).');
return;
}
await _updateBookMeta(bookId, {shelfTag: data.label});
renderBookList(_lastBookListData);
} 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 = 'Please enter your password.'; return; }
if (statusEl) statusEl.textContent = 'Deriving key…';
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 = '✓ Unlocked';
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 = 'Error: ' + err.message;
}
}
async function uploadEbook(file) {
const statusEl = $('book-upload-status');
const isPdf = /\.pdf$/i.test(file.name);
const isEpub = /\.epub$/i.test(file.name);
if (!isPdf && !isEpub) {
if (statusEl) statusEl.textContent = 'Only .epub and .pdf files are supported.';
return;
}
if (file.size > DIORA_CONFIG.ebookMaxBytes) {
if (statusEl) statusEl.textContent = `File too large (max ${DIORA_CONFIG.ebookMaxBytes / 1024 / 1024} MB).`;
return;
}
if (statusEl) statusEl.textContent = 'Encrypting…';
try {
const buf = await file.arrayBuffer();
let title = file.name.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: file.name, type, isbn, folder: '', shelfTag: ''}));
const [metaEnc, dataEnc] = await Promise.all([
encryptBytes(key, metaJson),
encryptBytes(key, buf),
]);
if (statusEl) statusEl.textContent = 'Uploading…';
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}" uploaded`;
loadBookList();
} else {
if (statusEl) statusEl.textContent = 'Error: ' + (data.error || 'upload failed');
}
} catch (e) {
if (statusEl) statusEl.textContent = 'Upload failed: ' + 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};
}
// ---------------------------------------------------------------------------
// 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')) 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);
}
async function openBook(bookId) {
const overlay = $('reader-overlay');
const contentEl = $('reader-content');
const titleEl = $('reader-title');
if (!overlay || !contentEl) return;
titleEl.textContent = 'Loading…';
contentEl.innerHTML = '';
overlay.style.display = '';
try {
loadReaderSettings(bookId);
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 = '<span class="pdf-loading-spinner"></span>';
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.
if (!isPdfBook && currentHighlights.length && !readerAnnotationsMarginOpen) {
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);
contentEl.scrollTop = (pct / 100) * contentEl.scrollHeight;
}
});
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) {
contentEl.scrollTop = fraction * (contentEl.scrollHeight - contentEl.clientHeight);
}
}
} 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;
} else {
const f = contentEl.scrollTop / (contentEl.scrollHeight - contentEl.clientHeight || 1);
progressInput.value = _formatBookPercent(f);
}
});
// 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);
}, 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 = '✓ Key copied to clipboard';
setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 3000);
} catch (e) {
if (statusEl) statusEl.textContent = 'Export failed: ' + e.message;
}
}
function showImportKey() {
const body = $('sidebar-body');
openSidebar('Import encryption key', `
<p class="muted">Paste the key exported from your other browser:</p>
<textarea id="import-key-input" class="search-input" rows="3" style="width:100%;resize:none;font-family:monospace;font-size:0.75rem;"></textarea>
<button class="btn" style="margin-top:8px;" data-import-key-apply>Apply</button>
<p class="muted" style="margin-top:8px;">This replaces the key in this browser. Books uploaded here won't be readable until you sync the key back.</p>
`);
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 = '✓ Key imported — reloading books…';
await loadBookList();
if (statusEl) setTimeout(() => { statusEl.textContent = ''; }, 3000);
} catch (e) {
if ($('book-key-status')) $('book-key-status').textContent = 'Import failed: invalid key';
}
});
}
async function deleteBook(bookId) {
if (!await customConfirm('Delete this book? This cannot be undone.', { 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);
}
fraction = Math.min(1.0, Math.max(0.0, fraction));
let anchor = '';
if (!currentPdfDoc) {
anchor = getPositionAnchor(contentEl);
_currentPositionAnchor = anchor;
}
_updateCachedBookProgress(currentBookId, fraction, anchor, force);
const body = JSON.stringify({scroll_fraction: fraction, position_anchor: anchor, ...(force && {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();
// 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('Table of Contents', '<p class="muted">No table of contents found in this book.</p>');
return;
}
let html = '<ul class="toc-list">';
for (const entry of currentBookToc) {
const indent = entry.depth * 14;
// Use data-toc-href — onclick would be stripped by sanitizeSidebarHtml
html += `<li style="padding-left:${indent}px">
<button class="btn-link toc-entry" data-toc-href="${escapeHtml(entry.href)}">${escapeHtml(entry.label)}</button>
</li>`;
}
html += '</ul>';
openSidebar('Table of Contents', 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
// ---------------------------------------------------------------------------
function loadReaderSettings(bookId) {
// Reset to defaults, then apply per-book overrides
Object.assign(readerSettings, { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark',
fontFamily: 'serif', noBold: false,
pdfZoom: 100, pdfInverted: false, pdfPaginated: false, pdfSpread: false });
try {
const saved = JSON.parse(localStorage.getItem(`diora_reader_settings_${bookId}`) || '{}');
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));
}
}
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: "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);
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();
}
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 = `
<label>Font <button class="btn btn-sm" id="rs-font-minus"></button> <input type="range" id="rs-font" min="12" max="24" step="1" value="${readerSettings.fontSize}"> <button class="btn btn-sm" id="rs-font-plus">+</button> <span id="rs-font-val">${readerSettings.fontSize}px</span></label>
<label>Line <button class="btn btn-sm" id="rs-line-minus"></button> <input type="range" id="rs-line" min="12" max="30" step="1" value="${Math.round(readerSettings.lineHeight * 10)}"> <button class="btn btn-sm" id="rs-line-plus">+</button> <span id="rs-line-val">${readerSettings.lineHeight}</span></label>
<label>Width <button class="btn btn-sm" id="rs-width-minus"></button> <input type="range" id="rs-width" min="40" max="90" step="5" value="${readerSettings.maxWidth}"> <button class="btn btn-sm" id="rs-width-plus">+</button> <span id="rs-width-val">${readerSettings.maxWidth}ch</span></label>
<button class="btn btn-sm" id="rs-width-full">Full</button>
<button class="btn btn-sm ${readerSettings.fontFamily === 'serif' ? 'active' : ''}" data-rs-font="serif">Serif</button>
<button class="btn btn-sm ${readerSettings.fontFamily === 'sans' ? 'active' : ''}" data-rs-font="sans">Sans</button>
<button class="btn btn-sm ${readerSettings.fontFamily === 'humanist' ? 'active' : ''}" data-rs-font="humanist">Verdana</button>
<button class="btn btn-sm ${readerSettings.fontFamily === 'mono' ? 'active' : ''}" data-rs-font="mono">Mono</button>
<button class="btn btn-sm ${readerSettings.noBold ? 'active' : ''}" id="rs-no-bold">Kein Fett</button>
<button class="btn btn-sm ${readerSettings.theme === 'dark' ? 'active' : ''}" data-rs-theme="dark">Dark</button>
<button class="btn btn-sm ${readerSettings.theme === 'sepia' ? 'active' : ''}" data-rs-theme="sepia">Sepia</button>
<button class="btn btn-sm ${readerSettings.theme === 'bright' ? 'active' : ''}" data-rs-theme="bright">Bright</button>
`;
} else {
panel.innerHTML = `
<label>Zoom <button class="btn btn-sm" id="rs-zoom-minus"></button> <input type="range" id="rs-zoom" min="50" max="200" step="10" value="${readerSettings.pdfZoom}"> <button class="btn btn-sm" id="rs-zoom-plus">+</button> <span id="rs-zoom-val">${readerSettings.pdfZoom}%</span></label>
<button class="btn btn-sm ${readerSettings.pdfInverted ? 'active' : ''}" id="rs-invert">Invert</button>
<button class="btn btn-sm ${readerSettings.pdfSpread ? 'active' : ''}" id="rs-spread">Spread</button>
`;
}
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();
});
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));
applyReaderSettings(false);
saveReaderSettings();
});
});
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 = `Page ${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 = `★ Bookmarked: ${label}`;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 2200);
}
function openBookmarksSidebar() {
if (!currentBookmarks.length) {
openSidebar('Bookmarks', '<p class="muted">No bookmarks yet. Press ★ while reading.</p>');
return;
}
let html = '<ul style="list-style:none;padding:0;">';
for (const bm of currentBookmarks) {
html += `<li class="bookmark-entry">
<button class="btn-link" data-jump-bookmark="${escapeHtml(bm.id)}" style="flex:1;text-align:left;">
${escapeHtml(bm.label)}
</button>
<button class="btn-icon" data-delete-bookmark="${escapeHtml(bm.id)}" title="Delete">✕</button>
</li>`;
}
html += '</ul>';
openSidebar('Bookmarks', 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 = `
<input type="text" id="reader-search-input" class="search-input" placeholder="Search…" style="width:160px;">
<button class="btn-icon" id="rs-search-prev" title="Previous">↑</button>
<button class="btn-icon" id="rs-search-next" title="Next">↓</button>
<span id="rs-search-count" class="muted"></span>
<button class="btn-icon" id="rs-search-clear" title="Close">✕</button>
`;
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 <mark> (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
// <a id="..."> 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 <a>".
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 = `
<button class="hl-color-btn" data-hl-color="yellow" style="background:#f1c40f" title="Yellow">A</button>
<button class="hl-color-btn" data-hl-color="green" style="background:#2ecc71" title="Green">A</button>
<button class="hl-color-btn" data-hl-color="blue" style="background:#3498db" title="Blue">A</button>
<button class="hl-color-btn" data-hl-color="red" style="background:#e63946" title="Red">A</button>
<button class="hl-note-btn" title="Add note">✎</button>
`;
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) : '<span class="muted">No note</span>';
popover.innerHTML = `
<div style="font-size:12px;padding-bottom:4px;">${noteText}</div>
<div style="display:flex;gap:6px;">
<button class="btn btn-sm" data-hl-edit-note="${escapeHtml(h?.id || '')}">Edit note</button>
<button class="btn btn-sm btn-danger" data-hl-delete="${escapeHtml(h?.id || '')}">Delete</button>
</div>
`;
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 = '<textarea class="margin-note-textarea"></textarea>';
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 = `
<div class="note-bottom-sheet-backdrop"></div>
<div class="note-bottom-sheet-panel">
<textarea class="note-bottom-sheet-textarea" placeholder="Notiz…"></textarea>
<div class="note-bottom-sheet-actions">
<button class="btn btn-sm" data-nbs-cancel>Abbrechen</button>
<button class="btn btn-sm btn-primary" data-nbs-save>Speichern</button>
</div>
</div>
`;
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('No highlights or notes in this book yet.'); 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('No highlights or notes in this book yet.'); 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 || `Book #${bookId}`;
const lines = [`${bookTitle} — Annotations`, `Exported: ${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 || 'Untitled section'} ──`);
lastChapter = chapterTitle;
}
if (h.type === 'note') {
lines.push(`[Note] near: "${(h.anchor?.quote || '').trim()}"`);
if (h.note) lines.push(` ${h.note}`);
} else {
lines.push(`[Highlight · ${h.color || 'yellow'}] "${(h.anchor?.quote || '').trim()}"`);
if (h.note) lines.push(` Note: ${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, '_')}-annotations.txt`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (e) {
await customAlert('Export failed: ' + 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 || !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;
}
function exportAnnotations() {
if (!currentHighlights.length) {
customAlert('No highlights or notes in this book yet.');
return;
}
const resolved = _sortedAnnotations();
const titleEl = $('reader-title');
const bookTitle = (titleEl?.textContent || 'Book').split(' — ')[0];
const lines = [`${bookTitle} — Annotations`, `Exported: ${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 || 'Untitled section'} ──`);
lastChapter = chapterTitle;
}
const pct = _percentFromRange(range);
const coord = pct !== null ? ` (≈${_formatBookPercent(pct / 100)}% im Buch)` : '';
if (h.type === 'note') {
lines.push(`[Note]${coord} near: "${(h.anchor?.quote || '').trim()}"`);
if (h.note) lines.push(` ${h.note}`);
} else {
lines.push(`[Highlight · ${h.color || 'yellow'}]${coord} "${(h.anchor?.quote || '').trim()}"`);
if (h.note) lines.push(` Note: ${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, '_')}-annotations.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('Highlights & Notes', '<p class="muted">No highlights or notes yet.</p>');
return;
}
const resolved = _sortedAnnotations();
let html = '<ul style="list-style:none;padding:0;">';
for (const {h, range} of resolved) {
const pct = _percentFromRange(range);
const pctLabel = pct !== null ? `${_formatBookPercent(pct / 100)}%` : '';
const kind = h.type === 'note' ? 'Note' : `Highlight${h.color ? ' · ' + h.color : ''}`;
const preview = h.note || h.anchor?.quote || '(no text)';
html += `<li class="bookmark-entry">
<button class="btn-link" data-jump-annotation="${escapeHtml(h.id)}" style="flex:1;text-align:left;">
<span class="muted" style="font-size:11px;">${escapeHtml(kind)} ${pctLabel}</span><br>
${escapeHtml(preview.slice(0, 80))}
</button>
<button class="btn-icon" data-delete-annotation="${escapeHtml(h.id)}" title="Delete">✕</button>
</li>`;
}
html += '</ul>';
openSidebar('Highlights & Notes', 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) : '— no station —';
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 => `<li><button class="btn btn-sm rsb-play-btn" data-url="${escapeHtml(r.dataset.url || '')}" data-name="${escapeHtml(r.dataset.name || '')}">${escapeHtml(r.dataset.name || '')}</button></li>`).join('')
: `<li class="muted">No saved stations.</li>`;
const html = `
<div class="rsb-nowplaying">
<div class="rsb-station-name">${stationName}</div>
${track ? `<div class="rsb-track muted">${track}</div>` : ''}
</div>
<div class="rsb-controls">
<button class="btn ${isPlaying ? 'playing' : ''}" id="rsb-playstop">${isPlaying ? '⏹ Stop' : '▶ Play'}</button>
<label class="rsb-vol">vol <input type="range" id="rsb-volume" min="0" max="255" value="${vol}"></label>
</div>
<ul class="rsb-station-list">${stationsHtml}</ul>
`;
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.
}
// Seed podcast feeds from server context
if (typeof INITIAL_PODCAST_FEEDS !== 'undefined' && Array.isArray(INITIAL_PODCAST_FEEDS)) {
podcastFeeds = INITIAL_PODCAST_FEEDS;
}
// Wire seek slider
const seekSlider = $('seek-slider');
if (seekSlider) {
seekSlider.addEventListener('input', function () {
if (podcastMode) audio.currentTime = parseInt(this.value, 10);
});
}
// 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);
}
// 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);
// Hourly background feed refresh (only when authenticated)
if (IS_AUTHENTICATED) {
setInterval(refreshAllFeeds, 60 * 60 * 1000);
}
// 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);
}
})();