diff --git a/README.md b/README.md index 0f30597..83014c9 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,9 @@ node --version # should be v20.19.0 or newer ```bash npm run dev # vite dev server (include tailwind) + # + http://localhost:5173/?inspect → Stately Inspector: + # diagrama LIVE a masinii de stari, cu tranzitii/events + # in timp real (doar dev; zero bytes in productie) npm test # unit tests (vitest) npm run test:coverage # unit coverage + raport HTML in coverage/index.html npm run test:e2e # e2e tests (playwright, pornește singur serverul) diff --git a/package-lock.json b/package-lock.json index 03b9e3d..3f40745 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,12 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "tailwindcss": "^4.0.14" + "tailwindcss": "^4.0.14", + "xstate": "^5.32.4" }, "devDependencies": { "@playwright/test": "^1.58.2", + "@statelyai/inspect": "^0.7.2", "@tailwindcss/vite": "^4.3.2", "@vitest/coverage-v8": "^3.2.4", "typescript": "^6.0.3", @@ -1116,6 +1118,24 @@ "win32" ] }, + "node_modules/@statelyai/inspect": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@statelyai/inspect/-/inspect-0.7.2.tgz", + "integrity": "sha512-axuXSEBsI8pQ89rP2DWq+kHLjABiXpe2qYDOo6+jVLJ+9NPOTjutMnmDd+XcJjn76d+RH6sfHa0XkFIYPUHDOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-safe-stringify": "^2.1.1", + "isomorphic-ws": "^5.0.0", + "partysocket": "^0.0.25", + "safe-stable-stringify": "^2.5.0", + "superjson": "^1.13.3", + "ws": "^8.20.0" + }, + "peerDependencies": { + "xstate": "^5.5.1" + } + }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", @@ -1952,6 +1972,22 @@ "dev": true, "license": "MIT" }, + "node_modules/copy-anything": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz", + "integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-what": "^4.1.8" + }, + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2080,6 +2116,19 @@ "@types/estree": "^1.0.0" } }, + "node_modules/event-target-shim": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-6.0.2.tgz", + "integrity": "sha512-8q3LsZjRezbFZ2PN+uP+Q7pnHUMmAOziU2vA2OwoFaKIXxlxl38IylhSSgUorWu/rf4er67w0ikBqjBFk/pomA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -2090,6 +2139,13 @@ "node": ">=12.0.0" } }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -2139,12 +2195,35 @@ "node": ">=8" } }, + "node_modules/is-what": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz", + "integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "node_modules/isomorphic-ws": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz", + "integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -2338,6 +2417,16 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "dev": true }, + "node_modules/partysocket": { + "version": "0.0.25", + "resolved": "https://registry.npmjs.org/partysocket/-/partysocket-0.0.25.tgz", + "integrity": "sha512-1oCGA65fydX/FgdnsiBh68buOvfxuteoZVSb3Paci2kRp/7lhF0HyA8EDb5X/O6FxId1e+usPTQNRuzFEvkJbQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "event-target-shim": "^6.0.2" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -2523,6 +2612,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/semver": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", @@ -2668,6 +2767,19 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/superjson": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-1.13.3.tgz", + "integrity": "sha512-mJiVjfd2vokfDxsQPOwJ/PtanO87LhpYY88ubI5dUB1Ab58Txbyje3+jpm+/83R/fevaq/107NNhtYBLuoTrFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "copy-anything": "^3.0.2" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -3195,6 +3307,38 @@ "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xstate": { + "version": "5.32.4", + "resolved": "https://registry.npmjs.org/xstate/-/xstate-5.32.4.tgz", + "integrity": "sha512-E5WtDB8DBs2ZWliz2Ry9XfbSZTbBRcK/cwefBot04qQ/L5SLP16xpnTDU4/ZFXuXFhNxi7JP2RhuoGwBnM+S4A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/xstate" + } } } } diff --git a/package.json b/package.json index d10408e..b5b4838 100644 --- a/package.json +++ b/package.json @@ -19,10 +19,12 @@ "license": "ISC", "description": "", "dependencies": { - "tailwindcss": "^4.0.14" + "tailwindcss": "^4.0.14", + "xstate": "^5.32.4" }, "devDependencies": { "@playwright/test": "^1.58.2", + "@statelyai/inspect": "^0.7.2", "@tailwindcss/vite": "^4.3.2", "@vitest/coverage-v8": "^3.2.4", "typescript": "^6.0.3", diff --git a/plan.md b/plan.md index f025576..2e016e3 100644 --- a/plan.md +++ b/plan.md @@ -252,6 +252,36 @@ manual pe MediaSession (iOS/macOS daca se poate — e zona cea mai fragila). Obiectiv: tranzitii declarative si timere/race-uri rezolvate din constructie, cu paritate 100% de comportament. +### 4a: masina + adaptorul [gata — branch `faza4-xstate`] + +Implementat conform planului de mai jos. Note: +- `applyFx` e entry action parametrizata per stare (nu subscribe) — reenter + (RESUME_FAILED, recheck offline) re-aplica fx exact ca vechiul setState. +- `beginErrorCycle` (increment recoveryCount + clear offlineRecheck) ruleaza + pe TRANZITIILE spre error, nu pe entry — garanteaza ca RECOVERY_DELAY vede + contextul incrementat. Recheck-ul offline e self-transition cu reenter + + `offlineRecheck: true` (cadenta fixa, fara escaladare). +- pauseRadio/resumePlayer/toggle/onPlayButtonClick raman in adaptor (citesc + playerIsPaused/getState live, ca inainte); restul devine events. +- deps.setTimeout/clearTimeout au DISPARUT din contract (after-delays traiesc + in clock-ul actorului); testele injecteaza SimulatedClock impachetat cu + vizibilitate pe delay-urile programate (clock.hasScheduled). +- CAPCANA gasita de e2e (nu de unit): browserul arunca "Illegal invocation" + cand masina apeleaza deps.setInterval ca metoda — global timer functions + cer this=window; fix: wrapper arrow in main.ts. Unit testele n-o puteau + prinde (mock-uri); inca un argument pentru e2e-ul neatins ca dovada. +- Cost bundle: 16KB -> 60KB raw (5.7 -> 19.3KB gzip) — pretul xstate. +- Verificat: typecheck curat, 63/63 unit portate (SimulatedClock, playId -> + asertiuni comportamentale), 37/37 e2e NEATINSE, coverage 99% pe masina, + smoke complet pe vite preview. + +### 4b: redesign audioInstance + canal de feedback [de facut] + +Ramane separat (vezi sectiunea de mai jos "Redesign audioInstance") — cere +re-validare pe device (lock screen, prev/next, offline). + +### Planul initial (referinta) + - Instalam `xstate` (fara `@xstate/react`). - `src/js/radioMachine.ts` cu `setup()`: - Stari: `idle`, `loading`, `playing`, `paused`, `retrying`, `error`, diff --git a/src/js/main.ts b/src/js/main.ts index 96c399f..3545859 100644 --- a/src/js/main.ts +++ b/src/js/main.ts @@ -88,6 +88,16 @@ const showButton = (which: 'play' | 'pause' | 'stop') => { // --- Core --- +// Dev-only: live machine diagram via the Stately Inspector. +// Run `npm run dev` and open http://localhost:5173/?inspect — a stately.ai +// window shows the running machine with transitions/events in real time. +// Compile-time dead code in production (import.meta.env.DEV is false), and +// opt-in via the URL param so the e2e runs (dev server!) never open it. +const inspector = + import.meta.env.DEV && new URLSearchParams(window.location.search).has('inspect') + ? (await import('@statelyai/inspect')).createBrowserInspector() + : undefined; + const core = createRadioCore({ getStationUrl: (i) => radioSelect.options[i].value, getStationCount: () => radioSelect.options.length, @@ -109,13 +119,13 @@ const core = createRadioCore({ maybeReloadForPendingServiceWorkerUpdate(s); }, saveLastIndex, - setTimeout, - clearTimeout: (id) => clearTimeout(id ?? undefined), - setInterval, + // Wrapped: the machine calls these as methods on deps, and browser timer + // functions throw "Illegal invocation" when invoked with a foreign `this`. + setInterval: (fn, ms) => setInterval(fn, ms), clearInterval: (id) => clearInterval(id ?? undefined), performanceNow: () => performance.now(), isOnline: () => navigator.onLine, -}); +}, inspector ? { inspect: inspector.inspect } : {}); connectMediaSessionCore(core); focusInitialPlaybackControl(); diff --git a/src/js/radioCore.test.ts b/src/js/radioCore.test.ts index 9beb8bf..f7d4ff2 100644 --- a/src/js/radioCore.test.ts +++ b/src/js/radioCore.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SimulatedClock } from 'xstate'; import { createRadioCore, RECOVERY_DELAY_MS, @@ -103,15 +104,41 @@ function flushPromises() { return new Promise(resolve => setTimeout(resolve, 0)); } -function fireTimer(deps: TestDeps, delayMs: number) { - for (const [id, timer] of deps._pendingTimers) { - if (timer.ms === delayMs) { - deps._pendingTimers.delete(id); - timer.fn(); - return true; - } - } - return false; +// The machine's `after` delays run on the actor clock (not deps timers). +// This wrapper adds visibility: which delays are currently scheduled. +function makeClock() { + const sim = new SimulatedClock(); + const pending = new Map(); + return { + setTimeout(fn: (...args: unknown[]) => void, ms: number) { + let id: unknown; + id = sim.setTimeout((...args: unknown[]) => { + pending.delete(id); + fn(...args); + }, ms); + pending.set(id, ms); + return id; + }, + clearTimeout(id: unknown) { + pending.delete(id); + sim.clearTimeout(id as Parameters[0]); + }, + /** Advance simulated time, firing every delay that comes due. */ + increment(ms: number) { + sim.increment(ms); + }, + /** Is a delay of exactly `ms` currently scheduled? */ + hasScheduled(ms: number) { + return [...pending.values()].includes(ms); + }, + }; +} +type TestClock = ReturnType; + +function createCore(deps: TestDeps) { + const clock = makeClock(); + const core = createRadioCore(deps, { clock }); + return { core, clock }; } function tickWatchdog(deps: TestDeps, times = 1) { @@ -129,7 +156,7 @@ function tickWatchdog(deps: TestDeps, times = 1) { describe('side-effects per state', () => { it('idle: play button, no sounds, no messages', () => { const { deps, calls } = makeDeps(); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); // starts idle expect(core.getState()).toBe('idle'); @@ -140,7 +167,7 @@ describe('side-effects per state', () => { it('loading: stop button, loading sound, loading message', () => { const { deps, calls } = makeDeps(); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(0); expect(core.getState()).toBe('loading'); @@ -152,7 +179,7 @@ describe('side-effects per state', () => { it('playing: pause button, sounds stopped, no messages', async () => { const { deps, calls } = makeDeps(); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(0); await flushPromises(); @@ -167,7 +194,7 @@ describe('side-effects per state', () => { it('paused: play button, sounds stopped', async () => { const { deps, calls } = makeDeps(); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(0); await flushPromises(); @@ -181,7 +208,7 @@ describe('side-effects per state', () => { it('retrying: stop button, loading sound keeps playing', async () => { const { deps, calls } = makeDeps(); deps._setPlayerPlayResult(Promise.reject(new Error('fail'))); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(0); await flushPromises(); @@ -196,11 +223,11 @@ describe('side-effects per state', () => { it('error: stop button, error sound, error message', async () => { const { deps, calls } = makeDeps(); deps._setPlayerPlayResult(Promise.reject(new Error('fail'))); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(0); await flushPromises(); - fireTimer(deps, 3000); + clock.increment(3000); await flushPromises(); expect(core.getState()).toBe('error'); @@ -213,7 +240,7 @@ describe('side-effects per state', () => { it('stopRadio → idle: play button, all sounds stopped', () => { const { deps, calls } = makeDeps(); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(0); core.stopRadio(); @@ -234,7 +261,7 @@ describe('side-effects per state', () => { describe('playRadio — happy path', () => { it('idle → loading → playing', async () => { const { deps, calls } = makeDeps(); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); expect(core.getState()).toBe('idle'); @@ -261,21 +288,21 @@ describe('playRadio — happy path', () => { describe('playRadio — error with retry', () => { it('goes straight to error when starting offline', () => { const { deps, calls } = makeDeps({ isOnline: () => false }); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(1); expect(core.getState()).toBe('error'); expect(calls.playerPlay).toEqual([]); expect(calls.playerSetSrc.at(-1)).toBe(''); - expect([...deps._pendingTimers.values()].some(t => t.ms === RECOVERY_DELAY_MS)).toBe(true); + expect(clock.hasScheduled(RECOVERY_DELAY_MS)).toBe(true); }); it('retries then errors after MAX_RETRIES', async () => { const { deps, calls } = makeDeps(); const playError = new Error('Network error'); deps._setPlayerPlayResult(Promise.reject(playError)); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(1); await flushPromises(); @@ -291,7 +318,7 @@ describe('playRadio — error with retry', () => { ); // Fire retry timer (3000ms) - fireTimer(deps, 3000); + clock.increment(3000); await flushPromises(); // After MAX_RETRIES (1), second failure → error @@ -310,7 +337,7 @@ describe('playRadio — error with retry', () => { describe('pause and resume', () => { it('playing → paused → playing', async () => { const { deps, calls } = makeDeps(); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(0); await flushPromises(); @@ -327,7 +354,7 @@ describe('pause and resume', () => { it('onPlayerPause does nothing when not playing', () => { const { deps } = makeDeps(); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.onPlayerPause(); expect(core.getState()).toBe('idle'); // unchanged @@ -335,7 +362,7 @@ describe('pause and resume', () => { it('onPlayerPlay does nothing when not paused', async () => { const { deps } = makeDeps(); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); // in loading state core.playRadio(0); @@ -353,7 +380,7 @@ describe('pause and resume', () => { describe('native player errors', () => { it('retries when the stream errors while playing', async () => { const { deps } = makeDeps(); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(0); await flushPromises(); @@ -366,7 +393,7 @@ describe('native player errors', () => { it('retries when the stream errors while paused', async () => { const { deps } = makeDeps(); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(0); await flushPromises(); @@ -386,7 +413,7 @@ describe('native player errors', () => { describe('stopRadio', () => { it('stops from loading', () => { const { deps, calls } = makeDeps(); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(0); expect(core.getState()).toBe('loading'); @@ -401,7 +428,7 @@ describe('stopRadio', () => { it('stops from playing', async () => { const { deps, calls } = makeDeps(); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(0); await flushPromises(); @@ -414,11 +441,11 @@ describe('stopRadio', () => { it('stops from error', async () => { const { deps, calls } = makeDeps(); deps._setPlayerPlayResult(Promise.reject(new Error('fail'))); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(0); await flushPromises(); - fireTimer(deps, 3000); // retry + clock.increment(3000); // retry await flushPromises(); // second failure → error expect(core.getState()).toBe('error'); @@ -428,20 +455,19 @@ describe('stopRadio', () => { expect(calls.errorSound.at(-1)).toBe('stop'); }); - it('invalidates pending callbacks (playId)', async () => { + it('discards the in-flight play attempt on stop', async () => { const { deps, calls } = makeDeps(); let resolvePlay!: () => void; deps._setPlayerPlayResult(new Promise(r => { resolvePlay = r; })); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(0); - const idBefore = core._getPlayId(); - core.stopRadio(); - expect(core._getPlayId()).not.toBe(idBefore); + expect(core.getState()).toBe('idle'); - // Now resolve the old play promise — should be ignored + // Now resolve the abandoned play promise — the machine left 'loading', + // so the invoked actor's late result is discarded by construction. resolvePlay(); await flushPromises(); expect(core.getState()).toBe('idle'); // not 'playing' @@ -455,7 +481,7 @@ describe('stopRadio', () => { describe('onPlayButtonClick', () => { it('plays from idle', () => { const { deps, calls } = makeDeps(); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); calls.selectedIndex = 3; core.onPlayButtonClick(); @@ -465,11 +491,11 @@ describe('onPlayButtonClick', () => { it('plays from error', async () => { const { deps, calls } = makeDeps(); deps._setPlayerPlayResult(Promise.reject(new Error('fail'))); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(0); await flushPromises(); - fireTimer(deps, 3000); + clock.increment(3000); await flushPromises(); expect(core.getState()).toBe('error'); @@ -480,7 +506,7 @@ describe('onPlayButtonClick', () => { it('resumes from paused', async () => { const { deps, calls } = makeDeps(); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(0); await flushPromises(); @@ -494,7 +520,7 @@ describe('onPlayButtonClick', () => { it('does nothing during loading', () => { const { deps } = makeDeps(); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(0); expect(core.getState()).toBe('loading'); @@ -511,7 +537,7 @@ describe('onPlayButtonClick', () => { describe('prevRadio / nextRadio', () => { it('nextRadio wraps around', () => { const { deps, calls } = makeDeps(); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); calls.selectedIndex = 4; // last station (count = 5) core.nextRadio(); @@ -520,7 +546,7 @@ describe('prevRadio / nextRadio', () => { it('prevRadio wraps around', () => { const { deps, calls } = makeDeps(); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); calls.selectedIndex = 0; core.prevRadio(); @@ -529,7 +555,7 @@ describe('prevRadio / nextRadio', () => { it('nextRadio advances normally', () => { const { deps, calls } = makeDeps(); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); calls.selectedIndex = 2; core.nextRadio(); @@ -546,22 +572,22 @@ describe('loading timeout', () => { const { deps, calls } = makeDeps(); // playerPlay never resolves (simulates stuck stream) deps._setPlayerPlayResult(new Promise(() => {})); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(0); expect(core.getState()).toBe('loading'); // Fire the loading timeout (6000ms) - fireTimer(deps, 6000); + clock.increment(6000); // First timeout → retry expect(core.getState()).toBe('retrying'); // Fire retry timer - fireTimer(deps, 3000); + clock.increment(3000); expect(core.getState()).toBe('loading'); // Fire loading timeout again - fireTimer(deps, 6000); + clock.increment(6000); // Now MAX_RETRIES exhausted → error expect(core.getState()).toBe('error'); }); @@ -579,7 +605,7 @@ describe('rapid station switching', () => { calls.playerPlay.push('play'); return new Promise(r => resolvers.push(r)); }; - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(0); core.playRadio(1); @@ -603,7 +629,7 @@ describe('retrying keeps loading sound', () => { it('loading sound is not stopped during retrying', async () => { const { deps, calls } = makeDeps(); deps._setPlayerPlayResult(Promise.reject(new Error('fail'))); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(0); await flushPromises(); @@ -624,7 +650,7 @@ describe('retrying keeps loading sound', () => { describe('pauseRadio / resumeRadio', () => { it('pauseRadio calls playerPause', async () => { const { deps, calls } = makeDeps(); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(0); await flushPromises(); @@ -636,7 +662,7 @@ describe('pauseRadio / resumeRadio', () => { it('resumeRadio calls playerPlay', async () => { const { deps, calls } = makeDeps(); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(0); await flushPromises(); @@ -650,7 +676,7 @@ describe('pauseRadio / resumeRadio', () => { describe('togglePlayPause', () => { it('pauses when playing', async () => { const { deps, calls } = makeDeps(); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(0); await flushPromises(); @@ -663,7 +689,7 @@ describe('togglePlayPause', () => { it('resumes from paused', async () => { const { deps, calls } = makeDeps(); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(0); await flushPromises(); @@ -677,7 +703,7 @@ describe('togglePlayPause', () => { it('plays from idle when paused', () => { const { deps, calls } = makeDeps(); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); calls.paused = true; core.togglePlayPause(); @@ -688,7 +714,7 @@ describe('togglePlayPause', () => { describe('resume failures', () => { it('resumeRadio keeps paused when playerPlay rejects', async () => { const { deps, calls } = makeDeps(); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); let rejectResume!: (reason?: unknown) => void; core.playRadio(0); @@ -710,7 +736,7 @@ describe('resume failures', () => { it('togglePlayPause keeps paused when resume rejects', async () => { const { deps, calls } = makeDeps(); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); let rejectResume!: (reason?: unknown) => void; core.playRadio(0); @@ -732,7 +758,7 @@ describe('resume failures', () => { it('onPlayButtonClick keeps paused when resume rejects', async () => { const { deps, calls } = makeDeps(); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); let rejectResume!: (reason?: unknown) => void; core.playRadio(0); @@ -753,7 +779,7 @@ describe('resume failures', () => { it('resumeRadio handles playerPlay without a promise', async () => { const { deps } = makeDeps(); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(0); await flushPromises(); @@ -780,7 +806,7 @@ describe('resume failures', () => { }, }); callsRef = calls; - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(0); await flushPromises(); @@ -803,7 +829,7 @@ describe('resume failures', () => { describe('restart after long pause', () => { it('restarts radio if paused > 2 seconds', async () => { const { deps, calls } = makeDeps(); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(0); await flushPromises(); @@ -824,7 +850,7 @@ describe('restart after long pause', () => { it('resumes normally if paused < 2 seconds', async () => { const { deps, calls } = makeDeps(); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(0); await flushPromises(); @@ -847,12 +873,12 @@ describe('recovery backoff', () => { it('keeps scheduling recovery forever, with exponential backoff capped at RECOVERY_DELAY_MAX_MS', async () => { const { deps } = makeDeps(); deps._setPlayerPlayResult(Promise.reject(new Error('fail'))); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); // Get to error state core.playRadio(0); await flushPromises(); - fireTimer(deps, 3000); // retry + clock.increment(3000); // retry await flushPromises(); // → error expect(core.getState()).toBe('error'); @@ -865,27 +891,27 @@ describe('recovery backoff', () => { RECOVERY_DELAY_MAX_MS, // stays capped ]; for (const delay of expectedDelays) { - expect([...deps._pendingTimers.values()].some(t => t.ms === delay)).toBe(true); - fireTimer(deps, delay); // retryFromError → recovering + expect(clock.hasScheduled(delay)).toBe(true); + clock.increment(delay); // retryFromError → recovering await flushPromises(); // fails → error + reschedule expect(core.getState()).toBe('error'); } // Recovery never gives up — there is always a next attempt scheduled - expect([...deps._pendingTimers.values()].some(t => t.ms === RECOVERY_DELAY_MAX_MS)).toBe(true); + expect(clock.hasScheduled(RECOVERY_DELAY_MAX_MS)).toBe(true); }); it('resets recovery count on successful playRadio', async () => { const { deps } = makeDeps(); deps._setPlayerPlayResult(Promise.reject(new Error('fail'))); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); // Get to error with some recovery attempts core.playRadio(0); await flushPromises(); - fireTimer(deps, 3000); + clock.increment(3000); await flushPromises(); - fireTimer(deps, RECOVERY_DELAY_MS); + clock.increment(RECOVERY_DELAY_MS); await flushPromises(); expect(core._getRecoveryCount()).toBeGreaterThan(0); @@ -899,13 +925,13 @@ describe('recovery backoff', () => { it('resets recovery count on stopRadio', async () => { const { deps } = makeDeps(); deps._setPlayerPlayResult(Promise.reject(new Error('fail'))); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(0); await flushPromises(); - fireTimer(deps, 3000); + clock.increment(3000); await flushPromises(); - fireTimer(deps, RECOVERY_DELAY_MS); + clock.increment(RECOVERY_DELAY_MS); await flushPromises(); expect(core._getRecoveryCount()).toBeGreaterThan(0); @@ -916,26 +942,26 @@ describe('recovery backoff', () => { it('resets recovery count when silent recovery succeeds', async () => { const { deps } = makeDeps(); deps._setPlayerPlayResult(Promise.reject(new Error('fail'))); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); // Get to error state core.playRadio(0); await flushPromises(); - fireTimer(deps, 3000); + clock.increment(3000); await flushPromises(); expect(core.getState()).toBe('error'); // Do a few failed recoveries (backoff: 10s, then 20s) - fireTimer(deps, RECOVERY_DELAY_MS); + clock.increment(RECOVERY_DELAY_MS); await flushPromises(); - fireTimer(deps, RECOVERY_DELAY_MS * 2); + clock.increment(RECOVERY_DELAY_MS * 2); await flushPromises(); // count=3: initial scheduleRecovery(1) + two failed retryFromError re-schedules(2,3) expect(core._getRecoveryCount()).toBe(3); // Now make recovery succeed deps._setPlayerPlayResult(Promise.resolve()); - fireTimer(deps, RECOVERY_DELAY_MS * 4); + clock.increment(RECOVERY_DELAY_MS * 4); await flushPromises(); expect(core.getState()).toBe('playing'); @@ -945,36 +971,36 @@ describe('recovery backoff', () => { it('returns to error when silent recovery times out', async () => { const { deps, calls } = makeDeps(); deps._setPlayerPlayResult(Promise.reject(new Error('fail'))); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(0); await flushPromises(); - fireTimer(deps, 3000); + clock.increment(3000); await flushPromises(); expect(core.getState()).toBe('error'); deps._setPlayerPlayResult(new Promise(() => {})); - fireTimer(deps, RECOVERY_DELAY_MS); + clock.increment(RECOVERY_DELAY_MS); expect(core.getState()).toBe('recovering'); - fireTimer(deps, 6000); + clock.increment(6000); expect(core.getState()).toBe('error'); expect(calls.playerSetSrc.at(-1)).toBe(''); // Second failed attempt → backoff doubles to 20s - expect([...deps._pendingTimers.values()].some(t => t.ms === RECOVERY_DELAY_MS * 2)).toBe(true); + expect(clock.hasScheduled(RECOVERY_DELAY_MS * 2)).toBe(true); }); it('offline recovery reschedules without attempting stream', async () => { let online = true; const { deps, calls } = makeDeps({ isOnline: () => online }); deps._setPlayerPlayResult(Promise.reject(new Error('fail'))); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); // Get to error state core.playRadio(0); await flushPromises(); - fireTimer(deps, 3000); + clock.increment(3000); await flushPromises(); expect(core.getState()).toBe('error'); @@ -983,30 +1009,30 @@ describe('recovery backoff', () => { const playsBefore = calls.playerPlay.length; // Fire recovery while offline — should reschedule, not attempt stream - fireTimer(deps, RECOVERY_DELAY_MS); + clock.increment(RECOVERY_DELAY_MS); await flushPromises(); expect(core.getState()).toBe('error'); // No playerPlay attempted while offline expect(calls.playerPlay.length).toBe(playsBefore); // A new recovery timer was scheduled - const hasRecoveryTimer = [...deps._pendingTimers.values()].some(t => t.ms === RECOVERY_DELAY_MS); + const hasRecoveryTimer = clock.hasScheduled(RECOVERY_DELAY_MS); expect(hasRecoveryTimer).toBe(true); }); it('onPlayButtonClick from recovering resets recovery count', async () => { const { deps } = makeDeps(); deps._setPlayerPlayResult(Promise.reject(new Error('fail'))); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); // Get to error → recovering core.playRadio(0); await flushPromises(); - fireTimer(deps, 3000); + clock.increment(3000); await flushPromises(); expect(core.getState()).toBe('error'); - fireTimer(deps, RECOVERY_DELAY_MS); + clock.increment(RECOVERY_DELAY_MS); await flushPromises(); expect(core._getRecoveryCount()).toBeGreaterThan(0); @@ -1023,12 +1049,12 @@ describe('recovery backoff', () => { let online = true; const { deps, calls } = makeDeps({ isOnline: () => online }); deps._setPlayerPlayResult(Promise.reject(new Error('fail'))); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); // Get to error state (online) core.playRadio(0); await flushPromises(); - fireTimer(deps, 3000); + clock.increment(3000); await flushPromises(); expect(core.getState()).toBe('error'); @@ -1036,19 +1062,19 @@ describe('recovery backoff', () => { online = false; const playsBefore = calls.playerPlay.length; for (let i = 0; i < 100; i++) { - fireTimer(deps, RECOVERY_DELAY_MS); + clock.increment(RECOVERY_DELAY_MS); await flushPromises(); } // Still waiting patiently: no stream attempts, but always a next check expect(core.getState()).toBe('error'); expect(calls.playerPlay.length).toBe(playsBefore); - expect([...deps._pendingTimers.values()].some(t => t.ms === RECOVERY_DELAY_MS)).toBe(true); + expect(clock.hasScheduled(RECOVERY_DELAY_MS)).toBe(true); // Net comes back — the very next check recovers playback on its own online = true; deps._setPlayerPlayResult(Promise.resolve()); - fireTimer(deps, RECOVERY_DELAY_MS); + clock.increment(RECOVERY_DELAY_MS); await flushPromises(); expect(core.getState()).toBe('playing'); }); @@ -1061,7 +1087,7 @@ describe('recovery backoff', () => { describe('playback watchdog', () => { it('does nothing while playback progresses', async () => { const { deps, calls } = makeDeps(); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(0); await flushPromises(); @@ -1076,7 +1102,7 @@ describe('playback watchdog', () => { it('restarts the stream when playback time freezes', async () => { const { deps, calls } = makeDeps(); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(0); await flushPromises(); @@ -1091,14 +1117,14 @@ describe('playback watchdog', () => { expect(core.getState()).toBe('retrying'); // The normal retry cycle then recovers playback - fireTimer(deps, 3000); + clock.increment(3000); await flushPromises(); expect(core.getState()).toBe('playing'); }); it('a moment of progress resets the stall countdown', async () => { const { deps, calls } = makeDeps(); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(0); await flushPromises(); @@ -1117,7 +1143,7 @@ describe('playback watchdog', () => { it('stops watching when paused or stopped', async () => { const { deps } = makeDeps(); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(0); await flushPromises(); @@ -1152,7 +1178,7 @@ describe('system pause vs user pause', () => { it('a pause the user asked for stays paused, even offline', async () => { let online = true; const { deps, calls } = makeDeps({ isOnline: () => online }); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(0); await flushPromises(); @@ -1168,7 +1194,7 @@ describe('system pause vs user pause', () => { it('an unexpected native pause while offline goes to retrying with the loading sound', async () => { let online = true; const { deps, calls } = makeDeps({ isOnline: () => online }); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(0); await flushPromises(); @@ -1183,7 +1209,7 @@ describe('system pause vs user pause', () => { it('the offline retry lands in error with the error sound and keeps recovering', async () => { let online = true; const { deps, calls } = makeDeps({ isOnline: () => online }); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(0); await flushPromises(); @@ -1192,20 +1218,20 @@ describe('system pause vs user pause', () => { core.onPlayerPause(); expect(core.getState()).toBe('retrying'); - fireTimer(deps, 3000); // the scheduled retry runs while still offline + clock.increment(3000); // the scheduled retry runs while still offline expect(core.getState()).toBe('error'); expect(calls.errorSound.at(-1)).toBe('play'); expect(calls.errorMsg.at(-1)).toBe(true); // Silent recovery is scheduled — the radio never gives up - fireTimer(deps, RECOVERY_DELAY_MS); + clock.increment(RECOVERY_DELAY_MS); expect(core.getState()).toBe('error'); // still offline: fixed-cadence recheck - expect([...deps._pendingTimers.values()].some(t => t.ms === RECOVERY_DELAY_MS)).toBe(true); + expect(clock.hasScheduled(RECOVERY_DELAY_MS)).toBe(true); }); it('an unexpected native pause while online still pauses (interruption, unplugged headphones)', async () => { const { deps } = makeDeps(); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(0); await flushPromises(); @@ -1218,7 +1244,7 @@ describe('system pause vs user pause', () => { it('user pause intent expires after USER_PAUSE_INTENT_MS', async () => { let online = true; const { deps, calls } = makeDeps({ isOnline: () => online }); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(0); await flushPromises(); @@ -1240,7 +1266,7 @@ describe('sound supervisor', () => { it('re-asserts the loading sound while loading', async () => { const { deps, calls } = makeDeps(); deps._setPlayerPlayResult(new Promise(() => {})); // stream never connects - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(0); expect(core.getState()).toBe('loading'); @@ -1251,7 +1277,7 @@ describe('sound supervisor', () => { it('re-asserts the error sound while in error', async () => { const { deps, calls } = makeDeps({ isOnline: () => false }); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(0); // offline → straight to error expect(core.getState()).toBe('error'); @@ -1262,7 +1288,7 @@ describe('sound supervisor', () => { it('the error sound stays audible indefinitely — never muted, never stopped', async () => { const { deps, calls } = makeDeps({ isOnline: () => false }); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(0); expect(core.getState()).toBe('error'); @@ -1274,7 +1300,7 @@ describe('sound supervisor', () => { tickSupervisor(deps, 5); expect(calls.errorSound.at(-1)).toBe('ensure'); expect(calls.errorSound).not.toContain('mute'); - expect([...deps._pendingTimers.values()].some(t => t.ms === RECOVERY_DELAY_MS)).toBe(true); + expect(clock.hasScheduled(RECOVERY_DELAY_MS)).toBe(true); }); it('starts the error sound BEFORE stopping the loading sound (no audio-session gap)', async () => { @@ -1288,11 +1314,11 @@ describe('sound supervisor', () => { }); const { deps } = makeDeps({ loadingSound: sound('loading'), errorSound: sound('error') }); deps._setPlayerPlayResult(Promise.reject(new Error('fail'))); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(0); // loading (loading:play) await flushPromises(); // retrying - fireTimer(deps, 3000); // retry fails → error + clock.increment(3000); // retry fails → error await flushPromises(); expect(core.getState()).toBe('error'); @@ -1304,7 +1330,7 @@ describe('sound supervisor', () => { it('the supervisor stops outside sound states', async () => { const { deps } = makeDeps({ isOnline: () => false }); - const core = createRadioCore(deps); + const { core, clock } = createCore(deps); core.playRadio(0); expect(core.getState()).toBe('error'); diff --git a/src/js/radioCore.ts b/src/js/radioCore.ts index 6fe946b..15914f3 100644 --- a/src/js/radioCore.ts +++ b/src/js/radioCore.ts @@ -1,272 +1,103 @@ /** - * Radio Player — pure logic core. + * Radio Player — thin adapter over the XState machine (radioMachine.ts). * - * All DOM / browser interaction comes in through the `deps` object - * so this module can be tested without a browser. + * 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. */ -import { createStateMachine } from './stateMachine'; +import { createActor } from 'xstate'; +import { createRadioMachine } from './radioMachine'; +import type { RadioDeps, RadioState } from './radioMachine'; -export type RadioState = - | 'idle' - | 'loading' - | 'playing' - | 'paused' - | 'retrying' - | 'error' - | 'recovering'; - -type PlaybackButton = 'play' | 'pause' | 'stop'; -type SoundFx = 'play' | 'stop' | 'keep'; - -interface StateFx { - button: PlaybackButton; - loading: SoundFx; - error: SoundFx; - loadingMsg: boolean; - errorMsg: boolean; +/** The clock shape xstate actors accept (not exported by the library). + * Tests inject a SimulatedClock here to control `after` delays. */ +interface ActorClock { + setTimeout(fn: (...args: unknown[]) => void, timeout: number): unknown; + clearTimeout(id: unknown): void; } -/** A feedback sound (loading/error noise) the core can drive. */ -export interface FeedbackSound { - play(): void; - stop(): void; - /** Re-assert playback: restart if a play() was rejected or the OS paused it. */ - ensure(): void; -} - -type TimerId = number; - -/** - * Everything the core needs from the outside world. The DOM glue layer - * implements this against real elements; tests implement it with mocks. - */ -export interface RadioDeps { - getStationUrl(index: number): string; - getStationCount(): number; - getSelectedIndex(): number; - setSelectedIndex(index: number): void; - playerPlay(): Promise; - playerPause(): void; - playerSetSrc(url: string): void; - playerLoad(): void; - playerIsPaused(): boolean; - playerCurrentTime(): number; - loadingSound: FeedbackSound; - errorSound: FeedbackSound; - showButton(which: PlaybackButton): void; - setLoadingMsg(visible: boolean): void; - setErrorMsg(visible: boolean): void; - updateMediaSession(state: RadioState): void; - saveLastIndex(index: number): void; - setTimeout(fn: () => void, ms: number): TimerId; - clearTimeout(id: TimerId | null): void; - setInterval(fn: () => void, ms: number): TimerId; - clearInterval(id: TimerId | null): void; - performanceNow(): number; - isOnline(): boolean; -} +type ActorOptions = NonNullable[1]>; + +// Shared domain types & timing constants live with the machine; re-exported +// here so the rest of the app keeps a single import surface. +export type { RadioState, FeedbackSound, RadioDeps } from './radioMachine'; +export { + MAX_RETRIES, + LOADING_TIMEOUT_MS, + RETRY_DELAY_MS, + RECOVERY_DELAY_MS, + RECOVERY_DELAY_MAX_MS, + WATCHDOG_INTERVAL_MS, + WATCHDOG_STALL_TICKS, + SOUND_SUPERVISOR_INTERVAL_MS, + USER_PAUSE_INTENT_MS, + LONG_PAUSE_RESTART_MS, +} from './radioMachine'; export type RadioCore = ReturnType; -export const MAX_RETRIES = 1; -export const LOADING_TIMEOUT_MS = 6000; -export const RECOVERY_DELAY_MS = 10000; -export const RECOVERY_DELAY_MAX_MS = 60000; -export const WATCHDOG_INTERVAL_MS = 2000; -export const WATCHDOG_STALL_TICKS = 3; // ≈6s of frozen playback ⇒ stream is dead -export const SOUND_SUPERVISOR_INTERVAL_MS = 2500; -export const USER_PAUSE_INTENT_MS = 2000; // how long a pauseRadio() call explains a native 'pause' - -const STATE_FX: Record = { - idle: { button: 'play', loading: 'stop', error: 'stop', loadingMsg: false, errorMsg: false }, - loading: { button: 'stop', loading: 'play', error: 'stop', loadingMsg: true, errorMsg: false }, - playing: { button: 'pause', loading: 'stop', error: 'stop', loadingMsg: false, errorMsg: false }, - paused: { button: 'play', loading: 'stop', error: 'stop', loadingMsg: false, errorMsg: false }, - // 'play' (not 'keep'): a retry can also start from a watchdog stall while - // playing, where no sound is active — the user must never sit in silence. - retrying: { button: 'stop', loading: 'play', error: 'stop', loadingMsg: false, errorMsg: false }, - error: { button: 'stop', loading: 'stop', error: 'play', loadingMsg: false, errorMsg: true }, - recovering: { button: 'stop', loading: 'stop', error: 'keep', loadingMsg: false, errorMsg: true }, -}; - -export function createRadioCore(deps: RadioDeps) { - const { - getStationUrl, - getStationCount, - getSelectedIndex, - setSelectedIndex, - playerPlay, - playerPause, - playerSetSrc, - playerLoad, - playerIsPaused, - playerCurrentTime, - loadingSound, - errorSound, - showButton, - setLoadingMsg, - setErrorMsg, - updateMediaSession, - saveLastIndex, - setTimeout: _setTimeout, - clearTimeout: _clearTimeout, - setInterval: _setInterval, - clearInterval: _clearInterval, - performanceNow, - isOnline, - } = deps; - - const timers: Record<'retry' | 'loading' | 'recovery' | 'watchdog' | 'soundSupervisor', TimerId | null> = - { retry: null, loading: null, recovery: null, watchdog: null, soundSupervisor: null }; - let retryCount = 0; - let recoveryCount = 0; - let currentPlayId = 0; - let lastPauseTime: number | null = null; - let userPauseIntentAt: number | null = null; // performanceNow() of the last pauseRadio() call - - // --- State machine (no radio knowledge) --- - - const { getState, setState } = createStateMachine(STATE_FX, (fx, newState) => { - showButton(fx.button); - // Start the new sound BEFORE stopping the old one: the brief overlap keeps - // the audio session continuously active, so iOS is far likelier to allow - // the new sound to start when the app is backgrounded/locked. A gap of - // silence between stop and play is exactly where play() gets denied. - if (fx.loading === 'play') loadingSound.play(); - if (fx.error === 'play') errorSound.play(); - if (fx.loading === 'stop') loadingSound.stop(); - if (fx.error === 'stop') errorSound.stop(); - setLoadingMsg(fx.loadingMsg); - setErrorMsg(fx.errorMsg); - updateMediaSession(newState); - // The watchdog only makes sense while we're supposed to be playing - if (newState === 'playing') startWatchdog(); - else stopWatchdog(); - // While a feedback sound is supposed to be audible, supervise it: - // background restrictions can reject or pause