mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
refactor(player): E.20 — extract HTML5 radio player (#584)
The HTMLAudioElement that handles internet-radio streams + its six
event listeners + the bounded stalled-reconnect loop move into
`src/store/radioPlayer.ts`. The module owns the singleton audio
element, the `radioStopping` suppression flag, the reconnect counter
and timer, and `MAX_RADIO_RECONNECTS`. Public API:
- `playRadioStream(url, volume)` — sets src + clamped volume + play,
resets reconnect counter
- `pauseRadio()` / `resumeRadio()` — soft pause/resume (keep src)
- `stopRadio()` — full stop (flag + pause + clear src + cancel
reconnect timer)
- `setRadioVolume(v)` — direct volume with clamp
- `clearRadioReconnectTimer()` — exposed for cleanup paths
playerStore's seven direct-access patterns become API calls. The
three `radioStopping = true; pause; src = ''` triples collapse to
single `stopRadio()` calls.
18 tests pin the imperative API + the listener loop: ended/error
state-clear, stalled → 4 s reconnect, stalled coalescing, the
MAX_RADIO_RECONNECTS hard stop, playing-resets-counter, and the
suspend → cancel.
playerStore 2983 → 2909 LOC.
This commit is contained in:
committed by
GitHub
parent
7dc4888a06
commit
6d07720b4f
+15
-89
@@ -98,6 +98,14 @@ import { tryAcquireTogglePlayLock } from './togglePlayLock';
|
|||||||
import { reseedLoudnessForTrackId } from './loudnessReseed';
|
import { reseedLoudnessForTrackId } from './loudnessReseed';
|
||||||
import { refreshWaveformForTrack } from './waveformRefresh';
|
import { refreshWaveformForTrack } from './waveformRefresh';
|
||||||
import { refreshLoudnessForTrack } from './loudnessRefresh';
|
import { refreshLoudnessForTrack } from './loudnessRefresh';
|
||||||
|
import {
|
||||||
|
clearRadioReconnectTimer,
|
||||||
|
pauseRadio,
|
||||||
|
playRadioStream,
|
||||||
|
resumeRadio,
|
||||||
|
setRadioVolume,
|
||||||
|
stopRadio,
|
||||||
|
} from './radioPlayer';
|
||||||
|
|
||||||
// Re-export so TauriEventBridge + persistence test keep their existing
|
// Re-export so TauriEventBridge + persistence test keep their existing
|
||||||
// `from './playerStore'` imports.
|
// `from './playerStore'` imports.
|
||||||
@@ -522,75 +530,6 @@ function scheduleSeekFallbackRetry(trackId: string, seconds: number) {
|
|||||||
}, SEEK_FALLBACK_RETRY_INTERVAL_MS);
|
}, SEEK_FALLBACK_RETRY_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── HTML5 Radio Player ────────────────────────────────────────────────────────
|
|
||||||
// Internet radio streams are played via a native <audio> element instead of
|
|
||||||
// the Rust/Symphonia engine. This gives us browser-native reconnect logic,
|
|
||||||
// codec support (MP3, AAC, HE-AAC, OGG) and stable ICY stream handling for
|
|
||||||
// free, without touching the regular playback pipeline at all.
|
|
||||||
const radioAudio = new Audio();
|
|
||||||
radioAudio.preload = 'none';
|
|
||||||
let radioStopping = false;
|
|
||||||
// Pending reconnect timer for stalled streams — null when no reconnect is scheduled.
|
|
||||||
let radioReconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
// Counts how many stalled-reconnects have been attempted for the current station.
|
|
||||||
// Reset to 0 on successful playback. Hard-stop after MAX_RADIO_RECONNECTS so a
|
|
||||||
// dead stream doesn't loop forever and leak resources in the background.
|
|
||||||
let radioReconnectCount = 0;
|
|
||||||
const MAX_RADIO_RECONNECTS = 5;
|
|
||||||
|
|
||||||
function clearRadioReconnectTimer() {
|
|
||||||
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 (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;
|
|
||||||
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);
|
|
||||||
}, 4000);
|
|
||||||
});
|
|
||||||
// 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();
|
|
||||||
});
|
|
||||||
|
|
||||||
function prefetchLoudnessForEnqueuedTracks(
|
function prefetchLoudnessForEnqueuedTracks(
|
||||||
mergedQueue: Track[],
|
mergedQueue: Track[],
|
||||||
queueIndex: number,
|
queueIndex: number,
|
||||||
@@ -1408,10 +1347,7 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
(set, get) => {
|
(set, get) => {
|
||||||
function applyQueueHistorySnapshot(snap: QueueUndoSnapshot, prior: PlayerState): boolean {
|
function applyQueueHistorySnapshot(snap: QueueUndoSnapshot, prior: PlayerState): boolean {
|
||||||
if (prior.currentRadio) {
|
if (prior.currentRadio) {
|
||||||
clearRadioReconnectTimer();
|
stopRadio();
|
||||||
radioStopping = true;
|
|
||||||
radioAudio.pause();
|
|
||||||
radioAudio.src = '';
|
|
||||||
}
|
}
|
||||||
let nextQueue = shallowCloneQueueTracks(snap.queue);
|
let nextQueue = shallowCloneQueueTracks(snap.queue);
|
||||||
let nextIndex = snap.queueIndex;
|
let nextIndex = snap.queueIndex;
|
||||||
@@ -1694,10 +1630,7 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
stop: () => {
|
stop: () => {
|
||||||
clearAllPlaybackScheduleTimers();
|
clearAllPlaybackScheduleTimers();
|
||||||
if (get().currentRadio) {
|
if (get().currentRadio) {
|
||||||
clearRadioReconnectTimer();
|
stopRadio();
|
||||||
radioStopping = true;
|
|
||||||
radioAudio.pause();
|
|
||||||
radioAudio.src = '';
|
|
||||||
} else {
|
} else {
|
||||||
invoke('audio_stop').catch(console.error);
|
invoke('audio_stop').catch(console.error);
|
||||||
}
|
}
|
||||||
@@ -1731,7 +1664,6 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
|
set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
|
||||||
isAudioPaused = false;
|
isAudioPaused = false;
|
||||||
clearRadioReconnectTimer();
|
clearRadioReconnectTimer();
|
||||||
radioReconnectCount = 0;
|
|
||||||
clearPreloadingIds();
|
clearPreloadingIds();
|
||||||
clearSeekFallbackRetry();
|
clearSeekFallbackRetry();
|
||||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } clearSeekTarget();
|
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } clearSeekTarget();
|
||||||
@@ -1741,12 +1673,9 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
// to HTML5 <audio> — the browser cannot play playlist files directly.
|
// to HTML5 <audio> — the browser cannot play playlist files directly.
|
||||||
const streamUrl = await invoke<string>('resolve_stream_url', { url: station.streamUrl })
|
const streamUrl = await invoke<string>('resolve_stream_url', { url: station.streamUrl })
|
||||||
.catch(() => station.streamUrl);
|
.catch(() => station.streamUrl);
|
||||||
// Play via HTML5 audio — browser handles reconnects, codec negotiation, buffering.
|
|
||||||
radioAudio.src = streamUrl;
|
|
||||||
const { replayGainFallbackDb } = useAuthStore.getState();
|
const { replayGainFallbackDb } = useAuthStore.getState();
|
||||||
const fallbackFactor = replayGainFallbackDb !== 0 ? Math.pow(10, replayGainFallbackDb / 20) : 1;
|
const fallbackFactor = replayGainFallbackDb !== 0 ? Math.pow(10, replayGainFallbackDb / 20) : 1;
|
||||||
radioAudio.volume = Math.min(1, volume * fallbackFactor);
|
playRadioStream(streamUrl, Math.min(1, volume * fallbackFactor)).catch((err: unknown) => {
|
||||||
radioAudio.play().catch((err: unknown) => {
|
|
||||||
console.error('[psysonic] radio HTML5 play failed:', err);
|
console.error('[psysonic] radio HTML5 play failed:', err);
|
||||||
showToast('Radio stream error', 3000, 'error');
|
showToast('Radio stream error', 3000, 'error');
|
||||||
set({ isPlaying: false, currentRadio: null });
|
set({ isPlaying: false, currentRadio: null });
|
||||||
@@ -1846,10 +1775,7 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
// If a radio stream is active, stop it before the new track starts so
|
// If a radio stream is active, stop it before the new track starts so
|
||||||
// the PlayerBar clears radio mode immediately and the stream is released.
|
// the PlayerBar clears radio mode immediately and the stream is released.
|
||||||
if (get().currentRadio) {
|
if (get().currentRadio) {
|
||||||
clearRadioReconnectTimer();
|
stopRadio();
|
||||||
radioStopping = true;
|
|
||||||
radioAudio.pause();
|
|
||||||
radioAudio.src = '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = get();
|
const state = get();
|
||||||
@@ -2090,7 +2016,7 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
pause: () => {
|
pause: () => {
|
||||||
clearAllPlaybackScheduleTimers();
|
clearAllPlaybackScheduleTimers();
|
||||||
if (get().currentRadio) {
|
if (get().currentRadio) {
|
||||||
radioAudio.pause();
|
pauseRadio();
|
||||||
} else {
|
} else {
|
||||||
invoke('audio_pause').catch(console.error);
|
invoke('audio_pause').catch(console.error);
|
||||||
isAudioPaused = true;
|
isAudioPaused = true;
|
||||||
@@ -2156,7 +2082,7 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (get().currentRadio) {
|
if (get().currentRadio) {
|
||||||
radioAudio.play().catch(console.error);
|
resumeRadio().catch(console.error);
|
||||||
set({ isPlaying: true });
|
set({ isPlaying: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2588,7 +2514,7 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
setVolume: (v) => {
|
setVolume: (v) => {
|
||||||
const clamped = Math.max(0, Math.min(1, v));
|
const clamped = Math.max(0, Math.min(1, v));
|
||||||
invoke('audio_set_volume', { volume: clamped }).catch(console.error);
|
invoke('audio_set_volume', { volume: clamped }).catch(console.error);
|
||||||
radioAudio.volume = clamped;
|
setRadioVolume(clamped);
|
||||||
set({ volume: clamped });
|
set({ volume: clamped });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
/**
|
||||||
|
* Tests cover the imperative API — playRadioStream / pauseRadio /
|
||||||
|
* resumeRadio / stopRadio / setRadioVolume / clearRadioReconnectTimer.
|
||||||
|
* The reconnect listener loop is exercised indirectly: dispatching the
|
||||||
|
* 'stalled' event on the audio element drives the timer + counter.
|
||||||
|
*/
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const hoisted = vi.hoisted(() => {
|
||||||
|
const playerStateGet = vi.fn(() => ({ currentRadio: null as { id: string } | null }));
|
||||||
|
return {
|
||||||
|
showToastMock: vi.fn(),
|
||||||
|
playerSetStateMock: vi.fn(),
|
||||||
|
playerStateGet,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../utils/toast', () => ({ showToast: hoisted.showToastMock }));
|
||||||
|
vi.mock('./playerStore', () => ({
|
||||||
|
usePlayerStore: {
|
||||||
|
getState: hoisted.playerStateGet,
|
||||||
|
setState: hoisted.playerSetStateMock,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
_radioAudioForTest,
|
||||||
|
_resetRadioPlayerForTest,
|
||||||
|
clearRadioReconnectTimer,
|
||||||
|
pauseRadio,
|
||||||
|
playRadioStream,
|
||||||
|
resumeRadio,
|
||||||
|
setRadioVolume,
|
||||||
|
stopRadio,
|
||||||
|
} from './radioPlayer';
|
||||||
|
|
||||||
|
const audio = _radioAudioForTest();
|
||||||
|
let pauseSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
let playSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
let loadSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
hoisted.showToastMock.mockClear();
|
||||||
|
hoisted.playerSetStateMock.mockClear();
|
||||||
|
hoisted.playerStateGet.mockReset();
|
||||||
|
hoisted.playerStateGet.mockReturnValue({ currentRadio: null });
|
||||||
|
pauseSpy = vi.spyOn(audio, 'pause').mockImplementation(() => {});
|
||||||
|
playSpy = vi.spyOn(audio, 'play').mockResolvedValue(undefined as never);
|
||||||
|
loadSpy = vi.spyOn(audio, 'load').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
_resetRadioPlayerForTest();
|
||||||
|
pauseSpy.mockRestore();
|
||||||
|
playSpy.mockRestore();
|
||||||
|
loadSpy.mockRestore();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('playRadioStream', () => {
|
||||||
|
it('sets src + clamped volume + calls play', async () => {
|
||||||
|
await playRadioStream('https://stream.example/foo', 0.6);
|
||||||
|
expect(audio.src).toContain('https://stream.example/foo');
|
||||||
|
expect(audio.volume).toBeCloseTo(0.6);
|
||||||
|
expect(playSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps volume above 1 to 1', async () => {
|
||||||
|
await playRadioStream('https://x/y', 1.5);
|
||||||
|
expect(audio.volume).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps volume below 0 to 0', async () => {
|
||||||
|
await playRadioStream('https://x/y', -0.5);
|
||||||
|
expect(audio.volume).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pauseRadio / resumeRadio', () => {
|
||||||
|
it('pause delegates to audio.pause without touching src', async () => {
|
||||||
|
await playRadioStream('https://x/y', 0.5);
|
||||||
|
const before = audio.src;
|
||||||
|
pauseRadio();
|
||||||
|
expect(pauseSpy).toHaveBeenCalled();
|
||||||
|
expect(audio.src).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resume delegates to audio.play', () => {
|
||||||
|
void resumeRadio();
|
||||||
|
expect(playSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stopRadio', () => {
|
||||||
|
it('pauses + clears src + cancels reconnect timer', async () => {
|
||||||
|
await playRadioStream('https://x/y', 0.5);
|
||||||
|
stopRadio();
|
||||||
|
expect(pauseSpy).toHaveBeenCalled();
|
||||||
|
// JSdom resolves an empty src against the page URL, so assert via the
|
||||||
|
// observable HTMLAudioElement attribute instead.
|
||||||
|
expect(audio.getAttribute('src')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show an error toast if the resulting "error" event was caused by stop', () => {
|
||||||
|
stopRadio();
|
||||||
|
audio.dispatchEvent(new Event('error'));
|
||||||
|
expect(hoisted.showToastMock).not.toHaveBeenCalled();
|
||||||
|
expect(hoisted.playerSetStateMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setRadioVolume', () => {
|
||||||
|
it('sets the volume directly', () => {
|
||||||
|
setRadioVolume(0.3);
|
||||||
|
expect(audio.volume).toBeCloseTo(0.3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps the volume', () => {
|
||||||
|
setRadioVolume(2);
|
||||||
|
expect(audio.volume).toBe(1);
|
||||||
|
setRadioVolume(-1);
|
||||||
|
expect(audio.volume).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('event listeners', () => {
|
||||||
|
it('"ended" clears radio state', () => {
|
||||||
|
audio.dispatchEvent(new Event('ended'));
|
||||||
|
const calls = hoisted.playerSetStateMock.mock.calls;
|
||||||
|
const lastCall = calls[calls.length - 1]?.[0] as Record<string, unknown>;
|
||||||
|
expect(lastCall).toMatchObject({
|
||||||
|
isPlaying: false,
|
||||||
|
currentRadio: null,
|
||||||
|
progress: 0,
|
||||||
|
currentTime: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"error" (without prior stop) shows a toast + clears radio state', () => {
|
||||||
|
audio.dispatchEvent(new Event('error'));
|
||||||
|
expect(hoisted.showToastMock).toHaveBeenCalledWith('Radio stream error', 3000, 'error');
|
||||||
|
const calls = hoisted.playerSetStateMock.mock.calls;
|
||||||
|
const lastCall = calls[calls.length - 1]?.[0] as Record<string, unknown>;
|
||||||
|
expect(lastCall).toMatchObject({ isPlaying: false, currentRadio: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"stalled" schedules a reconnect attempt after 4 s', () => {
|
||||||
|
hoisted.playerStateGet.mockReturnValue({ currentRadio: { id: 'r1' } });
|
||||||
|
audio.dispatchEvent(new Event('stalled'));
|
||||||
|
expect(loadSpy).not.toHaveBeenCalled();
|
||||||
|
vi.advanceTimersByTime(4000);
|
||||||
|
expect(loadSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(playSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"stalled" suppresses second schedule when one is already pending', () => {
|
||||||
|
hoisted.playerStateGet.mockReturnValue({ currentRadio: { id: 'r1' } });
|
||||||
|
audio.dispatchEvent(new Event('stalled'));
|
||||||
|
audio.dispatchEvent(new Event('stalled'));
|
||||||
|
vi.advanceTimersByTime(4000);
|
||||||
|
expect(loadSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"stalled" gives up after MAX_RADIO_RECONNECTS (5) attempts', () => {
|
||||||
|
hoisted.playerStateGet.mockReturnValue({ currentRadio: { id: 'r1' } });
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
audio.dispatchEvent(new Event('stalled'));
|
||||||
|
vi.advanceTimersByTime(4000);
|
||||||
|
}
|
||||||
|
// Sixth stall → counter at max, gives up with toast + state clear.
|
||||||
|
audio.dispatchEvent(new Event('stalled'));
|
||||||
|
expect(hoisted.showToastMock).toHaveBeenCalledWith('Radio stream disconnected', 4000, 'error');
|
||||||
|
const calls = hoisted.playerSetStateMock.mock.calls;
|
||||||
|
const lastCall = calls[calls.length - 1]?.[0] as Record<string, unknown>;
|
||||||
|
expect(lastCall).toMatchObject({ isPlaying: false, currentRadio: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"playing" resets the reconnect counter (next stall starts fresh)', () => {
|
||||||
|
hoisted.playerStateGet.mockReturnValue({ currentRadio: { id: 'r1' } });
|
||||||
|
// Push counter to 1 via one stall cycle
|
||||||
|
audio.dispatchEvent(new Event('stalled'));
|
||||||
|
vi.advanceTimersByTime(4000);
|
||||||
|
// Successful 'playing' resets
|
||||||
|
audio.dispatchEvent(new Event('playing'));
|
||||||
|
loadSpy.mockClear();
|
||||||
|
// Next stall: should schedule again (counter is 0)
|
||||||
|
audio.dispatchEvent(new Event('stalled'));
|
||||||
|
vi.advanceTimersByTime(4000);
|
||||||
|
expect(loadSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"suspend" cancels a pending reconnect', () => {
|
||||||
|
hoisted.playerStateGet.mockReturnValue({ currentRadio: { id: 'r1' } });
|
||||||
|
audio.dispatchEvent(new Event('stalled'));
|
||||||
|
audio.dispatchEvent(new Event('suspend'));
|
||||||
|
vi.advanceTimersByTime(4000);
|
||||||
|
expect(loadSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clearRadioReconnectTimer', () => {
|
||||||
|
it('cancels a scheduled reconnect', () => {
|
||||||
|
hoisted.playerStateGet.mockReturnValue({ currentRadio: { id: 'r1' } });
|
||||||
|
audio.dispatchEvent(new Event('stalled'));
|
||||||
|
clearRadioReconnectTimer();
|
||||||
|
vi.advanceTimersByTime(10_000);
|
||||||
|
expect(loadSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when nothing is scheduled', () => {
|
||||||
|
expect(() => clearRadioReconnectTimer()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { showToast } from '../utils/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 (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;
|
||||||
|
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 {
|
||||||
|
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 = '';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user