diff --git a/e2e/radio.spec.js b/e2e/radio.spec.js index bc2493e..7b8613f 100644 --- a/e2e/radio.spec.js +++ b/e2e/radio.spec.js @@ -680,8 +680,10 @@ test.describe('Offline — cached resources', () => { await expect(c.errorMsg).toBeVisible({ timeout: 3000 }); await expectSoundPlaying(page, 'errorNoise'); - const loadingPaused = await page.evaluate(() => document.getElementById('loadingNoise').paused); - expect(loadingPaused).toBe(true); + // The loading sound hands off gracefully: it may keep playing for the few + // ms until the error sound actually produces audio (iOS session handoff), + // but once the error sound is on, the loading one must fall silent. + await page.waitForFunction(() => document.getElementById('loadingNoise').paused, { timeout: 3000 }); }); test('error sound plays while offline, with no sound network request', async ({ page }) => { @@ -854,10 +856,15 @@ test.describe('Offline mid-playback — always audible', () => { await page.locator('#player').evaluate((el) => el.pause()); // NOT the paused UI: the app announces the problem (audible retry runs - // first, then the offline retry lands in error) instead of going mute + // first, then the offline retry lands in error) instead of going mute. + // Tone-swap design (R4b): the error tone sounds through whichever + // feedback element is live — mid-playback that is the loading element + // carrying the error tone, so assert "a feedback sound is on", not an id. await expect(c.errorMsg).toBeVisible({ timeout: 10000 }); await expect(c.playButton).toBeHidden(); - await expectSoundPlaying(page, 'errorNoise'); + await page.waitForFunction(() => + ['loadingNoise', 'errorNoise'].some((id) => !document.getElementById(id).paused), + { timeout: 3000 }); // The network comes back — the radio recovers with no click connectionDown = false; diff --git a/plan.md b/plan.md index 05523d1..601afd2 100644 --- a/plan.md +++ b/plan.md @@ -357,6 +357,25 @@ Criterii de acceptare R4b (pe iPhone, toate cu wifi off la momentul potrivit): 3. stop + play continua sa mearga ca azi. 4. Fluxul normal (eroare auzita cu app deschisa, apoi lock) ramane intact. +DECIZIA FINALA (Adrian, 2026-07-04): "folosim doar 2" — tone-swap e UNICUL +mecanism, nu fallback. Un singur element de feedback viu la un moment dat; +schimbarea de ton (loading <-> eroare, dus si intors) = swap de src pe +elementul care deja canta. Pornire proaspata DOAR din liniste (foreground/ +gest). Deferred stop, settlers, carry-once — sterse; raman: revert la ton +propriu daca swap-ul e refuzat (never trade audible for silent) si +reconcile-on-gesture. Unit testele reduse la 7 scenarii-esenta; e2e-urile +alb-box scoase — instrumentul de acceptare pentru zona iOS e checklist-ul +de device de mai sus (de re-rulat dupa simplificare!). + +Rezultat device-test (Adrian, 2026-07-04, PR #49, varianta pre-simplificare): +sunetul de eroare pe lock screen MERGE. Observatie noua: IMAGINEA de eroare nu apare pe lock +screen offline — sistemul isi descarca singur artwork-ul (fetch in afara +paginii, ocoleste SW-ul si cache-ul), deci offline ramane fara imagine. +Aceeasi radacina ca widget-ul macOS gol (limitare documentata, 3 fix-uri +revertate in PR #41). Acceptat ca OK deocamdata. Idee neincercata, separat: +cand navigator.onLine e false, artwork ca data: URI (imagine embedata, +zero retea) — de verificat daca iOS o accepta in MediaMetadata. + ### Planul initial (referinta) - Instalam `xstate` (fara `@xstate/react`). diff --git a/src/js/main.ts b/src/js/main.ts index 3dd8436..83d59a4 100644 --- a/src/js/main.ts +++ b/src/js/main.ts @@ -46,6 +46,11 @@ initMediaSession({ hasRestoredStation }); const loadingNoiseInstance = audioInstance(loadingNoise); const errorNoiseInstance = audioInstance(errorNoise); +// Each sound hands off gracefully to the other: deferred stop until the +// replacement is audible, carry when iOS denies the replacement's start +// (see the protocol comment in soundEffects.ts). +loadingNoiseInstance.setPartner(errorNoiseInstance); +errorNoiseInstance.setPartner(loadingNoiseInstance); // Preload audio blobs once per page. Re-called from user interactions as a // retry if the eager page-load preload failed — fetch() doesn't need a user diff --git a/src/js/soundEffects.test.ts b/src/js/soundEffects.test.ts index 58ddb98..f6bb900 100644 --- a/src/js/soundEffects.test.ts +++ b/src/js/soundEffects.test.ts @@ -2,29 +2,42 @@ 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. + * A fake HTMLAudioElement, just enough surface for audioInstance. Mirrors + * the real element where it matters: the src property reflects into the + * attribute, and a DENIED play() (iOS autoplay policy) leaves it paused. */ -function fakeAudioElement() { +function fakeAudioElement(tone: string) { const el = { volume: 1, currentTime: 0, paused: true, playCalls: 0, + denied: false, playResult: Promise.resolve() as Promise, dataset: {} as Record, _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; }, + addEventListener() {}, play() { this.playCalls++; - this.paused = false; + if (!this.denied) this.paused = false; return this.playResult; }, pause() { this.paused = true; }, - querySelector: () => ({ src: 'http://sounds.test/tone.mp3' }), + /** Backgrounded-iOS mode: every play() is denied, element stays paused. */ + deny() { + this.denied = true; + const rejection = Promise.reject(Object.assign(new Error('denied'), { name: 'NotAllowedError' })); + rejection.catch(() => {}); // pre-handled — audioInstance attaches its own catch later + this.playResult = rejection; + }, + allow() { + this.denied = false; + this.playResult = Promise.resolve(); + }, + querySelector: () => ({ src: `http://sounds.test/${tone}.mp3` }), }; return el; } @@ -33,20 +46,28 @@ function flushPromises() { return new Promise(resolve => setTimeout(resolve, 0)); } -describe('audioInstance ensure()', () => { - let el: ReturnType; - let resolveFetch: (r: Response) => void; +// The pair, as main.ts wires it: loading + error, partners of each other. +// Blob preloads resolve immediately so tones are distinct blob: URLs. +async function makePair() { + const loadEl = fakeAudioElement('loading'); + const errEl = fakeAudioElement('error'); + const loading = audioInstance(loadEl as unknown as HTMLAudioElement); + const error = audioInstance(errEl as unknown as HTMLAudioElement); + loading.setPartner(error); + error.setPartner(loading); + await loading.preloadBlob(); + await error.preloadBlob(); + return { loadEl, errEl, loading, error }; +} +describe('feedback sounds — the tone-swap rule', () => { beforeEach(() => { - el = fakeAudioElement(); - // No Cache API, and a fetch we control — the blob preload stays pending - // until the test resolves it. + let counter = 0; vi.stubGlobal('window', {}); - vi.stubGlobal('fetch', vi.fn(() => new Promise((resolve) => { - resolveFetch = resolve; - }))); + // Blob preloads succeed instantly; each instance gets a distinct URL. + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(new Response(new Blob(['x']))))); vi.stubGlobal('URL', { - createObjectURL: vi.fn(() => 'blob:fake'), + createObjectURL: vi.fn(() => `blob:tone-${++counter}`), revokeObjectURL: vi.fn(), }); }); @@ -55,69 +76,105 @@ describe('audioInstance ensure()', () => { 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); + it('from silence, a tone starts on its own element', async () => { + const { loadEl, loading } = await makePair(); - sound.play(); // blob preload pending — nothing started yet - await flushPromises(); // let the preload reach the (unresolved) fetch - expect(el.playCalls).toBe(0); + loading.play(); + expect(loadEl.playCalls).toBe(1); + expect(loadEl.getAttribute('src')).toBe('blob:tone-1'); + expect(loadEl.paused).toBe(false); + }); - // 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); + it('changing tones swaps the src of the playing element — the other element never starts', async () => { + const { loadEl, errEl, loading, error } = await makePair(); - // 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'); + // loading → error (applyFx order: the new tone plays, then the old stops) + loading.play(); + error.play(); + loading.stop(); + + expect(loadEl.paused).toBe(false); // still the live element + expect(loadEl.getAttribute('src')).toBe('blob:tone-2'); // …now sounding the error tone + expect(errEl.playCalls).toBe(0); // error element untouched }); - it('still restarts a started element the OS paused', async () => { - const sound = audioInstance(el as unknown as HTMLAudioElement); + it('switching back reclaims the element for its own tone — still gapless', async () => { + const { loadEl, errEl, loading, error } = await makePair(); - sound.play(); - await flushPromises(); - resolveFetch(new Response(new Blob(['x']))); - await flushPromises(); - expect(el.playCalls).toBe(1); + loading.play(); + error.play(); + loading.stop(); // loadEl carries the error tone + + // error → loading (user retries a station) + loading.play(); + error.stop(); - el.paused = true; // backgrounded: the OS paused it - sound.ensure(); - expect(el.playCalls).toBe(2); + expect(loadEl.paused).toBe(false); + expect(loadEl.getAttribute('src')).toBe('blob:tone-1'); // own tone again + expect(errEl.playCalls).toBe(0); }); - it('restarts from scratch when a play() was rejected outright', async () => { - const sound = audioInstance(el as unknown as HTMLAudioElement); + it('a denied swap keeps the current tone audible — never trade audible for silent', async () => { + const { loadEl, errEl, loading, error } = await makePair(); - sound.play(); + loading.play(); + loadEl.deny(); // locked iPhone: even the continuation is refused + error.play(); + loading.stop(); 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); + expect(loadEl.getAttribute('src')).toBe('blob:tone-1'); // reverted to its own tone + expect(errEl.playCalls).toBe(0); + }); + + it('a user stop silences everything, including a carrying element', async () => { + const { loadEl, errEl, loading, error } = await makePair(); + + loading.play(); + error.play(); + loading.stop(); // loadEl carries the error tone + + // applyFx for idle/paused: both tones stop. + loading.stop(); + error.stop(); + + expect(loadEl.paused).toBe(true); + expect(loadEl.getAttribute('src')).toBe(''); + expect(errEl.paused).toBe(true); }); - it('stop() detaches the element so ensure() has nothing to resurrect', async () => { - const sound = audioInstance(el as unknown as HTMLAudioElement); + it('a user gesture revives a desired-but-silent sound (the tap is never squandered)', async () => { + const { errEl, error } = await makePair(); - sound.play(); + errEl.deny(); // dead session: the fresh start was denied + error.play(); await flushPromises(); - resolveFetch(new Response(new Blob(['x']))); + expect(errEl.paused).toBe(true); + + errEl.allow(); // unlock + tap: play works inside a gesture + error.warmUp(); + expect(errEl.paused).toBe(false); + expect(errEl.getAttribute('src')).toBe('blob:tone-2'); + }); + + it('the supervisor does not disturb a play() still waiting for its blob', async () => { + // Regression guard for a real silence bug: an ensure() tick landing + // mid-preload used to reject on the empty src and cancel the pending + // start, adding a tick of silence right when the sound mattered. + const loadEl = fakeAudioElement('loading'); + const loading = audioInstance(loadEl as unknown as HTMLAudioElement); + let resolveFetch!: (r: Response) => void; + (fetch as ReturnType).mockImplementation( + () => new Promise((resolve) => { resolveFetch = resolve; }), + ); + + loading.play(); // blob preload pending — nothing started yet await flushPromises(); - expect(el.playCalls).toBe(1); + loading.ensure(); + expect(loadEl.playCalls).toBe(0); - sound.stop(); - expect(el.paused).toBe(true); - expect(el.getAttribute('src')).toBe(''); + resolveFetch(new Response(new Blob(['x']))); + await flushPromises(); + expect(loadEl.playCalls).toBe(1); // the original start still fired }); }); diff --git a/src/js/soundEffects.ts b/src/js/soundEffects.ts index 718b615..94c4f9a 100644 --- a/src/js/soundEffects.ts +++ b/src/js/soundEffects.ts @@ -5,6 +5,30 @@ * because iOS drops the media session without an active