From bee974aeff89f123644ea2aa15f243dd4500043c Mon Sep 17 00:00:00 2001 From: Adica Date: Fri, 3 Jul 2026 18:35:02 +0300 Subject: [PATCH] Phase 5: cleanup and documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README brought up to date with the final architecture: module tree, XState v5 machine (after-delays, invoked actors, applyFx), Stately Inspector/editor pointers, the user-pause-vs-system-pause and sound-supervisor pieces of the offline story, test counts and the updated unit/e2e test tables (63 unit + SimulatedClock, 37 e2e). - Delete _unused_svgs/ and tailwind.config.js (v4 + the Vite plugin use CSS-based config; verified utilities still emitted without it). - Clean .gitignore: the root `public` pattern (build output moved to dist/ long ago) was silently ignoring NEW files added under src/public/ — a real trap; also drop the stale src/css/output.css. - plan.md: final status — phases 1/2/3/4a/5 complete; 4b (audioInstance redesign + feedback tone channel) remains, gated on device validation. Typecheck clean, 63/63 unit, build OK (CSS verified), 37/37 e2e, smoke. Co-Authored-By: Claude Fable 5 --- .gitignore | 2 -- README.md | 69 ++++++++++++++++++++++++++++-------------- _unused_svgs/logo.svg | 31 ------------------- _unused_svgs/next.svg | 4 --- _unused_svgs/pause.svg | 5 --- _unused_svgs/play.svg | 4 --- _unused_svgs/prev.svg | 4 --- plan.md | 7 ++++- tailwind.config.js | 4 --- 9 files changed, 52 insertions(+), 78 deletions(-) delete mode 100644 _unused_svgs/logo.svg delete mode 100644 _unused_svgs/next.svg delete mode 100644 _unused_svgs/pause.svg delete mode 100644 _unused_svgs/play.svg delete mode 100644 _unused_svgs/prev.svg delete mode 100644 tailwind.config.js diff --git a/.gitignore b/.gitignore index 0e6e93f..6ffd287 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,5 @@ node_modules -public .env -src/css/output.css test-results/ coverage/ reports/ diff --git a/README.md b/README.md index 83014c9..d80a2bc 100644 --- a/README.md +++ b/README.md @@ -8,20 +8,33 @@ Deployed la [coji.ro](https://coji.ro). ## Arhitectură +TypeScript strict + Vite + XState v5. Stratul DOM e spart în module cu responsabilitate unică; logica trăiește într-o mașină de stări declarativă. + ``` -index.html ← UI (butoane, poster, selector) - └─ script.js ← DOM glue: event listeners, MediaSession, Cloudinary images - └─ radioCore.js ← Logică pură (zero DOM), testabilă izolat - └─ stateMachine.js ← Mașină de stare generică +index.html ← UI (butoane, poster, selector) + └─ main.ts ← entry point: doar wiring (deps + event listeners) + ├─ dom.ts ← referințele DOM partajate (el aruncă la id lipsă) + ├─ labels.ts ← textele user-facing (sursă unică) + ├─ storage.ts ← persistența ultimului post + ├─ cloudinary.ts ← imagini status + pre-cache offline + ├─ theme.ts ← theme color pe prefers-color-scheme + ├─ soundEffects.ts ← audioInstance: blob preload, warmUp, ensure + ├─ mediaSession.ts ← Now Playing + re-înregistrare handlers (iOS) + ├─ serviceWorker.ts ← înregistrare + reload amânat la idle + ├─ stationSelector.ts ← listbox accesibil (focus, keyboard, ARIA) + └─ radioCore.ts ← adaptor subțire: API imperativ → events + └─ radioMachine.ts ← mașina XState v5 (logică pură, zero DOM) ``` -**Principiu:** `radioCore.js` nu importă nimic din browser. Toate interacțiunile (audio, DOM, timere) vin prin obiectul `deps` — dependency injection manual. Asta permite testarea unitară fără browser real. +**Principiu:** `radioMachine.ts` nu importă nimic din browser. Toate interacțiunile (audio, DOM, timere de interval) vin prin obiectul `deps` (interfața `RadioDeps`, verificată de compilator) — dependency injection. Asta permite testarea unitară fără browser real, cu un `SimulatedClock` injectat pentru delay-uri. --- ## Mașina de stare -7 stări, fiecare cu side-effects declarative într-un tabel (`STATE_FX`): +XState v5, 7 stări. Tot ce era orchestrare manuală e acum declarativ: timerii sunt `after`-delays (ieșirea din stare le anulează), `player.play()` e promise actor invocat (un rezultat întârziat după ieșirea din stare e aruncat prin construcție — fostul mecanism `playId`), iar watchdog-ul și supervizorul de sunete sunt callback actors care trăiesc exact cât stările lor. Side-effects-urile rămân un tabel declarativ (`STATE_FX`), aplicat ca entry action la fiecare intrare în stare. + +> 🔍 **Vizualizare:** `npm run dev` + `http://localhost:5173/?inspect` deschide Stately Inspector cu diagrama LIVE a mașinii (tranziții/evenimente în timp real). Pentru diagrama statică, lipește `src/js/radioMachine.ts` în [stately.ai/editor](https://stately.ai/editor). ``` ┌─────────────────────────────────────┐ @@ -40,7 +53,7 @@ index.html ← UI (butoane, poster, selector) │ ▼ │ │ ┌──────────┐ │ │ │ retrying │ (max 1)│ - │ │ 🔊 keeps │ │ + │ │ 🔊 loading│ │ │ └────┬─────┘ │ │ │ fail again │ ▼ ▼ │ @@ -70,21 +83,21 @@ index.html ← UI (butoane, poster, selector) | `loading` | ■ stop | **play** | stop | **show** | hide | | `playing` | ❚❚ pause | stop | stop | hide | hide | | `paused` | ▶ play | stop | stop | hide | hide | -| `retrying` | ■ stop | **keep** | stop | hide | hide | +| `retrying` | ■ stop | **play** | stop | hide | hide | | `error` | ■ stop | stop | **play** | hide | **show** | | `recovering` | ■ stop | stop | **keep** | hide | **show** | -`keep` = nu opri/porni sunetul, lasă-l cum e. Tranziția `error → recovering` nu re-pornește sunetul de eroare — continuă neschimbat. +`keep` = nu opri/porni sunetul, lasă-l cum e. Tranziția `error → recovering` nu re-pornește sunetul de eroare — continuă neschimbat. `retrying` pornește explicit sunetul de loading (`play`, nu `keep`): un retry poate veni și dintr-un stall de watchdog în timpul redării, unde niciun sunet nu era activ — iar regula produsului e că **odată ce userul a dat play, ceva trebuie să se audă mereu** (stream, loading sau eroare; liniște doar în idle/paused). --- ## Recovery și offline -Problema clasică pe net intermitent: stream-ul moare **fără niciun eveniment** (`error`/`stalled` nu se emit, mai ales pe HLS) și aplicația rămâne blocată — fie „cântă" liniște, fie stă în error pentru totdeauna. Soluția are trei piese, toate în `radioCore.js`: +Problema clasică pe net intermitent: stream-ul moare **fără niciun eveniment** (`error`/`stalled` nu se emit, mai ales pe HLS) și aplicația rămâne blocată — fie „cântă" liniște, fie stă în error pentru totdeauna. Soluția are patru piese, toate în `radioMachine.ts`: ### 1. Watchdog pe progresul redării -Singurul semnal de încredere e progresul efectiv: cât timp starea e `playing`, `player.currentTime` trebuie să avanseze. Un interval (`WATCHDOG_INTERVAL_MS` = 2s) compară valoarea; după `WATCHDOG_STALL_TICKS` (3) tick-uri înghețate consecutive (≈6s) → ciclul normal de retry/recovery. Zero falsuri pozitive pe HLS (currentTime avansează din buffer și între fetch-uri de segmente). +Singurul semnal de încredere e progresul efectiv: cât timp starea e `playing`, `player.currentTime` trebuie să avanseze. Un callback actor invocat doar în `playing` (`WATCHDOG_INTERVAL_MS` = 2s) compară valoarea; după `WATCHDOG_STALL_TICKS` (3) tick-uri înghețate consecutive (≈6s) → evenimentul `STALLED` → ciclul normal de retry/recovery. Zero falsuri pozitive pe HLS (currentTime avansează din buffer și între fetch-uri de segmente). > De ce nu evenimentul `stalled`? Browserele îl emit **și în timpul redării normale de HLS** (pauzele dintre segmente arată ca „stalled"), ceea ce producea flash-uri false de „Se încarcă..."; iar la înghețuri reale de multe ori nu se emite deloc. A fost eliminat complet. @@ -97,6 +110,12 @@ Backoff exponențial: 10s → 20s → 40s → plafonat la `RECOVERY_DELAY_MAX_MS - `onLine === false` e de încredere → re-verificare fixă la 10s, fără să atingă rețeaua și fără escaladare de backoff; evenimentul `online` declanșează retry instant. - `onLine === true` poate fi fals pozitiv (WiFi fără internet) → se încearcă mereu; încercarea stream-ului e cea mai onestă probă de conectivitate. +### 4. Pauza userului vs. pauza sistemului + supervizorul de sunete + +Când stream-ul moare, OS-ul (mai ales iOS) dă `pause` nativ pe element — indistinguibil de pauza userului fără context. `pauseRadio()` marchează intenția userului (`USER_PAUSE_INTENT_MS` = 2s); un pause **neașteptat + offline** intră pe pipeline-ul de retry/eroare (cu sunete), nu în `paused`. Un pause neașteptat online rămâne pauză (căști scoase, telefon, altă aplicație). + +Iar pentru că sunetele de feedback pot fi refuzate de browser (autoplay/background) sau oprite de OS, un **supervizor** (callback actor în loading/retrying/error/recovering, tick la 2.5s) re-asertează sunetul cerut de stare via `ensure()` — la nesfârșit. Sunetul de eroare nu se oprește niciodată singur: doar recuperarea sau stop/pauză de la user îl tac. + ### Known issue: playerul HLS nativ din Chromium spamează request-uri Măsurat (iulie 2026, Chromium 145/149): un `