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 `