Add master display UI with audio output management and styling

- Implement main.js for the master display functionality, including WebSocket connection, audio output management, and state handling.
- Create style.css for the master display's visual design, ensuring a cohesive look and feel with a dark theme and responsive layout.
- Integrate device management with a fallback for non-Electron environments, allowing users to select audio outputs.
- Add features for managing favorites, including toggling favorites and filtering by genre.
- Enhance user experience with a responsive favorites grid and drag-to-scroll functionality.
This commit is contained in:
Marco Mooren
2026-05-11 17:55:09 +02:00
parent 86690c3753
commit b86dcfbb8d
40 changed files with 3943 additions and 274 deletions

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
node_modules/ node_modules/
dist/ dist/
data/db/ data/db/
data/images/
.env .env
.DS_Store .DS_Store
*.log *.log

View File

@@ -10,7 +10,8 @@
"dev:api": "node --watch server/index.js", "dev:api": "node --watch server/index.js",
"build": "vite build", "build": "vite build",
"start": "node server/index.js", "start": "node server/index.js",
"seed": "node server/scripts/seed.js" "seed": "node server/scripts/seed.js",
"images:fetch": "node server/scripts/download-images.js"
}, },
"dependencies": { "dependencies": {
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",

View File

@@ -34,6 +34,12 @@ function runMigrations(db) {
if (!stationCols.has('category')) { if (!stationCols.has('category')) {
db.exec('ALTER TABLE stations ADD COLUMN category TEXT'); db.exec('ALTER TABLE stations ADD COLUMN category TEXT');
} }
if (!stationCols.has('image_path')) {
db.exec('ALTER TABLE stations ADD COLUMN image_path TEXT');
}
if (!stationCols.has('image_source')) {
db.exec('ALTER TABLE stations ADD COLUMN image_source TEXT');
}
const streamCols = new Set(db.prepare("PRAGMA table_info(streams)").all().map((c) => c.name)); const streamCols = new Set(db.prepare("PRAGMA table_info(streams)").all().map((c) => c.name));
if (!streamCols.has('uuid')) { if (!streamCols.has('uuid')) {
db.exec('ALTER TABLE streams ADD COLUMN uuid TEXT'); db.exec('ALTER TABLE streams ADD COLUMN uuid TEXT');
@@ -55,5 +61,15 @@ function runMigrations(db) {
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_stations_uuid ON stations(uuid)'); db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_stations_uuid ON stations(uuid)');
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_streams_uuid ON streams(uuid)'); db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_streams_uuid ON streams(uuid)');
db.exec('CREATE INDEX IF NOT EXISTS idx_stations_category ON stations(category)'); db.exec('CREATE INDEX IF NOT EXISTS idx_stations_category ON stations(category)');
// station_plays gained session/listen-time aggregates so the leaderboard
// can rank by actual playtime, not just play-button taps.
const playCols = new Set(db.prepare("PRAGMA table_info(station_plays)").all().map((c) => c.name));
if (!playCols.has('sessions')) {
db.exec('ALTER TABLE station_plays ADD COLUMN sessions INTEGER NOT NULL DEFAULT 0');
}
if (!playCols.has('total_play_ms')) {
db.exec('ALTER TABLE station_plays ADD COLUMN total_play_ms INTEGER NOT NULL DEFAULT 0');
}
} }

View File

@@ -30,6 +30,8 @@ CREATE TABLE IF NOT EXISTS stations (
genres TEXT, -- JSON array genres TEXT, -- JSON array
description TEXT, description TEXT,
image_url TEXT, image_url TEXT,
image_path TEXT, -- relative path under data/images, e.g. "stations/12.jpg"
image_source TEXT, -- 'remote' | 'scraped' | 'upload'
source TEXT NOT NULL CHECK (source IN ('seed','radiobrowser','manual')), source TEXT NOT NULL CHECK (source IN ('seed','radiobrowser','manual')),
source_ref TEXT, source_ref TEXT,
category TEXT, category TEXT,
@@ -93,8 +95,43 @@ CREATE INDEX IF NOT EXISTS idx_votes_station ON station_votes(station_id);
-- Aggregate play counter. Cheaper than COUNT(*) over play_history every render -- Aggregate play counter. Cheaper than COUNT(*) over play_history every render
-- and lets anonymous/public listing show play counts without exposing history. -- and lets anonymous/public listing show play counts without exposing history.
-- `total_play_ms` and `sessions` accumulate from closed play_history rows so
-- the leaderboard can rank by actual listen time, not just play-button taps.
CREATE TABLE IF NOT EXISTS station_plays ( CREATE TABLE IF NOT EXISTS station_plays (
station_id INTEGER PRIMARY KEY REFERENCES stations(id) ON DELETE CASCADE, station_id INTEGER PRIMARY KEY REFERENCES stations(id) ON DELETE CASCADE,
plays INTEGER NOT NULL DEFAULT 0, plays INTEGER NOT NULL DEFAULT 0,
sessions INTEGER NOT NULL DEFAULT 0,
total_play_ms INTEGER NOT NULL DEFAULT 0,
last_played_at TEXT last_played_at TEXT
); );
-- Named listening rooms. One "display" client + many controller/panel clients
-- per room share state (now-playing, volume, votes). A personal room is
-- auto-provisioned per user so single-user kiosks Just Work.
CREATE TABLE IF NOT EXISTS rooms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS room_members (
room_id INTEGER NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('owner','member','guest')),
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (room_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_room_members_user ON room_members(user_id);
-- Last-known playback state per room. Persisted so clients reconnecting see
-- the same now-playing card immediately, even after a server restart.
CREATE TABLE IF NOT EXISTS room_state (
room_id INTEGER PRIMARY KEY REFERENCES rooms(id) ON DELETE CASCADE,
station_id INTEGER REFERENCES stations(id) ON DELETE SET NULL,
playing INTEGER NOT NULL DEFAULT 0,
volume REAL NOT NULL DEFAULT 0.7,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -10,12 +10,14 @@ import { authMiddleware, ensureBootstrapAdmin } from './auth.js';
import { applySeedIfEmpty } from './sources/seed.js'; import { applySeedIfEmpty } from './sources/seed.js';
import { scheduleHealthCheck } from './streams/checker.js'; import { scheduleHealthCheck } from './streams/checker.js';
import { attachWs } from './ws.js'; import { attachWs } from './ws.js';
import { ensureImageDirs, getImageRoot } from './media/images.js';
import { router as authRoutes } from './routes/auth.js'; import { router as authRoutes } from './routes/auth.js';
import { router as stationRoutes } from './routes/stations.js'; import { router as stationRoutes } from './routes/stations.js';
import { router as meRoutes } from './routes/me.js'; import { router as meRoutes } from './routes/me.js';
import { router as adminRoutes } from './routes/admin.js'; import { router as adminRoutes } from './routes/admin.js';
import { router as v1Routes } from './routes/v1.js'; import { router as v1Routes } from './routes/v1.js';
import { router as roomRoutes } from './routes/rooms.js';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const PORT = Number(process.env.PORT) || 4173; const PORT = Number(process.env.PORT) || 4173;
@@ -27,6 +29,7 @@ ensureBootstrapAdmin({
}); });
const seedResult = applySeedIfEmpty(); const seedResult = applySeedIfEmpty();
console.log('[seed]', seedResult); console.log('[seed]', seedResult);
ensureImageDirs();
const app = express(); const app = express();
app.use(express.json({ limit: '512kb' })); app.use(express.json({ limit: '512kb' }));
@@ -36,15 +39,40 @@ app.use('/api/auth', authRoutes);
app.use('/api/stations', stationRoutes); app.use('/api/stations', stationRoutes);
app.use('/api/me', meRoutes); app.use('/api/me', meRoutes);
app.use('/api/admin', adminRoutes); app.use('/api/admin', adminRoutes);
app.use('/api/rooms', roomRoutes);
app.use('/api/v1', v1Routes); app.use('/api/v1', v1Routes);
// Locally-cached cover art and other media live under data/images and are
// served unauthenticated on the LAN. Long cache OK — file names include the
// station id and we rewrite the file on update (browsers also revalidate).
app.use('/media', express.static(getImageRoot(), {
maxAge: '1h',
fallthrough: false,
setHeaders(res) { res.setHeader('Cache-Control', 'public, max-age=3600'); }
}));
// Static assets (built by Vite). In dev these don't exist; Vite serves them on :5173. // Static assets (built by Vite). In dev these don't exist; Vite serves them on :5173.
// HTML entry files must NOT be cached — they reference hashed JS/CSS that changes
// every build. Hashed assets under /assets/ can be cached aggressively.
const publicDir = resolve(__dirname, 'public'); const publicDir = resolve(__dirname, 'public');
const sendHtml = (file) => (_req, res) => {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.sendFile(resolve(publicDir, file));
};
if (existsSync(publicDir)) { if (existsSync(publicDir)) {
app.use(express.static(publicDir)); app.use(express.static(publicDir, {
app.get('/admin', (_req, res) => res.sendFile(resolve(publicDir, 'admin/index.html'))); setHeaders(res, filePath) {
app.get('/docs', (_req, res) => res.sendFile(resolve(publicDir, 'docs/index.html'))); if (filePath.endsWith('.html')) {
app.get('*', (_req, res) => res.sendFile(resolve(publicDir, 'index.html'))); res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
} else if (filePath.includes(`${publicDir}\\assets\\`) || filePath.includes(`${publicDir}/assets/`)) {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
}
}
}));
app.get('/admin', sendHtml('admin/index.html'));
app.get('/docs', sendHtml('docs/index.html'));
app.get('/master', sendHtml('master/index.html'));
app.get('*', sendHtml('index.html'));
} }
app.use((err, _req, res, _next) => { app.use((err, _req, res, _next) => {

168
server/media/images.js Normal file
View File

@@ -0,0 +1,168 @@
// Local cover-art storage for stations.
//
// Files live under <repo>/data/images/stations/<id>.<ext> and are served by
// express.static at /media/stations/<id>.<ext>. The DB tracks just the
// relative path in stations.image_path (e.g. "stations/12.jpg"), while
// stations.image_url keeps the original remote URL for refetch/debugging.
import { mkdirSync, existsSync, writeFileSync, readdirSync, unlinkSync, statSync } from 'node:fs';
import { resolve, join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { getDb } from '../db/index.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..', '..', 'data', 'images');
const STATIONS_DIR = join(ROOT, 'stations');
// A browser-like UA is required by Wikimedia and several CDNs; an opaque
// UA like "OnlineRadioExplorer/0.1" gets HTTP 400/403 from upload.wikimedia.org.
// Override via env if you want to publish a contact URL.
const UA = process.env.IMAGE_FETCH_UA
|| 'Mozilla/5.0 (compatible; OnlineRadioExplorer/0.1; +https://github.com/marcoheine/onlineRadioExplorer)';
const FETCH_TIMEOUT_MS = 10_000;
const MAX_BYTES = 4 * 1024 * 1024; // 4 MB per image
const MIME_EXT = {
'image/jpeg': 'jpg',
'image/jpg': 'jpg',
'image/pjpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
'image/gif': 'gif',
'image/svg+xml': 'svg',
'image/x-icon': 'ico',
'image/vnd.microsoft.icon': 'ico'
};
export function ensureImageDirs() {
mkdirSync(STATIONS_DIR, { recursive: true });
}
export function getImageRoot() { return ROOT; }
// Strip any existing file for this station id (with any extension).
function removeExistingStationFile(id) {
if (!existsSync(STATIONS_DIR)) return;
const prefix = `${id}.`;
for (const f of readdirSync(STATIONS_DIR)) {
if (f.startsWith(prefix)) {
try { unlinkSync(join(STATIONS_DIR, f)); } catch { }
}
}
}
function extFromMime(mime) {
if (!mime) return null;
const base = mime.split(';')[0].trim().toLowerCase();
return MIME_EXT[base] || null;
}
function extFromMagic(buf) {
if (buf.length < 12) return null;
// PNG
if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4E && buf[3] === 0x47) return 'png';
// JPEG
if (buf[0] === 0xFF && buf[1] === 0xD8 && buf[2] === 0xFF) return 'jpg';
// GIF
if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) return 'gif';
// WEBP: "RIFF...WEBP"
if (buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46
&& buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50) return 'webp';
// SVG: starts with "<?xml" or "<svg"
const head = buf.slice(0, 256).toString('utf8').trimStart().toLowerCase();
if (head.startsWith('<?xml') || head.startsWith('<svg')) return 'svg';
// ICO
if (buf[0] === 0x00 && buf[1] === 0x00 && buf[2] === 0x01 && buf[3] === 0x00) return 'ico';
return null;
}
async function downloadToBuffer(url) {
const ctl = new AbortController();
const t = setTimeout(() => ctl.abort(), FETCH_TIMEOUT_MS);
try {
const res = await fetch(url, {
headers: { 'User-Agent': UA, 'Accept': 'image/*' },
redirect: 'follow',
signal: ctl.signal
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const ct = res.headers.get('content-type') || '';
const reader = res.body?.getReader();
if (!reader) throw new Error('no body');
const chunks = [];
let received = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
received += value.length;
if (received > MAX_BYTES) { try { await reader.cancel(); } catch { } throw new Error('too large'); }
chunks.push(Buffer.from(value));
}
return { buffer: Buffer.concat(chunks), contentType: ct };
} finally { clearTimeout(t); }
}
/**
* Persist a buffer as the station's cover-art file and update the DB.
* Returns the relative path (e.g. "stations/12.jpg").
*/
export function saveStationImageFromBuffer(stationId, buf, mime, { source = 'upload' } = {}) {
ensureImageDirs();
// Reject obvious HTML responses (404 pages, SPA index, login walls) even
// when the upstream lies about the content-type.
const head = buf.slice(0, 512).toString('utf8').trimStart().toLowerCase();
if (head.startsWith('<!doctype html') || head.startsWith('<html')) {
throw new Error('response is HTML, not an image');
}
const ext = extFromMime(mime) || extFromMagic(buf);
if (!ext) throw new Error('unsupported image type');
removeExistingStationFile(stationId);
const fileName = `${stationId}.${ext}`;
writeFileSync(join(STATIONS_DIR, fileName), buf);
const rel = `stations/${fileName}`;
getDb().prepare(
"UPDATE stations SET image_path = ?, image_source = ?, updated_at = datetime('now') WHERE id = ?"
).run(rel, source, stationId);
return rel;
}
/**
* Download an image from a URL and store it locally for the station.
* Returns the relative path or null on failure.
*/
export async function saveStationImageFromUrl(stationId, url, { source = 'remote' } = {}) {
if (!url) return null;
let dl;
try {
dl = await downloadToBuffer(url);
} catch {
return null;
}
try {
return saveStationImageFromBuffer(stationId, dl.buffer, dl.contentType, { source });
} catch {
return null;
}
}
export function deleteStationImage(stationId) {
removeExistingStationFile(stationId);
getDb().prepare(
"UPDATE stations SET image_path = NULL, image_source = NULL, updated_at = datetime('now') WHERE id = ?"
).run(stationId);
}
/** Public URL the kiosk should use. Local if cached, else the remote URL, else null. */
export function publicImageUrl({ image_path, image_url } = {}) {
if (image_path) return `/media/${image_path}`;
return image_url || null;
}
/** Total bytes used by the station-image cache (best effort). */
export function imageCacheStats() {
if (!existsSync(STATIONS_DIR)) return { files: 0, bytes: 0 };
let files = 0, bytes = 0;
for (const f of readdirSync(STATIONS_DIR)) {
try { bytes += statSync(join(STATIONS_DIR, f)).size; files++; } catch { }
}
return { files, bytes };
}

View File

@@ -5,10 +5,10 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Radio Admin</title> <title>Radio Admin</title>
<script type="module" crossorigin src="/assets/admin-GqZPhz-K.js"></script> <script type="module" crossorigin src="/assets/admin-GqZPhz-K.js"></script>
<link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-B5Qt9EMX.js"> <link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-B5Qt9EMX.js">
<link rel="modulepreload" crossorigin href="/assets/dom-BvorgAdo.js"> <link rel="modulepreload" crossorigin href="/assets/player-BBOsFRH-.js">
<link rel="stylesheet" crossorigin href="/assets/admin-C-qnWY0z.css"> <link rel="stylesheet" crossorigin href="/assets/admin-C-qnWY0z.css">
</head> </head>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
async function a(t,i,o){const s=await fetch(i,{method:t,credentials:"same-origin",headers:o?{"Content-Type":"application/json"}:{},body:o?JSON.stringify(o):void 0});if(s.status===204)return null;const e=(s.headers.get("content-type")||"").includes("json")?await s.json():await s.text();if(!s.ok)throw Object.assign(new Error((e==null?void 0:e.error)||s.statusText),{status:s.status,data:e});return e}const c={get:t=>a("GET",t),post:(t,i)=>a("POST",t,i),put:(t,i)=>a("PUT",t,i),patch:(t,i)=>a("PATCH",t,i),del:t=>a("DELETE",t)};function r(t,i={},...o){const s=document.createElement(t);for(const[n,e]of Object.entries(i||{}))n==="class"?s.className=e:n==="style"&&typeof e=="object"?Object.assign(s.style,e):n.startsWith("on")&&typeof e=="function"?s.addEventListener(n.slice(2).toLowerCase(),e):n==="html"?s.innerHTML=e:e!==!1&&e!=null&&s.setAttribute(n,e===!0?"":e);for(const n of o.flat())n==null||n===!1||s.appendChild(n instanceof Node?n:document.createTextNode(String(n)));return s}function l(t){for(;t.firstChild;)t.removeChild(t.firstChild)}export{c as a,l as c,r as e};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
function l(s,n={}){let e,r=0,c=!1;function i(){const a=location.protocol==="https:"?"wss":"ws",o=new URLSearchParams;n.room&&o.set("room",n.room),n.kind&&o.set("kind",n.kind);const d=o.toString();e=new WebSocket(`${a}://${location.host}/ws${d?"?"+d:""}`),e.addEventListener("open",()=>{var t;r=0,(t=n.onOpen)==null||t.call(n)}),e.addEventListener("message",t=>{try{s(JSON.parse(t.data))}catch{}}),e.addEventListener("close",()=>{var t;(t=n.onClose)==null||t.call(n),!c&&(r=Math.min(r+1,6),setTimeout(i,500*2**r))}),e.addEventListener("error",()=>e.close())}return i(),{send(a){(e==null?void 0:e.readyState)===WebSocket.OPEN&&e.send(JSON.stringify(a))},close(){c=!0,e==null||e.close()},get readyState(){return e==null?void 0:e.readyState}}}export{l as c};

View File

@@ -5,10 +5,11 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=1080, initial-scale=1, maximum-scale=1, user-scalable=no" /> <meta name="viewport" content="width=1080, initial-scale=1, maximum-scale=1, user-scalable=no" />
<title>Radio Kiosk</title> <title>Radio Kiosk</title>
<script type="module" crossorigin src="/assets/kiosk-CzWLja7k.js"></script> <script type="module" crossorigin src="/assets/kiosk-CzWLja7k.js"></script>
<link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-B5Qt9EMX.js"> <link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-B5Qt9EMX.js">
<link rel="modulepreload" crossorigin href="/assets/dom-BvorgAdo.js"> <link rel="modulepreload" crossorigin href="/assets/player-BBOsFRH-.js">
<link rel="modulepreload" crossorigin href="/assets/ws-BM1PmMVd.js">
<link rel="stylesheet" crossorigin href="/assets/kiosk-PzkUrLf6.css"> <link rel="stylesheet" crossorigin href="/assets/kiosk-PzkUrLf6.css">
</head> </head>

View File

@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Radio Master</title>
<script type="module" crossorigin src="/assets/master-kSyrThjc.js"></script>
<link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-B5Qt9EMX.js">
<link rel="modulepreload" crossorigin href="/assets/player-BBOsFRH-.js">
<link rel="modulepreload" crossorigin href="/assets/ws-BM1PmMVd.js">
<link rel="stylesheet" crossorigin href="/assets/master-CpJfsvtJ.css">
</head>
<body>
<div id="app"></div>
</body>

142
server/rooms.js Normal file
View File

@@ -0,0 +1,142 @@
// Room model: named multi-client listening sessions.
//
// Each user has an auto-provisioned "personal" room (slug = `u-<id>`) so the
// kiosk Just Works on first login. Shared rooms are explicit creations and
// have a member list.
import { getDb } from './db/index.js';
import { slugify } from './stations.js';
function rowToRoom(row) {
if (!row) return null;
return {
id: row.id,
slug: row.slug,
name: row.name,
created_by: row.created_by,
created_at: row.created_at
};
}
export function getRoomBySlug(slug) {
return rowToRoom(getDb().prepare('SELECT * FROM rooms WHERE slug = ?').get(slug));
}
export function getRoomById(id) {
return rowToRoom(getDb().prepare('SELECT * FROM rooms WHERE id = ?').get(id));
}
export function isMember(roomId, userId) {
if (!roomId || !userId) return false;
const row = getDb().prepare(
'SELECT 1 FROM room_members WHERE room_id = ? AND user_id = ?'
).get(roomId, userId);
return !!row;
}
export function listRoomsForUser(userId) {
return getDb().prepare(`
SELECT r.*, rm.role
FROM rooms r
JOIN room_members rm ON rm.room_id = r.id
WHERE rm.user_id = ?
ORDER BY r.name COLLATE NOCASE
`).all(userId).map((row) => ({ ...rowToRoom(row), role: row.role }));
}
export function listMembers(roomId) {
return getDb().prepare(`
SELECT u.id, u.username, rm.role, rm.created_at
FROM room_members rm JOIN users u ON u.id = rm.user_id
WHERE rm.room_id = ?
ORDER BY u.username COLLATE NOCASE
`).all(roomId);
}
function uniqueRoomSlug(base) {
const db = getDb();
let slug = base, n = 1;
while (db.prepare('SELECT 1 FROM rooms WHERE slug = ?').get(slug)) {
n += 1;
slug = `${base}-${n}`;
}
return slug;
}
export function createRoom({ name, slug, ownerId }) {
if (!name) throw new Error('name required');
const db = getDb();
const baseSlug = slug || slugify(name);
const finalSlug = uniqueRoomSlug(baseSlug);
const info = db.prepare(
'INSERT INTO rooms (slug, name, created_by) VALUES (?, ?, ?)'
).run(finalSlug, name, ownerId ?? null);
const id = info.lastInsertRowid;
if (ownerId) {
db.prepare(
"INSERT OR IGNORE INTO room_members (room_id, user_id, role) VALUES (?, ?, 'owner')"
).run(id, ownerId);
}
db.prepare(
'INSERT OR IGNORE INTO room_state (room_id, playing, volume) VALUES (?, 0, 0.7)'
).run(id);
return getRoomById(id);
}
export function addMember(roomId, userId, role = 'member') {
getDb().prepare(
'INSERT OR IGNORE INTO room_members (room_id, user_id, role) VALUES (?, ?, ?)'
).run(roomId, userId, role);
}
export function removeMember(roomId, userId) {
return getDb().prepare(
'DELETE FROM room_members WHERE room_id = ? AND user_id = ?'
).run(roomId, userId).changes > 0;
}
/** Idempotently ensure a personal room exists for the user, returning it. */
export function ensurePersonalRoom(user) {
if (!user) return null;
const slug = `u-${user.id}`;
const existing = getRoomBySlug(slug);
if (existing) return existing;
const db = getDb();
db.prepare(
'INSERT INTO rooms (slug, name, created_by) VALUES (?, ?, ?)'
).run(slug, `${user.username}'s room`, user.id);
const room = getRoomBySlug(slug);
db.prepare(
"INSERT OR IGNORE INTO room_members (room_id, user_id, role) VALUES (?, ?, 'owner')"
).run(room.id, user.id);
db.prepare(
'INSERT OR IGNORE INTO room_state (room_id, playing, volume) VALUES (?, 0, 0.7)'
).run(room.id);
return room;
}
export function getRoomState(roomId) {
const row = getDb().prepare('SELECT * FROM room_state WHERE room_id = ?').get(roomId);
if (!row) return { station_id: null, playing: false, volume: 0.7, updated_at: null };
return {
station_id: row.station_id,
playing: !!row.playing,
volume: row.volume,
updated_at: row.updated_at
};
}
export function setRoomState(roomId, patch) {
const cur = getRoomState(roomId);
const next = { ...cur, ...patch };
getDb().prepare(`
INSERT INTO room_state (room_id, station_id, playing, volume, updated_at)
VALUES (?, ?, ?, ?, datetime('now'))
ON CONFLICT(room_id) DO UPDATE SET
station_id = excluded.station_id,
playing = excluded.playing,
volume = excluded.volume,
updated_at = excluded.updated_at
`).run(roomId, next.station_id ?? null, next.playing ? 1 : 0, next.volume ?? 0.7);
return getRoomState(roomId);
}

View File

@@ -1,14 +1,29 @@
import { Router } from 'express'; import { Router } from 'express';
import express from 'express';
import { requireAdmin } from '../auth.js'; import { requireAdmin } from '../auth.js';
import { runHealthCheck } from '../streams/checker.js'; import { runHealthCheck } from '../streams/checker.js';
import { probeStream } from '../streams/probe.js';
import { applySeedIfEmpty } from '../sources/seed.js'; import { applySeedIfEmpty } from '../sources/seed.js';
import { getDb } from '../db/index.js'; import { getDb } from '../db/index.js';
import { scrapeIcon } from '../sources/iconScraper.js'; import { scrapeIcon } from '../sources/iconScraper.js';
import { listStations, getStation, updateStation } from '../stations.js'; import {
listStations, getStation, updateStation, deleteStation,
getStreamsForStation, addStream, deleteStream
} from '../stations.js';
import {
saveStationImageFromUrl, saveStationImageFromBuffer,
deleteStationImage, imageCacheStats
} from '../media/images.js';
import { broadcastGlobal } from '../ws.js';
export const router = Router(); export const router = Router();
router.use(requireAdmin); router.use(requireAdmin);
// Raw body parser used only by the image upload route. The global JSON
// parser is mounted before us so we have to opt-out for `image/*`.
const rawImageBody = express.raw({ type: ['image/*', 'application/octet-stream'], limit: '5mb' });
router.post('/health-check', async (_req, res) => { router.post('/health-check', async (_req, res) => {
const n = await runHealthCheck(); const n = await runHealthCheck();
res.json({ checked: n }); res.json({ checked: n });
@@ -20,25 +35,31 @@ router.post('/reseed', (_req, res) => {
router.get('/system', (_req, res) => { router.get('/system', (_req, res) => {
const db = getDb(); const db = getDb();
const img = imageCacheStats();
res.json({ res.json({
stations: db.prepare('SELECT COUNT(*) AS n FROM stations').get().n, stations: db.prepare('SELECT COUNT(*) AS n FROM stations').get().n,
streams: db.prepare('SELECT COUNT(*) AS n FROM streams').get().n, streams: db.prepare('SELECT COUNT(*) AS n FROM streams').get().n,
users: db.prepare('SELECT COUNT(*) AS n FROM users').get().n, users: db.prepare('SELECT COUNT(*) AS n FROM users').get().n,
favorites: db.prepare('SELECT COUNT(*) AS n FROM favorites').get().n, favorites: db.prepare('SELECT COUNT(*) AS n FROM favorites').get().n,
image_cache: img,
node: process.version, node: process.version,
uptime_s: Math.round(process.uptime()) uptime_s: Math.round(process.uptime())
}); });
}); });
// Scrape an icon for a single station. // Scrape an icon for a single station and cache it locally.
router.post('/stations/:id/scrape-icon', async (req, res) => { router.post('/stations/:id/scrape-icon', async (req, res) => {
const id = Number(req.params.id); const id = Number(req.params.id);
const st = getStation(id); const st = getStation(id);
if (!st) return res.status(404).json({ error: 'not found' }); if (!st) return res.status(404).json({ error: 'not found' });
const url = await scrapeIcon(st); const url = await scrapeIcon(st);
if (!url) return res.status(404).json({ error: 'no icon found' }); if (!url) return res.status(404).json({ error: 'no icon found' });
const updated = updateStation(id, { image_url: url }); // Persist the remote URL as the canonical source...
res.json({ id, image_url: url, station: updated }); updateStation(id, { image_url: url });
// ...and try to cache it locally. Failure to cache is non-fatal.
const rel = await saveStationImageFromUrl(id, url, { source: 'scraped' });
const station = getStation(id);
res.json({ id, image_url: url, image_path: rel, station });
}); });
// Bulk: scrape icons for every station (optionally only those missing one). // Bulk: scrape icons for every station (optionally only those missing one).
@@ -56,8 +77,9 @@ router.post('/scrape-icons', async (req, res) => {
const url = await scrapeIcon(s); const url = await scrapeIcon(s);
if (url) { if (url) {
updateStation(s.id, { image_url: url }); updateStation(s.id, { image_url: url });
const rel = await saveStationImageFromUrl(s.id, url, { source: 'scraped' });
results.updated++; results.updated++;
results.items.push({ id: s.id, name: s.name, image_url: url }); results.items.push({ id: s.id, name: s.name, image_url: url, image_path: rel });
} else { } else {
results.failed++; results.failed++;
results.items.push({ id: s.id, name: s.name, image_url: null }); results.items.push({ id: s.id, name: s.name, image_url: null });
@@ -71,3 +93,213 @@ router.post('/scrape-icons', async (req, res) => {
await Promise.all(Array.from({ length: concurrency }, worker)); await Promise.all(Array.from({ length: concurrency }, worker));
res.json(results); res.json(results);
}); });
// ---------- Station edit (admin can override DB fields) ----------
// Plain PATCH /api/stations/:id already exists for admins. We add a sibling
// here so the admin UI can hit /api/admin/stations/:id consistently.
router.patch('/stations/:id', (req, res) => {
const id = Number(req.params.id);
const st = updateStation(id, req.body || {});
if (!st) return res.status(404).json({ error: 'not found' });
broadcastGlobal({ type: 'station-updated', stationId: id });
res.json(st);
});
router.delete('/stations/:id', (req, res) => {
const id = Number(req.params.id);
if (!deleteStation(id)) return res.status(404).json({ error: 'not found' });
deleteStationImage(id);
broadcastGlobal({ type: 'station-deleted', stationId: id });
res.json({ ok: true });
});
// ---------- Image management ----------
// Raw upload: PUT /api/admin/stations/:id/image (Content-Type: image/*)
router.put('/stations/:id/image', rawImageBody, (req, res) => {
const id = Number(req.params.id);
if (!getStation(id)) return res.status(404).json({ error: 'not found' });
const buf = req.body;
if (!Buffer.isBuffer(buf) || !buf.length) return res.status(400).json({ error: 'no body' });
const mime = req.get('content-type') || 'application/octet-stream';
try {
const rel = saveStationImageFromBuffer(id, buf, mime, { source: 'upload' });
broadcastGlobal({ type: 'station-updated', stationId: id });
res.json({ id, image_path: rel, station: getStation(id) });
} catch (err) {
res.status(400).json({ error: String(err.message || err) });
}
});
// Re-download the current remote image_url into the local cache.
router.post('/stations/:id/image/refetch', async (req, res) => {
const id = Number(req.params.id);
const st = getStation(id);
if (!st) return res.status(404).json({ error: 'not found' });
const target = (req.body && req.body.url) || st.image_url;
if (!target) return res.status(400).json({ error: 'no image_url to refetch' });
if (req.body && req.body.url) updateStation(id, { image_url: target });
const rel = await saveStationImageFromUrl(id, target, { source: 'remote' });
if (!rel) return res.status(502).json({ error: 'download failed' });
broadcastGlobal({ type: 'station-updated', stationId: id });
res.json({ id, image_path: rel, station: getStation(id) });
});
// Drop the local cache entry (keeps remote image_url).
router.delete('/stations/:id/image', (req, res) => {
const id = Number(req.params.id);
if (!getStation(id)) return res.status(404).json({ error: 'not found' });
deleteStationImage(id);
broadcastGlobal({ type: 'station-updated', stationId: id });
res.json({ ok: true, station: getStation(id) });
});
// ---------- Streams CRUD ----------
router.get('/stations/:id/streams', (req, res) => {
const id = Number(req.params.id);
if (!getStation(id)) return res.status(404).json({ error: 'not found' });
res.json(getStreamsForStation(id));
});
router.post('/stations/:id/streams', (req, res) => {
const id = Number(req.params.id);
if (!getStation(id)) return res.status(404).json({ error: 'not found' });
res.status(201).json(addStream(id, req.body || {}));
});
router.patch('/streams/:streamId', (req, res) => {
const sid = Number(req.params.streamId);
const db = getDb();
const cur = db.prepare('SELECT * FROM streams WHERE id = ?').get(sid);
if (!cur) return res.status(404).json({ error: 'not found' });
const next = { ...cur, ...(req.body || {}) };
db.prepare(`UPDATE streams SET url = ?, format = ?, bitrate = ?, label = ?, priority = ? WHERE id = ?`)
.run(next.url, next.format, next.bitrate || null, next.label || null, next.priority || 0, sid);
res.json(db.prepare('SELECT * FROM streams WHERE id = ?').get(sid));
});
router.delete('/streams/:streamId', (req, res) => {
if (!deleteStream(Number(req.params.streamId))) return res.status(404).json({ error: 'not found' });
res.json({ ok: true });
});
// Probe a single stream on demand (admin UI uses this for a "test" button).
router.post('/streams/:streamId/probe', async (req, res) => {
const sid = Number(req.params.streamId);
const row = getDb().prepare('SELECT * FROM streams WHERE id = ?').get(sid);
if (!row) return res.status(404).json({ error: 'not found' });
const status = await probeStream(row.url);
getDb().prepare(`UPDATE streams SET last_status = ?, last_checked_at = datetime('now') WHERE id = ?`).run(status, sid);
res.json({ id: sid, status });
});
// ---------- Bulk ops ----------
router.post('/stations/bulk', async (req, res) => {
const ids = Array.isArray(req.body?.ids) ? req.body.ids.map(Number).filter(Number.isFinite) : [];
const action = String(req.body?.action || '');
if (!ids.length) return res.status(400).json({ error: 'ids required' });
const results = { action, count: ids.length, ok: 0, failed: 0, items: [] };
for (const id of ids) {
try {
switch (action) {
case 'delete':
if (deleteStation(id)) { deleteStationImage(id); results.ok++; }
else results.failed++;
break;
case 'enable':
case 'disable':
updateStation(id, { enabled: action === 'enable' });
results.ok++;
break;
case 'scrape-icon': {
const st = getStation(id);
if (!st) { results.failed++; break; }
const url = await scrapeIcon(st);
if (url) {
updateStation(id, { image_url: url });
await saveStationImageFromUrl(id, url, { source: 'scraped' });
results.ok++;
} else results.failed++;
break;
}
case 'refetch-image': {
const st = getStation(id);
if (!st?.image_url) { results.failed++; break; }
const rel = await saveStationImageFromUrl(id, st.image_url, { source: 'remote' });
if (rel) results.ok++; else results.failed++;
break;
}
default:
return res.status(400).json({ error: 'unknown action' });
}
results.items.push({ id, ok: true });
} catch (err) {
results.failed++;
results.items.push({ id, error: String(err.message || err) });
}
}
broadcastGlobal({ type: 'bulk-completed', action });
res.json(results);
});
// ---------- Moderation ----------
router.delete('/stations/:id/votes', (req, res) => {
const id = Number(req.params.id);
if (!getStation(id)) return res.status(404).json({ error: 'not found' });
const n = getDb().prepare('DELETE FROM station_votes WHERE station_id = ?').run(id).changes;
broadcastGlobal({ type: 'vote', stationId: id, stats: { up: 0, down: 0, score: 0 }, by: 'admin' });
res.json({ ok: true, removed: n });
});
router.delete('/stations/:id/plays', (req, res) => {
const id = Number(req.params.id);
if (!getStation(id)) return res.status(404).json({ error: 'not found' });
const n = getDb().prepare('DELETE FROM station_plays WHERE station_id = ?').run(id).changes;
broadcastGlobal({ type: 'plays', stationId: id, plays: 0 });
res.json({ ok: true, removed: n });
});
router.get('/leaderboard', (req, res) => {
const db = getDb();
const top = db.prepare(`
SELECT s.id, s.uuid, s.name, s.country, s.image_path, s.image_url,
COALESCE((SELECT COUNT(*) FROM station_votes v WHERE v.station_id = s.id AND v.value = 1), 0) AS up,
COALESCE((SELECT COUNT(*) FROM station_votes v WHERE v.station_id = s.id AND v.value = -1), 0) AS down,
COALESCE(p.plays, 0) AS plays,
COALESCE(p.sessions, 0) AS sessions,
COALESCE(p.total_play_ms, 0) AS total_play_ms,
p.last_played_at AS last_played_at
FROM stations s
LEFT JOIN station_plays p ON p.station_id = s.id
ORDER BY total_play_ms DESC, plays DESC, up DESC
LIMIT 50
`).all();
for (const r of top) {
r.avg_session_ms = r.sessions > 0 ? Math.round(r.total_play_ms / r.sessions) : 0;
// Mirror what listStations() does so admin UIs can use a single field.
r.image_display_url = r.image_path ? `/media/${r.image_path}` : (r.image_url || null);
}
res.json(top);
});
// ---------- Room admin ----------
router.get('/rooms', (_req, res) => {
const db = getDb();
const rows = db.prepare(`
SELECT r.id, r.slug, r.name, r.created_by, r.created_at,
(SELECT COUNT(*) FROM room_members m WHERE m.room_id = r.id) AS members,
(SELECT COUNT(*) FROM room_state rs WHERE rs.room_id = r.id AND rs.station_id IS NOT NULL) AS active
FROM rooms r
ORDER BY r.created_at DESC
`).all();
res.json(rows);
});
router.delete('/rooms/:slug', (req, res) => {
const db = getDb();
const row = db.prepare('SELECT id, slug FROM rooms WHERE slug = ?').get(req.params.slug);
if (!row) return res.status(404).json({ error: 'not found' });
if (row.slug.startsWith('u-')) return res.status(400).json({ error: 'cannot delete personal rooms' });
db.prepare('DELETE FROM rooms WHERE id = ?').run(row.id);
res.json({ ok: true });
});

61
server/routes/rooms.js Normal file
View File

@@ -0,0 +1,61 @@
// /api/rooms — list/create rooms, manage members, fetch state.
import { Router } from 'express';
import { requireUser } from '../auth.js';
import {
listRoomsForUser, getRoomBySlug, createRoom,
addMember, removeMember, listMembers, isMember, getRoomState,
ensurePersonalRoom
} from '../rooms.js';
import { getStation } from '../stations.js';
export const router = Router();
router.use(requireUser);
router.get('/', (req, res) => {
// Guarantee a personal room exists for every authenticated user.
ensurePersonalRoom(req.user);
res.json(listRoomsForUser(req.user.id));
});
router.post('/', (req, res) => {
const { name, slug } = req.body || {};
if (!name || typeof name !== 'string') return res.status(400).json({ error: 'name required' });
const room = createRoom({ name: name.trim(), slug: slug?.trim() || undefined, ownerId: req.user.id });
res.status(201).json(room);
});
router.get('/:slug', (req, res) => {
const room = getRoomBySlug(req.params.slug);
if (!room) return res.status(404).json({ error: 'not found' });
if (!isMember(room.id, req.user.id)) return res.status(403).json({ error: 'not a member' });
const state = getRoomState(room.id);
const station = state.station_id ? getStation(state.station_id) : null;
res.json({ ...room, state: { ...state, station } });
});
router.get('/:slug/members', (req, res) => {
const room = getRoomBySlug(req.params.slug);
if (!room) return res.status(404).json({ error: 'not found' });
if (!isMember(room.id, req.user.id)) return res.status(403).json({ error: 'not a member' });
res.json(listMembers(room.id));
});
router.post('/:slug/members', (req, res) => {
const room = getRoomBySlug(req.params.slug);
if (!room) return res.status(404).json({ error: 'not found' });
if (!isMember(room.id, req.user.id)) return res.status(403).json({ error: 'not a member' });
const userId = Number(req.body?.user_id);
if (!userId) return res.status(400).json({ error: 'user_id required' });
const role = req.body?.role === 'guest' ? 'guest' : 'member';
addMember(room.id, userId, role);
res.json(listMembers(room.id));
});
router.delete('/:slug/members/:userId', (req, res) => {
const room = getRoomBySlug(req.params.slug);
if (!room) return res.status(404).json({ error: 'not found' });
if (!isMember(room.id, req.user.id)) return res.status(403).json({ error: 'not a member' });
removeMember(room.id, Number(req.params.userId));
res.json(listMembers(room.id));
});

View File

@@ -6,7 +6,8 @@ import {
import { resolveStream } from '../streams/resolver.js'; import { resolveStream } from '../streams/resolver.js';
import { requireAdmin, requireUser } from '../auth.js'; import { requireAdmin, requireUser } from '../auth.js';
import * as radiobrowser from '../sources/radiobrowser.js'; import * as radiobrowser from '../sources/radiobrowser.js';
import { castVote, getStationStats, getStatsMap, recordPlay, sortByMode } from '../stats.js'; import { castVote, getStationStats, getStatsMap, recordPlay, endPlaySession, sortByMode } from '../stats.js';
import { broadcastGlobal } from '../ws.js';
export const router = Router(); export const router = Router();
@@ -51,15 +52,41 @@ router.post('/:id/vote', requireUser, (req, res) => {
: raw === 0 || raw === '0' || raw === null || raw === 'clear' ? 0 : raw === 0 || raw === '0' || raw === null || raw === 'clear' ? 0
: NaN; : NaN;
if (Number.isNaN(value)) return res.status(400).json({ error: 'value must be 1, -1 or 0' }); if (Number.isNaN(value)) return res.status(400).json({ error: 'value must be 1, -1 or 0' });
res.json(castVote(req.user.id, id, value)); const stats = castVote(req.user.id, id, value);
// Tell every open client so other panels' vote counts update live.
broadcastGlobal({ type: 'vote', stationId: id, stats: { up: stats.up, down: stats.down, score: stats.score }, by: req.user.username });
res.json(stats);
}); });
// Lightweight play-count ping (called when the kiosk actually starts a station). // Lightweight play-count ping (called when the kiosk actually starts a station).
// Opens a listening session in play_history; the returned `sessionId` should be
// echoed back to POST /api/stations/:id/play/end so we can credit total listen
// time toward the leaderboard score.
router.post('/:id/play', requireUser, (req, res) => { router.post('/:id/play', requireUser, (req, res) => {
const id = Number(req.params.id); const id = Number(req.params.id);
if (!getStation(id)) return res.status(404).json({ error: 'not found' }); if (!getStation(id)) return res.status(404).json({ error: 'not found' });
recordPlay(id); const streamId = Number.isFinite(Number(req.body?.streamId)) ? Number(req.body.streamId) : null;
res.json(getStationStats(id, req.user.id)); const sessionId = recordPlay(id, req.user.id, streamId);
const stats = getStationStats(id, req.user.id);
broadcastGlobal({ type: 'plays', stationId: id, plays: stats.plays });
res.json({ ...stats, sessionId });
});
// Close a session opened by POST /:id/play. Idempotent — calling twice or with
// an unknown id silently no-ops. Accepts an optional `duration_ms` so a client
// that knows the real listened time (e.g. minus buffering stalls) can be honest.
router.post('/:id/play/end', requireUser, (req, res) => {
const id = Number(req.params.id);
if (!getStation(id)) return res.status(404).json({ error: 'not found' });
const sessionId = Number(req.body?.sessionId);
if (!Number.isFinite(sessionId)) return res.status(400).json({ error: 'sessionId required' });
const rawMs = req.body?.duration_ms;
const ms = rawMs == null ? null : Number(rawMs);
const closed = endPlaySession(sessionId, req.user.id, ms);
if (closed == null) return res.json({ ok: false });
const stats = getStationStats(closed, req.user.id);
broadcastGlobal({ type: 'plays', stationId: closed, plays: stats.plays });
res.json({ ok: true, ...stats });
}); });
router.post('/:id/resolve', requireUser, async (req, res) => { router.post('/:id/resolve', requireUser, async (req, res) => {

View File

@@ -50,7 +50,9 @@ function publicStation(s) {
country: s.country, country: s.country,
genres: s.genres, genres: s.genres,
description: s.description, description: s.description,
image_url: s.image_url, // Prefer the locally-cached file when available so public API consumers
// get a stable, fast URL on this host instead of upstream link rot.
image_url: s.image_display_url || s.image_url,
category: s.category, category: s.category,
enabled: s.enabled, enabled: s.enabled,
up: s.up ?? 0, up: s.up ?? 0,

View File

@@ -0,0 +1,65 @@
// Backfill cover-art for every station missing a local image.
//
// node server/scripts/download-images.js # only stations missing a local file
// node server/scripts/download-images.js --all # re-download even cached stations
// node server/scripts/download-images.js --no-scrape # skip homepage scrape fallback
//
// Strategy per station:
// 1. Try downloading the existing image_url.
// 2. On failure (404, HTML, dead host, ...), call scrapeIcon() to find a
// fresh icon from Radio-Browser / the station homepage, persist the new
// URL, and try again.
//
// Safe to re-run.
import 'dotenv/config';
import { initDb } from '../db/index.js';
import { listStations, updateStation } from '../stations.js';
import { saveStationImageFromUrl, ensureImageDirs } from '../media/images.js';
import { scrapeIcon } from '../sources/iconScraper.js';
const FORCE = process.argv.includes('--all');
const NO_SCRAPE = process.argv.includes('--no-scrape');
const CONCURRENCY = 4;
initDb(process.env.DB_PATH || './data/db/oradio.sqlite');
ensureImageDirs();
const all = listStations({ enabled: null });
const todo = all.filter((s) => FORCE || !s.image_path);
console.log(`[images] ${todo.length} of ${all.length} stations to fetch (force=${FORCE}, scrape=${!NO_SCRAPE})`);
let i = 0, ok = 0, scraped = 0, fail = 0;
async function worker() {
while (i < todo.length) {
const idx = i++;
const s = todo[idx];
const label = `[${idx + 1}/${todo.length}] ${s.name}`;
// 1. Try the existing remote URL.
let rel = s.image_url
? await saveStationImageFromUrl(s.id, s.image_url, { source: s.image_source || 'remote' })
: null;
// 2. Fallback: scrape a fresh icon from the homepage / Radio-Browser.
if (!rel && !NO_SCRAPE) {
const found = await scrapeIcon(s);
if (found && found !== s.image_url) {
updateStation(s.id, { image_url: found });
rel = await saveStationImageFromUrl(s.id, found, { source: 'scraped' });
if (rel) scraped++;
}
}
if (rel) {
ok++;
console.log(` ${label} -> ${rel}`);
} else {
fail++;
console.log(` ${label} ✗ (${s.image_url || 'no url'})`);
}
}
}
await Promise.all(Array.from({ length: CONCURRENCY }, worker));
console.log(`[images] done. ok=${ok} (scraped=${scraped}) fail=${fail}`);
process.exit(0);

View File

@@ -7,7 +7,9 @@
// 3. HEAD-probe /favicon.ico at the homepage origin. // 3. HEAD-probe /favicon.ico at the homepage origin.
// Returns the best absolute URL found, or null. // Returns the best absolute URL found, or null.
const UA = 'OnlineRadioExplorer/0.1 (+icon-scraper)'; // Browser-like UA: many station homepages (Cloudflare, Wikimedia) block opaque bots.
const UA = process.env.IMAGE_FETCH_UA
|| 'Mozilla/5.0 (compatible; OnlineRadioExplorer/0.1; +https://github.com/marcoheine/onlineRadioExplorer)';
const FETCH_TIMEOUT_MS = 8000; const FETCH_TIMEOUT_MS = 8000;
const MAX_HTML_BYTES = 256 * 1024; const MAX_HTML_BYTES = 256 * 1024;
const RB_BASE = 'https://de1.api.radio-browser.info'; const RB_BASE = 'https://de1.api.radio-browser.info';
@@ -45,13 +47,47 @@ async function fetchText(url) {
} }
async function head(url) { async function head(url) {
// We can't trust real HEAD: many CDNs/SPAs return 200 for *every* path with
// HTML. So we issue a small ranged GET and check the response is actually
// an image (content-type AND/OR magic bytes).
const t = withTimeout(FETCH_TIMEOUT_MS); const t = withTimeout(FETCH_TIMEOUT_MS);
try { try {
const res = await fetch(url, { method: 'HEAD', headers: { 'User-Agent': UA }, signal: t.signal, redirect: 'follow' }); const res = await fetch(url, {
return res.ok; method: 'GET',
headers: { 'User-Agent': UA, 'Accept': 'image/*', 'Range': 'bytes=0-1023' },
signal: t.signal,
redirect: 'follow'
});
if (!res.ok && res.status !== 206) return false;
const ct = (res.headers.get('content-type') || '').toLowerCase().split(';')[0].trim();
if (ct.startsWith('text/') || ct.includes('html')) return false;
// Sniff the first chunk to make sure it's not HTML masquerading as image/*.
const reader = res.body?.getReader();
if (!reader) return ct.startsWith('image/');
const { value } = await reader.read();
try { await reader.cancel(); } catch { }
const buf = value ? Buffer.from(value) : Buffer.alloc(0);
const head = buf.slice(0, 256).toString('utf8').trimStart().toLowerCase();
if (head.startsWith('<!doctype') || head.startsWith('<html')) return false;
if (ct.startsWith('image/')) return true;
// No content-type but bytes look like a known image format -> accept.
return isImageMagic(buf);
} catch { return false; } finally { t.done(); } } catch { return false; } finally { t.done(); }
} }
function isImageMagic(buf) {
if (buf.length < 4) return false;
if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4E && buf[3] === 0x47) return true; // PNG
if (buf[0] === 0xFF && buf[1] === 0xD8 && buf[2] === 0xFF) return true; // JPEG
if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) return true; // GIF
if (buf.length >= 12 && buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46
&& buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50) return true; // WEBP
if (buf[0] === 0x00 && buf[1] === 0x00 && buf[2] === 0x01 && buf[3] === 0x00) return true; // ICO
const head = buf.slice(0, 256).toString('utf8').trimStart().toLowerCase();
if (head.startsWith('<?xml') || head.startsWith('<svg')) return true; // SVG
return false;
}
function abs(base, href) { function abs(base, href) {
if (!href) return null; if (!href) return null;
try { return new URL(href, base).toString(); } catch { return null; } try { return new URL(href, base).toString(); } catch { return null; }
@@ -93,14 +129,16 @@ function parseIconCandidates(html, baseUrl) {
async function fromRadioBrowserByName(name) { async function fromRadioBrowserByName(name) {
if (!name) return null; if (!name) return null;
const q = String(name).trim();
if (!q) return null;
try { try {
const url = `${RB_BASE}/json/stations/search?name=${encodeURIComponent(name)}&limit=3&hidebroken=true&order=clickcount&reverse=true`; const url = `${RB_BASE}/json/stations/search?name=${encodeURIComponent(q)}&limit=3&hidebroken=true&order=clickcount&reverse=true`;
const t = withTimeout(FETCH_TIMEOUT_MS); const t = withTimeout(FETCH_TIMEOUT_MS);
const res = await fetch(url, { headers: { 'User-Agent': UA }, signal: t.signal }); const res = await fetch(url, { headers: { 'User-Agent': UA }, signal: t.signal });
t.done(); t.done();
if (!res.ok) return null; if (!res.ok) return null;
const list = await res.json(); const list = await res.json();
const target = name.toLowerCase().trim(); const target = q.toLowerCase();
const exact = list.find((s) => (s.name || '').toLowerCase().trim() === target); const exact = list.find((s) => (s.name || '').toLowerCase().trim() === target);
const pick = exact || list[0]; const pick = exact || list[0];
if (pick?.favicon) return pick.favicon; if (pick?.favicon) return pick.favicon;
@@ -119,12 +157,23 @@ async function fromHomepage(homepage) {
if (await head(c.href)) return c.href; if (await head(c.href)) return c.href;
} }
} }
// last resort: /favicon.ico // last resort on this host: /favicon.ico
const ico = `${base.origin}/favicon.ico`; const ico = `${base.origin}/favicon.ico`;
if (await head(ico)) return ico; if (await head(ico)) return ico;
return null; return null;
} }
// Final fallback: Google's public favicon service. Returns a real PNG (the
// browser-side favicon Google has on file) for virtually any homepage, so
// even SPA/JS-only sites end up with *some* artwork.
function fromGoogleFavicon(homepage, size = 128) {
if (!homepage) return null;
let host;
try { host = new URL(homepage).hostname; } catch { return null; }
if (!host) return null;
return `https://www.google.com/s2/favicons?sz=${size}&domain=${encodeURIComponent(host)}`;
}
/** /**
* Try to find an icon URL for a station. * Try to find an icon URL for a station.
* @param {{ name?: string, homepage?: string|null, source?: string }} station * @param {{ name?: string, homepage?: string|null, source?: string }} station
@@ -135,7 +184,13 @@ export async function scrapeIcon(station) {
// For non-RB stations, RB often still has an entry → cheap win. // For non-RB stations, RB often still has an entry → cheap win.
if (station.source !== 'radiobrowser') { if (station.source !== 'radiobrowser') {
const rb = await fromRadioBrowserByName(station.name); const rb = await fromRadioBrowserByName(station.name);
if (rb) return rb; if (rb && await head(rb)) return rb;
} }
return fromHomepage(station.homepage); const fromPage = await fromHomepage(station.homepage);
if (fromPage) return fromPage;
// Last-ditch: ask Google's favicon service. It almost always returns a
// 128×128 PNG, even for SPA-only homepages where direct scraping fails.
const g = fromGoogleFavicon(station.homepage, 128);
if (g && await head(g)) return g;
return null;
} }

View File

@@ -3,6 +3,8 @@ import { getDb } from './db/index.js';
function rowToStation(row) { function rowToStation(row) {
if (!row) return null; if (!row) return null;
const imagePath = row.image_path || null;
const remote = row.image_url || null;
return { return {
id: row.id, id: row.id,
uuid: row.uuid, uuid: row.uuid,
@@ -12,7 +14,12 @@ function rowToStation(row) {
country: row.country, country: row.country,
genres: row.genres ? JSON.parse(row.genres) : [], genres: row.genres ? JSON.parse(row.genres) : [],
description: row.description, description: row.description,
image_url: row.image_url, // image_url remains the remote/source URL (what admins edit).
// image_display_url is what UIs should render — prefers the local cache.
image_url: remote,
image_path: imagePath,
image_source: row.image_source || null,
image_display_url: imagePath ? `/media/${imagePath}` : remote,
source: row.source, source: row.source,
source_ref: row.source_ref, source_ref: row.source_ref,
category: row.category, category: row.category,

View File

@@ -1,23 +1,47 @@
// Vote + play stats and the ranking algorithm. // Vote + play stats and the ranking algorithm.
// //
// Score combines two signals: // Score combines three signals:
// - voteZ = (up - down) / sqrt(up + down + 1) z-like, penalizes small N // - voteZ = (up - down) / sqrt(up + down + 1) z-like, penalizes small N
// - playLog = log10(plays + 1) gentle popularity boost // - playLog = log10(plays + 1) gentle popularity boost
// - score = voteZ + 0.5 * playLog // - timeLog = log10(hours_listened + 1) rewards actual listen time
// - score = voteZ + 0.5 * playLog + 0.4 * timeLog
// //
// Net effect: // Net effect:
// * A handful of downvotes on an obscure station sinks it hard. // * A handful of downvotes on an obscure station sinks it hard.
// * One stray upvote on a brand new station barely moves it. // * One stray upvote on a brand new station barely moves it.
// * Popular stations float up only if they aren't being actively buried. // * Pressing play and skipping immediately barely counts; sticking with a
// station for hours/days pushes it up the leaderboard.
// * Established + positively-voted stations dominate the top. // * Established + positively-voted stations dominate the top.
import { getDb } from './db/index.js'; import { getDb } from './db/index.js';
export function computeScore({ up = 0, down = 0, plays = 0 } = {}) { // Sessions longer than this are almost certainly a forgotten-tab leak (laptop
// closed, browser put to sleep). Clamp so one user can't poison total_play_ms.
const MAX_SESSION_MS = 6 * 60 * 60 * 1000; // 6 hours
// Sessions shorter than this don't get credited toward listen time (people
// scrubbing through stations should still bump `plays`, but not `total_play_ms`).
const MIN_SESSION_MS = 3 * 1000;
export function computeScore({ up = 0, down = 0, plays = 0, totalPlayMs = 0 } = {}) {
const n = up + down; const n = up + down;
const voteZ = n === 0 ? 0 : (up - down) / Math.sqrt(n + 1); const voteZ = n === 0 ? 0 : (up - down) / Math.sqrt(n + 1);
const playLog = Math.log10(plays + 1); const playLog = Math.log10(plays + 1);
return voteZ + 0.5 * playLog; const hours = totalPlayMs / 3600000;
const timeLog = Math.log10(hours + 1);
return voteZ + 0.5 * playLog + 0.4 * timeLog;
}
function statsFromRow(r) {
const up = r.up || 0;
const down = r.down || 0;
const plays = r.plays || 0;
const sessions = r.sessions || 0;
const totalPlayMs = r.total_play_ms || 0;
const avgSessionMs = sessions > 0 ? Math.round(totalPlayMs / sessions) : 0;
return {
up, down, plays, sessions, totalPlayMs, avgSessionMs,
score: computeScore({ up, down, plays, totalPlayMs })
};
} }
export function getStationStats(stationId, userId = null) { export function getStationStats(stationId, userId = null) {
@@ -28,14 +52,15 @@ export function getStationStats(stationId, userId = null) {
COALESCE(SUM(CASE WHEN value = -1 THEN 1 ELSE 0 END), 0) AS down COALESCE(SUM(CASE WHEN value = -1 THEN 1 ELSE 0 END), 0) AS down
FROM station_votes WHERE station_id = ? FROM station_votes WHERE station_id = ?
`).get(stationId) || { up: 0, down: 0 }; `).get(stationId) || { up: 0, down: 0 };
const p = db.prepare('SELECT plays FROM station_plays WHERE station_id = ?').get(stationId); const p = db.prepare(`
const plays = p?.plays || 0; SELECT plays, sessions, total_play_ms FROM station_plays WHERE station_id = ?
`).get(stationId) || {};
let myVote = 0; let myVote = 0;
if (userId) { if (userId) {
const r = db.prepare('SELECT value FROM station_votes WHERE user_id = ? AND station_id = ?').get(userId, stationId); const r = db.prepare('SELECT value FROM station_votes WHERE user_id = ? AND station_id = ?').get(userId, stationId);
myVote = r?.value || 0; myVote = r?.value || 0;
} }
return { up: v.up, down: v.down, plays, myVote, score: computeScore({ up: v.up, down: v.down, plays }) }; return { ...statsFromRow({ ...v, ...p }), myVote };
} }
// Bulk stats for many stations in one query. Returns a Map<station_id, stats>. // Bulk stats for many stations in one query. Returns a Map<station_id, stats>.
@@ -46,7 +71,9 @@ export function getStatsMap(userId = null) {
s.id AS station_id, s.id AS station_id,
COALESCE(v.up, 0) AS up, COALESCE(v.up, 0) AS up,
COALESCE(v.down, 0) AS down, COALESCE(v.down, 0) AS down,
COALESCE(p.plays, 0) AS plays COALESCE(p.plays, 0) AS plays,
COALESCE(p.sessions, 0) AS sessions,
COALESCE(p.total_play_ms, 0) AS total_play_ms
FROM stations s FROM stations s
LEFT JOIN ( LEFT JOIN (
SELECT station_id, SELECT station_id,
@@ -65,11 +92,7 @@ export function getStatsMap(userId = null) {
} }
const out = new Map(); const out = new Map();
for (const r of rows) { for (const r of rows) {
const myVote = my.get(r.station_id) || 0; out.set(r.station_id, { ...statsFromRow(r), myVote: my.get(r.station_id) || 0 });
out.set(r.station_id, {
up: r.up, down: r.down, plays: r.plays, myVote,
score: computeScore({ up: r.up, down: r.down, plays: r.plays })
});
} }
return out; return out;
} }
@@ -89,18 +112,67 @@ export function castVote(userId, stationId, value) {
return getStationStats(stationId, userId); return getStationStats(stationId, userId);
} }
export function recordPlay(stationId) { // Record the start of a listening session. Bumps the play counter immediately
getDb().prepare(` // (so spam-clickers still register as taps) and opens a play_history row that
INSERT INTO station_plays (station_id, plays, last_played_at) VALUES (?, 1, datetime('now')) // `endPlaySession` will close with a duration. Returns the new session id when
// a user is known, or null for anonymous plays (which still bump the counter).
export function recordPlay(stationId, userId = null, streamId = null) {
const db = getDb();
db.prepare(`
INSERT INTO station_plays (station_id, plays, sessions, total_play_ms, last_played_at)
VALUES (?, 1, 0, 0, datetime('now'))
ON CONFLICT(station_id) DO UPDATE SET ON CONFLICT(station_id) DO UPDATE SET
plays = station_plays.plays + 1, plays = station_plays.plays + 1,
last_played_at = datetime('now') last_played_at = datetime('now')
`).run(stationId); `).run(stationId);
if (!userId) return null;
const info = db.prepare(`
INSERT INTO play_history (user_id, station_id, stream_id, started_at)
VALUES (?, ?, ?, datetime('now'))
`).run(userId, stationId, streamId || null);
return info.lastInsertRowid;
}
// Close a session opened by recordPlay. `durationMs` is optional — when the
// client knows the real wall-clock listen time (e.g. an `audio.currentTime`
// derivative) it should pass it; otherwise we compute it from started_at.
// Returns the station_id we closed against, or null when the session is
// unknown / already closed / belongs to someone else.
export function endPlaySession(sessionId, userId, durationMs = null) {
const db = getDb();
const row = db.prepare(`
SELECT id, station_id, user_id, started_at, ended_at
FROM play_history WHERE id = ?
`).get(sessionId);
if (!row || row.user_id !== userId || row.ended_at) return null;
let ms = Number.isFinite(durationMs) && durationMs >= 0
? Math.floor(durationMs)
: Math.max(0, Date.now() - Date.parse(String(row.started_at).replace(' ', 'T') + 'Z'));
if (!Number.isFinite(ms) || ms < 0) ms = 0;
if (ms > MAX_SESSION_MS) ms = MAX_SESSION_MS;
db.prepare(`UPDATE play_history SET ended_at = datetime('now') WHERE id = ?`).run(sessionId);
// Only credit listen-time aggregates for "real" sessions; sub-second
// sessions don't earn the station any score, but they already bumped
// `plays` in recordPlay so they aren't completely free either.
if (ms >= MIN_SESSION_MS) {
db.prepare(`
INSERT INTO station_plays (station_id, plays, sessions, total_play_ms, last_played_at)
VALUES (?, 0, 1, ?, datetime('now'))
ON CONFLICT(station_id) DO UPDATE SET
sessions = station_plays.sessions + 1,
total_play_ms = station_plays.total_play_ms + excluded.total_play_ms,
last_played_at = datetime('now')
`).run(row.station_id, ms);
}
return row.station_id;
} }
// Sort helper used by routes. Mutates the array. // Sort helper used by routes. Mutates the array.
export function sortByMode(items, mode, statsMap) { export function sortByMode(items, mode, statsMap) {
const s = (id) => statsMap.get(id) || { up: 0, down: 0, plays: 0, score: 0 }; const s = (id) => statsMap.get(id) || { up: 0, down: 0, plays: 0, totalPlayMs: 0, score: 0 };
switch (mode) { switch (mode) {
case 'hot': case 'hot':
items.sort((a, b) => s(b.id).score - s(a.id).score || a.name.localeCompare(b.name)); items.sort((a, b) => s(b.id).score - s(a.id).score || a.name.localeCompare(b.name));
@@ -111,6 +183,9 @@ export function sortByMode(items, mode, statsMap) {
case 'plays': case 'plays':
items.sort((a, b) => s(b.id).plays - s(a.id).plays || a.name.localeCompare(b.name)); items.sort((a, b) => s(b.id).plays - s(a.id).plays || a.name.localeCompare(b.name));
break; break;
case 'playtime':
items.sort((a, b) => s(b.id).totalPlayMs - s(a.id).totalPlayMs || a.name.localeCompare(b.name));
break;
case 'controversial': case 'controversial':
items.sort((a, b) => { items.sort((a, b) => {
const A = s(a.id), B = s(b.id); const A = s(a.id), B = s(b.id);

View File

@@ -1,8 +1,102 @@
import { WebSocketServer } from 'ws'; // Room-aware WebSocket hub.
import { getUserBySession, readSessionToken } from './auth.js'; //
// Connect to `/ws?room=<slug>&kind=display|controller|panel`. Auth via the
// session cookie. The server is the single source of truth for room state
// (now-playing, volume); clients send `command` (intent) and the elected
// display client emits `state` (truth) which is persisted to `room_state`
// and rebroadcast.
//
// Message envelope: `{ type, ...payload }`. Types:
// - hello server->client: { room, peers, state, role, you }
// - presence server->room: { peers }
// - command client->server: { action: play|pause|stop|volume|setSink, ... }
// server->room: forwarded as-is
// - state display->server: ground-truth playback snapshot
// server->room: persisted snapshot
// - devices display->server: { list, current } server->room: same
// - vote server->room: { stationId, stats } emitted after castVote
// - plays server->room: { stationId, plays } emitted after recordPlay
// per-user channel hub: any client of user U receives messages targeted to U. import { WebSocketServer } from 'ws';
const channels = new Map(); // userId -> Set<ws> import { URL } from 'node:url';
import { getUserBySession, readSessionToken } from './auth.js';
import {
getRoomBySlug, ensurePersonalRoom, isMember,
getRoomState, setRoomState
} from './rooms.js';
import { getStation } from './stations.js';
// roomSlug -> Set<ws>
const rooms = new Map();
// userId -> Set<ws>
const byUser = new Map();
function addToIndex(map, key, ws) {
if (!map.has(key)) map.set(key, new Set());
map.get(key).add(ws);
}
function removeFromIndex(map, key, ws) {
const set = map.get(key);
if (!set) return;
set.delete(ws);
if (!set.size) map.delete(key);
}
function presenceFor(roomSlug) {
const set = rooms.get(roomSlug);
if (!set) return [];
const out = [];
for (const ws of set) {
out.push({
user: { id: ws.user.id, username: ws.user.username },
kind: ws.kind,
since: ws.since
});
}
return out;
}
function send(ws, msg) {
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg));
}
export function broadcastToRoom(roomSlug, msg, except) {
const set = rooms.get(roomSlug);
if (!set) return;
const payload = JSON.stringify(msg);
for (const ws of set) {
if (ws === except) continue;
if (ws.readyState === ws.OPEN) ws.send(payload);
}
}
/** Send to every connection of the given user, across all their rooms. */
export function broadcastToUser(userId, msg, except) {
const set = byUser.get(userId);
if (!set) return;
const payload = JSON.stringify(msg);
for (const ws of set) {
if (ws === except) continue;
if (ws.readyState === ws.OPEN) ws.send(payload);
}
}
/** Broadcast to every open WS, regardless of room. Used for global events. */
export function broadcastGlobal(msg) {
const payload = JSON.stringify(msg);
for (const set of rooms.values()) {
for (const ws of set) {
if (ws.readyState === ws.OPEN) ws.send(payload);
}
}
}
function hasDisplay(roomSlug) {
const set = rooms.get(roomSlug);
if (!set) return false;
for (const ws of set) if (ws.kind === 'display') return true;
return false;
}
export function attachWs(server) { export function attachWs(server) {
const wss = new WebSocketServer({ noServer: true }); const wss = new WebSocketServer({ noServer: true });
@@ -16,41 +110,110 @@ export function attachWs(server) {
socket.destroy(); socket.destroy();
return; return;
} }
const url = new URL(req.url, 'http://x');
const slug = url.searchParams.get('room') || `u-${user.id}`;
let kindRaw = url.searchParams.get('kind') || 'controller';
if (!['display', 'controller', 'panel'].includes(kindRaw)) kindRaw = 'controller';
// Resolve room. The personal room is auto-provisioned on demand.
let room = getRoomBySlug(slug);
if (!room && slug === `u-${user.id}`) room = ensurePersonalRoom(user);
if (!room) {
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
socket.destroy();
return;
}
if (!isMember(room.id, user.id)) {
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
socket.destroy();
return;
}
// One display per room: subsequent claimers are silently demoted
// to passive panels (no audio, no device picker).
let kind = kindRaw;
if (kind === 'display' && hasDisplay(room.slug)) kind = 'panel';
wss.handleUpgrade(req, socket, head, (ws) => { wss.handleUpgrade(req, socket, head, (ws) => {
ws.user = user; ws.user = user;
addClient(user.id, ws); ws.kind = kind;
ws.on('close', () => removeClient(user.id, ws)); ws.room = room;
ws.since = Date.now();
addToIndex(rooms, room.slug, ws);
addToIndex(byUser, user.id, ws);
ws.on('close', () => {
removeFromIndex(rooms, room.slug, ws);
removeFromIndex(byUser, user.id, ws);
broadcastToRoom(room.slug, { type: 'presence', peers: presenceFor(room.slug) });
});
ws.on('message', (raw) => { ws.on('message', (raw) => {
let msg; let msg;
try { msg = JSON.parse(raw.toString()); } catch { return; } try { msg = JSON.parse(raw.toString()); } catch { return; }
// Re-broadcast every message to all connections of the same user. handleClientMessage(ws, msg);
// (e.g. phone sends `{type:"command", action:"play", stationId:7}` → kiosk receives)
broadcastToUser(user.id, msg, ws);
}); });
ws.send(JSON.stringify({ type: 'hello', user: { id: user.id, username: user.username, role: user.role } }));
// Send hello snapshot.
const state = getRoomState(room.id);
const station = state.station_id ? getStation(state.station_id) : null;
send(ws, {
type: 'hello',
you: { id: user.id, username: user.username, role: user.role, kind },
room: { id: room.id, slug: room.slug, name: room.name },
state: { ...state, station },
peers: presenceFor(room.slug)
});
broadcastToRoom(room.slug, { type: 'presence', peers: presenceFor(room.slug) }, ws);
}); });
}); });
return wss; return wss;
} }
function addClient(userId, ws) { function handleClientMessage(ws, msg) {
if (!channels.has(userId)) channels.set(userId, new Set()); if (!msg || typeof msg !== 'object') return;
channels.get(userId).add(ws); const slug = ws.room.slug;
}
function removeClient(userId, ws) {
const set = channels.get(userId);
if (!set) return;
set.delete(ws);
if (!set.size) channels.delete(userId);
}
export function broadcastToUser(userId, msg, except) { switch (msg.type) {
const set = channels.get(userId); case 'command': {
if (!set) return; // Controllers express intent. Forward to all peers; the display
const payload = JSON.stringify(msg); // is responsible for actually changing audio output. We also
for (const ws of set) { // optimistically reflect simple intents into room_state so a
if (ws === except) continue; // late-joining peer sees the latest target station/volume even
if (ws.readyState === ws.OPEN) ws.send(payload); // before the display emits a confirmation `state`.
if (msg.action === 'play' && Number.isFinite(msg.stationId)) {
setRoomState(ws.room.id, { station_id: Number(msg.stationId), playing: true });
} else if (msg.action === 'stop') {
setRoomState(ws.room.id, { playing: false });
} else if (msg.action === 'volume' && typeof msg.value === 'number') {
setRoomState(ws.room.id, { volume: Math.max(0, Math.min(1, msg.value)) });
}
broadcastToRoom(slug, msg, null); // include sender so its UI mirrors
return;
}
case 'state': {
// Only the display's state messages are persisted as truth.
if (ws.kind !== 'display') return;
const patch = {};
if ('stationId' in msg) patch.station_id = msg.stationId ?? null;
if ('playing' in msg) patch.playing = !!msg.playing;
if (typeof msg.volume === 'number') patch.volume = msg.volume;
const next = setRoomState(ws.room.id, patch);
const station = next.station_id ? getStation(next.station_id) : null;
broadcastToRoom(slug, { type: 'state', ...next, station });
return;
}
case 'devices': {
if (ws.kind !== 'display') return;
broadcastToRoom(slug, { type: 'devices', list: msg.list || [], current: msg.current || null });
return;
}
case 'ping':
send(ws, { type: 'pong', t: Date.now() });
return;
default:
return;
} }
} }

View File

@@ -8,6 +8,10 @@ export default defineConfig({
port: 5173, port: 5173,
proxy: { proxy: {
'/api': 'http://localhost:4173', '/api': 'http://localhost:4173',
// Cover-art and other static media are served by the backend's
// express.static('/media') mount; the dev server has to proxy
// them or kiosk thumbnails 404 in `npm run dev`.
'/media': 'http://localhost:4173',
'/ws': { target: 'ws://localhost:4173', ws: true } '/ws': { target: 'ws://localhost:4173', ws: true }
} }
}, },
@@ -18,7 +22,8 @@ export default defineConfig({
input: { input: {
kiosk: resolve(__dirname, 'web/index.html'), kiosk: resolve(__dirname, 'web/index.html'),
admin: resolve(__dirname, 'web/admin/index.html'), admin: resolve(__dirname, 'web/admin/index.html'),
docs: resolve(__dirname, 'web/docs/index.html') docs: resolve(__dirname, 'web/docs/index.html'),
master: resolve(__dirname, 'web/master/index.html')
} }
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -208,3 +208,236 @@ dialog .actions { padding-top: 12px; border-top: 1px solid #cccccc; }
} }
.stat .v { font-weight: 900; letter-spacing: -0.01em; } .stat .v { font-weight: 900; letter-spacing: -0.01em; }
.stat .k { font-weight: 700; letter-spacing: 0.08em; } .stat .k { font-weight: 700; letter-spacing: 0.08em; }
/* ============================================================
POWER-ADMIN ADDITIONS
============================================================ */
.tabs { display: flex; gap: 0; border-bottom: 1px solid var(--border); margin: 4px 0 12px; }
.tabs button {
background: var(--panel); border: 1px solid var(--border); border-bottom: 0;
padding: 8px 14px; font-size: 12px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.06em; margin-right: -1px;
}
.tabs button.active { background: var(--accent); color: #000; border-color: var(--accent); }
.tab-body { padding: 6px 0; }
.row.image-row { grid-template-columns: 1fr; }
.image-area { display: grid; grid-template-columns: 140px 1fr; gap: 16px; align-items: start; }
.image-area .preview {
width: 140px; height: 140px; border: 1px solid var(--border);
background-size: cover; background-position: center; background-color: #f5f5f5;
position: relative; display: grid; place-items: center; color: var(--muted); font-size: 11px;
}
.image-area .actions-col { display: grid; gap: 6px; align-content: start; }
.image-area .dropzone {
border: 2px dashed #999; padding: 10px 12px; font-size: 12px; color: var(--muted);
text-align: center; cursor: pointer; user-select: none;
}
.image-area .dropzone.over { border-color: var(--accent); color: var(--fg); background: #fff4ec; }
.bulkbar {
position: sticky; top: 0; z-index: 2;
background: #000; color: #fff; padding: 8px 12px; margin-bottom: 8px;
display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
}
.bulkbar .btn { background: #111 !important; color: #fff !important; border-color: #444 !important; }
.bulkbar .btn:hover { background: var(--accent) !important; color: #000 !important; border-color: var(--accent) !important; }
.bulkbar .count { font-weight: 800; text-transform: uppercase; letter-spacing: 0.06em; font-size: 12px; }
.station-art-thumb {
width: 48px; height: 48px; background-color: #f3f3f3;
border: 1px solid var(--border); flex-shrink: 0;
overflow: hidden; position: relative; display: block;
}
.station-art-thumb img {
width: 100%; height: 100%; object-fit: cover; display: block;
}
.station-art-thumb.empty::after {
content: "\266A"; position: absolute; inset: 0;
display: grid; place-items: center; color: var(--muted); font-size: 22px;
}
.station-cell { display: flex; align-items: center; gap: 10px; }
.station-cell .meta { min-width: 0; }
.station-cell .meta small { color: var(--muted); display: block; max-width: 360px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.preview-player {
display: inline-flex; align-items: center; gap: 6px; font-size: 11px;
border: 1px solid var(--border); padding: 2px 6px;
}
.preview-player button {
background: transparent; border: 0; font-size: 14px; padding: 0 2px; cursor: pointer;
}
.preview-player.playing button { color: var(--accent); }
/* Leaderboard view */
.leaderboard { display: grid; gap: 8px; }
.leader-row {
display: grid;
grid-template-columns: 32px 60px 1fr repeat(6, auto);
align-items: center; gap: 10px; padding: 8px 12px;
border: 1px solid var(--border); background: var(--panel);
}
.leader-row .rank { font-weight: 900; font-size: 18px; }
.leader-row .art {
width: 48px; height: 48px; border-radius: 6px; overflow: hidden;
background: #f3f3f3; display: flex; align-items: center; justify-content: center;
flex: none;
}
.leader-row .art img { width: 100%; height: 100%; object-fit: cover; display: block; }
.leader-row .art.empty::after { content: '♪'; color: var(--muted); font-size: 22px; }
.leader-row .name b { font-size: 14px; }
.leader-row .stat-num { font-weight: 800; font-size: 13px; white-space: nowrap; }
/* Wider dialog for the edit-station experience. */
dialog.wide { max-width: 880px; }
/* ---------- Strong delete confirmation (station removal warns about API impact) ---------- */
dialog.danger-confirm {
border: 2px solid var(--bad);
max-width: 560px;
width: 92%;
}
dialog.danger-confirm form {
gap: 14px;
padding: 22px;
}
dialog.danger-confirm .danger-header {
display: flex;
align-items: center;
gap: 12px;
}
dialog.danger-confirm .danger-icon {
width: 44px;
height: 44px;
display: grid;
place-items: center;
background: var(--bad);
color: #fff;
font-size: 26px;
font-weight: 900;
flex-shrink: 0;
}
dialog.danger-confirm h2 {
margin: 0;
color: var(--bad);
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 20px;
}
dialog.danger-confirm .danger-body {
display: flex;
flex-direction: column;
gap: 12px;
font-size: 14px;
}
dialog.danger-confirm .lede {
margin: 0;
padding: 12px;
background: #fde7e6;
border-left: 4px solid var(--bad);
color: #2a0000;
}
dialog.danger-confirm .impact {
margin: 0;
padding-left: 22px;
font-size: 13px;
color: var(--fg);
line-height: 1.5;
}
dialog.danger-confirm .impact code,
dialog.danger-confirm .type-to-confirm code,
dialog.danger-confirm .mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
background: #f0f1f5;
padding: 1px 6px;
font-size: 12px;
}
dialog.danger-confirm .impact-list {
background: #fafafc;
border: 1px solid var(--border);
padding: 8px 12px;
font-size: 12px;
max-height: 180px;
overflow: auto;
}
dialog.danger-confirm .impact-list-head {
font-weight: 700;
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.06em;
font-size: 11px;
color: var(--muted);
}
dialog.danger-confirm .impact-list ul {
margin: 0;
padding-left: 18px;
list-style: square;
}
dialog.danger-confirm .impact-list li.more {
color: var(--muted);
font-style: italic;
list-style: none;
margin-left: -16px;
}
dialog.danger-confirm .type-to-confirm {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 13px;
}
dialog.danger-confirm .type-to-confirm input {
border: 2px solid var(--bad);
font-size: 15px;
padding: 10px 12px;
}
dialog.danger-confirm .actions .btn.danger:disabled {
opacity: 0.35;
cursor: not-allowed;
background: var(--panel);
color: var(--bad);
}
dialog.danger-confirm .actions .btn.danger:not(:disabled) {
background: var(--bad);
color: #fff;
border-color: var(--bad);
font-weight: 700;
}
/* ---------- Read-only metadata block in station detail editor ---------- */
.readonly-meta {
margin-top: 8px;
padding: 10px 12px;
background: #fafafc;
border: 1px solid var(--border);
display: grid;
gap: 4px;
font-size: 12px;
}
.readonly-meta-head {
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
font-weight: 700;
margin-bottom: 4px;
font-size: 11px;
}
.readonly-meta .meta-row {
display: grid;
grid-template-columns: 140px 1fr;
gap: 8px;
align-items: center;
}
.readonly-meta .meta-k {
color: var(--muted);
}
.readonly-meta .meta-v.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11px;
word-break: break-all;
}
/* ---------- Discover table: dim entries already in the library ---------- */
tr.discover-existing { opacity: 0.55; }
tr.discover-existing td { background: #fafafc; }
.main .muted { color: var(--muted); }

View File

@@ -15,7 +15,16 @@ const state = {
query: '', query: '',
sort: 'hot', // hot | top | plays | name | controversial — applied in Browse sort: 'hot', // hot | top | plays | name | controversial — applied in Browse
randomMode: localStorage.getItem('oradio.randomMode') === 'favorites' ? 'favorites' : 'all', randomMode: localStorage.getItem('oradio.randomMode') === 'favorites' ? 'favorites' : 'all',
player: { stationId: null, stationName: null, genres: [], playing: false, loading: false, volume: 0.7, votes: null } // Room sync. mode='play-here' uses local <audio> (default). mode='follow-room'
// mirrors the room's display: no local audio, controls forwarded over WS.
rooms: [],
roomSlug: localStorage.getItem('oradio.room') || null,
mode: localStorage.getItem('oradio.mode') === 'follow-room' ? 'follow-room' : 'play-here',
roomState: null, // { station, station_id, playing, volume }
roomPeers: [], // [{ user, kind }]
roomDevices: { list: [], current: null },
player: { stationId: null, stationName: null, genres: [], playing: false, loading: false, volume: 0.7, votes: null },
session: null // { id, stationId, startedAt } for the currently-open play_history row
}; };
const player = new Player({ const player = new Player({
@@ -34,11 +43,52 @@ async function bootstrap() {
return; return;
} }
await refreshAll(); await refreshAll();
ws = connectWs(handleWs); // Load rooms; pick the user's personal room if none selected.
try {
state.rooms = await api.get('/api/rooms');
if (!state.roomSlug || !state.rooms.find((r) => r.slug === state.roomSlug)) {
state.roomSlug = (state.rooms[0] && state.rooms[0].slug) || `u-${state.user.id}`;
localStorage.setItem('oradio.room', state.roomSlug);
}
} catch { /* falls back to personal room slug */
state.roomSlug = state.roomSlug || `u-${state.user.id}`;
}
openWs();
render(); render();
requestWakeLock(); requestWakeLock();
} }
function openWs() {
if (ws) { try { ws.close(); } catch { } }
// 'panel' = no local audio, mirror display. 'controller' = play here.
const kind = state.mode === 'follow-room' ? 'panel' : 'controller';
ws = connectWs(handleWs, { room: state.roomSlug, kind });
}
function setMode(mode) {
if (mode !== 'play-here' && mode !== 'follow-room') return;
if (state.mode === mode) return;
state.mode = mode;
localStorage.setItem('oradio.mode', mode);
// Stop local playback if switching to follow-room.
if (mode === 'follow-room' && state.player.stationId) {
player.stop();
endCurrentSession();
}
openWs();
render();
}
function setRoom(slug) {
if (!slug || state.roomSlug === slug) return;
state.roomSlug = slug;
localStorage.setItem('oradio.room', slug);
state.roomPeers = [];
state.roomState = null;
openWs();
render();
}
async function refreshAll() { async function refreshAll() {
const [stations, favs, history, categories] = await Promise.all([ const [stations, favs, history, categories] = await Promise.all([
api.get(`/api/stations?sort=${encodeURIComponent(state.sort)}`), api.get(`/api/stations?sort=${encodeURIComponent(state.sort)}`),
@@ -57,14 +107,88 @@ async function refreshStations() {
} }
function handleWs(msg) { function handleWs(msg) {
if (msg.type === 'command') { if (!msg || !msg.type) return;
switch (msg.type) {
case 'hello':
state.roomState = msg.state || null;
state.roomPeers = msg.peers || [];
if (state.mode === 'follow-room') applyRoomStateToUI();
render();
return;
case 'presence':
state.roomPeers = msg.peers || [];
render();
return;
case 'devices':
state.roomDevices = { list: msg.list || [], current: msg.current || null };
render();
return;
case 'state':
state.roomState = { ...state.roomState, ...msg };
if (state.mode === 'follow-room') applyRoomStateToUI();
render();
return;
case 'vote': {
// Live vote update from any client (including ourselves).
const id = msg.stationId;
const stats = msg.stats || {};
for (const arr of [state.stations, state.favorites]) {
const hit = arr.find((s) => s.id === id);
if (hit) {
if ('up' in stats) hit.up = stats.up;
if ('down' in stats) hit.down = stats.down;
if ('score' in stats) hit.score = stats.score;
}
}
if (state.player.votes && state.player.stationId === id) {
state.player.votes = { ...state.player.votes, ...stats };
}
render();
return;
}
case 'plays': {
const id = msg.stationId;
for (const arr of [state.stations, state.favorites]) {
const hit = arr.find((s) => s.id === id);
if (hit) hit.plays = msg.plays;
}
if (state.player.votes && state.player.stationId === id) {
state.player.votes = { ...state.player.votes, plays: msg.plays };
}
render();
return;
}
case 'command': {
// Legacy: only act on commands when we're the audio source for this room.
if (state.mode !== 'play-here') return;
if (msg.action === 'play' && msg.stationId) { if (msg.action === 'play' && msg.stationId) {
const st = state.stations.find((s) => s.id === msg.stationId); const st = state.stations.find((s) => s.id === msg.stationId);
if (st) playStation(st); if (st) playStation(st);
} else if (msg.action === 'pause') player.togglePause(); } else if (msg.action === 'pause') player.togglePause();
else if (msg.action === 'volume') player.setVolume(msg.value); else if (msg.action === 'volume') player.setVolume(msg.value);
else if (msg.action === 'stop') player.stop(); else if (msg.action === 'stop') player.stop();
return;
} }
default:
return;
}
}
// Mirror the authoritative room state into the local "player" view-model so
// the now-playing card renders the same on all panels. No local audio plays.
function applyRoomStateToUI() {
const rs = state.roomState;
if (!rs) return;
state.player = {
...state.player,
stationId: rs.station_id ?? rs.station?.id ?? null,
stationName: rs.station?.name || null,
genres: rs.station?.genres || [],
playing: !!rs.playing,
loading: false,
volume: typeof rs.volume === 'number' ? rs.volume : state.player.volume,
error: null
};
} }
function showLogin() { function showLogin() {
@@ -134,20 +258,37 @@ function render() {
el('button', { el('button', {
class: `btn-play ${p.loading ? 'loading' : ''}`, class: `btn-play ${p.loading ? 'loading' : ''}`,
title: p.playing ? 'Pause' : 'Play', title: p.playing ? 'Pause' : 'Play',
onClick: () => p.stationId ? player.togglePause() : (state.favorites[0] && playStation(state.favorites[0])) onClick: () => {
if (state.mode === 'follow-room') {
ws?.send({ type: 'command', action: p.playing ? 'pause' : (p.stationId ? 'play' : 'play'), stationId: p.stationId || state.favorites[0]?.id });
} else {
p.stationId ? player.togglePause() : (state.favorites[0] && playStation(state.favorites[0]));
}
}
}, p.playing ? '❚❚' : '▶'), }, p.playing ? '❚❚' : '▶'),
el('button', { el('button', {
class: 'btn-stop', class: 'btn-stop',
title: 'Stop', title: 'Stop',
disabled: !p.stationId, disabled: !p.stationId,
onClick: () => player.stop() onClick: () => {
if (state.mode === 'follow-room') ws?.send({ type: 'command', action: 'stop' });
else { player.stop(); endCurrentSession(); }
}
}, '■'), }, '■'),
el('div', { class: 'vol' }, el('div', { class: 'vol' },
el('span', { class: 'vol-icon' }, p.volume === 0 ? '🔇' : p.volume < 0.5 ? '🔈' : '🔊'), el('span', { class: 'vol-icon' }, p.volume === 0 ? '🔇' : p.volume < 0.5 ? '🔈' : '🔊'),
el('input', { el('input', {
type: 'range', min: 0, max: 1, step: 0.05, value: p.volume, type: 'range', min: 0, max: 1, step: 0.05, value: p.volume,
'aria-label': 'Volume', 'aria-label': 'Volume',
onInput: (e) => player.setVolume(Number(e.target.value)) onInput: (e) => {
const v = Number(e.target.value);
if (state.mode === 'follow-room') {
state.player.volume = v;
ws?.send({ type: 'command', action: 'volume', value: v });
} else {
player.setVolume(v);
}
}
}), }),
el('span', { class: 'val' }, Math.round(p.volume * 100)) el('span', { class: 'val' }, Math.round(p.volume * 100))
) )
@@ -166,6 +307,7 @@ function render() {
) )
), ),
el('div', { class: 'header-tools' }, el('div', { class: 'header-tools' },
renderRoomPill(),
state.tab === 'browse' state.tab === 'browse'
? el('select', { ? el('select', {
class: 'sort', class: 'sort',
@@ -218,6 +360,30 @@ function render() {
} }
} }
function renderRoomPill() {
// Compact: room dropdown + mode toggle + peer count.
const peers = state.roomPeers || [];
const hasDisplay = peers.some((p) => p.kind === 'display');
return el('div', { class: 'room-pill', title: 'Listening room' },
el('span', { class: 'room-icon' }, '🏠'),
el('select', {
class: 'room-select',
onChange: (e) => setRoom(e.target.value),
'aria-label': 'Room'
}, ...(state.rooms.length ? state.rooms : [{ slug: state.roomSlug || '', name: 'My room' }])
.map((r) => el('option', { value: r.slug, selected: r.slug === state.roomSlug }, r.name))),
el('span', { class: 'room-peers', title: `${peers.length} client(s)${hasDisplay ? ' • display online' : ''}` },
`${peers.length}${hasDisplay ? '◉' : ''}`),
el('button', {
class: `room-mode ${state.mode}`,
title: state.mode === 'follow-room'
? 'Mirroring the room display. Click to play audio in this browser.'
: 'Playing audio in this browser. Click to follow the room display.',
onClick: () => setMode(state.mode === 'follow-room' ? 'play-here' : 'follow-room')
}, state.mode === 'follow-room' ? 'Follow' : 'Here')
);
}
function renderChips() { function renderChips() {
return el('div', { class: 'chips' }, return el('div', { class: 'chips' },
el('button', { el('button', {
@@ -282,10 +448,10 @@ function paintGrid(grid, favIds) {
onContextMenu: (e) => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, s); } onContextMenu: (e) => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, s); }
}, },
el('div', { class: 'art' }, el('div', { class: 'art' },
s.image_url (s.image_display_url || s.image_url)
? el('img', { ? el('img', {
class: 'art-img', class: 'art-img',
src: s.image_url, src: s.image_display_url || s.image_url,
alt: '', alt: '',
loading: 'lazy', loading: 'lazy',
referrerpolicy: 'no-referrer', referrerpolicy: 'no-referrer',
@@ -373,6 +539,35 @@ function recordHistory(stationId) {
// so the up/down buttons in the now-playing bar reflect the current station. // so the up/down buttons in the now-playing bar reflect the current station.
async function playStation(station) { async function playStation(station) {
state.player.votes = null; state.player.votes = null;
// Close whatever was playing before; the upcoming POST opens a fresh row.
endCurrentSession();
if (state.mode === 'follow-room') {
// Don't touch local audio — ask the room's display to play and let
// the resulting `state` message update our UI.
ws?.send({ type: 'command', action: 'play', stationId: station.id });
// Optimistically reflect locally so the card highlights immediately.
state.player = {
...state.player,
stationId: station.id,
stationName: station.name,
genres: station.genres || [],
playing: true,
loading: false,
error: null
};
render();
// No local audio means no local session — the master records its own.
try {
const stats = await api.get(`/api/stations/${station.id}/votes`);
if (state.player.stationId === station.id) {
state.player.votes = stats;
mergeStats(station.id, stats);
render();
}
} catch { /* ignore */ }
return;
}
player.play(station); player.play(station);
recordHistory(station.id); recordHistory(station.id);
try { try {
@@ -380,9 +575,17 @@ async function playStation(station) {
// Only apply if user hasn't switched stations in the meantime. // Only apply if user hasn't switched stations in the meantime.
if (state.player.stationId === station.id) { if (state.player.stationId === station.id) {
state.player.votes = stats; state.player.votes = stats;
// Refresh listing stats in the background so the score badge updates. // Remember the session so we can close it when the user stops or switches.
if (stats.sessionId) {
state.session = { id: stats.sessionId, stationId: station.id, startedAt: Date.now() };
}
mergeStats(station.id, stats); mergeStats(station.id, stats);
render(); render();
} else if (stats.sessionId) {
// Already moved on while the POST was in flight — close it immediately.
api.post(`/api/stations/${station.id}/play/end`, {
sessionId: stats.sessionId, duration_ms: 0
}).catch(() => { });
} }
} catch (err) { } catch (err) {
try { try {
@@ -396,6 +599,28 @@ async function playStation(station) {
} }
} }
// Close the currently-open play_history row, crediting the elapsed wall-clock
// time toward the station's total_play_ms. Safe to call multiple times.
function endCurrentSession({ beacon = false } = {}) {
const s = state.session;
if (!s || !s.id) return;
state.session = null;
const body = { sessionId: s.id, duration_ms: Math.max(0, Date.now() - s.startedAt) };
const url = `/api/stations/${s.stationId}/play/end`;
if (beacon && typeof navigator !== 'undefined' && navigator.sendBeacon) {
try {
navigator.sendBeacon(url, new Blob([JSON.stringify(body)], { type: 'application/json' }));
return;
} catch { /* fall through */ }
}
api.post(url, body).catch(() => { });
}
if (typeof window !== 'undefined') {
window.addEventListener('pagehide', () => endCurrentSession({ beacon: true }));
window.addEventListener('beforeunload', () => endCurrentSession({ beacon: true }));
}
async function votePlayer(value) { async function votePlayer(value) {
const id = state.player.stationId; const id = state.player.stationId;
if (!id) return; if (!id) return;

16
web/master/index.html Normal file
View File

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

602
web/master/main.js Normal file
View File

@@ -0,0 +1,602 @@
// Master display: owns the audio output for a room. Connects to the WS as
// kind='display', advertises a (fake) device list, plays the active station
// locally, and emits authoritative `state` events so other panels mirror.
//
// In production this same page is loaded inside an Electron window. The
// `window.oradioNative` bridge — when present — replaces the fake device
// enumerator below with the real OS one. The bridge contract is:
//
// window.oradioNative = {
// listOutputs(): Promise<{id, label, kind}[]>,
// setOutput(id): Promise<void>,
// getCurrent(): Promise<string>,
// onCurrentChanged(cb): unsubscribe
// };
import { api } from '../shared/api.js';
import { connectWs } from '../shared/ws.js';
import { el, clear } from '../shared/dom.js';
import { Player } from '../player.js';
// Fake list mirrors what a typical desktop sees. Used only when no native
// bridge is present (i.e. running in a normal browser tab, not Electron).
const FAKE_DEVICES = [
{ id: 'default', label: 'System default', kind: 'speakers' },
{ id: 'speakers-internal', label: 'Built-in speakers', kind: 'speakers' },
{ id: 'headphones-jack', label: 'Headphones (3.5mm)', kind: 'headphones' },
{ id: 'hdmi-tv', label: 'HDMI Living-room TV', kind: 'hdmi' },
{ id: 'bt-marshall', label: 'Bluetooth Marshall Stanmore', kind: 'bluetooth' },
{ id: 'usb-audient', label: 'USB Audient EVO 4', kind: 'usb' }
];
const app = document.getElementById('app');
const state = {
user: null,
rooms: [],
roomSlug: null,
room: null,
peers: [],
devices: { list: [], current: 'default' },
np: {
stationId: null, station: null, playing: false,
loading: false, volume: 0.7, error: null
},
voteStats: null,
favorites: [],
favGenre: '', // active genre filter for favorites browser
showOutputs: false, // output picker is hidden behind a button
session: null // { id, stationId, startedAt } for the open play_history row
};
const native = window.oradioNative || null;
let ws = null;
let player = null;
async function bootstrap() {
try { state.user = await api.get('/api/auth/me'); }
catch { return showLogin(); }
// Pick the room: ?room=<slug> wins, else first server-side room, else personal.
const params = new URLSearchParams(location.search);
const wanted = params.get('room');
try {
state.rooms = await api.get('/api/rooms');
} catch { state.rooms = []; }
state.roomSlug = wanted
|| (state.rooms[0] && state.rooms[0].slug)
|| `u-${state.user.id}`;
// Initial device list.
if (native?.listOutputs) {
state.devices.list = await native.listOutputs();
state.devices.current = (await native.getCurrent()) || state.devices.list[0]?.id;
native.onCurrentChanged?.((id) => { state.devices.current = id; advertiseDevices(); render(); });
} else {
state.devices.list = FAKE_DEVICES;
state.devices.current = 'default';
}
player = new Player({
onState: (s) => {
Object.assign(state.np, s);
// Push display truth out to the room.
sendState();
render();
}
});
// Load favorites so the touch browser + heart indicator work.
try { state.favorites = await api.get('/api/me/favorites'); }
catch { state.favorites = []; }
openWs();
render();
}
// Best-effort session flush on tab close so total_play_ms stays honest.
if (typeof window !== 'undefined') {
window.addEventListener('pagehide', () => endCurrentSession({ beacon: true }));
window.addEventListener('beforeunload', () => endCurrentSession({ beacon: true }));
}
function openWs() {
if (ws) { try { ws.close(); } catch { } }
ws = connectWs(handleWs, {
room: state.roomSlug,
kind: 'display',
onOpen: () => advertiseDevices()
});
}
function handleWs(msg) {
if (!msg || !msg.type) return;
switch (msg.type) {
case 'hello': {
state.room = msg.room;
state.peers = msg.peers || [];
// If the server thinks another display already owns this room we
// were demoted to 'panel' — surface that.
if (msg.you?.kind && msg.you.kind !== 'display') {
state.np.error = `This room already has a display (${countDisplays(msg.peers)} active). You were joined as ${msg.you.kind}.`;
}
// Resume room state when (re-)connecting: play whatever the room
// thinks is current, unless we're already on it.
const rs = msg.state;
if (rs?.station_id && rs.station && rs.station_id !== state.np.stationId) {
playStation(rs.station, { silent: true });
}
if (typeof rs?.volume === 'number') {
player.setVolume(rs.volume);
}
render();
return;
}
case 'presence':
state.peers = msg.peers || [];
render();
return;
case 'command':
handleCommand(msg);
return;
case 'vote':
case 'plays':
if (msg.stationId === state.np.stationId) {
state.voteStats = { ...(state.voteStats || {}), ...(msg.stats || {}) };
if (msg.type === 'plays') state.voteStats.plays = msg.plays;
render();
}
return;
default:
return;
}
}
function handleCommand(msg) {
switch (msg.action) {
case 'play': {
const id = Number(msg.stationId);
if (!Number.isFinite(id)) return;
api.get(`/api/stations/${id}`).then((st) => playStation(st)).catch(() => { });
return;
}
case 'pause':
player.togglePause();
return;
case 'stop':
player.stop();
endCurrentSession();
state.np.playing = false;
state.np.stationId = null;
sendState();
render();
return;
case 'volume':
if (typeof msg.value === 'number') player.setVolume(msg.value);
return;
case 'setSink':
setSink(String(msg.deviceId || ''));
return;
default:
return;
}
}
async function playStation(station, { silent } = {}) {
if (!station) return;
// Close any previous session before swapping. We compute the duration
// locally so resumes-after-suspend don't get charged the whole gap.
endCurrentSession();
state.np.station = station;
state.np.stationId = station.id;
state.voteStats = {
up: station.up || 0, down: station.down || 0,
plays: station.plays || 0, score: station.score || 0
};
render();
await player.play(station);
if (!silent) {
try {
const stats = await api.post(`/api/stations/${station.id}/play`);
// The same station may have been swapped out while the POST was in
// flight — only retain the session id when it's still current.
if (state.np.stationId === station.id) {
state.session = { id: stats.sessionId, stationId: station.id, startedAt: Date.now() };
state.voteStats = { ...state.voteStats, ...stats };
} else if (stats.sessionId) {
// We've already moved on; close the just-opened session immediately.
api.post(`/api/stations/${station.id}/play/end`, {
sessionId: stats.sessionId, duration_ms: 0
}).catch(() => { });
}
} catch { /* network blip — best-effort counter */ }
}
}
// Close whichever session is currently open. Idempotent.
function endCurrentSession({ beacon = false } = {}) {
const s = state.session;
if (!s || !s.id) return;
state.session = null;
const body = { sessionId: s.id, duration_ms: Math.max(0, Date.now() - s.startedAt) };
const url = `/api/stations/${s.stationId}/play/end`;
if (beacon && typeof navigator !== 'undefined' && navigator.sendBeacon) {
try {
navigator.sendBeacon(url, new Blob([JSON.stringify(body)], { type: 'application/json' }));
return;
} catch { /* fall through */ }
}
api.post(url, body).catch(() => { });
}
function sendState() {
if (!ws || !state.np.stationId) {
ws?.send({
type: 'state',
stationId: state.np.stationId,
playing: !!state.np.playing,
volume: state.np.volume
});
return;
}
ws.send({
type: 'state',
stationId: state.np.stationId,
playing: !!state.np.playing,
volume: state.np.volume
});
}
function advertiseDevices() {
ws?.send({
type: 'devices',
list: state.devices.list,
current: state.devices.current
});
}
async function setSink(deviceId) {
if (!deviceId) return;
if (native?.setOutput) {
try { await native.setOutput(deviceId); }
catch (err) { console.warn('[master] setOutput failed', err); return; }
}
state.devices.current = deviceId;
// Browser-only fallback: try `audio.setSinkId` if the device id maps to a
// real MediaDevices id. For the fake list this is a no-op visualisation.
if (player?.audio?.setSinkId && /^[a-f0-9]{16,}$/.test(deviceId)) {
try { await player.audio.setSinkId(deviceId); } catch { }
}
advertiseDevices();
state.showOutputs = false;
render();
}
function countDisplays(peers) {
return (peers || []).filter((p) => p.kind === 'display').length;
}
// ---------- Favorites ----------
function isFavorite(stationId) {
return !!stationId && state.favorites.some((f) => f.id === stationId);
}
async function toggleFavorite(stationId) {
if (!stationId) return;
const has = isFavorite(stationId);
try {
if (has) await api.del(`/api/me/favorites/${stationId}`);
else await api.put(`/api/me/favorites/${stationId}`, { position: state.favorites.length });
state.favorites = await api.get('/api/me/favorites');
render();
} catch (err) {
console.warn('[master] toggleFavorite failed', err);
}
}
function favoriteGenres() {
const counts = new Map();
for (const s of state.favorites) {
for (const g of (s.genres || [])) counts.set(g, (counts.get(g) || 0) + 1);
}
return [...counts.entries()]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.map(([g, n]) => ({ genre: g, count: n }));
}
function filteredFavorites() {
if (!state.favGenre) return state.favorites;
return state.favorites.filter((s) => (s.genres || []).includes(state.favGenre));
}
// ---------- Render ----------
function render() {
// Preserve scroll position of the favorites grid across re-renders so that
// tapping a tile (which triggers a full re-render) does not jump back to top.
const prevFavScroll = app.querySelector('.favs-grid')?.scrollLeft ?? 0;
clear(app);
const np = state.np;
const st = np.station;
const artUrl = st?.image_display_url || st?.image_url || null;
const fav = isFavorite(st?.id);
const shell = el('div', { class: 'master' },
// Topbar
el('header', { class: 'topbar' },
el('h1', {}, '◉ MASTER'),
el('div', { class: 'pill' },
el('span', {}, 'Room:'),
el('select', {
onChange: (e) => {
state.roomSlug = e.target.value;
history.replaceState(null, '', `?room=${encodeURIComponent(state.roomSlug)}`);
openWs();
}
}, ...state.rooms.map((r) =>
el('option', { value: r.slug, selected: r.slug === state.roomSlug }, r.name)))
),
el('div', { class: 'pill peers' }, `${state.peers.length} peer${state.peers.length === 1 ? '' : 's'}`),
np.error ? el('div', { class: 'err-banner' }, np.error) : null,
el('div', { class: 'grow' }),
el('button', {
class: 'pill out-btn' + (state.showOutputs ? ' active' : ''),
title: 'Audio output',
onClick: () => { state.showOutputs = !state.showOutputs; render(); }
}, '🔊 ', currentDeviceLabel()),
el('div', { class: 'pill' }, native ? 'native' : 'browser'),
el('div', { class: 'pill' }, state.user.username),
),
// Stage: now-playing block (with transport + volume embedded)
el('section', { class: 'stage' },
el('div', { class: 'np' },
el('div', { class: 'art' + (artUrl ? '' : ' empty') },
artUrl ? el('img', {
class: 'art-img',
src: artUrl,
alt: '',
referrerpolicy: 'no-referrer',
onError: (e) => {
// Fall back to the empty glyph if the image fails to load.
const parent = e.target.parentNode;
e.target.remove();
if (parent) parent.classList.add('empty');
}
}) : null
),
el('div', { class: 'meta' },
el('div', { class: 'tiny' }, np.loading ? 'Loading…' : np.playing ? 'Now playing' : st ? 'Paused' : 'Idle'),
el('div', { class: 'title-row' },
el('h2', {}, st?.name || '—'),
st ? el('button', {
class: 'fav-toggle' + (fav ? ' on' : ''),
title: fav ? 'Remove favorite' : 'Add favorite',
onClick: () => toggleFavorite(st.id)
}, fav ? '★' : '☆') : null
),
el('div', { class: 'genres' }, ...(st?.genres || []).slice(0, 6).map((g) => el('span', { class: 'tag' }, g))),
state.voteStats ? el('div', { class: 'stats' },
el('span', {}, '▲ ', el('b', {}, String(state.voteStats.up || 0))),
el('span', {}, '▼ ', el('b', {}, String(state.voteStats.down || 0))),
el('span', {}, '▶ ', el('b', {}, String(state.voteStats.plays || 0)))
) : null,
st?.country ? el('div', { class: 'stats' }, el('span', {}, st.country)) : null,
// Transport + volume embedded inside the now-playing block
el('div', { class: 'transport' },
el('button', {
class: 'ctrl primary',
title: 'Play / pause',
disabled: !st,
onClick: () => player.togglePause()
}, np.playing ? '❚❚' : '▶'),
el('button', {
class: 'ctrl',
title: 'Stop',
disabled: !st,
onClick: () => {
player.stop();
endCurrentSession();
state.np.playing = false;
sendState();
render();
}
}, '■'),
el('div', { class: 'vol' },
el('span', { class: 'vol-icon' }, '🔊'),
el('input', {
type: 'range', min: 0, max: 1, step: 0.01, value: np.volume,
onInput: (e) => player.setVolume(Number(e.target.value))
}),
el('span', { class: 'val' }, Math.round(np.volume * 100) + '%')
)
),
el('div', { class: 'peer-line' },
el('span', { class: 'peer-line-label' }, 'In room:'),
...(state.peers.length
? state.peers.map((p) => el('span', { class: 'peer role-' + p.kind },
el('span', { class: 'role-tag' }, p.kind),
el('span', {}, p.user?.username || '?')
))
: [el('span', { class: 'peer' }, 'Just you.')])
)
)
)
),
// Bottom: stations grid (2 rows of the viewport)
el('section', { class: 'stations-bar' },
renderFavoritesCard()
),
// Output picker popover (hidden by default; toggled by topbar button).
state.showOutputs ? renderOutputPopover() : null
);
app.appendChild(shell);
// Restore favorites grid scroll position after the DOM swap.
const favGrid = app.querySelector('.favs-grid');
if (favGrid) {
if (prevFavScroll) favGrid.scrollLeft = prevFavScroll;
attachDragScroll(favGrid);
}
}
// Pointer-drag horizontal scrolling for the favorites strip. Mouse users can
// click and drag like a touch surface; we suppress the click on the tile that
// was the drag origin so a drag doesn't fire a station change.
function attachDragScroll(el) {
if (el.dataset.dragBound === '1') return;
el.dataset.dragBound = '1';
let down = false;
let moved = false;
let startX = 0;
let startScroll = 0;
let pointerId = -1;
el.addEventListener('pointerdown', (e) => {
// Only left-button mouse / touch / pen; ignore wheel buttons.
if (e.pointerType === 'mouse' && e.button !== 0) return;
down = true;
moved = false;
startX = e.clientX;
startScroll = el.scrollLeft;
pointerId = e.pointerId;
});
el.addEventListener('pointermove', (e) => {
if (!down) return;
const dx = e.clientX - startX;
if (!moved && Math.abs(dx) > 5) {
moved = true;
try { el.setPointerCapture(pointerId); } catch { }
el.classList.add('dragging');
}
if (moved) {
el.scrollLeft = startScroll - dx;
e.preventDefault();
}
});
const endDrag = () => {
down = false;
if (moved) {
// Swallow the click that follows the drag-up so tiles aren't activated.
const swallow = (ev) => { ev.stopPropagation(); ev.preventDefault(); };
el.addEventListener('click', swallow, { capture: true, once: true });
setTimeout(() => el.removeEventListener('click', swallow, true), 0);
}
moved = false;
el.classList.remove('dragging');
try { el.releasePointerCapture(pointerId); } catch { }
};
el.addEventListener('pointerup', endDrag);
el.addEventListener('pointercancel', endDrag);
el.addEventListener('pointerleave', () => { if (down && !moved) down = false; });
}
function scrollFavs(direction) {
const grid = app.querySelector('.favs-grid');
if (!grid) return;
// Page by ~80% of the visible width, snapping feels natural with scroll-snap.
const delta = Math.max(160, Math.round(grid.clientWidth * 0.8));
grid.scrollBy({ left: direction * delta, behavior: 'smooth' });
}
function renderFavoritesCard() {
const genres = favoriteGenres();
const favs = filteredFavorites();
return el('div', { class: 'card favs-card' },
el('div', { class: 'favs-header' },
el('h3', {}, `Favorites (${favs.length}${state.favGenre ? `/${state.favorites.length}` : ''})`),
genres.length ? el('select', {
class: 'genre-filter',
title: 'Filter by genre',
onChange: (e) => { state.favGenre = e.target.value; render(); }
},
el('option', { value: '' }, 'All genres'),
...genres.map(({ genre, count }) =>
el('option', { value: genre, selected: state.favGenre === genre }, `${genre} (${count})`))
) : null,
el('button', {
class: 'favs-nav',
title: 'Scroll left',
onClick: () => scrollFavs(-1)
}, ''),
el('button', {
class: 'favs-nav',
title: 'Scroll right',
onClick: () => scrollFavs(1)
}, '')
),
el('div', { class: 'favs-grid' }, ...(favs.length ? favs.map((s) => {
const art = s.image_display_url || s.image_url;
const active = state.np.stationId === s.id;
return el('button', {
class: 'fav-tile' + (active ? ' active' : ''),
title: s.name,
onClick: () => playStation(s)
},
el('div', {
class: 'fav-art' + (art ? '' : ' empty'),
style: art ? { backgroundImage: `url("${art}")` } : {}
}),
el('div', { class: 'fav-name' }, s.name)
);
}) : [el('div', { class: 'favs-empty' },
state.favGenre ? 'No favorites in this genre.' : 'No favorites yet. Star a station to add it.')]))
);
}
function renderOutputPopover() {
return el('div', {
class: 'out-popover-wrap',
onClick: (e) => { if (e.target === e.currentTarget) { state.showOutputs = false; render(); } }
},
el('div', { class: 'out-popover card' },
el('div', { class: 'out-popover-head' },
el('h3', {}, 'Audio output'),
el('button', {
class: 'close', title: 'Close',
onClick: () => { state.showOutputs = false; render(); }
}, '×')
),
el('div', { class: 'device-list' }, ...state.devices.list.map((d) =>
el('button', {
class: 'device' + (d.id === state.devices.current ? ' active' : ''),
onClick: () => { setSink(d.id); }
},
el('span', { class: 'dot' }),
el('span', { class: 'name' }, d.label),
el('span', { class: 'kind' }, d.kind)
)))
)
);
}
function currentDeviceLabel() {
const d = state.devices.list.find((d) => d.id === state.devices.current);
return d ? d.label : '—';
}
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', {}, 'Master 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', { type: 'submit' }, 'Sign in')
)));
}
bootstrap();

723
web/master/style.css Normal file
View File

@@ -0,0 +1,723 @@
/* Master display: dedicated big-screen UI that owns the audio output for a room.
* In Phase 3 this same page is loaded inside an Electron window; the audio
* device picker below is a faked stand-in for the real OS device enumerator.
*
* Visual language mirrors the kiosk: flat panels, sharp corners, single accent.
*/
:root {
--bg-0: #07080b;
--bg-1: #0e1116;
--bg-2: #161a22;
--bg-3: #1f242e;
--fg: #e9ecf2;
--muted: #8a90a0;
--muted-2: #5d6373;
--line: #262b36;
--accent: #ff7a3d;
--accent-2: #ffb37a;
--accent-glow: rgba(255, 122, 61, 0.35);
--ok: #4ec9a6;
--warn: #ffd166;
--err: #ec6a6a;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
background:
radial-gradient(1200px 600px at 30% -10%, rgba(255, 122, 61, 0.06), transparent 60%),
var(--bg-0);
color: var(--fg);
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
overflow: hidden;
}
#app {
height: 100%;
width: 100%;
}
.login {
display: grid;
place-items: center;
height: 100%;
}
.login form {
display: grid;
gap: 8px;
padding: 24px;
background: var(--bg-1);
border: 1px solid var(--line);
min-width: 280px;
}
.login input,
.login button {
padding: 10px 12px;
background: var(--bg-2);
color: var(--fg);
border: 1px solid var(--line);
font-size: 14px;
}
.login button {
background: var(--accent);
color: #1a0a00;
font-weight: 700;
cursor: pointer;
border-color: var(--accent);
}
.login .err {
color: var(--err);
font-size: 12px;
min-height: 1em;
}
/* ---------- Master shell ----------
* Top bar | now-playing (height = cover art) | stations grid (rest, horizontal scroll)
*/
.master {
display: grid;
grid-template-rows: 56px auto 1fr;
height: 100%;
gap: 0;
}
.master .topbar {
display: flex;
align-items: center;
gap: 10px;
padding: 0 18px;
border-bottom: 1px solid var(--line);
background: var(--bg-1);
font-size: 13px;
}
.master .topbar h1 {
margin: 0;
font-size: 14px;
font-weight: 700;
letter-spacing: .06em;
}
.master .topbar .grow {
flex: 1;
}
.master .topbar .pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
height: 32px;
background: var(--bg-2);
border: 1px solid var(--line);
font-size: 12px;
}
.master .topbar select,
.master .topbar input,
.master .topbar button {
background: var(--bg-2);
color: var(--fg);
border: 1px solid var(--line);
height: 32px;
padding: 0 12px;
font-size: 12px;
}
.master .topbar button {
cursor: pointer;
}
.master .topbar .peers {
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.master .topbar .status-on {
color: var(--ok);
}
.master .topbar .status-off {
color: var(--muted-2);
}
/* ---------- Stage: now-playing block ---------- */
.master .stage {
padding: 12px 16px 8px;
overflow: hidden;
min-height: 0;
}
/* Block height is dictated by the square cover art — the meta column simply
* fills the same height. */
.master .np {
display: grid;
grid-template-columns: auto 1fr;
gap: 20px;
align-items: stretch;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.01)),
var(--bg-1);
border: 1px solid var(--line);
padding: 14px;
position: relative;
overflow: hidden;
}
.master .np::before {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(600px 220px at 0% 0%, var(--accent-glow), transparent 70%);
opacity: 0.35;
pointer-events: none;
}
.master .np > * {
position: relative;
}
.master .np .art {
height: clamp(180px, 28vh, 300px);
aspect-ratio: 1 / 1;
width: auto;
background: var(--bg-2);
background-size: cover;
background-position: center;
box-shadow: 0 24px 60px rgba(0, 0, 0, .6);
border: 1px solid var(--line);
position: relative;
overflow: hidden;
flex-shrink: 0;
}
.master .np .art .art-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.master .np .art.empty::after {
content: "♪";
position: absolute;
inset: 0;
display: grid;
place-items: center;
font-size: 64px;
color: var(--muted-2);
}
.master .np .meta {
display: grid;
gap: 6px;
align-content: center;
min-width: 0;
}
.master .np .meta .tiny {
color: var(--muted-2);
text-transform: uppercase;
letter-spacing: .14em;
font-size: 10px;
}
.master .np .meta h2 {
margin: 0;
font-size: clamp(20px, 2.4vw, 34px);
font-weight: 800;
line-height: 1.05;
letter-spacing: -.01em;
}
.master .np .meta .genres {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.master .np .meta .tag {
padding: 3px 10px;
background: rgba(255, 179, 122, 0.10);
color: var(--accent-2);
border: 1px solid rgba(255, 179, 122, 0.18);
font-size: 12px;
}
.master .np .meta .stats {
display: flex;
gap: 16px;
color: var(--muted);
font-size: 13px;
}
.master .np .meta .stats b {
color: var(--fg);
}
/* ---------- Transport (embedded in now-playing) ---------- */
.master .np .transport {
display: flex;
align-items: center;
gap: 10px;
margin-top: 2px;
}
.master .ctrl {
width: 38px;
height: 38px;
display: grid;
place-items: center;
background: var(--bg-2);
border: 1px solid var(--line);
color: var(--fg);
font-size: 15px;
cursor: pointer;
transition: background 120ms, border-color 120ms, transform 80ms;
}
.master .ctrl:hover:not(:disabled) {
background: var(--bg-3);
border-color: var(--accent);
}
.master .ctrl:active:not(:disabled) {
transform: scale(0.96);
}
.master .ctrl:disabled {
opacity: 0.35;
cursor: default;
}
.master .ctrl.primary {
width: 46px;
height: 46px;
background: var(--accent);
border-color: var(--accent);
color: #1a0a00;
font-size: 18px;
font-weight: 900;
box-shadow: 0 6px 20px var(--accent-glow);
}
.master .ctrl.primary:hover:not(:disabled) {
background: #ff8a55;
border-color: #ff8a55;
}
.master .vol {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 12px;
background: var(--bg-2);
border: 1px solid var(--line);
color: var(--muted);
font-size: 12px;
flex: 1;
max-width: 320px;
}
.master .vol .vol-icon {
font-size: 14px;
}
.master .vol input[type=range] {
flex: 1;
accent-color: var(--accent);
}
.master .vol .val {
width: 36px;
text-align: right;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
/* ---------- Peers line inside meta ---------- */
.master .np .peer-line {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
color: var(--muted);
font-size: 12px;
margin-top: 4px;
}
.master .np .peer-line-label {
color: var(--muted-2);
text-transform: uppercase;
letter-spacing: .1em;
font-size: 10px;
}
.master .peer {
display: inline-flex;
align-items: center;
gap: 6px;
}
.master .peer .role-tag {
font-size: 10px;
padding: 1px 6px;
background: var(--bg-2);
border: 1px solid var(--line);
text-transform: uppercase;
letter-spacing: .08em;
}
.master .peer.role-display .role-tag {
color: var(--accent-2);
border-color: rgba(255, 122, 61, 0.4);
}
/* ---------- Bottom stations bar (two rows of tiles) ---------- */
.master .stations-bar {
padding: 0 24px 20px;
overflow: hidden;
min-height: 0;
}
.master .err-banner {
background: rgba(236, 106, 106, 0.12);
border: 1px solid rgba(236, 106, 106, 0.4);
color: var(--err);
padding: 4px 10px;
font-size: 12px;
margin-left: 12px;
}
/* ---------- Cover art now-playing extras ---------- */
.master .np .meta .title-row {
display: flex;
align-items: center;
gap: 14px;
}
.master .np .meta .title-row h2 {
margin: 0;
flex: 1;
min-width: 0;
word-break: break-word;
}
.master .np .meta .fav-toggle {
width: 40px;
height: 40px;
background: var(--bg-2);
border: 1px solid var(--line);
color: var(--muted);
font-size: 20px;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
flex-shrink: 0;
transition: background 120ms, color 120ms, border-color 120ms;
}
.master .np .meta .fav-toggle:hover {
border-color: var(--accent);
color: var(--accent-2);
}
.master .np .meta .fav-toggle.on {
background: linear-gradient(180deg, rgba(255, 122, 61, 0.25), rgba(255, 122, 61, 0.12));
border-color: rgba(255, 122, 61, 0.5);
color: var(--accent);
text-shadow: 0 0 12px var(--accent-glow);
}
/* ---------- Output picker (hidden behind button) ---------- */
.master .topbar .out-btn {
cursor: pointer;
max-width: 280px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.master .topbar .out-btn.active {
border-color: var(--accent);
color: var(--accent-2);
}
.out-popover-wrap {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: grid;
place-items: start center;
padding-top: 64px;
z-index: 50;
backdrop-filter: blur(4px);
}
.out-popover {
width: min(440px, 92vw);
max-height: 70vh;
overflow: auto;
background: var(--bg-1);
border: 1px solid var(--line);
padding: 14px;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.5);
}
.out-popover-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.out-popover-head h3 {
flex: 1;
margin: 0;
font-size: 12px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: .12em;
}
.out-popover .close {
width: 32px;
height: 32px;
background: var(--bg-2);
border: 1px solid var(--line);
color: var(--fg);
font-size: 18px;
cursor: pointer;
}
.out-popover .device-list {
display: grid;
gap: 6px;
}
.out-popover .device {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
background: var(--bg-2);
border: 1px solid var(--line);
cursor: pointer;
font-size: 13px;
color: var(--fg);
text-align: left;
}
.out-popover .device:hover {
border-color: var(--accent);
}
.out-popover .device.active {
background: linear-gradient(180deg, rgba(255, 122, 61, 0.18), rgba(255, 122, 61, 0.08));
border-color: rgba(255, 122, 61, 0.4);
color: var(--accent-2);
}
.out-popover .device .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--muted-2);
flex-shrink: 0;
}
.out-popover .device.active .dot {
background: var(--accent);
box-shadow: 0 0 8px var(--accent-glow);
}
.out-popover .device .name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.out-popover .device .kind {
color: var(--muted-2);
font-size: 11px;
text-transform: uppercase;
letter-spacing: .06em;
}
/* ---------- Favorites browser (bottom 2-row touch grid) ---------- */
.master .favs-card {
background: var(--bg-1);
border: 1px solid var(--line);
padding: 10px 12px;
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
}
.master .favs-card .favs-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
flex-shrink: 0;
}
.master .favs-card .favs-header h3 {
flex: 1;
margin: 0;
font-size: 12px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: .12em;
font-weight: 600;
}
.master .favs-card .genre-filter {
background: var(--bg-2);
color: var(--fg);
border: 1px solid var(--line);
padding: 4px 8px;
font-size: 12px;
max-width: 200px;
}
.master .favs-nav {
width: 32px;
height: 32px;
display: grid;
place-items: center;
background: var(--bg-2);
border: 1px solid var(--line);
color: var(--fg);
font-size: 20px;
line-height: 1;
cursor: pointer;
transition: background 120ms, border-color 120ms, transform 80ms;
}
.master .favs-nav:hover {
background: var(--bg-3);
border-color: var(--accent);
}
.master .favs-nav:active {
transform: scale(0.94);
}
/* Two-row horizontal-scrolling grid of square tiles. Tile height = half the
* container height; aspect-ratio 1/1 makes them visually square. */
.master .favs-grid {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
flex-wrap: wrap;
align-content: flex-start;
overflow-x: auto;
overflow-y: hidden;
gap: 8px;
padding: 2px 2px 8px;
scroll-snap-type: x proximity;
scrollbar-gutter: stable;
cursor: grab;
user-select: none;
scroll-behavior: smooth;
}
.master .favs-grid.dragging {
cursor: grabbing;
scroll-behavior: auto;
}
.master .favs-grid.dragging .fav-tile {
pointer-events: none;
}
.master .favs-grid::-webkit-scrollbar {
height: 10px;
}
.master .fav-tile {
display: grid;
grid-template-rows: 1fr auto;
gap: 6px;
padding: 6px;
background: var(--bg-2);
border: 1px solid var(--line);
color: var(--fg);
cursor: pointer;
text-align: left;
transition: transform 80ms ease, border-color 120ms, background 120ms;
height: calc((100% - 8px) / 2);
aspect-ratio: 1 / 1;
flex-shrink: 0;
scroll-snap-align: start;
min-height: 0;
}
.master .fav-tile:hover {
border-color: var(--accent);
background: var(--bg-3);
}
.master .fav-tile:active {
transform: scale(0.97);
}
.master .fav-tile.active {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent) inset, 0 0 16px var(--accent-glow);
}
.master .fav-art {
width: 100%;
min-height: 0;
background: var(--bg-1) center/cover no-repeat;
position: relative;
aspect-ratio: 1 / 1;
justify-self: center;
max-height: 100%;
}
.master .fav-art.empty::after {
content: "♪";
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--muted-2);
font-size: 28px;
}
.master .fav-name {
font-size: 12px;
line-height: 1.2;
color: var(--fg);
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.master .favs-empty {
color: var(--muted);
font-size: 12px;
padding: 12px 4px;
text-align: center;
width: 100%;

View File

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

View File

@@ -313,6 +313,49 @@ textarea {
align-items: center; align-items: center;
} }
/* Room sync pill: room picker + peer count + here/follow mode toggle. */
.room-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
height: 36px;
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: 999px;
font-size: 12px;
}
.room-pill .room-icon { opacity: .7; }
.room-pill .room-select {
background: transparent;
color: var(--fg);
border: none;
outline: none;
font-size: 12px;
max-width: 140px;
}
.room-pill .room-peers {
color: var(--muted-2);
font-variant-numeric: tabular-nums;
min-width: 18px;
text-align: center;
}
.room-pill .room-mode {
height: 26px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid var(--line);
background: var(--bg-1);
color: var(--fg);
font-size: 11px;
cursor: pointer;
}
.room-pill .room-mode.follow-room {
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);
}
.search { .search {
width: 220px; width: 220px;
padding: 8px 12px; padding: 8px 12px;