From f0db83153bd84aba1712ca18044ef258c92a0605 Mon Sep 17 00:00:00 2001 From: Adrian Florescu Date: Sat, 4 Jul 2026 09:57:49 +0300 Subject: [PATCH 1/3] R5: offline rechecks re-run no effects; presentation memoized on (state, title) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the post-review refactor plan (plan.md, R5) — the battery-drain finding. Old e2e untouched and green (37/37); 79/79 unit. radioMachine: the error state's wait-for-recovery loop moves into substates (backoff -> offlineRecheck). Entry fx and the sound supervisor run ONCE per error cycle; an offline recheck only re-arms its own child timer (reenter on the atomic child leaves the parent alone). Before, every 10s tick re-entered 'error' and re-ran the whole entry pipeline — new MediaMetadata (lock-screen artwork refetch + widget flicker), 6 action handlers re-registered, poster img.src reassigned, supervisor interval torn down and recreated — all night, for as long as the phone sat offline. The offlineRecheck context flag and its two actions are gone; the cadence and backoff values are unchanged (the old cadence unit tests pass as-is). mediaSession: updateMediaSession memoizes the presentation (MediaMetadata, poster img.src, document.title, loadingMsg) on (state, station title) — identical consecutive renders are skipped and cloudinaryImageUrl is computed once instead of twice. Deliberately NOT memoized: action-handler re-registration and playbackState — iOS resets those out from under us (d798cc9), so they re-assert on every call, exactly as before. New unit test pins the phase's point: 50 offline rechecks produce zero new side-effect calls (mediaSession/sounds/buttons/supervisor), and the loop still recovers on the first online check. NEEDS DEVICE SMOKE before merge: phone offline in error 5+ min, locked — error tone keeps playing, Now Playing widget no longer flickers every 10s; wifi back on -> recovers to playing by itself. Co-Authored-By: Claude Fable 5 --- src/js/mediaSession.ts | 30 ++++++++++++----- src/js/radioCore.test.ts | 45 ++++++++++++++++++++++++++ src/js/radioMachine.ts | 70 ++++++++++++++++++++++++++-------------- 3 files changed, 112 insertions(+), 33 deletions(-) diff --git a/src/js/mediaSession.ts b/src/js/mediaSession.ts index 8e63f33..f6911c7 100644 --- a/src/js/mediaSession.ts +++ b/src/js/mediaSession.ts @@ -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'; @@ -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