Ein Import aus der Cloud lief während eines Updates des Servers; alle Bücher danach scheiterten in Sekunden. Jetzt bis zu drei weitere Versuche je Buch (3, 8, 20 s), am Ende ein Knopf für die übrigen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
153 lines
5.5 KiB
JavaScript
153 lines
5.5 KiB
JavaScript
/**
|
|
* 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, '>')
|
|
.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 100 MB)': 'Datei zu groß (höchstens 100 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 });
|
|
}
|