- 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.
808 lines
34 KiB
JavaScript
808 lines
34 KiB
JavaScript
import { api } from './shared/api.js';
|
|
import { connectWs } from './shared/ws.js';
|
|
import { el, clear } from './shared/dom.js';
|
|
import { Player } from './player.js';
|
|
|
|
const app = document.getElementById('app');
|
|
const state = {
|
|
user: null,
|
|
tab: 'favorites', // favorites | browse | recent
|
|
stations: [],
|
|
categories: [],
|
|
selectedCategory: null,
|
|
favorites: [],
|
|
history: [],
|
|
query: '',
|
|
sort: 'hot', // hot | top | plays | name | controversial — applied in Browse
|
|
randomMode: localStorage.getItem('oradio.randomMode') === 'favorites' ? 'favorites' : 'all',
|
|
// 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({
|
|
onState: (s) => {
|
|
state.player = { ...state.player, ...s };
|
|
render();
|
|
}
|
|
});
|
|
let ws;
|
|
|
|
async function bootstrap() {
|
|
try {
|
|
state.user = await api.get('/api/auth/me');
|
|
} catch {
|
|
showLogin();
|
|
return;
|
|
}
|
|
await refreshAll();
|
|
// 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)}`),
|
|
api.get('/api/me/favorites').catch(() => []),
|
|
api.get('/api/me/history').catch(() => []),
|
|
api.get('/api/v1/categories').catch(() => [])
|
|
]);
|
|
state.stations = stations;
|
|
state.favorites = favs;
|
|
state.history = history;
|
|
state.categories = categories;
|
|
}
|
|
|
|
async function refreshStations() {
|
|
state.stations = await api.get(`/api/stations?sort=${encodeURIComponent(state.sort)}`);
|
|
}
|
|
|
|
function handleWs(msg) {
|
|
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' },
|
|
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', {}, 'Sign in'),
|
|
el('input', { name: 'username', placeholder: 'Username', autocomplete: 'username', required: true }),
|
|
el('input', { name: 'password', type: 'password', placeholder: 'Password', autocomplete: 'current-password', required: true }),
|
|
el('div', { class: 'err' }),
|
|
el('button', { type: 'submit' }, 'Continue')
|
|
)
|
|
);
|
|
app.appendChild(overlay);
|
|
}
|
|
|
|
let savedGridScroll = 0;
|
|
function render() {
|
|
if (!state.user) return;
|
|
// Capture scroll from the live grid before tearing down (in case it changed since last scroll event).
|
|
const prevGrid = app.querySelector('.grid');
|
|
if (prevGrid && prevGrid.scrollTop > 0) savedGridScroll = prevGrid.scrollTop;
|
|
closeContextMenu();
|
|
clear(app);
|
|
|
|
const p = state.player;
|
|
const favIds = new Set(state.favorites.map((f) => f.id));
|
|
const v = p.votes; // { up, down, plays, myVote, score } or null
|
|
|
|
const now = el('section', { class: 'now' },
|
|
el('div', { class: 'meta' },
|
|
el('div', { class: 'name' }, p.stationName || 'Select a station'),
|
|
el('div', { class: 'sub' },
|
|
p.loading ? 'Connecting…' : (p.playing ? 'On air' : (p.error ? p.error : (p.stationId ? 'Paused' : 'Idle')))),
|
|
el('div', { class: 'tags' }, ...(p.genres || []).slice(0, 4).map((g) => el('span', { class: 'tag' }, g)))
|
|
),
|
|
el('div', { class: 'controls' },
|
|
el('div', { class: 'vote-group', title: 'Vote on current station' },
|
|
el('button', {
|
|
class: `vote up ${v?.myVote === 1 ? 'on' : ''}`,
|
|
disabled: !p.stationId,
|
|
title: 'Upvote',
|
|
onClick: () => votePlayer(1)
|
|
}, el('span', { class: 'vote-icon' }, '▲'),
|
|
el('span', { class: 'vote-count' }, String(v?.up ?? 0))),
|
|
el('button', {
|
|
class: `vote down ${v?.myVote === -1 ? 'on' : ''}`,
|
|
disabled: !p.stationId,
|
|
title: 'Downvote',
|
|
onClick: () => votePlayer(-1)
|
|
}, el('span', { class: 'vote-icon' }, '▼'),
|
|
el('span', { class: 'vote-count' }, String(v?.down ?? 0)))
|
|
),
|
|
el('button', {
|
|
class: `btn-play ${p.loading ? 'loading' : ''}`,
|
|
title: p.playing ? 'Pause' : 'Play',
|
|
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: () => {
|
|
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) => {
|
|
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))
|
|
)
|
|
)
|
|
);
|
|
|
|
const isAdmin = state.user.role === 'admin';
|
|
const header = el('div', { class: 'header' },
|
|
el('div', { class: 'tabs' },
|
|
...['favorites', 'browse', 'recent'].map((t) =>
|
|
el('button', {
|
|
class: `tab ${state.tab === t ? 'active' : ''}`,
|
|
onClick: () => { state.tab = t; savedGridScroll = 0; render(); }
|
|
},
|
|
t === 'favorites' ? '★ Favorites' : t === 'browse' ? '🌐 Browse' : '⏱ Recent')
|
|
)
|
|
),
|
|
el('div', { class: 'header-tools' },
|
|
renderRoomPill(),
|
|
state.tab === 'browse'
|
|
? el('select', {
|
|
class: 'sort',
|
|
title: 'Sort browse list',
|
|
onChange: (e) => { state.sort = e.target.value; savedGridScroll = 0; refreshStations().then(render); }
|
|
},
|
|
el('option', { value: 'hot', selected: state.sort === 'hot' }, '🔥 Hot (smart)'),
|
|
el('option', { value: 'top', selected: state.sort === 'top' }, '▲ Top voted'),
|
|
el('option', { value: 'plays', selected: state.sort === 'plays' }, '▶ Most played'),
|
|
el('option', { value: 'controversial', selected: state.sort === 'controversial' }, '⚡ Controversial'),
|
|
el('option', { value: 'name', selected: state.sort === 'name' }, 'A → Z')
|
|
)
|
|
: null,
|
|
el('input', {
|
|
class: 'search', type: 'search', placeholder: 'Search…', value: state.query,
|
|
onInput: (e) => { state.query = e.target.value; renderGrid(); }
|
|
}),
|
|
el('button', {
|
|
class: 'btn-random',
|
|
title: `Play random station (mode: ${state.randomMode}). Right-click to switch mode.`,
|
|
onClick: playRandom,
|
|
onContextMenu: (e) => { e.preventDefault(); toggleRandomMode(); }
|
|
},
|
|
el('span', { class: 'rand-icon' }, '🎲'),
|
|
el('span', { class: 'rand-mode' }, state.randomMode === 'favorites' ? '★' : 'All')
|
|
),
|
|
el('a', {
|
|
class: 'btn-docs', href: '/docs/', target: '_blank', rel: 'noopener',
|
|
title: 'Open API reference'
|
|
}, 'API'),
|
|
isAdmin ? el('button', { class: 'btn-add', title: 'Add station', onClick: openAddStation }, '+') : null
|
|
)
|
|
);
|
|
|
|
const sec = el('section', { class: 'lib' }, header);
|
|
if (state.tab === 'browse' && state.categories.length) {
|
|
sec.appendChild(renderChips());
|
|
}
|
|
const grid = el('div', { class: 'grid' });
|
|
grid.id = 'grid';
|
|
grid.addEventListener('scroll', () => { savedGridScroll = grid.scrollTop; }, { passive: true });
|
|
sec.appendChild(grid);
|
|
|
|
app.appendChild(now);
|
|
app.appendChild(sec);
|
|
paintGrid(grid, favIds);
|
|
if (savedGridScroll) {
|
|
grid.scrollTop = savedGridScroll;
|
|
requestAnimationFrame(() => { if (savedGridScroll) grid.scrollTop = savedGridScroll; });
|
|
}
|
|
}
|
|
|
|
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', {
|
|
class: `chip ${!state.selectedCategory ? 'active' : ''}`,
|
|
onClick: () => { state.selectedCategory = null; savedGridScroll = 0; render(); }
|
|
}, `All (${state.stations.length})`),
|
|
...state.categories.filter((c) => c.count > 0).map((c) => el('button', {
|
|
class: `chip ${state.selectedCategory === c.id ? 'active' : ''}`,
|
|
onClick: () => { state.selectedCategory = c.id; savedGridScroll = 0; render(); }
|
|
}, `${c.icon || ''} ${c.label} (${c.count})`.trim()))
|
|
);
|
|
}
|
|
|
|
function visibleItems() {
|
|
let items = [];
|
|
if (state.tab === 'favorites') items = state.favorites;
|
|
else if (state.tab === 'browse') {
|
|
items = state.stations;
|
|
if (state.selectedCategory) items = items.filter((s) => s.category === state.selectedCategory);
|
|
} else if (state.tab === 'recent') {
|
|
const seen = new Set();
|
|
items = state.history.filter((h) => !seen.has(h.station_id) && seen.add(h.station_id))
|
|
.map((h) => state.stations.find((s) => s.id === h.station_id)).filter(Boolean);
|
|
}
|
|
const q = state.query.trim().toLowerCase();
|
|
if (q) {
|
|
items = items.filter((s) =>
|
|
s.name.toLowerCase().includes(q) ||
|
|
(s.country || '').toLowerCase().includes(q) ||
|
|
(s.genres || []).some((g) => g.toLowerCase().includes(q))
|
|
);
|
|
}
|
|
return items;
|
|
}
|
|
|
|
function renderGrid() {
|
|
const grid = document.getElementById('grid');
|
|
if (!grid) return;
|
|
const favIds = new Set(state.favorites.map((f) => f.id));
|
|
paintGrid(grid, favIds);
|
|
}
|
|
|
|
function paintGrid(grid, favIds) {
|
|
clear(grid);
|
|
const items = visibleItems();
|
|
if (!items.length) {
|
|
grid.appendChild(el('div', { class: 'empty' },
|
|
state.tab === 'favorites' ? 'No favorites yet — long-press or tap ★ on a station.' :
|
|
state.query ? 'No matches.' : 'Nothing here yet.'));
|
|
return;
|
|
}
|
|
const p = state.player;
|
|
for (const s of items) {
|
|
const score = typeof s.score === 'number' ? s.score : 0;
|
|
const net = (s.up ?? 0) - (s.down ?? 0);
|
|
const badgeClass = net > 0 ? 'pos' : net < 0 ? 'neg' : 'neu';
|
|
const card = el('div', {
|
|
class: `card ${p.stationId === s.id ? 'playing' : ''}`,
|
|
role: 'button',
|
|
tabindex: 0,
|
|
onClick: () => playStation(s),
|
|
onContextMenu: (e) => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, s); }
|
|
},
|
|
el('div', { class: 'art' },
|
|
(s.image_display_url || s.image_url)
|
|
? el('img', {
|
|
class: 'art-img',
|
|
src: s.image_display_url || s.image_url,
|
|
alt: '',
|
|
loading: 'lazy',
|
|
referrerpolicy: 'no-referrer',
|
|
onError: (e) => {
|
|
const parent = e.target.parentNode;
|
|
e.target.remove();
|
|
if (parent) parent.appendChild(el('span', { class: 'art-glyph' }, '♪'));
|
|
}
|
|
})
|
|
: el('span', { class: 'art-glyph' }, '♪')),
|
|
el('div', { class: 'card-body' },
|
|
el('div', { class: 'n' }, s.name),
|
|
el('div', { class: 'g' },
|
|
(s.genres || []).slice(0, 3).join(' · ') || (s.country || '—'))
|
|
),
|
|
el('div', { class: `score-badge ${badgeClass}`, title: `▲${s.up ?? 0} · ▼${s.down ?? 0} · ▶${s.plays ?? 0} · score ${score.toFixed(2)}` },
|
|
net > 0 ? `+${net}` : String(net)
|
|
),
|
|
el('button', {
|
|
class: `fav ${favIds.has(s.id) ? 'on' : ''}`,
|
|
title: favIds.has(s.id) ? 'Remove favorite' : 'Add favorite',
|
|
onClick: (e) => { e.stopPropagation(); toggleFavorite(s); }
|
|
}, favIds.has(s.id) ? '★' : '☆'),
|
|
el('button', {
|
|
class: 'more',
|
|
title: 'API endpoints',
|
|
onClick: (e) => {
|
|
e.stopPropagation();
|
|
const r = e.currentTarget.getBoundingClientRect();
|
|
openContextMenu(r.right, r.bottom, s);
|
|
}
|
|
}, '⋯')
|
|
);
|
|
grid.appendChild(card);
|
|
}
|
|
}
|
|
|
|
async function toggleFavorite(station) {
|
|
const has = state.favorites.some((f) => f.id === station.id);
|
|
if (has) await api.del(`/api/me/favorites/${station.id}`);
|
|
else await api.put(`/api/me/favorites/${station.id}`, { position: state.favorites.length });
|
|
state.favorites = await api.get('/api/me/favorites');
|
|
render();
|
|
}
|
|
|
|
function toggleRandomMode() {
|
|
state.randomMode = state.randomMode === 'favorites' ? 'all' : 'favorites';
|
|
localStorage.setItem('oradio.randomMode', state.randomMode);
|
|
toast(`Random mode: ${state.randomMode === 'favorites' ? 'favorites only' : 'all stations'}`);
|
|
render();
|
|
}
|
|
|
|
// Pick a random station and play it. Uses the public /api/v1/stations/random
|
|
// for "all" mode, /api/me/favorites/random for "favorites" mode. Falls back
|
|
// to a local random pick if the network call fails.
|
|
async function playRandom() {
|
|
try {
|
|
const ep = state.randomMode === 'favorites'
|
|
? '/api/me/favorites/random'
|
|
: '/api/v1/stations/random';
|
|
const remote = await api.get(ep);
|
|
// The kiosk player needs the internal numeric id (used by /resolve etc.).
|
|
// The favorites endpoint returns it directly; the v1 endpoint does not,
|
|
// so resolve via the cached station list (or skip if missing).
|
|
let station = remote;
|
|
if (station.id == null) {
|
|
station = state.stations.find((s) => s.uuid === remote.uuid) || null;
|
|
}
|
|
if (!station) { toast('Random station not in cache'); return; }
|
|
playStation(station);
|
|
} catch (err) {
|
|
// Fallback: pick locally so the button still does something offline.
|
|
const pool = state.randomMode === 'favorites' ? state.favorites : state.stations;
|
|
if (!pool.length) { toast(err.message || 'No stations available'); return; }
|
|
playStation(pool[Math.floor(Math.random() * pool.length)]);
|
|
}
|
|
}
|
|
|
|
function recordHistory(stationId) {
|
|
// Server-side history insertion can be added later; for now, optimistic local insert.
|
|
state.history.unshift({ station_id: stationId, started_at: new Date().toISOString() });
|
|
}
|
|
|
|
// Play wrapper: starts playback, pings server play counter, fetches vote state
|
|
// 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 {
|
|
const stats = await api.post(`/api/stations/${station.id}/play`);
|
|
// Only apply if user hasn't switched stations in the meantime.
|
|
if (state.player.stationId === station.id) {
|
|
state.player.votes = stats;
|
|
// 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 {
|
|
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 */ }
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
// Toggle off when clicking the already-active button.
|
|
const cur = state.player.votes?.myVote || 0;
|
|
const next = cur === value ? 0 : value;
|
|
try {
|
|
const stats = await api.post(`/api/stations/${id}/vote`, { value: next });
|
|
state.player.votes = stats;
|
|
mergeStats(id, stats);
|
|
render();
|
|
} catch (err) {
|
|
toast(err.message || 'Vote failed');
|
|
}
|
|
}
|
|
|
|
function mergeStats(stationId, stats) {
|
|
const list = [state.stations, state.favorites];
|
|
for (const arr of list) {
|
|
const hit = arr.find((s) => s.id === stationId);
|
|
if (hit) {
|
|
hit.up = stats.up; hit.down = stats.down;
|
|
hit.plays = stats.plays; hit.score = stats.score;
|
|
hit.my_vote = stats.myVote;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- API endpoints context menu ----
|
|
let menuEl = null;
|
|
function closeContextMenu() {
|
|
if (menuEl) { menuEl.remove(); menuEl = null; }
|
|
}
|
|
function apiEndpoints(s) {
|
|
const origin = location.origin;
|
|
const base = `${origin}/api/v1`;
|
|
const items = [];
|
|
// Original (internal) endpoint — always available, keyed by station id.
|
|
if (s.id != null) {
|
|
items.push({ label: 'Station (original)', url: `${origin}/api/stations/${s.id}` });
|
|
}
|
|
// Public v1 endpoints — require uuid.
|
|
if (s.uuid) {
|
|
items.push(
|
|
{ label: 'Station detail', url: `${base}/stations/${s.uuid}` },
|
|
{ label: 'Stream redirect', url: `${base}/stations/${s.uuid}/stream` },
|
|
{ label: 'MP3 stream', url: `${base}/stations/${s.uuid}/stream?format=mp3` },
|
|
{ label: 'AAC stream', url: `${base}/stations/${s.uuid}/stream?format=aac` },
|
|
{ label: 'HLS stream', url: `${base}/stations/${s.uuid}/stream?format=hls` }
|
|
);
|
|
}
|
|
items.push(
|
|
{ label: 'All stations', url: `${base}/stations` },
|
|
{ label: 'Health', url: `${base}/health` }
|
|
);
|
|
return items;
|
|
}
|
|
function openContextMenu(x, y, station) {
|
|
closeContextMenu();
|
|
const items = apiEndpoints(station);
|
|
menuEl = el('div', { class: 'ctx-menu', role: 'menu' },
|
|
el('div', { class: 'ctx-title' }, station.name),
|
|
el('div', { class: 'ctx-sub' },
|
|
station.uuid ? `uuid · ${station.uuid}` : (station.id != null ? `id · ${station.id} (no uuid — public v1 hidden)` : 'no identifier')),
|
|
...(items.length ? items.map((it) => el('div', { class: 'ctx-row' },
|
|
el('div', { class: 'ctx-row-text' },
|
|
el('div', { class: 'ctx-label' }, it.label),
|
|
el('div', { class: 'ctx-url' }, it.url)
|
|
),
|
|
el('button', {
|
|
class: 'ctx-btn', title: 'Copy', onClick: async (e) => {
|
|
e.stopPropagation();
|
|
try { await navigator.clipboard.writeText(it.url); toast('Copied'); } catch { toast('Copy failed'); }
|
|
}
|
|
}, '⧉'),
|
|
el('button', {
|
|
class: 'ctx-btn', title: 'Open', onClick: (e) => {
|
|
e.stopPropagation();
|
|
window.open(it.url, '_blank', 'noopener');
|
|
}
|
|
}, '↗')
|
|
)) : [el('div', { class: 'ctx-empty' }, 'No public API for this station yet (missing uuid).')]),
|
|
state.user.role === 'admin' ? el('button', {
|
|
class: 'ctx-danger', onClick: async () => {
|
|
closeContextMenu();
|
|
if (!confirm(`Delete ${station.name}?`)) return;
|
|
try { await api.del(`/api/stations/${station.id}`); await refreshAll(); render(); toast('Deleted'); }
|
|
catch (e) { toast(e.message || 'Delete failed'); }
|
|
}
|
|
}, '🗑 Delete') : null
|
|
);
|
|
document.body.appendChild(menuEl);
|
|
// Position within viewport
|
|
const w = menuEl.offsetWidth, h = menuEl.offsetHeight;
|
|
const px = Math.min(x, window.innerWidth - w - 8);
|
|
const py = Math.min(y, window.innerHeight - h - 8);
|
|
menuEl.style.left = `${Math.max(8, px)}px`;
|
|
menuEl.style.top = `${Math.max(8, py)}px`;
|
|
}
|
|
document.addEventListener('click', (e) => {
|
|
if (menuEl && !menuEl.contains(e.target)) closeContextMenu();
|
|
});
|
|
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeContextMenu(); });
|
|
|
|
// ---- Add station dialog (admin only) ----
|
|
async function openAddStation() {
|
|
const dlg = document.createElement('dialog');
|
|
dlg.className = 'add-station';
|
|
const data = { name: '', country: '', genres: '', image_url: '', homepage: '', streamUrl: '', streamFormat: 'mp3' };
|
|
const errBox = el('div', { class: 'err' });
|
|
dlg.appendChild(el('form', {
|
|
method: 'dialog', onSubmit: async (e) => {
|
|
e.preventDefault();
|
|
errBox.textContent = '';
|
|
const payload = {
|
|
name: data.name.trim(),
|
|
country: data.country.trim() || null,
|
|
homepage: data.homepage.trim() || null,
|
|
image_url: data.image_url.trim() || null,
|
|
genres: data.genres.split(',').map((g) => g.trim()).filter(Boolean),
|
|
streams: data.streamUrl.trim() ? [{ url: data.streamUrl.trim(), format: data.streamFormat, priority: 0 }] : []
|
|
};
|
|
if (!payload.name) { errBox.textContent = 'Name is required.'; return; }
|
|
try {
|
|
await api.post('/api/stations', payload);
|
|
dlg.close();
|
|
await refreshAll();
|
|
render();
|
|
toast('Station added');
|
|
} catch (err) {
|
|
errBox.textContent = err.message || 'Failed to add station';
|
|
}
|
|
}
|
|
},
|
|
el('h2', {}, 'Add station'),
|
|
el('label', {}, 'Name', el('input', { required: true, autofocus: true, onInput: (e) => data.name = e.target.value })),
|
|
el('div', { class: 'row2' },
|
|
el('label', {}, 'Country', el('input', { maxlength: 4, placeholder: 'NL', onInput: (e) => data.country = e.target.value })),
|
|
el('label', {}, 'Genres', el('input', { placeholder: 'jazz, electronic', onInput: (e) => data.genres = e.target.value }))
|
|
),
|
|
el('label', {}, 'Homepage', el('input', { type: 'url', placeholder: 'https://…', onInput: (e) => data.homepage = e.target.value })),
|
|
el('label', {}, 'Image URL', el('input', { type: 'url', placeholder: 'https://…/logo.png', onInput: (e) => data.image_url = e.target.value })),
|
|
el('div', { class: 'row2' },
|
|
el('label', {}, 'Stream URL', el('input', { type: 'url', placeholder: 'https://…/stream', onInput: (e) => data.streamUrl = e.target.value })),
|
|
el('label', {}, 'Format',
|
|
el('select', { onChange: (e) => data.streamFormat = e.target.value },
|
|
...['mp3', 'aac', 'ogg', 'hls', 'm3u', 'pls', 'unknown'].map((f) =>
|
|
el('option', { value: f, selected: f === 'mp3' }, f))))
|
|
),
|
|
errBox,
|
|
el('div', { class: 'actions' },
|
|
el('button', { class: 'btn-ghost', type: 'button', onClick: () => dlg.close() }, 'Cancel'),
|
|
el('button', { class: 'btn-primary', type: 'submit' }, 'Add')
|
|
)
|
|
));
|
|
document.body.appendChild(dlg);
|
|
dlg.showModal();
|
|
dlg.addEventListener('close', () => dlg.remove());
|
|
}
|
|
|
|
// ---- Toast ----
|
|
let toastTimer = null;
|
|
function toast(text) {
|
|
const existing = document.querySelector('.toast');
|
|
if (existing) existing.remove();
|
|
const t = el('div', { class: 'toast' }, text);
|
|
document.body.appendChild(t);
|
|
clearTimeout(toastTimer);
|
|
toastTimer = setTimeout(() => t.remove(), 2200);
|
|
}
|
|
|
|
async function requestWakeLock() {
|
|
try { await navigator.wakeLock?.request('screen'); } catch { }
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.visibilityState === 'visible') navigator.wakeLock?.request('screen').catch(() => { });
|
|
});
|
|
}
|
|
|
|
document.addEventListener('contextmenu', (e) => {
|
|
// Only suppress the menu in real kiosk mode (e.g. Chromium --kiosk),
|
|
// so devtools right-click stays available during normal use.
|
|
if (window.matchMedia('(display-mode: fullscreen)').matches) e.preventDefault();
|
|
});
|
|
bootstrap();
|