Add player functionality with HLS support and API integration

- 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.
This commit is contained in:
Marco Mooren
2026-05-10 14:43:00 +02:00
commit e0a60f7b64
51 changed files with 9022 additions and 0 deletions

13
web/admin/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Radio Admin</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<div id="app"></div>
<script type="module" src="./main.js"></script>
</body>
</html>

294
web/admin/main.js Normal file
View File

@@ -0,0 +1,294 @@
import { api } from '../shared/api.js';
import { el, clear } from '../shared/dom.js';
const app = document.getElementById('app');
const state = { user: null, view: 'stations', stations: [], users: [], system: null, search: '' };
async function bootstrap() {
try { state.user = await api.get('/api/auth/me'); }
catch { return showLogin(); }
if (state.user.role !== 'admin') {
app.innerHTML = `<div class="login"><div><h1>Admin only</h1><p>Signed in as ${state.user.username} (${state.user.role}).</p></div></div>`;
return;
}
await refresh();
render();
}
async function refresh() {
const tasks = [api.get('/api/stations?all=1')];
if (state.view === 'users') tasks.push(api.get('/api/auth/users'));
if (state.view === 'system') tasks.push(api.get('/api/admin/system'));
const [stations, more1, more2] = await Promise.all(tasks);
state.stations = stations;
if (state.view === 'users') state.users = more1 || [];
if (state.view === 'system') state.system = more1 || more2 || null;
}
function showLogin() {
clear(app);
app.appendChild(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', {}, 'Admin sign in'),
el('input', { name: 'username', placeholder: 'Username', required: true }),
el('input', { name: 'password', type: 'password', placeholder: 'Password', required: true }),
el('div', { class: 'err' }),
el('button', { class: 'btn primary', type: 'submit' }, 'Sign in')
)));
}
function render() {
clear(app);
const side = el('aside', { class: 'side' },
el('h1', {}, 'Online Radio Explorer'),
...['stations', 'import', 'users', 'system'].map((v) =>
el('button', { class: `nav ${state.view === v ? 'active' : ''}`,
onClick: async () => { state.view = v; await refresh(); render(); } }, label(v))),
el('div', { class: 'me' }, `Signed in as ${state.user.username}`,
el('br'),
el('a', { href: '#', onClick: async (e) => { e.preventDefault(); await api.post('/api/auth/logout'); location.reload(); } }, 'Sign out'))
);
const main = el('main', { class: 'main' });
if (state.view === 'stations') renderStations(main);
else if (state.view === 'import') renderImport(main);
else if (state.view === 'users') renderUsers(main);
else if (state.view === 'system') renderSystem(main);
app.appendChild(el('div', { class: 'shell' }, side, main));
}
function label(v) {
return ({ stations: 'Stations', import: 'Import', users: 'Users', system: 'System' })[v];
}
// ---------- Stations ----------
function renderStations(root) {
root.appendChild(el('div', { class: 'bar' },
el('input', { placeholder: 'Search…', value: state.search,
onInput: (e) => { state.search = e.target.value; renderStationsTable(); } }),
el('button', { class: 'btn primary', onClick: () => openStationDialog() }, '+ Add station'),
el('button', { class: 'btn', onClick: async () => { await api.post('/api/admin/health-check'); alert('Health check finished'); await refresh(); render(); } }, 'Run health check')
));
const tableWrap = el('div', { id: 'tableWrap' });
root.appendChild(tableWrap);
renderStationsTable();
}
function renderStationsTable() {
const wrap = document.getElementById('tableWrap');
if (!wrap) return;
clear(wrap);
const q = state.search.toLowerCase();
const filtered = state.stations.filter((s) =>
!q || s.name.toLowerCase().includes(q) || (s.country || '').toLowerCase().includes(q) ||
(s.genres || []).some((g) => g.toLowerCase().includes(q))
);
const table = el('table', {},
el('thead', {}, el('tr', {},
el('th', {}, 'Name'), el('th', {}, 'Source'), el('th', {}, 'Genres'),
el('th', {}, 'Country'), el('th', {}, 'Enabled'), el('th', {}, 'Actions'))),
el('tbody', {}, ...filtered.map((s) => el('tr', {},
el('td', {}, el('strong', {}, s.name), el('br'), el('small', {}, s.homepage || '')),
el('td', {}, s.source),
el('td', {}, ...(s.genres || []).slice(0, 4).map((g) => el('span', { class: 'tag' }, g))),
el('td', {}, s.country || ''),
el('td', {}, s.enabled ? '✅' : '⛔'),
el('td', {},
el('button', { class: 'btn', onClick: () => openStationDialog(s.id) }, 'Edit'),
' ',
el('button', { class: 'btn danger', onClick: async () => {
if (confirm(`Delete ${s.name}?`)) { await api.del(`/api/stations/${s.id}`); await refresh(); render(); }
} }, 'Delete')
)
)))
);
wrap.appendChild(table);
}
async function openStationDialog(id) {
const station = id ? await api.get(`/api/stations/${id}`) : { name: '', genres: [], streams: [], enabled: true };
const dlg = el('dialog');
const streamsBox = el('div', { class: 'streams' });
function paintStreams() {
clear(streamsBox);
streamsBox.appendChild(el('div', { style: { fontWeight: 600, marginBottom: '6px' } }, 'Streams'));
if (!station.streams?.length) streamsBox.appendChild(el('div', { style: { color: '#6b7280' } }, 'No streams yet.'));
for (const s of station.streams || []) {
streamsBox.appendChild(el('div', { class: 'stream-row' },
el('select', { onChange: (e) => s.format = e.target.value },
...['mp3','aac','hls','m3u','pls','ogg','unknown'].map((f) =>
el('option', { value: f, selected: s.format === f }, f))),
el('input', { value: s.url, placeholder: 'https://…', onInput: (e) => s.url = e.target.value }),
el('input', { type: 'number', placeholder: 'kbps', value: s.bitrate || '', onInput: (e) => s.bitrate = Number(e.target.value) || null }),
el('input', { value: s.label || '', placeholder: 'Label', onInput: (e) => s.label = e.target.value }),
s.last_status ? el('span', { class: `pill ${s.last_status === 'up' ? 'up' : 'down'}` }, s.last_status) : el('span'),
el('button', { class: 'btn danger', type: 'button', onClick: () => { station.streams = station.streams.filter((x) => x !== s); paintStreams(); } }, '×')
));
}
streamsBox.appendChild(el('button', { class: 'btn', type: 'button', onClick: () => {
station.streams = [...(station.streams || []), { url: '', format: 'mp3', priority: (station.streams?.length || 0) }];
paintStreams();
} }, '+ Add stream'));
}
const form = el('form', { method: 'dialog', onSubmit: async (e) => {
e.preventDefault();
const payload = {
name: station.name, homepage: station.homepage, country: station.country,
genres: station.genres, description: station.description, image_url: station.image_url,
enabled: station.enabled
};
if (id) {
await api.patch(`/api/stations/${id}`, payload);
// sync streams: simple approach — delete all & re-add
const fresh = await api.get(`/api/stations/${id}`);
for (const s of fresh.streams || []) await api.del(`/api/stations/${id}/streams/${s.id}`);
for (const s of station.streams || []) if (s.url) await api.post(`/api/stations/${id}/streams`, s);
} else {
payload.streams = (station.streams || []).filter((s) => s.url);
await api.post('/api/stations', payload);
}
dlg.close();
await refresh();
render();
} },
el('h2', {}, id ? 'Edit station' : 'Add station'),
el('div', { class: 'row' }, el('label', {}, 'Name'), el('input', { value: station.name, onInput: (e) => station.name = e.target.value, required: true })),
el('div', { class: 'row' }, el('label', {}, 'Homepage'), el('input', { value: station.homepage || '', onInput: (e) => station.homepage = e.target.value })),
el('div', { class: 'row' }, el('label', {}, 'Country'), el('input', { value: station.country || '', maxlength: 4, onInput: (e) => station.country = e.target.value })),
el('div', { class: 'row' }, el('label', {}, 'Genres'), el('input', { value: (station.genres || []).join(', '), onInput: (e) => station.genres = e.target.value.split(',').map((s) => s.trim()).filter(Boolean) })),
el('div', { class: 'row' }, el('label', {}, 'Image URL'),el('input', { value: station.image_url || '', onInput: (e) => station.image_url = e.target.value })),
el('div', { class: 'row col' }, el('textarea', { rows: 2, placeholder: 'Description', onInput: (e) => station.description = e.target.value }, station.description || '')),
el('div', { class: 'row' }, el('label', {}, 'Enabled'), el('input', { type: 'checkbox', checked: station.enabled, onChange: (e) => station.enabled = e.target.checked })),
streamsBox,
el('div', { class: 'actions' },
el('button', { class: 'btn', type: 'button', onClick: () => dlg.close() }, 'Cancel'),
el('button', { class: 'btn primary', type: 'submit' }, 'Save'))
);
paintStreams();
dlg.appendChild(form);
document.body.appendChild(dlg);
dlg.showModal();
dlg.addEventListener('close', () => dlg.remove());
}
// ---------- Import (Radio-Browser) ----------
function renderImport(root) {
let results = [];
const resultsBox = el('div');
root.appendChild(el('h2', {}, 'Import from Radio-Browser'));
root.appendChild(el('div', { class: 'bar' },
el('input', { id: 'rbq', placeholder: 'Search by name…' }),
el('input', { id: 'rbcountry', placeholder: 'Country (e.g. NL)', style: { minWidth: '120px' } }),
el('input', { id: 'rbtag', placeholder: 'Tag/genre' }),
el('button', { class: 'btn primary', onClick: async () => {
const params = new URLSearchParams({
q: document.getElementById('rbq').value,
country: document.getElementById('rbcountry').value,
tag: document.getElementById('rbtag').value
});
results = await api.get(`/api/stations/sources/radiobrowser/search?${params}`);
paint();
} }, 'Search')
));
root.appendChild(resultsBox);
function paint() {
clear(resultsBox);
if (!results.length) { resultsBox.appendChild(el('p', {}, 'No results yet.')); return; }
const table = el('table', {},
el('thead', {}, el('tr', {}, el('th', {}, 'Name'), el('th', {}, 'Country'), el('th', {}, 'Tags'), el('th', {}, 'Stream'), el('th', {}, ''))),
el('tbody', {}, ...results.map((s) => el('tr', {},
el('td', {}, s.name),
el('td', {}, s.country || ''),
el('td', {}, ...(s.genres || []).slice(0, 4).map((g) => el('span', { class: 'tag' }, g))),
el('td', {}, el('small', {}, (s.streams[0]?.format || '') + ' ' + (s.streams[0]?.bitrate || ''))),
el('td', {}, el('button', { class: 'btn primary', onClick: async () => {
await api.post('/api/stations/sources/radiobrowser/import', s);
alert(`Imported ${s.name}`);
} }, 'Import'))
)))
);
resultsBox.appendChild(table);
}
}
// ---------- Users ----------
function renderUsers(root) {
root.appendChild(el('div', { class: 'bar' },
el('h2', { style: { margin: 0, flex: 1 } }, 'Users'),
el('button', { class: 'btn primary', onClick: openUserDialog }, '+ Add user')
));
root.appendChild(el('table', {},
el('thead', {}, el('tr', {}, el('th', {}, 'Username'), el('th', {}, 'Role'), el('th', {}, 'Created'), el('th', {}, ''))),
el('tbody', {}, ...state.users.map((u) => el('tr', {},
el('td', {}, u.username),
el('td', {}, u.role),
el('td', {}, u.created_at),
el('td', {},
el('button', { class: 'btn', onClick: async () => {
const pw = prompt(`New password for ${u.username}:`);
if (pw) { await api.patch(`/api/auth/users/${u.id}`, { password: pw }); alert('Updated'); }
} }, 'Reset PW'),
' ',
el('button', { class: 'btn', onClick: async () => {
const r = u.role === 'admin' ? 'user' : 'admin';
await api.patch(`/api/auth/users/${u.id}`, { role: r });
await refresh(); render();
} }, 'Toggle role'),
' ',
u.id !== state.user.id ? el('button', { class: 'btn danger', onClick: async () => {
if (confirm(`Delete ${u.username}?`)) { await api.del(`/api/auth/users/${u.id}`); await refresh(); render(); }
} }, 'Delete') : null
)
)))
));
}
function openUserDialog() {
const dlg = el('dialog');
dlg.appendChild(el('form', { method: 'dialog', onSubmit: async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
await api.post('/api/auth/users', {
username: fd.get('username'), password: fd.get('password'), role: fd.get('role')
});
dlg.close();
await refresh(); render();
} },
el('h2', {}, 'New user'),
el('div', { class: 'row' }, el('label', {}, 'Username'), el('input', { name: 'username', required: true })),
el('div', { class: 'row' }, el('label', {}, 'Password'), el('input', { name: 'password', type: 'password', required: true })),
el('div', { class: 'row' }, el('label', {}, 'Role'),
el('select', { name: 'role' }, el('option', { value: 'user' }, 'user'), el('option', { value: 'admin' }, 'admin'))),
el('div', { class: 'actions' },
el('button', { class: 'btn', type: 'button', onClick: () => dlg.close() }, 'Cancel'),
el('button', { class: 'btn primary', type: 'submit' }, 'Create'))
));
document.body.appendChild(dlg);
dlg.showModal();
dlg.addEventListener('close', () => dlg.remove());
}
// ---------- System ----------
function renderSystem(root) {
const s = state.system || {};
root.appendChild(el('h2', {}, 'System'));
root.appendChild(el('div', { class: 'system-grid' },
stat('Stations', s.stations), stat('Streams', s.streams), stat('Users', s.users),
stat('Favorites', s.favorites), stat('Node', s.node), stat('Uptime (s)', s.uptime_s)
));
root.appendChild(el('div', { class: 'bar', style: { marginTop: '16px' } },
el('button', { class: 'btn', onClick: async () => { await api.post('/api/admin/health-check'); alert('Health check finished'); await refresh(); render(); } }, 'Run health check'),
el('button', { class: 'btn', onClick: async () => { const r = await api.post('/api/admin/reseed'); alert(JSON.stringify(r)); } }, 'Reseed (if empty)')
));
}
function stat(k, v) { return el('div', { class: 'stat' }, el('div', { class: 'k' }, k), el('div', { class: 'v' }, v ?? '—')); }
bootstrap();

210
web/admin/style.css Normal file
View File

@@ -0,0 +1,210 @@
:root {
--bg: #f7f7fa; --panel: #fff; --fg: #14161b; --muted: #6b7280;
--border: #e3e5ec; --accent: #ff6a26; --good: #178a6a; --bad: #c2342f;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--fg); }
button { font: inherit; cursor: pointer; }
a { color: var(--accent); }
.shell { display: grid; grid-template-columns: 240px 1fr; min-height: 100vh; }
.side { background: #14161b; color: #e8eaef; padding: 20px; display: flex; flex-direction: column; gap: 8px; }
.side h1 { font-size: 18px; margin: 0 0 16px; }
.side button.nav {
text-align: left; background: transparent; color: #cdd1da; border: 0;
padding: 10px 12px; border-radius: 8px; font-size: 14px;
}
.side button.nav.active { background: var(--accent); color: #1a0a00; font-weight: 700; }
.side .me { margin-top: auto; font-size: 12px; color: #8a8f9c; }
.main { padding: 24px; }
.bar { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; flex-wrap: wrap; }
.bar input, .bar select {
padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel);
font-size: 14px; min-width: 220px;
}
.btn {
padding: 8px 14px; border-radius: 8px; border: 1px solid var(--border);
background: var(--panel); font-size: 14px;
}
.btn.primary { background: var(--accent); color: #1a0a00; border-color: var(--accent); font-weight: 700; }
.btn.danger { color: var(--bad); border-color: var(--bad); }
table { width: 100%; border-collapse: collapse; background: var(--panel); border-radius: 12px; overflow: hidden; }
th, td { padding: 10px 12px; border-bottom: 1px solid var(--border); text-align: left; font-size: 14px; vertical-align: top; }
th { background: #f0f1f5; font-weight: 600; }
tr:last-child td { border-bottom: 0; }
.tag { display: inline-block; font-size: 11px; padding: 2px 8px; border-radius: 999px; background: #eef0f4; margin-right: 4px; }
.pill { font-size: 11px; padding: 2px 8px; border-radius: 999px; }
.pill.up { background: #dff5ec; color: var(--good); }
.pill.down { background: #fde7e6; color: var(--bad); }
.pill.unknown { background: #eef0f4; color: var(--muted); }
dialog {
border: 0; border-radius: 14px; padding: 0; max-width: 720px; width: 90%;
box-shadow: 0 12px 40px rgba(0,0,0,0.2);
}
dialog form { padding: 24px; display: flex; flex-direction: column; gap: 12px; }
dialog h2 { margin: 0 0 4px; }
dialog input, dialog textarea, dialog select {
padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; font-size: 14px; width: 100%;
}
dialog .row { display: grid; grid-template-columns: 140px 1fr; gap: 12px; align-items: center; }
dialog .row.col { grid-template-columns: 1fr; }
dialog .actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 8px; }
.streams { background: #f7f7fa; border-radius: 8px; padding: 12px; }
.stream-row { display: grid; grid-template-columns: 100px 1fr 80px 110px auto; gap: 8px; align-items: center; padding: 6px 0; border-bottom: 1px dashed var(--border); }
.stream-row:last-child { border-bottom: 0; }
.login { min-height: 100vh; display: flex; align-items: center; justify-content: center; }
.login form { background: var(--panel); padding: 32px; border-radius: 12px; width: 360px; display: flex; flex-direction: column; gap: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.08); }
.login h1 { margin: 0; }
.err { color: var(--bad); font-size: 13px; min-height: 16px; }
.system-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
.stat { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 16px; }
.stat .v { font-size: 28px; font-weight: 700; }
.stat .k { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; }
/* ============================================================
MINIMAL HIGH-CONTRAST THEME OVERRIDE
Flat surfaces, sharp 90deg corners, monochrome + single accent.
============================================================ */
:root {
--bg: #ffffff;
--panel: #ffffff;
--fg: #000000;
--muted: #555555;
--border: #000000;
--accent: #ff5b00;
--good: #007a3d;
--bad: #c2001a;
}
*, *::before, *::after { border-radius: 0 !important; }
button, input, select, textarea, dialog { border-radius: 0 !important; }
a { color: var(--fg); text-decoration: underline; }
.shell { border: 0; }
.side {
background: #000000 !important;
color: #ffffff !important;
border-right: 1px solid #000;
}
.side h1 { text-transform: uppercase; letter-spacing: 0.08em; font-weight: 900; font-size: 16px; }
.side button.nav {
border: 1px solid transparent !important;
text-transform: uppercase; letter-spacing: 0.04em; font-weight: 700; font-size: 13px;
color: #cccccc;
}
.side button.nav:hover { color: #ffffff; border-color: #333333 !important; }
.side button.nav.active {
background: var(--accent) !important;
color: #000 !important;
border-color: var(--accent) !important;
}
.side .me {
text-transform: uppercase; letter-spacing: 0.06em; font-size: 11px; color: #888888;
}
.bar input, .bar select {
border: 1px solid var(--border) !important;
background: var(--panel) !important;
outline: none;
}
.bar input:focus, .bar select:focus { border-color: var(--accent) !important; }
.btn {
border: 1px solid var(--border) !important;
background: var(--panel) !important;
font-size: 12px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.06em;
transition: background 80ms linear, color 80ms linear;
}
.btn:hover { background: #000 !important; color: #fff !important; }
.btn.primary {
background: var(--accent) !important;
color: #000 !important;
border-color: var(--accent) !important;
}
.btn.primary:hover { background: #000 !important; color: #fff !important; border-color: #000 !important; }
.btn.danger { color: var(--bad) !important; border-color: var(--bad) !important; background: var(--panel) !important; }
.btn.danger:hover { background: var(--bad) !important; color: #fff !important; }
table {
background: var(--panel) !important;
border: 1px solid var(--border) !important;
overflow: visible !important;
}
th {
background: #000 !important;
color: #fff !important;
font-weight: 800;
text-transform: uppercase; letter-spacing: 0.06em; font-size: 11px;
border-bottom: 1px solid #000;
}
td { border-bottom: 1px solid #cccccc !important; font-size: 13px; }
tbody tr:hover { background: #f3f3f3; }
.tag {
background: #000 !important;
color: #fff !important;
text-transform: uppercase; letter-spacing: 0.06em; font-weight: 700; font-size: 10px;
}
.pill {
text-transform: uppercase; letter-spacing: 0.06em; font-weight: 800; font-size: 10px;
border: 1px solid currentColor;
}
.pill.up { background: var(--good) !important; color: #fff !important; border-color: var(--good) !important; }
.pill.down { background: var(--bad) !important; color: #fff !important; border-color: var(--bad) !important; }
.pill.unknown { background: #fff !important; color: var(--muted) !important; border-color: #cccccc !important; }
dialog {
border: 1px solid var(--border) !important;
box-shadow: none !important;
background: var(--panel) !important;
color: var(--fg) !important;
}
dialog::backdrop { background: rgba(0,0,0,0.5); }
dialog h2 {
margin: 0 0 8px;
text-transform: uppercase; letter-spacing: 0.04em; font-weight: 900; font-size: 16px;
border-bottom: 1px solid var(--border);
padding-bottom: 8px;
}
dialog input, dialog textarea, dialog select {
border: 1px solid var(--border) !important;
background: var(--panel) !important;
color: var(--fg) !important;
outline: none;
}
dialog input:focus, dialog textarea:focus, dialog select:focus { border-color: var(--accent) !important; }
dialog .actions { padding-top: 12px; border-top: 1px solid #cccccc; }
.streams {
background: #f5f5f5 !important;
border: 1px solid #cccccc !important;
}
.stream-row { border-bottom: 1px solid #cccccc !important; }
.stream-row:last-child { border-bottom: 0 !important; }
.login { background: #fff; }
.login form {
border: 1px solid var(--border) !important;
box-shadow: none !important;
background: var(--panel) !important;
}
.login h1 { text-transform: uppercase; letter-spacing: 0.04em; font-weight: 900; font-size: 20px; }
.err { font-weight: 600; }
.system-grid { gap: 0 !important; }
.stat {
border: 1px solid var(--border) !important;
margin: -1px 0 0 -1px;
}
.stat .v { font-weight: 900; letter-spacing: -0.01em; }
.stat .k { font-weight: 700; letter-spacing: 0.08em; }

13
web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=1080, initial-scale=1, maximum-scale=1, user-scalable=no" />
<title>Radio Kiosk</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body class="kiosk">
<div id="app"></div>
<script type="module" src="./main.js"></script>
</body>
</html>

427
web/main.js Normal file
View File

@@ -0,0 +1,427 @@
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();

83
web/player.js Normal file
View File

@@ -0,0 +1,83 @@
import Hls from 'hls.js';
import { api } from './shared/api.js';
export class Player {
constructor({ onState }) {
this.audio = new Audio();
this.audio.preload = 'none';
// Note: do NOT set crossOrigin — most Icecast/SHOUTcast servers don't send
// CORS headers and the browser will then refuse to play the stream.
this.hls = null;
this.station = null;
this.onState = onState || (() => { });
this.audio.addEventListener('playing', () => this.emit({ playing: true, loading: false, error: null }));
this.audio.addEventListener('pause', () => this.emit({ playing: false, loading: false }));
this.audio.addEventListener('waiting', () => this.emit({ loading: true }));
this.audio.addEventListener('error', () => {
const code = this.audio.error?.code;
const map = { 1: 'aborted', 2: 'network', 3: 'decode', 4: 'src not supported' };
const reason = map[code] || `code ${code}`;
console.warn('[player] audio error', reason, this.audio.currentSrc);
this.emit({ playing: false, loading: false, error: `stream error: ${reason}` });
});
}
emit(extra) {
this.onState({
stationId: this.station?.id ?? null,
stationName: this.station?.name ?? null,
genres: this.station?.genres || [],
volume: this.audio.volume,
...extra
});
}
setVolume(v) {
this.audio.volume = Math.max(0, Math.min(1, v));
this.emit({});
}
stop() {
this.audio.pause();
this.audio.removeAttribute('src');
this.audio.load();
if (this.hls) { this.hls.destroy(); this.hls = null; }
}
togglePause() {
if (!this.station) return;
if (this.audio.paused) this.audio.play().catch(() => { });
else this.audio.pause();
}
async play(station) {
this.stop();
this.station = station;
this.emit({ playing: false, loading: true });
let resolved;
try {
const r = await api.post(`/api/stations/${station.id}/resolve`);
resolved = r.resolved;
} catch (err) {
this.emit({ playing: false, loading: false, error: err.message });
return;
}
const url = resolved.url;
if (resolved.format === 'hls') {
if (Hls.isSupported()) {
this.hls = new Hls({ enableWorker: true });
this.hls.loadSource(url);
this.hls.attachMedia(this.audio);
this.hls.on(Hls.Events.MANIFEST_PARSED, () => this.audio.play().catch(() => { }));
} else if (this.audio.canPlayType('application/vnd.apple.mpegurl')) {
this.audio.src = url;
this.audio.play().catch(() => { });
} else {
this.emit({ playing: false, loading: false, error: 'HLS not supported' });
}
} else {
this.audio.src = url;
this.audio.play().catch(() => { });
}
}
}

21
web/shared/api.js Normal file
View File

@@ -0,0 +1,21 @@
async function http(method, path, body) {
const res = await fetch(path, {
method,
credentials: 'same-origin',
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined
});
if (res.status === 204) return null;
const ct = res.headers.get('content-type') || '';
const data = ct.includes('json') ? await res.json() : await res.text();
if (!res.ok) throw Object.assign(new Error(data?.error || res.statusText), { status: res.status, data });
return data;
}
export const api = {
get: (p) => http('GET', p),
post: (p, b) => http('POST', p, b),
put: (p, b) => http('PUT', p, b),
patch: (p, b) => http('PATCH', p, b),
del: (p) => http('DELETE', p)
};

17
web/shared/dom.js Normal file
View File

@@ -0,0 +1,17 @@
export function el(tag, props = {}, ...children) {
const node = document.createElement(tag);
for (const [k, v] of Object.entries(props || {})) {
if (k === 'class') node.className = v;
else if (k === 'style' && typeof v === 'object') Object.assign(node.style, v);
else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2).toLowerCase(), v);
else if (k === 'html') node.innerHTML = v;
else if (v !== false && v != null) node.setAttribute(k, v === true ? '' : v);
}
for (const c of children.flat()) {
if (c == null || c === false) continue;
node.appendChild(c instanceof Node ? c : document.createTextNode(String(c)));
}
return node;
}
export function clear(node) { while (node.firstChild) node.removeChild(node.firstChild); }

22
web/shared/ws.js Normal file
View File

@@ -0,0 +1,22 @@
export function connectWs(onMessage) {
let ws, retry = 0, closed = false;
function open() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/ws`);
ws.addEventListener('open', () => { retry = 0; });
ws.addEventListener('message', (ev) => {
try { onMessage(JSON.parse(ev.data)); } catch {}
});
ws.addEventListener('close', () => {
if (closed) return;
retry = Math.min(retry + 1, 6);
setTimeout(open, 500 * 2 ** retry);
});
ws.addEventListener('error', () => ws.close());
}
open();
return {
send(msg) { if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg)); },
close() { closed = true; ws?.close(); }
};
}

658
web/style.css Normal file
View File

@@ -0,0 +1,658 @@
:root {
--bg-0: #07080b;
--bg-1: #0e1116;
--bg-2: #161a22;
--bg-3: #1f242e;
--line: #262b36;
--fg: #e9ecf2;
--muted: #8a90a0;
--muted-2: #5d6373;
--accent: #ff7a3d;
--accent-2: #ffb37a;
--accent-glow: rgba(255, 122, 61, 0.35);
--good: #4ec9a6;
--bad: #ec6a6a;
--radius-sm: 10px;
--radius: 14px;
--radius-lg: 20px;
--pad: 16px;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.4);
--shadow: 0 8px 24px rgba(0,0,0,0.45);
--shadow-lg: 0 18px 40px rgba(0,0,0,0.55);
font-family: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-feature-settings: "ss01", "cv11";
color-scheme: dark;
}
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
background: radial-gradient(1200px 600px at 30% -10%, rgba(255,122,61,0.08), transparent 60%),
radial-gradient(900px 500px at 100% 110%, rgba(78, 201, 166, 0.06), transparent 60%),
var(--bg-0);
color: var(--fg);
}
body {
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
user-select: none;
overflow: hidden;
}
button {
font: inherit; color: inherit;
background: none; border: 0;
cursor: pointer;
padding: 0;
}
input, select, textarea { font: inherit; color: inherit; }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--bg-3); border-radius: 8px; }
::-webkit-scrollbar-thumb:hover { background: #2c323e; }
/* === Kiosk shell 1080 x 660 === */
.kiosk #app {
width: 1080px;
height: 660px;
margin: 0 auto;
display: grid;
grid-template-rows: 92px 1fr;
gap: 12px;
padding: 12px;
}
/* === Now-playing bar === */
.now {
display: grid;
grid-template-columns: 1fr auto;
gap: 16px;
padding: 10px 16px;
background: linear-gradient(135deg, rgba(255,255,255,0.04), rgba(255,255,255,0.01)),
var(--bg-1);
border: 1px solid var(--line);
border-radius: var(--radius);
align-items: center;
box-shadow: var(--shadow-sm);
position: relative;
overflow: hidden;
}
.now::before {
content: ""; position: absolute; inset: 0;
background: radial-gradient(400px 120px at 0% 0%, var(--accent-glow), transparent 70%);
opacity: 0.5; pointer-events: none;
}
.now > * { position: relative; }
@keyframes pulse {
0%, 100% { box-shadow: 0 0 0 0 var(--accent-glow); }
50% { box-shadow: 0 0 0 6px transparent; }
}
.now .meta { min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.now .meta .name {
font-size: 19px; font-weight: 700; letter-spacing: -0.01em; line-height: 1.15;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.now .meta .sub {
color: var(--muted); font-size: 12px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
display: flex; align-items: center; gap: 6px;
}
.now .meta .tags { display: flex; gap: 5px; flex-wrap: wrap; margin-top: 2px; }
.tag {
font-size: 11px; font-weight: 500;
padding: 2px 8px; border-radius: 999px;
background: rgba(255,179,122,0.10); color: var(--accent-2);
border: 1px solid rgba(255,179,122,0.18);
}
.now .controls { display: flex; gap: 10px; align-items: center; }
.btn-play, .btn-stop {
width: 46px; height: 46px;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 18px;
transition: transform 80ms ease, background 120ms ease, box-shadow 120ms ease;
}
.btn-play {
background: var(--accent); color: #1a0a00; font-weight: 900;
box-shadow: 0 6px 20px var(--accent-glow);
}
.btn-play:hover { background: #ff8a55; }
.btn-play:active { transform: scale(0.94); }
.btn-play.loading { opacity: 0.65; }
.btn-stop {
background: var(--bg-2); color: var(--muted);
border: 1px solid var(--line);
}
.btn-stop:not(:disabled):hover { background: var(--bg-3); color: var(--fg); }
.btn-stop:disabled { opacity: 0.35; cursor: default; }
.vol {
width: 170px; display: flex; align-items: center; gap: 8px;
padding: 6px 10px; background: var(--bg-2);
border: 1px solid var(--line); border-radius: 999px;
}
.vol .vol-icon { font-size: 13px; }
.vol input[type=range] { flex: 1; height: 18px; accent-color: var(--accent); }
.vol .val {
width: 28px; text-align: right; color: var(--muted);
font-variant-numeric: tabular-nums; font-size: 11px;
}
/* === Library shell === */
.lib {
background: var(--bg-1);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 10px 10px 6px;
display: flex; flex-direction: column;
min-height: 0; gap: 8px;
box-shadow: var(--shadow-sm);
}
.header { display: flex; align-items: center; gap: 8px; }
.tabs { display: flex; gap: 4px; flex: 1; min-width: 0; }
.tab {
padding: 9px 14px; border-radius: 10px;
background: transparent; color: var(--muted);
font-size: 13px; font-weight: 600; min-height: 38px;
border: 1px solid transparent;
transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
}
.tab:hover { color: var(--fg); background: var(--bg-2); }
.tab.active {
background: linear-gradient(180deg, rgba(255,122,61,0.18), rgba(255,122,61,0.08));
color: var(--accent-2);
border-color: rgba(255,122,61,0.30);
}
.header-tools { display: flex; gap: 6px; align-items: center; }
.search {
width: 220px;
padding: 8px 12px; height: 36px;
background: var(--bg-2); color: var(--fg);
border: 1px solid var(--line); border-radius: 999px;
font-size: 13px;
outline: none;
transition: border-color 120ms ease, box-shadow 120ms ease;
}
.search::placeholder { color: var(--muted-2); }
.search:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
.btn-add {
width: 36px; height: 36px; border-radius: 50%;
background: var(--accent); color: #1a0a00;
font-size: 22px; font-weight: 800; line-height: 1;
box-shadow: 0 4px 12px var(--accent-glow);
transition: transform 80ms ease, background 120ms ease;
}
.btn-add:hover { background: #ff8a55; }
.btn-add:active { transform: scale(0.94); }
.chips {
display: flex; flex-wrap: wrap; gap: 5px;
max-height: 64px; overflow-y: auto;
padding: 2px;
}
.chip {
padding: 4px 10px; border-radius: 999px;
background: var(--bg-2); color: var(--muted);
border: 1px solid var(--line);
font-size: 11px; font-weight: 600; min-height: 26px;
transition: background 120ms, color 120ms, border-color 120ms;
}
.chip:hover { color: var(--fg); }
.chip.active {
background: rgba(255,122,61,0.18);
color: var(--accent-2);
border-color: rgba(255,122,61,0.4);
}
.grid {
flex: 1; min-height: 0; overflow-y: auto;
display: flex; flex-direction: column;
gap: 4px;
padding: 2px 4px 6px 2px;
}
.card {
display: grid;
grid-template-columns: 44px 1fr auto auto;
align-items: center; gap: 12px;
padding: 6px 10px 6px 6px;
background: var(--bg-2);
border: 1px solid transparent;
border-radius: 10px;
min-height: 56px;
text-align: left;
cursor: pointer;
transition: background 100ms ease, border-color 100ms ease, transform 80ms ease;
position: relative;
}
.card:hover { background: var(--bg-3); }
.card:active { transform: scale(0.995); }
.card.playing {
background: linear-gradient(90deg, rgba(255,122,61,0.14), var(--bg-2) 60%);
border-color: rgba(255,122,61,0.35);
}
.card.playing::before {
content: ""; position: absolute; left: 0; top: 8px; bottom: 8px;
width: 3px; border-radius: 0 3px 3px 0; background: var(--accent);
}
.card .art {
width: 44px; height: 44px; border-radius: 8px;
background: var(--bg-3) center/cover no-repeat;
display: flex; align-items: center; justify-content: center;
font-size: 16px; color: var(--muted-2);
flex-shrink: 0;
border: 1px solid var(--line);
overflow: hidden;
}
.card .art .art-img {
width: 100%; height: 100%; object-fit: cover; display: block;
}
.card .card-body { min-width: 0; }
.card .n {
font-weight: 600; font-size: 14px; line-height: 1.2;
letter-spacing: -0.005em;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.card .g {
font-size: 11.5px; color: var(--muted);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
margin-top: 2px;
}
.card .fav, .card .more {
width: 32px; height: 32px;
border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-size: 16px; color: var(--muted);
transition: background 100ms, color 100ms;
}
.card .fav:hover, .card .more:hover { background: rgba(255,255,255,0.06); color: var(--fg); }
.card .fav.on { color: var(--accent); }
.card .more { font-weight: 700; letter-spacing: 1px; }
.empty {
color: var(--muted); padding: 32px 16px; text-align: center;
font-size: 13px;
}
/* === Login overlay === */
.login {
position: fixed; inset: 0;
background: radial-gradient(800px 500px at 50% 0%, rgba(255,122,61,0.10), transparent 60%),
rgba(7,8,11,0.97);
display: flex; align-items: center; justify-content: center;
z-index: 50;
}
.login form {
background: var(--bg-1); border: 1px solid var(--line);
padding: 32px; border-radius: var(--radius-lg);
display: flex; flex-direction: column; gap: 14px;
width: 380px;
box-shadow: var(--shadow-lg);
}
.login h1 { margin: 0 0 8px; font-size: 24px; letter-spacing: -0.01em; }
.login input {
background: var(--bg-2); border: 1px solid var(--line); color: var(--fg);
padding: 13px 14px; border-radius: var(--radius-sm);
font-size: 15px; outline: none;
transition: border-color 120ms, box-shadow 120ms;
}
.login input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
.login button {
background: var(--accent); color: #1a0a00; font-weight: 700;
padding: 13px; border-radius: var(--radius-sm); font-size: 15px;
box-shadow: 0 6px 18px var(--accent-glow);
transition: background 120ms;
}
.login button:hover { background: #ff8a55; }
.login .err { color: var(--bad); font-size: 13px; min-height: 18px; }
/* === Add-station dialog === */
dialog.add-station {
border: 1px solid var(--line);
background: var(--bg-1); color: var(--fg);
border-radius: var(--radius-lg);
padding: 0; max-width: 520px; width: 90%;
box-shadow: var(--shadow-lg);
}
dialog.add-station::backdrop { background: rgba(7,8,11,0.65); backdrop-filter: blur(4px); }
dialog.add-station form {
padding: 24px; display: flex; flex-direction: column; gap: 12px;
}
dialog.add-station h2 { margin: 0 0 4px; font-size: 18px; letter-spacing: -0.01em; }
dialog.add-station label {
display: flex; flex-direction: column; gap: 4px;
font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em;
}
dialog.add-station input,
dialog.add-station select {
background: var(--bg-2); border: 1px solid var(--line); color: var(--fg);
padding: 9px 11px; border-radius: var(--radius-sm);
font-size: 14px; outline: none;
transition: border-color 120ms, box-shadow 120ms;
}
dialog.add-station input:focus,
dialog.add-station select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
dialog.add-station .row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
dialog.add-station .err { color: var(--bad); font-size: 12px; min-height: 14px; }
dialog.add-station .actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 4px; }
.btn-primary {
background: var(--accent); color: #1a0a00; font-weight: 700;
padding: 9px 16px; border-radius: var(--radius-sm); font-size: 14px;
transition: background 120ms;
}
.btn-primary:hover { background: #ff8a55; }
.btn-ghost {
background: transparent; color: var(--muted);
padding: 9px 16px; border-radius: var(--radius-sm); font-size: 14px;
border: 1px solid var(--line);
transition: color 120ms, background 120ms;
}
.btn-ghost:hover { color: var(--fg); background: var(--bg-2); }
/* === Context menu (API endpoints) === */
.ctx-menu {
position: fixed;
z-index: 100;
min-width: 360px; max-width: 460px;
background: var(--bg-1); color: var(--fg);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
padding: 10px;
display: flex; flex-direction: column; gap: 2px;
animation: ctxIn 100ms ease-out;
}
@keyframes ctxIn { from { opacity: 0; transform: translateY(-4px) scale(0.98); } to { opacity: 1; transform: none; } }
.ctx-title {
font-weight: 700; font-size: 13px; padding: 4px 8px 0;
letter-spacing: -0.005em;
}
.ctx-sub {
font-size: 10.5px; color: var(--muted-2);
padding: 0 8px 8px; border-bottom: 1px solid var(--line);
font-family: ui-monospace, "SF Mono", Menlo, monospace;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.ctx-row {
display: grid; grid-template-columns: 1fr auto auto;
gap: 6px; align-items: center;
padding: 6px 8px;
border-radius: 8px;
}
.ctx-row:hover { background: var(--bg-2); }
.ctx-row-text { min-width: 0; }
.ctx-label { font-size: 12px; color: var(--fg); font-weight: 500; }
.ctx-url {
font-size: 11px; color: var(--muted);
font-family: ui-monospace, "SF Mono", Menlo, monospace;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.ctx-btn {
width: 28px; height: 28px; border-radius: 6px;
background: var(--bg-2); color: var(--muted);
font-size: 13px;
display: flex; align-items: center; justify-content: center;
transition: background 100ms, color 100ms;
border: 1px solid var(--line);
}
.ctx-btn:hover { background: var(--bg-3); color: var(--fg); }
.ctx-empty { padding: 8px; color: var(--muted); font-size: 12px; }
.ctx-danger {
margin-top: 4px;
padding: 7px 10px; border-radius: 8px;
background: transparent; color: var(--bad);
border: 1px solid rgba(236,106,106,0.25);
font-size: 12px; font-weight: 600;
text-align: left;
}
.ctx-danger:hover { background: rgba(236,106,106,0.10); }
/* === Toast === */
.toast {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
background: var(--bg-1); padding: 10px 18px; border-radius: 999px;
font-size: 13px; color: var(--fg); z-index: 200;
border: 1px solid var(--line);
box-shadow: var(--shadow);
animation: toastIn 180ms ease-out;
}
@keyframes toastIn { from { opacity: 0; transform: translate(-50%, 8px); } to { opacity: 1; transform: translate(-50%, 0); } }
/* ============================================================
MINIMAL HIGH-CONTRAST THEME OVERRIDE
Flat surfaces, sharp 90deg corners, monochrome + single accent.
============================================================ */
:root {
--bg-0: #000000;
--bg-1: #0a0a0a;
--bg-2: #141414;
--bg-3: #1f1f1f;
--line: #2e2e2e;
--fg: #ffffff;
--muted: #a0a0a0;
--muted-2: #6a6a6a;
--accent: #ff5b00;
--accent-2: #ff5b00;
--accent-glow: transparent;
--good: #00d27a;
--bad: #ff3030;
--radius-sm: 0;
--radius: 0;
--radius-lg: 0;
--shadow-sm: none;
--shadow: none;
--shadow-lg: none;
}
html, body {
background: var(--bg-0) !important;
}
*, *::before, *::after { border-radius: 0 !important; }
button, input, select, textarea, dialog { border-radius: 0 !important; }
::-webkit-scrollbar-thumb { background: var(--bg-3) !important; }
::-webkit-scrollbar-thumb:hover { background: var(--line) !important; }
/* Flatten decorative gradients/glows */
.now {
background: var(--bg-1) !important;
box-shadow: none !important;
border-color: var(--line) !important;
}
.now::before { display: none !important; }
@keyframes pulse {
0%, 100% { border-color: var(--accent); }
50% { border-color: var(--line); }
}
.now .meta .name { text-transform: uppercase; letter-spacing: 0.01em; font-weight: 800; }
.tag {
background: var(--bg-2) !important;
color: var(--fg) !important;
border-color: var(--line) !important;
text-transform: uppercase; letter-spacing: 0.05em; font-weight: 700;
}
.btn-play, .btn-stop, .btn-add {
box-shadow: none !important;
border: 1px solid var(--line);
transition: background 80ms linear, color 80ms linear, transform 60ms linear !important;
}
.btn-play, .btn-add {
background: var(--accent) !important;
color: #000 !important;
border-color: var(--accent) !important;
}
.btn-play:hover, .btn-add:hover {
background: #fff !important;
color: #000 !important;
border-color: #fff !important;
}
.btn-stop { background: var(--bg-2) !important; color: var(--fg) !important; }
.btn-stop:not(:disabled):hover {
background: #fff !important; color: #000 !important; border-color: #fff !important;
}
.vol {
background: var(--bg-2) !important;
border-color: var(--line) !important;
}
.lib {
background: var(--bg-1) !important;
box-shadow: none !important;
border-color: var(--line) !important;
}
/* Tabs: connected high-contrast segmented control */
.tabs { gap: 0 !important; }
.tab {
border: 1px solid var(--line) !important;
margin-right: -1px !important;
background: transparent !important;
text-transform: uppercase; letter-spacing: 0.06em; font-weight: 700; font-size: 12px;
}
.tab:hover { background: var(--bg-2) !important; color: var(--fg) !important; }
.tab.active {
background: var(--accent) !important;
color: #000 !important;
border-color: var(--accent) !important;
position: relative; z-index: 1;
}
.search {
background: var(--bg-2) !important;
border-color: var(--line) !important;
}
.search:focus { border-color: var(--accent) !important; box-shadow: none !important; }
.chip {
background: var(--bg-2) !important;
border-color: var(--line) !important;
text-transform: uppercase; letter-spacing: 0.04em; font-weight: 700;
}
.chip:hover { color: var(--fg) !important; border-color: var(--fg) !important; }
.chip.active {
background: var(--accent) !important;
color: #000 !important;
border-color: var(--accent) !important;
}
/* Card list: tight, sharp, single-pixel grid */
.grid { gap: 0 !important; }
.card {
background: var(--bg-1) !important;
border: 1px solid var(--line) !important;
margin-bottom: -1px;
transition: background 60ms linear, border-color 60ms linear !important;
}
.card:hover {
background: var(--bg-2) !important;
border-color: var(--muted-2) !important;
z-index: 1;
}
.card:active { transform: none !important; }
.card.playing {
background: var(--bg-2) !important;
border-color: var(--accent) !important;
z-index: 2;
}
.card.playing::before {
left: 0 !important; top: 0 !important; bottom: 0 !important;
width: 4px !important; background: var(--accent) !important;
}
.card .art { box-shadow: none !important; }
.card .n { font-weight: 700; }
.card .g { text-transform: uppercase; letter-spacing: 0.03em; }
.card .fav:hover, .card .more:hover {
background: var(--bg-3) !important; color: var(--fg) !important;
border: 1px solid var(--line) !important;
}
.empty { text-transform: uppercase; letter-spacing: 0.06em; }
/* Login */
.login { background: #000 !important; }
.login form {
border: 1px solid #fff !important;
box-shadow: none !important;
}
.login h1 { text-transform: uppercase; letter-spacing: 0.04em; font-weight: 900; }
.login input { background: var(--bg-2) !important; border-color: var(--line) !important; }
.login input:focus { border-color: var(--accent) !important; box-shadow: none !important; }
.login button {
background: var(--accent) !important;
color: #000 !important;
border: 1px solid var(--accent) !important;
box-shadow: none !important;
text-transform: uppercase; letter-spacing: 0.08em; font-weight: 900;
}
.login button:hover { background: #fff !important; color: #000 !important; border-color: #fff !important; }
/* Dialogs */
dialog.add-station {
border: 1px solid #fff !important;
box-shadow: none !important;
}
dialog.add-station::backdrop {
background: rgba(0,0,0,0.85) !important;
backdrop-filter: none !important;
}
dialog.add-station h2 { text-transform: uppercase; letter-spacing: 0.04em; font-weight: 900; }
dialog.add-station label { letter-spacing: 0.08em; font-weight: 700; }
dialog.add-station input,
dialog.add-station select { background: var(--bg-2) !important; border-color: var(--line) !important; }
dialog.add-station input:focus,
dialog.add-station select:focus { border-color: var(--accent) !important; box-shadow: none !important; }
.btn-primary {
background: var(--accent) !important;
color: #000 !important;
border: 1px solid var(--accent) !important;
text-transform: uppercase; letter-spacing: 0.06em; font-weight: 900;
}
.btn-primary:hover { background: #fff !important; color: #000 !important; border-color: #fff !important; }
.btn-ghost {
background: transparent !important; border: 1px solid var(--line) !important;
text-transform: uppercase; letter-spacing: 0.06em; font-weight: 700;
}
.btn-ghost:hover { color: var(--fg) !important; background: var(--bg-2) !important; border-color: var(--fg) !important; }
/* Context menu */
.ctx-menu {
border: 1px solid #fff !important;
box-shadow: none !important;
}
.ctx-title { text-transform: uppercase; letter-spacing: 0.06em; font-weight: 900; font-size: 12px; }
.ctx-row { border: 1px solid transparent; }
.ctx-row:hover { background: var(--bg-2) !important; border-color: var(--line); }
.ctx-btn {
background: var(--bg-2) !important;
border: 1px solid var(--line) !important;
color: var(--muted);
}
.ctx-btn:hover { background: #fff !important; color: #000 !important; border-color: #fff !important; }
.ctx-danger {
background: transparent !important;
border: 1px solid var(--bad) !important;
color: var(--bad) !important;
text-transform: uppercase; letter-spacing: 0.06em; font-weight: 800;
}
.ctx-danger:hover { background: var(--bad) !important; color: #000 !important; }
/* Toast */
.toast {
background: #fff !important;
color: #000 !important;
border: 1px solid #fff !important;
box-shadow: none !important;
text-transform: uppercase; letter-spacing: 0.06em; font-weight: 800; font-size: 12px;
}