fixed API and stopping delay
This commit is contained in:
141
web/main.js
141
web/main.js
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user