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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
data/db/
|
||||
data/images/
|
||||
.env
|
||||
.DS_Store
|
||||
*.log
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"dev:api": "node --watch server/index.js",
|
||||
"build": "vite build",
|
||||
"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": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
|
||||
@@ -34,6 +34,12 @@ function runMigrations(db) {
|
||||
if (!stationCols.has('category')) {
|
||||
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));
|
||||
if (!streamCols.has('uuid')) {
|
||||
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_streams_uuid ON streams(uuid)');
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ CREATE TABLE IF NOT EXISTS stations (
|
||||
genres TEXT, -- JSON array
|
||||
description 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_ref 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
|
||||
-- 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 (
|
||||
station_id INTEGER PRIMARY KEY REFERENCES stations(id) ON DELETE CASCADE,
|
||||
plays INTEGER NOT NULL DEFAULT 0,
|
||||
sessions INTEGER NOT NULL DEFAULT 0,
|
||||
total_play_ms INTEGER NOT NULL DEFAULT 0,
|
||||
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
|
||||
);
|
||||
|
||||
|
||||
@@ -10,12 +10,14 @@ import { authMiddleware, ensureBootstrapAdmin } from './auth.js';
|
||||
import { applySeedIfEmpty } from './sources/seed.js';
|
||||
import { scheduleHealthCheck } from './streams/checker.js';
|
||||
import { attachWs } from './ws.js';
|
||||
import { ensureImageDirs, getImageRoot } from './media/images.js';
|
||||
|
||||
import { router as authRoutes } from './routes/auth.js';
|
||||
import { router as stationRoutes } from './routes/stations.js';
|
||||
import { router as meRoutes } from './routes/me.js';
|
||||
import { router as adminRoutes } from './routes/admin.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 PORT = Number(process.env.PORT) || 4173;
|
||||
@@ -27,6 +29,7 @@ ensureBootstrapAdmin({
|
||||
});
|
||||
const seedResult = applySeedIfEmpty();
|
||||
console.log('[seed]', seedResult);
|
||||
ensureImageDirs();
|
||||
|
||||
const app = express();
|
||||
app.use(express.json({ limit: '512kb' }));
|
||||
@@ -36,15 +39,40 @@ app.use('/api/auth', authRoutes);
|
||||
app.use('/api/stations', stationRoutes);
|
||||
app.use('/api/me', meRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
app.use('/api/rooms', roomRoutes);
|
||||
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.
|
||||
// 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 sendHtml = (file) => (_req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
res.sendFile(resolve(publicDir, file));
|
||||
};
|
||||
if (existsSync(publicDir)) {
|
||||
app.use(express.static(publicDir));
|
||||
app.get('/admin', (_req, res) => res.sendFile(resolve(publicDir, 'admin/index.html')));
|
||||
app.get('/docs', (_req, res) => res.sendFile(resolve(publicDir, 'docs/index.html')));
|
||||
app.get('*', (_req, res) => res.sendFile(resolve(publicDir, 'index.html')));
|
||||
app.use(express.static(publicDir, {
|
||||
setHeaders(res, filePath) {
|
||||
if (filePath.endsWith('.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) => {
|
||||
|
||||
168
server/media/images.js
Normal file
168
server/media/images.js
Normal 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 };
|
||||
}
|
||||
@@ -5,10 +5,10 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Radio Admin</title>
|
||||
|
||||
|
||||
<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/dom-BvorgAdo.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-B5Qt9EMX.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/player-BBOsFRH-.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/admin-C-qnWY0z.css">
|
||||
</head>
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
server/public/assets/admin-GqZPhz-K.js
Normal file
1
server/public/assets/admin-GqZPhz-K.js
Normal file
File diff suppressed because one or more lines are too long
@@ -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
1
server/public/assets/kiosk-CzWLja7k.js
Normal file
1
server/public/assets/kiosk-CzWLja7k.js
Normal file
File diff suppressed because one or more lines are too long
1
server/public/assets/kiosk-PzkUrLf6.css
Normal file
1
server/public/assets/kiosk-PzkUrLf6.css
Normal file
File diff suppressed because one or more lines are too long
1
server/public/assets/master-CpJfsvtJ.css
Normal file
1
server/public/assets/master-CpJfsvtJ.css
Normal file
File diff suppressed because one or more lines are too long
1
server/public/assets/master-kSyrThjc.js
Normal file
1
server/public/assets/master-kSyrThjc.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
server/public/assets/ws-BM1PmMVd.js
Normal file
1
server/public/assets/ws-BM1PmMVd.js
Normal 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};
|
||||
@@ -5,10 +5,11 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=1080, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
||||
<title>Radio Kiosk</title>
|
||||
|
||||
|
||||
<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/dom-BvorgAdo.js">
|
||||
<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/kiosk-PzkUrLf6.css">
|
||||
</head>
|
||||
|
||||
|
||||
19
server/public/master/index.html
Normal file
19
server/public/master/index.html
Normal 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
142
server/rooms.js
Normal 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);
|
||||
}
|
||||
@@ -1,14 +1,29 @@
|
||||
import { Router } from 'express';
|
||||
import express from 'express';
|
||||
import { requireAdmin } from '../auth.js';
|
||||
import { runHealthCheck } from '../streams/checker.js';
|
||||
import { probeStream } from '../streams/probe.js';
|
||||
import { applySeedIfEmpty } from '../sources/seed.js';
|
||||
import { getDb } from '../db/index.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();
|
||||
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) => {
|
||||
const n = await runHealthCheck();
|
||||
res.json({ checked: n });
|
||||
@@ -20,25 +35,31 @@ router.post('/reseed', (_req, res) => {
|
||||
|
||||
router.get('/system', (_req, res) => {
|
||||
const db = getDb();
|
||||
const img = imageCacheStats();
|
||||
res.json({
|
||||
stations: db.prepare('SELECT COUNT(*) AS n FROM stations').get().n,
|
||||
streams: db.prepare('SELECT COUNT(*) AS n FROM streams').get().n,
|
||||
users: db.prepare('SELECT COUNT(*) AS n FROM users').get().n,
|
||||
favorites: db.prepare('SELECT COUNT(*) AS n FROM favorites').get().n,
|
||||
image_cache: img,
|
||||
node: process.version,
|
||||
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) => {
|
||||
const id = Number(req.params.id);
|
||||
const st = getStation(id);
|
||||
if (!st) return res.status(404).json({ error: 'not found' });
|
||||
const url = await scrapeIcon(st);
|
||||
if (!url) return res.status(404).json({ error: 'no icon found' });
|
||||
const updated = updateStation(id, { image_url: url });
|
||||
res.json({ id, image_url: url, station: updated });
|
||||
// Persist the remote URL as the canonical source...
|
||||
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).
|
||||
@@ -56,8 +77,9 @@ router.post('/scrape-icons', async (req, res) => {
|
||||
const url = await scrapeIcon(s);
|
||||
if (url) {
|
||||
updateStation(s.id, { image_url: url });
|
||||
const rel = await saveStationImageFromUrl(s.id, url, { source: 'scraped' });
|
||||
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 {
|
||||
results.failed++;
|
||||
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));
|
||||
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
61
server/routes/rooms.js
Normal 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));
|
||||
});
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
import { resolveStream } from '../streams/resolver.js';
|
||||
import { requireAdmin, requireUser } from '../auth.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();
|
||||
|
||||
@@ -51,15 +52,41 @@ router.post('/:id/vote', requireUser, (req, res) => {
|
||||
: raw === 0 || raw === '0' || raw === null || raw === 'clear' ? 0
|
||||
: NaN;
|
||||
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).
|
||||
// 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) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!getStation(id)) return res.status(404).json({ error: 'not found' });
|
||||
recordPlay(id);
|
||||
res.json(getStationStats(id, req.user.id));
|
||||
const streamId = Number.isFinite(Number(req.body?.streamId)) ? Number(req.body.streamId) : null;
|
||||
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) => {
|
||||
|
||||
@@ -50,7 +50,9 @@ function publicStation(s) {
|
||||
country: s.country,
|
||||
genres: s.genres,
|
||||
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,
|
||||
enabled: s.enabled,
|
||||
up: s.up ?? 0,
|
||||
|
||||
65
server/scripts/download-images.js
Normal file
65
server/scripts/download-images.js
Normal 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);
|
||||
@@ -7,7 +7,9 @@
|
||||
// 3. HEAD-probe /favicon.ico at the homepage origin.
|
||||
// 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 MAX_HTML_BYTES = 256 * 1024;
|
||||
const RB_BASE = 'https://de1.api.radio-browser.info';
|
||||
@@ -45,13 +47,47 @@ async function fetchText(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);
|
||||
try {
|
||||
const res = await fetch(url, { method: 'HEAD', headers: { 'User-Agent': UA }, signal: t.signal, redirect: 'follow' });
|
||||
return res.ok;
|
||||
const res = await fetch(url, {
|
||||
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(); }
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!href) return null;
|
||||
try { return new URL(href, base).toString(); } catch { return null; }
|
||||
@@ -93,14 +129,16 @@ function parseIconCandidates(html, baseUrl) {
|
||||
|
||||
async function fromRadioBrowserByName(name) {
|
||||
if (!name) return null;
|
||||
const q = String(name).trim();
|
||||
if (!q) return null;
|
||||
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 res = await fetch(url, { headers: { 'User-Agent': UA }, signal: t.signal });
|
||||
t.done();
|
||||
if (!res.ok) return null;
|
||||
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 pick = exact || list[0];
|
||||
if (pick?.favicon) return pick.favicon;
|
||||
@@ -119,12 +157,23 @@ async function fromHomepage(homepage) {
|
||||
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`;
|
||||
if (await head(ico)) return ico;
|
||||
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.
|
||||
* @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.
|
||||
if (station.source !== 'radiobrowser') {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { getDb } from './db/index.js';
|
||||
|
||||
function rowToStation(row) {
|
||||
if (!row) return null;
|
||||
const imagePath = row.image_path || null;
|
||||
const remote = row.image_url || null;
|
||||
return {
|
||||
id: row.id,
|
||||
uuid: row.uuid,
|
||||
@@ -12,7 +14,12 @@ function rowToStation(row) {
|
||||
country: row.country,
|
||||
genres: row.genres ? JSON.parse(row.genres) : [],
|
||||
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_ref: row.source_ref,
|
||||
category: row.category,
|
||||
|
||||
111
server/stats.js
111
server/stats.js
@@ -1,23 +1,47 @@
|
||||
// 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
|
||||
// - 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:
|
||||
// * A handful of downvotes on an obscure station sinks it hard.
|
||||
// * 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.
|
||||
|
||||
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 voteZ = n === 0 ? 0 : (up - down) / Math.sqrt(n + 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) {
|
||||
@@ -28,14 +52,15 @@ export function getStationStats(stationId, userId = null) {
|
||||
COALESCE(SUM(CASE WHEN value = -1 THEN 1 ELSE 0 END), 0) AS down
|
||||
FROM station_votes WHERE station_id = ?
|
||||
`).get(stationId) || { up: 0, down: 0 };
|
||||
const p = db.prepare('SELECT plays FROM station_plays WHERE station_id = ?').get(stationId);
|
||||
const plays = p?.plays || 0;
|
||||
const p = db.prepare(`
|
||||
SELECT plays, sessions, total_play_ms FROM station_plays WHERE station_id = ?
|
||||
`).get(stationId) || {};
|
||||
let myVote = 0;
|
||||
if (userId) {
|
||||
const r = db.prepare('SELECT value FROM station_votes WHERE user_id = ? AND station_id = ?').get(userId, stationId);
|
||||
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>.
|
||||
@@ -46,7 +71,9 @@ export function getStatsMap(userId = null) {
|
||||
s.id AS station_id,
|
||||
COALESCE(v.up, 0) AS up,
|
||||
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
|
||||
LEFT JOIN (
|
||||
SELECT station_id,
|
||||
@@ -65,11 +92,7 @@ export function getStatsMap(userId = null) {
|
||||
}
|
||||
const out = new Map();
|
||||
for (const r of rows) {
|
||||
const 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 })
|
||||
});
|
||||
out.set(r.station_id, { ...statsFromRow(r), myVote: my.get(r.station_id) || 0 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -89,18 +112,67 @@ export function castVote(userId, stationId, value) {
|
||||
return getStationStats(stationId, userId);
|
||||
}
|
||||
|
||||
export function recordPlay(stationId) {
|
||||
getDb().prepare(`
|
||||
INSERT INTO station_plays (station_id, plays, last_played_at) VALUES (?, 1, datetime('now'))
|
||||
// Record the start of a listening session. Bumps the play counter immediately
|
||||
// (so spam-clickers still register as taps) and opens a play_history row that
|
||||
// `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
|
||||
plays = station_plays.plays + 1,
|
||||
last_played_at = datetime('now')
|
||||
`).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.
|
||||
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) {
|
||||
case 'hot':
|
||||
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':
|
||||
items.sort((a, b) => s(b.id).plays - s(a.id).plays || a.name.localeCompare(b.name));
|
||||
break;
|
||||
case 'playtime':
|
||||
items.sort((a, b) => s(b.id).totalPlayMs - s(a.id).totalPlayMs || a.name.localeCompare(b.name));
|
||||
break;
|
||||
case 'controversial':
|
||||
items.sort((a, b) => {
|
||||
const A = s(a.id), B = s(b.id);
|
||||
|
||||
217
server/ws.js
217
server/ws.js
@@ -1,8 +1,102 @@
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { getUserBySession, readSessionToken } from './auth.js';
|
||||
// Room-aware WebSocket hub.
|
||||
//
|
||||
// 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.
|
||||
const channels = new Map(); // userId -> Set<ws>
|
||||
import { WebSocketServer } from '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) {
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
@@ -16,41 +110,110 @@ export function attachWs(server) {
|
||||
socket.destroy();
|
||||
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) => {
|
||||
ws.user = user;
|
||||
addClient(user.id, ws);
|
||||
ws.on('close', () => removeClient(user.id, ws));
|
||||
ws.kind = kind;
|
||||
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) => {
|
||||
let msg;
|
||||
try { msg = JSON.parse(raw.toString()); } catch { return; }
|
||||
// Re-broadcast every message to all connections of the same user.
|
||||
// (e.g. phone sends `{type:"command", action:"play", stationId:7}` → kiosk receives)
|
||||
broadcastToUser(user.id, msg, ws);
|
||||
handleClientMessage(ws, msg);
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
function addClient(userId, ws) {
|
||||
if (!channels.has(userId)) channels.set(userId, new Set());
|
||||
channels.get(userId).add(ws);
|
||||
}
|
||||
function removeClient(userId, ws) {
|
||||
const set = channels.get(userId);
|
||||
if (!set) return;
|
||||
set.delete(ws);
|
||||
if (!set.size) channels.delete(userId);
|
||||
}
|
||||
function handleClientMessage(ws, msg) {
|
||||
if (!msg || typeof msg !== 'object') return;
|
||||
const slug = ws.room.slug;
|
||||
|
||||
export function broadcastToUser(userId, msg, except) {
|
||||
const set = channels.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);
|
||||
switch (msg.type) {
|
||||
case 'command': {
|
||||
// Controllers express intent. Forward to all peers; the display
|
||||
// is responsible for actually changing audio output. We also
|
||||
// optimistically reflect simple intents into room_state so a
|
||||
// late-joining peer sees the latest target station/volume even
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@ export default defineConfig({
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/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 }
|
||||
}
|
||||
},
|
||||
@@ -18,7 +22,8 @@ export default defineConfig({
|
||||
input: {
|
||||
kiosk: resolve(__dirname, 'web/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
@@ -208,3 +208,236 @@ dialog .actions { padding-top: 12px; border-top: 1px solid #cccccc; }
|
||||
}
|
||||
.stat .v { font-weight: 900; letter-spacing: -0.01em; }
|
||||
.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); }
|
||||
|
||||
243
web/main.js
243
web/main.js
@@ -15,7 +15,16 @@ const state = {
|
||||
query: '',
|
||||
sort: 'hot', // hot | top | plays | name | controversial — applied in Browse
|
||||
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({
|
||||
@@ -34,11 +43,52 @@ async function bootstrap() {
|
||||
return;
|
||||
}
|
||||
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();
|
||||
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() {
|
||||
const [stations, favs, history, categories] = await Promise.all([
|
||||
api.get(`/api/stations?sort=${encodeURIComponent(state.sort)}`),
|
||||
@@ -57,14 +107,88 @@ async function refreshStations() {
|
||||
}
|
||||
|
||||
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) {
|
||||
const st = state.stations.find((s) => s.id === msg.stationId);
|
||||
if (st) playStation(st);
|
||||
} else if (msg.action === 'pause') player.togglePause();
|
||||
else if (msg.action === 'volume') player.setVolume(msg.value);
|
||||
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() {
|
||||
@@ -134,20 +258,37 @@ function render() {
|
||||
el('button', {
|
||||
class: `btn-play ${p.loading ? 'loading' : ''}`,
|
||||
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 ? '❚❚' : '▶'),
|
||||
el('button', {
|
||||
class: 'btn-stop',
|
||||
title: 'Stop',
|
||||
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('span', { class: 'vol-icon' }, p.volume === 0 ? '🔇' : p.volume < 0.5 ? '🔈' : '🔊'),
|
||||
el('input', {
|
||||
type: 'range', min: 0, max: 1, step: 0.05, value: p.volume,
|
||||
'aria-label': 'Volume',
|
||||
onInput: (e) => player.setVolume(Number(e.target.value))
|
||||
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))
|
||||
)
|
||||
@@ -166,6 +307,7 @@ function render() {
|
||||
)
|
||||
),
|
||||
el('div', { class: 'header-tools' },
|
||||
renderRoomPill(),
|
||||
state.tab === 'browse'
|
||||
? el('select', {
|
||||
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() {
|
||||
return el('div', { class: 'chips' },
|
||||
el('button', {
|
||||
@@ -282,10 +448,10 @@ function paintGrid(grid, favIds) {
|
||||
onContextMenu: (e) => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, s); }
|
||||
},
|
||||
el('div', { class: 'art' },
|
||||
s.image_url
|
||||
(s.image_display_url || s.image_url)
|
||||
? el('img', {
|
||||
class: 'art-img',
|
||||
src: s.image_url,
|
||||
src: s.image_display_url || s.image_url,
|
||||
alt: '',
|
||||
loading: 'lazy',
|
||||
referrerpolicy: 'no-referrer',
|
||||
@@ -373,6 +539,35 @@ function recordHistory(stationId) {
|
||||
// so the up/down buttons in the now-playing bar reflect the current station.
|
||||
async function playStation(station) {
|
||||
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);
|
||||
recordHistory(station.id);
|
||||
try {
|
||||
@@ -380,9 +575,17 @@ async function playStation(station) {
|
||||
// Only apply if user hasn't switched stations in the meantime.
|
||||
if (state.player.stationId === station.id) {
|
||||
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);
|
||||
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) {
|
||||
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) {
|
||||
const id = state.player.stationId;
|
||||
if (!id) return;
|
||||
|
||||
16
web/master/index.html
Normal file
16
web/master/index.html
Normal 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
602
web/master/main.js
Normal 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
723
web/master/style.css
Normal 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%;
|
||||
@@ -1,13 +1,18 @@
|
||||
export function connectWs(onMessage) {
|
||||
export function connectWs(onMessage, opts = {}) {
|
||||
let ws, retry = 0, closed = false;
|
||||
function open() {
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
ws = new WebSocket(`${proto}://${location.host}/ws`);
|
||||
ws.addEventListener('open', () => { retry = 0; });
|
||||
const params = new URLSearchParams();
|
||||
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) => {
|
||||
try { onMessage(JSON.parse(ev.data)); } catch { }
|
||||
});
|
||||
ws.addEventListener('close', () => {
|
||||
opts.onClose?.();
|
||||
if (closed) return;
|
||||
retry = Math.min(retry + 1, 6);
|
||||
setTimeout(open, 500 * 2 ** retry);
|
||||
@@ -17,6 +22,7 @@ export function connectWs(onMessage) {
|
||||
open();
|
||||
return {
|
||||
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; }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -313,6 +313,49 @@ textarea {
|
||||
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 {
|
||||
width: 220px;
|
||||
padding: 8px 12px;
|
||||
|
||||
Reference in New Issue
Block a user