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();