Files
radio-explorer/web/master/main.js
Marco Mooren b86dcfbb8d 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.
2026-05-11 17:55:09 +02:00

603 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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();