- Feed title in inbox and queue is now a link that opens the feed's episode list - Episode title in inbox is now clickable and opens the show notes sidebar - Backend: include description in inbox API response so sidebar has content Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
4393 lines
152 KiB
JavaScript
4393 lines
152 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();
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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);
|
||
|
||
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;
|
||
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 = 200;
|
||
|
||
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 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); }
|
||
}
|
||
}
|
||
});
|
||
|
||
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 > 5 * 1024 * 1024) {
|
||
alert('Image must be 5 MB or smaller.');
|
||
return;
|
||
}
|
||
|
||
const key = await getOrCreateEncKey();
|
||
const buf = await file.arrayBuffer();
|
||
const {iv, ciphertext} = await encryptBytes(key, buf);
|
||
|
||
const res = await fetch('/accounts/background/upload/', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
||
body: JSON.stringify({iv, ciphertext, mime_type: file.type, file_size: file.size}),
|
||
});
|
||
const data = await res.json();
|
||
if (!data.ok) throw new Error(data.error || 'upload failed');
|
||
}
|
||
|
||
async function applyEncryptedBackground() {
|
||
if (typeof ENCRYPTED_BG === 'undefined' || !ENCRYPTED_BG.ciphertext) return;
|
||
try {
|
||
const key = await getOrCreateEncKey();
|
||
const plain = await decryptBytes(key, ENCRYPTED_BG.iv, ENCRYPTED_BG.ciphertext);
|
||
const blob = new Blob([plain], {type: ENCRYPTED_BG.mime || 'image/jpeg'});
|
||
const url = URL.createObjectURL(blob);
|
||
document.body.style.backgroundImage = `url('${url}')`;
|
||
document.body.style.backgroundSize = 'cover';
|
||
document.body.style.backgroundPosition = 'center';
|
||
document.body.style.backgroundAttachment = 'fixed';
|
||
|
||
// Analyze brightness for accent/scheme
|
||
analyzeBackground(url).then(({bright, bgLuminance}) => {
|
||
setScheme(bright);
|
||
applyAccent(pickBestAccent(bgLuminance));
|
||
});
|
||
} catch (e) {
|
||
console.warn('Could not decrypt background:', e);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// EPUB parser (requires JSZip)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function resolveEpubPath(base, relative) {
|
||
if (!relative) return '';
|
||
if (relative.startsWith('/')) return relative.slice(1);
|
||
const hashIdx = relative.indexOf('#');
|
||
const frag = hashIdx >= 0 ? relative.slice(hashIdx) : '';
|
||
const rel = hashIdx >= 0 ? relative.slice(0, hashIdx) : relative;
|
||
const parts = (base + rel).split('/');
|
||
const resolved = [];
|
||
for (const p of parts) {
|
||
if (p === '..') resolved.pop();
|
||
else if (p !== '.') resolved.push(p);
|
||
}
|
||
return resolved.join('/') + frag;
|
||
}
|
||
|
||
async function parseEpub(arrayBuffer) {
|
||
const zip = await JSZip.loadAsync(arrayBuffer);
|
||
|
||
// 1. Find OPF via container.xml
|
||
const containerXml = await zip.file('META-INF/container.xml').async('text');
|
||
const containerDoc = new DOMParser().parseFromString(containerXml, 'application/xml');
|
||
const rootfileEl = containerDoc.querySelector('rootfile');
|
||
if (!rootfileEl) throw new Error('No rootfile in container.xml');
|
||
const opfPath = rootfileEl.getAttribute('full-path');
|
||
const opfDir = opfPath.includes('/') ? opfPath.substring(0, opfPath.lastIndexOf('/') + 1) : '';
|
||
|
||
// 2. Parse OPF
|
||
const opfText = await zip.file(opfPath).async('text');
|
||
const opfDoc = new DOMParser().parseFromString(opfText, 'application/xml');
|
||
|
||
const title = opfDoc.querySelector('metadata > title, metadata > *|title')?.textContent?.trim() || 'Unknown Title';
|
||
const author = opfDoc.querySelector('metadata > creator, metadata > *|creator')?.textContent?.trim() || 'Unknown Author';
|
||
|
||
// 3. Build manifest: id → {href, mediaType, properties}
|
||
const manifest = {};
|
||
opfDoc.querySelectorAll('manifest > item').forEach(item => {
|
||
manifest[item.getAttribute('id')] = {
|
||
href: opfDir + item.getAttribute('href'),
|
||
mediaType: item.getAttribute('media-type') || '',
|
||
properties: item.getAttribute('properties') || '',
|
||
};
|
||
});
|
||
|
||
// 4. Build image map: abs zip path → blob URL
|
||
const imageMap = {};
|
||
for (const {href, mediaType} of Object.values(manifest)) {
|
||
if (mediaType.startsWith('image/')) {
|
||
try {
|
||
const buf = await zip.file(href).async('arraybuffer');
|
||
imageMap[href] = URL.createObjectURL(new Blob([buf], {type: mediaType}));
|
||
} catch (e) { /* missing asset */ }
|
||
}
|
||
}
|
||
|
||
// 5. Parse TOC
|
||
const toc = await _parseEpubToc(zip, opfDoc, manifest);
|
||
|
||
// 6. Get spine and concatenate chapters
|
||
const spineItems = Array.from(opfDoc.querySelectorAll('spine > itemref'))
|
||
.map(ref => manifest[ref.getAttribute('idref')]?.href)
|
||
.filter(Boolean);
|
||
|
||
const parts = [];
|
||
for (let i = 0; i < spineItems.length; i++) {
|
||
const href = spineItems[i];
|
||
try {
|
||
const chapterText = await zip.file(href).async('text');
|
||
const chapterDir = href.includes('/') ? href.substring(0, href.lastIndexOf('/') + 1) : '';
|
||
const withBlobs = _injectImageBlobs(chapterText, chapterDir, imageMap);
|
||
const sanitized = sanitizeEpubHtml(withBlobs);
|
||
parts.push(`<div id="epub-chapter-${i}" data-epub-src="${href}">${sanitized}</div>`);
|
||
} catch (e) { /* skip missing */ }
|
||
}
|
||
|
||
return {title, author, html: parts.join('\n'), toc, imageMap};
|
||
}
|
||
|
||
async function _parseEpubToc(zip, opfDoc, manifest) {
|
||
// Try EPUB3 nav document
|
||
const navItem = Object.values(manifest).find(m => m.properties.includes('nav'));
|
||
if (navItem) {
|
||
try {
|
||
const navText = await zip.file(navItem.href).async('text');
|
||
const navDoc = new DOMParser().parseFromString(navText, 'application/xhtml+xml');
|
||
const tocNav = navDoc.querySelector('nav[epub\\:type="toc"]') || navDoc.querySelector('nav');
|
||
if (tocNav) {
|
||
const ol = tocNav.querySelector('ol');
|
||
if (ol) return _parseTocOl(ol, navItem.href, 0);
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
// Fall back to EPUB2 NCX
|
||
const ncxItem = Object.values(manifest).find(m => m.mediaType === 'application/x-dtbncx+xml');
|
||
if (ncxItem) {
|
||
try {
|
||
const ncxText = await zip.file(ncxItem.href).async('text');
|
||
const ncxDoc = new DOMParser().parseFromString(ncxText, 'application/xml');
|
||
const ncxDir = ncxItem.href.includes('/') ? ncxItem.href.substring(0, ncxItem.href.lastIndexOf('/') + 1) : '';
|
||
return _parseNcxNavMap(ncxDoc.querySelector('navMap'), ncxDir, 0);
|
||
} catch (e) {}
|
||
}
|
||
|
||
return [];
|
||
}
|
||
|
||
function _parseTocOl(ol, navHref, depth) {
|
||
if (!ol || depth > 5) return [];
|
||
const navDir = navHref.includes('/') ? navHref.substring(0, navHref.lastIndexOf('/') + 1) : '';
|
||
const items = [];
|
||
for (const li of Array.from(ol.children)) {
|
||
const a = li.querySelector(':scope > a') || li.querySelector(':scope > span');
|
||
if (a) {
|
||
const rawHref = a.getAttribute('href') || '';
|
||
items.push({
|
||
label: a.textContent.trim(),
|
||
href: rawHref ? resolveEpubPath(navDir, rawHref) : '',
|
||
depth,
|
||
});
|
||
}
|
||
const childOl = li.querySelector(':scope > ol');
|
||
if (childOl) items.push(..._parseTocOl(childOl, navHref, depth + 1));
|
||
}
|
||
return items;
|
||
}
|
||
|
||
function _parseNcxNavMap(navMap, ncxDir, depth) {
|
||
if (!navMap || depth > 5) return [];
|
||
const items = [];
|
||
for (const navPoint of Array.from(navMap.children)) {
|
||
if (navPoint.tagName !== 'navPoint') continue;
|
||
const label = navPoint.querySelector('navLabel > text')?.textContent?.trim() || '';
|
||
const src = navPoint.querySelector('content')?.getAttribute('src') || '';
|
||
items.push({label, href: src ? resolveEpubPath(ncxDir, src) : '', depth});
|
||
items.push(..._parseNcxNavMap(navPoint, ncxDir, depth + 1));
|
||
}
|
||
return items;
|
||
}
|
||
|
||
function _resolveImageBlob(imageMap, absPath) {
|
||
if (imageMap[absPath]) return imageMap[absPath];
|
||
// Fallback: match by decoded filename only
|
||
const name = decodeURIComponent(absPath.split('/').pop()).toLowerCase();
|
||
for (const [k, v] of Object.entries(imageMap)) {
|
||
if (decodeURIComponent(k.split('/').pop()).toLowerCase() === name) return v;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Replace image src/href in raw chapter HTML text with blob URLs before DOMParser sees it.
|
||
// This avoids relying on innerHTML serialisation preserving attributes we set on DOM nodes.
|
||
function _injectImageBlobs(html, chapterDir, imageMap) {
|
||
function subst(src) {
|
||
if (!src || src.startsWith('blob:') || src.startsWith('data:') || src.startsWith('http')) return src;
|
||
return _resolveImageBlob(imageMap, resolveEpubPath(chapterDir, src)) || '';
|
||
}
|
||
// <img src="...">
|
||
html = html.replace(/(<img\b[^>]*?)\bsrc="([^"]*)"/gi,
|
||
(_, pre, src) => { const b = subst(src); return b ? `${pre}src="${b}"` : pre; });
|
||
// SVG <image xlink:href="...">
|
||
html = html.replace(/(<image\b[^>]*?)\bxlink:href="([^"]*)"/gi,
|
||
(_, pre, src) => { const b = subst(src); return b ? `${pre}xlink:href="${b}"` : pre; });
|
||
// SVG <image href="...">
|
||
html = html.replace(/(<image\b[^>]*?)\bhref="([^"]*)"/gi,
|
||
(_, pre, src) => { const b = subst(src); return b ? `${pre}href="${b}"` : pre; });
|
||
return html;
|
||
}
|
||
|
||
function sanitizeEpubHtml(html) {
|
||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||
|
||
doc.querySelectorAll('script, iframe, object, embed, style, head, meta, link').forEach(el => el.remove());
|
||
|
||
doc.querySelectorAll('*').forEach(el => {
|
||
Array.from(el.attributes).forEach(attr => {
|
||
if (attr.name.startsWith('on')) el.removeAttribute(attr.name);
|
||
});
|
||
const tag = el.tagName.toLowerCase();
|
||
if (tag === 'img') {
|
||
const src = el.getAttribute('src') || '';
|
||
// Remove any non-blob, non-data src that slipped through (broken relative paths)
|
||
if (src && !src.startsWith('blob:') && !src.startsWith('data:')) el.removeAttribute('src');
|
||
}
|
||
if (el.tagName === 'A') {
|
||
const href = el.getAttribute('href') || '';
|
||
if (href.startsWith('http://') || href.startsWith('https://')) {
|
||
el.setAttribute('target', '_blank');
|
||
el.setAttribute('rel', 'noopener noreferrer');
|
||
} else {
|
||
el.removeAttribute('href');
|
||
el.style.cursor = 'default';
|
||
}
|
||
}
|
||
});
|
||
|
||
return doc.body ? doc.body.innerHTML : doc.documentElement.innerHTML;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Books
|
||
// ---------------------------------------------------------------------------
|
||
|
||
let currentBookId = null;
|
||
let currentBookToc = [];
|
||
let currentImageMap = {};
|
||
let readerScrollSaveTimer = null;
|
||
const bookMetaCache = {}; // id → {title, author, type}
|
||
|
||
// Reader settings
|
||
let readerSettings = { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark',
|
||
pdfZoom: 100, pdfInverted: false, pdfPaginated: false };
|
||
let readerSettingsPanelOpen = false;
|
||
let currentPdfDoc = null;
|
||
let currentPdfBuffer = null;
|
||
|
||
// Bookmarks
|
||
let currentBookmarks = [];
|
||
let bookmarksDirty = false;
|
||
|
||
// Highlights
|
||
let currentHighlights = [];
|
||
let highlightsDirty = false;
|
||
let currentHighlightPopover = null;
|
||
|
||
// Search
|
||
let searchMatches = [];
|
||
let searchMatchIndex = -1;
|
||
let searchOriginalContent = null;
|
||
let readerSearchOpen = false;
|
||
|
||
// PDF paginated
|
||
let pdfCurrentPage = 1;
|
||
let pdfTotalPages = 0;
|
||
let _pdfPageTextBoxCache = {};
|
||
let _touchStartX = 0;
|
||
|
||
if (typeof pdfjsLib !== 'undefined') {
|
||
pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/js/pdf.worker.min.js';
|
||
}
|
||
const DEFAULT_FOCUS_STATION = {
|
||
url: 'https://ice5.somafm.com/groovesalad-128-aac',
|
||
name: 'SomaFM Groove Salad',
|
||
};
|
||
|
||
async function loadBookList() {
|
||
if (!IS_AUTHENTICATED) return;
|
||
const listEl = $('book-list');
|
||
if (!listEl) return;
|
||
listEl.innerHTML = '<p class="muted">Loading…</p>';
|
||
|
||
try {
|
||
const res = await fetch('/books/');
|
||
if (!res.ok) {
|
||
listEl.innerHTML = `<p class="muted">Server error ${res.status} loading books.</p>`;
|
||
return;
|
||
}
|
||
const books = await res.json();
|
||
if (!Array.isArray(books)) {
|
||
listEl.innerHTML = `<p class="muted">Unexpected response from server.</p>`;
|
||
return;
|
||
}
|
||
if (!books.length) {
|
||
listEl.innerHTML = '<p class="muted">No books yet. Drop an .epub or .pdf above.</p>';
|
||
return;
|
||
}
|
||
|
||
const key = await getOrCreateEncKey();
|
||
const decrypted = [];
|
||
for (const b of books) {
|
||
try {
|
||
const metaBuf = await decryptBytes(key, b.meta_iv, b.meta_ct);
|
||
const meta = JSON.parse(new TextDecoder().decode(metaBuf));
|
||
bookMetaCache[b.id] = {title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub'};
|
||
decrypted.push({id: b.id, title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', scroll_fraction: b.scroll_fraction, uploaded_at: b.uploaded_at});
|
||
} catch (e) {
|
||
bookMetaCache[b.id] = {title: `Book #${b.id}`, author: '', type: 'epub'};
|
||
decrypted.push({id: b.id, title: `Book #${b.id}`, author: '', type: 'epub', scroll_fraction: b.scroll_fraction, uploaded_at: b.uploaded_at});
|
||
}
|
||
}
|
||
renderBookList(decrypted);
|
||
} catch (e) {
|
||
if (listEl) listEl.innerHTML = `<p class="muted">Error: ${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);
|
||
html += `<div class="book-item">
|
||
<div class="book-item-info">
|
||
<strong class="book-title">${escapeHtml(b.title)}</strong>
|
||
<span class="muted book-author">${escapeHtml(b.author)}</span>
|
||
${pct > 0 ? `<span class="muted book-progress">${pct}% read</span>` : ''}
|
||
</div>
|
||
<div class="book-item-actions">
|
||
<button class="btn btn-sm" onclick="openBook(${b.id})">Open</button>
|
||
<button class="btn btn-sm btn-danger" onclick="deleteBook(${b.id})">Delete</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
listEl.innerHTML = html;
|
||
}
|
||
|
||
function bookFileSelected(input) {
|
||
const file = input.files[0];
|
||
if (!file) return;
|
||
uploadEbook(file);
|
||
input.value = '';
|
||
}
|
||
|
||
function initBookDropZone() {
|
||
// 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 > 10 * 1024 * 1024) {
|
||
if (statusEl) statusEl.textContent = 'File too large (max 10 MB).';
|
||
return;
|
||
}
|
||
|
||
if (statusEl) statusEl.textContent = 'Encrypting…';
|
||
|
||
try {
|
||
const buf = await file.arrayBuffer();
|
||
|
||
let title = file.name.replace(/\.(epub|pdf)$/i, '');
|
||
let author = '';
|
||
const type = isPdf ? 'pdf' : 'epub';
|
||
|
||
if (isPdf) {
|
||
try {
|
||
const pdfDoc = await pdfjsLib.getDocument({data: new Uint8Array(buf.slice(0))}).promise;
|
||
const meta = await pdfDoc.getMetadata();
|
||
title = meta.info?.Title?.trim() || title;
|
||
author = meta.info?.Author?.trim() || '';
|
||
} catch (e) { /* use filename as title */ }
|
||
} else {
|
||
try {
|
||
const zip = await JSZip.loadAsync(buf.slice(0));
|
||
const containerXml = await zip.file('META-INF/container.xml').async('text');
|
||
const containerDoc = new DOMParser().parseFromString(containerXml, 'application/xml');
|
||
const opfPath = containerDoc.querySelector('rootfile')?.getAttribute('full-path');
|
||
if (opfPath) {
|
||
const opfText = await zip.file(opfPath).async('text');
|
||
const opfDoc = new DOMParser().parseFromString(opfText, 'application/xml');
|
||
title = opfDoc.querySelector('metadata > title, metadata > *|title')?.textContent?.trim() || title;
|
||
author = opfDoc.querySelector('metadata > creator, metadata > *|creator')?.textContent?.trim() || '';
|
||
}
|
||
} catch (e) { /* use filename as title */ }
|
||
}
|
||
|
||
const key = await getOrCreateEncKey();
|
||
const metaJson = new TextEncoder().encode(JSON.stringify({title, author, filename: file.name, type}));
|
||
const [metaEnc, dataEnc] = await Promise.all([
|
||
encryptBytes(key, metaJson),
|
||
encryptBytes(key, buf),
|
||
]);
|
||
|
||
if (statusEl) statusEl.textContent = 'Uploading…';
|
||
|
||
const res = await fetch('/books/upload/', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
||
body: JSON.stringify({
|
||
meta_ct: metaEnc.ciphertext,
|
||
meta_iv: metaEnc.iv,
|
||
data_ct: dataEnc.ciphertext,
|
||
data_iv: dataEnc.iv,
|
||
}),
|
||
});
|
||
const data = await res.json();
|
||
if (data.ok) {
|
||
if (statusEl) statusEl.textContent = `✓ "${title}" uploaded`;
|
||
loadBookList();
|
||
} else {
|
||
if (statusEl) statusEl.textContent = 'Error: ' + (data.error || 'upload failed');
|
||
}
|
||
} catch (e) {
|
||
if (statusEl) statusEl.textContent = 'Upload failed: ' + e.message;
|
||
}
|
||
}
|
||
|
||
async function _parsePdfOutline(pdf, items, depth) {
|
||
depth = depth || 0;
|
||
const result = [];
|
||
for (const item of items) {
|
||
let href = '';
|
||
if (item.dest) {
|
||
try {
|
||
const dest = typeof item.dest === 'string' ? await pdf.getDestination(item.dest) : item.dest;
|
||
if (dest) {
|
||
const pageIndex = await pdf.getPageIndex(dest[0]);
|
||
href = `#pdf-page-${pageIndex + 1}`;
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
result.push({label: item.title || '(untitled)', href, depth});
|
||
if (item.items && item.items.length) {
|
||
result.push(...await _parsePdfOutline(pdf, item.items, depth + 1));
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
async function renderPdf(arrayBuffer, contentEl, scaleOverride) {
|
||
const pdf = currentPdfDoc || await pdfjsLib.getDocument({data: new Uint8Array(arrayBuffer)}).promise;
|
||
currentPdfDoc = pdf;
|
||
|
||
let pdfTitle = '', pdfAuthor = '';
|
||
try {
|
||
const meta = await pdf.getMetadata();
|
||
pdfTitle = meta.info?.Title?.trim() || '';
|
||
pdfAuthor = meta.info?.Author?.trim() || '';
|
||
} catch (e) {}
|
||
|
||
let toc = [];
|
||
try {
|
||
const outline = await pdf.getOutline();
|
||
if (outline && outline.length) toc = await _parsePdfOutline(pdf, outline);
|
||
} catch (e) {}
|
||
|
||
contentEl.innerHTML = '';
|
||
|
||
const containerWidth = contentEl.clientWidth - 32;
|
||
|
||
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
|
||
const page = await pdf.getPage(pageNum);
|
||
const naturalVp = page.getViewport({scale: 1});
|
||
const scale = scaleOverride != null ? scaleOverride
|
||
: Math.max(0.5, (containerWidth / naturalVp.width) * (readerSettings.pdfZoom / 100));
|
||
const viewport = page.getViewport({scale});
|
||
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = 'pdf-page-wrapper';
|
||
wrapper.id = `pdf-page-${pageNum}`;
|
||
|
||
// Inner container gives canvas + text layer a shared position:relative origin,
|
||
// independent of the outer flex wrapper's centering.
|
||
const inner = document.createElement('div');
|
||
inner.className = 'pdf-page-inner';
|
||
|
||
const canvas = document.createElement('canvas');
|
||
canvas.className = 'pdf-page';
|
||
canvas.width = viewport.width;
|
||
canvas.height = viewport.height;
|
||
inner.appendChild(canvas);
|
||
wrapper.appendChild(inner);
|
||
contentEl.appendChild(wrapper);
|
||
|
||
await page.render({canvasContext: canvas.getContext('2d'), viewport}).promise;
|
||
|
||
// Text layer disabled — re-enable once overlay rendering is resolved
|
||
}
|
||
|
||
pdfTotalPages = pdf.numPages;
|
||
return {title: pdfTitle, author: pdfAuthor, toc, numPages: pdf.numPages};
|
||
}
|
||
|
||
async function openBook(bookId) {
|
||
const overlay = $('reader-overlay');
|
||
const contentEl = $('reader-content');
|
||
const titleEl = $('reader-title');
|
||
if (!overlay || !contentEl) return;
|
||
|
||
titleEl.textContent = 'Loading…';
|
||
contentEl.innerHTML = '';
|
||
overlay.style.display = '';
|
||
|
||
try {
|
||
loadReaderSettings();
|
||
const key = await getOrCreateEncKey();
|
||
const res = await fetch(`/books/${bookId}/data/`);
|
||
const {data_ct, data_iv} = await res.json();
|
||
const plain = await decryptBytes(key, data_iv, data_ct);
|
||
|
||
// Revoke any previous image blob URLs
|
||
for (const url of Object.values(currentImageMap)) URL.revokeObjectURL(url);
|
||
currentImageMap = {};
|
||
|
||
const cachedMeta = bookMetaCache[bookId] || {};
|
||
let title = cachedMeta.title || '';
|
||
let author = cachedMeta.author || '';
|
||
let toc = [];
|
||
let numPages = 0;
|
||
const isPdfBook = cachedMeta.type === 'pdf';
|
||
|
||
if (isPdfBook) {
|
||
currentPdfDoc = null; // reset so renderPdf creates fresh doc
|
||
const result = await renderPdf(plain, contentEl);
|
||
title = result.title || title;
|
||
author = result.author || author;
|
||
toc = result.toc;
|
||
numPages = result.numPages;
|
||
currentPdfBuffer = plain;
|
||
} else {
|
||
currentPdfBuffer = null;
|
||
const result = await parseEpub(plain);
|
||
title = result.title || title;
|
||
author = result.author || author;
|
||
toc = result.toc;
|
||
currentImageMap = result.imageMap;
|
||
contentEl.innerHTML = result.html;
|
||
}
|
||
|
||
currentBookToc = toc;
|
||
titleEl.textContent = title + (author ? ` — ${author}` : '');
|
||
|
||
currentBookId = bookId;
|
||
|
||
// Load bookmarks and highlights
|
||
await Promise.all([
|
||
loadBookmarks(bookId),
|
||
loadHighlights(bookId),
|
||
]);
|
||
|
||
// Apply reader settings (theme, font size, etc.)
|
||
applyReaderSettings(isPdfBook);
|
||
|
||
// Enable PDF paginated mode if configured (auto on mobile)
|
||
if (isPdfBook && readerSettings.pdfPaginated) {
|
||
enterPdfPaginatedMode();
|
||
}
|
||
|
||
// Wire highlight selection listener for EPUB
|
||
if (!isPdfBook) {
|
||
contentEl.addEventListener('mouseup', handleReaderSelection);
|
||
}
|
||
|
||
// Swipe for PDF paginated
|
||
contentEl.addEventListener('touchstart', e => { _touchStartX = e.touches[0].clientX; }, {passive: true});
|
||
contentEl.addEventListener('touchend', e => {
|
||
if (!readerSettings.pdfPaginated) return;
|
||
const delta = e.changedTouches[0].clientX - _touchStartX;
|
||
if (delta > 50) pdfGoToPage(pdfCurrentPage - 1);
|
||
else if (delta < -50) pdfGoToPage(pdfCurrentPage + 1);
|
||
}, {passive: true});
|
||
|
||
// Set up progress input
|
||
const progressInput = $('reader-progress-input');
|
||
const progressSuffix = $('reader-progress-suffix');
|
||
const isPdf = isPdfBook;
|
||
|
||
if (progressInput) {
|
||
progressInput.style.display = '';
|
||
if (isPdf) {
|
||
progressInput.min = 1;
|
||
progressInput.max = numPages;
|
||
progressInput.value = 1;
|
||
if (progressSuffix) progressSuffix.textContent = `/ ${numPages}`;
|
||
} else {
|
||
progressInput.min = 0;
|
||
progressInput.max = 100;
|
||
progressInput.value = 0;
|
||
if (progressSuffix) progressSuffix.textContent = '%';
|
||
}
|
||
|
||
progressInput.addEventListener('change', function () {
|
||
if (isPdf) {
|
||
const page = Math.min(numPages, Math.max(1, parseInt(this.value, 10) || 1));
|
||
this.value = page;
|
||
const target = contentEl.querySelector(`#pdf-page-${page}`);
|
||
if (target) {
|
||
const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top;
|
||
contentEl.scrollBy({top: top - 8, behavior: 'smooth'});
|
||
}
|
||
} else {
|
||
const pct = Math.min(100, Math.max(0, parseInt(this.value, 10) || 0));
|
||
this.value = pct;
|
||
contentEl.scrollTop = (pct / 100) * contentEl.scrollHeight;
|
||
}
|
||
});
|
||
|
||
progressInput.addEventListener('click', function () { this.select(); });
|
||
}
|
||
|
||
// Restore scroll position
|
||
try {
|
||
const progressRes = await fetch('/books/');
|
||
const allBooks = await progressRes.json();
|
||
const bookData = allBooks.find(b => b.id === bookId);
|
||
const fraction = bookData ? (bookData.scroll_fraction || 0) : 0;
|
||
if (fraction > 0) {
|
||
if (isPdf && readerSettings.pdfPaginated && pdfTotalPages > 1) {
|
||
pdfCurrentPage = Math.max(1, Math.round(fraction * (pdfTotalPages - 1)) + 1);
|
||
} else {
|
||
// For EPUB: wait for all images to load so scrollHeight is final
|
||
if (!isPdf) {
|
||
const imgs = Array.from(contentEl.querySelectorAll('img'));
|
||
if (imgs.length) {
|
||
await Promise.all(imgs.map(img =>
|
||
img.complete ? Promise.resolve()
|
||
: new Promise(r => { img.onload = r; img.onerror = r; })
|
||
));
|
||
}
|
||
}
|
||
// One more rAF to let the browser recalculate layout after image load
|
||
await new Promise(r => requestAnimationFrame(r));
|
||
contentEl.scrollTop = fraction * (contentEl.scrollHeight - contentEl.clientHeight);
|
||
}
|
||
}
|
||
} catch (e) {}
|
||
|
||
// Update progress input on scroll
|
||
contentEl.addEventListener('scroll', () => {
|
||
if (!progressInput) return;
|
||
if (isPdf) {
|
||
const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper');
|
||
const cTop = contentEl.getBoundingClientRect().top;
|
||
let currentPage = 1;
|
||
for (const w of wrappers) {
|
||
if (w.getBoundingClientRect().bottom > cTop + 20) {
|
||
currentPage = parseInt(w.id.replace('pdf-page-', ''), 10) || 1;
|
||
break;
|
||
}
|
||
}
|
||
progressInput.value = currentPage;
|
||
} else {
|
||
const f = contentEl.scrollTop / (contentEl.scrollHeight - contentEl.clientHeight || 1);
|
||
progressInput.value = Math.round(f * 100);
|
||
}
|
||
});
|
||
|
||
// Auto-save progress every 10s and on scroll (debounced 2s)
|
||
readerScrollSaveTimer = setInterval(saveReaderProgress, 10000);
|
||
let _scrollDebounce = null;
|
||
contentEl.addEventListener('scroll', () => {
|
||
clearTimeout(_scrollDebounce);
|
||
_scrollDebounce = setTimeout(saveReaderProgress, 2000);
|
||
}, {passive: true});
|
||
|
||
// Determine which station to play (null = use default, {url:''} = disabled)
|
||
const focusStation = USER_FOCUS_STATION === null ? DEFAULT_FOCUS_STATION
|
||
: (USER_FOCUS_STATION.url ? USER_FOCUS_STATION : null);
|
||
|
||
if (focusStation) {
|
||
if (isPlaying) {
|
||
// Don't interrupt — highlight button, play on click instead
|
||
const btn = $('focus-station-btn');
|
||
if (btn) {
|
||
btn.classList.add('focus-pending');
|
||
btn.title = `Click to play focus station: ${focusStation.name}`;
|
||
btn._pendingFocusStation = focusStation;
|
||
btn.onclick = function () {
|
||
playStation(focusStation.url, focusStation.name, null);
|
||
btn.classList.remove('focus-pending');
|
||
btn.title = 'Focus station';
|
||
btn._pendingFocusStation = null;
|
||
btn.onclick = openFocusStationSidebar;
|
||
};
|
||
}
|
||
} else {
|
||
playStation(focusStation.url, focusStation.name, null);
|
||
}
|
||
}
|
||
|
||
} catch (e) {
|
||
contentEl.innerHTML = `<p class="muted">Failed to open book: ${escapeHtml(e.message)}</p>`;
|
||
}
|
||
}
|
||
|
||
async function exportEncKey() {
|
||
const statusEl = $('book-key-status');
|
||
try {
|
||
const key = await getOrCreateEncKey();
|
||
const raw = await crypto.subtle.exportKey('raw', key);
|
||
const b64 = bytesToBase64(raw);
|
||
await navigator.clipboard.writeText(b64);
|
||
if (statusEl) statusEl.textContent = '✓ Key copied to clipboard';
|
||
setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 3000);
|
||
} catch (e) {
|
||
if (statusEl) statusEl.textContent = 'Export failed: ' + e.message;
|
||
}
|
||
}
|
||
|
||
function showImportKey() {
|
||
const body = $('sidebar-body');
|
||
openSidebar('Import encryption key', `
|
||
<p class="muted">Paste the key exported from your other browser:</p>
|
||
<textarea id="import-key-input" class="search-input" rows="3" style="width:100%;resize:none;font-family:monospace;font-size:0.75rem;"></textarea>
|
||
<button class="btn" style="margin-top:8px;" data-import-key-apply>Apply</button>
|
||
<p class="muted" style="margin-top:8px;">This replaces the key in this browser. Books uploaded here won't be readable until you sync the key back.</p>
|
||
`);
|
||
body.addEventListener('click', async function _importClick(e) {
|
||
if (!e.target.closest('[data-import-key-apply]')) return;
|
||
body.removeEventListener('click', _importClick);
|
||
const b64 = (body.querySelector('#import-key-input')?.value || '').trim();
|
||
const statusEl = $('book-key-status');
|
||
try {
|
||
const raw = base64ToBytes(b64);
|
||
const importedKey = await crypto.subtle.importKey('raw', raw, {name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']);
|
||
const re_exported = await crypto.subtle.exportKey('raw', importedKey);
|
||
localStorage.setItem(`diora_enc_key_${window.USER_ID}`, bytesToBase64(re_exported));
|
||
closeSidebar();
|
||
if (statusEl) statusEl.textContent = '✓ Key imported — reloading books…';
|
||
await loadBookList();
|
||
if (statusEl) setTimeout(() => { statusEl.textContent = ''; }, 3000);
|
||
} catch (e) {
|
||
if ($('book-key-status')) $('book-key-status').textContent = 'Import failed: invalid key';
|
||
}
|
||
});
|
||
}
|
||
|
||
async function deleteBook(bookId) {
|
||
if (!confirm('Delete this book? This cannot be undone.')) return;
|
||
try {
|
||
const res = await fetch(`/books/${bookId}/delete/`, {
|
||
method: 'POST',
|
||
headers: {'X-CSRFToken': getCsrfToken()},
|
||
});
|
||
const data = await res.json();
|
||
if (data.ok) loadBookList();
|
||
} catch (e) {}
|
||
}
|
||
|
||
async function saveReaderProgress() {
|
||
if (!currentBookId) return;
|
||
const contentEl = $('reader-content');
|
||
if (!contentEl) return;
|
||
|
||
let fraction;
|
||
if (readerSettings.pdfPaginated && currentPdfDoc && pdfTotalPages > 1) {
|
||
fraction = (pdfCurrentPage - 1) / (pdfTotalPages - 1);
|
||
} else {
|
||
fraction = contentEl.scrollTop / (contentEl.scrollHeight - contentEl.clientHeight || 1);
|
||
}
|
||
fraction = Math.min(1.0, Math.max(0.0, fraction));
|
||
|
||
// Cache for sendBeacon on unload
|
||
_lastProgressBeacon = {
|
||
url: `/books/${currentBookId}/progress/`,
|
||
body: JSON.stringify({scroll_fraction: fraction}),
|
||
};
|
||
|
||
try {
|
||
await fetch(`/books/${currentBookId}/progress/`, {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
||
body: _lastProgressBeacon.body,
|
||
});
|
||
} catch (e) {}
|
||
}
|
||
|
||
function closeReader() {
|
||
// Save progress BEFORE hiding — scrollHeight/clientHeight return 0 once display:none
|
||
saveReaderProgress();
|
||
if (bookmarksDirty) saveBookmarks();
|
||
if (highlightsDirty) saveHighlights();
|
||
|
||
const overlay = $('reader-overlay');
|
||
if (overlay) overlay.style.display = 'none';
|
||
if (readerScrollSaveTimer) {
|
||
clearInterval(readerScrollSaveTimer);
|
||
readerScrollSaveTimer = null;
|
||
}
|
||
|
||
// Clear search before wiping content
|
||
clearReaderSearch();
|
||
|
||
// Close settings panel if open
|
||
readerSettingsPanelOpen = false;
|
||
const sp = document.getElementById('reader-settings-panel');
|
||
if (sp) sp.remove();
|
||
|
||
// Reset progress input
|
||
const progressInput = $('reader-progress-input');
|
||
if (progressInput) { progressInput.style.display = 'none'; progressInput.value = 0; }
|
||
const progressSuffix = $('reader-progress-suffix');
|
||
if (progressSuffix) progressSuffix.textContent = '';
|
||
|
||
// Free image blob URLs
|
||
const contentEl = $('reader-content');
|
||
if (contentEl) contentEl.innerHTML = '';
|
||
for (const url of Object.values(currentImageMap)) URL.revokeObjectURL(url);
|
||
currentImageMap = {};
|
||
|
||
// Reset all state
|
||
currentBookId = null;
|
||
currentBookToc = [];
|
||
currentPdfDoc = null;
|
||
currentPdfBuffer = null;
|
||
currentBookmarks = [];
|
||
bookmarksDirty = false;
|
||
currentHighlights = [];
|
||
highlightsDirty = false;
|
||
_lastProgressBeacon = null;
|
||
_lastBookmarkBeacon = null;
|
||
_lastHighlightBeacon = null;
|
||
dismissHighlightPopover();
|
||
pdfCurrentPage = 1;
|
||
pdfTotalPages = 0;
|
||
_pdfPageTextBoxCache = {};
|
||
|
||
// Remove PDF invert class
|
||
if (overlay) overlay.classList.remove('pdf-inverted');
|
||
|
||
// Clear any pending focus station highlight
|
||
const btn = $('focus-station-btn');
|
||
if (btn && btn._pendingFocusStation) {
|
||
btn.classList.remove('focus-pending');
|
||
btn.title = 'Focus station';
|
||
btn._pendingFocusStation = null;
|
||
btn.onclick = openFocusStationSidebar;
|
||
}
|
||
}
|
||
|
||
function openTocSidebar() {
|
||
if (!currentBookToc.length) {
|
||
openSidebar('Table of Contents', '<p class="muted">No table of contents found in this book.</p>');
|
||
return;
|
||
}
|
||
let html = '<ul class="toc-list">';
|
||
for (const entry of currentBookToc) {
|
||
const indent = entry.depth * 14;
|
||
// Use data-toc-href — onclick would be stripped by sanitizeSidebarHtml
|
||
html += `<li style="padding-left:${indent}px">
|
||
<button class="btn-link toc-entry" data-toc-href="${escapeHtml(entry.href)}">${escapeHtml(entry.label)}</button>
|
||
</li>`;
|
||
}
|
||
html += '</ul>';
|
||
openSidebar('Table of Contents', html);
|
||
// Attach delegated listener after sidebar body is populated
|
||
const body = $('sidebar-body');
|
||
body.addEventListener('click', function _tocClick(e) {
|
||
const btn = e.target.closest('.toc-entry');
|
||
if (btn) {
|
||
body.removeEventListener('click', _tocClick);
|
||
jumpToTocEntry(btn.getAttribute('data-toc-href') || '');
|
||
}
|
||
});
|
||
}
|
||
|
||
function jumpToTocEntry(href) {
|
||
closeSidebar();
|
||
setTimeout(() => {
|
||
const contentEl = $('reader-content');
|
||
if (!contentEl) return;
|
||
|
||
// PDF page jump
|
||
if (href.startsWith('#pdf-page-')) {
|
||
const target = contentEl.querySelector(href);
|
||
if (target) {
|
||
const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top;
|
||
contentEl.scrollBy({top: top - 16, behavior: 'smooth'});
|
||
}
|
||
return;
|
||
}
|
||
|
||
const hashIdx = href.indexOf('#');
|
||
const fragment = hashIdx >= 0 ? href.slice(hashIdx + 1) : '';
|
||
const filePath = hashIdx >= 0 ? href.slice(0, hashIdx) : href;
|
||
|
||
let target = null;
|
||
if (fragment) {
|
||
target = contentEl.querySelector(`#${CSS.escape(fragment)}`);
|
||
}
|
||
if (!target && filePath) {
|
||
target = Array.from(contentEl.querySelectorAll('[data-epub-src]'))
|
||
.find(el => el.getAttribute('data-epub-src') === filePath) || null;
|
||
}
|
||
if (target) {
|
||
const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top;
|
||
contentEl.scrollBy({top: top - 16, behavior: 'smooth'});
|
||
}
|
||
}, 50);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Reader Settings
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function loadReaderSettings() {
|
||
try {
|
||
const saved = JSON.parse(localStorage.getItem('diora_reader_settings') || '{}');
|
||
Object.assign(readerSettings, saved);
|
||
// Auto-paginate on mobile if not explicitly set
|
||
if (saved.pdfPaginated === undefined) {
|
||
readerSettings.pdfPaginated = window.innerWidth < 768;
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
function saveReaderSettings() {
|
||
localStorage.setItem('diora_reader_settings', JSON.stringify(readerSettings));
|
||
}
|
||
|
||
function applyReaderSettings(isPdf) {
|
||
const overlay = $('reader-overlay');
|
||
const contentEl = $('reader-content');
|
||
if (!overlay || !contentEl) return;
|
||
|
||
if (!isPdf) {
|
||
contentEl.style.fontSize = readerSettings.fontSize + 'px';
|
||
contentEl.style.lineHeight = readerSettings.lineHeight;
|
||
contentEl.style.setProperty('--reader-max-width', readerSettings.maxWidth + 'ch');
|
||
}
|
||
|
||
// Theme
|
||
overlay.classList.remove('reader-theme-sepia', 'reader-theme-bright');
|
||
if (readerSettings.theme === 'sepia') overlay.classList.add('reader-theme-sepia');
|
||
else if (readerSettings.theme === 'bright') overlay.classList.add('reader-theme-bright');
|
||
|
||
// PDF invert
|
||
if (isPdf && readerSettings.pdfInverted) overlay.classList.add('pdf-inverted');
|
||
else overlay.classList.remove('pdf-inverted');
|
||
}
|
||
|
||
function toggleSettingsPanel() {
|
||
const overlay = $('reader-overlay');
|
||
const contentEl = $('reader-content');
|
||
if (!overlay || !contentEl) return;
|
||
|
||
const existing = document.getElementById('reader-settings-panel');
|
||
if (existing) {
|
||
existing.remove();
|
||
readerSettingsPanelOpen = false;
|
||
return;
|
||
}
|
||
|
||
readerSettingsPanelOpen = true;
|
||
const isPdf = !!currentPdfDoc;
|
||
|
||
const panel = document.createElement('div');
|
||
panel.id = 'reader-settings-panel';
|
||
panel.className = 'reader-settings-panel';
|
||
|
||
if (!isPdf) {
|
||
panel.innerHTML = `
|
||
<label>Font <input type="range" id="rs-font" min="12" max="24" step="1" value="${readerSettings.fontSize}"> <span id="rs-font-val">${readerSettings.fontSize}px</span></label>
|
||
<label>Line <input type="range" id="rs-line" min="12" max="30" step="1" value="${Math.round(readerSettings.lineHeight * 10)}"> <span id="rs-line-val">${readerSettings.lineHeight}</span></label>
|
||
<label>Width <input type="range" id="rs-width" min="40" max="90" step="5" value="${readerSettings.maxWidth}"> <span id="rs-width-val">${readerSettings.maxWidth}ch</span></label>
|
||
<button class="btn btn-sm" id="rs-width-full">Full</button>
|
||
<button class="btn btn-sm ${readerSettings.theme === 'dark' ? 'active' : ''}" data-rs-theme="dark">Dark</button>
|
||
<button class="btn btn-sm ${readerSettings.theme === 'sepia' ? 'active' : ''}" data-rs-theme="sepia">Sepia</button>
|
||
<button class="btn btn-sm ${readerSettings.theme === 'bright' ? 'active' : ''}" data-rs-theme="bright">Bright</button>
|
||
`;
|
||
} else {
|
||
panel.innerHTML = `
|
||
<label>Zoom <input type="range" id="rs-zoom" min="50" max="200" step="10" value="${readerSettings.pdfZoom}"> <span id="rs-zoom-val">${readerSettings.pdfZoom}%</span></label>
|
||
<button class="btn btn-sm ${readerSettings.pdfInverted ? 'active' : ''}" id="rs-invert">Invert</button>
|
||
<button class="btn btn-sm ${readerSettings.pdfPaginated ? 'active' : ''}" id="rs-paginated">Paginated</button>
|
||
`;
|
||
}
|
||
|
||
overlay.insertBefore(panel, contentEl);
|
||
|
||
if (!isPdf) {
|
||
const fontRange = panel.querySelector('#rs-font');
|
||
const fontVal = panel.querySelector('#rs-font-val');
|
||
fontRange.addEventListener('input', () => {
|
||
readerSettings.fontSize = parseInt(fontRange.value, 10);
|
||
fontVal.textContent = readerSettings.fontSize + 'px';
|
||
applyReaderSettings(false);
|
||
saveReaderSettings();
|
||
});
|
||
|
||
const lineRange = panel.querySelector('#rs-line');
|
||
const lineVal = panel.querySelector('#rs-line-val');
|
||
lineRange.addEventListener('input', () => {
|
||
readerSettings.lineHeight = (parseInt(lineRange.value, 10) / 10).toFixed(1);
|
||
lineVal.textContent = readerSettings.lineHeight;
|
||
applyReaderSettings(false);
|
||
saveReaderSettings();
|
||
});
|
||
|
||
const widthRange = panel.querySelector('#rs-width');
|
||
const widthVal = panel.querySelector('#rs-width-val');
|
||
widthRange.addEventListener('input', () => {
|
||
readerSettings.maxWidth = parseInt(widthRange.value, 10);
|
||
widthVal.textContent = readerSettings.maxWidth + 'ch';
|
||
applyReaderSettings(false);
|
||
saveReaderSettings();
|
||
});
|
||
|
||
panel.querySelector('#rs-width-full').addEventListener('click', () => {
|
||
readerSettings.maxWidth = 999;
|
||
widthRange.value = 90;
|
||
widthVal.textContent = 'full';
|
||
applyReaderSettings(false);
|
||
saveReaderSettings();
|
||
});
|
||
|
||
panel.querySelectorAll('[data-rs-theme]').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
readerSettings.theme = btn.dataset.rsTheme;
|
||
panel.querySelectorAll('[data-rs-theme]').forEach(b => b.classList.toggle('active', b === btn));
|
||
applyReaderSettings(false);
|
||
saveReaderSettings();
|
||
});
|
||
});
|
||
} else {
|
||
const zoomRange = panel.querySelector('#rs-zoom');
|
||
const zoomVal = panel.querySelector('#rs-zoom-val');
|
||
zoomRange.addEventListener('change', () => {
|
||
readerSettings.pdfZoom = parseInt(zoomRange.value, 10);
|
||
zoomVal.textContent = readerSettings.pdfZoom + '%';
|
||
saveReaderSettings();
|
||
reRenderPdf();
|
||
});
|
||
|
||
panel.querySelector('#rs-invert').addEventListener('click', function () {
|
||
readerSettings.pdfInverted = !readerSettings.pdfInverted;
|
||
this.classList.toggle('active', readerSettings.pdfInverted);
|
||
applyReaderSettings(true);
|
||
saveReaderSettings();
|
||
});
|
||
|
||
panel.querySelector('#rs-paginated').addEventListener('click', function () {
|
||
readerSettings.pdfPaginated = !readerSettings.pdfPaginated;
|
||
this.classList.toggle('active', readerSettings.pdfPaginated);
|
||
saveReaderSettings();
|
||
if (readerSettings.pdfPaginated) {
|
||
enterPdfPaginatedMode();
|
||
} else {
|
||
exitPdfPaginatedMode();
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
async function reRenderPdf() {
|
||
if (!currentPdfBuffer) return;
|
||
const contentEl = $('reader-content');
|
||
if (!contentEl) return;
|
||
currentPdfDoc = null; // force re-parse with same buffer
|
||
await renderPdf(currentPdfBuffer, contentEl);
|
||
if (readerSettings.pdfPaginated) enterPdfPaginatedMode();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// PDF Paginated Mode
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function enterPdfPaginatedMode() {
|
||
const contentEl = $('reader-content');
|
||
if (!contentEl) return;
|
||
contentEl.classList.add('pdf-paginated');
|
||
contentEl.style.overflow = 'hidden';
|
||
|
||
const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper');
|
||
wrappers.forEach((w, i) => {
|
||
w.style.display = (i + 1 === pdfCurrentPage) ? '' : 'none';
|
||
});
|
||
|
||
pdfSmartZoomPage(pdfCurrentPage);
|
||
|
||
// Tap left/right to navigate
|
||
contentEl.addEventListener('click', _pdfPaginatedClick);
|
||
}
|
||
|
||
function exitPdfPaginatedMode() {
|
||
const contentEl = $('reader-content');
|
||
if (!contentEl) return;
|
||
contentEl.classList.remove('pdf-paginated');
|
||
contentEl.style.overflow = '';
|
||
contentEl.removeEventListener('click', _pdfPaginatedClick);
|
||
const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper');
|
||
wrappers.forEach(w => {
|
||
w.style.display = '';
|
||
const canvas = w.querySelector('canvas');
|
||
if (canvas) canvas.style.transform = '';
|
||
});
|
||
}
|
||
|
||
function _pdfPaginatedClick(e) {
|
||
const w = e.currentTarget.clientWidth;
|
||
if (e.clientX < w * 0.4) pdfGoToPage(pdfCurrentPage - 1);
|
||
else if (e.clientX > w * 0.6) pdfGoToPage(pdfCurrentPage + 1);
|
||
}
|
||
|
||
function pdfGoToPage(n) {
|
||
if (!currentPdfDoc) return;
|
||
n = Math.max(1, Math.min(pdfTotalPages, n));
|
||
if (n === pdfCurrentPage) return;
|
||
const contentEl = $('reader-content');
|
||
if (!contentEl) return;
|
||
|
||
const oldWrapper = contentEl.querySelector(`#pdf-page-${pdfCurrentPage}`);
|
||
if (oldWrapper) oldWrapper.style.display = 'none';
|
||
|
||
pdfCurrentPage = n;
|
||
const newWrapper = contentEl.querySelector(`#pdf-page-${pdfCurrentPage}`);
|
||
if (newWrapper) newWrapper.style.display = '';
|
||
|
||
pdfSmartZoomPage(pdfCurrentPage);
|
||
|
||
const progressInput = $('reader-progress-input');
|
||
if (progressInput) progressInput.value = pdfCurrentPage;
|
||
}
|
||
|
||
async function pdfSmartZoomPage(pageNum) {
|
||
if (!currentPdfDoc) return;
|
||
const contentEl = $('reader-content');
|
||
if (!contentEl) return;
|
||
const wrapper = contentEl.querySelector(`#pdf-page-${pageNum}`);
|
||
if (!wrapper) return;
|
||
const canvas = wrapper.querySelector('canvas');
|
||
if (!canvas) return;
|
||
|
||
const page = await currentPdfDoc.getPage(pageNum);
|
||
const naturalVp = page.getViewport({scale: 1});
|
||
const pageW = naturalVp.width;
|
||
const pageH = naturalVp.height;
|
||
|
||
let bbox = _pdfPageTextBoxCache[pageNum];
|
||
if (!bbox) {
|
||
bbox = await _computePdfTextBox(page, pageW, pageH);
|
||
_pdfPageTextBoxCache[pageNum] = bbox;
|
||
}
|
||
|
||
const containerW = contentEl.clientWidth;
|
||
const containerH = contentEl.clientHeight;
|
||
const contentW = bbox.x2 - bbox.x1;
|
||
const contentH = bbox.y2 - bbox.y1;
|
||
const pad = 12;
|
||
const scale = Math.min(
|
||
(containerW - pad * 2) / contentW,
|
||
(containerH - pad * 2) / contentH
|
||
);
|
||
|
||
// Re-render canvas at new scale if significantly different
|
||
const currentScale = canvas.width / naturalVp.width;
|
||
if (Math.abs(scale - currentScale) / currentScale > 0.05) {
|
||
const vp = page.getViewport({scale});
|
||
canvas.width = vp.width;
|
||
canvas.height = vp.height;
|
||
await page.render({canvasContext: canvas.getContext('2d'), viewport: vp}).promise;
|
||
}
|
||
|
||
// Position canvas to center the text bounding box
|
||
const renderedScale = canvas.width / naturalVp.width;
|
||
const offsetX = -renderedScale * (bbox.x1 - pad) + (containerW - renderedScale * contentW - pad * 2) / 2;
|
||
// PDF y-axis is bottom-up; canvas is top-down
|
||
const offsetY = -renderedScale * (pageH - bbox.y2 - pad) + (containerH - renderedScale * contentH - pad * 2) / 2;
|
||
canvas.style.transform = `translate(${offsetX}px, ${offsetY}px)`;
|
||
wrapper.style.overflow = 'hidden';
|
||
wrapper.style.width = containerW + 'px';
|
||
wrapper.style.height = containerH + 'px';
|
||
}
|
||
|
||
async function _computePdfTextBox(page, pageW, pageH) {
|
||
// Tier 1: text-based
|
||
try {
|
||
const tc = await page.getTextContent();
|
||
if (tc.items && tc.items.length) {
|
||
let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity;
|
||
for (const item of tc.items) {
|
||
if (!item.transform) continue;
|
||
const tx = item.transform[4], ty = item.transform[5];
|
||
const iw = item.width || 0, ih = item.height || 0;
|
||
if (tx < x1) x1 = tx;
|
||
if (ty < y1) y1 = ty;
|
||
if (tx + iw > x2) x2 = tx + iw;
|
||
if (ty + ih > y2) y2 = ty + ih;
|
||
}
|
||
const area = (x2 - x1) * (y2 - y1);
|
||
if (isFinite(x1) && area > pageW * pageH * 0.25) {
|
||
return {x1, y1, x2, y2};
|
||
}
|
||
}
|
||
} catch (e) {}
|
||
|
||
// Tier 2: pixel analysis at scale 0.3
|
||
try {
|
||
const lowScale = 0.3;
|
||
const vp = page.getViewport({scale: lowScale});
|
||
const offCanvas = document.createElement('canvas');
|
||
offCanvas.width = vp.width;
|
||
offCanvas.height = vp.height;
|
||
const ctx = offCanvas.getContext('2d');
|
||
await page.render({canvasContext: ctx, viewport: vp}).promise;
|
||
const {data, width, height} = ctx.getImageData(0, 0, vp.width, vp.height);
|
||
|
||
let rMin = height, rMax = 0, cMin = width, cMax = 0;
|
||
for (let r = 0; r < height; r++) {
|
||
for (let c = 0; c < width; c++) {
|
||
const idx = (r * width + c) * 4;
|
||
if (data[idx] + data[idx+1] + data[idx+2] < 720) {
|
||
if (r < rMin) rMin = r;
|
||
if (r > rMax) rMax = r;
|
||
if (c < cMin) cMin = c;
|
||
if (c > cMax) cMax = c;
|
||
}
|
||
}
|
||
}
|
||
if (rMin < rMax && cMin < cMax) {
|
||
return {
|
||
x1: cMin / lowScale,
|
||
y1: (height - rMax) / lowScale,
|
||
x2: cMax / lowScale,
|
||
y2: (height - rMin) / lowScale,
|
||
};
|
||
}
|
||
} catch (e) {}
|
||
|
||
// Fallback: full page
|
||
return {x1: 0, y1: 0, x2: pageW, y2: pageH};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Bookmarks
|
||
// ---------------------------------------------------------------------------
|
||
|
||
async function loadBookmarks(bookId) {
|
||
try {
|
||
const res = await fetch(`/books/${bookId}/bookmarks/`);
|
||
const {ct, iv} = await res.json();
|
||
if (ct) {
|
||
const key = await getOrCreateEncKey();
|
||
const plain = await decryptBytes(key, iv, ct);
|
||
currentBookmarks = JSON.parse(new TextDecoder().decode(plain));
|
||
} else {
|
||
currentBookmarks = [];
|
||
}
|
||
} catch (e) {
|
||
currentBookmarks = [];
|
||
}
|
||
}
|
||
|
||
async function saveBookmarks() {
|
||
if (!currentBookId) return;
|
||
try {
|
||
const key = await getOrCreateEncKey();
|
||
const plain = new TextEncoder().encode(JSON.stringify(currentBookmarks));
|
||
const {iv, ciphertext} = await encryptBytes(key, plain);
|
||
const body = JSON.stringify({ct: ciphertext, iv});
|
||
const url = `/books/${currentBookId}/bookmarks/`;
|
||
_lastBookmarkBeacon = {url, body};
|
||
await fetch(url, {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
||
body,
|
||
});
|
||
bookmarksDirty = false;
|
||
} catch (e) {}
|
||
}
|
||
|
||
function addBookmark() {
|
||
const contentEl = $('reader-content');
|
||
if (!contentEl || !currentBookId) return;
|
||
|
||
let label, anchor, scrollFraction;
|
||
|
||
if (currentPdfDoc) {
|
||
const page = pdfCurrentPage || parseInt($('reader-progress-input')?.value, 10) || 1;
|
||
label = `Page ${page}`;
|
||
anchor = `pdf-page-${page}`;
|
||
scrollFraction = (page - 1) / Math.max(1, pdfTotalPages - 1);
|
||
} else {
|
||
// Find first visible chapter div
|
||
const chapters = contentEl.querySelectorAll('[data-epub-src]');
|
||
let visibleChapter = null;
|
||
for (const ch of chapters) {
|
||
const rect = ch.getBoundingClientRect();
|
||
if (rect.bottom > 0 && rect.top < window.innerHeight) {
|
||
visibleChapter = ch;
|
||
break;
|
||
}
|
||
}
|
||
const src = visibleChapter?.getAttribute('data-epub-src') || '';
|
||
label = src.split('/').pop().replace(/\.x?html?$/i, '') || 'Bookmark';
|
||
anchor = src;
|
||
scrollFraction = contentEl.scrollTop / (contentEl.scrollHeight - contentEl.clientHeight || 1);
|
||
}
|
||
|
||
const bm = {
|
||
id: crypto.randomUUID(),
|
||
label,
|
||
anchor,
|
||
scrollFraction,
|
||
createdAt: new Date().toISOString(),
|
||
};
|
||
currentBookmarks.unshift(bm);
|
||
bookmarksDirty = true;
|
||
saveBookmarks();
|
||
|
||
// Toast
|
||
const toast = document.createElement('div');
|
||
toast.className = 'reader-toast';
|
||
toast.textContent = `★ Bookmarked: ${label}`;
|
||
document.body.appendChild(toast);
|
||
setTimeout(() => toast.remove(), 2200);
|
||
}
|
||
|
||
function openBookmarksSidebar() {
|
||
if (!currentBookmarks.length) {
|
||
openSidebar('Bookmarks', '<p class="muted">No bookmarks yet. Press ★ while reading.</p>');
|
||
return;
|
||
}
|
||
let html = '<ul style="list-style:none;padding:0;">';
|
||
for (const bm of currentBookmarks) {
|
||
html += `<li class="bookmark-entry">
|
||
<button class="btn-link" data-jump-bookmark="${escapeHtml(bm.id)}" style="flex:1;text-align:left;">
|
||
${escapeHtml(bm.label)}
|
||
</button>
|
||
<button class="btn-icon" data-delete-bookmark="${escapeHtml(bm.id)}" title="Delete">✕</button>
|
||
</li>`;
|
||
}
|
||
html += '</ul>';
|
||
openSidebar('Bookmarks', html);
|
||
|
||
const body = $('sidebar-body');
|
||
body.addEventListener('click', function _bmClick(e) {
|
||
const jumpBtn = e.target.closest('[data-jump-bookmark]');
|
||
const delBtn = e.target.closest('[data-delete-bookmark]');
|
||
if (jumpBtn) {
|
||
body.removeEventListener('click', _bmClick);
|
||
jumpToBookmark(jumpBtn.dataset.jumpBookmark);
|
||
}
|
||
if (delBtn) {
|
||
const id = delBtn.dataset.deleteBookmark;
|
||
currentBookmarks = currentBookmarks.filter(b => b.id !== id);
|
||
bookmarksDirty = true;
|
||
saveBookmarks();
|
||
openBookmarksSidebar(); // re-render
|
||
}
|
||
});
|
||
}
|
||
|
||
function jumpToBookmark(id) {
|
||
const bm = currentBookmarks.find(b => b.id === id);
|
||
if (!bm) return;
|
||
closeSidebar();
|
||
setTimeout(() => {
|
||
const contentEl = $('reader-content');
|
||
if (!contentEl) return;
|
||
if (bm.anchor.startsWith('pdf-page-')) {
|
||
if (readerSettings.pdfPaginated) {
|
||
pdfGoToPage(parseInt(bm.anchor.replace('pdf-page-', ''), 10) || 1);
|
||
} else {
|
||
const target = contentEl.querySelector('#' + bm.anchor);
|
||
if (target) {
|
||
const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top;
|
||
contentEl.scrollBy({top: top - 16, behavior: 'smooth'});
|
||
}
|
||
}
|
||
} else {
|
||
const target = Array.from(contentEl.querySelectorAll('[data-epub-src]'))
|
||
.find(el => el.getAttribute('data-epub-src') === bm.anchor);
|
||
if (target) {
|
||
const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top;
|
||
contentEl.scrollBy({top: top - 16, behavior: 'smooth'});
|
||
} else {
|
||
contentEl.scrollTop = bm.scrollFraction * (contentEl.scrollHeight - contentEl.clientHeight);
|
||
}
|
||
}
|
||
}, 50);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Reader Search
|
||
// ---------------------------------------------------------------------------
|
||
|
||
let _readerSearchDebounce = null;
|
||
|
||
function toggleReaderSearch() {
|
||
const overlay = $('reader-overlay');
|
||
const contentEl = $('reader-content');
|
||
if (!overlay || !contentEl) return;
|
||
|
||
const existing = document.getElementById('reader-search-bar');
|
||
if (existing) {
|
||
existing.remove();
|
||
readerSearchOpen = false;
|
||
clearReaderSearch();
|
||
return;
|
||
}
|
||
|
||
readerSearchOpen = true;
|
||
const bar = document.createElement('div');
|
||
bar.id = 'reader-search-bar';
|
||
bar.className = 'reader-search-bar';
|
||
bar.innerHTML = `
|
||
<input type="text" id="reader-search-input" class="search-input" placeholder="Search…" style="width:160px;">
|
||
<button class="btn-icon" id="rs-search-prev" title="Previous">↑</button>
|
||
<button class="btn-icon" id="rs-search-next" title="Next">↓</button>
|
||
<span id="rs-search-count" class="muted"></span>
|
||
<button class="btn-icon" id="rs-search-clear" title="Close">✕</button>
|
||
`;
|
||
overlay.insertBefore(bar, contentEl);
|
||
|
||
const input = bar.querySelector('#reader-search-input');
|
||
input.focus();
|
||
input.addEventListener('input', () => {
|
||
clearTimeout(_readerSearchDebounce);
|
||
_readerSearchDebounce = setTimeout(() => doReaderSearch(input.value.trim()), 300);
|
||
});
|
||
input.addEventListener('keydown', e => {
|
||
if (e.key === 'Enter') { e.shiftKey ? readerSearchPrev() : readerSearchNext(); }
|
||
if (e.key === 'Escape') { toggleReaderSearch(); }
|
||
});
|
||
bar.querySelector('#rs-search-prev').addEventListener('click', readerSearchPrev);
|
||
bar.querySelector('#rs-search-next').addEventListener('click', readerSearchNext);
|
||
bar.querySelector('#rs-search-clear').addEventListener('click', toggleReaderSearch);
|
||
}
|
||
|
||
async function doReaderSearch(query) {
|
||
const contentEl = $('reader-content');
|
||
if (!contentEl) return;
|
||
const countEl = document.getElementById('rs-search-count');
|
||
|
||
clearReaderSearchHighlights();
|
||
searchMatches = [];
|
||
searchMatchIndex = -1;
|
||
|
||
if (!query) { if (countEl) countEl.textContent = ''; return; }
|
||
|
||
if (!currentPdfDoc) {
|
||
// EPUB: snapshot original content
|
||
if (!searchOriginalContent) {
|
||
searchOriginalContent = contentEl.innerHTML;
|
||
} else {
|
||
contentEl.innerHTML = searchOriginalContent;
|
||
applyHighlightsToContent();
|
||
}
|
||
|
||
const walker = document.createTreeWalker(contentEl, NodeFilter.SHOW_TEXT);
|
||
const lq = query.toLowerCase();
|
||
const ranges = [];
|
||
let node;
|
||
while ((node = walker.nextNode())) {
|
||
const text = node.textContent;
|
||
const lt = text.toLowerCase();
|
||
let idx = 0;
|
||
while ((idx = lt.indexOf(lq, idx)) !== -1) {
|
||
const range = document.createRange();
|
||
range.setStart(node, idx);
|
||
range.setEnd(node, idx + query.length);
|
||
ranges.push(range);
|
||
idx += query.length;
|
||
}
|
||
}
|
||
|
||
// Insert marks in reverse to preserve range validity
|
||
for (let i = ranges.length - 1; i >= 0; i--) {
|
||
try {
|
||
const mark = document.createElement('mark');
|
||
mark.className = 'reader-search-match';
|
||
ranges[i].surroundContents(mark);
|
||
searchMatches.unshift(mark);
|
||
} catch (e) {}
|
||
}
|
||
} else {
|
||
// PDF: collect text layer spans
|
||
const spans = contentEl.querySelectorAll('.pdf-text-layer > span');
|
||
const lq = query.toLowerCase();
|
||
for (const span of spans) {
|
||
if (span.textContent.toLowerCase().includes(lq)) {
|
||
span.classList.add('reader-search-match');
|
||
searchMatches.push(span);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (countEl) countEl.textContent = searchMatches.length ? `1 / ${searchMatches.length}` : '0';
|
||
if (searchMatches.length) {
|
||
searchMatchIndex = 0;
|
||
scrollToSearchMatch(0);
|
||
}
|
||
}
|
||
|
||
function clearReaderSearchHighlights() {
|
||
if (!currentPdfDoc) {
|
||
// EPUB: restore from snapshot
|
||
if (searchOriginalContent !== null) {
|
||
const contentEl = $('reader-content');
|
||
if (contentEl) {
|
||
contentEl.innerHTML = searchOriginalContent;
|
||
applyHighlightsToContent();
|
||
}
|
||
searchOriginalContent = null;
|
||
} else {
|
||
// Just remove marks without full restore
|
||
document.querySelectorAll('mark.reader-search-match').forEach(m => {
|
||
const parent = m.parentNode;
|
||
while (m.firstChild) parent.insertBefore(m.firstChild, m);
|
||
parent.removeChild(m);
|
||
});
|
||
}
|
||
} else {
|
||
// PDF: remove highlight class from spans
|
||
document.querySelectorAll('.reader-search-match').forEach(el => {
|
||
el.classList.remove('reader-search-match', 'active');
|
||
});
|
||
}
|
||
searchMatches = [];
|
||
searchMatchIndex = -1;
|
||
}
|
||
|
||
function clearReaderSearch() {
|
||
clearTimeout(_readerSearchDebounce);
|
||
clearReaderSearchHighlights();
|
||
readerSearchOpen = false;
|
||
const countEl = document.getElementById('rs-search-count');
|
||
if (countEl) countEl.textContent = '';
|
||
}
|
||
|
||
function scrollToSearchMatch(idx) {
|
||
if (!searchMatches.length) return;
|
||
searchMatches.forEach((m, i) => m.classList.toggle('active', i === idx));
|
||
searchMatches[idx].scrollIntoView({behavior: 'smooth', block: 'center'});
|
||
const countEl = document.getElementById('rs-search-count');
|
||
if (countEl) countEl.textContent = `${idx + 1} / ${searchMatches.length}`;
|
||
}
|
||
|
||
function readerSearchNext() {
|
||
if (!searchMatches.length) return;
|
||
searchMatchIndex = (searchMatchIndex + 1) % searchMatches.length;
|
||
scrollToSearchMatch(searchMatchIndex);
|
||
}
|
||
|
||
function readerSearchPrev() {
|
||
if (!searchMatches.length) return;
|
||
searchMatchIndex = (searchMatchIndex - 1 + searchMatches.length) % searchMatches.length;
|
||
scrollToSearchMatch(searchMatchIndex);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Highlights
|
||
// ---------------------------------------------------------------------------
|
||
|
||
async function loadHighlights(bookId) {
|
||
try {
|
||
const res = await fetch(`/books/${bookId}/highlights/`);
|
||
const {ct, iv} = await res.json();
|
||
if (ct) {
|
||
const key = await getOrCreateEncKey();
|
||
const plain = await decryptBytes(key, iv, ct);
|
||
currentHighlights = JSON.parse(new TextDecoder().decode(plain));
|
||
} else {
|
||
currentHighlights = [];
|
||
}
|
||
applyHighlightsToContent();
|
||
} catch (e) {
|
||
currentHighlights = [];
|
||
}
|
||
}
|
||
|
||
async function saveHighlights() {
|
||
if (!currentBookId) return;
|
||
try {
|
||
const key = await getOrCreateEncKey();
|
||
const plain = new TextEncoder().encode(JSON.stringify(currentHighlights));
|
||
const {iv, ciphertext} = await encryptBytes(key, plain);
|
||
const body = JSON.stringify({ct: ciphertext, iv});
|
||
const url = `/books/${currentBookId}/highlights/`;
|
||
_lastHighlightBeacon = {url, body};
|
||
await fetch(url, {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
||
body,
|
||
});
|
||
highlightsDirty = false;
|
||
} catch (e) {}
|
||
}
|
||
|
||
let _highlightSaveDebounce = null;
|
||
function debounceSaveHighlights() {
|
||
clearTimeout(_highlightSaveDebounce);
|
||
_highlightSaveDebounce = setTimeout(saveHighlights, 2000);
|
||
}
|
||
|
||
function applyHighlightsToContent() {
|
||
const contentEl = $('reader-content');
|
||
if (!contentEl || currentPdfDoc) return;
|
||
for (const h of currentHighlights) {
|
||
try { renderHighlight(h); } catch (e) {}
|
||
}
|
||
}
|
||
|
||
function renderHighlight(h) {
|
||
const contentEl = $('reader-content');
|
||
if (!contentEl || !h.anchor) return;
|
||
|
||
const chapterEl = contentEl.querySelector(`[data-epub-src="${CSS.escape(h.anchor.chapterSrc || '')}"]`)
|
||
|| contentEl;
|
||
|
||
let range = null;
|
||
try {
|
||
const startNode = xpathToNode(h.anchor.startXpath, chapterEl);
|
||
const endNode = xpathToNode(h.anchor.endXpath, chapterEl);
|
||
if (startNode && endNode) {
|
||
range = document.createRange();
|
||
range.setStart(startNode, h.anchor.startOffset);
|
||
range.setEnd(endNode, h.anchor.endOffset);
|
||
}
|
||
} catch (e) {}
|
||
|
||
// Fallback: quote substring search
|
||
if (!range && h.anchor.quote) {
|
||
const walker = document.createTreeWalker(chapterEl, NodeFilter.SHOW_TEXT);
|
||
let node;
|
||
while ((node = walker.nextNode())) {
|
||
const idx = node.textContent.indexOf(h.anchor.quote);
|
||
if (idx !== -1) {
|
||
range = document.createRange();
|
||
range.setStart(node, idx);
|
||
range.setEnd(node, idx + h.anchor.quote.length);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!range) return;
|
||
|
||
try {
|
||
const mark = document.createElement('mark');
|
||
mark.className = 'epub-highlight';
|
||
mark.dataset.highlightId = h.id;
|
||
mark.dataset.color = h.color || 'yellow';
|
||
range.surroundContents(mark);
|
||
} catch (e) {}
|
||
}
|
||
|
||
function xpathToNode(xpath, root) {
|
||
if (!xpath) return null;
|
||
const result = document.evaluate(xpath, root, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
|
||
return result.singleNodeValue;
|
||
}
|
||
|
||
function getXPathForNode(node, root) {
|
||
const parts = [];
|
||
let current = node;
|
||
while (current && current !== root) {
|
||
const parent = current.parentNode;
|
||
if (!parent) break;
|
||
if (current.nodeType === Node.TEXT_NODE) {
|
||
const siblings = Array.from(parent.childNodes).filter(n => n.nodeType === Node.TEXT_NODE);
|
||
const idx = siblings.indexOf(current);
|
||
parts.unshift(`text()[${idx + 1}]`);
|
||
} else {
|
||
const siblings = Array.from(parent.children).filter(n => n.tagName === current.tagName);
|
||
const idx = siblings.indexOf(current);
|
||
parts.unshift(`${current.tagName.toLowerCase()}[${idx + 1}]`);
|
||
}
|
||
current = parent;
|
||
}
|
||
return parts.join('/');
|
||
}
|
||
|
||
function buildEpubAnchor(range) {
|
||
const contentEl = $('reader-content');
|
||
const chapterEl = range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
|
||
? range.commonAncestorContainer.closest('[data-epub-src]')
|
||
: range.commonAncestorContainer.parentElement?.closest('[data-epub-src]');
|
||
const root = chapterEl || contentEl;
|
||
|
||
return {
|
||
type: 'epub',
|
||
chapterSrc: chapterEl?.getAttribute('data-epub-src') || '',
|
||
startXpath: getXPathForNode(range.startContainer, root),
|
||
startOffset: range.startOffset,
|
||
endXpath: getXPathForNode(range.endContainer, root),
|
||
endOffset: range.endOffset,
|
||
quote: range.toString().slice(0, 200),
|
||
};
|
||
}
|
||
|
||
function handleReaderSelection(e) {
|
||
// If clicking an existing highlight, show tooltip
|
||
const hlMark = e.target.closest('.epub-highlight');
|
||
if (hlMark) {
|
||
dismissHighlightPopover();
|
||
const id = hlMark.dataset.highlightId;
|
||
const h = currentHighlights.find(x => x.id === id);
|
||
showHighlightTooltip(hlMark, h);
|
||
return;
|
||
}
|
||
|
||
dismissHighlightPopover();
|
||
const sel = window.getSelection();
|
||
if (!sel || sel.isCollapsed || !sel.rangeCount) return;
|
||
const range = sel.getRangeAt(0);
|
||
const contentEl = $('reader-content');
|
||
if (!contentEl || !contentEl.contains(range.commonAncestorContainer)) return;
|
||
if (range.toString().trim().length === 0) return;
|
||
|
||
showHighlightPopover(range);
|
||
}
|
||
|
||
function showHighlightPopover(range) {
|
||
const rect = range.getBoundingClientRect();
|
||
const popover = document.createElement('div');
|
||
popover.id = 'highlight-popover';
|
||
popover.className = 'highlight-popover';
|
||
popover.innerHTML = `
|
||
<button class="hl-color-btn" data-hl-color="yellow" style="background:#f1c40f" title="Yellow">A</button>
|
||
<button class="hl-color-btn" data-hl-color="green" style="background:#2ecc71" title="Green">A</button>
|
||
<button class="hl-color-btn" data-hl-color="blue" style="background:#3498db" title="Blue">A</button>
|
||
<button class="hl-color-btn" data-hl-color="red" style="background:#e63946" title="Red">A</button>
|
||
<button class="hl-note-btn" title="Add note">✎</button>
|
||
`;
|
||
popover.style.top = (rect.top + window.scrollY - 44) + 'px';
|
||
popover.style.left = (rect.left + window.scrollX + rect.width / 2 - 70) + 'px';
|
||
document.body.appendChild(popover);
|
||
currentHighlightPopover = popover;
|
||
|
||
// Store range info before selection is cleared
|
||
const savedRange = range.cloneRange();
|
||
|
||
popover.addEventListener('click', e => {
|
||
const colorBtn = e.target.closest('.hl-color-btn');
|
||
const noteBtn = e.target.closest('.hl-note-btn');
|
||
if (colorBtn) {
|
||
createHighlight(colorBtn.dataset.hlColor, savedRange);
|
||
} else if (noteBtn) {
|
||
createHighlightWithNote(savedRange);
|
||
}
|
||
});
|
||
}
|
||
|
||
function showHighlightTooltip(markEl, h) {
|
||
const rect = markEl.getBoundingClientRect();
|
||
const popover = document.createElement('div');
|
||
popover.id = 'highlight-popover';
|
||
popover.className = 'highlight-popover';
|
||
popover.style.flexDirection = 'column';
|
||
popover.style.maxWidth = '220px';
|
||
const noteText = h?.note ? escapeHtml(h.note) : '<span class="muted">No note</span>';
|
||
popover.innerHTML = `
|
||
<div style="font-size:12px;padding-bottom:4px;">${noteText}</div>
|
||
<div style="display:flex;gap:6px;">
|
||
<button class="btn btn-sm" data-hl-edit-note="${escapeHtml(h?.id || '')}">Edit note</button>
|
||
<button class="btn btn-sm btn-danger" data-hl-delete="${escapeHtml(h?.id || '')}">Delete</button>
|
||
</div>
|
||
`;
|
||
popover.style.top = (rect.bottom + window.scrollY + 4) + 'px';
|
||
popover.style.left = (rect.left + window.scrollX) + 'px';
|
||
document.body.appendChild(popover);
|
||
currentHighlightPopover = popover;
|
||
|
||
popover.addEventListener('click', ev => {
|
||
const editBtn = ev.target.closest('[data-hl-edit-note]');
|
||
const delBtn = ev.target.closest('[data-hl-delete]');
|
||
if (editBtn && h) {
|
||
dismissHighlightPopover();
|
||
openNoteEditor(h);
|
||
}
|
||
if (delBtn && h) {
|
||
dismissHighlightPopover();
|
||
deleteHighlight(h.id);
|
||
}
|
||
});
|
||
|
||
// Close on outside click
|
||
setTimeout(() => {
|
||
document.addEventListener('click', dismissHighlightPopover, {once: true});
|
||
}, 0);
|
||
}
|
||
|
||
function createHighlight(color, range) {
|
||
const anchor = buildEpubAnchor(range);
|
||
const h = {
|
||
id: crypto.randomUUID(),
|
||
anchor,
|
||
color,
|
||
note: '',
|
||
createdAt: new Date().toISOString(),
|
||
};
|
||
currentHighlights.push(h);
|
||
highlightsDirty = true;
|
||
window.getSelection()?.removeAllRanges();
|
||
dismissHighlightPopover();
|
||
renderHighlight(h);
|
||
debounceSaveHighlights();
|
||
}
|
||
|
||
function createHighlightWithNote(range) {
|
||
const anchor = buildEpubAnchor(range);
|
||
const h = {
|
||
id: crypto.randomUUID(),
|
||
anchor,
|
||
color: 'yellow',
|
||
note: '',
|
||
createdAt: new Date().toISOString(),
|
||
};
|
||
currentHighlights.push(h);
|
||
highlightsDirty = true;
|
||
window.getSelection()?.removeAllRanges();
|
||
dismissHighlightPopover();
|
||
renderHighlight(h);
|
||
openNoteEditor(h);
|
||
}
|
||
|
||
function openNoteEditor(h) {
|
||
openSidebar('Edit note', `
|
||
<textarea id="hl-note-input" class="search-input" rows="5" style="width:100%;resize:vertical;">${escapeHtml(h.note || '')}</textarea>
|
||
<button class="btn" style="margin-top:8px;" data-save-note="${escapeHtml(h.id)}">Save note</button>
|
||
`);
|
||
const body = $('sidebar-body');
|
||
body.addEventListener('click', function _noteClick(e) {
|
||
const btn = e.target.closest('[data-save-note]');
|
||
if (!btn) return;
|
||
body.removeEventListener('click', _noteClick);
|
||
const text = (body.querySelector('#hl-note-input')?.value || '').trim();
|
||
h.note = text;
|
||
highlightsDirty = true;
|
||
debounceSaveHighlights();
|
||
closeSidebar();
|
||
});
|
||
}
|
||
|
||
function deleteHighlight(id) {
|
||
currentHighlights = currentHighlights.filter(h => h.id !== id);
|
||
highlightsDirty = true;
|
||
// Re-apply all highlights after removing the deleted one
|
||
const contentEl = $('reader-content');
|
||
if (contentEl && !currentPdfDoc) {
|
||
// Snapshot restore not available mid-session, so remove the mark manually
|
||
const mark = contentEl.querySelector(`mark[data-highlight-id="${id}"]`);
|
||
if (mark) {
|
||
const parent = mark.parentNode;
|
||
while (mark.firstChild) parent.insertBefore(mark.firstChild, mark);
|
||
parent.removeChild(mark);
|
||
}
|
||
}
|
||
debounceSaveHighlights();
|
||
}
|
||
|
||
function dismissHighlightPopover() {
|
||
if (currentHighlightPopover) {
|
||
currentHighlightPopover.remove();
|
||
currentHighlightPopover = null;
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Focus station sidebar
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const FOCUS_STATION_PRESETS = [
|
||
{name: 'None (no station)', url: ''},
|
||
{name: 'SomaFM Groove Salad', url: 'https://ice5.somafm.com/groovesalad-128-aac'},
|
||
{name: 'SomaFM Deep Space One', url: 'https://ice5.somafm.com/deepspaceone-128-aac'},
|
||
{name: 'SomaFM Drone Zone', url: 'https://ice5.somafm.com/dronezone-128-aac'},
|
||
{name: 'SomaFM Space Station', url: 'https://ice5.somafm.com/spacestation-128-aac'},
|
||
{name: 'Linn Jazz', url: 'http://radio.linnrecords.com/linnjazz.pls'},
|
||
];
|
||
|
||
function openFocusStationSidebar() {
|
||
// null = never saved (default active); {url:''} = disabled; {url:'...'} = custom
|
||
const effectiveUrl = USER_FOCUS_STATION === null ? DEFAULT_FOCUS_STATION.url : (USER_FOCUS_STATION.url || '');
|
||
const currentName = USER_FOCUS_STATION === null ? DEFAULT_FOCUS_STATION.name
|
||
: (USER_FOCUS_STATION.name || 'None (no station)');
|
||
|
||
let presetsHtml = FOCUS_STATION_PRESETS.map((p, i) => {
|
||
const active = p.url === effectiveUrl ? ' class="focus-preset-active"' : '';
|
||
return `<li${active}><button class="btn btn-sm" data-focus-preset="${i}">${escapeHtml(p.name)}</button></li>`;
|
||
}).join('');
|
||
|
||
const html = `
|
||
<p class="muted">Station played when opening a book.</p>
|
||
<p><strong>Current:</strong> ${escapeHtml(currentName)}</p>
|
||
<ul class="focus-preset-list">${presetsHtml}</ul>
|
||
<div class="focus-custom-input">
|
||
<input type="text" id="focus-custom-name" class="search-input" placeholder="Station name" value="${escapeHtml(effectiveUrl ? currentName : '')}">
|
||
<input type="text" id="focus-custom-url" class="search-input" placeholder="Stream URL" value="${escapeHtml(effectiveUrl)}">
|
||
<button class="btn" data-focus-save="1">Save</button>
|
||
<button class="btn btn-sm" data-focus-play="1">Play Now</button>
|
||
</div>
|
||
`;
|
||
|
||
openSidebar('Focus Station', html);
|
||
|
||
const body = $('sidebar-body');
|
||
body.addEventListener('click', function _focusClick(e) {
|
||
const presetBtn = e.target.closest('[data-focus-preset]');
|
||
const saveBtn = e.target.closest('[data-focus-save]');
|
||
const playBtn = e.target.closest('[data-focus-play]');
|
||
if (presetBtn) {
|
||
const preset = FOCUS_STATION_PRESETS[parseInt(presetBtn.dataset.focusPreset, 10)];
|
||
if (preset) saveFocusStation(preset.url, preset.name);
|
||
body.removeEventListener('click', _focusClick);
|
||
} else if (saveBtn) {
|
||
const url = (body.querySelector('#focus-custom-url')?.value || '').trim();
|
||
const name = (body.querySelector('#focus-custom-name')?.value || '').trim();
|
||
saveFocusStation(url, name);
|
||
body.removeEventListener('click', _focusClick);
|
||
} else if (playBtn) {
|
||
const url = (body.querySelector('#focus-custom-url')?.value || '').trim();
|
||
const name = (body.querySelector('#focus-custom-name')?.value || '').trim();
|
||
if (url) playStation(url, name, null);
|
||
}
|
||
});
|
||
}
|
||
|
||
|
||
async function saveFocusStation(url, name) {
|
||
url = (url || '').trim();
|
||
name = (name || '').trim();
|
||
if (!IS_AUTHENTICATED) {
|
||
USER_FOCUS_STATION = {url, name};
|
||
closeSidebar();
|
||
return;
|
||
}
|
||
try {
|
||
const res = await fetch('/accounts/focus-station/', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
||
body: JSON.stringify({url, name}),
|
||
});
|
||
const data = await res.json();
|
||
if (data.ok) {
|
||
USER_FOCUS_STATION = {url, name};
|
||
closeSidebar();
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Init
|
||
// ---------------------------------------------------------------------------
|
||
|
||
(function init() {
|
||
// Migrate PBKDF2-derived key stored by login/register form
|
||
if (window.USER_ID) {
|
||
const pending = localStorage.getItem('diora_pending_enc_key');
|
||
if (pending) {
|
||
localStorage.setItem(`diora_enc_key_${window.USER_ID}`, pending);
|
||
localStorage.removeItem('diora_pending_enc_key');
|
||
}
|
||
}
|
||
|
||
// Populate saved stations from server-side context if available
|
||
if (typeof INITIAL_SAVED !== 'undefined' && Array.isArray(INITIAL_SAVED)) {
|
||
// The server already renders saved stations in the template; nothing extra needed.
|
||
// But if JS-rendered saved tab were needed we'd call addSavedRow here.
|
||
}
|
||
|
||
// Seed podcast feeds from server context
|
||
if (typeof INITIAL_PODCAST_FEEDS !== 'undefined' && Array.isArray(INITIAL_PODCAST_FEEDS)) {
|
||
podcastFeeds = INITIAL_PODCAST_FEEDS;
|
||
}
|
||
|
||
// Wire seek slider
|
||
const seekSlider = $('seek-slider');
|
||
if (seekSlider) {
|
||
seekSlider.addEventListener('input', function () {
|
||
if (podcastMode) audio.currentTime = parseInt(this.value, 10);
|
||
});
|
||
}
|
||
|
||
// Restore persisted volume, fall back to slider default
|
||
const volSlider = $('volume');
|
||
if (volSlider) {
|
||
const saved = localStorage.getItem('diora_volume');
|
||
const vol = saved !== null ? parseInt(saved, 10) : parseInt(volSlider.value, 10);
|
||
setVolume(vol);
|
||
}
|
||
|
||
// Load recommendations on page load
|
||
loadRecommendations();
|
||
|
||
// Initialise focus timer display
|
||
renderTimer();
|
||
|
||
// Initialise mood/genre chips
|
||
initMoodChips();
|
||
|
||
// Initialise curated station lists
|
||
initCuratedLists();
|
||
|
||
// Show curated lists again when search input is cleared
|
||
const searchInput = document.getElementById('search-input');
|
||
if (searchInput) {
|
||
searchInput.addEventListener('input', function () {
|
||
if (this.value === '') {
|
||
const curated = document.getElementById('curated-lists');
|
||
if (curated) curated.style.display = '';
|
||
}
|
||
});
|
||
}
|
||
|
||
// Load focus session stats
|
||
loadFocusStats();
|
||
|
||
// Apply encrypted wallpaper (if set)
|
||
applyEncryptedBackground();
|
||
|
||
// Init book drop zone
|
||
initBookDropZone();
|
||
|
||
// Restore last active tab
|
||
const savedTab = localStorage.getItem('diora_active_tab') || 'radio';
|
||
const savedRadioTab = localStorage.getItem('diora_active_radio_tab') || 'saved';
|
||
showTab(savedTab);
|
||
showRadioTab(savedRadioTab);
|
||
|
||
// Hourly background feed refresh (only when authenticated)
|
||
if (IS_AUTHENTICATED) {
|
||
setInterval(refreshAllFeeds, 60 * 60 * 1000);
|
||
}
|
||
})();
|