/** * 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 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 }); }