/**
* diora — radio player
* Handles playback, SSE metadata, search, station management, and affiliate links.
*/
'use strict';
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let currentStation = null; // { url, name, id } | null
let currentTrack = '';
let sseSource = null;
let isPlaying = false;
let currentPlayId = null;
// Podcast state
let podcastMode = false;
let currentEpisode = null; // {id, title, audioUrl, durationSeconds, feedId}
let seekSaveTimer = null;
let podcastFeeds = [];
let podcastQueue = [];
let podcastCurrentView = 'feeds';
let podcastCurrentFeedId = null;
const podcastEpCache = {}; // id → episode data, avoids encoding strings in onclick attrs
let sleepTimerInterval = null;
let sleepTimerEndSecs = 0;
let sleepTimerEndOfEp = false;
let _dragSrcEl = null;
let feedSortOrder = 'alpha';
const audio = new Audio();
// Reconnect audio when the output device changes (e.g. Bluetooth, USB DAC)
if (navigator.mediaDevices) {
let _deviceChangeDebounce = null;
navigator.mediaDevices.addEventListener('devicechange', () => {
clearTimeout(_deviceChangeDebounce);
_deviceChangeDebounce = setTimeout(() => {
if (!isPlaying || audio.src === '') return;
const savedTime = audio.currentTime;
const savedSrc = audio.src;
audio.src = savedSrc;
audio.load();
if (savedTime > 0) {
audio.addEventListener('loadedmetadata', function onMeta() {
audio.currentTime = savedTime;
audio.removeEventListener('loadedmetadata', onMeta);
});
}
audio.play().catch(() => {});
}, 500);
});
}
// ---------------------------------------------------------------------------
// DOM helpers
// ---------------------------------------------------------------------------
function $(id) { return document.getElementById(id); }
function getCsrfToken() {
const cookie = document.cookie.split('; ').find(r => r.startsWith('csrftoken='));
return cookie ? cookie.split('=')[1] : '';
}
function formatDateTime(iso) {
if (!iso) return '';
const d = new Date(iso);
const pad = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} `
+ `${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"');
}
// ---------------------------------------------------------------------------
// 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:
`;
for (const r of data.recommendations) {
html += `-
${r.play_count}×
`;
}
html += '
';
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 = `
`;
}
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 = DIORA_CONFIG.podcastInboxPageSize;
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); }
}
// Vim-style scroll — ignore when typing in an input
const tag = document.activeElement?.tagName;
if (tag !== 'INPUT' && tag !== 'TEXTAREA') {
const contentEl = $('reader-content');
if (contentEl) {
const large = Math.round(contentEl.clientHeight * 0.85);
const continuousKeys = {'j': 8, 'k': -8};
if (continuousKeys[e.key] !== undefined && !e.repeat) {
e.preventDefault();
_readerScrollStep = continuousKeys[e.key];
if (!_readerScrollInterval) {
_readerScrollInterval = setInterval(() => {
$('reader-content')?.scrollBy({top: _readerScrollStep});
}, 16);
}
}
if (e.key === 'd') { e.preventDefault(); contentEl.scrollBy({top: large / 2, behavior: 'smooth'}); }
if (e.key === 'u') { e.preventDefault(); contentEl.scrollBy({top: -large / 2, behavior: 'smooth'}); }
if (e.key === 'f') { e.preventDefault(); contentEl.scrollBy({top: large, behavior: 'smooth'}); }
if (e.key === 'b') { e.preventDefault(); contentEl.scrollBy({top: -large, behavior: 'smooth'}); }
if (e.key === 'g') { e.preventDefault(); contentEl.scrollTop = 0; }
if (e.key === 'G') { e.preventDefault(); contentEl.scrollTop = contentEl.scrollHeight; }
if (currentPdfDoc && !readerSettings.pdfPaginated) {
if (e.key === 'n') { e.preventDefault(); pdfGoToPage(pdfCurrentPage + 1); }
if (e.key === 'p') { e.preventDefault(); pdfGoToPage(pdfCurrentPage - 1); }
}
}
}
}
});
document.addEventListener('keyup', e => {
if (e.key === 'j' || e.key === 'k') {
clearInterval(_readerScrollInterval);
_readerScrollInterval = null;
}
});
function sanitizeSidebarHtml(html) {
if (!html) return '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 > DIORA_CONFIG.bgMaxBytes) {
alert(`Image must be ${DIORA_CONFIG.bgMaxBytes / 1024 / 1024} MB or smaller.`);
return;
}
const key = await getOrCreateEncKey();
const buf = await file.arrayBuffer();
const {iv, ciphertext} = await encryptBytes(key, buf);
const res = await fetch('/accounts/background/upload/', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify({iv, ciphertext, mime_type: file.type, file_size: file.size}),
});
const data = await res.json();
if (!data.ok) throw new Error(data.error || 'upload failed');
}
async function applyEncryptedBackground() {
if (typeof ENCRYPTED_BG === 'undefined' || !ENCRYPTED_BG.ciphertext) return;
try {
const key = await getOrCreateEncKey();
const plain = await decryptBytes(key, ENCRYPTED_BG.iv, ENCRYPTED_BG.ciphertext);
const blob = new Blob([plain], {type: ENCRYPTED_BG.mime || 'image/jpeg'});
const url = URL.createObjectURL(blob);
document.body.style.backgroundImage = `url('${url}')`;
document.body.style.backgroundSize = 'cover';
document.body.style.backgroundPosition = 'center';
document.body.style.backgroundAttachment = 'fixed';
// Analyze brightness for accent/scheme
analyzeBackground(url).then(({bright, bgLuminance}) => {
setScheme(bright);
applyAccent(pickBestAccent(bgLuminance));
});
} catch (e) {
console.warn('Could not decrypt background:', e);
}
}
// ---------------------------------------------------------------------------
// EPUB parser (requires JSZip)
// ---------------------------------------------------------------------------
function resolveEpubPath(base, relative) {
if (!relative) return '';
if (relative.startsWith('/')) return relative.slice(1);
const hashIdx = relative.indexOf('#');
const frag = hashIdx >= 0 ? relative.slice(hashIdx) : '';
const rel = hashIdx >= 0 ? relative.slice(0, hashIdx) : relative;
const parts = (base + rel).split('/');
const resolved = [];
for (const p of parts) {
if (p === '..') resolved.pop();
else if (p !== '.') resolved.push(p);
}
return resolved.join('/') + frag;
}
async function parseEpub(arrayBuffer) {
const zip = await JSZip.loadAsync(arrayBuffer);
// 1. Find OPF via container.xml
const containerXml = await zip.file('META-INF/container.xml').async('text');
const containerDoc = new DOMParser().parseFromString(containerXml, 'application/xml');
const rootfileEl = containerDoc.querySelector('rootfile');
if (!rootfileEl) throw new Error('No rootfile in container.xml');
const opfPath = rootfileEl.getAttribute('full-path');
const opfDir = opfPath.includes('/') ? opfPath.substring(0, opfPath.lastIndexOf('/') + 1) : '';
// 2. Parse OPF
const opfText = await zip.file(opfPath).async('text');
const opfDoc = new DOMParser().parseFromString(opfText, 'application/xml');
const title = opfDoc.querySelector('metadata > title, metadata > *|title')?.textContent?.trim() || 'Unknown Title';
const author = opfDoc.querySelector('metadata > creator, metadata > *|creator')?.textContent?.trim() || 'Unknown Author';
// 3. Build manifest: id → {href, mediaType, properties}
const manifest = {};
opfDoc.querySelectorAll('manifest > item').forEach(item => {
manifest[item.getAttribute('id')] = {
href: opfDir + item.getAttribute('href'),
mediaType: item.getAttribute('media-type') || '',
properties: item.getAttribute('properties') || '',
};
});
// 4. Build image map: abs zip path → blob URL (parallel)
const imageMap = {};
await Promise.all(
Object.values(manifest)
.filter(({mediaType}) => mediaType.startsWith('image/'))
.map(async ({href, mediaType}) => {
try {
const buf = await zip.file(href).async('arraybuffer');
imageMap[href] = URL.createObjectURL(new Blob([buf], {type: mediaType}));
} catch (e) { /* missing asset */ }
})
);
// 5. Parse TOC
const toc = await _parseEpubToc(zip, opfDoc, manifest);
// 6. Get spine items; chapters are returned raw for progressive DOM injection
const spineItems = Array.from(opfDoc.querySelectorAll('spine > itemref'))
.map(ref => manifest[ref.getAttribute('idref')]?.href)
.filter(Boolean);
const chapters = [];
for (let i = 0; i < spineItems.length; i++) {
const href = spineItems[i];
try {
let chapterText = await zip.file(href).async('text');
// Strip style/script blocks early — publisher EPUBs often embed large base64 fonts in CSS.
// This shrinks the string before regex and DOMParser work on it.
chapterText = chapterText.replace(/