Scroll position was restored before browser finished layout, so scrollHeight was still wrong. Now waits two animation frames after reRenderPdf completes before restoring position. Also shows loading overlay during zoom re-renders, not just on initial load. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
4702 lines
164 KiB
JavaScript
4702 lines
164 KiB
JavaScript
/**
|
||
* diora — radio player
|
||
* Handles playback, SSE metadata, search, station management, and affiliate links.
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// State
|
||
// ---------------------------------------------------------------------------
|
||
|
||
let currentStation = null; // { url, name, id } | null
|
||
let currentTrack = '';
|
||
let sseSource = null;
|
||
let isPlaying = false;
|
||
let currentPlayId = null;
|
||
|
||
// Podcast state
|
||
let podcastMode = false;
|
||
let currentEpisode = null; // {id, title, audioUrl, durationSeconds, feedId}
|
||
let seekSaveTimer = null;
|
||
let podcastFeeds = [];
|
||
let podcastQueue = [];
|
||
let podcastCurrentView = 'feeds';
|
||
let podcastCurrentFeedId = null;
|
||
const podcastEpCache = {}; // id → episode data, avoids encoding strings in onclick attrs
|
||
|
||
let sleepTimerInterval = null;
|
||
let sleepTimerEndSecs = 0;
|
||
let sleepTimerEndOfEp = false;
|
||
let _dragSrcEl = null;
|
||
let feedSortOrder = 'alpha';
|
||
|
||
const audio = new Audio();
|
||
|
||
// Reconnect audio when the output device changes (e.g. Bluetooth, USB DAC)
|
||
if (navigator.mediaDevices) {
|
||
let _deviceChangeDebounce = null;
|
||
navigator.mediaDevices.addEventListener('devicechange', () => {
|
||
clearTimeout(_deviceChangeDebounce);
|
||
_deviceChangeDebounce = setTimeout(() => {
|
||
if (!isPlaying || audio.src === '') return;
|
||
const savedTime = audio.currentTime;
|
||
const savedSrc = audio.src;
|
||
audio.src = savedSrc;
|
||
audio.load();
|
||
if (savedTime > 0) {
|
||
audio.addEventListener('loadedmetadata', function onMeta() {
|
||
audio.currentTime = savedTime;
|
||
audio.removeEventListener('loadedmetadata', onMeta);
|
||
});
|
||
}
|
||
audio.play().catch(() => {});
|
||
}, 500);
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// DOM helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function $(id) { return document.getElementById(id); }
|
||
|
||
function getCsrfToken() {
|
||
const cookie = document.cookie.split('; ').find(r => r.startsWith('csrftoken='));
|
||
return cookie ? cookie.split('=')[1] : '';
|
||
}
|
||
|
||
function formatDateTime(iso) {
|
||
if (!iso) return '';
|
||
const d = new Date(iso);
|
||
const pad = n => String(n).padStart(2, '0');
|
||
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} `
|
||
+ `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||
}
|
||
|
||
function escapeHtml(str) {
|
||
return String(str)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Play / Stop
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function playStation(url, name, stationId) {
|
||
stopPlayback(false);
|
||
|
||
if (location.protocol === 'https:' && url.startsWith('http://')) {
|
||
window.open(url, '_blank');
|
||
return;
|
||
}
|
||
|
||
currentStation = { url, name, id: stationId || null };
|
||
isPlaying = true;
|
||
|
||
audio.src = url;
|
||
const volSlider = document.getElementById('volume');
|
||
if (volSlider) audio.volume = volSlider.value / 255;
|
||
audio.play().catch(() => {
|
||
// Browser may block autoplay; the user needs to interact first
|
||
console.warn('Audio play blocked by browser policy.');
|
||
});
|
||
|
||
$('now-playing-station').textContent = name;
|
||
$('now-playing-track').textContent = '';
|
||
$('play-stop-btn').style.display = '';
|
||
$('play-stop-btn').textContent = '⏹ Stop';
|
||
$('play-stop-btn').classList.add('playing');
|
||
$('save-station-btn').style.display = '';
|
||
|
||
startMetadataSSE(url);
|
||
startPlaySession(name, url);
|
||
maybeShowDonationHint(url, name);
|
||
|
||
if ('mediaSession' in navigator) {
|
||
navigator.mediaSession.metadata = new MediaMetadata({title: name, artist: 'Radio'});
|
||
navigator.mediaSession.setActionHandler('play', () => { audio.play(); isPlaying = true; });
|
||
navigator.mediaSession.setActionHandler('pause', () => { audio.pause(); isPlaying = false; });
|
||
navigator.mediaSession.setActionHandler('stop', () => stopPlayback(true));
|
||
try { navigator.mediaSession.setActionHandler('seekbackward', null); } catch (_) {}
|
||
try { navigator.mediaSession.setActionHandler('seekforward', null); } catch (_) {}
|
||
try { navigator.mediaSession.setActionHandler('nexttrack', null); } catch (_) {}
|
||
try { navigator.mediaSession.setActionHandler('previoustrack',null); } catch (_) {}
|
||
navigator.mediaSession.playbackState = 'playing';
|
||
}
|
||
}
|
||
|
||
function stopPlayback(clearStation = true) {
|
||
// Save podcast progress before stopping
|
||
if (podcastMode && currentEpisode) {
|
||
savePodcastProgress();
|
||
}
|
||
if (seekSaveTimer) { clearInterval(seekSaveTimer); seekSaveTimer = null; }
|
||
|
||
audio.pause();
|
||
audio.src = '';
|
||
audio.ontimeupdate = null;
|
||
audio.onended = null;
|
||
audio.onerror = null;
|
||
isPlaying = false;
|
||
podcastMode = false;
|
||
if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'none';
|
||
|
||
const seekBar = $('podcast-seek-bar');
|
||
if (seekBar) seekBar.style.display = 'none';
|
||
|
||
if (sseSource) {
|
||
sseSource.close();
|
||
sseSource = null;
|
||
}
|
||
|
||
$('play-stop-btn').textContent = '▶ Play';
|
||
$('play-stop-btn').classList.remove('playing');
|
||
$('save-station-btn').style.display = 'none';
|
||
$('affiliate-section').style.display = 'none';
|
||
|
||
stopPlaySession();
|
||
|
||
const stationEl = $('now-playing-station');
|
||
stationEl.classList.remove('podcast-station-link');
|
||
stationEl.onclick = null;
|
||
|
||
const trackEl = $('now-playing-track');
|
||
trackEl.classList.remove('podcast-track-link');
|
||
trackEl.onclick = null;
|
||
|
||
if (clearStation) {
|
||
currentStation = null;
|
||
currentTrack = '';
|
||
stationEl.textContent = '— no station —';
|
||
trackEl.textContent = '';
|
||
$('play-stop-btn').style.display = 'none';
|
||
}
|
||
}
|
||
|
||
function togglePlayStop() {
|
||
if (isPlaying) {
|
||
stopPlayback(true);
|
||
} else if (currentStation) {
|
||
playStation(currentStation.url, currentStation.name, currentStation.id);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Play session tracking
|
||
// ---------------------------------------------------------------------------
|
||
|
||
async function startPlaySession(stationName, stationUrl) {
|
||
try {
|
||
const res = await fetch('/radio/play/start/', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
||
body: JSON.stringify({station_name: stationName, station_url: stationUrl})
|
||
});
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
currentPlayId = data.play_id;
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
async function stopPlaySession() {
|
||
if (!currentPlayId) return;
|
||
try {
|
||
await fetch('/radio/play/stop/', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
||
body: JSON.stringify({play_id: currentPlayId})
|
||
});
|
||
} catch (e) {}
|
||
currentPlayId = null;
|
||
}
|
||
|
||
window.addEventListener('beforeunload', () => {
|
||
if (currentPlayId) {
|
||
navigator.sendBeacon('/radio/play/stop/', JSON.stringify({play_id: currentPlayId}));
|
||
}
|
||
if (podcastMode && currentEpisode) {
|
||
navigator.sendBeacon('/podcasts/progress/save/', JSON.stringify({
|
||
episode_id: currentEpisode.id,
|
||
position_seconds: Math.floor(audio.currentTime),
|
||
}));
|
||
}
|
||
// Flush cached encrypted payloads for reader data (encryption is async so we
|
||
// use pre-computed blobs stored by the debounced savers)
|
||
if (_lastProgressBeacon) navigator.sendBeacon(_lastProgressBeacon.url, _lastProgressBeacon.body);
|
||
if (_lastBookmarkBeacon) navigator.sendBeacon(_lastBookmarkBeacon.url, _lastBookmarkBeacon.body);
|
||
if (_lastHighlightBeacon) navigator.sendBeacon(_lastHighlightBeacon.url, _lastHighlightBeacon.body);
|
||
});
|
||
|
||
document.addEventListener('visibilitychange', () => {
|
||
if (document.visibilityState === 'hidden' && currentBookId) {
|
||
if (bookmarksDirty) saveBookmarks();
|
||
if (highlightsDirty) saveHighlights();
|
||
saveReaderProgress();
|
||
}
|
||
});
|
||
|
||
// Cached beacon payloads — updated after each successful encrypt in save functions
|
||
let _lastBookmarkBeacon = null;
|
||
let _lastHighlightBeacon = null;
|
||
let _lastProgressBeacon = null;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Recommendations
|
||
// ---------------------------------------------------------------------------
|
||
|
||
async function loadRecommendations() {
|
||
const container = document.getElementById('recommendations');
|
||
if (!container) return;
|
||
try {
|
||
const res = await fetch('/radio/recommendations/');
|
||
const data = await res.json();
|
||
if (!data.recommendations.length) {
|
||
container.innerHTML = '<p class="muted">Play more stations to get recommendations.</p>';
|
||
return;
|
||
}
|
||
const label = data.context;
|
||
let html = `<p class="recommendations-context">Based on your ${label} listening:</p><ul class="recommendations-list">`;
|
||
for (const r of data.recommendations) {
|
||
html += `<li>
|
||
<button class="btn btn-sm" onclick="playStation('${escapeAttr(r.station_url)}', '${escapeAttr(r.station_name)}', ${r.saved_id || 'null'})">
|
||
▶ ${escapeHtml(r.station_name)}
|
||
</button>
|
||
<span class="muted">${r.play_count}×</span>
|
||
</li>`;
|
||
}
|
||
html += '</ul>';
|
||
container.innerHTML = html;
|
||
} catch (e) {}
|
||
}
|
||
|
||
function escapeAttr(s) {
|
||
return String(s).replace(/'/g, "\\'").replace(/"/g, '"');
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Volume
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function setVolume(val) {
|
||
val = Math.max(0, Math.min(255, Math.round(val)));
|
||
audio.volume = val / 255;
|
||
localStorage.setItem('diora_volume', val);
|
||
const slider = $('volume');
|
||
const numInput = $('volume-num');
|
||
if (slider) slider.value = val;
|
||
if (numInput) numInput.value = val;
|
||
}
|
||
|
||
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 || '',
|
||
})})'>
|
||
▶ 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">★</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})">
|
||
▶ 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">★ 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 ${feed.auto_queue ? 'active' : ''}" onclick="toggleFeedAutoQueue(${feed.id}, this)" title="${feed.auto_queue ? 'Auto-queue ON' : 'Auto-queue new episodes'}">⚡Q</button>
|
||
<button class="btn btn-sm btn-danger" onclick="removeFeed(${feed.id})">Remove</button>
|
||
</div>
|
||
`;
|
||
container.appendChild(div);
|
||
});
|
||
|
||
const filterVal = ($('feed-filter-input') || {}).value || '';
|
||
if (filterVal) filterFeeds(filterVal);
|
||
}
|
||
|
||
function filterFeeds(query) {
|
||
const container = $('podcast-feed-list');
|
||
if (!container) return;
|
||
const q = query.toLowerCase().trim();
|
||
container.querySelectorAll('.podcast-feed-item').forEach(item => {
|
||
const title = (item.querySelector('.podcast-feed-title') || {}).textContent?.toLowerCase() || '';
|
||
const author = (item.querySelector('.muted') || {}).textContent?.toLowerCase() || '';
|
||
item.style.display = (!q || title.includes(q) || author.includes(q)) ? '' : 'none';
|
||
});
|
||
}
|
||
|
||
function sortFeeds(order) {
|
||
feedSortOrder = order;
|
||
if (order === 'alpha') podcastFeeds.sort((a, b) => a.title.localeCompare(b.title));
|
||
if (order === 'alpha-desc') podcastFeeds.sort((a, b) => b.title.localeCompare(a.title));
|
||
if (order === 'added') podcastFeeds.sort((a, b) => (b.added_at || '').localeCompare(a.added_at || ''));
|
||
if (order === 'latest_episode') podcastFeeds.sort((a, b) => (b.latest_episode_at || '').localeCompare(a.latest_episode_at || ''));
|
||
renderFeedList();
|
||
}
|
||
|
||
async function toggleFeedAutoQueue(feedId, btn) {
|
||
try {
|
||
const res = await fetch(`/podcasts/feeds/${feedId}/set-auto-queue/`, {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
||
body: JSON.stringify({}),
|
||
});
|
||
const data = await res.json();
|
||
if (data.ok) {
|
||
const feed = podcastFeeds.find(f => f.id === feedId);
|
||
if (feed) feed.auto_queue = data.auto_queue;
|
||
btn.classList.toggle('active', data.auto_queue);
|
||
btn.title = data.auto_queue ? 'Auto-queue ON' : 'Auto-queue new episodes';
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
async function openFeed(feedId) {
|
||
podcastCurrentFeedId = feedId;
|
||
showPodcastView('episodes');
|
||
const headerEl = $('podcast-feed-header');
|
||
const listEl = $('podcast-episode-list');
|
||
if (headerEl) headerEl.innerHTML = '<p class="muted">Loading…</p>';
|
||
if (listEl) listEl.innerHTML = '';
|
||
|
||
try {
|
||
const res = await fetch(`/podcasts/feeds/${feedId}/episodes/`);
|
||
const data = await res.json();
|
||
const feed = data.feed;
|
||
const episodes = data.episodes || [];
|
||
|
||
if (headerEl) {
|
||
headerEl.innerHTML = `
|
||
<div class="podcast-feed-header-inner">
|
||
${feed.artwork_url ? `<img class="podcast-thumb-lg" src="${escapeHtml(feed.artwork_url)}" alt="">` : ''}
|
||
<div>
|
||
<div class="podcast-feed-title">${escapeHtml(feed.title)}</div>
|
||
${feed.author ? `<div class="muted">${escapeHtml(feed.author)}</div>` : ''}
|
||
</div>
|
||
<button class="btn btn-sm feed-refresh-btn" id="feed-refresh-btn" onclick="refreshOpenFeed(this)" title="Refresh feed">↻ Refresh</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
renderEpisodeList(episodes, feedId, listEl);
|
||
const filterBar = $('episode-search-bar');
|
||
if (filterBar) { filterBar.style.display = ''; $('episode-filter-input').value = ''; }
|
||
} catch (e) {
|
||
if (headerEl) headerEl.innerHTML = '<p class="muted">Failed to load episodes.</p>';
|
||
}
|
||
}
|
||
|
||
function filterEpisodes(query) {
|
||
const listEl = $('podcast-episode-list');
|
||
if (!listEl) return;
|
||
const q = query.toLowerCase().trim();
|
||
listEl.querySelectorAll('.episode-item').forEach(item => {
|
||
const text = (item.querySelector('.episode-title') || {}).textContent?.toLowerCase() || '';
|
||
item.style.display = (!q || text.includes(q)) ? '' : 'none';
|
||
});
|
||
}
|
||
|
||
function renderEpisodeList(episodes, feedId, container) {
|
||
if (!container) return;
|
||
if (!episodes.length) {
|
||
container.innerHTML = '<p class="muted">No episodes found.</p>';
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = '';
|
||
episodes.forEach(ep => {
|
||
// Cache episode data by id so onclick attrs only need the id (avoids encoding
|
||
// strings with quotes inside HTML attributes which breaks the attribute parser)
|
||
podcastEpCache[ep.id] = {
|
||
id: ep.id,
|
||
title: ep.title,
|
||
description: ep.description || '',
|
||
audioUrl: ep.audio_url,
|
||
durationSeconds: ep.duration_seconds,
|
||
positionSeconds: ep.position_seconds || 0,
|
||
feedId: feedId || 0,
|
||
played: ep.played,
|
||
};
|
||
|
||
const div = document.createElement('div');
|
||
div.className = 'episode-item' + (ep.played ? ' episode-played' : '');
|
||
div.id = `episode-item-${ep.id}`;
|
||
|
||
const artSrc = ep.artwork_url || (feedId ? (podcastFeeds.find(f => f.id === feedId) || {}).artwork_url || '' : '');
|
||
const dur = formatDuration(ep.duration_seconds);
|
||
const dateStr = ep.pub_date ? ep.pub_date.slice(0, 10) : '';
|
||
const posStr = ep.position_seconds > 0 ? formatDuration(ep.position_seconds) + ' played' : '';
|
||
const progressPct = (ep.duration_seconds > 0 && ep.position_seconds > 0)
|
||
? Math.min(100, (ep.position_seconds / ep.duration_seconds) * 100) : 0;
|
||
|
||
div.innerHTML = `
|
||
${artSrc ? `<img class="podcast-thumb" src="${escapeHtml(artSrc)}" alt="">` : '<div class="podcast-thumb-placeholder"></div>'}
|
||
<div class="episode-info">
|
||
<div class="episode-title ep-clickable" onclick="openEpisodeSidebar(${ep.id})" title="Show notes">${escapeHtml(ep.title)}</div>
|
||
<div class="episode-meta">
|
||
${dateStr ? `<span class="episode-date">${escapeHtml(dateStr)}</span>` : ''}
|
||
${dur !== '0:00' ? `<span class="episode-dur">${escapeHtml(dur)}</span>` : ''}
|
||
${posStr ? `<span class="episode-pos muted">${escapeHtml(posStr)}</span>` : ''}
|
||
</div>
|
||
${progressPct > 0 ? `<div class="episode-progress-bar"><div class="episode-progress-fill" style="width:${progressPct.toFixed(1)}%"></div></div>` : ''}
|
||
</div>
|
||
<div class="episode-actions">
|
||
<button class="btn btn-sm btn-play" onclick="playEpisodeById(${ep.id})">▶</button>
|
||
<button class="btn btn-sm" onclick="openEpisodeSidebar(${ep.id})" title="Show notes">📋</button>
|
||
<button class="btn btn-sm" onclick="queueAddEpisode(${ep.id})" title="${ep.in_queue ? 'In queue' : 'Add to queue'}">${ep.in_queue ? '✓Q' : '+Q'}</button>
|
||
<button class="btn btn-sm" onclick="toggleMarkPlayed(${ep.id}, this)" title="Mark played">${ep.played ? '✓' : '○'}</button>
|
||
<button class="btn btn-sm" onclick="downloadEpisodeById(${ep.id}, this)" title="Download">⬇</button>
|
||
</div>
|
||
`;
|
||
container.appendChild(div);
|
||
});
|
||
}
|
||
|
||
function playEpisodeById(id) {
|
||
const ep = podcastEpCache[id];
|
||
if (!ep) return;
|
||
playEpisode(ep.id, ep.title, ep.audioUrl, ep.durationSeconds, ep.positionSeconds, ep.feedId);
|
||
}
|
||
|
||
function downloadEpisodeById(id, btn) {
|
||
const ep = podcastEpCache[id];
|
||
if (!ep) return;
|
||
downloadEpisode(ep.audioUrl, ep.title, btn);
|
||
}
|
||
|
||
function playEpisode(id, title, url, durationSeconds, positionSeconds, feedId) {
|
||
// Auto-enqueue if not already in queue
|
||
const inQueue = podcastQueue.some(q => q['episode__id'] === id);
|
||
if (!inQueue) {
|
||
fetch('/podcasts/queue/add/', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
||
body: JSON.stringify({episode_id: id}),
|
||
}).then(() => {
|
||
// update local queue state and any visible +Q buttons
|
||
if (podcastCurrentView === 'queue') loadAndRenderQueue();
|
||
const qBtn = document.querySelector(`#episode-item-${id} .episode-actions .btn-sm:nth-child(3)`);
|
||
if (qBtn && (qBtn.textContent === '+Q' || qBtn.textContent.includes('Q'))) {
|
||
qBtn.textContent = '✓Q'; qBtn.title = 'In queue';
|
||
}
|
||
}).catch(() => {});
|
||
}
|
||
|
||
stopPlayback(false);
|
||
|
||
podcastMode = true;
|
||
isPlaying = true; // fix: was missing, causing stop button to do nothing
|
||
currentEpisode = {id, title, audioUrl: url, durationSeconds, feedId};
|
||
|
||
audio.src = url;
|
||
const volSlider = $('volume');
|
||
if (volSlider) audio.volume = volSlider.value / 255;
|
||
|
||
if (positionSeconds > 0) {
|
||
audio.addEventListener('loadedmetadata', function onMeta() {
|
||
audio.currentTime = positionSeconds;
|
||
audio.removeEventListener('loadedmetadata', onMeta);
|
||
});
|
||
}
|
||
|
||
audio.play().catch(e => console.warn('Podcast play blocked:', e));
|
||
|
||
const feedTitle = (podcastFeeds.find(f => f.id === feedId) || {}).title || 'Podcast';
|
||
const stationEl = $('now-playing-station');
|
||
stationEl.textContent = feedTitle;
|
||
stationEl.classList.add('podcast-station-link');
|
||
stationEl.onclick = () => { showTab('podcasts'); openFeed(feedId); };
|
||
|
||
const trackEl = $('now-playing-track');
|
||
trackEl.textContent = title;
|
||
trackEl.classList.add('podcast-track-link');
|
||
trackEl.onclick = () => openEpisodeSidebar(id);
|
||
$('play-stop-btn').style.display = '';
|
||
$('play-stop-btn').textContent = '⏹ Stop';
|
||
$('play-stop-btn').classList.add('playing');
|
||
|
||
const seekBar = $('podcast-seek-bar');
|
||
if (seekBar) seekBar.style.display = '';
|
||
|
||
const slider = $('seek-slider');
|
||
if (slider && durationSeconds > 0) slider.max = durationSeconds;
|
||
|
||
// Reset speed to 1× for each new episode
|
||
setPlaybackRate(1);
|
||
|
||
audio.ontimeupdate = podcastTimeUpdate;
|
||
audio.onended = podcastOnEnded;
|
||
|
||
// Media Session API — maps hardware media keys, lock-screen controls, and
|
||
// Windows taskbar thumbnail buttons (play/pause, previous, next)
|
||
if ('mediaSession' in navigator) {
|
||
const feedTitle = (podcastFeeds.find(f => f.id === feedId) || {}).title || '';
|
||
const artSrc = (podcastFeeds.find(f => f.id === feedId) || {}).artwork_url || '';
|
||
navigator.mediaSession.metadata = new MediaMetadata({
|
||
title,
|
||
artist: feedTitle,
|
||
artwork: artSrc ? [{src: artSrc, sizes: '512x512', type: 'image/jpeg'}] : [],
|
||
});
|
||
navigator.mediaSession.setActionHandler('play', () => { audio.play(); isPlaying = true; });
|
||
navigator.mediaSession.setActionHandler('pause', () => { audio.pause(); isPlaying = false; });
|
||
navigator.mediaSession.setActionHandler('stop', () => stopPlayback(true));
|
||
navigator.mediaSession.setActionHandler('seekbackward', () => skipBack());
|
||
navigator.mediaSession.setActionHandler('seekforward', () => skipForward());
|
||
navigator.mediaSession.setActionHandler('nexttrack', () => podcastOnEnded());
|
||
try { navigator.mediaSession.setActionHandler('previoustrack', () => skipBack()); } catch (_) {}
|
||
navigator.mediaSession.playbackState = 'playing';
|
||
}
|
||
|
||
if (seekSaveTimer) clearInterval(seekSaveTimer);
|
||
seekSaveTimer = setInterval(savePodcastProgress, 15000);
|
||
}
|
||
|
||
function podcastTimeUpdate() {
|
||
const pos = Math.floor(audio.currentTime);
|
||
const dur = currentEpisode ? currentEpisode.durationSeconds : 0;
|
||
|
||
const curEl = $('seek-current');
|
||
if (curEl) curEl.textContent = formatDuration(pos);
|
||
|
||
const durEl = $('seek-duration');
|
||
if (durEl) durEl.textContent = formatDuration(dur || Math.floor(audio.duration) || 0);
|
||
|
||
const slider = $('seek-slider');
|
||
if (slider) {
|
||
if (dur > 0) {
|
||
slider.max = dur;
|
||
} else if (audio.duration && isFinite(audio.duration)) {
|
||
slider.max = Math.floor(audio.duration);
|
||
}
|
||
slider.value = pos;
|
||
}
|
||
}
|
||
|
||
function skipBack() {
|
||
audio.currentTime = Math.max(0, audio.currentTime - 15);
|
||
}
|
||
|
||
function skipForward() {
|
||
const dur = audio.duration;
|
||
audio.currentTime = dur && isFinite(dur)
|
||
? Math.min(dur, audio.currentTime + 30)
|
||
: audio.currentTime + 30;
|
||
}
|
||
|
||
function setPlaybackRate(rate) {
|
||
audio.playbackRate = rate;
|
||
// Keep pitch natural at non-1× speeds (supported in all modern browsers)
|
||
audio.preservesPitch = true;
|
||
|
||
document.querySelectorAll('.speed-btn').forEach(btn => {
|
||
btn.classList.toggle('active', parseFloat(btn.textContent) === rate
|
||
|| (rate === 0.75 && btn.textContent.startsWith('¾'))
|
||
|| (rate === 1.25 && btn.textContent.startsWith('1¼'))
|
||
|| (rate === 1.5 && btn.textContent.startsWith('1½'))
|
||
|| (rate === 1.75 && btn.textContent.startsWith('1¾'))
|
||
|| (rate === 2.5 && btn.textContent.startsWith('2½')));
|
||
});
|
||
}
|
||
|
||
async function podcastOnEnded() {
|
||
if (!podcastMode || !currentEpisode) return;
|
||
|
||
await fetch('/podcasts/progress/mark-played/', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
||
body: JSON.stringify({episode_id: currentEpisode.id, played: true}),
|
||
}).catch(() => {});
|
||
|
||
if (sleepTimerEndOfEp) { clearSleepTimer(); audio.pause(); return; }
|
||
|
||
const finishedId = currentEpisode.id;
|
||
try {
|
||
const res = await fetch('/podcasts/queue/');
|
||
const data = await res.json();
|
||
const items = data.queue || [];
|
||
const currentIdx = items.findIndex(item => item['episode__id'] === finishedId);
|
||
const nextItem = currentIdx >= 0 ? items[currentIdx + 1] : null;
|
||
|
||
await fetch('/podcasts/queue/remove/', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
||
body: JSON.stringify({episode_id: finishedId}),
|
||
}).catch(() => {});
|
||
|
||
if (nextItem) {
|
||
const nextEpId = nextItem['episode__id'];
|
||
const cached = podcastEpCache[nextEpId] || {};
|
||
playEpisode(nextEpId, nextItem['episode__title'], nextItem['episode__audio_url'],
|
||
nextItem['episode__duration_seconds'], cached.positionSeconds || 0, nextItem['episode__feed__id']);
|
||
} else {
|
||
stopPlayback(false);
|
||
}
|
||
if (podcastCurrentView === 'queue') loadAndRenderQueue();
|
||
} catch (e) {
|
||
stopPlayback(false);
|
||
}
|
||
}
|
||
|
||
function openSleepTimerMenu() {
|
||
const existing = document.getElementById('sleep-timer-menu');
|
||
if (existing) { existing.remove(); return; }
|
||
const options = [
|
||
{label: 'Off', value: 0}, {label: '5m', value: 5}, {label: '10m', value: 10},
|
||
{label: '15m', value: 15}, {label: '30m', value: 30}, {label: '45m', value: 45},
|
||
{label: '60m', value: 60}, {label: 'End of episode', value: -1},
|
||
];
|
||
const menu = document.createElement('div');
|
||
menu.id = 'sleep-timer-menu';
|
||
menu.className = 'sleep-timer-menu';
|
||
options.forEach(opt => {
|
||
const btn = document.createElement('button');
|
||
btn.className = 'sleep-timer-option';
|
||
btn.textContent = opt.label;
|
||
btn.onclick = () => { setSleepTimer(opt.value); menu.remove(); };
|
||
menu.appendChild(btn);
|
||
});
|
||
document.getElementById('sleep-timer-btn').insertAdjacentElement('afterend', menu);
|
||
}
|
||
|
||
function setSleepTimer(minutes) {
|
||
clearSleepTimer();
|
||
const btn = document.getElementById('sleep-timer-btn');
|
||
if (minutes === 0) { if (btn) btn.textContent = 'Sleep'; return; }
|
||
if (minutes === -1) {
|
||
sleepTimerEndOfEp = true;
|
||
if (btn) btn.textContent = 'Sleep:EoE';
|
||
return;
|
||
}
|
||
sleepTimerEndOfEp = false;
|
||
sleepTimerEndSecs = Math.floor(Date.now() / 1000) + minutes * 60;
|
||
sleepTimerInterval = setInterval(() => {
|
||
const remaining = sleepTimerEndSecs - Math.floor(Date.now() / 1000);
|
||
if (remaining <= 0) { clearSleepTimer(); audio.pause(); isPlaying = false; return; }
|
||
const m = Math.floor(remaining / 60);
|
||
const s = remaining % 60;
|
||
if (btn) btn.textContent = `${m}:${String(s).padStart(2, '0')}`;
|
||
}, 1000);
|
||
}
|
||
|
||
function clearSleepTimer() {
|
||
if (sleepTimerInterval) { clearInterval(sleepTimerInterval); sleepTimerInterval = null; }
|
||
sleepTimerEndOfEp = false;
|
||
sleepTimerEndSecs = 0;
|
||
const btn = document.getElementById('sleep-timer-btn');
|
||
if (btn) btn.textContent = 'Sleep';
|
||
}
|
||
|
||
async function savePodcastProgress() {
|
||
if (!currentEpisode) return;
|
||
const pos = Math.floor(audio.currentTime);
|
||
try {
|
||
await fetch('/podcasts/progress/save/', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
||
body: JSON.stringify({episode_id: currentEpisode.id, position_seconds: pos}),
|
||
});
|
||
} catch (e) {}
|
||
}
|
||
|
||
let _inboxOffset = 0;
|
||
const _inboxPageSize = DIORA_CONFIG.podcastInboxPageSize;
|
||
|
||
async function loadAndRenderInbox(append = false) {
|
||
const listEl = $('podcast-inbox-list');
|
||
if (!listEl) return;
|
||
|
||
if (!append) {
|
||
_inboxOffset = 0;
|
||
listEl.innerHTML = '<p class="muted">Loading…</p>';
|
||
inboxUpdateBulkBar();
|
||
}
|
||
|
||
try {
|
||
const res = await fetch(`/podcasts/inbox/?limit=${_inboxPageSize}&offset=${_inboxOffset}`);
|
||
const data = await res.json();
|
||
const episodes = data.episodes || [];
|
||
|
||
if (!append) listEl.innerHTML = '';
|
||
|
||
if (!episodes.length && !append) {
|
||
listEl.innerHTML = '<p class="muted">Inbox empty — all caught up!</p>';
|
||
$('inbox-load-more-bar').style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
episodes.forEach(ep => {
|
||
podcastEpCache[ep.id] = {
|
||
id: ep.id,
|
||
title: ep.title,
|
||
description: ep.description || '',
|
||
audioUrl: ep.audio_url,
|
||
durationSeconds: ep.duration_seconds,
|
||
positionSeconds: ep.position_seconds || 0,
|
||
feedId: ep['feed__id'],
|
||
played: false,
|
||
};
|
||
|
||
const progressPct = (ep.duration_seconds > 0 && ep.position_seconds > 0)
|
||
? Math.min(100, (ep.position_seconds / ep.duration_seconds) * 100) : 0;
|
||
const dur = formatDuration(ep.duration_seconds);
|
||
const dateStr = ep.pub_date ? ep.pub_date.slice(0, 10) : '';
|
||
const inQueue = ep.in_queue;
|
||
|
||
const div = document.createElement('div');
|
||
div.className = 'episode-item';
|
||
div.dataset.epId = ep.id;
|
||
div.innerHTML = `
|
||
<label class="inbox-checkbox-label">
|
||
<input type="checkbox" class="inbox-cb" data-ep-id="${ep.id}" onchange="inboxOnCheck()">
|
||
</label>
|
||
${ep['feed__artwork_url'] ? `<img class="podcast-thumb" src="${escapeHtml(ep['feed__artwork_url'])}" alt="">` : '<div class="podcast-thumb-placeholder"></div>'}
|
||
<div class="episode-info">
|
||
<div class="episode-title ep-clickable" onclick="openEpisodeSidebar(${ep.id})" title="Show notes">${escapeHtml(ep.title)}</div>
|
||
<div class="episode-meta">
|
||
<span class="episode-date episode-feed-link" onclick="openFeed(${ep['feed__id']})" title="Open feed">${escapeHtml(ep['feed__title'])}</span>
|
||
${dateStr ? `<span class="episode-dur">${escapeHtml(dateStr)}</span>` : ''}
|
||
${dur !== '0:00' ? `<span class="episode-dur">${escapeHtml(dur)}</span>` : ''}
|
||
</div>
|
||
${progressPct > 0 ? `<div class="episode-progress-bar"><div class="episode-progress-fill" style="width:${progressPct.toFixed(1)}%"></div></div>` : ''}
|
||
</div>
|
||
<div class="episode-actions">
|
||
<button class="btn btn-sm btn-play" onclick="playEpisodeById(${ep.id})">▶</button>
|
||
<button class="btn btn-sm" onclick="queueAddEpisode(${ep.id})" title="${inQueue ? 'In queue' : 'Add to queue'}">${inQueue ? '✓Q' : '+Q'}</button>
|
||
<button class="btn btn-sm" onclick="downloadEpisodeById(${ep.id}, this)" title="Download">⬇</button>
|
||
<button class="btn btn-sm btn-danger" onclick="inboxDismissOne(${ep.id}, this)" title="Dismiss">✕</button>
|
||
</div>
|
||
`;
|
||
listEl.appendChild(div);
|
||
});
|
||
|
||
_inboxOffset += episodes.length;
|
||
const moreBar = $('inbox-load-more-bar');
|
||
const countLabel = $('inbox-count-label');
|
||
if (episodes.length === _inboxPageSize) {
|
||
moreBar.style.display = '';
|
||
if (countLabel) countLabel.textContent = `${_inboxOffset} loaded`;
|
||
} else {
|
||
moreBar.style.display = 'none';
|
||
if (countLabel) countLabel.textContent = '';
|
||
}
|
||
} catch (e) {
|
||
if (!append) listEl.innerHTML = '<p class="muted">Failed to load inbox.</p>';
|
||
}
|
||
}
|
||
|
||
function inboxLoadMore() {
|
||
loadAndRenderInbox(true);
|
||
}
|
||
|
||
function inboxGetSelectedIds() {
|
||
return Array.from(document.querySelectorAll('.inbox-cb:checked'))
|
||
.map(cb => parseInt(cb.dataset.epId, 10));
|
||
}
|
||
|
||
function inboxOnCheck() {
|
||
inboxUpdateBulkBar();
|
||
// sync select-all state
|
||
const all = document.querySelectorAll('.inbox-cb');
|
||
const checked = document.querySelectorAll('.inbox-cb:checked');
|
||
const selectAll = $('inbox-select-all');
|
||
if (selectAll) {
|
||
selectAll.indeterminate = checked.length > 0 && checked.length < all.length;
|
||
selectAll.checked = all.length > 0 && checked.length === all.length;
|
||
}
|
||
}
|
||
|
||
function inboxSelectAll(checked) {
|
||
document.querySelectorAll('.inbox-cb').forEach(cb => { cb.checked = checked; });
|
||
inboxUpdateBulkBar();
|
||
}
|
||
|
||
function inboxUpdateBulkBar() {
|
||
const ids = inboxGetSelectedIds();
|
||
const bar = $('inbox-bulk-actions');
|
||
const countEl = $('inbox-selection-count');
|
||
if (!bar) return;
|
||
if (ids.length > 0) {
|
||
bar.style.display = '';
|
||
if (countEl) countEl.textContent = `${ids.length} selected`;
|
||
} else {
|
||
bar.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
async function inboxBulkDismiss() {
|
||
const ids = inboxGetSelectedIds();
|
||
if (!ids.length) return;
|
||
try {
|
||
await fetch('/podcasts/progress/dismiss/', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
||
body: JSON.stringify({episode_ids: ids, dismissed: true}),
|
||
});
|
||
ids.forEach(id => {
|
||
const div = document.querySelector(`.episode-item[data-ep-id="${id}"]`);
|
||
if (div) div.remove();
|
||
});
|
||
inboxUpdateBulkBar();
|
||
const selectAll = $('inbox-select-all');
|
||
if (selectAll) { selectAll.checked = false; selectAll.indeterminate = false; }
|
||
if (!document.querySelector('.inbox-cb')) {
|
||
const listEl = $('podcast-inbox-list');
|
||
if (listEl && !listEl.querySelector('.episode-item')) {
|
||
listEl.innerHTML = '<p class="muted">Inbox empty — all caught up!</p>';
|
||
}
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
async function inboxDismissOne(epId, btn) {
|
||
try {
|
||
await fetch('/podcasts/progress/dismiss/', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
||
body: JSON.stringify({episode_ids: [epId], dismissed: true}),
|
||
});
|
||
const div = document.querySelector(`.episode-item[data-ep-id="${epId}"]`);
|
||
if (div) div.remove();
|
||
if (!document.querySelector('.inbox-cb')) {
|
||
const listEl = $('podcast-inbox-list');
|
||
if (listEl && !listEl.querySelector('.episode-item')) {
|
||
listEl.innerHTML = '<p class="muted">Inbox empty — all caught up!</p>';
|
||
}
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
async function inboxBulkQueueAdd() {
|
||
const ids = inboxGetSelectedIds();
|
||
for (const id of ids) {
|
||
await queueAddEpisode(id);
|
||
}
|
||
// Update queue button states
|
||
ids.forEach(id => {
|
||
const div = document.querySelector(`.episode-item[data-ep-id="${id}"]`);
|
||
if (div) {
|
||
const qBtn = div.querySelector('.episode-actions .btn-sm:nth-child(2)');
|
||
if (qBtn) { qBtn.textContent = '✓Q'; qBtn.title = 'In queue'; }
|
||
}
|
||
});
|
||
}
|
||
|
||
async function inboxBulkMarkPlayed() {
|
||
const ids = inboxGetSelectedIds();
|
||
try {
|
||
for (const id of ids) {
|
||
await fetch('/podcasts/progress/mark-played/', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
||
body: JSON.stringify({episode_id: id, played: true}),
|
||
});
|
||
}
|
||
ids.forEach(id => {
|
||
const div = document.querySelector(`.episode-item[data-ep-id="${id}"]`);
|
||
if (div) div.remove();
|
||
});
|
||
inboxUpdateBulkBar();
|
||
const selectAll = $('inbox-select-all');
|
||
if (selectAll) { selectAll.checked = false; selectAll.indeterminate = false; }
|
||
} catch (e) {}
|
||
}
|
||
|
||
async function inboxBulkDownload() {
|
||
const ids = inboxGetSelectedIds();
|
||
for (const id of ids) {
|
||
const ep = podcastEpCache[id];
|
||
if (ep) await downloadEpisode(ep.audioUrl, ep.title, null);
|
||
}
|
||
}
|
||
|
||
async function loadAndRenderQueue() {
|
||
const ol = $('podcast-queue-ol');
|
||
if (!ol) return;
|
||
ol.innerHTML = '<li class="muted">Loading…</li>';
|
||
|
||
try {
|
||
const res = await fetch('/podcasts/queue/');
|
||
const data = await res.json();
|
||
const items = data.queue || [];
|
||
podcastQueue = items;
|
||
|
||
ol.innerHTML = '';
|
||
if (!items.length) {
|
||
ol.innerHTML = '<li class="muted">Queue is empty.</li>';
|
||
return;
|
||
}
|
||
|
||
items.forEach(item => {
|
||
const epId = item['episode__id'];
|
||
podcastEpCache[epId] = {
|
||
id: epId,
|
||
title: item['episode__title'],
|
||
audioUrl: item['episode__audio_url'],
|
||
durationSeconds: item['episode__duration_seconds'],
|
||
positionSeconds: item['position_seconds'] || 0,
|
||
feedId: item['episode__feed__id'],
|
||
played: false,
|
||
};
|
||
|
||
const progressPct = (item['episode__duration_seconds'] > 0 && item['position_seconds'] > 0)
|
||
? Math.min(100, (item['position_seconds'] / item['episode__duration_seconds']) * 100) : 0;
|
||
const dur = formatDuration(item['episode__duration_seconds']);
|
||
|
||
const li = document.createElement('li');
|
||
li.className = 'episode-item';
|
||
li.draggable = true;
|
||
li.dataset.epId = epId;
|
||
li.innerHTML = `
|
||
<span class="drag-handle">⠿</span>
|
||
<div class="episode-info">
|
||
<div class="episode-title">${escapeHtml(item['episode__title'])}</div>
|
||
<div class="episode-meta">
|
||
<span class="episode-date episode-feed-link" onclick="openFeed(${item['episode__feed__id']})" title="Open feed">${escapeHtml(item['episode__feed__title'])}</span>
|
||
${dur !== '0:00' ? `<span class="episode-dur">${escapeHtml(dur)}</span>` : ''}
|
||
</div>
|
||
${progressPct > 0 ? `<div class="episode-progress-bar"><div class="episode-progress-fill" style="width:${progressPct.toFixed(1)}%"></div></div>` : ''}
|
||
</div>
|
||
<div class="episode-actions">
|
||
<button class="btn btn-sm btn-play" onclick="playEpisodeById(${epId})">▶</button>
|
||
<button class="btn btn-sm" onclick="downloadEpisodeById(${epId}, this)" title="Download">⬇</button>
|
||
<button class="btn btn-sm btn-danger" onclick="queueRemoveEpisode(${epId})">✕</button>
|
||
</div>
|
||
`;
|
||
li.addEventListener('dragstart', queueDragStart);
|
||
li.addEventListener('dragover', queueDragOver);
|
||
li.addEventListener('drop', queueDrop);
|
||
li.addEventListener('dragend', queueDragEnd);
|
||
ol.appendChild(li);
|
||
});
|
||
} catch (e) {
|
||
ol.innerHTML = '<li class="muted">Failed to load queue.</li>';
|
||
}
|
||
}
|
||
|
||
function queueDragStart(e) {
|
||
_dragSrcEl = this;
|
||
e.dataTransfer.effectAllowed = 'move';
|
||
this.classList.add('dragging');
|
||
}
|
||
|
||
function queueDragOver(e) {
|
||
e.preventDefault();
|
||
const ol = document.getElementById('podcast-queue-ol');
|
||
const dragging = ol.querySelector('.dragging');
|
||
if (!dragging || dragging === this) return;
|
||
const rect = this.getBoundingClientRect();
|
||
ol.insertBefore(dragging, e.clientY < rect.top + rect.height / 2 ? this : this.nextSibling);
|
||
}
|
||
|
||
function queueDrop(e) { e.preventDefault(); }
|
||
|
||
function queueDragEnd() {
|
||
this.classList.remove('dragging');
|
||
_dragSrcEl = null;
|
||
const ol = document.getElementById('podcast-queue-ol');
|
||
const newOrder = Array.from(ol.querySelectorAll('li[data-ep-id]'))
|
||
.map(li => parseInt(li.dataset.epId, 10));
|
||
fetch('/podcasts/queue/reorder/', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
||
body: JSON.stringify({order: newOrder}),
|
||
}).catch(() => {});
|
||
}
|
||
|
||
async function queueAddEpisode(id) {
|
||
try {
|
||
await fetch('/podcasts/queue/add/', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
||
body: JSON.stringify({episode_id: id}),
|
||
});
|
||
} catch (e) {}
|
||
}
|
||
|
||
async function queueRemoveEpisode(id) {
|
||
try {
|
||
await fetch('/podcasts/queue/remove/', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
||
body: JSON.stringify({episode_id: id}),
|
||
});
|
||
if (podcastCurrentView === 'queue') loadAndRenderQueue();
|
||
} catch (e) {}
|
||
}
|
||
|
||
async function toggleMarkPlayed(id, btn) {
|
||
const ep = podcastEpCache[id];
|
||
const current = ep ? ep.played : btn.textContent === '✓';
|
||
const newPlayed = !current;
|
||
try {
|
||
const res = await fetch('/podcasts/progress/mark-played/', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
||
body: JSON.stringify({episode_id: id, played: newPlayed}),
|
||
});
|
||
if (res.ok) {
|
||
if (ep) ep.played = newPlayed;
|
||
btn.textContent = newPlayed ? '✓' : '○';
|
||
const item = document.getElementById(`episode-item-${id}`);
|
||
if (item) item.classList.toggle('episode-played', newPlayed);
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
async function refreshFeed(feedId) {
|
||
try {
|
||
const res = await fetch('/podcasts/feeds/refresh/', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
||
body: JSON.stringify({feed_id: feedId}),
|
||
});
|
||
const data = await res.json();
|
||
if (data.ok && podcastCurrentFeedId === feedId) {
|
||
openFeed(feedId);
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
async function refreshOpenFeed(btn) {
|
||
if (!podcastCurrentFeedId) return;
|
||
if (btn) { btn.disabled = true; btn.textContent = '↻ …'; }
|
||
try {
|
||
const res = await fetch('/podcasts/feeds/refresh/', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
||
body: JSON.stringify({feed_id: podcastCurrentFeedId}),
|
||
});
|
||
const data = await res.json();
|
||
if (data.ok) {
|
||
await openFeed(podcastCurrentFeedId);
|
||
// openFeed re-renders the header, btn reference is stale — nothing to restore
|
||
return;
|
||
}
|
||
} catch (e) {}
|
||
if (btn) { btn.disabled = false; btn.textContent = '↻ Refresh'; }
|
||
}
|
||
|
||
async function refreshAllFeedsBtn(btn) {
|
||
if (btn) { btn.disabled = true; btn.textContent = '↻ 0/' + podcastFeeds.length; }
|
||
let done = 0;
|
||
for (const feed of podcastFeeds) {
|
||
try {
|
||
await fetch('/podcasts/feeds/refresh/', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
||
body: JSON.stringify({feed_id: feed.id}),
|
||
});
|
||
} catch (e) {}
|
||
done++;
|
||
if (btn) btn.textContent = `↻ ${done}/${podcastFeeds.length}`;
|
||
}
|
||
await loadFeedList();
|
||
if (podcastCurrentView === 'feeds') renderFeedList();
|
||
if (podcastCurrentView === 'inbox') loadAndRenderInbox();
|
||
if (podcastCurrentView === 'episodes' && podcastCurrentFeedId) openFeed(podcastCurrentFeedId);
|
||
if (btn) { btn.disabled = false; btn.textContent = '↻ All'; }
|
||
}
|
||
|
||
async function refreshAllFeeds() {
|
||
if (!IS_AUTHENTICATED || !podcastFeeds.length) return;
|
||
for (const feed of podcastFeeds) {
|
||
try {
|
||
await fetch('/podcasts/feeds/refresh/', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
||
body: JSON.stringify({feed_id: feed.id}),
|
||
});
|
||
} catch (e) {}
|
||
}
|
||
// Reload feed metadata and refresh the currently open view
|
||
await loadFeedList();
|
||
if (podcastCurrentView === 'feeds') renderFeedList();
|
||
if (podcastCurrentView === 'inbox') loadAndRenderInbox();
|
||
if (podcastCurrentView === 'episodes' && podcastCurrentFeedId) openFeed(podcastCurrentFeedId);
|
||
}
|
||
|
||
async function removeFeed(feedId) {
|
||
if (!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); }
|
||
}
|
||
|
||
// Vim-style scroll — ignore when typing in an input
|
||
const tag = document.activeElement?.tagName;
|
||
if (tag !== 'INPUT' && tag !== 'TEXTAREA') {
|
||
const contentEl = $('reader-content');
|
||
if (contentEl) {
|
||
const large = Math.round(contentEl.clientHeight * 0.85);
|
||
const continuousKeys = {'j': 8, 'k': -8};
|
||
if (continuousKeys[e.key] !== undefined && !e.repeat) {
|
||
e.preventDefault();
|
||
_readerScrollStep = continuousKeys[e.key];
|
||
if (!_readerScrollInterval) {
|
||
_readerScrollInterval = setInterval(() => {
|
||
$('reader-content')?.scrollBy({top: _readerScrollStep});
|
||
}, 16);
|
||
}
|
||
}
|
||
if (e.key === 'd') { e.preventDefault(); contentEl.scrollBy({top: large / 2, behavior: 'smooth'}); }
|
||
if (e.key === 'u') { e.preventDefault(); contentEl.scrollBy({top: -large / 2, behavior: 'smooth'}); }
|
||
if (e.key === 'f') { e.preventDefault(); contentEl.scrollBy({top: large, behavior: 'smooth'}); }
|
||
if (e.key === 'b') { e.preventDefault(); contentEl.scrollBy({top: -large, behavior: 'smooth'}); }
|
||
if (e.key === 'g') { e.preventDefault(); contentEl.scrollTop = 0; }
|
||
if (e.key === 'G') { e.preventDefault(); contentEl.scrollTop = contentEl.scrollHeight; }
|
||
if (currentPdfDoc && !readerSettings.pdfPaginated) {
|
||
if (e.key === 'n') { e.preventDefault(); pdfGoToPage(pdfCurrentPage + 1); }
|
||
if (e.key === 'p') { e.preventDefault(); pdfGoToPage(pdfCurrentPage - 1); }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
document.addEventListener('keyup', e => {
|
||
if (e.key === 'j' || e.key === 'k') {
|
||
clearInterval(_readerScrollInterval);
|
||
_readerScrollInterval = null;
|
||
}
|
||
});
|
||
|
||
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 || 0}`;
|
||
const stored = localStorage.getItem(storageKey);
|
||
if (stored) {
|
||
try {
|
||
const raw = base64ToBytes(stored);
|
||
_encKey = await crypto.subtle.importKey('raw', raw, {name: 'AES-GCM'}, false, ['encrypt', 'decrypt']);
|
||
return _encKey;
|
||
} catch (e) { /* fall through, generate new */ }
|
||
}
|
||
// No key found — generate one and store it
|
||
_encKey = await crypto.subtle.generateKey({name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']);
|
||
const raw = await crypto.subtle.exportKey('raw', _encKey);
|
||
localStorage.setItem(storageKey, bytesToBase64(new Uint8Array(raw)));
|
||
return _encKey;
|
||
}
|
||
|
||
async function encryptBytes(key, plainBytes) {
|
||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||
const ct = await crypto.subtle.encrypt({name: 'AES-GCM', iv}, key, plainBytes);
|
||
return {iv: bytesToHex(iv), ciphertext: bytesToBase64(ct)};
|
||
}
|
||
|
||
async function decryptBytes(key, ivHex, ctB64) {
|
||
const iv = hexToBytes(ivHex);
|
||
const ct = base64ToBytes(ctB64);
|
||
return crypto.subtle.decrypt({name: 'AES-GCM', iv}, key, ct);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Encrypted wallpaper
|
||
// ---------------------------------------------------------------------------
|
||
|
||
async function uploadBackground(file) {
|
||
if (!file) return;
|
||
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||
if (!allowedTypes.includes(file.type)) {
|
||
alert('Only JPEG, PNG, or WebP images are allowed.');
|
||
return;
|
||
}
|
||
if (file.size > DIORA_CONFIG.bgMaxBytes) {
|
||
alert(`Image must be ${DIORA_CONFIG.bgMaxBytes / 1024 / 1024} MB or smaller.`);
|
||
return;
|
||
}
|
||
|
||
const key = await getOrCreateEncKey();
|
||
const buf = await file.arrayBuffer();
|
||
const {iv, ciphertext} = await encryptBytes(key, buf);
|
||
|
||
const res = await fetch('/accounts/background/upload/', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
||
body: JSON.stringify({iv, ciphertext, mime_type: file.type, file_size: file.size}),
|
||
});
|
||
const data = await res.json();
|
||
if (!data.ok) throw new Error(data.error || 'upload failed');
|
||
}
|
||
|
||
async function applyEncryptedBackground() {
|
||
if (typeof ENCRYPTED_BG === 'undefined' || !ENCRYPTED_BG.ciphertext) return;
|
||
try {
|
||
const key = await getOrCreateEncKey();
|
||
const plain = await decryptBytes(key, ENCRYPTED_BG.iv, ENCRYPTED_BG.ciphertext);
|
||
const blob = new Blob([plain], {type: ENCRYPTED_BG.mime || 'image/jpeg'});
|
||
const url = URL.createObjectURL(blob);
|
||
document.body.style.backgroundImage = `url('${url}')`;
|
||
document.body.style.backgroundSize = 'cover';
|
||
document.body.style.backgroundPosition = 'center';
|
||
document.body.style.backgroundAttachment = 'fixed';
|
||
|
||
// Analyze brightness for accent/scheme
|
||
analyzeBackground(url).then(({bright, bgLuminance}) => {
|
||
setScheme(bright);
|
||
applyAccent(pickBestAccent(bgLuminance));
|
||
});
|
||
} catch (e) {
|
||
console.warn('Could not decrypt background:', e);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// EPUB parser (requires JSZip)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function resolveEpubPath(base, relative) {
|
||
if (!relative) return '';
|
||
if (relative.startsWith('/')) return relative.slice(1);
|
||
const hashIdx = relative.indexOf('#');
|
||
const frag = hashIdx >= 0 ? relative.slice(hashIdx) : '';
|
||
const rel = hashIdx >= 0 ? relative.slice(0, hashIdx) : relative;
|
||
const parts = (base + rel).split('/');
|
||
const resolved = [];
|
||
for (const p of parts) {
|
||
if (p === '..') resolved.pop();
|
||
else if (p !== '.') resolved.push(p);
|
||
}
|
||
return resolved.join('/') + frag;
|
||
}
|
||
|
||
async function parseEpub(arrayBuffer) {
|
||
const zip = await JSZip.loadAsync(arrayBuffer);
|
||
|
||
// 1. Find OPF via container.xml
|
||
const containerXml = await zip.file('META-INF/container.xml').async('text');
|
||
const containerDoc = new DOMParser().parseFromString(containerXml, 'application/xml');
|
||
const rootfileEl = containerDoc.querySelector('rootfile');
|
||
if (!rootfileEl) throw new Error('No rootfile in container.xml');
|
||
const opfPath = rootfileEl.getAttribute('full-path');
|
||
const opfDir = opfPath.includes('/') ? opfPath.substring(0, opfPath.lastIndexOf('/') + 1) : '';
|
||
|
||
// 2. Parse OPF
|
||
const opfText = await zip.file(opfPath).async('text');
|
||
const opfDoc = new DOMParser().parseFromString(opfText, 'application/xml');
|
||
|
||
const title = opfDoc.querySelector('metadata > title, metadata > *|title')?.textContent?.trim() || 'Unknown Title';
|
||
const author = opfDoc.querySelector('metadata > creator, metadata > *|creator')?.textContent?.trim() || 'Unknown Author';
|
||
|
||
// 3. Build manifest: id → {href, mediaType, properties}
|
||
const manifest = {};
|
||
opfDoc.querySelectorAll('manifest > item').forEach(item => {
|
||
manifest[item.getAttribute('id')] = {
|
||
href: opfDir + item.getAttribute('href'),
|
||
mediaType: item.getAttribute('media-type') || '',
|
||
properties: item.getAttribute('properties') || '',
|
||
};
|
||
});
|
||
|
||
// 4. Build image map: abs zip path → blob URL
|
||
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;
|
||
let _resizeObserver = null;
|
||
let _currentPositionAnchor = '';
|
||
const bookMetaCache = {}; // id → {title, author, type}
|
||
|
||
const EPUB_BLOCK_SELECTOR = 'p, h1, h2, h3, h4, h5, h6, li, blockquote, dt, dd, figcaption, div:not(:has(*))';
|
||
|
||
function getPositionAnchor(contentEl) {
|
||
const blocks = Array.from(contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR));
|
||
if (!blocks.length) return '';
|
||
const containerTop = contentEl.getBoundingClientRect().top;
|
||
const containerBottom = containerTop + contentEl.clientHeight;
|
||
let bestIndex = 0;
|
||
let bestDelta = Infinity;
|
||
for (let i = 0; i < blocks.length; i++) {
|
||
const rect = blocks[i].getBoundingClientRect();
|
||
if (rect.height < 1) continue;
|
||
if (rect.top > containerBottom) break;
|
||
const delta = rect.top - containerTop;
|
||
if (delta <= 0 && Math.abs(delta) < Math.abs(bestDelta)) {
|
||
bestIndex = i;
|
||
bestDelta = delta;
|
||
} else if (delta > 0 && bestDelta === Infinity) {
|
||
bestIndex = i;
|
||
bestDelta = delta;
|
||
}
|
||
}
|
||
const rect = blocks[bestIndex].getBoundingClientRect();
|
||
const innerFraction = Math.max(0, Math.min(1, (containerTop - rect.top) / (rect.height || 1)));
|
||
return `${bestIndex}:${innerFraction.toFixed(6)}`;
|
||
}
|
||
|
||
function restoreFromAnchor(contentEl, anchor) {
|
||
if (!anchor) return false;
|
||
const parts = anchor.split(':');
|
||
if (parts.length !== 2) return false;
|
||
const idx = parseInt(parts[0], 10);
|
||
const innerFraction = parseFloat(parts[1]);
|
||
if (isNaN(idx) || isNaN(innerFraction)) return false;
|
||
const blocks = Array.from(contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR));
|
||
if (idx >= blocks.length) return false;
|
||
const el = blocks[idx];
|
||
contentEl.scrollTop = el.offsetTop + Math.round(innerFraction * el.offsetHeight);
|
||
return true;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Book cache — IndexedDB, evict after 4 weeks of inactivity
|
||
// ---------------------------------------------------------------------------
|
||
const _BOOK_CACHE_STORE = 'books';
|
||
const _BOOK_CACHE_EVICT_MS = 4 * 7 * 24 * 60 * 60 * 1000;
|
||
|
||
function _openBookCacheDb() {
|
||
return new Promise((resolve, reject) => {
|
||
const req = indexedDB.open('diora_book_cache', 1);
|
||
req.onupgradeneeded = e => e.target.result.createObjectStore(_BOOK_CACHE_STORE, {keyPath: 'id'});
|
||
req.onsuccess = e => resolve(e.target.result);
|
||
req.onerror = () => reject();
|
||
});
|
||
}
|
||
|
||
async function _getCachedBook(bookId) {
|
||
try {
|
||
const db = await _openBookCacheDb();
|
||
return await new Promise((resolve) => {
|
||
const req = db.transaction(_BOOK_CACHE_STORE).objectStore(_BOOK_CACHE_STORE).get(bookId);
|
||
req.onsuccess = e => resolve(e.target.result || null);
|
||
req.onerror = () => resolve(null);
|
||
});
|
||
} catch { return null; }
|
||
}
|
||
|
||
async function _setCachedBook(bookId, data_ct, data_iv) {
|
||
try {
|
||
const db = await _openBookCacheDb();
|
||
await new Promise((resolve) => {
|
||
const tx = db.transaction(_BOOK_CACHE_STORE, 'readwrite');
|
||
tx.objectStore(_BOOK_CACHE_STORE).put({id: bookId, data_ct, data_iv, cached_at: Date.now()});
|
||
tx.oncomplete = resolve;
|
||
tx.onerror = resolve;
|
||
});
|
||
} catch {}
|
||
}
|
||
|
||
async function _evictBookCache(bookList) {
|
||
try {
|
||
const db = await _openBookCacheDb();
|
||
const now = Date.now();
|
||
const metaById = Object.fromEntries(bookList.map(b => [b.id, b]));
|
||
await new Promise((resolve) => {
|
||
const tx = db.transaction(_BOOK_CACHE_STORE, 'readwrite');
|
||
const store = tx.objectStore(_BOOK_CACHE_STORE);
|
||
store.openCursor().onsuccess = e => {
|
||
const cursor = e.target.result;
|
||
if (!cursor) { resolve(); return; }
|
||
const meta = metaById[cursor.value.id];
|
||
const lastRead = meta?.last_read ? new Date(meta.last_read).getTime() : 0;
|
||
if (!meta || (now - lastRead) > _BOOK_CACHE_EVICT_MS) cursor.delete();
|
||
cursor.continue();
|
||
};
|
||
tx.onerror = resolve;
|
||
});
|
||
} catch {}
|
||
}
|
||
|
||
// Reader settings
|
||
let readerSettings = { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark',
|
||
pdfZoom: 100, pdfInverted: false, pdfPaginated: false, pdfSpread: false };
|
||
let readerSettingsPanelOpen = false;
|
||
let currentPdfDoc = null;
|
||
let currentPdfBuffer = null;
|
||
|
||
// Bookmarks
|
||
let currentBookmarks = [];
|
||
let bookmarksDirty = false;
|
||
|
||
// Highlights
|
||
let currentHighlights = [];
|
||
let highlightsDirty = false;
|
||
let currentHighlightPopover = null;
|
||
|
||
// Search
|
||
let searchMatches = [];
|
||
let searchMatchIndex = -1;
|
||
let searchOriginalContent = null;
|
||
let readerSearchOpen = false;
|
||
|
||
// PDF paginated
|
||
let pdfCurrentPage = 1;
|
||
let pdfTotalPages = 0;
|
||
let _pdfPageTextBoxCache = {};
|
||
let _pdfRenderGen = 0;
|
||
let _touchStartX = 0;
|
||
let _readerScrollInterval = null;
|
||
let _readerScrollStep = 0;
|
||
let _pinchStartDist = 0;
|
||
let _pinchStartZoom = 100;
|
||
let _isPinching = false;
|
||
|
||
if (typeof pdfjsLib !== 'undefined') {
|
||
pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/js/pdf.worker.min.js';
|
||
}
|
||
|
||
async function loadBookList() {
|
||
if (!IS_AUTHENTICATED) return;
|
||
const listEl = $('book-list');
|
||
if (!listEl) return;
|
||
listEl.innerHTML = '<p class="muted">Loading books…</p>';
|
||
|
||
try {
|
||
listEl.innerHTML = '<p class="muted">Fetching book list from server…</p>';
|
||
const res = await fetch('/books/', {cache: 'no-store'});
|
||
if (!res.ok) {
|
||
listEl.innerHTML = `<p class="muted">Server error ${res.status} loading books.</p>`;
|
||
return;
|
||
}
|
||
const books = await res.json();
|
||
_evictBookCache(books); // fire-and-forget
|
||
if (!Array.isArray(books)) {
|
||
listEl.innerHTML = `<p class="muted">Unexpected response from server (not an array).</p>`;
|
||
return;
|
||
}
|
||
if (!books.length) {
|
||
listEl.innerHTML = '<p class="muted">No books yet. Drop an .epub or .pdf above.</p>';
|
||
return;
|
||
}
|
||
|
||
listEl.innerHTML = `<p class="muted">Found ${books.length} book(s) on server. Decrypting…</p>`;
|
||
|
||
let key;
|
||
try {
|
||
key = await getOrCreateEncKey();
|
||
} catch (e) {
|
||
listEl.innerHTML = `<p class="muted">Encryption not available: ${e.message}. Make sure you are on HTTPS.</p>`;
|
||
return;
|
||
}
|
||
|
||
const decrypted = [];
|
||
for (const b of books) {
|
||
try {
|
||
const metaBuf = await decryptBytes(key, b.meta_iv, b.meta_ct);
|
||
const meta = JSON.parse(new TextDecoder().decode(metaBuf));
|
||
bookMetaCache[b.id] = {title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub'};
|
||
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, last_read: b.last_read || null, keyOk: true});
|
||
} 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, last_read: b.last_read || null, keyOk: false});
|
||
}
|
||
}
|
||
decrypted.sort((a, b) => {
|
||
if (a.last_read && b.last_read) return b.last_read.localeCompare(a.last_read);
|
||
if (a.last_read) return -1;
|
||
if (b.last_read) return 1;
|
||
return b.uploaded_at.localeCompare(a.uploaded_at);
|
||
});
|
||
renderBookList(decrypted);
|
||
} catch (e) {
|
||
if (listEl) listEl.innerHTML = `<p class="muted">Error loading books: ${e.message}</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);
|
||
const keyWarning = b.keyOk === false ? '<span title="Wrong encryption key — import the correct key to open this book" style="color:var(--accent,#e63946);margin-left:4px;">⚠️ wrong key</span>' : '';
|
||
html += `<div class="book-item">
|
||
<div class="book-item-info">
|
||
<strong class="book-title">${escapeHtml(b.title)}${keyWarning}</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})"${b.keyOk === false ? ' disabled title="Import the correct encryption key first"' : ''}>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() {
|
||
// Prevent Firefox from opening dragged files when dropped outside the zone
|
||
document.addEventListener('dragover', e => e.preventDefault());
|
||
document.addEventListener('drop', e => {
|
||
const zone = $('book-drop-zone');
|
||
if (!zone || !zone.contains(e.target)) e.preventDefault();
|
||
});
|
||
|
||
const zone = $('book-drop-zone');
|
||
if (!zone) return;
|
||
|
||
zone.addEventListener('dragover', e => {
|
||
e.preventDefault();
|
||
zone.classList.add('drag-over');
|
||
});
|
||
zone.addEventListener('dragleave', () => zone.classList.remove('drag-over'));
|
||
zone.addEventListener('drop', e => {
|
||
e.preventDefault();
|
||
zone.classList.remove('drag-over');
|
||
const file = e.dataTransfer.files[0];
|
||
if (file) uploadEbook(file);
|
||
});
|
||
}
|
||
|
||
async function deriveAndStoreKey() {
|
||
const pwInput = document.getElementById('enc-key-password');
|
||
const statusEl = $('enc-key-status');
|
||
const pw = pwInput ? pwInput.value : '';
|
||
if (!pw) { if (statusEl) statusEl.textContent = 'Please enter your password.'; return; }
|
||
|
||
if (statusEl) statusEl.textContent = 'Deriving key…';
|
||
try {
|
||
const enc = new TextEncoder();
|
||
const username = document.querySelector('meta[name="username"]')?.content || '';
|
||
const mat = await crypto.subtle.importKey('raw', enc.encode(pw), 'PBKDF2', false, ['deriveKey']);
|
||
const key = await crypto.subtle.deriveKey(
|
||
{name: 'PBKDF2', salt: enc.encode('diora:' + username), iterations: 200000, hash: 'SHA-256'},
|
||
mat, {name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']
|
||
);
|
||
const raw = await crypto.subtle.exportKey('raw', key);
|
||
const storageKey = `diora_enc_key_${window.USER_ID || 0}`;
|
||
localStorage.setItem(storageKey, bytesToBase64(new Uint8Array(raw)));
|
||
_encKey = null; // reset cached key
|
||
if (statusEl) statusEl.textContent = '✓ Unlocked';
|
||
const prompt = $('enc-key-prompt');
|
||
const uploadArea = $('book-upload-area');
|
||
if (prompt) prompt.style.display = 'none';
|
||
if (uploadArea) uploadArea.style.display = '';
|
||
loadBookList();
|
||
} catch (err) {
|
||
if (statusEl) statusEl.textContent = 'Error: ' + err.message;
|
||
}
|
||
}
|
||
|
||
async function uploadEbook(file) {
|
||
const statusEl = $('book-upload-status');
|
||
const isPdf = /\.pdf$/i.test(file.name);
|
||
const isEpub = /\.epub$/i.test(file.name);
|
||
if (!isPdf && !isEpub) {
|
||
if (statusEl) statusEl.textContent = 'Only .epub and .pdf files are supported.';
|
||
return;
|
||
}
|
||
if (file.size > DIORA_CONFIG.ebookMaxBytes) {
|
||
if (statusEl) statusEl.textContent = `File too large (max ${DIORA_CONFIG.ebookMaxBytes / 1024 / 1024} MB).`;
|
||
return;
|
||
}
|
||
|
||
if (statusEl) statusEl.textContent = 'Encrypting…';
|
||
|
||
try {
|
||
const buf = await file.arrayBuffer();
|
||
|
||
let title = file.name.replace(/\.(epub|pdf)$/i, '');
|
||
let author = '';
|
||
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 myGen = ++_pdfRenderGen;
|
||
const pdf = currentPdfDoc || await pdfjsLib.getDocument({data: new Uint8Array(arrayBuffer.slice(0))}).promise;
|
||
if (_pdfRenderGen !== myGen) return null;
|
||
currentPdfDoc = pdf;
|
||
|
||
let pdfTitle = '', pdfAuthor = '';
|
||
try {
|
||
const meta = await pdf.getMetadata();
|
||
pdfTitle = meta.info?.Title?.trim() || '';
|
||
pdfAuthor = meta.info?.Author?.trim() || '';
|
||
} catch (e) {}
|
||
|
||
let toc = [];
|
||
try {
|
||
const outline = await pdf.getOutline();
|
||
if (outline && outline.length) toc = await _parsePdfOutline(pdf, outline);
|
||
} catch (e) {}
|
||
|
||
contentEl.innerHTML = '';
|
||
|
||
// Viewport wrapper: CSS zoom controls display scale without re-rendering
|
||
const pdfVp = document.createElement('div');
|
||
pdfVp.id = 'pdf-viewport';
|
||
contentEl.appendChild(pdfVp);
|
||
|
||
const containerWidth = readerSettings.pdfSpread
|
||
? contentEl.clientWidth - 32
|
||
: Math.min(contentEl.clientWidth - 32, 900);
|
||
|
||
let spreadContainer = null;
|
||
|
||
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
|
||
if (_pdfRenderGen !== myGen) { contentEl.innerHTML = ''; return null; }
|
||
const page = await pdf.getPage(pageNum);
|
||
const naturalVp = page.getViewport({scale: 1});
|
||
const pageWidth = readerSettings.pdfSpread ? (containerWidth - 8) / 2 : containerWidth;
|
||
// Zoom baked into canvas resolution — no CSS zoom, stays sharp at any DPR
|
||
const scale = scaleOverride != null ? scaleOverride
|
||
: Math.max(0.5, pageWidth / naturalVp.width) * (readerSettings.pdfZoom / 100);
|
||
const viewport = page.getViewport({scale});
|
||
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = 'pdf-page-wrapper';
|
||
wrapper.id = `pdf-page-${pageNum}`;
|
||
|
||
// 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 dpr = window.devicePixelRatio || 1;
|
||
const canvas = document.createElement('canvas');
|
||
canvas.className = 'pdf-page';
|
||
canvas.width = Math.round(viewport.width * dpr);
|
||
canvas.height = Math.round(viewport.height * dpr);
|
||
canvas.style.width = viewport.width + 'px';
|
||
canvas.style.height = viewport.height + 'px';
|
||
inner.appendChild(canvas);
|
||
wrapper.appendChild(inner);
|
||
|
||
if (!readerSettings.pdfSpread) {
|
||
pdfVp.appendChild(wrapper);
|
||
} else if (pageNum === 1) {
|
||
wrapper.classList.add('pdf-spread-cover');
|
||
pdfVp.appendChild(wrapper);
|
||
spreadContainer = null;
|
||
} else {
|
||
if (pageNum % 2 === 0) {
|
||
spreadContainer = document.createElement('div');
|
||
spreadContainer.className = 'pdf-spread-wrapper';
|
||
pdfVp.appendChild(spreadContainer);
|
||
}
|
||
if (spreadContainer) spreadContainer.appendChild(wrapper);
|
||
else pdfVp.appendChild(wrapper);
|
||
}
|
||
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.scale(dpr, dpr);
|
||
await page.render({canvasContext: ctx, viewport}).promise;
|
||
|
||
// Text layer disabled — re-enable once overlay rendering is resolved
|
||
}
|
||
|
||
pdfTotalPages = pdf.numPages;
|
||
return {title: pdfTitle, author: pdfAuthor, toc, numPages: pdf.numPages};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Immersive reader mode — tap centre of screen to toggle bars
|
||
// ---------------------------------------------------------------------------
|
||
let _immBarsVisible = true;
|
||
|
||
function _immHandleTap(e) {
|
||
// Ignore taps on interactive elements (buttons, links, inputs, settings panel)
|
||
if (e.target.closest('button, a, input, select, label, #reader-settings-panel, .reader-header')) return;
|
||
_immBarsVisible = !_immBarsVisible;
|
||
document.body.classList.toggle('reader-immersive', !_immBarsVisible);
|
||
// Close settings panel when bars disappear
|
||
if (!_immBarsVisible && readerSettingsPanelOpen) {
|
||
const sp = document.getElementById('reader-settings-panel');
|
||
if (sp) sp.remove();
|
||
readerSettingsPanelOpen = false;
|
||
}
|
||
}
|
||
|
||
function enterReaderImmersiveMode() {
|
||
_immBarsVisible = true;
|
||
document.body.classList.remove('reader-immersive');
|
||
document.addEventListener('click', _immHandleTap);
|
||
}
|
||
|
||
function exitReaderImmersiveMode() {
|
||
_immBarsVisible = true;
|
||
document.body.classList.remove('reader-immersive');
|
||
document.removeEventListener('click', _immHandleTap);
|
||
}
|
||
|
||
async function openBook(bookId) {
|
||
const overlay = $('reader-overlay');
|
||
const contentEl = $('reader-content');
|
||
const titleEl = $('reader-title');
|
||
if (!overlay || !contentEl) return;
|
||
|
||
titleEl.textContent = 'Loading…';
|
||
contentEl.innerHTML = '';
|
||
overlay.style.display = '';
|
||
|
||
try {
|
||
loadReaderSettings(bookId);
|
||
const key = await getOrCreateEncKey();
|
||
let data_ct, data_iv;
|
||
const cached = await _getCachedBook(bookId);
|
||
if (cached) {
|
||
({data_ct, data_iv} = cached);
|
||
} else {
|
||
const res = await fetch(`/books/${bookId}/data/`);
|
||
({data_ct, data_iv} = await res.json());
|
||
_setCachedBook(bookId, data_ct, data_iv); // fire-and-forget
|
||
}
|
||
const plain = await decryptBytes(key, data_iv, data_ct);
|
||
|
||
// Revoke any previous image blob URLs
|
||
for (const url of Object.values(currentImageMap)) URL.revokeObjectURL(url);
|
||
currentImageMap = {};
|
||
|
||
const cachedMeta = bookMetaCache[bookId] || {};
|
||
let title = cachedMeta.title || '';
|
||
let author = cachedMeta.author || '';
|
||
let toc = [];
|
||
let numPages = 0;
|
||
const isPdfBook = cachedMeta.type === 'pdf';
|
||
|
||
if (isPdfBook) {
|
||
currentPdfDoc = null; // reset so renderPdf creates fresh doc
|
||
const loadingEl = document.createElement('div');
|
||
loadingEl.className = 'pdf-loading-overlay';
|
||
loadingEl.innerHTML = '<span class="pdf-loading-spinner"></span>';
|
||
overlay.appendChild(loadingEl);
|
||
const result = await renderPdf(plain, contentEl);
|
||
loadingEl.remove();
|
||
title = result.title || title;
|
||
author = result.author || author;
|
||
toc = result.toc;
|
||
numPages = result.numPages;
|
||
currentPdfBuffer = plain;
|
||
} else {
|
||
currentPdfBuffer = null;
|
||
const result = await parseEpub(plain);
|
||
title = result.title || title;
|
||
author = result.author || author;
|
||
toc = result.toc;
|
||
currentImageMap = result.imageMap;
|
||
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);
|
||
|
||
// Touch: swipe (paginated) + pinch-to-zoom
|
||
contentEl.addEventListener('touchstart', _pdfTouchStart, {passive: true});
|
||
contentEl.addEventListener('touchmove', _pdfTouchMove, {passive: false});
|
||
contentEl.addEventListener('touchend', _pdfTouchEnd, {passive: true});
|
||
contentEl.addEventListener('touchcancel', _pdfTouchEnd, {passive: true});
|
||
|
||
// Set up progress input
|
||
const progressInput = $('reader-progress-input');
|
||
const progressSuffix = $('reader-progress-suffix');
|
||
const isPdf = isPdfBook;
|
||
|
||
if (progressInput) {
|
||
progressInput.style.display = '';
|
||
if (isPdf) {
|
||
progressInput.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 — must happen BEFORE auto-save timer is started
|
||
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;
|
||
const anchor = bookData ? (bookData.position_anchor || '') : '';
|
||
_currentPositionAnchor = anchor;
|
||
if (isPdf && readerSettings.pdfPaginated) {
|
||
if (fraction > 0 && pdfTotalPages > 1)
|
||
pdfCurrentPage = Math.max(1, Math.round(fraction * (pdfTotalPages - 1)) + 1);
|
||
enterPdfPaginatedMode();
|
||
} else if (isPdf) {
|
||
// PDF scroll mode
|
||
await new Promise(r => requestAnimationFrame(r));
|
||
if (fraction > 0)
|
||
contentEl.scrollTop = fraction * (contentEl.scrollHeight - contentEl.clientHeight);
|
||
} else {
|
||
// EPUB — wait for images so scrollHeight is final
|
||
const imgs = Array.from(contentEl.querySelectorAll('img'));
|
||
if (imgs.length) {
|
||
await Promise.all(imgs.map(img =>
|
||
img.complete ? Promise.resolve()
|
||
: new Promise(r => { img.onload = r; img.onerror = r; })
|
||
));
|
||
}
|
||
await new Promise(r => requestAnimationFrame(r));
|
||
if (!restoreFromAnchor(contentEl, anchor) && fraction > 0) {
|
||
contentEl.scrollTop = fraction * (contentEl.scrollHeight - contentEl.clientHeight);
|
||
}
|
||
}
|
||
} catch (e) {}
|
||
|
||
// 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)
|
||
// Started AFTER restore so an early visibilitychange can't overwrite with position 0
|
||
readerScrollSaveTimer = setInterval(saveReaderProgress, 10000);
|
||
let _scrollDebounce = null;
|
||
contentEl.addEventListener('scroll', () => {
|
||
clearTimeout(_scrollDebounce);
|
||
_scrollDebounce = setTimeout(saveReaderProgress, 2000);
|
||
}, {passive: true});
|
||
|
||
// Restore anchor on viewport resize (e.g. screen rotation, font zoom)
|
||
if (!isPdf) {
|
||
_resizeObserver = new ResizeObserver(() => {
|
||
if (_currentPositionAnchor) {
|
||
requestAnimationFrame(() => restoreFromAnchor(contentEl, _currentPositionAnchor));
|
||
}
|
||
});
|
||
_resizeObserver.observe(contentEl);
|
||
}
|
||
|
||
enterReaderImmersiveMode();
|
||
|
||
} catch (e) {
|
||
overlay.style.display = 'none';
|
||
alert(`Failed to open book: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
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));
|
||
|
||
let anchor = '';
|
||
if (!currentPdfDoc) {
|
||
anchor = getPositionAnchor(contentEl);
|
||
_currentPositionAnchor = anchor;
|
||
}
|
||
|
||
// Cache for sendBeacon on unload
|
||
_lastProgressBeacon = {
|
||
url: `/books/${currentBookId}/progress/`,
|
||
body: JSON.stringify({scroll_fraction: fraction, position_anchor: anchor}),
|
||
};
|
||
|
||
try {
|
||
await fetch(`/books/${currentBookId}/progress/`, {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
||
body: _lastProgressBeacon.body,
|
||
});
|
||
} catch (e) {}
|
||
}
|
||
|
||
function closeReader() {
|
||
exitReaderImmersiveMode();
|
||
// Save progress BEFORE hiding — scrollHeight/clientHeight return 0 once display:none
|
||
saveReaderProgress();
|
||
if (bookmarksDirty) saveBookmarks();
|
||
if (highlightsDirty) saveHighlights();
|
||
|
||
const overlay = $('reader-overlay');
|
||
if (overlay) overlay.style.display = 'none';
|
||
if (readerScrollSaveTimer) {
|
||
clearInterval(readerScrollSaveTimer);
|
||
readerScrollSaveTimer = null;
|
||
}
|
||
if (_resizeObserver) {
|
||
_resizeObserver.disconnect();
|
||
_resizeObserver = null;
|
||
}
|
||
_currentPositionAnchor = '';
|
||
|
||
// Clear search before wiping content
|
||
clearReaderSearch();
|
||
|
||
// Close settings panel if open
|
||
readerSettingsPanelOpen = false;
|
||
const sp = document.getElementById('reader-settings-panel');
|
||
if (sp) sp.remove();
|
||
|
||
// Reset progress input
|
||
const progressInput = $('reader-progress-input');
|
||
if (progressInput) { progressInput.style.display = 'none'; progressInput.value = 0; }
|
||
const progressSuffix = $('reader-progress-suffix');
|
||
if (progressSuffix) progressSuffix.textContent = '';
|
||
|
||
// Remove touch handlers and free image blob URLs
|
||
const contentEl = $('reader-content');
|
||
if (contentEl) {
|
||
contentEl.removeEventListener('touchstart', _pdfTouchStart);
|
||
contentEl.removeEventListener('touchmove', _pdfTouchMove);
|
||
contentEl.removeEventListener('touchend', _pdfTouchEnd);
|
||
contentEl.removeEventListener('touchcancel', _pdfTouchEnd);
|
||
contentEl.classList.remove('pinch-active');
|
||
contentEl.innerHTML = '';
|
||
}
|
||
for (const url of Object.values(currentImageMap)) URL.revokeObjectURL(url);
|
||
currentImageMap = {};
|
||
|
||
// Reset all state
|
||
currentBookId = null;
|
||
currentBookToc = [];
|
||
currentPdfDoc = null;
|
||
currentPdfBuffer = null;
|
||
currentBookmarks = [];
|
||
bookmarksDirty = false;
|
||
currentHighlights = [];
|
||
highlightsDirty = false;
|
||
_lastProgressBeacon = null;
|
||
_lastBookmarkBeacon = null;
|
||
_lastHighlightBeacon = null;
|
||
dismissHighlightPopover();
|
||
pdfCurrentPage = 1;
|
||
pdfTotalPages = 0;
|
||
_pdfPageTextBoxCache = {};
|
||
|
||
// Remove PDF invert class
|
||
if (overlay) overlay.classList.remove('pdf-inverted');
|
||
|
||
}
|
||
|
||
function openTocSidebar() {
|
||
if (!currentBookToc.length) {
|
||
openSidebar('Table of Contents', '<p class="muted">No table of contents found in this book.</p>');
|
||
return;
|
||
}
|
||
let html = '<ul class="toc-list">';
|
||
for (const entry of currentBookToc) {
|
||
const indent = entry.depth * 14;
|
||
// Use data-toc-href — onclick would be stripped by sanitizeSidebarHtml
|
||
html += `<li style="padding-left:${indent}px">
|
||
<button class="btn-link toc-entry" data-toc-href="${escapeHtml(entry.href)}">${escapeHtml(entry.label)}</button>
|
||
</li>`;
|
||
}
|
||
html += '</ul>';
|
||
openSidebar('Table of Contents', html);
|
||
// Attach delegated listener after sidebar body is populated
|
||
const body = $('sidebar-body');
|
||
body.addEventListener('click', function _tocClick(e) {
|
||
const btn = e.target.closest('.toc-entry');
|
||
if (btn) {
|
||
body.removeEventListener('click', _tocClick);
|
||
jumpToTocEntry(btn.getAttribute('data-toc-href') || '');
|
||
}
|
||
});
|
||
}
|
||
|
||
function jumpToTocEntry(href) {
|
||
closeSidebar();
|
||
setTimeout(() => {
|
||
const contentEl = $('reader-content');
|
||
if (!contentEl) return;
|
||
|
||
// PDF page jump
|
||
if (href.startsWith('#pdf-page-')) {
|
||
const target = contentEl.querySelector(href);
|
||
if (target) {
|
||
const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top;
|
||
contentEl.scrollBy({top: top - 16, behavior: 'smooth'});
|
||
}
|
||
return;
|
||
}
|
||
|
||
const hashIdx = href.indexOf('#');
|
||
const fragment = hashIdx >= 0 ? href.slice(hashIdx + 1) : '';
|
||
const filePath = hashIdx >= 0 ? href.slice(0, hashIdx) : href;
|
||
|
||
let target = null;
|
||
if (fragment) {
|
||
target = contentEl.querySelector(`#${CSS.escape(fragment)}`);
|
||
}
|
||
if (!target && filePath) {
|
||
target = Array.from(contentEl.querySelectorAll('[data-epub-src]'))
|
||
.find(el => el.getAttribute('data-epub-src') === filePath) || null;
|
||
}
|
||
if (target) {
|
||
const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top;
|
||
contentEl.scrollBy({top: top - 16, behavior: 'smooth'});
|
||
}
|
||
}, 50);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Reader Settings
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function loadReaderSettings(bookId) {
|
||
// Reset to defaults, then apply per-book overrides
|
||
Object.assign(readerSettings, { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark',
|
||
pdfZoom: 100, pdfInverted: false, pdfPaginated: false, pdfSpread: false });
|
||
try {
|
||
const saved = JSON.parse(localStorage.getItem(`diora_reader_settings_${bookId}`) || '{}');
|
||
Object.assign(readerSettings, saved);
|
||
if (saved.pdfPaginated === undefined) {
|
||
readerSettings.pdfPaginated = window.innerWidth < 768;
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
function saveReaderSettings() {
|
||
if (currentBookId) {
|
||
localStorage.setItem(`diora_reader_settings_${currentBookId}`, JSON.stringify(readerSettings));
|
||
}
|
||
}
|
||
|
||
function applyReaderSettings(isPdf) {
|
||
const overlay = $('reader-overlay');
|
||
const contentEl = $('reader-content');
|
||
if (!overlay || !contentEl) return;
|
||
|
||
if (!isPdf) {
|
||
contentEl.style.fontSize = readerSettings.fontSize + 'px';
|
||
contentEl.style.lineHeight = readerSettings.lineHeight;
|
||
contentEl.style.setProperty('--reader-max-width', readerSettings.maxWidth + 'ch');
|
||
if (_currentPositionAnchor && currentBookId) {
|
||
requestAnimationFrame(() => restoreFromAnchor($('reader-content'), _currentPositionAnchor));
|
||
}
|
||
}
|
||
|
||
// Theme
|
||
overlay.classList.remove('reader-theme-sepia', 'reader-theme-bright');
|
||
if (readerSettings.theme === 'sepia') overlay.classList.add('reader-theme-sepia');
|
||
else if (readerSettings.theme === 'bright') overlay.classList.add('reader-theme-bright');
|
||
|
||
// PDF invert
|
||
if (isPdf && readerSettings.pdfInverted) overlay.classList.add('pdf-inverted');
|
||
else overlay.classList.remove('pdf-inverted');
|
||
}
|
||
|
||
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 <button class="btn btn-sm" id="rs-zoom-minus">−</button> <input type="range" id="rs-zoom" min="50" max="200" step="10" value="${readerSettings.pdfZoom}"> <button class="btn btn-sm" id="rs-zoom-plus">+</button> <span id="rs-zoom-val">${readerSettings.pdfZoom}%</span></label>
|
||
<button class="btn btn-sm ${readerSettings.pdfInverted ? 'active' : ''}" id="rs-invert">Invert</button>
|
||
<button class="btn btn-sm ${readerSettings.pdfSpread ? 'active' : ''}" id="rs-spread">Spread</button>
|
||
`;
|
||
}
|
||
|
||
overlay.insertBefore(panel, 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');
|
||
|
||
zoomRange.addEventListener('input', () => applyPdfZoom(parseInt(zoomRange.value, 10)));
|
||
panel.querySelector('#rs-zoom-minus').addEventListener('click', () =>
|
||
applyPdfZoom(Math.max(50, readerSettings.pdfZoom - 10)));
|
||
panel.querySelector('#rs-zoom-plus').addEventListener('click', () =>
|
||
applyPdfZoom(Math.min(200, readerSettings.pdfZoom + 10)));
|
||
|
||
panel.querySelector('#rs-invert').addEventListener('click', function () {
|
||
readerSettings.pdfInverted = !readerSettings.pdfInverted;
|
||
this.classList.toggle('active', readerSettings.pdfInverted);
|
||
applyReaderSettings(true);
|
||
saveReaderSettings();
|
||
});
|
||
|
||
panel.querySelector('#rs-spread').addEventListener('click', function () {
|
||
readerSettings.pdfSpread = !readerSettings.pdfSpread;
|
||
this.classList.toggle('active', readerSettings.pdfSpread);
|
||
saveReaderSettings();
|
||
reRenderPdf();
|
||
});
|
||
|
||
}
|
||
}
|
||
|
||
async function applyPdfZoom(newZoom) {
|
||
const contentEl2 = $('reader-content');
|
||
const fraction = contentEl2
|
||
? contentEl2.scrollTop / (contentEl2.scrollHeight - contentEl2.clientHeight || 1)
|
||
: 0;
|
||
readerSettings.pdfZoom = newZoom;
|
||
const zoomRange = document.getElementById('rs-zoom');
|
||
const zoomVal = document.getElementById('rs-zoom-val');
|
||
if (zoomRange) zoomRange.value = newZoom;
|
||
if (zoomVal) zoomVal.textContent = newZoom + '%';
|
||
saveReaderSettings();
|
||
if (readerSettings.pdfPaginated) {
|
||
pdfSmartZoomPage(pdfCurrentPage);
|
||
} else {
|
||
await reRenderPdf();
|
||
// Wait for two animation frames so the browser finishes layout
|
||
// before reading scrollHeight for position restoration
|
||
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||
if (contentEl2 && fraction > 0) {
|
||
contentEl2.scrollTop = fraction * (contentEl2.scrollHeight - contentEl2.clientHeight);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function reRenderPdf() {
|
||
if (!currentPdfBuffer) return;
|
||
const contentEl = $('reader-content');
|
||
if (!contentEl) return;
|
||
const overlay = $('reader-overlay');
|
||
const loadingEl = document.createElement('div');
|
||
loadingEl.className = 'pdf-loading-overlay';
|
||
loadingEl.innerHTML = '<span class="pdf-loading-spinner"></span>';
|
||
if (overlay) overlay.appendChild(loadingEl);
|
||
await renderPdf(currentPdfBuffer, contentEl);
|
||
loadingEl.remove();
|
||
if (readerSettings.pdfPaginated) enterPdfPaginatedMode();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// PDF Paginated Mode
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function enterPdfPaginatedMode() {
|
||
const contentEl = $('reader-content');
|
||
if (!contentEl) return;
|
||
contentEl.classList.add('pdf-paginated');
|
||
contentEl.style.overflow = 'hidden';
|
||
const pdfVp = document.getElementById('pdf-viewport');
|
||
if (pdfVp) pdfVp.style.zoom = 1;
|
||
|
||
const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper');
|
||
wrappers.forEach((w, i) => {
|
||
w.style.display = (i + 1 === pdfCurrentPage) ? '' : 'none';
|
||
});
|
||
|
||
pdfSmartZoomPage(pdfCurrentPage);
|
||
|
||
// Tap left/right to navigate
|
||
contentEl.addEventListener('click', _pdfPaginatedClick);
|
||
}
|
||
|
||
function exitPdfPaginatedMode() {
|
||
const contentEl = $('reader-content');
|
||
if (!contentEl) return;
|
||
contentEl.classList.remove('pdf-paginated');
|
||
contentEl.style.overflow = '';
|
||
contentEl.removeEventListener('click', _pdfPaginatedClick);
|
||
reRenderPdf();
|
||
}
|
||
|
||
function _pdfPaginatedClick(e) {
|
||
const w = e.currentTarget.clientWidth;
|
||
if (e.clientX < w * 0.4) pdfGoToPage(pdfCurrentPage - 1);
|
||
else if (e.clientX > w * 0.6) pdfGoToPage(pdfCurrentPage + 1);
|
||
}
|
||
|
||
function _pdfTouchStart(e) {
|
||
if (e.touches.length === 2) {
|
||
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
||
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
||
_pinchStartDist = Math.hypot(dx, dy);
|
||
_pinchStartZoom = readerSettings.pdfZoom;
|
||
_isPinching = true;
|
||
const ce = $('reader-content');
|
||
if (ce) ce.classList.add('pinch-active');
|
||
} else {
|
||
_touchStartX = e.touches[0].clientX;
|
||
_isPinching = false;
|
||
}
|
||
}
|
||
|
||
function _pdfTouchMove(e) {
|
||
if (e.touches.length !== 2 || !_isPinching) return;
|
||
e.preventDefault();
|
||
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
||
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
||
const dist = Math.hypot(dx, dy);
|
||
if (_pinchStartDist === 0) return;
|
||
const liveZoom = Math.max(50, Math.min(200, _pinchStartZoom * (dist / _pinchStartDist)));
|
||
const vp = document.getElementById('pdf-viewport');
|
||
if (vp) vp.style.zoom = liveZoom / 100;
|
||
}
|
||
|
||
function _pdfTouchEnd(e) {
|
||
if (_isPinching) {
|
||
_isPinching = false;
|
||
const ce = $('reader-content');
|
||
if (ce) ce.classList.remove('pinch-active');
|
||
const vp = document.getElementById('pdf-viewport');
|
||
const liveZoom = vp ? parseFloat(vp.style.zoom) * 100 : readerSettings.pdfZoom;
|
||
const snapped = Math.max(50, Math.min(200, Math.round(liveZoom / 10) * 10));
|
||
applyPdfZoom(snapped);
|
||
return;
|
||
}
|
||
if (!readerSettings.pdfPaginated) return;
|
||
const delta = e.changedTouches[0].clientX - _touchStartX;
|
||
if (delta > 50) pdfGoToPage(pdfCurrentPage - 1);
|
||
else if (delta < -50) pdfGoToPage(pdfCurrentPage + 1);
|
||
}
|
||
|
||
function pdfGoToPage(n) {
|
||
if (!currentPdfDoc) return;
|
||
n = Math.max(1, Math.min(pdfTotalPages, n));
|
||
if (n === pdfCurrentPage) return;
|
||
const contentEl = $('reader-content');
|
||
if (!contentEl) return;
|
||
|
||
const oldWrapper = contentEl.querySelector(`#pdf-page-${pdfCurrentPage}`);
|
||
if (oldWrapper) oldWrapper.style.display = 'none';
|
||
|
||
pdfCurrentPage = n;
|
||
const newWrapper = contentEl.querySelector(`#pdf-page-${pdfCurrentPage}`);
|
||
if (newWrapper) newWrapper.style.display = '';
|
||
|
||
pdfSmartZoomPage(pdfCurrentPage);
|
||
|
||
const progressInput = $('reader-progress-input');
|
||
if (progressInput) progressInput.value = pdfCurrentPage;
|
||
}
|
||
|
||
async function pdfSmartZoomPage(pageNum) {
|
||
if (!currentPdfDoc) return;
|
||
const contentEl = $('reader-content');
|
||
if (!contentEl) return;
|
||
const wrapper = contentEl.querySelector(`#pdf-page-${pageNum}`);
|
||
if (!wrapper) return;
|
||
const canvas = wrapper.querySelector('canvas');
|
||
if (!canvas) return;
|
||
|
||
const page = await currentPdfDoc.getPage(pageNum);
|
||
const naturalVp = page.getViewport({scale: 1});
|
||
const pageW = naturalVp.width;
|
||
const pageH = naturalVp.height;
|
||
|
||
let bbox = _pdfPageTextBoxCache[pageNum];
|
||
if (!bbox) {
|
||
bbox = await _computePdfTextBox(page, pageW, pageH);
|
||
_pdfPageTextBoxCache[pageNum] = bbox;
|
||
}
|
||
|
||
const containerW = contentEl.clientWidth;
|
||
const containerH = contentEl.clientHeight;
|
||
const contentW = bbox.x2 - bbox.x1;
|
||
const contentH = bbox.y2 - bbox.y1;
|
||
const pad = 12;
|
||
const scale = Math.min(
|
||
(containerW - pad * 2) / contentW,
|
||
(containerH - pad * 2) / contentH
|
||
) * (readerSettings.pdfZoom / 100);
|
||
|
||
// Re-render canvas at new scale if significantly different
|
||
const currentScale = canvas.width / naturalVp.width;
|
||
if (Math.abs(scale - currentScale) / currentScale > 0.05) {
|
||
const vp = page.getViewport({scale});
|
||
canvas.width = vp.width;
|
||
canvas.height = vp.height;
|
||
await page.render({canvasContext: canvas.getContext('2d'), viewport: vp}).promise;
|
||
}
|
||
|
||
// Position canvas to center the text bounding box
|
||
const renderedScale = canvas.width / naturalVp.width;
|
||
const offsetX = -renderedScale * (bbox.x1 - pad) + (containerW - renderedScale * contentW - pad * 2) / 2;
|
||
// PDF y-axis is bottom-up; canvas is top-down
|
||
const offsetY = -renderedScale * (pageH - bbox.y2 - pad) + (containerH - renderedScale * contentH - pad * 2) / 2;
|
||
canvas.style.transform = `translate(${offsetX}px, ${offsetY}px)`;
|
||
wrapper.style.overflow = 'hidden';
|
||
wrapper.style.width = containerW + 'px';
|
||
wrapper.style.height = containerH + 'px';
|
||
}
|
||
|
||
async function _computePdfTextBox(page, pageW, pageH) {
|
||
// Tier 1: text-based
|
||
try {
|
||
const tc = await page.getTextContent();
|
||
if (tc.items && tc.items.length) {
|
||
let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity;
|
||
for (const item of tc.items) {
|
||
if (!item.transform) continue;
|
||
const tx = item.transform[4], ty = item.transform[5];
|
||
const iw = item.width || 0, ih = item.height || 0;
|
||
if (tx < x1) x1 = tx;
|
||
if (ty < y1) y1 = ty;
|
||
if (tx + iw > x2) x2 = tx + iw;
|
||
if (ty + ih > y2) y2 = ty + ih;
|
||
}
|
||
const area = (x2 - x1) * (y2 - y1);
|
||
if (isFinite(x1) && area > pageW * pageH * 0.25) {
|
||
return {x1, y1, x2, y2};
|
||
}
|
||
}
|
||
} catch (e) {}
|
||
|
||
// Tier 2: pixel analysis at scale 0.3
|
||
try {
|
||
const lowScale = 0.3;
|
||
const vp = page.getViewport({scale: lowScale});
|
||
const offCanvas = document.createElement('canvas');
|
||
offCanvas.width = vp.width;
|
||
offCanvas.height = vp.height;
|
||
const ctx = offCanvas.getContext('2d');
|
||
await page.render({canvasContext: ctx, viewport: vp}).promise;
|
||
const {data, width, height} = ctx.getImageData(0, 0, vp.width, vp.height);
|
||
|
||
let rMin = height, rMax = 0, cMin = width, cMax = 0;
|
||
for (let r = 0; r < height; r++) {
|
||
for (let c = 0; c < width; c++) {
|
||
const idx = (r * width + c) * 4;
|
||
if (data[idx] + data[idx+1] + data[idx+2] < 720) {
|
||
if (r < rMin) rMin = r;
|
||
if (r > rMax) rMax = r;
|
||
if (c < cMin) cMin = c;
|
||
if (c > cMax) cMax = c;
|
||
}
|
||
}
|
||
}
|
||
if (rMin < rMax && cMin < cMax) {
|
||
return {
|
||
x1: cMin / lowScale,
|
||
y1: (height - rMax) / lowScale,
|
||
x2: cMax / lowScale,
|
||
y2: (height - rMin) / lowScale,
|
||
};
|
||
}
|
||
} catch (e) {}
|
||
|
||
// Fallback: full page
|
||
return {x1: 0, y1: 0, x2: pageW, y2: pageH};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Bookmarks
|
||
// ---------------------------------------------------------------------------
|
||
|
||
async function loadBookmarks(bookId) {
|
||
try {
|
||
const res = await fetch(`/books/${bookId}/bookmarks/`);
|
||
const {ct, iv} = await res.json();
|
||
if (ct) {
|
||
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;
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Radio sidebar (compact player)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function openRadioSidebar() {
|
||
const stationName = currentStation ? escapeHtml(currentStation.name) : '— no station —';
|
||
const track = currentTrack ? escapeHtml(currentTrack) : '';
|
||
const vol = document.getElementById('volume')?.value ?? 204;
|
||
|
||
const rows = [...document.querySelectorAll('#saved-tbody tr[data-id]')];
|
||
const stationsHtml = rows.length
|
||
? rows.map(r => `<li><button class="btn btn-sm rsb-play-btn" data-url="${escapeHtml(r.dataset.url || '')}" data-name="${escapeHtml(r.dataset.name || '')}">${escapeHtml(r.dataset.name || '')}</button></li>`).join('')
|
||
: `<li class="muted">No saved stations.</li>`;
|
||
|
||
const html = `
|
||
<div class="rsb-nowplaying">
|
||
<div class="rsb-station-name">${stationName}</div>
|
||
${track ? `<div class="rsb-track muted">${track}</div>` : ''}
|
||
</div>
|
||
<div class="rsb-controls">
|
||
<button class="btn ${isPlaying ? 'playing' : ''}" id="rsb-playstop">${isPlaying ? '⏹ Stop' : '▶ Play'}</button>
|
||
<label class="rsb-vol">vol <input type="range" id="rsb-volume" min="0" max="255" value="${vol}"></label>
|
||
</div>
|
||
<ul class="rsb-station-list">${stationsHtml}</ul>
|
||
`;
|
||
|
||
openSidebar('Radio', html);
|
||
|
||
const body = $('sidebar-body');
|
||
body.querySelector('#rsb-playstop').addEventListener('click', togglePlayStop);
|
||
body.querySelector('#rsb-volume').addEventListener('input', function () {
|
||
const v = this.value;
|
||
const mainSlider = document.getElementById('volume');
|
||
const mainNum = document.getElementById('volume-num');
|
||
if (mainSlider) mainSlider.value = v;
|
||
if (mainNum) mainNum.value = v;
|
||
audio.volume = v / 255;
|
||
});
|
||
body.querySelectorAll('.rsb-play-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => playStation(btn.dataset.url, btn.dataset.name, null));
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Init
|
||
// ---------------------------------------------------------------------------
|
||
|
||
(function init() {
|
||
// Migrate PBKDF2-derived key stored by login/register form
|
||
if (window.USER_ID) {
|
||
const pending = localStorage.getItem('diora_pending_enc_key');
|
||
if (pending) {
|
||
localStorage.setItem(`diora_enc_key_${window.USER_ID}`, pending);
|
||
localStorage.removeItem('diora_pending_enc_key');
|
||
}
|
||
}
|
||
|
||
// Populate saved stations from server-side context if available
|
||
if (typeof INITIAL_SAVED !== 'undefined' && Array.isArray(INITIAL_SAVED)) {
|
||
// The server already renders saved stations in the template; nothing extra needed.
|
||
// But if JS-rendered saved tab were needed we'd call addSavedRow here.
|
||
}
|
||
|
||
// Seed podcast feeds from server context
|
||
if (typeof INITIAL_PODCAST_FEEDS !== 'undefined' && Array.isArray(INITIAL_PODCAST_FEEDS)) {
|
||
podcastFeeds = INITIAL_PODCAST_FEEDS;
|
||
}
|
||
|
||
// Wire seek slider
|
||
const seekSlider = $('seek-slider');
|
||
if (seekSlider) {
|
||
seekSlider.addEventListener('input', function () {
|
||
if (podcastMode) audio.currentTime = parseInt(this.value, 10);
|
||
});
|
||
}
|
||
|
||
// Restore persisted volume, fall back to slider default
|
||
const volSlider = $('volume');
|
||
if (volSlider) {
|
||
const saved = localStorage.getItem('diora_volume');
|
||
const vol = saved !== null ? parseInt(saved, 10) : parseInt(volSlider.value, 10);
|
||
setVolume(vol);
|
||
}
|
||
|
||
// Load recommendations on page load
|
||
loadRecommendations();
|
||
|
||
// Initialise focus timer display
|
||
renderTimer();
|
||
|
||
// Initialise mood/genre chips
|
||
initMoodChips();
|
||
|
||
// Initialise curated station lists
|
||
initCuratedLists();
|
||
|
||
// Show curated lists again when search input is cleared
|
||
const searchInput = document.getElementById('search-input');
|
||
if (searchInput) {
|
||
searchInput.addEventListener('input', function () {
|
||
if (this.value === '') {
|
||
const curated = document.getElementById('curated-lists');
|
||
if (curated) curated.style.display = '';
|
||
}
|
||
});
|
||
}
|
||
|
||
// Load focus session stats
|
||
loadFocusStats();
|
||
|
||
// Apply encrypted wallpaper (if set)
|
||
applyEncryptedBackground();
|
||
|
||
// Init book drop zone
|
||
initBookDropZone();
|
||
|
||
// 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);
|
||
|
||
// Hourly background feed refresh (only when authenticated)
|
||
if (IS_AUTHENTICATED) {
|
||
setInterval(refreshAllFeeds, 60 * 60 * 1000);
|
||
}
|
||
})();
|