/** * 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, '"'); } // --------------------------------------------------------------------------- // Play / Stop // --------------------------------------------------------------------------- function playStation(url, name, stationId) { stopPlayback(false); if (location.protocol === 'https:' && url.startsWith('http://')) { window.open(url, '_blank'); return; } currentStation = { url, name, id: stationId || null }; isPlaying = true; audio.src = url; const volSlider = document.getElementById('volume'); if (volSlider) audio.volume = volSlider.value / 255; audio.play().catch(() => { // Browser may block autoplay; the user needs to interact first console.warn('Audio play blocked by browser policy.'); }); $('now-playing-station').textContent = name; $('now-playing-track').textContent = ''; $('play-stop-btn').style.display = ''; $('play-stop-btn').textContent = '⏹ Stop'; $('play-stop-btn').classList.add('playing'); $('save-station-btn').style.display = ''; startMetadataSSE(url); startPlaySession(name, url); maybeShowDonationHint(url, name); if ('mediaSession' in navigator) { navigator.mediaSession.metadata = new MediaMetadata({title: name, artist: 'Radio'}); navigator.mediaSession.setActionHandler('play', () => { audio.play(); isPlaying = true; }); navigator.mediaSession.setActionHandler('pause', () => { audio.pause(); isPlaying = false; }); navigator.mediaSession.setActionHandler('stop', () => stopPlayback(true)); try { navigator.mediaSession.setActionHandler('seekbackward', null); } catch (_) {} try { navigator.mediaSession.setActionHandler('seekforward', null); } catch (_) {} try { navigator.mediaSession.setActionHandler('nexttrack', null); } catch (_) {} try { navigator.mediaSession.setActionHandler('previoustrack',null); } catch (_) {} navigator.mediaSession.playbackState = 'playing'; } } function stopPlayback(clearStation = true) { // Save podcast progress before stopping if (podcastMode && currentEpisode) { savePodcastProgress(); } if (seekSaveTimer) { clearInterval(seekSaveTimer); seekSaveTimer = null; } audio.pause(); audio.src = ''; audio.ontimeupdate = null; audio.onended = null; audio.onerror = null; isPlaying = false; podcastMode = false; if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'none'; const seekBar = $('podcast-seek-bar'); if (seekBar) seekBar.style.display = 'none'; if (sseSource) { sseSource.close(); sseSource = null; } $('play-stop-btn').textContent = '▶ Play'; $('play-stop-btn').classList.remove('playing'); $('save-station-btn').style.display = 'none'; $('affiliate-section').style.display = 'none'; stopPlaySession(); const stationEl = $('now-playing-station'); stationEl.classList.remove('podcast-station-link'); stationEl.onclick = null; const trackEl = $('now-playing-track'); trackEl.classList.remove('podcast-track-link'); trackEl.onclick = null; if (clearStation) { currentStation = null; currentTrack = ''; stationEl.textContent = '— no station —'; trackEl.textContent = ''; $('play-stop-btn').style.display = 'none'; } } function togglePlayStop() { if (isPlaying) { stopPlayback(true); } else if (currentStation) { playStation(currentStation.url, currentStation.name, currentStation.id); } } // --------------------------------------------------------------------------- // Play session tracking // --------------------------------------------------------------------------- async function startPlaySession(stationName, stationUrl) { try { const res = await fetch('/radio/play/start/', { method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, body: JSON.stringify({station_name: stationName, station_url: stationUrl}) }); if (res.ok) { const data = await res.json(); currentPlayId = data.play_id; } } catch (e) {} } async function stopPlaySession() { if (!currentPlayId) return; try { await fetch('/radio/play/stop/', { method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, body: JSON.stringify({play_id: currentPlayId}) }); } catch (e) {} currentPlayId = null; } window.addEventListener('beforeunload', () => { if (currentPlayId) { navigator.sendBeacon('/radio/play/stop/', JSON.stringify({play_id: currentPlayId})); } if (podcastMode && currentEpisode) { navigator.sendBeacon('/podcasts/progress/save/', JSON.stringify({ episode_id: currentEpisode.id, position_seconds: Math.floor(audio.currentTime), })); } // Flush cached encrypted payloads for reader data (encryption is async so we // use pre-computed blobs stored by the debounced savers) if (_lastProgressBeacon) navigator.sendBeacon(_lastProgressBeacon.url, _lastProgressBeacon.body); if (_lastBookmarkBeacon) navigator.sendBeacon(_lastBookmarkBeacon.url, _lastBookmarkBeacon.body); if (_lastHighlightBeacon) navigator.sendBeacon(_lastHighlightBeacon.url, _lastHighlightBeacon.body); }); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden' && currentBookId) { if (bookmarksDirty) saveBookmarks(); if (highlightsDirty) saveHighlights(); saveReaderProgress(); } }); // Cached beacon payloads — updated after each successful encrypt in save functions let _lastBookmarkBeacon = null; let _lastHighlightBeacon = null; let _lastProgressBeacon = null; // --------------------------------------------------------------------------- // Recommendations // --------------------------------------------------------------------------- async function loadRecommendations() { const container = document.getElementById('recommendations'); if (!container) return; try { const res = await fetch('/radio/recommendations/'); const data = await res.json(); if (!data.recommendations.length) { container.innerHTML = '

Play more stations to get recommendations.

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

Based on your ${label} listening:

'; 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 = ` ${escapeHtml(formatDateTime(now))} ${escapeHtml(stationName)} ${escapeHtml(track)} `; tbody.insertBefore(tr, tbody.firstChild); } async function deleteHistoryEntry(id, btn) { const tr = btn.closest('tr'); if (!id) { tr.remove(); return; } try { const res = await fetch(`/radio/history/${id}/delete/`, { method: 'POST', headers: {'X-CSRFToken': getCsrfToken()}, }); if (res.ok) tr.remove(); } catch (e) {} } // --------------------------------------------------------------------------- // Affiliate links // --------------------------------------------------------------------------- async function fetchAffiliateLinks(track) { const section = $('affiliate-section'); if (section && section.dataset.disabled) return; try { const res = await fetch('/radio/affiliate/?track=' + encodeURIComponent(track)); if (!res.ok) return; const data = await res.json(); const itunes = data.itunes_data || {}; $('affiliate-track-name').textContent = itunes.name || track; $('affiliate-artist-name').textContent = itunes.artist || ''; $('affiliate-album-name').textContent = itunes.album || ''; const artEl = $('affiliate-artwork'); if (itunes.artwork) { artEl.src = itunes.artwork; artEl.style.display = ''; } else { artEl.style.display = 'none'; } const amzLink = $('affiliate-amazon-link'); if (data.amazon_url) { amzLink.href = data.amazon_url; amzLink.style.display = ''; } else { amzLink.style.display = 'none'; } section.style.display = 'flex'; } catch (err) { console.error('fetchAffiliateLinks error:', err); section.style.display = 'none'; } } // --------------------------------------------------------------------------- // Search (radio-browser.info) // --------------------------------------------------------------------------- async function doSearch() { const query = $('search-input').value.trim(); if (!query) return; const statusEl = $('search-status'); const tableEl = $('search-results-table'); const tbody = $('search-results-body'); 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 = ` ${safeName} ${safeBr} ${safeCC} ${safeTags} `; 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 = ` ${safeName} ${safeBr} ${safeCC} `; 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 = 'No saved stations yet.'; 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 = 'No focus sessions yet. Start the timer!'; return; } data.sessions.forEach(s => { const tr = document.createElement('tr'); const dt = new Date(s.completed_at).toLocaleString([], {dateStyle: 'short', timeStyle: 'short'}); tr.innerHTML = `${dt}${escapeHtml(s.station_name || '—')}${s.duration_minutes} min`; tbody.appendChild(tr); }); } catch (e) {} } // --------------------------------------------------------------------------- // Do Not Disturb / focus mode // --------------------------------------------------------------------------- let dndActive = false; function toggleDNDLight() { document.body.classList.toggle('dnd-dark'); const btn = $('dnd-light-btn'); if (btn) btn.style.opacity = document.body.classList.contains('dnd-dark') ? '0.4' : '1'; } function toggleDND() { dndActive = !dndActive; if (!dndActive) document.body.classList.remove('dnd-dark'); document.body.classList.toggle('dnd-mode', dndActive); const btn = $('dnd-btn'); if (btn) btn.classList.toggle('active', dndActive); if (dndActive) { const el = document.documentElement; if (el.requestFullscreen) el.requestFullscreen(); else if (el.webkitRequestFullscreen) el.webkitRequestFullscreen(); } else { if (document.fullscreenElement && document.exitFullscreen) document.exitFullscreen(); else if (document.webkitFullscreenElement && document.webkitExitFullscreen) document.webkitExitFullscreen(); } } // Exit DND on Escape (browser also exits fullscreen on Escape, so sync state) document.addEventListener('fullscreenchange', () => { if (!document.fullscreenElement && dndActive) { dndActive = false; document.body.classList.remove('dnd-mode'); const btn = $('dnd-btn'); if (btn) btn.classList.remove('active'); } }); document.addEventListener('keydown', e => { if (e.key === 'Escape' && dndActive) toggleDND(); }); // --------------------------------------------------------------------------- // Mood / genre tag filter // --------------------------------------------------------------------------- const MOOD_TAGS = [ { label: '🎯 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 = `
★ Featured
`; const ul = document.createElement('ul'); ul.className = 'curated-stations'; INITIAL_FEATURED.forEach(s => { const li = document.createElement('li'); li.innerHTML = ` ${escapeHtml(s.name)} ${s.description ? `${escapeHtml(s.description)}` : ''}`; ul.appendChild(li); }); section.appendChild(ul); container.appendChild(section); } CURATED_LISTS.forEach(list => { const section = document.createElement('div'); section.className = 'curated-section'; section.innerHTML = `
${list.label}
`; const ul = document.createElement('ul'); ul.className = 'curated-stations'; list.stations.forEach(s => { const li = document.createElement('li'); li.innerHTML = ` ${escapeHtml(s.name)}`; ul.appendChild(li); }); section.appendChild(ul); container.appendChild(section); }); } // --------------------------------------------------------------------------- // Donation hint // --------------------------------------------------------------------------- const DONATION_HINT_THRESHOLD = 10; const DONATION_HINT_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000; // 7 days function maybeShowDonationHint(stationUrl, stationName) { const station = INITIAL_SAVED.find(s => s.url === stationUrl); if (!station || station.play_count < DONATION_HINT_THRESHOLD) return; const key = `diora_donation_hint_${stationUrl}`; const last = parseInt(localStorage.getItem(key) || '0', 10); if (Date.now() - last < DONATION_HINT_COOLDOWN_MS) return; const existing = document.getElementById('donation-hint'); if (existing) existing.remove(); const el = document.createElement('div'); el.id = 'donation-hint'; el.innerHTML = ` You listen to ${escapeHtml(stationName)} a lot — consider supporting them ❤️ `; 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 ? `` : '
'}
${escapeHtml(r.title)}
${escapeHtml(r.author)}
`; // 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 = '

No subscriptions yet. Search or import OPML to add feeds.

'; return; } container.innerHTML = ''; podcastFeeds.forEach(feed => { const div = document.createElement('div'); div.className = 'podcast-feed-item'; div.innerHTML = ` ${feed.artwork_url ? `` : '
'}
${escapeHtml(feed.title)}
${feed.author ? `
${escapeHtml(feed.author)}
` : ''}
`; 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 = '

Loading…

'; 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 = `
${feed.artwork_url ? `` : ''}
${escapeHtml(feed.title)}
${feed.author ? `
${escapeHtml(feed.author)}
` : ''}
`; } renderEpisodeList(episodes, feedId, listEl); const filterBar = $('episode-search-bar'); if (filterBar) { filterBar.style.display = ''; $('episode-filter-input').value = ''; } } catch (e) { if (headerEl) headerEl.innerHTML = '

Failed to load episodes.

'; } } 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 = '

No episodes found.

'; 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 ? `` : '
'}
${escapeHtml(ep.title)}
${dateStr ? `${escapeHtml(dateStr)}` : ''} ${dur !== '0:00' ? `${escapeHtml(dur)}` : ''} ${posStr ? `${escapeHtml(posStr)}` : ''}
${progressPct > 0 ? `
` : ''}
`; 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 = '

Loading…

'; 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 = '

Inbox empty — all caught up!

'; $('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 = ` ${ep['feed__artwork_url'] ? `` : '
'}
${escapeHtml(ep.title)}
${escapeHtml(ep['feed__title'])} ${dateStr ? `${escapeHtml(dateStr)}` : ''} ${dur !== '0:00' ? `${escapeHtml(dur)}` : ''}
${progressPct > 0 ? `
` : ''}
`; 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 = '

Failed to load inbox.

'; } } 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 = '

Inbox empty — all caught up!

'; } } } 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 = '

Inbox empty — all caught up!

'; } } } 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 = '
  • Loading…
  • '; 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 = '
  • Queue is empty.
  • '; 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 = `
    ${escapeHtml(item['episode__title'])}
    ${escapeHtml(item['episode__feed__title'])} ${dur !== '0:00' ? `${escapeHtml(dur)}` : ''}
    ${progressPct > 0 ? `
    ` : ''}
    `; li.addEventListener('dragstart', queueDragStart); li.addEventListener('dragover', queueDragOver); li.addEventListener('drop', queueDrop); li.addEventListener('dragend', queueDragEnd); ol.appendChild(li); }); } catch (e) { ol.innerHTML = '
  • Failed to load queue.
  • '; } } function queueDragStart(e) { _dragSrcEl = this; e.dataTransfer.effectAllowed = 'move'; this.classList.add('dragging'); } function queueDragOver(e) { e.preventDefault(); const ol = document.getElementById('podcast-queue-ol'); const dragging = ol.querySelector('.dragging'); if (!dragging || dragging === this) return; const rect = this.getBoundingClientRect(); ol.insertBefore(dragging, e.clientY < rect.top + rect.height / 2 ? this : this.nextSibling); } function queueDrop(e) { e.preventDefault(); } function queueDragEnd() { this.classList.remove('dragging'); _dragSrcEl = null; const ol = document.getElementById('podcast-queue-ol'); const newOrder = Array.from(ol.querySelectorAll('li[data-ep-id]')) .map(li => parseInt(li.dataset.epId, 10)); fetch('/podcasts/queue/reorder/', { method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, body: JSON.stringify({order: newOrder}), }).catch(() => {}); } async function queueAddEpisode(id) { try { await fetch('/podcasts/queue/add/', { method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, body: JSON.stringify({episode_id: id}), }); } catch (e) {} } async function queueRemoveEpisode(id) { try { await fetch('/podcasts/queue/remove/', { method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, body: JSON.stringify({episode_id: id}), }); if (podcastCurrentView === 'queue') loadAndRenderQueue(); } catch (e) {} } async function toggleMarkPlayed(id, btn) { const ep = podcastEpCache[id]; const current = ep ? ep.played : btn.textContent === '✓'; const newPlayed = !current; try { const res = await fetch('/podcasts/progress/mark-played/', { method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, body: JSON.stringify({episode_id: id, played: newPlayed}), }); if (res.ok) { if (ep) ep.played = newPlayed; btn.textContent = newPlayed ? '✓' : '○'; const item = document.getElementById(`episode-item-${id}`); if (item) item.classList.toggle('episode-played', newPlayed); } } catch (e) {} } async function refreshFeed(feedId) { try { const res = await fetch('/podcasts/feeds/refresh/', { method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, body: JSON.stringify({feed_id: feedId}), }); const data = await res.json(); if (data.ok && podcastCurrentFeedId === feedId) { openFeed(feedId); } } catch (e) {} } async function refreshOpenFeed(btn) { if (!podcastCurrentFeedId) return; if (btn) { btn.disabled = true; btn.textContent = '↻ …'; } try { const res = await fetch('/podcasts/feeds/refresh/', { method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, body: JSON.stringify({feed_id: podcastCurrentFeedId}), }); const data = await res.json(); if (data.ok) { await openFeed(podcastCurrentFeedId); // openFeed re-renders the header, btn reference is stale — nothing to restore return; } } catch (e) {} if (btn) { btn.disabled = false; btn.textContent = '↻ Refresh'; } } async function refreshAllFeedsBtn(btn) { if (btn) { btn.disabled = true; btn.textContent = '↻ 0/' + podcastFeeds.length; } let done = 0; for (const feed of podcastFeeds) { try { await fetch('/podcasts/feeds/refresh/', { method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, body: JSON.stringify({feed_id: feed.id}), }); } catch (e) {} done++; if (btn) btn.textContent = `↻ ${done}/${podcastFeeds.length}`; } await loadFeedList(); if (podcastCurrentView === 'feeds') renderFeedList(); if (podcastCurrentView === 'inbox') loadAndRenderInbox(); if (podcastCurrentView === 'episodes' && podcastCurrentFeedId) openFeed(podcastCurrentFeedId); if (btn) { btn.disabled = false; btn.textContent = '↻ All'; } } async function refreshAllFeeds() { if (!IS_AUTHENTICATED || !podcastFeeds.length) return; for (const feed of podcastFeeds) { try { await fetch('/podcasts/feeds/refresh/', { method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, body: JSON.stringify({feed_id: feed.id}), }); } catch (e) {} } // Reload feed metadata and refresh the currently open view await loadFeedList(); if (podcastCurrentView === 'feeds') renderFeedList(); if (podcastCurrentView === 'inbox') loadAndRenderInbox(); if (podcastCurrentView === 'episodes' && podcastCurrentFeedId) openFeed(podcastCurrentFeedId); } async function removeFeed(feedId) { if (!confirm('Remove this podcast?')) return; try { await fetch(`/podcasts/feeds/${feedId}/remove/`, { method: 'POST', headers: {'X-CSRFToken': getCsrfToken()}, }); podcastFeeds = podcastFeeds.filter(f => f.id !== feedId); renderFeedList(); } catch (e) {} } async function importOPML(input) { const file = input.files[0]; if (!file) return; const statusEl = $('opml-status'); if (statusEl) statusEl.textContent = 'Importing…'; const form = new FormData(); form.append('file', file); form.append('csrfmiddlewaretoken', getCsrfToken()); try { const res = await fetch('/podcasts/feeds/import/', {method: 'POST', body: form}); const data = await res.json(); if (data.ok) { if (statusEl) statusEl.textContent = `✓ ${data.added} added, ${data.skipped} already subscribed`; await loadFeedList(); renderFeedList(); } else { if (statusEl) statusEl.textContent = 'Error: ' + (data.error || 'unknown'); } } catch (e) { if (statusEl) statusEl.textContent = 'Import failed.'; } input.value = ''; } async function downloadEpisode(url, title, btn) { if (!('caches' in window)) { alert('Cache API not supported in this browser.'); return; } const origText = btn ? btn.textContent : '⬇'; if (btn) { btn.textContent = '…'; btn.disabled = true; } try { const cache = await caches.open('diora-podcast-v1'); const existing = await cache.match(url); if (existing) { if (btn) { btn.textContent = '✓'; btn.disabled = false; } return; } // Use no-cors so cross-origin audio URLs don't block the fetch const resp = await fetch(url, {mode: 'no-cors'}); await cache.put(url, resp); if (btn) { btn.textContent = '✓'; btn.disabled = false; } } catch (e) { if (btn) { btn.textContent = origText; btn.disabled = false; } alert('Download failed: ' + e.message); } } // --------------------------------------------------------------------------- // Sidebar // --------------------------------------------------------------------------- function openSidebar(title, htmlContent) { $('sidebar-title').textContent = title; $('sidebar-body').innerHTML = sanitizeSidebarHtml(htmlContent); $('sidebar-overlay').style.display = ''; $('sidebar').classList.add('open'); } function closeSidebar() { $('sidebar').classList.remove('open'); $('sidebar-overlay').style.display = 'none'; } document.addEventListener('keydown', e => { const overlay = $('reader-overlay'); const readerOpen = overlay && overlay.style.display !== 'none'; if (e.key === 'Escape') { if (readerOpen) { closeReader(); } else { closeSidebar(); } } if (readerOpen) { if ((e.ctrlKey && e.key === 'f') || e.key === 'F3') { e.preventDefault(); toggleReaderSearch(); } if (readerSearchOpen) { if (e.key === 'ArrowDown') { e.preventDefault(); readerSearchNext(); } if (e.key === 'ArrowUp') { e.preventDefault(); readerSearchPrev(); } } if (readerSettings.pdfPaginated && currentPdfDoc) { if (e.key === 'ArrowRight') { e.preventDefault(); pdfGoToPage(pdfCurrentPage + 1); } if (e.key === 'ArrowLeft') { e.preventDefault(); pdfGoToPage(pdfCurrentPage - 1); } } } }); function sanitizeSidebarHtml(html) { if (!html) return '

    No show notes available.

    '; 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(`
    ${sanitized}
    `); } 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)) || ''; } // html = html.replace(/(]*?)\bsrc="([^"]*)"/gi, (_, pre, src) => { const b = subst(src); return b ? `${pre}src="${b}"` : pre; }); // SVG html = html.replace(/(]*?)\bxlink:href="([^"]*)"/gi, (_, pre, src) => { const b = subst(src); return b ? `${pre}xlink:href="${b}"` : pre; }); // SVG html = html.replace(/(]*?)\bhref="([^"]*)"/gi, (_, pre, src) => { const b = subst(src); return b ? `${pre}href="${b}"` : pre; }); return html; } function sanitizeEpubHtml(html) { const doc = new DOMParser().parseFromString(html, 'text/html'); doc.querySelectorAll('script, iframe, object, embed, style, head, meta, link').forEach(el => el.remove()); doc.querySelectorAll('*').forEach(el => { Array.from(el.attributes).forEach(attr => { if (attr.name.startsWith('on')) el.removeAttribute(attr.name); }); const tag = el.tagName.toLowerCase(); if (tag === 'img') { const src = el.getAttribute('src') || ''; // Remove any non-blob, non-data src that slipped through (broken relative paths) if (src && !src.startsWith('blob:') && !src.startsWith('data:')) el.removeAttribute('src'); } if (el.tagName === 'A') { const href = el.getAttribute('href') || ''; if (href.startsWith('http://') || href.startsWith('https://')) { el.setAttribute('target', '_blank'); el.setAttribute('rel', 'noopener noreferrer'); } else { el.removeAttribute('href'); el.style.cursor = 'default'; } } }); return doc.body ? doc.body.innerHTML : doc.documentElement.innerHTML; } // --------------------------------------------------------------------------- // Books // --------------------------------------------------------------------------- let currentBookId = null; let currentBookToc = []; let currentImageMap = {}; let readerScrollSaveTimer = null; let _resizeObserver = null; let _currentPositionAnchor = ''; const bookMetaCache = {}; // id → {title, author, type} const EPUB_BLOCK_SELECTOR = 'p, h1, h2, h3, h4, h5, h6, li, blockquote, dt, dd, figcaption, div:not(:has(*))'; function getPositionAnchor(contentEl) { const blocks = Array.from(contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR)); if (!blocks.length) return ''; const containerTop = contentEl.getBoundingClientRect().top; const containerBottom = containerTop + contentEl.clientHeight; let bestIndex = 0; let bestDelta = Infinity; for (let i = 0; i < blocks.length; i++) { const rect = blocks[i].getBoundingClientRect(); if (rect.height < 1) continue; if (rect.top > containerBottom) break; const delta = rect.top - containerTop; if (delta <= 0 && Math.abs(delta) < Math.abs(bestDelta)) { bestIndex = i; bestDelta = delta; } else if (delta > 0 && bestDelta === Infinity) { bestIndex = i; bestDelta = delta; } } const rect = blocks[bestIndex].getBoundingClientRect(); const innerFraction = Math.max(0, Math.min(1, (containerTop - rect.top) / (rect.height || 1))); return `${bestIndex}:${innerFraction.toFixed(6)}`; } function restoreFromAnchor(contentEl, anchor) { if (!anchor) return false; const parts = anchor.split(':'); if (parts.length !== 2) return false; const idx = parseInt(parts[0], 10); const innerFraction = parseFloat(parts[1]); if (isNaN(idx) || isNaN(innerFraction)) return false; const blocks = Array.from(contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR)); if (idx >= blocks.length) return false; const el = blocks[idx]; contentEl.scrollTop = el.offsetTop + Math.round(innerFraction * el.offsetHeight); return true; } // 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 _pdfRenderGen = 0; 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 = '

    Loading books…

    '; try { listEl.innerHTML = '

    Fetching book list from server…

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

    Server error ${res.status} loading books.

    `; return; } const books = await res.json(); if (!Array.isArray(books)) { listEl.innerHTML = `

    Unexpected response from server (not an array).

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

    No books yet. Drop an .epub or .pdf above.

    '; return; } listEl.innerHTML = `

    Found ${books.length} book(s) on server. Decrypting…

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

    Encryption not available: ${e.message}. Make sure you are on HTTPS.

    `; return; } const decrypted = []; for (const b of books) { try { const metaBuf = await decryptBytes(key, b.meta_iv, b.meta_ct); const meta = JSON.parse(new TextDecoder().decode(metaBuf)); bookMetaCache[b.id] = {title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub'}; decrypted.push({id: b.id, title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', scroll_fraction: b.scroll_fraction, uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: true}); } catch (e) { bookMetaCache[b.id] = {title: `Book #${b.id}`, author: '', type: 'epub'}; decrypted.push({id: b.id, title: `Book #${b.id}`, author: '', type: 'epub', scroll_fraction: b.scroll_fraction, uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: false}); } } decrypted.sort((a, b) => { if (a.last_read && b.last_read) return b.last_read.localeCompare(a.last_read); if (a.last_read) return -1; if (b.last_read) return 1; return b.uploaded_at.localeCompare(a.uploaded_at); }); renderBookList(decrypted); } catch (e) { if (listEl) listEl.innerHTML = `

    Error loading books: ${e.message}

    `; } } function renderBookList(books) { const listEl = $('book-list'); if (!listEl) return; let html = ''; for (const b of books) { const pct = Math.round((b.scroll_fraction || 0) * 100); const keyWarning = b.keyOk === false ? '⚠️ wrong key' : ''; html += `
    ${escapeHtml(b.title)}${keyWarning} ${escapeHtml(b.author)} ${pct > 0 ? `${pct}% read` : ''}
    `; } 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 myGen = ++_pdfRenderGen; const pdf = currentPdfDoc || await pdfjsLib.getDocument({data: new Uint8Array(arrayBuffer.slice(0))}).promise; if (_pdfRenderGen !== myGen) return null; currentPdfDoc = pdf; let pdfTitle = '', pdfAuthor = ''; try { const meta = await pdf.getMetadata(); pdfTitle = meta.info?.Title?.trim() || ''; pdfAuthor = meta.info?.Author?.trim() || ''; } catch (e) {} let toc = []; try { const outline = await pdf.getOutline(); if (outline && outline.length) toc = await _parsePdfOutline(pdf, outline); } catch (e) {} contentEl.innerHTML = ''; // Viewport wrapper: CSS zoom controls display scale without re-rendering const pdfVp = document.createElement('div'); pdfVp.id = 'pdf-viewport'; if (scaleOverride == null) pdfVp.style.zoom = readerSettings.pdfZoom / 100; contentEl.appendChild(pdfVp); const containerWidth = Math.min(contentEl.clientWidth - 32, 900); for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) { if (_pdfRenderGen !== myGen) { contentEl.innerHTML = ''; return null; } const page = await pdf.getPage(pageNum); const naturalVp = page.getViewport({scale: 1}); const scale = scaleOverride != null ? scaleOverride : Math.max(0.5, containerWidth / naturalVp.width); const viewport = page.getViewport({scale}); const wrapper = document.createElement('div'); wrapper.className = 'pdf-page-wrapper'; wrapper.id = `pdf-page-${pageNum}`; // Inner container gives canvas + text layer a shared position:relative origin, // independent of the outer flex wrapper's centering. const inner = document.createElement('div'); inner.className = 'pdf-page-inner'; const dpr = window.devicePixelRatio || 1; const canvas = document.createElement('canvas'); canvas.className = 'pdf-page'; canvas.width = Math.round(viewport.width * dpr); canvas.height = Math.round(viewport.height * dpr); canvas.style.width = viewport.width + 'px'; canvas.style.height = viewport.height + 'px'; inner.appendChild(canvas); wrapper.appendChild(inner); pdfVp.appendChild(wrapper); const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr); await page.render({canvasContext: ctx, viewport}).promise; // Text layer disabled — re-enable once overlay rendering is resolved } pdfTotalPages = pdf.numPages; return {title: pdfTitle, author: pdfAuthor, toc, numPages: pdf.numPages}; } // --------------------------------------------------------------------------- // Immersive reader mode — tap centre of screen to toggle bars // --------------------------------------------------------------------------- let _immBarsVisible = true; function _immHandleTap(e) { // Ignore taps on interactive elements (buttons, links, inputs, settings panel) if (e.target.closest('button, a, input, select, label, #reader-settings-panel, .reader-header')) return; _immBarsVisible = !_immBarsVisible; document.body.classList.toggle('reader-immersive', !_immBarsVisible); } function enterReaderImmersiveMode() { _immBarsVisible = true; document.body.classList.remove('reader-immersive'); document.addEventListener('click', _immHandleTap); } function exitReaderImmersiveMode() { _immBarsVisible = true; document.body.classList.remove('reader-immersive'); document.removeEventListener('click', _immHandleTap); } async function openBook(bookId) { const overlay = $('reader-overlay'); const contentEl = $('reader-content'); const titleEl = $('reader-title'); if (!overlay || !contentEl) return; titleEl.textContent = 'Loading…'; contentEl.innerHTML = ''; overlay.style.display = ''; try { loadReaderSettings(); 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); // 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; const anchor = bookData ? (bookData.position_anchor || '') : ''; _currentPositionAnchor = anchor; if (isPdf && readerSettings.pdfPaginated && pdfTotalPages > 1) { if (fraction > 0) pdfCurrentPage = Math.max(1, Math.round(fraction * (pdfTotalPages - 1)) + 1); } else if (!isPdf) { // Wait for all images so scrollHeight is final const imgs = Array.from(contentEl.querySelectorAll('img')); if (imgs.length) { await Promise.all(imgs.map(img => img.complete ? Promise.resolve() : new Promise(r => { img.onload = r; img.onerror = r; }) )); } await new Promise(r => requestAnimationFrame(r)); if (!restoreFromAnchor(contentEl, anchor) && fraction > 0) { contentEl.scrollTop = fraction * (contentEl.scrollHeight - contentEl.clientHeight); } } } catch (e) {} // Update progress input on scroll contentEl.addEventListener('scroll', () => { if (!progressInput) return; if (isPdf) { const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper'); const cTop = contentEl.getBoundingClientRect().top; let currentPage = 1; for (const w of wrappers) { if (w.getBoundingClientRect().bottom > cTop + 20) { currentPage = parseInt(w.id.replace('pdf-page-', ''), 10) || 1; break; } } progressInput.value = currentPage; } else { const f = contentEl.scrollTop / (contentEl.scrollHeight - contentEl.clientHeight || 1); progressInput.value = Math.round(f * 100); } }); // Auto-save progress every 10s and on scroll (debounced 2s) readerScrollSaveTimer = setInterval(saveReaderProgress, 10000); let _scrollDebounce = null; contentEl.addEventListener('scroll', () => { clearTimeout(_scrollDebounce); _scrollDebounce = setTimeout(saveReaderProgress, 2000); }, {passive: true}); // Restore anchor on viewport resize (e.g. screen rotation, font zoom) if (!isPdf) { _resizeObserver = new ResizeObserver(() => { if (_currentPositionAnchor) { requestAnimationFrame(() => restoreFromAnchor(contentEl, _currentPositionAnchor)); } }); _resizeObserver.observe(contentEl); } // 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); } } enterReaderImmersiveMode(); } catch (e) { overlay.style.display = 'none'; alert(`Failed to open book: ${e.message}`); } } async function exportEncKey() { const statusEl = $('book-key-status'); try { const key = await getOrCreateEncKey(); const raw = await crypto.subtle.exportKey('raw', key); const b64 = bytesToBase64(raw); await navigator.clipboard.writeText(b64); if (statusEl) statusEl.textContent = '✓ Key copied to clipboard'; setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 3000); } catch (e) { if (statusEl) statusEl.textContent = 'Export failed: ' + e.message; } } function showImportKey() { const body = $('sidebar-body'); openSidebar('Import encryption key', `

    Paste the key exported from your other browser:

    This replaces the key in this browser. Books uploaded here won't be readable until you sync the key back.

    `); body.addEventListener('click', async function _importClick(e) { if (!e.target.closest('[data-import-key-apply]')) return; body.removeEventListener('click', _importClick); const b64 = (body.querySelector('#import-key-input')?.value || '').trim(); const statusEl = $('book-key-status'); try { const raw = base64ToBytes(b64); const importedKey = await crypto.subtle.importKey('raw', raw, {name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']); const re_exported = await crypto.subtle.exportKey('raw', importedKey); localStorage.setItem(`diora_enc_key_${window.USER_ID}`, bytesToBase64(re_exported)); closeSidebar(); if (statusEl) statusEl.textContent = '✓ Key imported — reloading books…'; await loadBookList(); if (statusEl) setTimeout(() => { statusEl.textContent = ''; }, 3000); } catch (e) { if ($('book-key-status')) $('book-key-status').textContent = 'Import failed: invalid key'; } }); } async function deleteBook(bookId) { if (!confirm('Delete this book? This cannot be undone.')) return; try { const res = await fetch(`/books/${bookId}/delete/`, { method: 'POST', headers: {'X-CSRFToken': getCsrfToken()}, }); const data = await res.json(); if (data.ok) loadBookList(); } catch (e) {} } async function saveReaderProgress() { if (!currentBookId) return; const contentEl = $('reader-content'); if (!contentEl) return; let fraction; if (readerSettings.pdfPaginated && currentPdfDoc && pdfTotalPages > 1) { fraction = (pdfCurrentPage - 1) / (pdfTotalPages - 1); } else { fraction = contentEl.scrollTop / (contentEl.scrollHeight - contentEl.clientHeight || 1); } fraction = Math.min(1.0, Math.max(0.0, fraction)); let anchor = ''; if (!currentPdfDoc) { anchor = getPositionAnchor(contentEl); _currentPositionAnchor = anchor; } // Cache for sendBeacon on unload _lastProgressBeacon = { url: `/books/${currentBookId}/progress/`, body: JSON.stringify({scroll_fraction: fraction, position_anchor: anchor}), }; try { await fetch(`/books/${currentBookId}/progress/`, { method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, body: _lastProgressBeacon.body, }); } catch (e) {} } function closeReader() { exitReaderImmersiveMode(); // Save progress BEFORE hiding — scrollHeight/clientHeight return 0 once display:none saveReaderProgress(); if (bookmarksDirty) saveBookmarks(); if (highlightsDirty) saveHighlights(); const overlay = $('reader-overlay'); if (overlay) overlay.style.display = 'none'; if (readerScrollSaveTimer) { clearInterval(readerScrollSaveTimer); readerScrollSaveTimer = null; } if (_resizeObserver) { _resizeObserver.disconnect(); _resizeObserver = null; } _currentPositionAnchor = ''; // Clear search before wiping content clearReaderSearch(); // Close settings panel if open readerSettingsPanelOpen = false; const sp = document.getElementById('reader-settings-panel'); if (sp) sp.remove(); // Reset progress input const progressInput = $('reader-progress-input'); if (progressInput) { progressInput.style.display = 'none'; progressInput.value = 0; } const progressSuffix = $('reader-progress-suffix'); if (progressSuffix) progressSuffix.textContent = ''; // 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', '

    No table of contents found in this book.

    '); return; } let html = '
      '; for (const entry of currentBookToc) { const indent = entry.depth * 14; // Use data-toc-href — onclick would be stripped by sanitizeSidebarHtml html += `
    • `; } html += '
    '; openSidebar('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'); if (_currentPositionAnchor && currentBookId) { requestAnimationFrame(() => restoreFromAnchor($('reader-content'), _currentPositionAnchor)); } } // Theme overlay.classList.remove('reader-theme-sepia', 'reader-theme-bright'); if (readerSettings.theme === 'sepia') overlay.classList.add('reader-theme-sepia'); else if (readerSettings.theme === 'bright') overlay.classList.add('reader-theme-bright'); // PDF invert if (isPdf && readerSettings.pdfInverted) overlay.classList.add('pdf-inverted'); else overlay.classList.remove('pdf-inverted'); } function toggleSettingsPanel() { const overlay = $('reader-overlay'); const contentEl = $('reader-content'); if (!overlay || !contentEl) return; const existing = document.getElementById('reader-settings-panel'); if (existing) { existing.remove(); readerSettingsPanelOpen = false; return; } readerSettingsPanelOpen = true; const isPdf = !!currentPdfDoc; const panel = document.createElement('div'); panel.id = 'reader-settings-panel'; panel.className = 'reader-settings-panel'; if (!isPdf) { panel.innerHTML = ` `; } else { panel.innerHTML = ` `; } 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('input', () => { readerSettings.pdfZoom = parseInt(zoomRange.value, 10); zoomVal.textContent = readerSettings.pdfZoom + '%'; saveReaderSettings(); if (readerSettings.pdfPaginated) { pdfSmartZoomPage(pdfCurrentPage); } else { const vp = document.getElementById('pdf-viewport'); if (vp) vp.style.zoom = readerSettings.pdfZoom / 100; } }); panel.querySelector('#rs-invert').addEventListener('click', function () { readerSettings.pdfInverted = !readerSettings.pdfInverted; this.classList.toggle('active', readerSettings.pdfInverted); applyReaderSettings(true); saveReaderSettings(); }); } } 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 pdfVp = document.getElementById('pdf-viewport'); if (pdfVp) pdfVp.style.zoom = 1; const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper'); wrappers.forEach((w, i) => { w.style.display = (i + 1 === pdfCurrentPage) ? '' : 'none'; }); pdfSmartZoomPage(pdfCurrentPage); // Tap left/right to navigate contentEl.addEventListener('click', _pdfPaginatedClick); } function exitPdfPaginatedMode() { const contentEl = $('reader-content'); if (!contentEl) return; contentEl.classList.remove('pdf-paginated'); contentEl.style.overflow = ''; contentEl.removeEventListener('click', _pdfPaginatedClick); const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper'); wrappers.forEach(w => { w.style.display = ''; const canvas = w.querySelector('canvas'); if (canvas) canvas.style.transform = ''; }); const pdfVp = document.getElementById('pdf-viewport'); if (pdfVp) pdfVp.style.zoom = readerSettings.pdfZoom / 100; } 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 ) * (readerSettings.pdfZoom / 100); // Re-render canvas at new scale if significantly different const currentScale = canvas.width / naturalVp.width; if (Math.abs(scale - currentScale) / currentScale > 0.05) { const vp = page.getViewport({scale}); canvas.width = vp.width; canvas.height = vp.height; await page.render({canvasContext: canvas.getContext('2d'), viewport: vp}).promise; } // Position canvas to center the text bounding box const renderedScale = canvas.width / naturalVp.width; const offsetX = -renderedScale * (bbox.x1 - pad) + (containerW - renderedScale * contentW - pad * 2) / 2; // PDF y-axis is bottom-up; canvas is top-down const offsetY = -renderedScale * (pageH - bbox.y2 - pad) + (containerH - renderedScale * contentH - pad * 2) / 2; canvas.style.transform = `translate(${offsetX}px, ${offsetY}px)`; wrapper.style.overflow = 'hidden'; wrapper.style.width = containerW + 'px'; wrapper.style.height = containerH + 'px'; } async function _computePdfTextBox(page, pageW, pageH) { // Tier 1: text-based try { const tc = await page.getTextContent(); if (tc.items && tc.items.length) { let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity; for (const item of tc.items) { if (!item.transform) continue; const tx = item.transform[4], ty = item.transform[5]; const iw = item.width || 0, ih = item.height || 0; if (tx < x1) x1 = tx; if (ty < y1) y1 = ty; if (tx + iw > x2) x2 = tx + iw; if (ty + ih > y2) y2 = ty + ih; } const area = (x2 - x1) * (y2 - y1); if (isFinite(x1) && area > pageW * pageH * 0.25) { return {x1, y1, x2, y2}; } } } catch (e) {} // Tier 2: pixel analysis at scale 0.3 try { const lowScale = 0.3; const vp = page.getViewport({scale: lowScale}); const offCanvas = document.createElement('canvas'); offCanvas.width = vp.width; offCanvas.height = vp.height; const ctx = offCanvas.getContext('2d'); await page.render({canvasContext: ctx, viewport: vp}).promise; const {data, width, height} = ctx.getImageData(0, 0, vp.width, vp.height); let rMin = height, rMax = 0, cMin = width, cMax = 0; for (let r = 0; r < height; r++) { for (let c = 0; c < width; c++) { const idx = (r * width + c) * 4; if (data[idx] + data[idx+1] + data[idx+2] < 720) { if (r < rMin) rMin = r; if (r > rMax) rMax = r; if (c < cMin) cMin = c; if (c > cMax) cMax = c; } } } if (rMin < rMax && cMin < cMax) { return { x1: cMin / lowScale, y1: (height - rMax) / lowScale, x2: cMax / lowScale, y2: (height - rMin) / lowScale, }; } } catch (e) {} // Fallback: full page return {x1: 0, y1: 0, x2: pageW, y2: pageH}; } // --------------------------------------------------------------------------- // Bookmarks // --------------------------------------------------------------------------- async function loadBookmarks(bookId) { try { const res = await fetch(`/books/${bookId}/bookmarks/`); const {ct, iv} = await res.json(); if (ct) { const key = await getOrCreateEncKey(); const plain = await decryptBytes(key, iv, ct); currentBookmarks = JSON.parse(new TextDecoder().decode(plain)); } else { currentBookmarks = []; } } catch (e) { currentBookmarks = []; } } async function saveBookmarks() { if (!currentBookId) return; try { const key = await getOrCreateEncKey(); const plain = new TextEncoder().encode(JSON.stringify(currentBookmarks)); const {iv, ciphertext} = await encryptBytes(key, plain); const body = JSON.stringify({ct: ciphertext, iv}); const url = `/books/${currentBookId}/bookmarks/`; _lastBookmarkBeacon = {url, body}; await fetch(url, { method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, body, }); bookmarksDirty = false; } catch (e) {} } function addBookmark() { const contentEl = $('reader-content'); if (!contentEl || !currentBookId) return; let label, anchor, scrollFraction; if (currentPdfDoc) { const page = pdfCurrentPage || parseInt($('reader-progress-input')?.value, 10) || 1; label = `Page ${page}`; anchor = `pdf-page-${page}`; scrollFraction = (page - 1) / Math.max(1, pdfTotalPages - 1); } else { // Find first visible chapter div const chapters = contentEl.querySelectorAll('[data-epub-src]'); let visibleChapter = null; for (const ch of chapters) { const rect = ch.getBoundingClientRect(); if (rect.bottom > 0 && rect.top < window.innerHeight) { visibleChapter = ch; break; } } const src = visibleChapter?.getAttribute('data-epub-src') || ''; label = src.split('/').pop().replace(/\.x?html?$/i, '') || 'Bookmark'; anchor = src; scrollFraction = contentEl.scrollTop / (contentEl.scrollHeight - contentEl.clientHeight || 1); } const bm = { id: crypto.randomUUID(), label, anchor, scrollFraction, createdAt: new Date().toISOString(), }; currentBookmarks.unshift(bm); bookmarksDirty = true; saveBookmarks(); // Toast const toast = document.createElement('div'); toast.className = 'reader-toast'; toast.textContent = `★ Bookmarked: ${label}`; document.body.appendChild(toast); setTimeout(() => toast.remove(), 2200); } function openBookmarksSidebar() { if (!currentBookmarks.length) { openSidebar('Bookmarks', '

    No bookmarks yet. Press ★ while reading.

    '); return; } let html = '
      '; for (const bm of currentBookmarks) { html += `
    • `; } html += '
    '; 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 = ` `; 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 = ` `; 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) : 'No note'; popover.innerHTML = `
    ${noteText}
    `; 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', ` `); 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 ``; }).join(''); const html = `

    Station played when opening a book.

    Current: ${escapeHtml(currentName)}

      ${presetsHtml}
    `; 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); } })();