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:

View file

@ -1,7 +1,7 @@
# Graph Report - dvorakmortonxd.dev (2026-10-16)
## Corpus Check
- 44 files · ~320,564 words
- 44 files · ~321,153 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: `0fb84583`
- Built from commit: `8f8a54cf`
- 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": 1792104286.5544052,
"seen": 1792104298.8915398,
"ast_hash": "9aeb2ca2591e1b5a33a5082e7c728cfe",
"mtime": 1792105413.2698624,
"seen": 1792105427.0840094,
"ast_hash": "525a5d656fcf65a5330469b1e172c8e8",
"semantic_hash": ""
},
"public/freezmo_aero.png": {
@ -384,9 +384,9 @@
"semantic_hash": ""
},
"src/app/components/AeroMusic.tsx": {
"mtime": 1792104240.1346495,
"seen": 1792104298.8914325,
"ast_hash": "9d82d03e20d0a618d1c7bb737e670abf",
"mtime": 1792105353.1835876,
"seen": 1792105427.0839016,
"ast_hash": "dfcee56447317d270441c10847efa9c9",
"semantic_hash": ""
}
}

View file

@ -4,7 +4,7 @@ import { useEffect, useRef, useState } from "react";
/* Frutiger Aero soundtrack: a random track from the "Frutiger Aero" playlist on
SoundCloud. A hidden SoundCloud iframe is driven through their Widget API so
the playlist autoplays, jumps to a random track and re-randomises whenever
the playlist plays, jumps to a random track and re-randomises whenever
playback moves on; a small glass chip shows what's playing and gives the
visitor a play/pause control. The iframe is only mounted by `WebLayout` while
aero mode is active, so unmounting it stops the music. */
@ -16,6 +16,17 @@ const VOLUME = 35;
report the previous index for a moment, which would otherwise bounce us into
an endless re-pick. */
const PICK_GUARD_MS = 3000;
/* SoundCloud describes a set in stages — `playable` reads true for every entry
at first and only settles once the widget has fetched the whole set. Wait for
the descriptions (titles) to arrive before choosing, but not forever. */
const HYDRATE_RETRY_MS = 300;
const HYDRATE_MAX_ATTEMPTS = 20;
/* SoundCloud's `playable` flag is only a hint — it arrives late and can be
wrong — so after starting a track, confirm it actually started. A media
element flips out of "paused" as soon as `play()` is accepted (even while it
is still buffering), so still being paused means the request was dropped. */
const START_CHECK_MS = 2500;
const MAX_PLAY_ATTEMPTS = 4;
type SoundCloudSound = {
title?: string;
@ -29,6 +40,7 @@ type SoundCloudWidget = {
play: () => void;
pause: () => void;
skip: (index: number) => void;
isPaused: (callback: (paused: boolean) => void) => void;
setVolume: (volume: number) => void;
getSounds: (callback: (sounds: SoundCloudSound[]) => void) => void;
getCurrentSound: (callback: (sound: SoundCloudSound | null) => void) => void;
@ -52,10 +64,14 @@ declare global {
}
}
/* No `auto_play=true` on purpose: that makes the widget start the set on its own
first track, and the first track of this set can't be streamed to third-party
apps, so the player gets stuck on an error before our `skip()` rescues it.
Playback is started by us instead, on a track we know is playable. */
const WIDGET_SRC =
"https://w.soundcloud.com/player/?url=" +
encodeURIComponent(PLAYLIST_URL) +
"&color=%2364e3ff&auto_play=true&hide_related=true&show_comments=false" +
"&color=%2364e3ff&hide_related=true&show_comments=false" +
"&show_user=false&show_reposts=false&show_teaser=false&visual=false";
let apiPromise: Promise<SoundCloudGlobal> | null = null;
@ -91,19 +107,49 @@ function toTrack(sound: SoundCloudSound | null | undefined): Track {
export default function AeroMusic() {
const frameRef = useRef<HTMLIFrameElement>(null);
const widgetRef = useRef<SoundCloudWidget | null>(null);
/* Whether the visitor wants audio (false once they hit pause), so the
playback nudge never resumes something they stopped. */
const wantsPlayRef = useRef(true);
const [track, setTrack] = useState<Track>(PLACEHOLDER);
const [playing, setPlaying] = useState(true);
const [apiReady, setApiReady] = useState(false);
/* Load SoundCloud's Widget API *before* mounting the player iframe. Creating
the iframe first races the script — the widget boots and fires READY before
we can bind to it, and the first switch ends up silent (later switches are
fine because the script is cached). Loading the script first makes every
switch behave like a warm one. */
useEffect(() => {
let cancelled = false;
loadWidgetApi()
.then(() => {
if (!cancelled) setApiReady(true);
})
.catch(() => {
/* Offline or blocked — the theme just stays silent. */
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!apiReady) return;
const frame = frameRef.current;
if (!frame) return;
const SC = window.SC;
if (!frame || !SC) 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;
let nudgeTimer = 0;
let retryTimer = 0;
let verifyTimer = 0;
let pickAttempts = 0;
let playAttempts = 0;
/* Tracks that were started but never began playing. */
const rejected = new Set<number>();
/* The widget API drives the iframe over postMessage, so any call made after
React has detached the iframe throws "Cannot read properties of null
@ -111,10 +157,7 @@ export default function AeroMusic() {
unmounts the whole page. Every call is gated behind this. */
const alive = () => !cancelled && frame.isConnected;
loadWidgetApi()
.then((SC) => {
if (!alive()) return;
widget = SC.Widget(frame);
const widget = SC.Widget(frame);
widgetRef.current = widget;
/* The chip mirrors what the player is actually on, so a skip that gets
@ -122,26 +165,82 @@ export default function AeroMusic() {
title yet, which is ignored rather than blanking the label. */
const syncTrack = () => {
if (!alive()) return;
widget?.getCurrentSound((sound) => {
widget.getCurrentSound((sound) => {
if (!alive() || !sound?.title) return;
setTrack({ title: sound.title, artist: sound.user?.username || "SoundCloud" });
});
};
/* `skip()` + `play()` can land before the newly selected sound has finished
loading, in which case the play request is dropped. Check back once and
retry, then verify it really started and move on if it didn't. */
const nudgePlayback = () => {
window.clearTimeout(nudgeTimer);
window.clearTimeout(verifyTimer);
nudgeTimer = window.setTimeout(() => {
if (!alive() || !wantsPlayRef.current) return;
widget.isPaused((paused) => {
if (!alive() || !wantsPlayRef.current || !paused) return;
widget.play();
verifyStarted();
});
}, 600);
};
const verifyStarted = () => {
window.clearTimeout(verifyTimer);
verifyTimer = window.setTimeout(() => {
if (!alive() || !wantsPlayRef.current) return;
widget.isPaused((paused) => {
if (!alive() || !wantsPlayRef.current || !paused) {
/* It is playing (or buffering) — forget the rejections. */
if (alive()) {
rejected.clear();
playAttempts = 0;
}
return;
}
if (playAttempts >= MAX_PLAY_ATTEMPTS) return;
playAttempts += 1;
if (chosen >= 0) rejected.add(chosen);
playRandomTrack();
});
}, START_CHECK_MS);
};
const playRandomTrack = () => {
if (!alive()) return;
widget?.getSounds((sounds) => {
if (!alive() || !sounds || sounds.length === 0) return;
widget.getSounds((sounds) => {
if (!alive()) return;
/* SoundCloud sends the set in stages: every entry claims `playable:
true` until the widget has actually described it, so choosing too
early happily lands on a track that can't be streamed. Wait for the
whole set to be described before picking. */
const described =
Boolean(sounds) && sounds.length > 0 && sounds.every((sound) => Boolean(sound.title));
if (!described) {
if (pickAttempts < HYDRATE_MAX_ATTEMPTS) {
pickAttempts += 1;
window.clearTimeout(retryTimer);
retryTimer = window.setTimeout(playRandomTrack, HYDRATE_RETRY_MS);
}
return;
}
pickAttempts = 0;
/* A few tracks in any set are `playable: false` (the uploader left
"Enable app playback" off) and SoundCloud refuses to stream those
to third-party apps — picking one just leaves the player stuck on
silence. Skip those, but keep the original set index because
that's what `skip()` takes. Don't filter on the track metadata:
entries can arrive not-yet-hydrated (no title/user) and are still
perfectly playable. */
"Enable app playback" off) and SoundCloud refuses to stream those to
third-party apps — picking one just leaves the player stuck on
silence. Skip those, and anything we already tried that never started,
but keep the original set index because that's what `skip()` takes. */
const all = sounds.map((sound, index) => ({ sound, index }));
const playable = all.filter(({ sound }) => sound.playable !== false);
const pool = playable.length > 0 ? playable : all;
const playable = all.filter(
({ sound, index }) => sound.playable !== false && !rejected.has(index),
);
const untried = all.filter(({ index }) => !rejected.has(index));
const pool =
playable.length > 0 ? playable : untried.length > 0 ? untried : all;
let choice = Math.floor(Math.random() * pool.length);
/* Don't just replay whatever is already on. */
@ -152,19 +251,16 @@ export default function AeroMusic() {
const { sound, index } = pool[choice];
chosen = index;
pickedAt = Date.now();
widget?.skip(index);
widget?.play();
/* Show it straight away when it is already named, then settle on the
truth once the player has caught up. */
if (sound.title) setTrack(toTrack(sound));
window.clearTimeout(syncTimer);
syncTimer = window.setTimeout(syncTrack, 900);
widget.skip(index);
widget.play();
nudgePlayback();
setTrack(toTrack(sound));
});
};
widget.bind(SC.Widget.Events.READY, () => {
if (!alive()) return;
widget?.setVolume(VOLUME);
widget.setVolume(VOLUME);
playRandomTrack();
});
@ -175,7 +271,7 @@ export default function AeroMusic() {
setPlaying(true);
syncTrack();
if (Date.now() - pickedAt < PICK_GUARD_MS) return;
widget?.getCurrentSoundIndex((index) => {
widget.getCurrentSoundIndex((index) => {
if (!alive() || index === chosen) return;
playRandomTrack();
});
@ -184,17 +280,15 @@ export default function AeroMusic() {
/* Fires at the end of the set (or of the single selected track). */
widget.bind(SC.Widget.Events.FINISH, playRandomTrack);
/* Cover the case where READY already fired before we bound it. */
/* READY may already have fired by the time this effect runs. */
widget.setVolume(VOLUME);
playRandomTrack();
})
.catch(() => {
/* Offline, blocked or SoundCloud is down — the theme just stays silent. */
});
return () => {
cancelled = true;
window.clearTimeout(syncTimer);
window.clearTimeout(nudgeTimer);
window.clearTimeout(retryTimer);
window.clearTimeout(verifyTimer);
/* 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. */
@ -207,15 +301,17 @@ export default function AeroMusic() {
}
widgetRef.current = null;
};
}, []);
}, [apiReady]);
const toggle = () => {
const widget = widgetRef.current;
if (!widget) return;
if (playing) {
wantsPlayRef.current = false;
widget.pause();
setPlaying(false);
} else {
wantsPlayRef.current = true;
widget.play();
setPlaying(true);
}
@ -223,6 +319,7 @@ export default function AeroMusic() {
return (
<>
{apiReady && (
<iframe
ref={frameRef}
className="web-aero-music-frame"
@ -231,6 +328,7 @@ export default function AeroMusic() {
allow="autoplay"
loading="eager"
/>
)}
<button
type="button"
className="web-aero-music"