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:
13
web/admin/index.html
Normal file
13
web/admin/index.html
Normal 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
294
web/admin/main.js
Normal 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
210
web/admin/style.css
Normal 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
13
web/index.html
Normal 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
427
web/main.js
Normal 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
83
web/player.js
Normal 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
21
web/shared/api.js
Normal 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
17
web/shared/dom.js
Normal 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
22
web/shared/ws.js
Normal 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
658
web/style.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user