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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
161
web/player.js
161
web/player.js
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user