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
19 changes: 17 additions & 2 deletions plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -600,7 +600,7 @@ offline.

Verificare: typecheck, unit (cu teste noi), e2e neatins.

## Faza R5: recheck offline fara reenter + memoizare updateMediaSession
## Faza R5: recheck offline fara reenter + memoizare updateMediaSession [in PR #50]

Zona sensibila iOS — ultima faza de logica, cu smoke pe device.

Expand All @@ -622,7 +622,22 @@ tick-uri), e2e neatins, SMOKE PE DEVICE: telefon offline in error 5+ min cu
ecranul blocat — widget-ul nu mai palpaie, bateria nu se scurge; revenire
online → recovering → playing.

## Faza R6: startup mai usor (cloudinary precache)
## Faza R6: startup mai usor (cloudinary precache) [in PR #50]

Nota de implementare (2026-07-04): la cererea lui Adrian, R5 + fix-ul
zombie (carrySound cu garda de generatie) + R6 merg intr-UN SINGUR PR
(#50). Abateri deliberate fata de planul initial al R6:
- Lista de precache NU se reduce la labels + statia curenta: e2e-ul (si
produsul) garanteaza ca posterele TUTUROR posturilor functioneaza
offline — reducerea ar fi schimbat comportamentul. Ramane lista
completa, doar amanata la requestIdleCallback (fallback setTimeout 3s
pe Safari).
- Pagina isi pastreaza cache.put-ul propriu: pe primul load SW-ul preia
controlul abia dupa activate/claim, iar fara put-ul paginii imaginile
fetch-uite inainte de claim ar ramane necache-uite.
- sw.js: put-ul de app-shell iese de pe calea raspunsului (waitUntil, ca
la cloudinary), /downloads/ (APK 2.4MB) nu se mai cache-uieste, bump
APP_CACHE v4 ca sa evacueze APK-ul din instalarile existente.

- cloudinary.ts/main.ts: amana precacheStatusImages la `requestIdleCallback`
(fallback setTimeout) si redu la labels + statia curenta (azi: 22 de
Expand Down
12 changes: 10 additions & 2 deletions src/js/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,19 @@ declare global {

document.addEventListener("touchstart", function () { }, true);

// Pre-cache status images + station name images for offline use
precacheStatusImages([
// Pre-cache status images + station name images for offline use — deferred
// to idle time so the ~22 image fetches don't compete with the stream
// connection and the sound-blob preloads at startup (time-to-audio first).
const runStatusImagePrecache = () => precacheStatusImages([
...Object.values(LABELS),
...Array.from(radioSelect.options).map(o => o.text),
]);
if ('requestIdleCallback' in window) {
requestIdleCallback(runStatusImagePrecache, { timeout: 5000 });
} else {
// Safari has no requestIdleCallback — a plain delay clears startup anyway.
setTimeout(runStatusImagePrecache, 3000);
}

// Restore last station before anything reads selectedIndex
const restoredStationIndex = getStoredStationIndex(radioSelect.options.length);
Expand Down
30 changes: 22 additions & 8 deletions src/js/mediaSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,13 @@ function reassertPlaybackState() {
loadingNoise.addEventListener('pause', reassertPlaybackState);
errorNoise.addEventListener('pause', reassertPlaybackState);

// The presentation (metadata, poster, document title) is a pure function of
// (state, station title) — re-rendering it when neither changed is wasted
// work (and on iOS it re-fetches lock-screen artwork). Memoize on that key.
// Handler re-registration and playbackState are deliberately NOT memoized:
// iOS resets them out from under us (d798cc9), so they re-assert every call.
let lastRender: { state: RadioState; title: string } | null = null;

export const updateMediaSession = (newState: RadioState) => {
const title = radioSelect.options[radioSelect.selectedIndex].text;
const isIdle = newState === 'idle';
Expand All @@ -100,13 +107,17 @@ export const updateMediaSession = (newState: RadioState) => {

const idleText = hasRestoredStation ? title : LABELS.appName;
const displayText = isIdle ? idleText : isLoading ? LABELS.loading : hasError ? LABELS.error : title;
const changed = lastRender?.state !== newState || lastRender?.title !== title;
const artworkUrl = changed ? cloudinaryImageUrl(displayText, isLive) : '';

if ('mediaSession' in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
title: isLoading ? `${LABELS.loading}${title}` : hasError ? `${LABELS.error} la încărcarea ${title}` : isIdle ? idleText : title,
artist: `${LABELS.appName}${isIdle && !hasRestoredStation ? '' : ` | ${title}`}`,
artwork: [{ src: cloudinaryImageUrl(displayText, isLive) }]
});
if (changed) {
navigator.mediaSession.metadata = new MediaMetadata({
title: isLoading ? `${LABELS.loading}${title}` : hasError ? `${LABELS.error} la încărcarea ${title}` : isIdle ? idleText : title,
artist: `${LABELS.appName}${isIdle && !hasRestoredStation ? '' : ` | ${title}`}`,
artwork: [{ src: artworkUrl }]
});
}

// Re-register ALL action handlers on every state transition.
// iOS resets them when a different <audio> element (loading/error sound)
Expand All @@ -125,7 +136,10 @@ export const updateMediaSession = (newState: RadioState) => {
}
}

posterImage.querySelector('img')!.src = cloudinaryImageUrl(displayText, isLive);
document.title = `${isLoading ? "⏳" : ''} ${hasError ? '❤️‍🩹' : ''} ${isLive ? '🔴' : ''} ${isIdle ? idleText : isLoading ? `${LABELS.loading} ${title}` : hasError ? LABELS.error : title}`;
loadingMsg.innerText = isLoading ? `${LABELS.loading} ${title}` : '';
if (changed) {
posterImage.querySelector('img')!.src = artworkUrl;
document.title = `${isLoading ? "⏳" : ''} ${hasError ? '❤️‍🩹' : ''} ${isLive ? '🔴' : ''} ${isIdle ? idleText : isLoading ? `${LABELS.loading} ${title}` : hasError ? LABELS.error : title}`;
loadingMsg.innerText = isLoading ? `${LABELS.loading} ${title}` : '';
}
lastRender = { state: newState, title };
};
45 changes: 45 additions & 0 deletions src/js/radioCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1356,6 +1356,51 @@ describe('system pause vs user pause', () => {
expect(clock.hasScheduled(RECOVERY_DELAY_MS)).toBe(true);
});

it('an offline night in error is quiet work: rechecks re-run NO effects', async () => {
let online = true;
const { deps, calls } = makeDeps({ isOnline: () => online });
deps._setPlayerPlayResult(Promise.reject(new Error('fail')));
const { core, clock } = createCore(deps);

core.playRadio(0);
await flushPromises();
clock.increment(3000);
await flushPromises();
expect(core.getState()).toBe('error');
online = false;

// Snapshot every observable side effect after entering error…
clock.increment(RECOVERY_DELAY_MS); // land in the fixed-cadence recheck
const fxBefore = {
mediaSession: calls.mediaSession.length,
errorSound: calls.errorSound.length,
showButton: calls.showButton.length,
supervisors: (deps.setInterval as ReturnType<typeof vi.fn>).mock.calls.length,
};

// …then let a whole offline night of rechecks pass.
for (let i = 0; i < 50; i++) {
clock.increment(RECOVERY_DELAY_MS);
await flushPromises();
}
expect(core.getState()).toBe('error');

// The old reenter design re-applied ALL of these every 10s (metadata
// rebuild, sound re-assert, supervisor teardown+recreate) — a battery
// drain and lock-screen flicker. Now a recheck only re-arms its timer.
expect(calls.mediaSession.length).toBe(fxBefore.mediaSession);
expect(calls.errorSound.length).toBe(fxBefore.errorSound);
expect(calls.showButton.length).toBe(fxBefore.showButton);
expect((deps.setInterval as ReturnType<typeof vi.fn>).mock.calls.length).toBe(fxBefore.supervisors);

// And the recovery loop is still alive: net returns → next check plays.
online = true;
deps._setPlayerPlayResult(Promise.resolve());
clock.increment(RECOVERY_DELAY_MS);
await flushPromises();
expect(core.getState()).toBe('playing');
});

it('an unexpected native pause while online still pauses (interruption, unplugged headphones)', async () => {
const { deps } = makeDeps();
const { core, clock } = createCore(deps);
Expand Down
70 changes: 45 additions & 25 deletions src/js/radioMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,6 @@ export interface RadioContext {
recoveryCount: number;
lastPauseTime: number | null;
userPauseIntentAt: number | null;
/** True while the recovery loop is just re-checking a known-offline network
* (fixed cadence, no backoff escalation, no recoveryCount increment). */
offlineRecheck: boolean;
}

export type RadioEvent =
Expand Down Expand Up @@ -168,13 +165,6 @@ export function createRadioMachine(deps: RadioDeps) {
{ target: 'error' as const, actions: [...extra, 'beginErrorCycle' as const] },
];

// Network is back → attempt recovery; still offline → re-check on a fixed
// cadence without escalating (nothing was attempted).
const tryRecover = [
{ guard: 'isOnline' as const, target: 'recovering' as const, actions: 'clearOfflineRecheck' as const },
{ target: 'error' as const, reenter: true, actions: 'markOfflineRecheck' as const },
];

// Live streams drift — resuming after a long pause would replay stale
// buffer, so restart the station instead, honoring the same offline
// fast-fail as PLAY (no point trying on a dead network).
Expand Down Expand Up @@ -232,15 +222,15 @@ export function createRadioMachine(deps: RadioDeps) {
LOADING_TIMEOUT: LOADING_TIMEOUT_MS,
RETRY_DELAY: RETRY_DELAY_MS,
// Exponential backoff (10s → 20s → … capped), forever — a radio should
// never give up. While clearly offline, re-check on a fixed cadence
// without escalating (nothing was attempted).
// never give up.
RECOVERY_DELAY: ({ context }) =>
context.offlineRecheck
? RECOVERY_DELAY_MS
: Math.min(
RECOVERY_DELAY_MS * 2 ** Math.min(context.recoveryCount - 1, 6),
RECOVERY_DELAY_MAX_MS,
),
Math.min(
RECOVERY_DELAY_MS * 2 ** Math.min(context.recoveryCount - 1, 6),
RECOVERY_DELAY_MAX_MS,
),
// While clearly offline, re-check on a fixed cadence without
// escalating (nothing was attempted).
OFFLINE_RECHECK: RECOVERY_DELAY_MS,
},

actors: {
Expand Down Expand Up @@ -301,11 +291,8 @@ export function createRadioMachine(deps: RadioDeps) {
// Runs on the TRANSITION into error (not on entry) so the RECOVERY_DELAY
// expression is guaranteed to see the incremented count.
beginErrorCycle: assign(({ context }) => ({
offlineRecheck: false,
recoveryCount: context.recoveryCount + 1,
})),
markOfflineRecheck: assign({ offlineRecheck: true }),
clearOfflineRecheck: assign({ offlineRecheck: false }),
markUserPauseIntent: assign(() => ({ userPauseIntentAt: deps.performanceNow() })),
consumeUserPauseIntent: assign({ userPauseIntentAt: null }),
markPauseTime: assign(() => ({ lastPauseTime: deps.performanceNow() })),
Expand Down Expand Up @@ -336,7 +323,6 @@ export function createRadioMachine(deps: RadioDeps) {
recoveryCount: 0,
lastPauseTime: null,
userPauseIntentAt: null,
offlineRecheck: false,
},
initial: 'idle',

Expand Down Expand Up @@ -467,13 +453,47 @@ export function createRadioMachine(deps: RadioDeps) {
},

error: {
// Entry fx and the sound supervisor run ONCE per error cycle — the
// wait-for-recovery loop lives in substates so an offline night does
// not rebuild MediaMetadata / re-register handlers / re-create the
// supervisor every 10 seconds (it used to, via reenter).
entry: [{ type: 'applyFx', params: { state: 'error' } }],
invoke: { src: 'soundSupervisor', input: { sound: 'error' } as const },
after: {
RECOVERY_DELAY: tryRecover,
initial: 'backoff',
states: {
// One escalating backoff wait after a failed attempt.
backoff: {
after: {
RECOVERY_DELAY: [
{ guard: 'isOnline', target: '#radio.recovering' },
{ target: 'offlineRecheck' },
],
},
on: {
RETRY_FROM_ERROR: [
{ guard: 'isOnline', target: '#radio.recovering' },
{ target: 'offlineRecheck' },
],
},
},
// Clearly offline: re-check on a fixed cadence. The reenter only
// re-arms this child's timer — the parent (fx, supervisor) stays.
offlineRecheck: {
after: {
OFFLINE_RECHECK: [
{ guard: 'isOnline', target: '#radio.recovering' },
{ target: 'offlineRecheck', reenter: true },
],
},
on: {
RETRY_FROM_ERROR: [
{ guard: 'isOnline', target: '#radio.recovering' },
{ target: 'offlineRecheck', reenter: true },
],
},
},
},
on: {
RETRY_FROM_ERROR: tryRecover,
...startFromSelector,
PAUSE_REQUESTED: { actions: 'raiseStop' },
},
Expand Down
27 changes: 27 additions & 0 deletions src/js/soundEffects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,33 @@ describe('feedback sounds — the tone-swap rule', () => {
expect(errEl.playCalls).toBe(0);
});

it('a stop during a pending tone swap must not resurrect the sound', async () => {
// Device-observed zombie: the swap's play() settles LATE (iOS latency),
// the stream recovers meanwhile and applyFx('playing') stops the tones —
// then the late rejection used to revert-and-restart the element, playing
// the loading tone UNDER the live radio, unstoppable (isPlaying already
// false, so later stops skipped it).
const { loadEl, loading, error } = await makePair();

loading.play();
let rejectSwap!: (e: unknown) => void;
loadEl.playResult = new Promise((_, reject) => { rejectSwap = reject; });
error.play(); // tone swap onto the live element is in flight
loading.stop();

// The stream recovers: applyFx('playing') stops both tones.
loading.stop();
error.stop();
expect(loadEl.paused).toBe(true);

// The swap's rejection lands only now.
rejectSwap(Object.assign(new Error('denied'), { name: 'NotAllowedError' }));
await flushPromises();

expect(loadEl.paused).toBe(true); // stays dead
expect(loadEl.getAttribute('src')).toBe(''); // no revert, no restart
});

it('a user stop silences everything, including a carrying element', async () => {
const { loadEl, errEl, loading, error } = await makePair();

Expand Down
10 changes: 8 additions & 2 deletions src/js/soundEffects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,8 +255,14 @@ export function audioInstance(htmlElement: HTMLAudioElement): SoundInstance {
carriedSrc = src;
htmlElement.src = src;
htmlElement.currentTime = 0;
htmlElement.play().catch(() => {
// Even the continuation was denied — restore our own sound rather
const gen = playGeneration;
htmlElement.play().catch((error) => {
// The swap failed — but if we were stopped or reclaimed meanwhile
// (generation moved on), or our own stop interrupted it (AbortError),
// stay silent: restarting here would resurrect a zombie tone UNDER
// the live stream (device-observed: loading tone + radio at once).
if (gen !== playGeneration || isAbortError(error)) return;
// Denied outright while still wanted — restore our own sound rather
// than trade something audible for silence.
carriedSrc = null;
htmlElement.src = ownSrc();
Expand Down
23 changes: 18 additions & 5 deletions src/public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// Bump APP_CACHE version when static files change to force re-cache.
// Bump SOUND_CACHE version when sound files change.

const APP_CACHE_NAME = 'radio-app-v3';
const APP_CACHE_NAME = 'radio-app-v4'; // v4: the APK no longer lands in the cache
const CACHE_NAME = 'radio-images-v3';
const SOUND_CACHE_NAME = 'radio-sounds-v2';
const MAX_CACHED_IMAGES = 30;
Expand Down Expand Up @@ -98,19 +98,32 @@ self.addEventListener('fetch', (event) => {
return;
}

// App shell — network-first, fallback to cache for offline
// App shell — network-first, fallback to cache for offline.
// The APK download is streamed straight through: 2.4MB has no business
// sitting in the app cache, and caching it also delayed the download.
if (
event.request.method === 'GET' &&
reqUrl.origin === self.location.origin &&
!reqUrl.pathname.startsWith('/sounds/')
!reqUrl.pathname.startsWith('/sounds/') &&
!reqUrl.pathname.startsWith('/downloads/')
) {
event.respondWith(
(async () => {
try {
const response = await fetch(event.request);
if (response.ok) {
const cache = await caches.open(APP_CACHE_NAME);
await cache.put(event.request, response.clone());
// Cache write happens off the response path (same pattern as the
// cloudinary branch) — the page gets its first bytes without
// waiting for a full body download + disk write.
const clone = response.clone();
event.waitUntil(
(async () => {
try {
const cache = await caches.open(APP_CACHE_NAME);
await cache.put(event.request, clone);
} catch (_) { /* swallow cache errors */ }
})()
);
}
return response;
} catch (_) {
Expand Down