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

@@ -43,11 +43,15 @@ function readLastStation() {
} catch { /* ignore */ }
return null;
}
function writeLastStation(station) {
// Persist the last station along with whether it was playing at the time.
// On reload we only auto-resume audio when `playing` was true — a user who
// hit Stop explicitly should not be ambushed by the stream restarting.
function writeLastStation(station, { playing = true } = {}) {
if (!station || !Number.isFinite(station.id)) return;
try {
localStorage.setItem('oradio.lastStation', JSON.stringify({
id: station.id, name: station.name, genres: station.genres || []
id: station.id, name: station.name, genres: station.genres || [],
playing: !!playing
}));
} catch { /* private mode */ }
}
@@ -175,6 +179,22 @@ function sendBufferCommandSoon(ms) {
ws?.send({ type: 'command', action: 'setSyncBuffer', value });
}, 150);
}
// Single entry point for transport commands (play/pause/stop/etc). Returns
// false when the WS isn't OPEN — callers use the return value to roll back
// optimistic state so a transient disconnect is visible to the user instead
// of silently dropping the click.
function sendCommand(payload) {
const ok = !!ws?.send({ type: 'command', ...payload });
if (!ok) {
// Roll back any optimistic UI flip — the command never reached the
// server, so the room state we'd be predicting will not arrive.
clearPendingCommand();
state.player = { ...state.player, error: 'Disconnected — reconnecting…' };
render();
}
return ok;
}
// Apply restored local volume immediately so the very first stream honours it.
player.setLocalVolume(readLocalVolume());
player.onSyncChange = (info) => {
@@ -251,8 +271,11 @@ async function bootstrap() {
mountDebugPane({ player, clock, getWs: () => ws, role: 'kiosk' });
render();
requestWakeLock();
// Solo restore: try to resume the last station. Use the autoplay gate so
// a blocked browser shows the click-to-start modal.
// Solo restore: try to resume the last station. Only auto-resume audio
// when the last-known state was `playing: true` — a user who hit Stop
// and reloaded should see the station selected but silent. If the
// playing flag is missing (older localStorage entries) we treat it as
// true for backwards compatibility on first reload after upgrading.
if (state.mode === 'solo') {
const last = readLastStation();
if (last && Number.isFinite(last.id)) {
@@ -260,7 +283,25 @@ async function bootstrap() {
const full = state.favorites.find((s) => s.id === last.id)
|| state.stations.find((s) => s.id === last.id)
|| await api.get(`/api/stations/${last.id}`).catch(() => null);
if (full) await soloResumeStation(full);
if (full) {
const wasPlaying = last.playing !== false;
if (wasPlaying) {
await soloResumeStation(full);
} else {
// Adopt metadata so the card shows the station as
// selected, but do not start audio.
state.player = {
...state.player,
stationId: full.id,
stationName: full.name,
genres: full.genres || [],
playing: false,
loading: false,
error: null
};
render();
}
}
} catch { /* ignore */ }
}
}
@@ -503,10 +544,17 @@ function handleWs(msg) {
case 'state':
state.roomState = { ...state.roomState, ...msg };
logSync('state', msg);
// A real state echo from the server is the convergence point —
// drop any optimistic pending command and let the canonical
// state drive the buttons.
clearPendingCommand();
// The server emits a synthetic `state` immediately after handling
// any command (with `started_at: null` while a display is present)
// BEFORE the master has actually anchored on the new station. Only
// clear the optimistic pending command when this is a truly
// authoritative echo: the master has anchored (`started_at` set)
// OR the room moved to stopped/paused. Otherwise the kiosk would
// briefly snap back to a stale "loading" view, then snap forward
// again 100-500 ms later when the master actually starts.
if (msg.started_at || msg.playing === false || !msg.station_id) {
clearPendingCommand();
}
if (state.mode === 'synced') applyRoomStateToUI();
// Update the sync anchor if the server is telling us one. A null
// `started_at` just means the master hasn't (re-)anchored yet
@@ -836,8 +884,9 @@ function render() {
markGesture();
if (state.mode === 'synced' && !state.syncedAudio) {
const target = view.stationId || state.favorites[0]?.id;
if (!target) return;
setPendingCommand({ stationId: target, playing: !view.playing });
ws?.send({ type: 'command', action: view.playing ? 'pause' : 'play', stationId: target });
sendCommand({ action: view.playing ? 'pause' : 'play', stationId: target });
render();
return;
}
@@ -854,11 +903,23 @@ function render() {
if (view.stationId) {
const wasPlaying = view.playing;
if (state.mode === 'synced' && state.syncedAudio) {
// Synced+audio: send command first; only flip local
// audio after the WS send actually succeeded. The
// canonical `state` echo will mirror back to the
// local <audio> via the state handler so both ends
// stay in lockstep.
setPendingCommand({ stationId: view.stationId, playing: !wasPlaying });
}
player.togglePause();
if (broadcasts()) {
ws?.send({ type: 'command', action: wasPlaying ? 'pause' : 'play', stationId: view.stationId });
const ok = sendCommand({ action: wasPlaying ? 'pause' : 'play', stationId: view.stationId });
if (!ok) return;
player.togglePause();
} else {
// Solo: local only.
player.togglePause();
// Persist new playing state so reload honours it.
const last = readLastStation();
if (last && last.id === view.stationId) {
writeLastStation({ id: last.id, name: last.name, genres: last.genres }, { playing: !wasPlaying });
}
}
} else if (state.favorites[0]) {
playStation(state.favorites[0]);
@@ -874,15 +935,26 @@ function render() {
markGesture();
if (state.mode === 'synced' && !state.syncedAudio) {
setPendingCommand({ stationId: null, playing: false });
ws?.send({ type: 'command', action: 'stop' });
sendCommand({ action: 'stop' });
render();
} else {
if (state.mode === 'synced' && state.syncedAudio) {
setPendingCommand({ stationId: null, playing: false });
}
return;
}
if (state.mode === 'synced' && state.syncedAudio) {
setPendingCommand({ stationId: null, playing: false });
const ok = sendCommand({ action: 'stop' });
if (!ok) return;
player.stop();
endCurrentSession();
if (broadcasts()) ws?.send({ type: 'command', action: 'stop' });
return;
}
// Solo: local only. Mark last-station as not playing so
// the next reload doesn't surprise the user with audio.
const stopping = view.stationId;
player.stop();
endCurrentSession();
const last = readLastStation();
if (last && last.id === stopping) {
writeLastStation({ id: last.id, name: last.name, genres: last.genres }, { playing: false });
}
}
}, '■'),
@@ -1215,13 +1287,24 @@ async function playStation(station) {
// Close whatever was playing before; the upcoming POST opens a fresh row.
endCurrentSession();
// Remember this as the last station for Solo restore-on-reload.
if (state.mode === 'solo') writeLastStation(station);
if (state.mode === 'synced' && !state.syncedAudio) {
// Don't touch local audio — ask the room's display to play and let
// the resulting `state` message update our UI.
if (state.mode === 'solo') writeLastStation(station, { playing: true });
if (state.mode === 'synced') {
// Synced mode (audio on OR off): the room is the source of truth.
// Send the command, optimistically flip the card, and let the
// resulting `state` echo + autoJoinRoomPlayback drive any local
// audio start (when syncedAudio is on). Single code path = no
// race between the optimistic local player.play and the join
// triggered by the state echo.
setPendingCommand({ stationId: station.id, playing: true });
ws?.send({ type: 'command', action: 'play', stationId: station.id });
// Optimistically reflect locally so the card highlights immediately.
const ok = sendCommand({ action: 'play', stationId: station.id });
if (!ok) return;
// Optimistically flip the card to the new station name immediately.
// Do NOT set loading=true here — joinStationSilently uses
// (loading && stationId===id) as its re-entry guard, and we want
// the state-echo-driven autoJoinRoomPlayback to actually start the
// local <audio>. The "On air — local audio loading…" sub-line
// covers the brief window where view.playing is true but the local
// audio hasn't begun yet.
state.player = {
...state.player,
stationId: station.id,
@@ -1232,7 +1315,9 @@ async function playStation(station) {
error: null
};
render();
// No local audio means no local session — the master records its own.
// No local audio = no local session — the master records its own.
// When syncedAudio is on the local session will be opened by
// joinStationSilently once autoJoin kicks in.
try {
const stats = await api.get(`/api/stations/${station.id}/votes`, ac ? { signal: ac.signal } : undefined);
if (state.player.stationId === station.id) {
@@ -1244,9 +1329,9 @@ async function playStation(station) {
return;
}
// Solo path: drive local audio directly.
player.play(station);
recordHistory(station.id);
if (broadcasts()) ws?.send({ type: 'command', action: 'play', stationId: station.id });
try {
const stats = await api.post(`/api/stations/${station.id}/play`, null, ac ? { signal: ac.signal } : undefined);
// Only apply if user hasn't switched stations in the meantime.

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;

View File

@@ -111,6 +111,38 @@ export class Player {
};
this.onSyncChange = null; // optional sync status listener
this.audio.addEventListener('playing', () => {
// Restore output gain in case a prior pause/stop muted the
// post-DelayNode GainNode for instant silence.
if (this._outputMuted) {
// When sync is active the delay node was reset to 0 in stop().
// Keep the gain muted while the delay line pre-fills with
// bufferMs of the new station's audio, then atomically snap
// the delay to bufferMs and unmute. This gives clean silence
// during the buffer fill instead of the old "playing empty"
// sensation (gain open but only zeros coming through the delay).
if (this.sync.enabled && this.audio[DELAY_KEY] && this.sync.bufferMs > 0) {
clearTimeout(this._delayPrefillTimer);
const targetMs = this.sync.bufferMs;
this._delayPrefillTimer = setTimeout(() => {
this._delayPrefillTimer = null;
if (this.audio.paused || !this.sync.enabled) return;
const d = this.audio[DELAY_KEY];
const c = this.audio[CTX_KEY];
if (d && c) {
try {
d.delayTime.cancelScheduledValues(c.currentTime);
d.delayTime.value = targetMs / 1000;
} catch { /* ignore */ }
}
this.sync.currentDelay = targetMs / 1000;
this._setOutputMuted(false);
}, targetMs);
} else {
// Solo mode or no delay graph — unmute immediately so
// audio kicks in the moment the first sample is decoded.
this._setOutputMuted(false);
}
}
// First decoded sample after a fresh load — anchor the drift
// reference. From now on we expect audio.currentTime to track
// (clock.now() - t0Wall) / 1000 + t0Audio. Any divergence is
@@ -174,13 +206,18 @@ export class Player {
if (gain && ctx) {
// Route via the post-DelayNode GainNode so the change is audible
// immediately, not queued behind `bufferMs` of buffered audio.
try {
const t = ctx.currentTime;
gain.gain.cancelScheduledValues(t);
gain.gain.setValueAtTime(gain.gain.value, t);
gain.gain.linearRampToValueAtTime(clamped, t + 0.03);
} catch {
gain.gain.value = clamped;
// When the output is currently muted (pause/stop) skip the
// hardware-gain write — _setOutputMuted owns the gain in that
// state and would otherwise un-mute the speaker mid-pause.
if (!this._outputMuted) {
try {
const t = ctx.currentTime;
gain.gain.cancelScheduledValues(t);
gain.gain.setValueAtTime(gain.gain.value, t);
gain.gain.linearRampToValueAtTime(clamped, t + 0.03);
} catch {
gain.gain.value = clamped;
}
}
// Audio element is pinned to 1 once the graph exists — its volume
// is applied pre-delay and would compound with the GainNode.
@@ -191,6 +228,29 @@ export class Player {
this.emit({});
}
// Cut (or restore) the post-DelayNode gain. Used by pause/stop so the
// user gets instant silence instead of waiting for the DelayNode's
// buffered samples (up to bufferMs) to drain out the speaker. Cheap
// no-op when the WebAudio graph hasn't been built (solo / no-sync
// path — there the audio element's own pause IS instant).
_setOutputMuted(muted) {
this._outputMuted = !!muted;
const gain = this.audio[GAIN_KEY];
const ctx = this.audio[CTX_KEY];
if (!gain || !ctx) return;
const target = muted ? 0 : (this._logicalVolume ?? 1);
try {
const t = ctx.currentTime;
gain.gain.cancelScheduledValues(t);
gain.gain.setValueAtTime(gain.gain.value, t);
// 30 ms ramp matches setVolume — feels instant, avoids the
// click that an abrupt setValueAtTime can produce.
gain.gain.linearRampToValueAtTime(target, t + 0.03);
} catch {
gain.gain.value = target;
}
}
/** Alias kept for clarity: local <audio>.volume only; never broadcasts. */
setLocalVolume(v) { this.setVolume(v); }
@@ -224,17 +284,60 @@ export class Player {
// Invalidate any in-flight play() continuation immediately.
this._playGen++;
if (this._playAbort) { try { this._playAbort.abort(); } catch { /* ignore */ } this._playAbort = null; }
// Cancel any pending delay-prefill unmute so a timer from a previous
// station doesn't fire and unmute mid-switch.
clearTimeout(this._delayPrefillTimer);
this._delayPrefillTimer = null;
// Hard-cut the output gain immediately. We bypass the 30 ms ramp that
// _setOutputMuted uses (appropriate for pause/resume to avoid pops)
// and go straight to zero — we're about to suspend the AudioContext
// anyway, so the ramp would just be cancelled a quantum later.
this._outputMuted = true;
const gain = this.audio[GAIN_KEY];
const ctx = this.audio[CTX_KEY];
if (gain && ctx) {
try {
gain.gain.cancelScheduledValues(ctx.currentTime);
gain.gain.setValueAtTime(0, ctx.currentTime);
} catch { gain.gain.value = 0; }
} else if (gain) {
gain.gain.value = 0;
}
this.audio.pause();
this.audio.removeAttribute('src');
this.audio.load();
if (this.hls) { this.hls.destroy(); this.hls = null; }
// Reset the DelayNode to 0 so residual buffered audio doesn't linger.
const delay = this.audio[DELAY_KEY];
if (delay && ctx) {
try {
delay.delayTime.cancelScheduledValues(ctx.currentTime);
delay.delayTime.value = 0;
} catch { /* ignore */ }
}
// Suspend the AudioContext: immediately freezes all WebAudio rendering
// (delay drain, analyser updates, etc.). play() / _ensureAudioGraph()
// will resume it when the next station starts.
if (ctx) { try { ctx.suspend().catch(() => { }); } catch { /* ignore */ } }
this._resetSyncAnchor();
}
togglePause() {
if (!this.station) return;
if (this.audio.paused) this.audio.play().catch(() => { });
else this.audio.pause();
if (this.audio.paused) {
// Un-mute first so the speaker is live when the new samples
// begin to flow through the DelayNode. (No-op if no graph.)
this._setOutputMuted(false);
this.audio.play().catch(() => { });
} else {
// Mute the post-DelayNode gain BEFORE pausing so the speaker
// goes silent immediately. Without this, the DelayNode keeps
// draining its buffered samples (up to bufferMs) out the
// speaker after audio.pause() — what the user perceives as
// "the stream kept playing after I clicked pause".
this._setOutputMuted(true);
this.audio.pause();
}
}
async play(station) {
@@ -253,14 +356,20 @@ export class Player {
// station name immediately. Otherwise on slow phones the old
// name stays visible for the duration of /resolve + connect.
this.emit({ playing: false, loading: true, error: null });
// Pre-warm AudioContext while we still have the user-gesture
// Pre-warm / resume AudioContext while we still have the user-gesture
// credit. iOS will keep it running for the rest of the session.
if (this.sync.enabled) {
// Also resumes a context that was suspended by stop() — required even
// in solo mode if a WebAudio graph was built during a previous sync
// session (audio is routed through the graph, not the raw element).
{
const Ctor = typeof window !== 'undefined' && (window.AudioContext || window.webkitAudioContext);
if (Ctor) {
if (this.sync.enabled && Ctor) {
let ctx = this.audio[CTX_KEY];
if (!ctx) { try { ctx = new Ctor(); this.audio[CTX_KEY] = ctx; } catch { /* ignore */ } }
if (ctx && ctx.state === 'suspended') ctx.resume().catch(() => { });
} else {
const existingCtx = this.audio[CTX_KEY];
if (existingCtx && existingCtx.state === 'suspended') existingCtx.resume().catch(() => { });
}
}
let resolved, streamMeta;
@@ -378,6 +487,13 @@ export class Player {
this.sync.startedAt = null;
this.sync.status = 'off';
if (this.sync.timer) { clearInterval(this.sync.timer); this.sync.timer = null; }
// If a prefill timer was waiting to unmute, cancel it and unmute now
// since we're leaving sync mode.
if (this._delayPrefillTimer) {
clearTimeout(this._delayPrefillTimer);
this._delayPrefillTimer = null;
if (this._outputMuted && !this.audio.paused) this._setOutputMuted(false);
}
try { this.audio.playbackRate = 1.0; } catch { /* ignore */ }
// Drain the DelayNode so we don't keep holding several seconds of
// audio when the user switches back to solo mode.
@@ -462,12 +578,10 @@ export class Player {
/**
* Build (once per <audio>) the Web Audio chain:
* MediaElementSource -> DelayNode -> AnalyserNode -> destination
* The analyser is placed AFTER the delay so the spectrum it produces
* matches the audio the user actually hears (the speakers emit the
* delayed signal). Consequence: the visualiser is silent for `bufferMs`
* after a station change while the delay refills with new audio —
* that's correct, not a bug.
* MediaElementSource -> DelayNode -> GainNode -> AnalyserNode -> destination
* The analyser is placed AFTER the gain so the spectrum it produces
* is only active when audio is actually audible — it stays silent
* during the muted prefill period (gain=0) after a station change.
* If the audio is cross-origin without CORS, `createMediaElementSource`
* throws and we silently bail — sync degrades to 'no-buffer' in that
* environment but plain playback continues.
@@ -513,9 +627,9 @@ export class Player {
gain.gain.value = (this._logicalVolume ?? a.volume);
a.volume = 1;
src.connect(delay);
delay.connect(analyser);
analyser.connect(gain);
gain.connect(ctx.destination);
delay.connect(gain);
gain.connect(analyser);
analyser.connect(ctx.destination);
a[ANALYSER_KEY] = analyser;
a[DELAY_KEY] = delay;
a[GAIN_KEY] = gain;
@@ -606,7 +720,10 @@ export class Player {
if (graph?.delay) {
const cur = graph.delay.delayTime.value;
if (Math.abs(cur - clamped) > 0.01) {
// While the delay-prefill timer is running (station just switched,
// delay was reset to 0), do not ramp the delay back up — the timer
// will snap it to the target value once the line has pre-filled.
if (Math.abs(cur - clamped) > 0.01 && !this._delayPrefillTimer) {
try {
const t = graph.ctx.currentTime;
graph.delay.delayTime.cancelScheduledValues(t);

View File

@@ -21,7 +21,15 @@ export function connectWs(onMessage, opts = {}) {
}
open();
return {
send(msg) { if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg)); },
// Returns true when the message was actually queued on a live socket,
// false when the socket is closing/reconnecting. Callers that need to
// surface a transport failure (transport buttons) inspect the result;
// fire-and-forget callers can ignore it.
send(msg) {
if (ws?.readyState !== WebSocket.OPEN) return false;
ws.send(JSON.stringify(msg));
return true;
},
close() { closed = true; ws?.close(); },
get readyState() { return ws?.readyState; }
};