fixed API and stopping delay

This commit is contained in:
Marco Mooren
2026-05-27 12:54:56 +02:00
parent 470d4e8e76
commit 7b8d78ddaf
22 changed files with 495 additions and 98 deletions

View File

@@ -272,11 +272,20 @@ function handleWs(msg) {
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.
// Resume room state when (re-)connecting: only auto-start audio
// if the room was actually playing — a user who hit Stop and
// reloaded should NOT get audio back. We still adopt the station
// metadata for the card so the UI shows what's "selected".
const rs = msg.state;
if (rs?.station_id && rs.station && rs.station_id !== state.np.stationId) {
playStation(rs.station, { silent: true });
if (rs.playing) {
playStation(rs.station, { silent: true });
} else {
state.np.station = rs.station;
state.np.stationId = rs.station_id;
state.np.playing = false;
state.np.loading = false;
}
}
if (typeof rs?.volume === 'number') {
player.setVolume(rs.volume);
@@ -321,12 +330,12 @@ function handleCommand(msg) {
case 'play': {
const id = Number(msg.stationId);
if (!Number.isFinite(id)) return;
// Idempotency: ignore a play for the station already current OR
// already pending. Without these guards a flood of commands (e.g.
// multiple kiosks racing, or repeated clicks) would call
// playStation() in a loop, each one doing stop()+play() and
// producing audible start/stop thrashing.
if (id === state.np.stationId) return;
// Idempotency: only skip if we're already on this station AND
// audio is actually playing or loading it. If a previous play
// attempt failed (gesture refused, resolve error), state.np was
// cleared by clearNowPlaying() so this guard correctly lets the
// retry through.
if (id === state.np.stationId && (state.np.playing || state.np.loading)) return;
if (id === _pendingStationId) return;
_pendingStationId = id;
const gen = ++_cmdGen;
@@ -345,11 +354,16 @@ function handleCommand(msg) {
if (state.np.playing) player.togglePause();
return;
case 'stop':
if (!state.np.stationId) return;
player.stop();
// Always idempotent: even if local state thinks nothing is
// playing, the command may be a retry from a kiosk that saw a
// stale "playing" — stop unconditionally so the room converges.
try { player.stop(); } catch { /* ignore */ }
endCurrentSession();
state.np.playing = false;
state.np.stationId = null;
state.np.station = null;
state.np.loading = false;
_pendingStationId = null;
sendState();
render();
return;
@@ -388,6 +402,8 @@ async function playStation(station, { silent } = {}) {
endCurrentSession();
state.np.station = station;
state.np.stationId = station.id;
state.np.loading = true;
state.np.error = null;
state.voteStats = {
up: station.up || 0, down: station.down || 0,
plays: station.plays || 0, score: station.score || 0
@@ -397,8 +413,21 @@ async function playStation(station, { silent } = {}) {
// would otherwise call audio.play() without one and iOS/Chromium
// would refuse. ensureGesture short-circuits once unlocked.
const ok = await ensureGesture(station.name, silent ? 'Tap Start to resume the group audio.' : 'Tap Start to play.');
if (!ok) return;
await player.play(station);
if (!ok) {
// Gesture refused: don't leave state pointing at a station we never
// actually played, otherwise the dedupe in handleCommand('play')
// would wedge — every future command for this id would be skipped.
// Only clear if we're still the active station; a newer playStation
// may have superseded us during the modal await.
if (state.np.stationId === station.id) clearNowPlaying();
return;
}
try {
await player.play(station);
} catch {
if (state.np.stationId === station.id) clearNowPlaying();
return;
}
if (player.audio.paused) {
// Gesture confirmed but browser hasn't started yet — try once more.
player.audio.play().catch(() => { });
@@ -421,6 +450,21 @@ async function playStation(station, { silent } = {}) {
}
}
// Wipe local now-playing state and broadcast the cleared state to the room.
// Used when a play attempt fails (gesture refused, resolve error) so the
// dedupe in handleCommand doesn't trap us pointing at a station we never
// actually played.
function clearNowPlaying() {
state.np.stationId = null;
state.np.station = null;
state.np.playing = false;
state.np.loading = false;
state.voteStats = null;
try { player?.stop(); } catch { /* ignore */ }
sendState();
render();
}
// Close whichever session is currently open. Idempotent.
function endCurrentSession({ beacon = false } = {}) {
const s = state.session;