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

@@ -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,16 +107,90 @@ async function refreshStations() {
}
function handleWs(msg) {
if (msg.type === 'command') {
if (msg.action === 'play' && msg.stationId) {
const st = state.stations.find((s) => s.id === msg.stationId);
if (st) 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();
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() {
clear(app);
const overlay = el('div', { class: 'login' },
@@ -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;