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.