diff --git a/static/js/app.js b/static/js/app.js
deleted file mode 100644
index d64eb01..0000000
--- a/static/js/app.js
+++ /dev/null
@@ -1,6864 +0,0 @@
-/**
- * diora — radio player
- * Handles playback, SSE metadata, search, station management, and affiliate links.
- */
-
-'use strict';
-
-// ---------------------------------------------------------------------------
-// State
-// ---------------------------------------------------------------------------
-
-// Hardcoded for now -- only creamfresh radio gets the vote buttons, since
-// only its backend (the DJ) actually does anything with them.
-const CREAMFRESH_RADIO_URL = 'https://diora.creamfresh.xyz/radio/creamfresh-stream/';
-
-let currentStation = null; // { url, name, id } | null
-let currentTrack = '';
-let sseSource = null;
-let isPlaying = false;
-let currentPlayId = null;
-
-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, '"');
-}
-
-// ---------------------------------------------------------------------------
-// Modal dialogs — styled replacements for native alert()/confirm()/prompt(),
-// which look jarring against the rest of the custom dark UI and can't be
-// styled or (in confirm/prompt's case) awaited without blocking the thread.
-// All three return a Promise; callers must be async and `await` them.
-// ---------------------------------------------------------------------------
-
-function _showModal({ message, showInput = false, inputValue = '', showCancel = false, okLabel = 'OK', danger = false }) {
- return new Promise((resolve) => {
- const overlay = $('modal-overlay');
- const dialog = $('modal-dialog');
- const msgEl = $('modal-message');
- const inputEl = $('modal-input');
- const okBtn = $('modal-ok-btn');
- const cancelBtn = $('modal-cancel-btn');
- if (!overlay || !dialog) { resolve(null); return; }
-
- msgEl.textContent = message;
- inputEl.style.display = showInput ? '' : 'none';
- inputEl.value = inputValue;
- cancelBtn.style.display = showCancel ? '' : 'none';
- okBtn.textContent = okLabel;
- okBtn.className = danger ? 'btn btn-danger' : 'btn btn-primary';
-
- overlay.style.display = '';
- dialog.style.display = '';
- (showInput ? inputEl : okBtn).focus();
- if (showInput) inputEl.select();
-
- function cleanup(result) {
- overlay.style.display = 'none';
- dialog.style.display = 'none';
- overlay.removeEventListener('click', onCancel);
- okBtn.removeEventListener('click', onOk);
- cancelBtn.removeEventListener('click', onCancel);
- document.removeEventListener('keydown', onKeydown);
- resolve(result);
- }
- function onOk() { cleanup(showInput ? inputEl.value : true); }
- function onCancel() { cleanup(showInput ? null : false); }
- function onKeydown(e) {
- if (e.key === 'Escape') onCancel();
- if (e.key === 'Enter' && showInput) onOk();
- }
-
- okBtn.addEventListener('click', onOk);
- cancelBtn.addEventListener('click', onCancel);
- overlay.addEventListener('click', onCancel);
- document.addEventListener('keydown', onKeydown);
- });
-}
-
-function customAlert(message) {
- return _showModal({ message });
-}
-
-function customConfirm(message, { danger = false } = {}) {
- return _showModal({ message, showCancel: true, danger });
-}
-
-// The server answers with short English error codes (an API contract, tests and
-// other clients rely on them); what the page shows of them is German.
-const _GERMAN_ERRORS = {
- 'no stations found in file': 'In der Datei wurden keine Sender gefunden.',
- 'file must be .m3u or .m3u8': 'Die Datei muss eine .m3u- oder .m3u8-Datei sein.',
- 'no file uploaded': 'Keine Datei hochgeladen.',
- 'only jpeg, png, or webp allowed': 'Nur JPEG, PNG oder WebP erlaubt.',
- 'file too large (max 50 MB)': 'Datei zu groß (höchstens 50 MB).',
- 'file too large (max 5 MB)': 'Datei zu groß (höchstens 5 MB).',
- 'authentication required': 'Bitte anmelden.',
- 'not found': 'Nicht gefunden.',
- 'invalid JSON': 'Ungültige Anfrage.',
- 'upstream unreachable': 'Der Server dahinter ist nicht erreichbar.',
-};
-function _germanError(code) {
- return _GERMAN_ERRORS[code] || code;
-}
-
-function customPrompt(message, defaultValue = '') {
- return _showModal({ message, showInput: true, inputValue: defaultValue, showCancel: true });
-}
-
-// ---------------------------------------------------------------------------
-// Play / Stop
-// ---------------------------------------------------------------------------
-
-function playStation(url, name, stationId) {
- stopPlayback(false);
- if (window.__dioraAmbientStop) window.__dioraAmbientStop();
-
- // A secure page can't load plain-http streams (mixed content). Native shells
- // that play the stream themselves set window.DIORA_PLAYS_HTTP_STREAMS.
- if (location.protocol === 'https:' && url.startsWith('http://') && !window.DIORA_PLAYS_HTTP_STREAMS) {
- 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 = '⏹ Stopp';
- $('play-stop-btn').classList.add('playing');
- $('save-station-btn').style.display = '';
-
- const isCreamfresh = url === CREAMFRESH_RADIO_URL;
- $('creamfresh-vote-up-btn').style.display = isCreamfresh ? '' : 'none';
- $('creamfresh-vote-down-btn').style.display = isCreamfresh ? '' : 'none';
- $('creamfresh-vote-up-btn').classList.remove('active');
- $('creamfresh-vote-down-btn').classList.remove('active');
-
- 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) {
- audio.pause();
- audio.src = '';
- isPlaying = false;
- if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'none';
-
- if (sseSource) {
- sseSource.close();
- sseSource = null;
- }
-
- $('play-stop-btn').textContent = '▶ Abspielen';
- $('play-stop-btn').classList.remove('playing');
- $('save-station-btn').style.display = 'none';
- $('creamfresh-vote-up-btn').style.display = 'none';
- $('creamfresh-vote-down-btn').style.display = 'none';
- $('affiliate-section').style.display = 'none';
-
- stopPlaySession();
-
- const stationEl = $('now-playing-station');
- const trackEl = $('now-playing-track');
-
- if (clearStation) {
- currentStation = null;
- currentTrack = '';
- stationEl.textContent = '— kein Sender —';
- 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() {
- // Take the id before awaiting: playStation() calls this without waiting and
- // starts the next session right away, whose id must not be cleared here.
- const playId = currentPlayId;
- if (!playId) return;
- currentPlayId = null;
- try {
- await fetch('/radio/play/stop/', {
- method: 'POST',
- headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
- body: JSON.stringify({play_id: playId})
- });
- } catch (e) {}
-}
-
-window.addEventListener('beforeunload', () => {
- if (currentPlayId) {
- navigator.sendBeacon('/radio/play/stop/', JSON.stringify({play_id: currentPlayId}));
- }
- // Flush cached encrypted payloads for reader data (encryption is async so we
- // use pre-computed blobs stored by the debounced savers)
- // The last position written (it's in the outbox too, in case the beacon is lost).
- if (_lastProgressBeacon) navigator.sendBeacon(`/books/${_lastProgressBeacon.book}/progress/`, _progressBody(_lastProgressBeacon));
- 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();
- }
- // Back in front: maybe the book was read on another device meanwhile.
- if (document.visibilityState === 'visible' && currentBookId) _checkNewerPositionElsewhere();
-});
-
-// 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 = '
Hör mehr Sender, dann gibt es hier Empfehlungen.
';
- return;
- }
- const label = data.context;
- let html = `Was du ${label} oft hörst:
`;
- 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;
- _ambientVolume(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');
-
- tableEl.style.display = 'none';
- tbody.innerHTML = '';
-
- // A stream address instead of a name: offer to play or save it directly.
- if (/^https?:\/\/\S+$/i.test(query)) {
- showStreamAddressResult(query);
- return;
- }
-
- statusEl.textContent = 'Suche …';
-
- 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 = 'Keine Sender gefunden.';
- return;
- }
-
- statusEl.textContent = stations.length === 1 ? '1 Treffer' : `${stations.length} Treffer`;
- 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 = 'Suche fehlgeschlagen: ' + err.message;
- }
-}
-
-// A name to start from for a pasted address: "klassik.mp3" on radio.example.org
-// -> "Klassik (radio.example.org)". The query string is left out on purpose —
-// it often carries an access token, which shouldn't end up on screen.
-function _streamAddressName(url) {
- try {
- const u = new URL(url);
- const file = decodeURIComponent(u.pathname.split('/').filter(Boolean).pop() || '');
- const base = file.replace(/\.(mp3|aac|aacp|ogg|opus|m3u8?|pls|flac)$/i, '').replace(/[-_]+/g, ' ').trim();
- const nice = base ? base.charAt(0).toUpperCase() + base.slice(1) : '';
- return nice ? `${nice} (${u.host})` : u.host;
- } catch (e) {
- return url;
- }
-}
-
-function showStreamAddressResult(url) {
- const statusEl = $('search-status');
- const tableEl = $('search-results-table');
- const tbody = $('search-results-body');
- const name = _streamAddressName(url);
- const shown = url.split('?')[0];
- const data = {name, url, bitrate: '', country: '', tags: '', favicon_url: ''};
-
- statusEl.textContent = 'Stream-Adresse';
- const curated = document.getElementById('curated-lists');
- if (curated) curated.style.display = 'none';
-
- // Same five columns as a radio-browser result: name, bitrate, country, tags, actions.
- const tr = document.createElement('tr');
- tr.className = 'stream-address-row';
- for (let i = 0; i < 4; i++) tr.appendChild(document.createElement('td'));
- tr.children[0].textContent = name;
- tr.children[0].title = shown;
- tr.children[3].textContent = shown;
- tr.children[3].title = shown;
-
- const actions = document.createElement('td');
- const play = document.createElement('button');
- play.className = 'btn btn-sm btn-play';
- play.innerHTML = '▶ Abspielen';
- play.addEventListener('click', () => searchPlay(url, name, data));
- const save = document.createElement('button');
- save.className = 'btn btn-sm';
- save.innerHTML = '★ Speichern';
- save.style.marginLeft = '4px';
- save.addEventListener('click', async e => {
- e.stopPropagation();
- const chosen = await customPrompt('Name für diesen Sender', name);
- if (chosen == null) return;
- const finalName = chosen.trim() || name;
- await saveStation({...data, name: finalName});
- statusEl.textContent = `Gespeichert als „${finalName}“.`;
- });
- actions.append(play, save);
- tr.appendChild(actions);
- tbody.appendChild(tr);
- tableEl.style.display = '';
-}
-
-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);
-}
-
-// ---------------------------------------------------------------------------
-// creamfresh radio: DJ feedback (hardcoded to this one station, see
-// CREAMFRESH_RADIO_URL above)
-// ---------------------------------------------------------------------------
-
-async function creamfreshVote(direction) {
- const upBtn = $('creamfresh-vote-up-btn');
- const downBtn = $('creamfresh-vote-down-btn');
- upBtn.disabled = true;
- downBtn.disabled = true;
- try {
- const res = await fetch('/radio/creamfresh-feedback/', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'X-CSRFToken': getCsrfToken(),
- },
- body: JSON.stringify({ vote: direction }),
- });
- if (!res.ok) throw new Error(`feedback returned ${res.status}`);
- upBtn.classList.toggle('active', direction === 'up');
- downBtn.classList.toggle('active', direction === 'down');
- } catch (err) {
- console.warn('creamfresh vote failed', err);
- } finally {
- upBtn.disabled = false;
- downBtn.disabled = false;
- }
-}
-
-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) {
- await customAlert('Bitte anmelden, um Sender zu speichern.');
- 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
-// ---------------------------------------------------------------------------
-
-// The only toggleFav: a second definition further down (expecting the button
-// as an argument) used to shadow this one, so the server-rendered rows — which
-// pass no button — toggled the favourite on the server without the star ever
-// changing, and a second click silently took it back.
-async function toggleFav(pk, btnEl) {
- 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 = btnEl || 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 = 'Noch keine gespeicherten Sender. | ';
- tbody.appendChild(tr);
- }
- }
- } catch (err) {
- console.error('removeStation 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('Pause! 5 Minuten.');
- // auto-pause playback during break
- if (audio.src && !audio.paused) audio.pause();
- } else {
- // break ended → reset to work
- timerIsBreak = false;
- timerSeconds = TIMER_WORK;
- showTimerNotification('Pause vorbei. Weiter geht’s.');
- }
- 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 ? 'Pause' : 'Fokus';
- // 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 = `Heute: ${data.today_sessions} ${data.today_sessions !== 1 ? 'Sitzungen' : 'Sitzung'} · ${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 = '| Noch keine Fokus-Sitzungen. Starte den 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: 'Fokus', tag: 'ambient' },
- { label: 'Lo-fi', tag: 'lofi' },
- { label: 'Jazz', tag: 'jazz' },
- { label: 'Klassik', tag: 'classical' },
- { label: 'Ambient', tag: 'ambient' },
- { label: 'Metal', tag: 'metal' },
- { label: 'Elektronisch', tag: 'electronic' },
- { label: 'Wort', 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: 'Fokus',
- 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: 'Klassik',
- 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 = `★ Empfohlen
`;
- 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;
-
- // Past the threshold just means "this station is a regular" — from then on
- // every single play would otherwise qualify, so only actually show the hint
- // on roughly 1 in 10 of those plays instead of every time.
- if (Math.random() >= 0.1) return;
-
- const existing = document.getElementById('donation-hint');
- if (existing) existing.remove();
-
- const el = document.createElement('div');
- el.id = 'donation-hint';
- el.innerHTML = `
- Du hörst ${escapeHtml(stationName)} oft – vielleicht magst du den Sender unterstützen ♥
-
- `;
- 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
-// ---------------------------------------------------------------------------
-
-async function editNotes(pk, current) {
- const note = await customPrompt('Notiz zum Sender:', 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 = ['home', 'radio', 'focus', 'podcasts', 'books', 'feeds', 'ambient'];
-const RADIO_SUB_TABS = ['search', 'saved', 'history'];
-
-// ---------------------------------------------------------------------------
-// URL hash routing — makes tabs deep-linkable/shareable and lets the browser
-// back/forward buttons move between them. Deliberately hash-based rather
-// than real paths: /podcasts/ and /books/ etc. are already taken by the
-// JSON API, so this avoids touching Django's urls.py at all.
-// ---------------------------------------------------------------------------
-let _routingFromHash = false;
-let _currentTopTab = 'radio';
-
-function _syncHash() {
- if (_routingFromHash) return;
- let hash = _currentTopTab;
- if (_currentTopTab === 'radio') {
- hash += '/' + (localStorage.getItem('diora_active_radio_tab') || 'saved');
- }
- if (location.hash.slice(1) !== hash) history.pushState(null, '', '#' + hash);
-}
-
-function _routeFromHash() {
- const [tab, sub] = (location.hash || '').replace(/^#/, '').split('/').filter(Boolean);
- if (!TOP_TABS.includes(tab)) return;
- _routingFromHash = true;
- try {
- showTab(tab);
- if (tab === 'radio' && RADIO_SUB_TABS.includes(sub)) showRadioTab(sub);
- } finally {
- _routingFromHash = false;
- }
-}
-
-window.addEventListener('popstate', _routeFromHash);
-window.addEventListener('hashchange', _routeFromHash);
-
-function showOfflineBanner(offline) {
- document.body.classList.toggle('app-offline', offline);
- const uploadArea = $('book-upload-area');
- if (uploadArea) uploadArea.style.display = offline ? 'none' : '';
-}
-
-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);
- _currentTopTab = name;
- _syncHash();
-
- if (name === 'books') loadBookList();
- if (name === 'ambient') _ambientLoad();
- if (name === 'home' && window.__dioraHomeShow) window.__dioraHomeShow(); // static/js/home.js
- if (name === 'feeds' && window.__dioraFeedsShow) window.__dioraFeedsShow(); // static/js/feeds.js
-}
-
-// ---------------------------------------------------------------------------
-// Ambient: "Drift" (static/ambient/), a generative synthesizer in a frame of its
-// own. It and the radio / podcast player take turns — one starting stops the
-// other — by postMessage: {type: 'ambient', cmd: 'start'|'stop'} to the frame,
-// {type: 'ambient', state: 'playing'|'stopped'} from it.
-// ---------------------------------------------------------------------------
-let _ambientPlaying = false;
-let _ambientLast = false; // Ambient was started last: the player bar shows it (native-podcast.js)
-
-function _ambientFrame() {
- return $('ambient-frame');
-}
-
-function _ambientLoad() {
- const f = _ambientFrame();
- if (!f) return;
- if (!f.getAttribute('src')) {
- // Drift takes the page's volume before its first sound; the bar follows its sound world.
- f.addEventListener('load', () => {
- _ambientVolume();
- try {
- const sel = f.contentDocument.getElementById('mode');
- // The sound world chosen last comes back (Drift itself starts with its default).
- let last = '';
- try { last = localStorage.getItem('diora_ambient_mode') || ''; } catch (e) {}
- if (last && last !== sel.value && [...sel.options].some(o => o.value === last)) {
- f.contentWindow.postMessage({type: 'ambient', cmd: 'mode', mode: last}, '*');
- }
- sel.addEventListener('change', () => {
- try {
- localStorage.setItem('diora_ambient_mode', sel.value);
- localStorage.setItem('diora_ambient_mode_label', sel.selectedOptions[0] ? sel.selectedOptions[0].text : '');
- } catch (e) {}
- if (window.__dioraAmbientChanged) window.__dioraAmbientChanged();
- });
- } catch (e) {}
- });
- f.src = f.dataset.src;
- }
- requestAnimationFrame(_ambientSize);
-}
-
-// The frame fills the room between the tabs and the player bar at the bottom.
-function _ambientSize() {
- const f = _ambientFrame();
- if (!f || _currentTopTab !== 'ambient') return;
- const root = getComputedStyle(document.documentElement);
- const bar = parseFloat(root.getPropertyValue('--nui-dock-h')) || parseFloat(root.getPropertyValue('--dpw-bar-h')) || 0;
- const top = f.getBoundingClientRect().top + window.scrollY;
- f.style.height = Math.max(320, Math.floor(window.innerHeight - top - bar)) + 'px';
-}
-window.addEventListener('resize', () => requestAnimationFrame(_ambientSize));
-// The bar at the bottom grows or shrinks (the player appears, a second line …): its height
-// comes as --nui-dock-h / --dpw-bar-h on , set inline — measure again then.
-new MutationObserver(() => requestAnimationFrame(_ambientSize))
- .observe(document.documentElement, {attributes: true, attributeFilter: ['style']});
-
-// A command for Drift ('start' | 'stop'): the app's notification, the sleep timer.
-window.__dioraAmbientCmd = function (cmd, value) {
- const f = _ambientFrame();
- if (f && f.contentWindow && f.getAttribute('src')) f.contentWindow.postMessage({type: 'ambient', cmd, value}, '*');
-};
-
-// Start Drift from elsewhere (Home): load the frame if needed, then start it — with the
-// sound world chosen last, which the frame's load handler above sets.
-window.__dioraAmbientStart = function () {
- const f = _ambientFrame();
- if (!f) return;
- const go = () => window.__dioraAmbientCmd('start');
- let ready = false;
- try { ready = !!f.getAttribute('src') && f.contentDocument.readyState === 'complete' && !!f.contentDocument.getElementById('mode'); } catch (e) {}
- if (ready) { go(); return; }
- f.addEventListener('load', () => setTimeout(go, 0), {once: true});
- _ambientLoad();
-};
-
-// The radio or a podcast starts: Ambient stops, and the player bar is theirs again.
-window.__dioraAmbientStop = function () {
- if (_ambientPlaying) window.__dioraAmbientCmd('stop');
- if (_ambientLast) {
- _ambientLast = false;
- if (window.__dioraAmbientChanged) window.__dioraAmbientChanged();
- }
-};
-window.__dioraAmbientPlaying = () => _ambientPlaying;
-
-// For the player bar: playing, started last, the sound world's name (the frame is ours).
-window.__dioraAmbientInfo = function () {
- const f = _ambientFrame();
- let mode = '';
- try {
- const sel = f && f.contentDocument && f.contentDocument.getElementById('mode');
- if (sel && sel.selectedOptions[0]) mode = sel.selectedOptions[0].text;
- } catch (e) {}
- return {playing: _ambientPlaying, last: _ambientLast, mode};
-};
-
-// The page's volume (0–255, the bar's slider) is Drift's too; its own slider hides when
-// embedded. Not in the Android app: there the volume keys set it, as for everything.
-function _ambientVolume(val) {
- if (window.DioraNative && DioraNative.ambientState) return;
- if (val == null) {
- const s = $('volume');
- val = s ? +s.value : +(localStorage.getItem('diora_volume') || 102);
- }
- window.__dioraAmbientCmd && window.__dioraAmbientCmd('volume', val / 255);
-}
-
-window.addEventListener('message', e => {
- const f = _ambientFrame();
- const d = e.data;
- if (!f || e.source !== f.contentWindow || !d || d.type !== 'ambient' || !d.state) return;
- _ambientPlaying = d.state === 'playing';
- if (_ambientPlaying) _ambientLast = true;
- if (window.__dioraAmbientChanged) window.__dioraAmbientChanged();
- // The app keeps it going with the screen off (AmbientService); the browser's sleep timer counts it.
- if (window.DioraNative && DioraNative.ambientState) DioraNative.ambientState(_ambientPlaying);
- if (window.__dioraOnAmbient) window.__dioraOnAmbient(_ambientPlaying);
- if (!_ambientPlaying) return;
- // Ambient starts: the radio and a podcast here stop.
- if (typeof isPlaying !== 'undefined' && isPlaying) stopPlayback(false);
- if (window.__dioraPausePlayer) window.__dioraPausePlayer();
-});
-
-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);
- _syncHash();
-
- if (name === 'saved') loadRecommendations();
-}
-
-// ---------------------------------------------------------------------------
-// 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': 1, 'k': -1};
- if (continuousKeys[e.key] !== undefined && !e.repeat) {
- e.preventDefault();
- _readerScrollDir = continuousKeys[e.key];
- if (!_readerScrollInterval) {
- _readerScrollLastTs = performance.now();
- _readerScrollInterval = setInterval(_readerScrollTick, READER_SCROLL_TICK_MS);
- }
- }
- 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;
- _readerScrollDir = 0;
- }
-});
-
-function sanitizeSidebarHtml(html) {
- if (!html) return 'Keine Shownotes vorhanden.
';
- 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;
-}
-
-// ---------------------------------------------------------------------------
-// Service Worker
-// ---------------------------------------------------------------------------
-
-if ('serviceWorker' in navigator) {
- window.addEventListener('load', () => {
- // Registered from '/sw.js' (root), not '/static/js/sw.js': a service
- // worker's default scope is its own script's directory, so registering
- // it from under /static/js/ would only ever let it control requests
- // under that path — page navigations to '/', '/books/' etc. would never
- // be intercepted, breaking offline entirely.
- navigator.serviceWorker.register('/sw.js', {scope: '/'}).catch(err => {
- console.warn('Service worker registration failed:', err);
- });
- // Drop any leftover registration from the old, wrongly-scoped '/static/js/sw.js'
- // path so it doesn't keep running or fight the new root-scoped worker.
- navigator.serviceWorker.getRegistrations().then(regs => {
- regs.forEach(r => { if (r.scope.endsWith('/static/js/')) r.unregister(); });
- });
- });
- // When a new SW activates it sends SW_ACTIVATED — reload to get fresh assets,
- // but never in the middle of something: not while a book is open, radio or a
- // podcast plays, or a text field has the focus — then as soon as that's over.
- let reloadPending = false;
- const busy = () => {
- if (currentBookId || isPlaying) return true;
- const host = window.DioraPodcastHost;
- if (host && host.playerState) {
- try { if (JSON.parse(host.playerState()).playing) return true; } catch (_) {}
- }
- const el = document.activeElement;
- return !!el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName));
- };
- const reloadWhenIdle = () => {
- if (busy()) setTimeout(reloadWhenIdle, 5000);
- else window.location.reload();
- };
- navigator.serviceWorker.addEventListener('message', e => {
- if (e.data?.type === 'SW_ACTIVATED' && !reloadPending) {
- reloadPending = true;
- reloadWhenIdle();
- }
- });
-}
-
-// ---------------------------------------------------------------------------
-// M3U import
-// ---------------------------------------------------------------------------
-
-async function importM3U(input) {
- const file = input.files[0];
- if (!file) return;
- const status = document.getElementById('import-status');
- status.textContent = 'Importiere …';
-
- 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} hinzugefügt, ${data.skipped} schon gespeichert`;
- if (data.added > 0) location.reload();
- } else {
- status.textContent = `Fehler: ${_germanError(data.error)}`;
- }
- } catch (e) {
- status.textContent = 'Hochladen fehlgeschlagen';
- }
- 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);
-}
-
-// Light and dark: the switch is in base.html (every page has it). The reader's
-// "Wie die App" follows it.
-window.addEventListener('diora-theme', () => {
- if (currentBookId) applyReaderSettings(!!currentPdfDoc);
-});
-
-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)) {
- await customAlert('Nur JPEG-, PNG- oder WebP-Bilder sind erlaubt.');
- return;
- }
- if (file.size > DIORA_CONFIG.bgMaxBytes) {
- await customAlert(`Das Bild darf höchstens ${DIORA_CONFIG.bgMaxBytes / 1024 / 1024} MB groß sein.`);
- 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}')`;
- const contrastBtn = document.getElementById('contrast-toggle');
- if (contrastBtn) contrastBtn.hidden = false; // only with a picture behind the page
- 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('Ungültige EPUB-Datei (container.xml ohne rootfile)');
- 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() || 'Unbekannter Titel';
- const author = opfDoc.querySelector('metadata > creator, metadata > *|creator')?.textContent?.trim() || 'Unbekannter Autor';
-
- // 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);
-
- // All chapters unpacked at once: one after the other, every chapter waits for
- // JSZip's own ticks (a book like Das Kapital has 148 of them).
- const texts = await Promise.all(spineItems.map(href => {
- const f = zip.file(href);
- return f ? f.async('text').catch(() => null) : null;
- }));
-
- const chapters = [];
- let slice = performance.now();
- for (let i = 0; i < spineItems.length; i++) {
- const href = spineItems[i];
- if (texts[i] == null) continue; // missing
- try {
- let chapterText = texts[i];
- texts[i] = null;
- // 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(/