Files
radio-explorer/web/main.js
Marco Mooren 00246389bc Add API documentation and underground station importer
- Introduced a new HTML documentation page for the oradio API, including a JavaScript file to handle dynamic content and API requests.
- Added a CSS file for styling the documentation page.
- Implemented an underground station importer script that fetches data from Radio-Browser and writes it to a JSON file.
- Created a stats module to compute and manage vote and play statistics for radio stations.
- Added a polyfill for modulepreload to ensure compatibility with older browsers.
2026-05-11 02:06:48 +02:00

583 lines
24 KiB
JavaScript

import { api } from './shared/api.js';
import { connectWs } from './shared/ws.js';
import { el, clear } from './shared/dom.js';
import { Player } from './player.js';
const app = document.getElementById('app');
const state = {
user: null,
tab: 'favorites', // favorites | browse | recent
stations: [],
categories: [],
selectedCategory: null,
favorites: [],
history: [],
query: '',
sort: 'hot', // hot | top | plays | name | controversial — applied in Browse
randomMode: localStorage.getItem('oradio.randomMode') === 'favorites' ? 'favorites' : 'all',
player: { stationId: null, stationName: null, genres: [], playing: false, loading: false, volume: 0.7, votes: null }
};
const player = new Player({
onState: (s) => {
state.player = { ...state.player, ...s };
render();
}
});
let ws;
async function bootstrap() {
try {
state.user = await api.get('/api/auth/me');
} catch {
showLogin();
return;
}
await refreshAll();
ws = connectWs(handleWs);
render();
requestWakeLock();
}
async function refreshAll() {
const [stations, favs, history, categories] = await Promise.all([
api.get(`/api/stations?sort=${encodeURIComponent(state.sort)}`),
api.get('/api/me/favorites').catch(() => []),
api.get('/api/me/history').catch(() => []),
api.get('/api/v1/categories').catch(() => [])
]);
state.stations = stations;
state.favorites = favs;
state.history = history;
state.categories = categories;
}
async function refreshStations() {
state.stations = await api.get(`/api/stations?sort=${encodeURIComponent(state.sort)}`);
}
function handleWs(msg) {
if (msg.type === 'command') {
if (msg.action === 'play' && msg.stationId) {
const st = state.stations.find((s) => s.id === msg.stationId);
if (st) playStation(st);
} else if (msg.action === 'pause') player.togglePause();
else if (msg.action === 'volume') player.setVolume(msg.value);
else if (msg.action === 'stop') player.stop();
}
}
function showLogin() {
clear(app);
const overlay = el('div', { class: 'login' },
el('form', {
onSubmit: async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
try {
state.user = await api.post('/api/auth/login', {
username: fd.get('username'), password: fd.get('password')
});
await bootstrap();
} catch (err) {
e.target.querySelector('.err').textContent = err.message;
}
}
},
el('h1', {}, 'Sign in'),
el('input', { name: 'username', placeholder: 'Username', autocomplete: 'username', required: true }),
el('input', { name: 'password', type: 'password', placeholder: 'Password', autocomplete: 'current-password', required: true }),
el('div', { class: 'err' }),
el('button', { type: 'submit' }, 'Continue')
)
);
app.appendChild(overlay);
}
let savedGridScroll = 0;
function render() {
if (!state.user) return;
// Capture scroll from the live grid before tearing down (in case it changed since last scroll event).
const prevGrid = app.querySelector('.grid');
if (prevGrid && prevGrid.scrollTop > 0) savedGridScroll = prevGrid.scrollTop;
closeContextMenu();
clear(app);
const p = state.player;
const favIds = new Set(state.favorites.map((f) => f.id));
const v = p.votes; // { up, down, plays, myVote, score } or null
const now = el('section', { class: 'now' },
el('div', { class: 'meta' },
el('div', { class: 'name' }, p.stationName || 'Select a station'),
el('div', { class: 'sub' },
p.loading ? 'Connecting…' : (p.playing ? 'On air' : (p.error ? p.error : (p.stationId ? 'Paused' : 'Idle')))),
el('div', { class: 'tags' }, ...(p.genres || []).slice(0, 4).map((g) => el('span', { class: 'tag' }, g)))
),
el('div', { class: 'controls' },
el('div', { class: 'vote-group', title: 'Vote on current station' },
el('button', {
class: `vote up ${v?.myVote === 1 ? 'on' : ''}`,
disabled: !p.stationId,
title: 'Upvote',
onClick: () => votePlayer(1)
}, el('span', { class: 'vote-icon' }, '▲'),
el('span', { class: 'vote-count' }, String(v?.up ?? 0))),
el('button', {
class: `vote down ${v?.myVote === -1 ? 'on' : ''}`,
disabled: !p.stationId,
title: 'Downvote',
onClick: () => votePlayer(-1)
}, el('span', { class: 'vote-icon' }, '▼'),
el('span', { class: 'vote-count' }, String(v?.down ?? 0)))
),
el('button', {
class: `btn-play ${p.loading ? 'loading' : ''}`,
title: p.playing ? 'Pause' : 'Play',
onClick: () => p.stationId ? player.togglePause() : (state.favorites[0] && playStation(state.favorites[0]))
}, p.playing ? '❚❚' : '▶'),
el('button', {
class: 'btn-stop',
title: 'Stop',
disabled: !p.stationId,
onClick: () => player.stop()
}, '■'),
el('div', { class: 'vol' },
el('span', { class: 'vol-icon' }, p.volume === 0 ? '🔇' : p.volume < 0.5 ? '🔈' : '🔊'),
el('input', {
type: 'range', min: 0, max: 1, step: 0.05, value: p.volume,
'aria-label': 'Volume',
onInput: (e) => player.setVolume(Number(e.target.value))
}),
el('span', { class: 'val' }, Math.round(p.volume * 100))
)
)
);
const isAdmin = state.user.role === 'admin';
const header = el('div', { class: 'header' },
el('div', { class: 'tabs' },
...['favorites', 'browse', 'recent'].map((t) =>
el('button', {
class: `tab ${state.tab === t ? 'active' : ''}`,
onClick: () => { state.tab = t; savedGridScroll = 0; render(); }
},
t === 'favorites' ? '★ Favorites' : t === 'browse' ? '🌐 Browse' : '⏱ Recent')
)
),
el('div', { class: 'header-tools' },
state.tab === 'browse'
? el('select', {
class: 'sort',
title: 'Sort browse list',
onChange: (e) => { state.sort = e.target.value; savedGridScroll = 0; refreshStations().then(render); }
},
el('option', { value: 'hot', selected: state.sort === 'hot' }, '🔥 Hot (smart)'),
el('option', { value: 'top', selected: state.sort === 'top' }, '▲ Top voted'),
el('option', { value: 'plays', selected: state.sort === 'plays' }, '▶ Most played'),
el('option', { value: 'controversial', selected: state.sort === 'controversial' }, '⚡ Controversial'),
el('option', { value: 'name', selected: state.sort === 'name' }, 'A → Z')
)
: null,
el('input', {
class: 'search', type: 'search', placeholder: 'Search…', value: state.query,
onInput: (e) => { state.query = e.target.value; renderGrid(); }
}),
el('button', {
class: 'btn-random',
title: `Play random station (mode: ${state.randomMode}). Right-click to switch mode.`,
onClick: playRandom,
onContextMenu: (e) => { e.preventDefault(); toggleRandomMode(); }
},
el('span', { class: 'rand-icon' }, '🎲'),
el('span', { class: 'rand-mode' }, state.randomMode === 'favorites' ? '★' : 'All')
),
el('a', {
class: 'btn-docs', href: '/docs/', target: '_blank', rel: 'noopener',
title: 'Open API reference'
}, 'API'),
isAdmin ? el('button', { class: 'btn-add', title: 'Add station', onClick: openAddStation }, '+') : null
)
);
const sec = el('section', { class: 'lib' }, header);
if (state.tab === 'browse' && state.categories.length) {
sec.appendChild(renderChips());
}
const grid = el('div', { class: 'grid' });
grid.id = 'grid';
grid.addEventListener('scroll', () => { savedGridScroll = grid.scrollTop; }, { passive: true });
sec.appendChild(grid);
app.appendChild(now);
app.appendChild(sec);
paintGrid(grid, favIds);
if (savedGridScroll) {
grid.scrollTop = savedGridScroll;
requestAnimationFrame(() => { if (savedGridScroll) grid.scrollTop = savedGridScroll; });
}
}
function renderChips() {
return el('div', { class: 'chips' },
el('button', {
class: `chip ${!state.selectedCategory ? 'active' : ''}`,
onClick: () => { state.selectedCategory = null; savedGridScroll = 0; render(); }
}, `All (${state.stations.length})`),
...state.categories.filter((c) => c.count > 0).map((c) => el('button', {
class: `chip ${state.selectedCategory === c.id ? 'active' : ''}`,
onClick: () => { state.selectedCategory = c.id; savedGridScroll = 0; render(); }
}, `${c.icon || ''} ${c.label} (${c.count})`.trim()))
);
}
function visibleItems() {
let items = [];
if (state.tab === 'favorites') items = state.favorites;
else if (state.tab === 'browse') {
items = state.stations;
if (state.selectedCategory) items = items.filter((s) => s.category === state.selectedCategory);
} else if (state.tab === 'recent') {
const seen = new Set();
items = state.history.filter((h) => !seen.has(h.station_id) && seen.add(h.station_id))
.map((h) => state.stations.find((s) => s.id === h.station_id)).filter(Boolean);
}
const q = state.query.trim().toLowerCase();
if (q) {
items = items.filter((s) =>
s.name.toLowerCase().includes(q) ||
(s.country || '').toLowerCase().includes(q) ||
(s.genres || []).some((g) => g.toLowerCase().includes(q))
);
}
return items;
}
function renderGrid() {
const grid = document.getElementById('grid');
if (!grid) return;
const favIds = new Set(state.favorites.map((f) => f.id));
paintGrid(grid, favIds);
}
function paintGrid(grid, favIds) {
clear(grid);
const items = visibleItems();
if (!items.length) {
grid.appendChild(el('div', { class: 'empty' },
state.tab === 'favorites' ? 'No favorites yet — long-press or tap ★ on a station.' :
state.query ? 'No matches.' : 'Nothing here yet.'));
return;
}
const p = state.player;
for (const s of items) {
const score = typeof s.score === 'number' ? s.score : 0;
const net = (s.up ?? 0) - (s.down ?? 0);
const badgeClass = net > 0 ? 'pos' : net < 0 ? 'neg' : 'neu';
const card = el('div', {
class: `card ${p.stationId === s.id ? 'playing' : ''}`,
role: 'button',
tabindex: 0,
onClick: () => playStation(s),
onContextMenu: (e) => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, s); }
},
el('div', { class: 'art' },
s.image_url
? el('img', {
class: 'art-img',
src: s.image_url,
alt: '',
loading: 'lazy',
referrerpolicy: 'no-referrer',
onError: (e) => {
const parent = e.target.parentNode;
e.target.remove();
if (parent) parent.appendChild(el('span', { class: 'art-glyph' }, '♪'));
}
})
: el('span', { class: 'art-glyph' }, '♪')),
el('div', { class: 'card-body' },
el('div', { class: 'n' }, s.name),
el('div', { class: 'g' },
(s.genres || []).slice(0, 3).join(' · ') || (s.country || '—'))
),
el('div', { class: `score-badge ${badgeClass}`, title: `${s.up ?? 0} · ▼${s.down ?? 0} · ▶${s.plays ?? 0} · score ${score.toFixed(2)}` },
net > 0 ? `+${net}` : String(net)
),
el('button', {
class: `fav ${favIds.has(s.id) ? 'on' : ''}`,
title: favIds.has(s.id) ? 'Remove favorite' : 'Add favorite',
onClick: (e) => { e.stopPropagation(); toggleFavorite(s); }
}, favIds.has(s.id) ? '★' : '☆'),
el('button', {
class: 'more',
title: 'API endpoints',
onClick: (e) => {
e.stopPropagation();
const r = e.currentTarget.getBoundingClientRect();
openContextMenu(r.right, r.bottom, s);
}
}, '⋯')
);
grid.appendChild(card);
}
}
async function toggleFavorite(station) {
const has = state.favorites.some((f) => f.id === station.id);
if (has) await api.del(`/api/me/favorites/${station.id}`);
else await api.put(`/api/me/favorites/${station.id}`, { position: state.favorites.length });
state.favorites = await api.get('/api/me/favorites');
render();
}
function toggleRandomMode() {
state.randomMode = state.randomMode === 'favorites' ? 'all' : 'favorites';
localStorage.setItem('oradio.randomMode', state.randomMode);
toast(`Random mode: ${state.randomMode === 'favorites' ? 'favorites only' : 'all stations'}`);
render();
}
// Pick a random station and play it. Uses the public /api/v1/stations/random
// for "all" mode, /api/me/favorites/random for "favorites" mode. Falls back
// to a local random pick if the network call fails.
async function playRandom() {
try {
const ep = state.randomMode === 'favorites'
? '/api/me/favorites/random'
: '/api/v1/stations/random';
const remote = await api.get(ep);
// The kiosk player needs the internal numeric id (used by /resolve etc.).
// The favorites endpoint returns it directly; the v1 endpoint does not,
// so resolve via the cached station list (or skip if missing).
let station = remote;
if (station.id == null) {
station = state.stations.find((s) => s.uuid === remote.uuid) || null;
}
if (!station) { toast('Random station not in cache'); return; }
playStation(station);
} catch (err) {
// Fallback: pick locally so the button still does something offline.
const pool = state.randomMode === 'favorites' ? state.favorites : state.stations;
if (!pool.length) { toast(err.message || 'No stations available'); return; }
playStation(pool[Math.floor(Math.random() * pool.length)]);
}
}
function recordHistory(stationId) {
// Server-side history insertion can be added later; for now, optimistic local insert.
state.history.unshift({ station_id: stationId, started_at: new Date().toISOString() });
}
// Play wrapper: starts playback, pings server play counter, fetches vote state
// so the up/down buttons in the now-playing bar reflect the current station.
async function playStation(station) {
state.player.votes = null;
player.play(station);
recordHistory(station.id);
try {
const stats = await api.post(`/api/stations/${station.id}/play`);
// Only apply if user hasn't switched stations in the meantime.
if (state.player.stationId === station.id) {
state.player.votes = stats;
// Refresh listing stats in the background so the score badge updates.
mergeStats(station.id, stats);
render();
}
} catch (err) {
try {
const stats = await api.get(`/api/stations/${station.id}/votes`);
if (state.player.stationId === station.id) {
state.player.votes = stats;
mergeStats(station.id, stats);
render();
}
} catch { /* ignore */ }
}
}
async function votePlayer(value) {
const id = state.player.stationId;
if (!id) return;
// Toggle off when clicking the already-active button.
const cur = state.player.votes?.myVote || 0;
const next = cur === value ? 0 : value;
try {
const stats = await api.post(`/api/stations/${id}/vote`, { value: next });
state.player.votes = stats;
mergeStats(id, stats);
render();
} catch (err) {
toast(err.message || 'Vote failed');
}
}
function mergeStats(stationId, stats) {
const list = [state.stations, state.favorites];
for (const arr of list) {
const hit = arr.find((s) => s.id === stationId);
if (hit) {
hit.up = stats.up; hit.down = stats.down;
hit.plays = stats.plays; hit.score = stats.score;
hit.my_vote = stats.myVote;
}
}
}
// ---- API endpoints context menu ----
let menuEl = null;
function closeContextMenu() {
if (menuEl) { menuEl.remove(); menuEl = null; }
}
function apiEndpoints(s) {
const origin = location.origin;
const base = `${origin}/api/v1`;
const items = [];
// Original (internal) endpoint — always available, keyed by station id.
if (s.id != null) {
items.push({ label: 'Station (original)', url: `${origin}/api/stations/${s.id}` });
}
// Public v1 endpoints — require uuid.
if (s.uuid) {
items.push(
{ label: 'Station detail', url: `${base}/stations/${s.uuid}` },
{ label: 'Stream redirect', url: `${base}/stations/${s.uuid}/stream` },
{ label: 'MP3 stream', url: `${base}/stations/${s.uuid}/stream?format=mp3` },
{ label: 'AAC stream', url: `${base}/stations/${s.uuid}/stream?format=aac` },
{ label: 'HLS stream', url: `${base}/stations/${s.uuid}/stream?format=hls` }
);
}
items.push(
{ label: 'All stations', url: `${base}/stations` },
{ label: 'Health', url: `${base}/health` }
);
return items;
}
function openContextMenu(x, y, station) {
closeContextMenu();
const items = apiEndpoints(station);
menuEl = el('div', { class: 'ctx-menu', role: 'menu' },
el('div', { class: 'ctx-title' }, station.name),
el('div', { class: 'ctx-sub' },
station.uuid ? `uuid · ${station.uuid}` : (station.id != null ? `id · ${station.id} (no uuid — public v1 hidden)` : 'no identifier')),
...(items.length ? items.map((it) => el('div', { class: 'ctx-row' },
el('div', { class: 'ctx-row-text' },
el('div', { class: 'ctx-label' }, it.label),
el('div', { class: 'ctx-url' }, it.url)
),
el('button', {
class: 'ctx-btn', title: 'Copy', onClick: async (e) => {
e.stopPropagation();
try { await navigator.clipboard.writeText(it.url); toast('Copied'); } catch { toast('Copy failed'); }
}
}, '⧉'),
el('button', {
class: 'ctx-btn', title: 'Open', onClick: (e) => {
e.stopPropagation();
window.open(it.url, '_blank', 'noopener');
}
}, '↗')
)) : [el('div', { class: 'ctx-empty' }, 'No public API for this station yet (missing uuid).')]),
state.user.role === 'admin' ? el('button', {
class: 'ctx-danger', onClick: async () => {
closeContextMenu();
if (!confirm(`Delete ${station.name}?`)) return;
try { await api.del(`/api/stations/${station.id}`); await refreshAll(); render(); toast('Deleted'); }
catch (e) { toast(e.message || 'Delete failed'); }
}
}, '🗑 Delete') : null
);
document.body.appendChild(menuEl);
// Position within viewport
const w = menuEl.offsetWidth, h = menuEl.offsetHeight;
const px = Math.min(x, window.innerWidth - w - 8);
const py = Math.min(y, window.innerHeight - h - 8);
menuEl.style.left = `${Math.max(8, px)}px`;
menuEl.style.top = `${Math.max(8, py)}px`;
}
document.addEventListener('click', (e) => {
if (menuEl && !menuEl.contains(e.target)) closeContextMenu();
});
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeContextMenu(); });
// ---- Add station dialog (admin only) ----
async function openAddStation() {
const dlg = document.createElement('dialog');
dlg.className = 'add-station';
const data = { name: '', country: '', genres: '', image_url: '', homepage: '', streamUrl: '', streamFormat: 'mp3' };
const errBox = el('div', { class: 'err' });
dlg.appendChild(el('form', {
method: 'dialog', onSubmit: async (e) => {
e.preventDefault();
errBox.textContent = '';
const payload = {
name: data.name.trim(),
country: data.country.trim() || null,
homepage: data.homepage.trim() || null,
image_url: data.image_url.trim() || null,
genres: data.genres.split(',').map((g) => g.trim()).filter(Boolean),
streams: data.streamUrl.trim() ? [{ url: data.streamUrl.trim(), format: data.streamFormat, priority: 0 }] : []
};
if (!payload.name) { errBox.textContent = 'Name is required.'; return; }
try {
await api.post('/api/stations', payload);
dlg.close();
await refreshAll();
render();
toast('Station added');
} catch (err) {
errBox.textContent = err.message || 'Failed to add station';
}
}
},
el('h2', {}, 'Add station'),
el('label', {}, 'Name', el('input', { required: true, autofocus: true, onInput: (e) => data.name = e.target.value })),
el('div', { class: 'row2' },
el('label', {}, 'Country', el('input', { maxlength: 4, placeholder: 'NL', onInput: (e) => data.country = e.target.value })),
el('label', {}, 'Genres', el('input', { placeholder: 'jazz, electronic', onInput: (e) => data.genres = e.target.value }))
),
el('label', {}, 'Homepage', el('input', { type: 'url', placeholder: 'https://…', onInput: (e) => data.homepage = e.target.value })),
el('label', {}, 'Image URL', el('input', { type: 'url', placeholder: 'https://…/logo.png', onInput: (e) => data.image_url = e.target.value })),
el('div', { class: 'row2' },
el('label', {}, 'Stream URL', el('input', { type: 'url', placeholder: 'https://…/stream', onInput: (e) => data.streamUrl = e.target.value })),
el('label', {}, 'Format',
el('select', { onChange: (e) => data.streamFormat = e.target.value },
...['mp3', 'aac', 'ogg', 'hls', 'm3u', 'pls', 'unknown'].map((f) =>
el('option', { value: f, selected: f === 'mp3' }, f))))
),
errBox,
el('div', { class: 'actions' },
el('button', { class: 'btn-ghost', type: 'button', onClick: () => dlg.close() }, 'Cancel'),
el('button', { class: 'btn-primary', type: 'submit' }, 'Add')
)
));
document.body.appendChild(dlg);
dlg.showModal();
dlg.addEventListener('close', () => dlg.remove());
}
// ---- Toast ----
let toastTimer = null;
function toast(text) {
const existing = document.querySelector('.toast');
if (existing) existing.remove();
const t = el('div', { class: 'toast' }, text);
document.body.appendChild(t);
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.remove(), 2200);
}
async function requestWakeLock() {
try { await navigator.wakeLock?.request('screen'); } catch { }
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') navigator.wakeLock?.request('screen').catch(() => { });
});
}
document.addEventListener('contextmenu', (e) => {
// Only suppress the menu in real kiosk mode (e.g. Chromium --kiosk),
// so devtools right-click stays available during normal use.
if (window.matchMedia('(display-mode: fullscreen)').matches) e.preventDefault();
});
bootstrap();