From 89db0066b6bba1f3ef20d57f344652763fbc3f3b Mon Sep 17 00:00:00 2001 From: Adrian Florescu Date: Sat, 4 Jul 2026 08:40:02 +0300 Subject: [PATCH 1/2] R3: resume, toggle, and pause intent move into the machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the post-review refactor plan (plan.md, R3). The adapter is now pure forwarding — every user gesture is one send(); what it MEANS per state is the machine's decision. Old e2e suite untouched and green (37/37). New events, replacing USER_PAUSE_INTENT and RESUME_FAILED: - RESUME — paused: in-machine play attempt (invoked attemptPlay in a paused.resuming substate; presentation stays 'paused', a failed play() re-applies the paused fx, leaving the substate discards stale resolve/reject). idle/error/recovering: raises PLAY with the selected station. Fixes the confirmed bug: lock-screen Play was a silent no-op in 'error' (the on-screen button worked) — both are now the same event. - TOGGLE — playing: pause with user intent marked; paused/idle/error/ recovering: same as RESUME; loading/retrying: full STOP. Fixes the confirmed bug: toggle during loading swallowed the pause (AbortError) and the loading timeout then restarted playback the user had stopped. - PAUSE_REQUESTED — marks intent + pauses; the feedback states override it (and TOGGLE) with a raised STOP, so the stop-vs-pause policy left mediaSession.ts and lives with the other policies. Also fixed (confirmed regression): restart-after-long-pause now honors the same offline fast-fail as PLAY — resuming on a dead network goes straight to the error state + recovery loop instead of ~9s of loading tone. Shared restartAfterLongPause ladder serves PLAYER_PLAY, RESUME and TOGGLE. Fallout: - playerIsPaused left RadioDeps — nothing reads the element's paused flag anymore; toggle is state-driven. - radioCore: handleResumeError/resumePlayer deleted; getState/log normalize the compound 'paused' state to the flat RadioState. - mediaSession 'pause' handler is a plain core.pauseRadio(). - 6 new unit tests spec the fixed behaviors; 2 resume tests adapted to the event API (one now asserts resume-while-playing is a no-op, previously it pinned the blind play() forward). NEEDS DEVICE SMOKE before merge (lock screen: play/pause/prev/next from paused AND from error; resume after long pause; offline). Co-Authored-By: Claude Fable 5 --- src/js/main.ts | 1 - src/js/mediaSession.ts | 15 ++-- src/js/radioCore.test.ts | 145 ++++++++++++++++++++++++++++++++++++--- src/js/radioCore.ts | 55 +++++---------- src/js/radioMachine.ts | 94 +++++++++++++++++++++---- 5 files changed, 238 insertions(+), 72 deletions(-) diff --git a/src/js/main.ts b/src/js/main.ts index bd6ca47..3dd8436 100644 --- a/src/js/main.ts +++ b/src/js/main.ts @@ -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, diff --git a/src/js/mediaSession.ts b/src/js/mediaSession.ts index b8b3c8f..8e63f33 100644 --- a/src/js/mediaSession.ts +++ b/src/js/mediaSession.ts @@ -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'; @@ -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); diff --git a/src/js/radioCore.test.ts b/src/js/radioCore.test.ts index 6953677..18ae3ad 100644 --- a/src/js/radioCore.test.ts +++ b/src/js/radioCore.test.ts @@ -6,6 +6,8 @@ import { isErrorLike, isFeedbackAudible, playbackStateFor, + LOADING_TIMEOUT_MS, + RETRY_DELAY_MS, RECOVERY_DELAY_MS, RECOVERY_DELAY_MAX_MS, WATCHDOG_INTERVAL_MS, @@ -58,7 +60,6 @@ function makeDeps(overrides: Partial = {}) { 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'), @@ -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', () => { @@ -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); @@ -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); @@ -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); @@ -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 // ============================================= diff --git a/src/js/radioCore.ts b/src/js/radioCore.ts index 74b8293..52c9e39 100644 --- a/src/js/radioCore.ts +++ b/src/js/radioCore.ts @@ -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). @@ -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)[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 }); @@ -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 @@ -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, diff --git a/src/js/radioMachine.ts b/src/js/radioMachine.ts index e84021e..df0c6c0 100644 --- a/src/js/radioMachine.ts +++ b/src/js/radioMachine.ts @@ -17,7 +17,7 @@ * injected deps, so it stays testable without a browser. */ -import { setup, fromPromise, fromCallback, assign } from 'xstate'; +import { setup, fromPromise, fromCallback, assign, raise, and } from 'xstate'; // --- Shared domain types & timing constants (owned here; radioCore // re-exports them so the rest of the app keeps importing from one place) --- @@ -56,7 +56,6 @@ export interface RadioDeps { playerPause(): void; playerSetSrc(url: string): void; playerLoad(): void; - playerIsPaused(): boolean; playerCurrentTime(): number; loadingSound: FeedbackSound; errorSound: FeedbackSound; @@ -143,8 +142,13 @@ export interface RadioContext { export type RadioEvent = | { type: 'PLAY'; index: number } | { type: 'STOP' } - | { type: 'USER_PAUSE_INTENT' } - | { type: 'RESUME_FAILED' } + /** User asked to pause (on-screen button, lock screen). The machine decides + * per state whether that means pause or a full stop. */ + | { type: 'PAUSE_REQUESTED' } + /** User asked to (re)start playback (play button, lock-screen play, + * media-key resume) — the machine decides per state what that means. */ + | { type: 'RESUME' } + | { type: 'TOGGLE' } | { type: 'PLAYER_PLAY' } | { type: 'PLAYER_PAUSE' } | { type: 'PLAYER_ERROR' } @@ -171,6 +175,36 @@ export function createRadioMachine(deps: RadioDeps) { { target: 'error' as const, reenter: true, actions: 'markOfflineRecheck' as const }, ]; + // Live streams drift — resuming after a long pause would replay stale + // buffer, so restart the station instead, honoring the same offline + // fast-fail as PLAY (no point trying on a dead network). + const restartAfterLongPause = [ + { + guard: and(['pausedTooLong', 'isOnline']), + target: 'loading' as const, + actions: ['clearPauseTime' as const, 'stopPlayer' as const, 'resetRetryCount' as const, 'resetRecoveryCount' as const], + }, + { + guard: 'pausedTooLong' as const, + target: 'error' as const, + actions: ['clearPauseTime' as const, 'stopPlayer' as const, 'resetRetryCount' as const, 'resetRecoveryCount' as const, 'beginErrorCycle' as const], + }, + ]; + + // In the states where a user gesture means "(re)start the radio", RESUME + // and TOGGLE behave identically. + const startFromSelector = { + RESUME: { actions: 'raisePlaySelected' as const }, + TOGGLE: { actions: 'raisePlaySelected' as const }, + }; + + // In the feedback states, any pause-ish gesture cancels everything — + // same as the on-screen stop button. + const pauseMeansStop = { + TOGGLE: { actions: 'raiseStop' as const }, + PAUSE_REQUESTED: { actions: 'raiseStop' as const }, + }; + return setup({ types: { context: {} as RadioContext, @@ -278,6 +312,11 @@ export function createRadioMachine(deps: RadioDeps) { clearPauseTime: assign({ lastPauseTime: null }), stopPlayer: () => stopPlayer(), + pausePlayer: () => deps.playerPause(), + raiseStop: raise({ type: 'STOP' } as const), + // Every "start playing" affordance (play button, lock screen, media + // key) starts from whatever station the selector currently shows. + raisePlaySelected: raise(() => ({ type: 'PLAY' as const, index: deps.getSelectedIndex() })), // Order matters: this runs as an ENTRY action after applyFx, so the // native 'pause' event it triggers arrives while the state is already // loading/recovering and gets ignored (main.ts skips playbackState @@ -319,13 +358,17 @@ export function createRadioMachine(deps: RadioDeps) { target: '.idle', actions: ['resetRetryCount', 'resetRecoveryCount', 'clearPauseTime', 'stopPlayer'], }, - USER_PAUSE_INTENT: { actions: 'markUserPauseIntent' }, + // A user-intent pause: mark it so the native 'pause' event it triggers + // isn't mistaken for a dying stream. The feedback states override this + // (and TOGGLE) with a full stop. + PAUSE_REQUESTED: { actions: ['markUserPauseIntent', 'pausePlayer'] }, PLAYER_PLAY: { actions: 'clearPauseTime' }, }, states: { idle: { entry: [{ type: 'applyFx', params: { state: 'idle' } }], + on: { ...startFromSelector }, }, loading: { @@ -349,6 +392,7 @@ export function createRadioMachine(deps: RadioDeps) { after: { LOADING_TIMEOUT: streamFailure('stopPlayer'), }, + on: { ...pauseMeansStop }, }, retrying: { @@ -360,12 +404,14 @@ export function createRadioMachine(deps: RadioDeps) { { target: 'error', actions: ['stopPlayer', 'beginErrorCycle'] }, ], }, + on: { ...pauseMeansStop }, }, playing: { entry: [{ type: 'applyFx', params: { state: 'playing' } }], invoke: { src: 'watchdog' }, on: { + TOGGLE: { actions: ['markUserPauseIntent', 'pausePlayer'] }, STALLED: streamFailure('clearPauseTime'), PLAYER_PAUSE: [ { @@ -385,21 +431,35 @@ export function createRadioMachine(deps: RadioDeps) { paused: { entry: [{ type: 'applyFx', params: { state: 'paused' } }], + initial: 'still', + states: { + still: {}, + // The in-machine resume attempt (replaces the adapter's old + // resumePlayer + RESUME_FAILED). Presentation stays 'paused' — the + // substate exists so a failed play() re-applies the paused fx, and + // leaving it discards a stale resolve/reject like every attemptPlay. + resuming: { + invoke: { + src: 'attemptPlay', + onDone: { target: '#radio.playing', actions: 'clearPauseTime' }, + onError: [ + // Our own pause/src change interrupted play() — stay paused. + { guard: 'isAbortError', target: 'still' }, + // Failed resume: pause defensively and re-enter paused to + // re-apply the fx (the old RESUME_FAILED re-application). + { target: '#radio.paused', actions: 'pausePlayer' }, + ], + }, + }, + }, on: { PLAYER_PLAY: [ - { - // Live streams drift — resuming after a long pause replays stale - // buffer, so restart the station from scratch instead. - guard: 'pausedTooLong', - target: 'loading', - actions: ['clearPauseTime', 'stopPlayer', 'resetRetryCount', 'resetRecoveryCount'], - }, + ...restartAfterLongPause, { target: 'playing', actions: 'clearPauseTime' }, ], + RESUME: [...restartAfterLongPause, { target: '.resuming' }], + TOGGLE: [...restartAfterLongPause, { target: '.resuming' }], PLAYER_ERROR: streamFailure('clearPauseTime'), - // A failed resume keeps us paused; re-enter to re-apply the fx - // (same as the old setState('paused') re-application). - RESUME_FAILED: { target: 'paused', reenter: true }, }, }, @@ -411,6 +471,8 @@ export function createRadioMachine(deps: RadioDeps) { }, on: { RETRY_FROM_ERROR: tryRecover, + ...startFromSelector, + PAUSE_REQUESTED: { actions: 'raiseStop' }, }, }, @@ -437,6 +499,8 @@ export function createRadioMachine(deps: RadioDeps) { }, on: { RETRY_FROM_ERROR: { guard: 'isOnline', target: 'recovering', reenter: true }, + ...startFromSelector, + PAUSE_REQUESTED: { actions: 'raiseStop' }, }, }, }, From e29cb66772a3191062514f7d86ee4dd22e6caf48 Mon Sep 17 00:00:00 2001 From: Adrian Florescu Date: Sat, 4 Jul 2026 08:52:39 +0300 Subject: [PATCH 2/2] plan.md: record the device-observed iOS repro for the 4b tone channel Play-then-immediate-lock -> wifi off: loading tone plays but the error tone never starts (fresh background start refused by iOS); once the app has been through an error cycle with the screen open, later lock-screen cycles work. The single tone channel (src swap on an already-audible element) fixes this by construction. Co-Authored-By: Claude Fable 5 --- plan.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/plan.md b/plan.md index cfb5a9f..f792e0d 100644 --- a/plan.md +++ b/plan.md @@ -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`).