fix(web): make the Frutiger Aero soundtrack start on the first switch and skip tracks that never begin playing

This commit is contained in:
Martin Dvořák 2026-10-16 01:04:02 +02:00
commit 2d4a42bcef
5 changed files with 208 additions and 105 deletions

View file

@ -186,15 +186,20 @@ 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). Two
SoundCloud quirks are handled there: entries from `getSounds` can arrive
not-yet-hydrated (no `title`/`user`) and are still perfectly playable, so the
random pick filters on `playable !== false` *only* — `playable: false` means the
uploader left "Enable app playback" off, and SoundCloud refuses to stream those
to a third-party app at all (picking one just leaves the player silent); and the
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
label is synced from `getCurrentSound` on PLAY, not from what we asked for).
SoundCloud's widget is quirky and `AeroMusic.tsx` works around all of it: the
Widget API script is loaded *before* the player iframe is mounted, because
creating the iframe first lets the widget boot and fire READY before we can
bind to it, which leaves the first switch silent (later switches are fine, as
the script is cached by then); `playable` is only trusted once the whole set is
described, because SoundCloud sends the set in stages and every entry claims
`playable: true` until then (`playable: false` means the uploader left "Enable
app playback" off, so a third-party app can't stream it at all) — and since even
that flag still lies, playback is *verified*: a track that is still `paused`
once we have started it is rejected and another is picked; and every widget call
goes through an `alive()` check, because the API drives the iframe over
`postMessage` and React detaches the iframe *before* the effect cleanup runs —
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: