Files
Psychotoxical-psysonic/src/store/radioPlayer.ts
T
Frank Stellmacher d7abf9be3b fix(radio): card polish and paused streams stop auto-resuming (#786)
* refactor(playback): extract fadeOut helper

Used by playAlbum.ts today; about to be reused for the radio delete
fade-out so it lives in its own module instead of being copy-pasted.

* fix(player): disable Repeat button while a radio stream is active

Matches the Prev / Next buttons that already gate on isRadio — repeat
has no meaning for a live stream, and the live tooltip plus accent
colour suggested it was interactive.

* fix(radio): fade out before deleting the playing station

Mirrors the 700 ms fade-out playAlbum uses on track changes — beats
the abrupt cut when the deleted station is the one currently on air.

* fix(radio): add Play/Stop tooltip to the cover-overlay button

Cast / X icon now describes itself, matching the favourite and delete
buttons that already had tooltips.

* fix(radio): use Square stop icon on the active card

The play-overlay and delete buttons both used X, making it ambiguous
which click stopped the stream and which deleted the station. Stop
gets a filled Square; delete keeps its X.

* fix(radio): cancel auto-reconnect when the user pauses a stream

Closes #779.

Root cause: a 'stalled' event during a paused stream still scheduled a
4 s reconnect timer that called play(), and the timer was not cancelled
on pauseRadio(). On macOS WKWebView the underlying TCP socket droops
roughly a minute after pause, the browser fires 'stalled', and the
stream resumes against the user's intent. The store's isPlaying flag
stays false because nothing on the reconnect path syncs it, so the
play/pause button stops matching reality.

Fix: skip the schedule when radioAudio.paused, re-check inside the
timer callback (covers a pause within the 4 s window), and clear any
pending timer in pauseRadio().

* docs: changelog for radio card polish + paused-stream fix (#786)
2026-05-18 22:10:04 +02:00

141 lines
5.3 KiB
TypeScript

import { showToast } from '../utils/ui/toast';
import { usePlayerStore } from './playerStore';
/**
* Internet radio streams play through a native HTMLAudioElement rather
* than the Rust/Symphonia engine — the browser handles reconnect logic,
* codec negotiation (MP3, AAC, HE-AAC, OGG), and ICY headers for free.
*
* This module owns:
* - the singleton `<audio>` element used for radio
* - the bounded stalled-reconnect retry loop (MAX_RADIO_RECONNECTS)
* - the suppression flag (`radioStopping`) that stops the error
* listener from clobbering store state when the caller asked for a
* clean stop
*
* Callers drive playback through `playRadioStream` / `pauseRadio` /
* `resumeRadio` / `stopRadio` / `setRadioVolume`. The event listeners
* write the store state directly when the browser decides the stream
* is dead (ended / error / unrecoverable stall).
*/
const radioAudio = new Audio();
radioAudio.preload = 'none';
let radioStopping = false;
let radioReconnectTimer: ReturnType<typeof setTimeout> | null = null;
let radioReconnectCount = 0;
const MAX_RADIO_RECONNECTS = 5;
const RECONNECT_DELAY_MS = 4000;
export function clearRadioReconnectTimer(): void {
if (radioReconnectTimer) { clearTimeout(radioReconnectTimer); radioReconnectTimer = null; }
}
radioAudio.addEventListener('ended', () => {
// Stream disconnected unexpectedly — clear radio state.
clearRadioReconnectTimer();
radioReconnectCount = 0;
usePlayerStore.setState({ isPlaying: false, currentRadio: null, progress: 0, currentTime: 0 });
});
radioAudio.addEventListener('error', () => {
clearRadioReconnectTimer();
if (radioStopping) { radioStopping = false; radioReconnectCount = 0; return; }
radioReconnectCount = 0;
usePlayerStore.setState({ isPlaying: false, currentRadio: null });
showToast('Radio stream error', 3000, 'error');
});
// Playing: stream is delivering audio — reset the reconnect counter.
radioAudio.addEventListener('playing', () => {
radioReconnectCount = 0;
});
// Stalled: stream stopped delivering data — try to reconnect after 4 s.
// On macOS/WKWebView, reassigning src during a stall can itself trigger
// another stall event before the new connection is established. The
// radioReconnectTimer guard prevents stacking, and MAX_RADIO_RECONNECTS
// ensures we don't loop forever on a dead stream.
radioAudio.addEventListener('stalled', () => {
if (radioReconnectTimer) return; // already scheduled
if (radioAudio.paused) return; // user paused — reconnect would resume against intent
if (radioReconnectCount >= MAX_RADIO_RECONNECTS) {
radioReconnectCount = 0;
usePlayerStore.setState({ isPlaying: false, currentRadio: null });
showToast('Radio stream disconnected', 4000, 'error');
return;
}
radioReconnectTimer = setTimeout(() => {
radioReconnectTimer = null;
if (!usePlayerStore.getState().currentRadio) return;
if (radioAudio.paused) return; // user paused while we were waiting
radioReconnectCount++;
// Use load() + play() instead of src reassignment — more reliable on
// macOS WKWebView where setting src can fire a premature error event.
radioAudio.load();
radioAudio.play().catch(console.error);
}, RECONNECT_DELAY_MS);
});
// Waiting: browser is rebuffering — normal for live streams, no action needed.
radioAudio.addEventListener('waiting', () => {
console.debug('[psysonic] radio: buffering');
});
// Suspend: browser paused loading (sufficient buffer) — cancel any stale reconnect.
radioAudio.addEventListener('suspend', () => {
clearRadioReconnectTimer();
});
/**
* Start a new stream. Resets the reconnect counter, sets src + volume,
* and fires play. The returned promise rejects with the audio-element's
* error so callers can surface it (the runtime currently uses this to
* show a toast and clear `currentRadio` state).
*/
export function playRadioStream(streamUrl: string, volume: number): Promise<void> {
radioReconnectCount = 0;
radioAudio.src = streamUrl;
radioAudio.volume = Math.max(0, Math.min(1, volume));
return radioAudio.play();
}
/** Soft pause — keeps the src loaded so resume can pick up cheaply. */
export function pauseRadio(): void {
// A reconnect timer may be pending from a previous 'stalled' event. Cancel
// it so it can't fire play() against the user's pause intent (issue #779).
clearRadioReconnectTimer();
radioAudio.pause();
}
/** Soft resume — re-plays the loaded src without reconnect. */
export function resumeRadio(): Promise<void> {
return radioAudio.play();
}
/**
* Full stop. Marks the next 'error' event as expected (so the listener
* doesn't show an error toast), pauses, and clears the src so the
* browser releases the underlying network resources.
*/
export function stopRadio(): void {
radioStopping = true;
radioAudio.pause();
radioAudio.src = '';
clearRadioReconnectTimer();
radioReconnectCount = 0;
}
export function setRadioVolume(volume: number): void {
radioAudio.volume = Math.max(0, Math.min(1, volume));
}
/** Test-only access to the underlying audio element + reset hook. */
export function _radioAudioForTest(): HTMLAudioElement {
return radioAudio;
}
export function _resetRadioPlayerForTest(): void {
radioStopping = false;
radioReconnectCount = 0;
clearRadioReconnectTimer();
radioAudio.pause();
radioAudio.src = '';
}