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: '', player: { stationId: null, stationName: null, genres: [], playing: false, loading: false, volume: 0.7 } }; 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'), 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; } 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); } 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 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('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])) }, 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' }, el('input', { class: 'search', type: 'search', placeholder: 'Search…', value: state.query, onInput: (e) => { state.query = e.target.value; renderGrid(); } }), 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 card = el('div', { class: `card ${p.stationId === s.id ? 'playing' : ''}`, role: 'button', tabindex: 0, onClick: () => { player.play(s); recordHistory(s.id); }, 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('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 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() }); } // ---- 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` }, { label: 'All stations', url: `${base}/stations` }, { label: 'Health', url: `${base}/health` } ]; } 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'), ...(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();