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
86 changes: 73 additions & 13 deletions plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,10 @@ cu `tone: 'loading'|'error'|'none'` in STATE_FX — overlap imposibil prin
constructie, schimbarea de ton = swap de src pe elementul care deja canta
(exact continuarea permisa de iOS). De facut eventual in Faza 4 (XState),
cu re-validare completa pe device.
[DEPASIT 2026-07-04 — ideea "un singur element" a fost retrasa; vezi
CORECTIA DE DIRECTIE din sectiunea 4b: fara element partener nu exista
plasa "never trade audible for silent", iar golul de swap ramane fatal
pe iPhone blocat (a0aec46).]

## Faza 2: TypeScript [gata]

Expand Down Expand Up @@ -289,8 +293,15 @@ 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:
CERINTA EXPLICITA (Adrian, 2026-07-04): sunetul de eroare TREBUIE sa se auda
pe lock screen DIN PRIMA — nu doar dupa ce aplicatia a trecut o data prin
eroare cu ecranul deschis. Repro-ul de mai jos e deci un BUG de cerinta, nu
o limitare acceptata. PR #41 a demonstrat pe iPhone ca e realizabil (carry);
4b il reconstruieste curat si devine URMATOAREA FAZA dupa R4 (inaintea
R5/R6 — vezi ordinea actualizata in planul de refactor).

Repro observat pe iPhone (Adrian, 2026-07-04) — cazul exact pe care
mecanismul handoff/carry il rezolva:
- 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,
Expand All @@ -302,8 +313,49 @@ de ton il rezolva prin constructie:
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.

CORECTIE DE DIRECTIE (2026-07-04, dupa recitirea istoricului cu Adrian):
NU "un singur element de feedback". Istoria branch-ului PR #41 arata de ce:
- a0aec46: play() doar INITIAZA redarea (decode/buffer async) — orice gol
real de liniste pe iPhone blocat omoara sesiunea si play()-ul pendinte e
refuzat. De aceea sunetul vechi trebuie sa cante PANA CAND cel nou e
efectiv audibil ('playing') — deferred stop intre DOUA elemente.
- 31d684a: carry-ul (elementul care deja canta isi schimba src-ul pe tonul
partenerului refuzat) e LAST RESORT, cu regula "never trade audible for
silent": o singura tentativa, si daca si continuarea e refuzata, revine
la sunetul propriu. Un element unic face din swap singura cale, fara
plasa de siguranta — un swap esuat inseamna liniste totala.
- Handoff-ul feedback <-> player principal ramane oricum intre doua
elemente; "un singur element" nu-l elimina.
Directia 4b devine: re-implementarea semanticii VERIFICATE PE DEVICE din
PR #41 (deferred stop + carry last-resort + reclaim + never-trade-audible-
for-silent), dar condusa din masina/reconciler — precisa si testabila unit,
nu coordonare event-driven ad-hoc in stratul DOM (motivul revertului a fost
CUM era scrisa, nu CA nu mergea).

Repro suplimentar (Adrian, 2026-07-04) — gestul irosit, diagnoza defectului
"isPlaying = intentie, nu realitate":
- play → lock → wifi off → loading porneste, apoi liniste la eroare (stiut).
- Deblocat cu aplicatia in fata: ecranul de eroare se vede, NIMIC nu se
aude — deblocarea nu e gest in pagina, iar dupa moartea sesiunii iOS
refuza si in foreground play()-urile programatice (supervisor).
- next/prev: ecranul ramane, tot fara sunet. Gestul userului e IROSIT:
la click, isPlaying e adesea true (incercare programatica refuzata inca
in zbor), asa ca warmUp() si play() fac early-return pe intentie si
niciun element.play() nu ruleaza in call stack-ul gestului.
- stop + play: se aude — stop() reseteaza fortat intentia (gen++, src=''),
deci play-ul urmator chiar executa element.play() inauntrul gestului.
Regula noua pentru reconciler: ORICE gest de user reconciliaza realitatea —
daca elementul cerut de stare nu canta EFECTIV (element.paused), play() se
executa atunci, in stack-ul gestului, indiferent de flag-ul de intentie.
Ideal si reconciliere pe visibilitychange la revenirea in aplicatie.

Criterii de acceptare R4b (pe iPhone, toate cu wifi off la momentul potrivit):
1. play → lock IMEDIAT → wifi off → eroarea se aude DIN PRIMA (carry).
2. In starea muta istorica: deblocare + next/prev → sunetul revine din
gestul ala (reconcile-on-gesture).
3. stop + play continua sa mearga ca azi.
4. Fluxul normal (eroare auzita cu app deschisa, apoi lock) ramane intact.

### Planul initial (referinta)

Expand Down Expand Up @@ -424,15 +476,20 @@ Sursa: review multi-agent pe intervalul `3e36147..HEAD` (ultimele 2 zile) —

## Ce NU facem in acest plan

- Faza 4b (reconcile() in audioInstance + canal unic de feedback cu tone in
STATE_FX) ramane separata — cere re-validare completa pe device. Aici facem
doar fix-ul minim al race-ului ensure() (R4), compatibil cu redesignul viitor.
- (Actualizat 2026-07-04) Faza 4b NU mai e amanata: cerinta explicita a lui
Adrian — sunetul de eroare audibil pe lock screen DIN PRIMA — o face
obligatorie. Intra in ordine imediat dupa R4, ca faza R4b (handoff/carry
condus din masina, vezi CORECTIA DE DIRECTIE din sectiunea 4b). Cere
re-validare completa pe device inainte de merge.
- NU consolidam cele 3 hook-uri de re-asertare din mediaSession.ts
(play/playing, timeupdate, pause) — empirism iOS/macOS calit pe device
(d798cc9, 2933d78, 5106a92). Le atingem doar prin predicate partajate (R2),
fara sa schimbam timing-ul apelurilor.

## Faza R1: curatenie mecanica — zero schimbare de comportament
STATUS (2026-07-04): R1 (PR #45), R2 (PR #46), R3 (PR #47) — MERGED.
R3 verificat pe device de Adrian inainte de merge. Urmeaza R4.

## Faza R1: curatenie mecanica — zero schimbare de comportament [gata]

Numai stersaturi si extrageri; bundle-ul si comportamentul identice.

Expand All @@ -458,7 +515,7 @@ Numai stersaturi si extrageri; bundle-ul si comportamentul identice.

Verificare: typecheck, 63→~62 unit (unul sters), build, e2e integral neatins.

## Faza R2: sursa unica pentru clasificarea starilor
## Faza R2: sursa unica pentru clasificarea starilor [gata]

Clasificarea "ce e audibil / cum se raporteaza playbackState" exista azi in
4 liste de mana (main.ts:183, mediaSession.ts:44, :94, :105-106) care au
Expand All @@ -476,7 +533,7 @@ divergat deja o data (a667b7f).
Verificare: typecheck, unit, e2e neatins. Diff-ul de bundle trebuie sa fie
doar renamings.

## Faza R3: resume si intentiile userului intra in masina
## Faza R3: resume si intentiile userului intra in masina [gata]

Cea mai valoroasa faza — inchide 2 bug-uri confirmate si goleste adaptorul.

Expand Down Expand Up @@ -563,9 +620,12 @@ statiilor raman disponibile offline dupa prima redare).

## Ordine si estimare

R1 → R2 → R3 → R4 → R5 → R6. R1-R2 sunt mecanice (o sesiune). R3 e miezul
(masina + adaptor + mediaSession, cu device smoke). R4 marunt. R5 cere
atentie la empirismul iOS. R6 independent (poate fi facut oricand dupa R1).
R1 → R2 → R3 → R4 → R4b → R5 → R6. R1-R2 sunt mecanice (o sesiune). R3 e
miezul (masina + adaptor + mediaSession, cu device smoke). R4 marunt.
R4b (handoff/carry din masina) e cerinta lock-screen a lui Adrian — cea mai
empirica faza, cu re-validare completa pe iPhone (play→lock imediat→wifi
off→eroarea se aude DIN PRIMA). R5 cere atentie la empirismul iOS. R6
independent (poate fi facut oricand dupa R1).

## Definition of done

Expand Down
36 changes: 36 additions & 0 deletions src/js/radioCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1213,6 +1213,42 @@ describe('playback watchdog', () => {
expect(core.getState()).toBe('playing');
});

it('a stall silences the zombie stream before the loading tone starts', async () => {
const { deps, calls } = makeDeps();
const { core, clock } = createCore(deps);

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

calls.currentTime = 5;
tickWatchdog(deps);

const pausesBefore = calls.playerPause.length;
tickWatchdog(deps, WATCHDOG_STALL_TICKS);
expect(core.getState()).toBe('retrying');

// The stalled stream must be detached — a refilled buffer would
// otherwise resume audibly UNDER the loading tone during RETRY_DELAY.
expect(calls.playerPause.length).toBeGreaterThan(pausesBefore);
expect(calls.playerSetSrc.at(-1)).toBe('');
});

it('a native stream error silences the player before retrying', async () => {
const { deps, calls } = makeDeps();
const { core, clock } = createCore(deps);

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

const pausesBefore = calls.playerPause.length;
core.onPlayerError();
expect(core.getState()).toBe('retrying');
expect(calls.playerPause.length).toBeGreaterThan(pausesBefore);
expect(calls.playerSetSrc.at(-1)).toBe('');
});

it('a moment of progress resets the stall countdown', async () => {
const { deps, calls } = makeDeps();
const { core, clock } = createCore(deps);
Expand Down
7 changes: 5 additions & 2 deletions src/js/radioMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,10 @@ export function createRadioMachine(deps: RadioDeps) {
invoke: { src: 'watchdog' },
on: {
TOGGLE: { actions: ['markUserPauseIntent', 'pausePlayer'] },
STALLED: streamFailure('clearPauseTime'),
// stopPlayer: the stalled/errored stream stays attached otherwise,
// and a refilled buffer would resume audibly UNDER the loading tone
// during RETRY_DELAY — the machine never allows overlapping sounds.
STALLED: streamFailure('stopPlayer', 'clearPauseTime'),
PLAYER_PAUSE: [
{
guard: 'unexpectedOfflinePause',
Expand All @@ -425,7 +428,7 @@ export function createRadioMachine(deps: RadioDeps) {
// phone call, another app taking audio) — stay paused.
{ target: 'paused', actions: ['consumeUserPauseIntent', 'markPauseTime'] },
],
PLAYER_ERROR: streamFailure('clearPauseTime'),
PLAYER_ERROR: streamFailure('stopPlayer', 'clearPauseTime'),
},
},

Expand Down
123 changes: 123 additions & 0 deletions src/js/soundEffects.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { audioInstance } from './soundEffects';

/**
* A fake HTMLAudioElement, just enough surface for audioInstance.
* Mirrors the real element's src semantics: the property assignment is
* reflected into the attribute, which is what ensure() inspects.
*/
function fakeAudioElement() {
const el = {
volume: 1,
currentTime: 0,
paused: true,
playCalls: 0,
playResult: Promise.resolve() as Promise<void>,
dataset: {} as Record<string, string>,
_srcAttr: null as string | null,
set src(value: string) { this._srcAttr = value; },
get src(): string { return this._srcAttr ?? ''; },
getAttribute(name: string) { return name === 'src' ? this._srcAttr : null; },
play() {
this.playCalls++;
this.paused = false;
return this.playResult;
},
pause() { this.paused = true; },
querySelector: () => ({ src: 'http://sounds.test/tone.mp3' }),
};
return el;
}

function flushPromises() {
return new Promise(resolve => setTimeout(resolve, 0));
}

describe('audioInstance ensure()', () => {
let el: ReturnType<typeof fakeAudioElement>;
let resolveFetch: (r: Response) => void;

beforeEach(() => {
el = fakeAudioElement();
// No Cache API, and a fetch we control — the blob preload stays pending
// until the test resolves it.
vi.stubGlobal('window', {});
vi.stubGlobal('fetch', vi.fn(() => new Promise<Response>((resolve) => {
resolveFetch = resolve;
})));
vi.stubGlobal('URL', {
createObjectURL: vi.fn(() => 'blob:fake'),
revokeObjectURL: vi.fn(),
});
});

afterEach(() => {
vi.unstubAllGlobals();
});

it('does not poke the element while the initial play() still waits for the blob', async () => {
const sound = audioInstance(el as unknown as HTMLAudioElement);

sound.play(); // blob preload pending — nothing started yet
await flushPromises(); // let the preload reach the (unresolved) fetch
expect(el.playCalls).toBe(0);

// Supervisor tick lands mid-preload. The old bug: ensure() called
// play() on the empty src, the rejection flipped isPlaying to false,
// and the pending start bailed — one extra tick of silence.
sound.ensure();
expect(el.playCalls).toBe(0);

// When the blob finally lands, the original play() must still fire.
resolveFetch(new Response(new Blob(['x'])));
await flushPromises();
expect(el.playCalls).toBe(1);
expect(el.getAttribute('src')).toBe('blob:fake');
});

it('still restarts a started element the OS paused', async () => {
const sound = audioInstance(el as unknown as HTMLAudioElement);

sound.play();
await flushPromises();
resolveFetch(new Response(new Blob(['x'])));
await flushPromises();
expect(el.playCalls).toBe(1);

el.paused = true; // backgrounded: the OS paused it
sound.ensure();
expect(el.playCalls).toBe(2);
});

it('restarts from scratch when a play() was rejected outright', async () => {
const sound = audioInstance(el as unknown as HTMLAudioElement);

sound.play();
await flushPromises();
const denied = Promise.reject(Object.assign(new Error('denied'), { name: 'NotAllowedError' }));
denied.catch(() => {}); // pre-handle: audioInstance attaches its own catch later
el.playResult = denied;
resolveFetch(new Response(new Blob(['x'])));
await flushPromises(); // startPlayback ran, its play() was denied
expect(el.playCalls).toBe(1);

el.playResult = Promise.resolve();
sound.ensure(); // isPlaying flipped false → full play() again
await flushPromises();
expect(el.playCalls).toBe(2);
});

it('stop() detaches the element so ensure() has nothing to resurrect', async () => {
const sound = audioInstance(el as unknown as HTMLAudioElement);

sound.play();
await flushPromises();
resolveFetch(new Response(new Blob(['x'])));
await flushPromises();
expect(el.playCalls).toBe(1);

sound.stop();
expect(el.paused).toBe(true);
expect(el.getAttribute('src')).toBe('');
});
});
5 changes: 5 additions & 0 deletions src/js/soundEffects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,11 @@ export function audioInstance(htmlElement: HTMLAudioElement) {
return;
}
if (htmlElement.paused) {
// No src on the element means play() is still waiting for the blob
// preload (startPlayback always sets src before playing) — poking
// play() now would reject on the empty source and cancel that
// pending start, adding a tick of silence. Leave it alone.
if (!htmlElement.getAttribute('src')) return;
const gen = playGeneration;
htmlElement.play().catch((error) => {
if (gen !== playGeneration) return;
Expand Down