Aero music: start without a tap on the player

"It should play by default when the Frutiger theme loads in." The embed URL
now carries auto_play=true as a second try alongside the immediate play()
call, and when the browser still refuses (a reload carries no gesture), the
retry rides the next pointerdown/keydown anywhere OUTSIDE the player - the
first tap on anything starts the music. A 700ms throttle keeps a flurry of
taps from skipping through the set, taps on the player's own controls are
left to their handlers (no blip on the x), and an explicit pause or dismiss
is respected: no later gesture undoes it. Looking at the player (expanding
the circle) starts a still-pending soundtrack too.

aero-music-test simulates the refusal (__scAllowPlay) and pins all three
paths: one attempt anyway, the next tap starts it, an explicit pause stays.
28 checks. Version 0.7.1, SW v93.
This commit is contained in:
Martin Dvořák 2026-10-16 05:28:12 +00:00
commit 1ad199fb56
5 changed files with 118 additions and 12 deletions

View file

@ -84,6 +84,11 @@ export function AeroMusic() {
const [allowed, setAllowed] = useState(false);
const [expanded, setExpanded] = useState(false);
const [playing, setPlaying] = useState(false);
/** True until playback is actually running (or the listener said no): a
* blocked autoplay is retried from the next real gesture, so the theme
* still "just plays" without a dedicated tap on the player. */
const [autoStart, setAutoStart] = useState(true);
const startAttempts = useRef(0);
const [ready, setReady] = useState(false);
const [track, setTrack] = useState<{ title: string; artist: string } | null>(null);
@ -116,6 +121,15 @@ export function AeroMusic() {
[refreshTrack],
);
/** The retry path: called from the next gesture, throttled so a flurry of
* taps does not skip through the playlist. */
const startFromGesture = useCallback(() => {
const now = Date.now();
if (now - startAttempts.current < 700) return;
startAttempts.current = now;
playRandom();
}, [playRandom]);
useEffect(() => {
if (!show) return;
const element = iframe.current;
@ -133,14 +147,16 @@ export function AeroMusic() {
instance.getSounds((sounds) => {
if (cancelled) return;
trackCount.current = sounds.length;
/* A gesture selected the theme, so this usually plays right away;
if the browser refuses (a reload with no gesture), the circle
stays paused until the listener taps it. */
/* Tried at once, with no gesture: allowed when the theme was just
picked (that click counts) and wherever the browser trusts the
site. When it is refused, the next tap or key press starts the
music instead (see the gesture effect below). */
playRandom();
});
});
instance.bind(PLAY, () => {
setPlaying(true);
setAutoStart(false);
refreshTrack();
});
instance.bind(PAUSE, () => setPlaying(false));
@ -153,6 +169,24 @@ export function AeroMusic() {
};
}, [show, playRandom, refreshTrack]);
/* Autoplay was refused: the next real gesture anywhere outside the player
starts the music. Taps on the player's own controls are left alone — they
carry their own intent (expand, play, turn it off). */
useEffect(() => {
if (!show || !autoStart || playing) return;
const onGesture = (event: Event) => {
const target = event.target;
if (target instanceof Element && target.closest("[data-testid=aero-music]")) return;
startFromGesture();
};
document.addEventListener("pointerdown", onGesture, true);
document.addEventListener("keydown", onGesture, true);
return () => {
document.removeEventListener("pointerdown", onGesture, true);
document.removeEventListener("keydown", onGesture, true);
};
}, [show, autoStart, playing, startFromGesture]);
if (!show) return null;
const title = track?.title ?? FALLBACK_TITLE;
@ -173,7 +207,7 @@ export function AeroMusic() {
allow="autoplay; encrypted-media"
scrolling="no"
frameBorder="no"
src={`https://w.soundcloud.com/player/?url=${encodeURIComponent(PLAYLIST_URL)}&auto_play=false&hide_related=true&show_comments=false&show_user=true&show_reposts=false&show_teaser=false&visual=false&color=%2338bdf8`}
src={`https://w.soundcloud.com/player/?url=${encodeURIComponent(PLAYLIST_URL)}&auto_play=true&hide_related=true&show_comments=false&show_user=true&show_reposts=false&show_teaser=false&visual=false&color=%2338bdf8`}
className="pointer-events-none absolute size-px opacity-0"
/>
@ -187,6 +221,8 @@ export function AeroMusic() {
<button
type="button"
onClick={() => {
/* An explicit choice either way: no gesture retries after it. */
setAutoStart(false);
if (playing) {
widget.current?.pause();
return;
@ -230,7 +266,11 @@ export function AeroMusic() {
) : (
<button
type="button"
onClick={() => setExpanded(true)}
onClick={() => {
setExpanded(true);
/* Looking at the player is as good a moment as any to start it. */
if (autoStart && !playing) startFromGesture();
}}
aria-label={t("aero.music")}
title={t("aero.music")}
className="glass-pill touch-target grid size-11 place-items-center rounded-full"