Add master display UI with audio output management and styling

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

View File

@@ -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) => {