Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,22 @@ Implementat conform planului de mai jos. Note:
Ramane separat (vezi sectiunea de mai jos "Redesign audioInstance") — cere
re-validare pe device (lock screen, prev/next, offline).

Repro observat pe iPhone (Adrian, 2026-07-04) — cazul exact pe care canalul
de ton il rezolva prin constructie:
- Flux OK: play din app, folosire normala, lock -> wifi off -> loading sound
+ imagine loading -> apoi eroare cu sunet -> wifi on -> revine singur.
- Caveat: play din app si LOCK IMEDIAT -> wifi off -> loading-ul se aude,
dar la trecerea in error audio se opreste complet (liniste pe lock screen).
- Daca aplicatia a trecut O DATA prin eroare cu ecranul deschis (iOS a auzit
efectiv elementul de eroare), ciclurile urmatoare din lock merg corect.
- Interpretare: warmUp-ul de o fractiune de secunda la gestul de play nu e
suficient ca iOS sa "binecuvanteze" elementul de eroare daca lock-ul vine
imediat; loading-ul merge pentru ca porneste chiar in call stack-ul
gestului, cu aplicatia in fata. Supervisor-ul care reincearca la 2.5s e
refuzat la nesfarsit din acelasi motiv (pornire proaspata in background).
- Canalul unic de ton (loading care isi schimba src-ul in eroare, element
deja audibil) ocoleste exact refuzul asta.

### Planul initial (referinta)

- Instalam `xstate` (fara `@xstate/react`).
Expand Down
1 change: 0 additions & 1 deletion src/js/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,6 @@ const core = createRadioCore({
playerPause: () => player.pause(),
playerSetSrc: (url) => { player.src = url; },
playerLoad: () => player.load(),
playerIsPaused: () => player.paused,
playerCurrentTime: () => player.currentTime,
loadingSound: loadingNoiseInstance,
errorSound: errorNoiseInstance,
Expand Down
15 changes: 4 additions & 11 deletions src/js/mediaSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
*/

import type { RadioCore, RadioState } from './radioCore';
import { isLoadingLike, isErrorLike, isFeedbackAudible, playbackStateFor } from './radioCore';
import { isLoadingLike, isErrorLike, playbackStateFor } from './radioCore';
import { LABELS } from './labels';
import { cloudinaryImageUrl } from './cloudinary';
import { radioSelect, posterImage, loadingMsg, loadingNoise, errorNoise } from './dom';
Expand All @@ -37,16 +37,9 @@ export function connectMediaSessionCore(radioCore: RadioCore): void {
function registerMediaSessionHandlers() {
navigator.mediaSession.setActionHandler('previoustrack', () => core?.prevRadio());
navigator.mediaSession.setActionHandler('nexttrack', () => core?.nextRadio());
navigator.mediaSession.setActionHandler('pause', () => {
if (!core) return;
// While a feedback sound is what's audible (not the stream), "pause"
// should cancel everything (same as the on-screen stop button).
if (isFeedbackAudible(core.getState())) {
core.stopRadio();
} else {
core.pauseRadio();
}
});
// Whether "pause" means pause or a full stop (while a feedback sound is
// what's audible) is the machine's per-state decision, not ours.
navigator.mediaSession.setActionHandler('pause', () => core?.pauseRadio());
navigator.mediaSession.setActionHandler('play', () => core?.resumeRadio());
navigator.mediaSession.setActionHandler('seekbackward', null);
navigator.mediaSession.setActionHandler('seekforward', null);
Expand Down
145 changes: 137 additions & 8 deletions src/js/radioCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
isErrorLike,
isFeedbackAudible,
playbackStateFor,
LOADING_TIMEOUT_MS,
RETRY_DELAY_MS,
RECOVERY_DELAY_MS,
RECOVERY_DELAY_MAX_MS,
WATCHDOG_INTERVAL_MS,
Expand Down Expand Up @@ -58,7 +60,6 @@ function makeDeps(overrides: Partial<RadioDeps> = {}) {
playerPause: () => { calls.playerPause.push('pause'); calls.paused = true; },
playerSetSrc: (url: string) => { calls.playerSetSrc.push(url); },
playerLoad: () => { calls.playerLoad.push('load'); },
playerIsPaused: () => calls.paused,
playerCurrentTime: () => calls.currentTime,
loadingSound: {
play: () => calls.loadingSound.push('play'),
Expand Down Expand Up @@ -665,17 +666,32 @@ describe('pauseRadio / resumeRadio', () => {
expect(calls.playerPause.length).toBe(pausesBefore + 1);
});

it('resumeRadio calls playerPlay', async () => {
it('resumeRadio calls playerPlay from paused', async () => {
const { deps, calls } = makeDeps();
const { core, clock } = createCore(deps);

core.playRadio(0);
await flushPromises();
core.onPlayerPause(); // state → paused
const playsBefore = calls.playerPlay.length;

core.resumeRadio();
expect(calls.playerPlay.length).toBe(playsBefore + 1);
});

it('resumeRadio while already playing is a no-op', async () => {
const { deps, calls } = makeDeps();
const { core, clock } = createCore(deps);

core.playRadio(0);
await flushPromises();
expect(core.getState()).toBe('playing');
const playsBefore = calls.playerPlay.length;

core.resumeRadio();
expect(calls.playerPlay.length).toBe(playsBefore);
expect(core.getState()).toBe('playing');
});
});

describe('togglePlayPause', () => {
Expand Down Expand Up @@ -729,9 +745,9 @@ describe('resume failures', () => {

const pauseCallsBeforeResume = calls.playerPause.length;
deps._setPlayerPlayResult(new Promise((_, reject) => { rejectResume = reject; }));
const resume = core.resumeRadio();
core.resumeRadio();
rejectResume(new Error('resume blocked'));
await resume;
await flushPromises();

expect(core.getState()).toBe('paused');
expect(calls.paused).toBe(true);
Expand All @@ -752,9 +768,9 @@ describe('resume failures', () => {

const pauseCallsBeforeResume = calls.playerPause.length;
deps._setPlayerPlayResult(new Promise((_, reject) => { rejectResume = reject; }));
const resume = core.togglePlayPause();
core.togglePlayPause();
rejectResume(new Error('resume blocked'));
await resume;
await flushPromises();

expect(core.getState()).toBe('paused');
expect(calls.paused).toBe(true);
Expand All @@ -773,9 +789,9 @@ describe('resume failures', () => {

const pauseCallsBeforeResume = calls.playerPause.length;
deps._setPlayerPlayResult(new Promise((_, reject) => { rejectResume = reject; }));
const resume = core.onPlayButtonClick();
core.onPlayButtonClick();
rejectResume(new Error('resume blocked'));
await resume;
await flushPromises();

expect(core.getState()).toBe('paused');
expect(calls.paused).toBe(true);
Expand All @@ -784,6 +800,119 @@ describe('resume failures', () => {

});

// =============================================
// USER GESTURES ARE MACHINE POLICY (R3)
// =============================================

describe('user gestures are machine policy', () => {
it('toggle during loading cancels everything — playback must not restart later', async () => {
const { deps } = makeDeps();
deps._setPlayerPlayResult(new Promise(() => {})); // stream never connects
const { core, clock } = createCore(deps);

core.playRadio(0);
expect(core.getState()).toBe('loading');

core.togglePlayPause(); // the user's last action: "stop this"
expect(core.getState()).toBe('idle');

// The old bug: the loading timeout survived, retried, and started
// playback the user had just tried to stop.
clock.increment(LOADING_TIMEOUT_MS + RETRY_DELAY_MS + 1000);
await flushPromises();
expect(core.getState()).toBe('idle');
});

it('a pause request during loading stops instead of pausing', () => {
const { deps } = makeDeps();
deps._setPlayerPlayResult(new Promise(() => {}));
const { core, clock } = createCore(deps);

core.playRadio(0);
expect(core.getState()).toBe('loading');

core.pauseRadio(); // lock-screen pause while the loading tone plays
expect(core.getState()).toBe('idle');
});

it('a play gesture from error starts the selected station (was a lock-screen no-op)', async () => {
const { deps } = makeDeps();
deps._setPlayerPlayResult(Promise.reject(new Error('fail')));
const { core, clock } = createCore(deps);

core.playRadio(0);
await flushPromises();
clock.increment(RETRY_DELAY_MS);
await flushPromises();
expect(core.getState()).toBe('error');

deps._setPlayerPlayResult(Promise.resolve());
core.resumeRadio(); // lock-screen / media-key play
expect(core.getState()).toBe('loading');
await flushPromises();
expect(core.getState()).toBe('playing');
});

it('a play gesture from error while offline fast-fails without touching the player', async () => {
let online = true;
const { deps, calls } = makeDeps({ isOnline: () => online });
deps._setPlayerPlayResult(Promise.reject(new Error('fail')));
const { core, clock } = createCore(deps);

core.playRadio(0);
await flushPromises();
clock.increment(RETRY_DELAY_MS);
await flushPromises();
expect(core.getState()).toBe('error');

online = false;
const playsBefore = calls.playerPlay.length;
core.resumeRadio();
expect(core.getState()).toBe('error');
expect(calls.playerPlay.length).toBe(playsBefore); // nothing attempted on a dead network
expect(calls.errorSound.at(-1)).toBe('play'); // still audible
});

it('resume after a long pause while offline fast-fails to error, not ~9s of loading tone', async () => {
let online = true;
const { deps, calls } = makeDeps({ isOnline: () => online });
const { core, clock } = createCore(deps);

core.playRadio(0);
await flushPromises();
expect(core.getState()).toBe('playing');

calls.now = 1000;
core.pauseRadio();
core.onPlayerPause();
expect(core.getState()).toBe('paused');

online = false;
calls.now = 5000; // well past LONG_PAUSE_RESTART_MS
core.resumeRadio();
expect(core.getState()).toBe('error'); // immediate fast-fail, recovery loop armed
expect(calls.errorSound.at(-1)).toBe('play');
});

it('resume gesture after a long pause restarts the station through loading', async () => {
const { deps, calls } = makeDeps();
const { core, clock } = createCore(deps);

core.playRadio(0);
await flushPromises();
calls.now = 1000;
core.pauseRadio();
core.onPlayerPause();
expect(core.getState()).toBe('paused');

calls.now = 5000;
core.resumeRadio();
expect(core.getState()).toBe('loading'); // full restart, not a stale-buffer resume
await flushPromises();
expect(core.getState()).toBe('playing');
});
});

// =============================================
// RESTART AFTER LONG PAUSE
// =============================================
Expand Down
55 changes: 18 additions & 37 deletions src/js/radioCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
*
* Keeps the same public API the DOM layer always used (playRadio, stopRadio,
* togglePlayPause, onPlayerPause, …) and translates it into machine events.
* All DOM / browser interaction still comes in through the `deps` object so
* everything stays testable without a browser.
* Pure forwarding: every user gesture and player event is one send(); all
* playback policy (what resume/toggle/pause mean per state) lives in the
* machine. All DOM / browser interaction still comes in through the `deps`
* object so everything stays testable without a browser.
*/

import { createActor } from 'xstate';
import { createRadioMachine, isAbortError } from './radioMachine';
import { createRadioMachine } from './radioMachine';
import type { RadioDeps, RadioState } from './radioMachine';

/** The clock shape xstate actors accept (not exported by the library).
Expand Down Expand Up @@ -53,16 +55,21 @@ export function createRadioCore(
...(options.inspect ? { inspect: options.inspect } : {}),
});

// 'paused' is a compound state (its resume attempt lives in a substate);
// the public RadioState stays the flat top-level name.
const stateOf = (value: unknown): RadioState =>
(typeof value === 'string' ? value : Object.keys(value as Record<string, unknown>)[0]) as RadioState;

// Same transition log the old state machine printed.
let prev: RadioState | null = null;
actor.subscribe((snapshot) => {
const next = snapshot.value as RadioState;
const next = stateOf(snapshot.value);
if (prev !== next) console.log(`[radio] ${prev ?? '∅'} → ${next}`);
prev = next;
});
actor.start();

const getState = (): RadioState => actor.getSnapshot().value as RadioState;
const getState = (): RadioState => stateOf(actor.getSnapshot().value);

function playRadio(index: number) {
actor.send({ type: 'PLAY', index });
Expand All @@ -85,36 +92,15 @@ export function createRadioCore(
}

function pauseRadio() {
// Remember that this pause was asked for by the user, so the native
// 'pause' event it triggers isn't mistaken for a dying stream.
actor.send({ type: 'USER_PAUSE_INTENT' });
deps.playerPause();
}

function handleResumeError(error: unknown) {
if (isAbortError(error)) return;
try {
deps.playerPause();
} catch (_) {
// Keep resume error handling focused on restoring state.
}
actor.send({ type: 'RESUME_FAILED' });
actor.send({ type: 'PAUSE_REQUESTED' });
}

function resumeRadio() {
return deps.playerPlay().catch(handleResumeError);
actor.send({ type: 'RESUME' });
}

function togglePlayPause() {
if (deps.playerIsPaused()) {
const s = getState();
if (s === 'paused') return resumeRadio();
else if (s === 'idle' || s === 'error' || s === 'recovering') {
playRadio(deps.getSelectedIndex());
}
} else {
pauseRadio();
}
actor.send({ type: 'TOGGLE' });
}

// Native player events → machine events
Expand All @@ -123,14 +109,9 @@ export function createRadioCore(
const onPlayerError = () => actor.send({ type: 'PLAYER_ERROR' });
const retryFromError = () => actor.send({ type: 'RETRY_FROM_ERROR' });

function onPlayButtonClick() {
const s = getState();
if (s === 'idle' || s === 'error' || s === 'recovering') {
playRadio(deps.getSelectedIndex());
} else if (s === 'paused') {
return resumeRadio();
}
}
// The on-screen play button and the lock-screen play control are the same
// gesture — the machine decides per state what it means.
const onPlayButtonClick = resumeRadio;

return {
getState,
Expand Down
Loading