diora-web/static/js/app/01-core.js

154 lines
5.5 KiB
JavaScript
Raw Normal View History

/**
* 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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ---------------------------------------------------------------------------
// 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 });
}