- Implemented a new Player class in player.js to handle audio playback, including HLS support using hls.js. - Created a shared API module in api.js for making HTTP requests with proper error handling. - Added DOM utility functions in dom.js for creating and clearing elements. - Introduced WebSocket connection handling in ws.js for real-time updates. - Developed a comprehensive CSS stylesheet for styling the application, including a high-contrast theme.
428 lines
17 KiB
JavaScript
428 lines
17 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: '',
|
|
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();
|