fix(web): stop the Frutiger Aero SoundCloud widget from crashing the page on teardown

This commit is contained in:
Martin Dvořák 2026-10-16 00:24:09 +02:00
commit 0fb845838c
5 changed files with 49 additions and 23 deletions

View file

@ -186,7 +186,11 @@ Themes: `web` = default frosted-glass website (sidebar + content, light/dark),
random track from the *Frutiger Aero* SoundCloud playlist (a hidden SoundCloud
iframe driven through their widget API: it picks a random index from `getSounds`
and re-randomises on every `PLAY` event so the set never plays in order; the chip
label is synced from `getCurrentSound`, not from what we asked for). The two remaining feature panels (`.web-split`) stretch
label is synced from `getCurrentSound`, not from what we asked for). That widget
API drives the iframe over `postMessage`, so every call is gated behind an
`alive()` check: React detaches the iframe *before* the effect cleanup runs, and
an unguarded `pause()` there throws `Cannot read properties of null (reading
'postMessage')`, which unmounts the whole page. The two remaining feature panels (`.web-split`) stretch
to equal height and share a fixed `16 / 10` `.fw` window (the `FeatureWindow`
component) — decorative, **asset-free CSS mock-ups** rather than screenshots:
Revival Inc. is a little necromancy game scene (night sky, moon, tombstones,

View file

@ -1,7 +1,7 @@
# Graph Report - dvorakmortonxd.dev (2026-10-16)
## Corpus Check
- 44 files · ~320,188 words
- 44 files · ~320,357 words
- Verdict: corpus is large enough that graph structure adds value.
- Unclassified: 19 file(s) not represented in the graph (top: (none) 6, .ttf 3, .css 3)
@ -11,7 +11,7 @@
- Token cost: 0 input · 0 output
## Graph Freshness
- Built from commit: `e4530afd`
- Built from commit: `be33317d`
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
- Run `graphify update .` after code changes (no API cost).

File diff suppressed because one or more lines are too long

View file

@ -366,9 +366,9 @@
"semantic_hash": "6f69e16823f10d9c7af1625eff2b32e8"
},
"AGENTS.md": {
"mtime": 1792102785.3782775,
"seen": 1792102795.1327217,
"ast_hash": "594842bd5f504ca50401180b54e743b8",
"mtime": 1792103035.6232371,
"seen": 1792103044.7911508,
"ast_hash": "546487f136313be6c94c604a4ff8866d",
"semantic_hash": ""
},
"public/freezmo_aero.png": {
@ -384,9 +384,9 @@
"semantic_hash": ""
},
"src/app/components/AeroMusic.tsx": {
"mtime": 1792102680.4096167,
"seen": 1792102795.1326532,
"ast_hash": "9e5d7d4591477a92d78eebf144627856",
"mtime": 1792102961.1760592,
"seen": 1792103044.7910783,
"ast_hash": "8587acdbce460ee950777e38a552c311",
"semantic_hash": ""
}
}

View file

@ -92,39 +92,50 @@ export default function AeroMusic() {
const [playing, setPlaying] = useState(true);
useEffect(() => {
const frame = frameRef.current;
if (!frame) return;
let cancelled = false;
/* The index we deliberately jumped to, and when — see PICK_GUARD_MS. */
let chosen = -1;
let pickedAt = 0;
let syncTimer = 0;
let widget: SoundCloudWidget | null = null;
/* The widget API drives the iframe over postMessage, so any call made after
React has detached the iframe throws "Cannot read properties of null
(reading 'postMessage')" — and an exception thrown from an effect cleanup
unmounts the whole page. Every call is gated behind this. */
const alive = () => !cancelled && frame.isConnected;
loadWidgetApi()
.then((SC) => {
const frame = frameRef.current;
if (cancelled || !frame) return;
const widget = SC.Widget(frame);
if (!alive()) return;
widget = SC.Widget(frame);
widgetRef.current = widget;
/* The chip mirrors what the player is actually on, so a skip that gets
ignored can't leave it lying. Partial reads are ignored rather than
blanking the label. */
const syncTrack = () => {
widget.getCurrentSound((sound) => {
if (cancelled || !sound || !sound.title) return;
if (!alive()) return;
widget?.getCurrentSound((sound) => {
if (!alive() || !sound || !sound.title) return;
setTrack({ title: sound.title, artist: sound.user?.username || "SoundCloud" });
});
};
const playRandomTrack = () => {
widget.getSounds((sounds) => {
if (cancelled || !sounds || sounds.length === 0) return;
if (!alive()) return;
widget?.getSounds((sounds) => {
if (!alive() || !sounds || sounds.length === 0) return;
let index = Math.floor(Math.random() * sounds.length);
/* Don't just replay whatever is already on. */
if (sounds.length > 1 && index === chosen) index = (index + 1) % sounds.length;
chosen = index;
pickedAt = Date.now();
widget.skip(index);
widget.play();
widget?.skip(index);
widget?.play();
/* Show it straight away, then settle on the truth once the player
has caught up. */
setTrack(toTrack(sounds[index]));
@ -134,18 +145,20 @@ export default function AeroMusic() {
};
widget.bind(SC.Widget.Events.READY, () => {
widget.setVolume(VOLUME);
if (!alive()) return;
widget?.setVolume(VOLUME);
playRandomTrack();
});
/* The set plays on in its own order — grab every chance to re-randomise
instead of following the playlist track by track. */
widget.bind(SC.Widget.Events.PLAY, () => {
if (!alive()) return;
setPlaying(true);
syncTrack();
if (Date.now() - pickedAt < PICK_GUARD_MS) return;
widget.getCurrentSoundIndex((index) => {
if (cancelled || index === chosen) return;
widget?.getCurrentSoundIndex((index) => {
if (!alive() || index === chosen) return;
playRandomTrack();
});
});
@ -164,7 +177,16 @@ export default function AeroMusic() {
return () => {
cancelled = true;
window.clearTimeout(syncTimer);
widgetRef.current?.pause();
/* React detaches the iframe before this cleanup runs, so `pause()` is
usually impossible — and unnecessary, since unloading the iframe stops
its audio. Only bother while the frame is still attached. */
if (frame.isConnected) {
try {
widgetRef.current?.pause();
} catch {
/* the widget beat us to teardown */
}
}
widgetRef.current = null;
};
}, []);