Skip to content
72 changes: 70 additions & 2 deletions e2e/radio.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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 });
});
});
20 changes: 20 additions & 0 deletions plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
47 changes: 47 additions & 0 deletions src/js/radioCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
11 changes: 11 additions & 0 deletions src/js/radioCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -496,6 +506,7 @@ export function createRadioCore(deps: RadioDeps) {
onPlayerPlay,
onPlayerPause,
onPlayerError,
onNetworkOffline,
retryFromError,
onPlayButtonClick,
_getPlayId: () => currentPlayId,
Expand Down
141 changes: 130 additions & 11 deletions src/js/script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,14 +135,70 @@ async function getSoundResponse(src: string): Promise<Response> {
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<string | null>;
/** 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;
let playGeneration = 0;
let preloadPromise: Promise<string | null> | 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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
Expand All @@ -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.
Expand Down Expand Up @@ -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();
Expand Down