diora-web/static/js/app.js
marwin c965da6891
All checks were successful
Build and push Docker image / build (push) Successful in 13s
Test / test (push) Successful in 15s
Fix USER_ID TDZ error in Firefox by using window.USER_ID
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 20:59:12 +01:00

3903 lines
132 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
const audio = new Audio();
// ---------------------------------------------------------------------------
// 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;');
}
// ---------------------------------------------------------------------------
// Play / Stop
// ---------------------------------------------------------------------------
function playStation(url, name, stationId) {
stopPlayback(false);
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);
}
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;
isPlaying = false;
podcastMode = false;
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) {
alert('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;
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
// ---------------------------------------------------------------------------
function editNotes(pk, current) {
const note = prompt('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'];
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);
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);
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 (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');
}
function addFeedByUrl() {
const url = prompt('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 btn-danger" onclick="removeFeed(${feed.id})">Remove</button>
</div>
`;
container.appendChild(div);
});
}
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>
</div>
`;
}
renderEpisodeList(episodes, feedId, listEl);
} catch (e) {
if (headerEl) headerEl.innerHTML = '<p class="muted">Failed to load episodes.</p>';
}
}
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` : '';
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="muted">${escapeHtml(dateStr)} · ${escapeHtml(dur)}${escapeHtml(posStr)}</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="${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) {
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;
// Media Session API — maps hardware media keys & lock-screen controls
if ('mediaSession' in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
title: title,
artist: (podcastFeeds.find(f => f.id === feedId) || {}).title || '',
});
navigator.mediaSession.setActionHandler('seekbackward', () => skipBack());
navigator.mediaSession.setActionHandler('seekforward', () => skipForward());
navigator.mediaSession.setActionHandler('play', () => { audio.play(); });
navigator.mediaSession.setActionHandler('pause', () => { audio.pause(); });
}
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½')));
});
}
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) {}
}
async function loadAndRenderInbox() {
const listEl = $('podcast-inbox-list');
if (!listEl) return;
listEl.innerHTML = '<p class="muted">Loading…</p>';
try {
const res = await fetch('/podcasts/inbox/');
const data = await res.json();
const episodes = data.episodes || [];
listEl.innerHTML = '';
if (!episodes.length) {
listEl.innerHTML = '<p class="muted">Inbox empty — all caught up!</p>';
return;
}
episodes.forEach(ep => {
podcastEpCache[ep.id] = {
id: ep.id,
title: ep.title,
audioUrl: ep.audio_url,
durationSeconds: ep.duration_seconds,
positionSeconds: 0,
feedId: ep['feed__id'],
played: false,
};
const div = document.createElement('div');
div.className = 'episode-item';
div.innerHTML = `
${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">${escapeHtml(ep.title)}</div>
<div class="muted">${escapeHtml(ep['feed__title'])} · ${ep.pub_date ? ep.pub_date.slice(0,10) : ''} · ${formatDuration(ep.duration_seconds)}</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="Add to queue">+Q</button>
<button class="btn btn-sm" onclick="downloadEpisodeById(${ep.id}, this)" title="Download">⬇</button>
</div>
`;
listEl.appendChild(div);
});
} catch (e) {
listEl.innerHTML = '<p class="muted">Failed to load inbox.</p>';
}
}
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: 0,
feedId: item['episode__feed__id'],
played: false,
};
const li = document.createElement('li');
li.className = 'episode-item';
li.innerHTML = `
<div class="episode-info">
<div class="episode-title">${escapeHtml(item['episode__title'])}</div>
<div class="muted">${escapeHtml(item['episode__feed__title'])} · ${formatDuration(item['episode__duration_seconds'])}</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>
`;
ol.appendChild(li);
});
} catch (e) {
ol.innerHTML = '<li class="muted">Failed to load queue.</li>';
}
}
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 removeFeed(feedId) {
if (!confirm('Remove this podcast?')) 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)) {
alert('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; }
alert('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); }
}
}
});
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', () => {
navigator.serviceWorker.register('/static/js/sw.js').catch(err => {
console.warn('Service worker registration failed:', err);
});
});
}
// ---------------------------------------------------------------------------
// 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 || 'anon'}`;
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 */ }
}
throw new Error('No encryption key found. Please log out and log in again to unlock encrypted content.');
}
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)) {
alert('Only JPEG, PNG, or WebP images are allowed.');
return;
}
if (file.size > 5 * 1024 * 1024) {
alert('Image must be 5 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
const imageMap = {};
for (const {href, mediaType} of Object.values(manifest)) {
if (mediaType.startsWith('image/')) {
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 and concatenate chapters
const spineItems = Array.from(opfDoc.querySelectorAll('spine > itemref'))
.map(ref => manifest[ref.getAttribute('idref')]?.href)
.filter(Boolean);
const parts = [];
for (let i = 0; i < spineItems.length; i++) {
const href = spineItems[i];
try {
const chapterText = await zip.file(href).async('text');
const chapterDir = href.includes('/') ? href.substring(0, href.lastIndexOf('/') + 1) : '';
const withBlobs = _injectImageBlobs(chapterText, chapterDir, imageMap);
const sanitized = sanitizeEpubHtml(withBlobs);
parts.push(`<div id="epub-chapter-${i}" data-epub-src="${href}">${sanitized}</div>`);
} catch (e) { /* skip missing */ }
}
return {title, author, html: parts.join('\n'), 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;
}
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')) 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 {
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;
const bookMetaCache = {}; // id → {title, author, type}
// Reader settings
let readerSettings = { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark',
pdfZoom: 100, pdfInverted: false, pdfPaginated: 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 _touchStartX = 0;
if (typeof pdfjsLib !== 'undefined') {
pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/js/pdf.worker.min.js';
}
const DEFAULT_FOCUS_STATION = {
url: 'https://ice5.somafm.com/groovesalad-128-aac',
name: 'SomaFM Groove Salad',
};
async function loadBookList() {
if (!IS_AUTHENTICATED) return;
const listEl = $('book-list');
if (!listEl) return;
listEl.innerHTML = '<p class="muted">Loading…</p>';
try {
const res = await fetch('/books/');
const books = await res.json();
if (!books.length) {
listEl.innerHTML = '<p class="muted">No books yet. Drop an .epub or .pdf above.</p>';
return;
}
const key = await getOrCreateEncKey();
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'};
decrypted.push({id: b.id, title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', scroll_fraction: b.scroll_fraction, uploaded_at: b.uploaded_at});
} catch (e) {
bookMetaCache[b.id] = {title: `Book #${b.id}`, author: '', type: 'epub'};
decrypted.push({id: b.id, title: `Book #${b.id}`, author: '', type: 'epub', scroll_fraction: b.scroll_fraction, uploaded_at: b.uploaded_at});
}
}
renderBookList(decrypted);
} catch (e) {
if (listEl) listEl.innerHTML = '<p class="muted">Failed to load books.</p>';
}
}
function renderBookList(books) {
const listEl = $('book-list');
if (!listEl) return;
let html = '';
for (const b of books) {
const pct = Math.round((b.scroll_fraction || 0) * 100);
html += `<div class="book-item">
<div class="book-item-info">
<strong class="book-title">${escapeHtml(b.title)}</strong>
<span class="muted book-author">${escapeHtml(b.author)}</span>
${pct > 0 ? `<span class="muted book-progress">${pct}% read</span>` : ''}
</div>
<div class="book-item-actions">
<button class="btn btn-sm" onclick="openBook(${b.id})">Open</button>
<button class="btn btn-sm btn-danger" onclick="deleteBook(${b.id})">Delete</button>
</div>
</div>`;
}
listEl.innerHTML = html;
}
function bookFileSelected(input) {
const file = input.files[0];
if (!file) return;
uploadEbook(file);
input.value = '';
}
function initBookDropZone() {
const zone = $('book-drop-zone');
// Prevent Firefox from opening dragged files when dropped outside the zone
document.addEventListener('dragover', e => e.preventDefault());
document.addEventListener('drop', e => {
if (!zone || !zone.contains(e.target)) e.preventDefault();
});
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 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 > 10 * 1024 * 1024) {
if (statusEl) statusEl.textContent = 'File too large (max 10 MB).';
return;
}
if (statusEl) statusEl.textContent = 'Encrypting…';
try {
const buf = await file.arrayBuffer();
let title = file.name.replace(/\.(epub|pdf)$/i, '');
let author = '';
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() || '';
}
} catch (e) { /* use filename as title */ }
}
const key = await getOrCreateEncKey();
const metaJson = new TextEncoder().encode(JSON.stringify({title, author, filename: file.name, type}));
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) {
const pdf = currentPdfDoc || await pdfjsLib.getDocument({data: new Uint8Array(arrayBuffer)}).promise;
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 containerWidth = contentEl.clientWidth - 32;
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
const page = await pdf.getPage(pageNum);
const naturalVp = page.getViewport({scale: 1});
const scale = scaleOverride != null ? scaleOverride
: Math.max(0.5, (containerWidth / 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}`;
// Inner container gives canvas + text layer a shared position:relative origin,
// independent of the outer flex wrapper's centering.
const inner = document.createElement('div');
inner.className = 'pdf-page-inner';
const canvas = document.createElement('canvas');
canvas.className = 'pdf-page';
canvas.width = viewport.width;
canvas.height = viewport.height;
inner.appendChild(canvas);
wrapper.appendChild(inner);
contentEl.appendChild(wrapper);
await page.render({canvasContext: canvas.getContext('2d'), viewport}).promise;
// Text layer disabled — re-enable once overlay rendering is resolved
}
pdfTotalPages = pdf.numPages;
return {title: pdfTitle, author: pdfAuthor, toc, numPages: pdf.numPages};
}
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();
const key = await getOrCreateEncKey();
const res = await fetch(`/books/${bookId}/data/`);
const {data_ct, data_iv} = await res.json();
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 result = await renderPdf(plain, contentEl);
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;
contentEl.innerHTML = result.html;
}
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);
// Enable PDF paginated mode if configured (auto on mobile)
if (isPdfBook && readerSettings.pdfPaginated) {
enterPdfPaginatedMode();
}
// Wire highlight selection listener for EPUB
if (!isPdfBook) {
contentEl.addEventListener('mouseup', handleReaderSelection);
}
// Swipe for PDF paginated
contentEl.addEventListener('touchstart', e => { _touchStartX = e.touches[0].clientX; }, {passive: true});
contentEl.addEventListener('touchend', e => {
if (!readerSettings.pdfPaginated) return;
const delta = e.changedTouches[0].clientX - _touchStartX;
if (delta > 50) pdfGoToPage(pdfCurrentPage - 1);
else if (delta < -50) pdfGoToPage(pdfCurrentPage + 1);
}, {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.min = 1;
progressInput.max = numPages;
progressInput.value = 1;
if (progressSuffix) progressSuffix.textContent = `/ ${numPages}`;
} else {
progressInput.min = 0;
progressInput.max = 100;
progressInput.value = 0;
if (progressSuffix) progressSuffix.textContent = '%';
}
progressInput.addEventListener('change', function () {
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, parseInt(this.value, 10) || 0));
this.value = pct;
contentEl.scrollTop = (pct / 100) * contentEl.scrollHeight;
}
});
progressInput.addEventListener('click', function () { this.select(); });
}
// Restore scroll position
try {
const progressRes = await fetch('/books/');
const allBooks = await progressRes.json();
const bookData = allBooks.find(b => b.id === bookId);
const fraction = bookData ? (bookData.scroll_fraction || 0) : 0;
if (fraction > 0) {
if (isPdf && readerSettings.pdfPaginated && pdfTotalPages > 1) {
pdfCurrentPage = Math.max(1, Math.round(fraction * (pdfTotalPages - 1)) + 1);
} else {
// For EPUB: wait for all images to load so scrollHeight is final
if (!isPdf) {
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; })
));
}
}
// One more rAF to let the browser recalculate layout after image load
await new Promise(r => requestAnimationFrame(r));
contentEl.scrollTop = fraction * (contentEl.scrollHeight - contentEl.clientHeight);
}
}
} catch (e) {}
// 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 = Math.round(f * 100);
}
});
// Auto-save progress every 10s and on scroll (debounced 2s)
readerScrollSaveTimer = setInterval(saveReaderProgress, 10000);
let _scrollDebounce = null;
contentEl.addEventListener('scroll', () => {
clearTimeout(_scrollDebounce);
_scrollDebounce = setTimeout(saveReaderProgress, 2000);
}, {passive: true});
// Determine which station to play (null = use default, {url:''} = disabled)
const focusStation = USER_FOCUS_STATION === null ? DEFAULT_FOCUS_STATION
: (USER_FOCUS_STATION.url ? USER_FOCUS_STATION : null);
if (focusStation) {
if (isPlaying) {
// Don't interrupt — highlight button, play on click instead
const btn = $('focus-station-btn');
if (btn) {
btn.classList.add('focus-pending');
btn.title = `Click to play focus station: ${focusStation.name}`;
btn._pendingFocusStation = focusStation;
btn.onclick = function () {
playStation(focusStation.url, focusStation.name, null);
btn.classList.remove('focus-pending');
btn.title = 'Focus station';
btn._pendingFocusStation = null;
btn.onclick = openFocusStationSidebar;
};
}
} else {
playStation(focusStation.url, focusStation.name, null);
}
}
} catch (e) {
contentEl.innerHTML = `<p class="muted">Failed to open book: ${escapeHtml(e.message)}</p>`;
}
}
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 (!confirm('Delete this book? This cannot be undone.')) 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() {
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));
// Cache for sendBeacon on unload
_lastProgressBeacon = {
url: `/books/${currentBookId}/progress/`,
body: JSON.stringify({scroll_fraction: fraction}),
};
try {
await fetch(`/books/${currentBookId}/progress/`, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: _lastProgressBeacon.body,
});
} catch (e) {}
}
function closeReader() {
// 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;
}
// 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 = '';
// Free image blob URLs
const contentEl = $('reader-content');
if (contentEl) contentEl.innerHTML = '';
for (const url of Object.values(currentImageMap)) URL.revokeObjectURL(url);
currentImageMap = {};
// Reset all state
currentBookId = null;
currentBookToc = [];
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');
// Clear any pending focus station highlight
const btn = $('focus-station-btn');
if (btn && btn._pendingFocusStation) {
btn.classList.remove('focus-pending');
btn.title = 'Focus station';
btn._pendingFocusStation = null;
btn.onclick = openFocusStationSidebar;
}
}
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();
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() {
try {
const saved = JSON.parse(localStorage.getItem('diora_reader_settings') || '{}');
Object.assign(readerSettings, saved);
// Auto-paginate on mobile if not explicitly set
if (saved.pdfPaginated === undefined) {
readerSettings.pdfPaginated = window.innerWidth < 768;
}
} catch (e) {}
}
function saveReaderSettings() {
localStorage.setItem('diora_reader_settings', 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');
}
// 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');
}
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 <input type="range" id="rs-font" min="12" max="24" step="1" value="${readerSettings.fontSize}"> <span id="rs-font-val">${readerSettings.fontSize}px</span></label>
<label>Line <input type="range" id="rs-line" min="12" max="30" step="1" value="${Math.round(readerSettings.lineHeight * 10)}"> <span id="rs-line-val">${readerSettings.lineHeight}</span></label>
<label>Width <input type="range" id="rs-width" min="40" max="90" step="5" value="${readerSettings.maxWidth}"> <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.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 <input type="range" id="rs-zoom" min="50" max="200" step="10" value="${readerSettings.pdfZoom}"> <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.pdfPaginated ? 'active' : ''}" id="rs-paginated">Paginated</button>
`;
}
overlay.insertBefore(panel, contentEl);
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();
});
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();
});
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-full').addEventListener('click', () => {
readerSettings.maxWidth = 999;
widthRange.value = 90;
widthVal.textContent = 'full';
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');
const zoomVal = panel.querySelector('#rs-zoom-val');
zoomRange.addEventListener('change', () => {
readerSettings.pdfZoom = parseInt(zoomRange.value, 10);
zoomVal.textContent = readerSettings.pdfZoom + '%';
saveReaderSettings();
reRenderPdf();
});
panel.querySelector('#rs-invert').addEventListener('click', function () {
readerSettings.pdfInverted = !readerSettings.pdfInverted;
this.classList.toggle('active', readerSettings.pdfInverted);
applyReaderSettings(true);
saveReaderSettings();
});
panel.querySelector('#rs-paginated').addEventListener('click', function () {
readerSettings.pdfPaginated = !readerSettings.pdfPaginated;
this.classList.toggle('active', readerSettings.pdfPaginated);
saveReaderSettings();
if (readerSettings.pdfPaginated) {
enterPdfPaginatedMode();
} else {
exitPdfPaginatedMode();
}
});
}
}
async function reRenderPdf() {
if (!currentPdfBuffer) return;
const contentEl = $('reader-content');
if (!contentEl) return;
currentPdfDoc = null; // force re-parse with same buffer
await renderPdf(currentPdfBuffer, contentEl);
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 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);
const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper');
wrappers.forEach(w => {
w.style.display = '';
const canvas = w.querySelector('canvas');
if (canvas) canvas.style.transform = '';
});
}
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 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
);
// 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) {
const key = await getOrCreateEncKey();
const plain = await decryptBytes(key, iv, ct);
currentBookmarks = JSON.parse(new TextDecoder().decode(plain));
} else {
currentBookmarks = [];
}
} catch (e) {
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};
await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body,
});
bookmarksDirty = false;
} catch (e) {}
}
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();
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, contentEl);
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;
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) {
const key = await getOrCreateEncKey();
const plain = await decryptBytes(key, iv, ct);
currentHighlights = JSON.parse(new TextDecoder().decode(plain));
} else {
currentHighlights = [];
}
applyHighlightsToContent();
} catch (e) {
currentHighlights = [];
}
}
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};
await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body,
});
highlightsDirty = false;
} catch (e) {}
}
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) {}
}
}
function renderHighlight(h) {
const contentEl = $('reader-content');
if (!contentEl || !h.anchor) return;
const chapterEl = contentEl.querySelector(`[data-epub-src="${CSS.escape(h.anchor.chapterSrc || '')}"]`)
|| contentEl;
let range = null;
try {
const startNode = xpathToNode(h.anchor.startXpath, chapterEl);
const endNode = xpathToNode(h.anchor.endXpath, chapterEl);
if (startNode && endNode) {
range = document.createRange();
range.setStart(startNode, h.anchor.startOffset);
range.setEnd(endNode, h.anchor.endOffset);
}
} catch (e) {}
// Fallback: quote substring search
if (!range && h.anchor.quote) {
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;
}
}
}
if (!range) return;
try {
const mark = document.createElement('mark');
mark.className = 'epub-highlight';
mark.dataset.highlightId = h.id;
mark.dataset.color = h.color || 'yellow';
range.surroundContents(mark);
} catch (e) {}
}
function xpathToNode(xpath, root) {
if (!xpath) return null;
const result = document.evaluate(xpath, root, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
return result.singleNodeValue;
}
function getXPathForNode(node, root) {
const parts = [];
let current = node;
while (current && current !== root) {
const parent = current.parentNode;
if (!parent) break;
if (current.nodeType === Node.TEXT_NODE) {
const siblings = Array.from(parent.childNodes).filter(n => n.nodeType === Node.TEXT_NODE);
const idx = siblings.indexOf(current);
parts.unshift(`text()[${idx + 1}]`);
} else {
const siblings = Array.from(parent.children).filter(n => n.tagName === current.tagName);
const idx = siblings.indexOf(current);
parts.unshift(`${current.tagName.toLowerCase()}[${idx + 1}]`);
}
current = parent;
}
return parts.join('/');
}
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 root = chapterEl || contentEl;
return {
type: 'epub',
chapterSrc: chapterEl?.getAttribute('data-epub-src') || '',
startXpath: getXPathForNode(range.startContainer, root),
startOffset: range.startOffset,
endXpath: getXPathForNode(range.endContainer, root),
endOffset: range.endOffset,
quote: range.toString().slice(0, 200),
};
}
function handleReaderSelection(e) {
// If clicking an existing highlight, show tooltip
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;
}
dismissHighlightPopover();
const sel = window.getSelection();
if (!sel || sel.isCollapsed || !sel.rangeCount) return;
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);
}
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.top = (rect.top + window.scrollY - 44) + 'px';
popover.style.left = (rect.left + window.scrollX + rect.width / 2 - 70) + 'px';
document.body.appendChild(popover);
currentHighlightPopover = popover;
// 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.top = (rect.bottom + window.scrollY + 4) + 'px';
popover.style.left = (rect.left + window.scrollX) + 'px';
document.body.appendChild(popover);
currentHighlightPopover = popover;
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(),
anchor,
color,
note: '',
createdAt: new Date().toISOString(),
};
currentHighlights.push(h);
highlightsDirty = true;
window.getSelection()?.removeAllRanges();
dismissHighlightPopover();
renderHighlight(h);
debounceSaveHighlights();
}
function createHighlightWithNote(range) {
const anchor = buildEpubAnchor(range);
const h = {
id: crypto.randomUUID(),
anchor,
color: 'yellow',
note: '',
createdAt: new Date().toISOString(),
};
currentHighlights.push(h);
highlightsDirty = true;
window.getSelection()?.removeAllRanges();
dismissHighlightPopover();
renderHighlight(h);
openNoteEditor(h);
}
function openNoteEditor(h) {
openSidebar('Edit note', `
<textarea id="hl-note-input" class="search-input" rows="5" style="width:100%;resize:vertical;">${escapeHtml(h.note || '')}</textarea>
<button class="btn" style="margin-top:8px;" data-save-note="${escapeHtml(h.id)}">Save note</button>
`);
const body = $('sidebar-body');
body.addEventListener('click', function _noteClick(e) {
const btn = e.target.closest('[data-save-note]');
if (!btn) return;
body.removeEventListener('click', _noteClick);
const text = (body.querySelector('#hl-note-input')?.value || '').trim();
h.note = text;
highlightsDirty = true;
debounceSaveHighlights();
closeSidebar();
});
}
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);
}
}
debounceSaveHighlights();
}
function dismissHighlightPopover() {
if (currentHighlightPopover) {
currentHighlightPopover.remove();
currentHighlightPopover = null;
}
}
// ---------------------------------------------------------------------------
// Focus station sidebar
// ---------------------------------------------------------------------------
const FOCUS_STATION_PRESETS = [
{name: 'None (no station)', url: ''},
{name: 'SomaFM Groove Salad', url: 'https://ice5.somafm.com/groovesalad-128-aac'},
{name: 'SomaFM Deep Space One', url: 'https://ice5.somafm.com/deepspaceone-128-aac'},
{name: 'SomaFM Drone Zone', url: 'https://ice5.somafm.com/dronezone-128-aac'},
{name: 'SomaFM Space Station', url: 'https://ice5.somafm.com/spacestation-128-aac'},
{name: 'Linn Jazz', url: 'http://radio.linnrecords.com/linnjazz.pls'},
];
function openFocusStationSidebar() {
// null = never saved (default active); {url:''} = disabled; {url:'...'} = custom
const effectiveUrl = USER_FOCUS_STATION === null ? DEFAULT_FOCUS_STATION.url : (USER_FOCUS_STATION.url || '');
const currentName = USER_FOCUS_STATION === null ? DEFAULT_FOCUS_STATION.name
: (USER_FOCUS_STATION.name || 'None (no station)');
let presetsHtml = FOCUS_STATION_PRESETS.map((p, i) => {
const active = p.url === effectiveUrl ? ' class="focus-preset-active"' : '';
return `<li${active}><button class="btn btn-sm" data-focus-preset="${i}">${escapeHtml(p.name)}</button></li>`;
}).join('');
const html = `
<p class="muted">Station played when opening a book.</p>
<p><strong>Current:</strong> ${escapeHtml(currentName)}</p>
<ul class="focus-preset-list">${presetsHtml}</ul>
<div class="focus-custom-input">
<input type="text" id="focus-custom-name" class="search-input" placeholder="Station name" value="${escapeHtml(effectiveUrl ? currentName : '')}">
<input type="text" id="focus-custom-url" class="search-input" placeholder="Stream URL" value="${escapeHtml(effectiveUrl)}">
<button class="btn" data-focus-save="1">Save</button>
<button class="btn btn-sm" data-focus-play="1">Play Now</button>
</div>
`;
openSidebar('Focus Station', html);
const body = $('sidebar-body');
body.addEventListener('click', function _focusClick(e) {
const presetBtn = e.target.closest('[data-focus-preset]');
const saveBtn = e.target.closest('[data-focus-save]');
const playBtn = e.target.closest('[data-focus-play]');
if (presetBtn) {
const preset = FOCUS_STATION_PRESETS[parseInt(presetBtn.dataset.focusPreset, 10)];
if (preset) saveFocusStation(preset.url, preset.name);
body.removeEventListener('click', _focusClick);
} else if (saveBtn) {
const url = (body.querySelector('#focus-custom-url')?.value || '').trim();
const name = (body.querySelector('#focus-custom-name')?.value || '').trim();
saveFocusStation(url, name);
body.removeEventListener('click', _focusClick);
} else if (playBtn) {
const url = (body.querySelector('#focus-custom-url')?.value || '').trim();
const name = (body.querySelector('#focus-custom-name')?.value || '').trim();
if (url) playStation(url, name, null);
}
});
}
async function saveFocusStation(url, name) {
url = (url || '').trim();
name = (name || '').trim();
if (!IS_AUTHENTICATED) {
USER_FOCUS_STATION = {url, name};
closeSidebar();
return;
}
try {
const res = await fetch('/accounts/focus-station/', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify({url, name}),
});
const data = await res.json();
if (data.ok) {
USER_FOCUS_STATION = {url, name};
closeSidebar();
}
} catch (e) {}
}
// ---------------------------------------------------------------------------
// 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();
// Restore last active tab
const savedTab = localStorage.getItem('diora_active_tab') || 'radio';
const savedRadioTab = localStorage.getItem('diora_active_radio_tab') || 'saved';
showTab(savedTab);
showRadioTab(savedRadioTab);
})();