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:
255
web/main.js
255
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,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;
|
||||
|
||||
Reference in New Issue
Block a user