diff --git a/server/player/index.html b/server/player/index.html
index 0368d04..601bcae 100644
--- a/server/player/index.html
+++ b/server/player/index.html
@@ -1718,7 +1718,11 @@
device_utc: Date.now(),
// Real values where the platform has them (temperature, storage quota). Spread LAST so
// it overrides the nulls above, and empty off-platform so nothing else changes.
- ...(BS ? BS.telemetrySnapshot() : {}),
+ // typeof, not truthiness: the bridge and this page are halves of one contract but are
+ // fetched separately, so a player can run a NEW page against a CACHED older bridge.
+ // Calling a method that version lacks threw here every 15s and took the whole heartbeat
+ // with it — the display kept playing while silently reporting nothing at all.
+ ...(BS && typeof BS.telemetrySnapshot === 'function' ? BS.telemetrySnapshot() : {}),
}
});
}, HEARTBEAT_INTERVAL);
diff --git a/server/player/sw.js b/server/player/sw.js
index 0178414..188f61f 100644
--- a/server/player/sw.js
+++ b/server/player/sw.js
@@ -1,4 +1,9 @@
-const CACHE_NAME = 'rd-player-v19';
+// v20: rc3 changed the fetch strategy AND the shipped player assets. The activate handler deletes
+// every cache whose name does not match, so leaving this at v19 kept the previous shell cache alive
+// — a player then ran a new index.html against a stale st-bridge.js and threw on every heartbeat.
+// Bump whenever a shipped /player asset changes shape; content lives in its own cache, so this
+// costs a small re-download and never re-fetches the playlist.
+const CACHE_NAME = 'rd-player-v20';
// Content lives in its own cache so the shell can be re-versioned (the activate handler deletes
// every cache that is not CACHE_NAME) WITHOUT throwing away megabytes of media that are still
// perfectly valid. Rolling the shell used to mean a player re-downloaded its entire playlist.