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.
This commit is contained in:
185
web/main.js
185
web/main.js
@@ -13,7 +13,9 @@ const state = {
|
||||
favorites: [],
|
||||
history: [],
|
||||
query: '',
|
||||
player: { stationId: null, stationName: null, genres: [], playing: false, loading: false, volume: 0.7 }
|
||||
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({
|
||||
@@ -39,7 +41,7 @@ async function bootstrap() {
|
||||
|
||||
async function refreshAll() {
|
||||
const [stations, favs, history, categories] = await Promise.all([
|
||||
api.get('/api/stations'),
|
||||
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(() => [])
|
||||
@@ -50,11 +52,15 @@ async function refreshAll() {
|
||||
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) player.play(st);
|
||||
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();
|
||||
@@ -99,6 +105,7 @@ function render() {
|
||||
|
||||
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' },
|
||||
@@ -108,10 +115,26 @@ function render() {
|
||||
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] && player.play(state.favorites[0]))
|
||||
onClick: () => p.stationId ? player.togglePause() : (state.favorites[0] && playStation(state.favorites[0]))
|
||||
}, p.playing ? '❚❚' : '▶'),
|
||||
el('button', {
|
||||
class: 'btn-stop',
|
||||
@@ -143,10 +166,36 @@ function render() {
|
||||
)
|
||||
),
|
||||
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
|
||||
)
|
||||
);
|
||||
@@ -222,11 +271,14 @@ function paintGrid(grid, favIds) {
|
||||
}
|
||||
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: () => { player.play(s); recordHistory(s.id); },
|
||||
onClick: () => playStation(s),
|
||||
onContextMenu: (e) => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, s); }
|
||||
},
|
||||
el('div', { class: 'art' },
|
||||
@@ -249,6 +301,9 @@ function paintGrid(grid, favIds) {
|
||||
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',
|
||||
@@ -276,35 +331,135 @@ async function toggleFavorite(station) {
|
||||
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) {
|
||||
if (!s.uuid) return [];
|
||||
const base = `${location.origin}/api/v1`;
|
||||
return [
|
||||
{ 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` },
|
||||
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}` : 'no uuid'),
|
||||
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),
|
||||
|
||||
Reference in New Issue
Block a user