diff --git a/e2e/radio.spec.js b/e2e/radio.spec.js index bc2493e..c136368 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 }) => { @@ -885,3 +887,69 @@ test.describe('Offline mid-playback — always audible', () => { await expect(c.loadingMsg).toBeHidden(); }); }); + +// Deliberate white-box exception (same rationale as the cache-versioning +// describe): the iOS behavior this guards — backgrounded Safari denying any +// FRESH play() start while allowing an already-playing element to swap its +// source and continue — cannot be reproduced in headless Chromium. We +// simulate the denial by patching the error element's play() to reject the +// way iOS does, then assert the user-audible outcome. +test.describe('iOS-like playback denial — sound carry', () => { + + test('when the error sound cannot start, the loading element carries it', async ({ page }) => { + test.setTimeout(60_000); + const c = ui(page); + + let connectionDown = false; + await page.route(STREAM_URL_RE, async (route) => { + if (connectionDown) { + await route.abort('internetdisconnected'); + return; + } + await route.fulfill({ status: 200, contentType: 'audio/mpeg', path: 'src/public/sounds/test-tone.mp3' }); + }); + + await page.goto('/'); + await waitForSoundBlobs(page); + await c.playButton.click(); + await expect(c.pauseButton).toBeVisible({ timeout: 8000 }); + + // From here on, the error element behaves like backgrounded iOS: every + // fresh start is denied. + await page.evaluate(() => { + const el = document.getElementById('errorNoise'); + el.play = () => Promise.reject(new DOMException('denied', 'NotAllowedError')); + }); + + connectionDown = true; + await page.context().setOffline(true); + + // The loading sound starts (retrying); remember its own source. + await expectSoundPlaying(page, 'loadingNoise'); + const loadingOwnSrc = await page.evaluate(() => document.getElementById('loadingNoise').src); + + await expect(c.errorMsg).toBeVisible({ timeout: 10000 }); + + // The user must NOT end up in silence: the loading element keeps playing + // and, once the supervisor notices the denied start, carries the error + // tone (its source switches away from its own sound). + await page.waitForFunction((ownSrc) => { + const carrier = document.getElementById('loadingNoise'); + return !carrier.paused && Boolean(carrier.src) && carrier.src !== ownSrc; + }, loadingOwnSrc, { timeout: 10000 }); + + // …while the denied element itself never started. + const errorPaused = await page.evaluate(() => document.getElementById('errorNoise').paused); + expect(errorPaused).toBe(true); + + // The network returns: the radio recovers and every feedback sound stops, + // including the carrying element. + connectionDown = false; + await page.context().setOffline(false); + await expect(c.pauseButton).toBeVisible({ timeout: 45000 }); + await page.waitForFunction(() => + document.getElementById('loadingNoise').paused && + document.getElementById('errorNoise').paused, + { timeout: 5000 }); + }); +}); diff --git a/plan.md b/plan.md index 2b85ba7..e786592 100644 --- a/plan.md +++ b/plan.md @@ -127,6 +127,26 @@ dat pauza. Cauze gasite si fixate (branch `fix/always-audible-offline`): suprapunere) — golul de tacere dintre stop si play era exact locul unde iOS refuza play() in background/lock screen. (banuit vinovat pentru "eroarea nu se aude pe lock screen desi loading da") +6. Handoff real intre sunete (branch `fix/sound-handoff`): play() doar + INITIAZA pornirea (async) — punctul 5 nu garanta suprapunerea. Acum + stop()-ul sunetului vechi se amana pana cand cel nou emite efectiv + 'playing'; daca iOS refuza pornirea, cel vechi continua sa cante + (invariantul: orice sunet > liniste) iar supervizorul reincearca. + Testul e2e 'offline station change...' actualizat la noua semantica + (asteapta finalizarea handoff-ului, nu opririle instantanee). +7. Carry (confirmat pe iPhone real): iOS in background REFUZA orice + pornire proaspata de element audio, dar PERMITE unui element care + deja canta sa-si schimbe src si sa continue (pattern-ul playlist). + Daca sunetul de eroare nu porneste pana la tick-ul supervizorului, + elementul de loading (audibil) ii "cara" tonul — carrySound(). Protocol + documentat in script.ts; testat e2e prin simularea refuzului iOS + (describe 'iOS-like playback denial', exceptie white-box justificata). +8. Reactie instant la evenimentul window 'offline' (in loc de ~6s de + watchdog) — watchdog-ul ramane pentru wifi-fara-internet. +9. Widget-ul Now Playing pe macOS ramane gol in starea de eroare offline — + limitare cunoscuta, acceptata (3 tentative de fix revertate: re-assert + amanat, hook-uri pe evenimentele playerului, artwork scos offline). + Edge case rar pe laptop; de reluat doar daca devine suparator. Teste: 63 unit (incl. 'error sound stays audible indefinitely' si testul de ordine play-inainte-de-stop), 37 e2e — cele 35 vechi neatinse si verzi. diff --git a/src/js/radioCore.test.ts b/src/js/radioCore.test.ts index 9beb8bf..32f7d62 100644 --- a/src/js/radioCore.test.ts +++ b/src/js/radioCore.test.ts @@ -1316,3 +1316,50 @@ describe('sound supervisor', () => { expect(supervisorCount()).toBe(0); }); }); + +// ============================================= +// FAST OFFLINE REACTION — the 'offline' event beats the watchdog +// ============================================= + +describe('onNetworkOffline', () => { + it('reacts instantly while playing: retrying with the loading sound, no watchdog wait', async () => { + let online = true; + const { deps, calls } = makeDeps({ isOnline: () => online }); + const core = createRadioCore(deps); + + core.playRadio(0); + await flushPromises(); + expect(core.getState()).toBe('playing'); + + online = false; + core.onNetworkOffline(); // window 'offline' event + + expect(core.getState()).toBe('retrying'); + expect(calls.loadingSound.at(-1)).toBe('play'); + + fireTimer(deps, 3000); // the retry runs while still offline → error + expect(core.getState()).toBe('error'); + expect(calls.errorSound.at(-1)).toBe('play'); + }); + + it('does nothing outside playing (paused stays paused, idle stays idle)', async () => { + let online = true; + const { deps } = makeDeps({ isOnline: () => online }); + const core = createRadioCore(deps); + + online = false; + core.onNetworkOffline(); + expect(core.getState()).toBe('idle'); + + online = true; + core.playRadio(0); + await flushPromises(); + core.pauseRadio(); + core.onPlayerPause(); + expect(core.getState()).toBe('paused'); + + online = false; + core.onNetworkOffline(); + expect(core.getState()).toBe('paused'); + }); +}); diff --git a/src/js/radioCore.ts b/src/js/radioCore.ts index 6fe946b..c696be4 100644 --- a/src/js/radioCore.ts +++ b/src/js/radioCore.ts @@ -347,6 +347,16 @@ export function createRadioCore(deps: RadioDeps) { } } + // Called from the window 'offline' event: the browser KNOWS the network is + // gone, so don't wait ~6s for the watchdog to notice the frozen stream — + // start the audible retry/error pipeline immediately. The watchdog still + // covers wifi-without-internet, where no 'offline' event ever fires. + function onNetworkOffline() { + if (getState() !== 'playing') return; + lastPauseTime = null; + handlePlayError(currentPlayId, getSelectedIndex(), new Error('Network offline')); + } + // --- Playback watchdog --- // Stream failures often don't fire any 'error'/'stalled' event — the audio // just goes silent while currentTime stops advancing (classic with HLS or @@ -496,6 +506,7 @@ export function createRadioCore(deps: RadioDeps) { onPlayerPlay, onPlayerPause, onPlayerError, + onNetworkOffline, retryFromError, onPlayButtonClick, _getPlayId: () => currentPlayId, diff --git a/src/js/script.ts b/src/js/script.ts index ad79156..3017348 100644 --- a/src/js/script.ts +++ b/src/js/script.ts @@ -135,7 +135,49 @@ async function getSoundResponse(src: string): Promise { return response; } -function audioInstance(htmlElement: HTMLAudioElement) { +// ===================================================================== +// Sound handoff protocol (rules verified on a real iPhone, 2026-07-03): +// +// 1. Backgrounded iOS DENIES any fresh play() start — even on a warmed-up +// element, even while another element of the page is playing. +// 2. Backgrounded iOS ALLOWS an element that is already playing to swap +// its src and continue (the playlist pattern). +// +// Product invariant: once the user pressed play, something must always be +// audible. So when one feedback sound replaces the other (loading <-> error): +// +// - deferred stop — the OLD sound keeps playing until the NEW one actually +// produces audio (its 'playing' event); no silent gap ever opens. +// - carry — if the new sound still hasn't started by a supervisor +// tick (rule 1 denied it) while the old one is audible, the old ELEMENT +// carries the new sound: its src is swapped to the new tone (rule 2). +// - reclaim — play()/stop() on a carrying element automatically +// restore its own sound / release the deferral. +// +// A user stop/pause silences both immediately: stopping a still-pending +// sound settles it, which releases the partner's deferred stop too. +// ===================================================================== + +interface SfxInstance { + play(): void; + stop(): void; + /** Supervisor hook: re-assert playback if a play() was denied or the OS + * paused the element; escalates to carry() on the partner (see protocol). */ + ensure(): void; + warmUp(): void; + preloadBlob(): Promise; + /** True between play() and the element actually producing audio. */ + isStartPending(): boolean; + /** Runs callback once this sound starts OR is stopped — whichever first. */ + onStartSettled(callback: () => void): void; + setPartner(partner: SfxInstance): void; + isAudiblyPlaying(): boolean; + /** Carry the partner's sound on this (already playing) element: swap src + * and continue — the one playback start backgrounded iOS allows. */ + carrySound(src: string): void; +} + +function audioInstance(htmlElement: HTMLAudioElement): SfxInstance { let initialSrc = htmlElement.querySelector('source')!.src; let isPlaying = false; let blobUrl: string | null = null; @@ -143,6 +185,20 @@ function audioInstance(htmlElement: HTMLAudioElement) { let preloadPromise: Promise | null = null; htmlElement.dataset.blobReady = 'false'; + // --- Handoff state (see protocol above) --- + let partner: SfxInstance | null = null; + let startPending = false; + let deferredStopGeneration = 0; // invalidates a queued deferred stop + let carriedSrc: string | null = null; // set while carrying the partner's sound + let carryAttempted = false; // ask the partner to carry at most once per play cycle + const startSettlers: Array<() => void> = []; + + const settleStart = () => { + startPending = false; + while (startSettlers.length) startSettlers.shift()!(); + }; + htmlElement.addEventListener('playing', settleStart); + const preloadBlob = () => { if (blobUrl) return Promise.resolve(blobUrl); if (preloadPromise) return preloadPromise; @@ -179,7 +235,17 @@ function audioInstance(htmlElement: HTMLAudioElement) { }; const play = () => { + // Fresh play intent: cancel a queued deferred stop for this instance + // (error → loading → error flapping while the partner never started) + // and reclaim the element if it was carrying the partner's sound. + deferredStopGeneration++; + carryAttempted = false; + if (carriedSrc) { + carriedSrc = null; + isPlaying = false; + } if (!isPlaying) { + startPending = true; const gen = ++playGeneration; isPlaying = true; if (blobUrl) { @@ -190,29 +256,52 @@ function audioInstance(htmlElement: HTMLAudioElement) { } }; + const doStop = () => { + playGeneration++; + htmlElement.pause(); + htmlElement.src = ''; + isPlaying = false; + carriedSrc = null; + carryAttempted = false; + }; + return { play, stop() { - playGeneration++; - htmlElement.pause(); - htmlElement.src = ''; - isPlaying = false; + // This sound will never start now — settle it, which also releases a + // partner waiting on us (so a user stop silences BOTH immediately). + startPending = false; + settleStart(); + + // Deferred stop: while the replacement sound hasn't produced audio yet, + // keep this one playing (see protocol rule 1 — the silent gap is where + // iOS denies the replacement's start). + if (partner?.isStartPending()) { + const gen = ++deferredStopGeneration; + partner.onStartSettled(() => { + if (gen === deferredStopGeneration) doStop(); + }); + return; + } + doStop(); }, - // Self-healing: called periodically by the core's sound supervisor while - // this sound is supposed to be audible. Restarts playback if a play() - // was rejected (background/autoplay policy) or the OS paused the element. ensure() { if (!isPlaying) { play(); - return; - } - if (htmlElement.paused) { + } else if (htmlElement.paused) { const gen = playGeneration; htmlElement.play().catch((error) => { if (gen !== playGeneration) return; if (error.name !== 'AbortError') isPlaying = false; // retried next tick }); } + + // Escalation: our start keeps being denied but the partner element is + // audible — have IT carry our sound (protocol rule 2). + if (startPending && htmlElement.paused && !carryAttempted && partner?.isAudiblyPlaying()) { + carryAttempted = true; + partner.carrySound(blobUrl || initialSrc); + } }, warmUp() { if (isPlaying) return; @@ -231,11 +320,37 @@ function audioInstance(htmlElement: HTMLAudioElement) { }).catch(() => {}); }, preloadBlob, + isStartPending: () => startPending, + onStartSettled(callback: () => void) { + startSettlers.push(callback); + }, + setPartner(p: SfxInstance) { + partner = p; + }, + isAudiblyPlaying: () => isPlaying && !htmlElement.paused, + carrySound(src: string) { + if (!isPlaying || htmlElement.paused) return; // nothing audible to lend + if (carriedSrc === src) return; // already carrying it + carriedSrc = src; + htmlElement.src = src; + htmlElement.currentTime = 0; + htmlElement.play().catch(() => { + // Even the continuation was denied — restore our own sound rather + // than trade something audible for silence. + carriedSrc = null; + htmlElement.src = blobUrl || initialSrc; + htmlElement.currentTime = 0; + htmlElement.play().catch(() => {}); + }); + }, }; } const loadingNoiseInstance = audioInstance(loadingNoise); const errorNoiseInstance = audioInstance(errorNoise); +// Each sound hands off gracefully to the other (see audioInstance.stop()). +loadingNoiseInstance.setPartner(errorNoiseInstance); +errorNoiseInstance.setPartner(loadingNoiseInstance); // Eagerly preload sound blobs so loading/error feedback can start from memory. // fetch() doesn't need a user gesture — only playback does. @@ -504,6 +619,10 @@ player.addEventListener('error', () => core.onPlayerError()); // Auto-recovery when network comes back window.addEventListener('online', () => core.retryFromError()); +// React instantly when the browser reports the network is gone — no need to +// wait for the playback watchdog to notice the frozen stream. +window.addEventListener('offline', () => core.onNetworkOffline()); + // Prev / Next prevButton.addEventListener('click', () => { preloadAudioBlobs();